Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions cycode/cli/apps/ai_guardrails/session_start_command.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
"""Handle AI guardrails session start: auth, conversation creation, session context."""

import os
import platform
import socket
import sys
from typing import TYPE_CHECKING, Annotated, Optional

Expand All @@ -15,6 +12,13 @@
from cycode.cli.apps.auth.auth_manager import AuthManager
from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception
from cycode.cli.utils.get_api_client import get_ai_security_manager_client
from cycode.cli.utils.host_info import (
get_hostname,
get_last_login_user,
get_os_version,
get_platform_name,
get_serial_number,
)
from cycode.logger import get_logger

if TYPE_CHECKING:
Expand All @@ -23,24 +27,18 @@
logger = get_logger('AI Guardrails')


def _get_logged_in_user() -> Optional[str]:
"""Best-effort OS account name (whoami). None if it can't be resolved."""
try:
return os.getlogin()
except Exception:
return None


def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None:
"""Report IDE session context to the AI security manager. Never raises."""
try:
global_config_file, enabled_plugins = ide.get_session_context()
if not global_config_file and not enabled_plugins:
return
ai_client.report_session_context(
hostname=socket.gethostname(),
platform=platform.system(),
logged_in_user=_get_logged_in_user(),
hostname=get_hostname(),
platform_name=get_platform_name(),
os_version=get_os_version(),
serial_number=get_serial_number(),
last_login_user=get_last_login_user(),
global_config_file=global_config_file,
enabled_plugins=enabled_plugins,
user_email=user_email,
Expand Down
114 changes: 114 additions & 0 deletions cycode/cli/utils/host_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import getpass
import platform
import re
import socket
import subprocess
from typing import Optional

from cycode.logger import get_logger

logger = get_logger('HOST INFO')

_SUBPROCESS_TIMEOUT_SEC = 5

_PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'}


def _run(command: list, timeout: int = _SUBPROCESS_TIMEOUT_SEC) -> Optional[str]:
"""Run a command and return its stripped stdout. Never raises; returns None on any error."""
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout) # noqa: S603
return result.stdout.strip() or None
except Exception as e:
logger.debug('Failed to run command %s', command, exc_info=e)
return None


def _read_text_file(path: str) -> Optional[str]:
"""Read and strip a text file. Never raises; returns None if it can't be read."""
try:
with open(path) as text_file:
return text_file.read().strip() or None
except OSError:
return None


def get_hostname() -> Optional[str]:
try:
return socket.gethostname() or None
except Exception as e:
logger.debug('Failed to resolve hostname', exc_info=e)
return None


def get_platform_name() -> Optional[str]:
try:
system = platform.system()
return _PLATFORM_NAMES.get(system, system or None)
except Exception as e:
logger.debug('Failed to resolve platform name', exc_info=e)
return None


def get_os_version() -> Optional[str]:
try:
system = platform.system()
if system == 'Darwin':
return platform.mac_ver()[0] or None
if system == 'Windows':
return platform.win32_ver()[1] or platform.version() or None
if system == 'Linux':
return _get_linux_os_version()
return platform.release() or None
except Exception as e:
logger.debug('Failed to resolve OS version', exc_info=e)
return None


def _get_linux_os_version() -> Optional[str]:
freedesktop_os_release = getattr(platform, 'freedesktop_os_release', None) # Python 3.10+
if freedesktop_os_release is not None:
try:
version_id = freedesktop_os_release().get('VERSION_ID')
if version_id:
return version_id
except OSError:
pass

os_release = _read_text_file('/etc/os-release') # Python 3.9 fallback: parse manually
if os_release:
for line in os_release.splitlines():
if line.startswith('VERSION_ID='):
return line.split('=', 1)[1].strip().strip('"') or None

return platform.release() or None


def get_last_login_user() -> Optional[str]:
try:
return getpass.getuser() or None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you check this is not returning root from hooks?

except Exception as e:
logger.debug('Failed to resolve last login user', exc_info=e)
return None


def get_serial_number() -> Optional[str]:
try:
system = platform.system()
if system == 'Darwin':
return _get_macos_serial_number()
if system == 'Windows':
return _run(
['powershell', '-NoProfile', '-Command', '(Get-CimInstance -ClassName Win32_BIOS).SerialNumber']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not sure but maybe we should avoid subprocess for this? (powershell takes time to spawn) (we can probably get it through python libraries)

)
except Exception as e:
logger.debug('Failed to resolve serial number', exc_info=e)
return None


