From 6e4848a0ab1f69cec7cbd74c1e8e54b77204572c Mon Sep 17 00:00:00 2001 From: Roni Ku Date: Wed, 24 Jun 2026 12:51:46 +0300 Subject: [PATCH 1/2] CM-67459-enrich-payload --- .../ai_guardrails/session_start_command.py | 26 ++-- cycode/cli/utils/host_info.py | 117 ++++++++++++++++++ cycode/cyclient/ai_security_manager_client.py | 12 +- .../test_session_start_command.py | 18 ++- tests/utils/test_host_info.py | 73 +++++++++++ 5 files changed, 222 insertions(+), 24 deletions(-) create mode 100644 cycode/cli/utils/host_info.py create mode 100644 tests/utils/test_host_info.py diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index 3a20b2c8..5d491f10 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -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 @@ -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: @@ -23,14 +27,6 @@ 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: @@ -38,9 +34,11 @@ def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user 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, diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py new file mode 100644 index 00000000..e3798834 --- /dev/null +++ b/cycode/cli/utils/host_info.py @@ -0,0 +1,117 @@ +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'} +_LINUX_SERIAL_PATH = '/sys/class/dmi/id/product_serial' + + +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 + 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'] + ) + if system == 'Linux': + return _read_text_file(_LINUX_SERIAL_PATH) + 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 diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 19955410..a4f9bd76 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -94,8 +94,10 @@ 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, @@ -103,8 +105,10 @@ def report_session_context( """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, diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index d048c8b3..ed6708ce 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -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}), @@ -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}), @@ -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}), diff --git a/tests/utils/test_host_info.py b/tests/utils/test_host_info.py new file mode 100644 index 00000000..20b7801a --- /dev/null +++ b/tests/utils/test_host_info.py @@ -0,0 +1,73 @@ +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: + 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_linux_reads_dmi_file(mocker: MockerFixture) -> None: + mocker.patch(f'{_MODULE}.platform.system', return_value='Linux') + mocker.patch('builtins.open', mocker.mock_open(read_data='SERIAL-LINUX-1\n')) + assert host_info.get_serial_number() == 'SERIAL-LINUX-1' + + +def test_get_serial_number_linux_returns_none_when_unreadable(mocker: MockerFixture) -> None: + mocker.patch(f'{_MODULE}.platform.system', return_value='Linux') + mocker.patch('builtins.open', side_effect=PermissionError('not root')) + 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 From 17ed929e1f9da4f6f1858daa7eaa2fdb171186f8 Mon Sep 17 00:00:00 2001 From: Roni Ku Date: Wed, 24 Jun 2026 12:53:30 +0300 Subject: [PATCH 2/2] CM-67459-omit-linux --- cycode/cli/utils/host_info.py | 3 --- tests/utils/test_host_info.py | 9 +-------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index e3798834..ecbfc397 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -12,7 +12,6 @@ _SUBPROCESS_TIMEOUT_SEC = 5 _PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'} -_LINUX_SERIAL_PATH = '/sys/class/dmi/id/product_serial' def _run(command: list, timeout: int = _SUBPROCESS_TIMEOUT_SEC) -> Optional[str]: @@ -102,8 +101,6 @@ def get_serial_number() -> Optional[str]: return _run( ['powershell', '-NoProfile', '-Command', '(Get-CimInstance -ClassName Win32_BIOS).SerialNumber'] ) - if system == 'Linux': - return _read_text_file(_LINUX_SERIAL_PATH) except Exception as e: logger.debug('Failed to resolve serial number', exc_info=e) return None diff --git a/tests/utils/test_host_info.py b/tests/utils/test_host_info.py index 20b7801a..985dad58 100644 --- a/tests/utils/test_host_info.py +++ b/tests/utils/test_host_info.py @@ -37,15 +37,8 @@ def test_get_serial_number_windows_uses_command_output(mocker: MockerFixture) -> assert host_info.get_serial_number() == 'ABC123XYZ' -def test_get_serial_number_linux_reads_dmi_file(mocker: MockerFixture) -> None: +def test_get_serial_number_unsupported_platform_returns_none(mocker: MockerFixture) -> None: mocker.patch(f'{_MODULE}.platform.system', return_value='Linux') - mocker.patch('builtins.open', mocker.mock_open(read_data='SERIAL-LINUX-1\n')) - assert host_info.get_serial_number() == 'SERIAL-LINUX-1' - - -def test_get_serial_number_linux_returns_none_when_unreadable(mocker: MockerFixture) -> None: - mocker.patch(f'{_MODULE}.platform.system', return_value='Linux') - mocker.patch('builtins.open', side_effect=PermissionError('not root')) assert host_info.get_serial_number() is None