def _get_macos_serial_number() -> Optional[str]:
output = _run(['ioreg', '-c', 'IOPlatformExpertDevice', '-d', '2'])
if not output:
return None
match = re.search(r'"IOPlatformSerialNumber"\s*=\s*"([^"]+)"', output)
return match.group(1) if match else None
12 changes: 8 additions & 4 deletions cycode/cyclient/ai_security_manager_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,21 @@ def create_event(
def report_session_context(
self,
hostname: Optional[str] = None,
platform: Optional[str] = None,
logged_in_user: Optional[str] = None,
platform_name: Optional[str] = None,
os_version: Optional[str] = None,
serial_number: Optional[str] = None,
last_login_user: Optional[str] = None,
global_config_file: Optional[dict] = None,
enabled_plugins: Optional[dict] = None,
user_email: Optional[str] = None,
) -> None:
"""Report session context to the backend."""
body: dict = {
'hostname': hostname,
'platform': platform,
'logged_in_user': logged_in_user,
'platform_name': platform_name,
'os_version': os_version,
'serial_number': serial_number,
'last_login_user': last_login_user,
'user_email': user_email,
'global_config_file': global_config_file,
'enabled_plugins': enabled_plugins,
Expand Down
18 changes: 12 additions & 6 deletions tests/cli/commands/ai_guardrails/test_session_start_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,10 @@ def test_claude_code_reports_mcp_servers(

mock_ai_client.report_session_context.assert_called_once_with(
hostname=ANY,
platform=ANY,
logged_in_user=ANY,
platform_name=ANY,
os_version=ANY,
serial_number=ANY,
last_login_user=ANY,
global_config_file={
'path': str(_claude_mod._CLAUDE_CONFIG_PATH),
'content': json.dumps({'mcpServers': mcp_servers}),
Expand Down Expand Up @@ -291,8 +293,10 @@ def test_claude_code_reports_global_file_and_plugin_metadata(
plugin_mcp = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}}
mock_ai_client.report_session_context.assert_called_once_with(
hostname=ANY,
platform=ANY,
logged_in_user=ANY,
platform_name=ANY,
os_version=ANY,
serial_number=ANY,
last_login_user=ANY,
global_config_file={
'path': str(_claude_mod._CLAUDE_CONFIG_PATH),
'content': json.dumps({'mcpServers': user_mcp_servers}),
Expand Down Expand Up @@ -361,8 +365,10 @@ def test_cursor_reports_mcp_servers(

mock_ai_client.report_session_context.assert_called_once_with(
hostname=ANY,
platform=ANY,
logged_in_user=ANY,
platform_name=ANY,
os_version=ANY,
serial_number=ANY,
last_login_user=ANY,
global_config_file={
'path': str(Path.home() / '.cursor' / 'mcp.json'),
'content': json.dumps({'mcpServers': mcp_servers}),
Expand Down
66 changes: 66 additions & 0 deletions tests/utils/test_host_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from pytest_mock import MockerFixture

from cycode.cli.utils import host_info

_MODULE = 'cycode.cli.utils.host_info'

_IOREG_SAMPLE = """
"IOPlatformUUID" = "00000000-0000-0000-0000-000000000000"
"IOPlatformSerialNumber" = "AAAA888111"
"""
# platform_name mapping


def test_get_platform_name_maps_known_systems(mocker: MockerFixture) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all of these tests are mocked and meaningless

for system, expected in (('Darwin', 'macOS'), ('Windows', 'Windows'), ('Linux', 'Linux')):
mocker.patch(f'{_MODULE}.platform.system', return_value=system)
assert host_info.get_platform_name() == expected


def test_get_platform_name_falls_back_to_raw_system(mocker: MockerFixture) -> None:
mocker.patch(f'{_MODULE}.platform.system', return_value='SunOS')
assert host_info.get_platform_name() == 'SunOS'


# serial_number per platform


def test_get_serial_number_macos_parses_ioreg(mocker: MockerFixture) -> None:
mocker.patch(f'{_MODULE}.platform.system', return_value='Darwin')
mocker.patch(f'{_MODULE}._run', return_value=_IOREG_SAMPLE)
assert host_info.get_serial_number() == 'AAAA888111'


def test_get_serial_number_windows_uses_command_output(mocker: MockerFixture) -> None:
mocker.patch(f'{_MODULE}.platform.system', return_value='Windows')
mocker.patch(f'{_MODULE}._run', return_value='ABC123XYZ')
assert host_info.get_serial_number() == 'ABC123XYZ'


def test_get_serial_number_unsupported_platform_returns_none(mocker: MockerFixture) -> None:
mocker.patch(f'{_MODULE}.platform.system', return_value='Linux')
assert host_info.get_serial_number() is None


# Robustness: getters never raise, returning None when their backing call fails.


def test_get_serial_number_returns_none_when_command_raises(mocker: MockerFixture) -> None:
mocker.patch(f'{_MODULE}.platform.system', return_value='Darwin')
mocker.patch(f'{_MODULE}._run', side_effect=RuntimeError('subprocess failed'))
assert host_info.get_serial_number() is None


def test_run_returns_none_on_failure(mocker: MockerFixture) -> None:
mocker.patch(f'{_MODULE}.subprocess.run', side_effect=FileNotFoundError('missing'))
assert host_info._run(['does-not-exist']) is None


def test_get_hostname_returns_none_on_error(mocker: MockerFixture) -> None:
mocker.patch(f'{_MODULE}.socket.gethostname', side_effect=OSError('hostname unavailable'))
assert host_info.get_hostname() is None


def test_get_last_login_user_returns_none_on_error(mocker: MockerFixture) -> None:
mocker.patch(f'{_MODULE}.getpass.getuser', side_effect=KeyError('no user'))
assert host_info.get_last_login_user() is None
Loading