From 6e4848a0ab1f69cec7cbd74c1e8e54b77204572c Mon Sep 17 00:00:00 2001 From: Roni Ku Date: Wed, 24 Jun 2026 12:51:46 +0300 Subject: [PATCH 1/5] 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/5] 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 From 0da297a58bdf42685548b05bd42959b219f8c770 Mon Sep 17 00:00:00 2001 From: Roni Ku Date: Thu, 25 Jun 2026 17:32:16 +0300 Subject: [PATCH 3/5] CM-67459-address-review --- cycode/cli/utils/host_info.py | 14 +++++++++++--- pyproject.toml | 2 ++ tests/utils/test_host_info.py | 11 ----------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index ecbfc397..6c3dc46f 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -98,9 +98,7 @@ def get_serial_number() -> Optional[str]: if system == 'Darwin': return _get_macos_serial_number() if system == 'Windows': - return _run( - ['powershell', '-NoProfile', '-Command', '(Get-CimInstance -ClassName Win32_BIOS).SerialNumber'] - ) + return _get_windows_serial_number() except Exception as e: logger.debug('Failed to resolve serial number', exc_info=e) return None @@ -112,3 +110,13 @@ def _get_macos_serial_number() -> Optional[str]: return None match = re.search(r'"IOPlatformSerialNumber"\s*=\s*"([^"]+)"', output) return match.group(1) if match else None + + +def _get_windows_serial_number() -> Optional[str]: + import wmi # Windows-only dependency; imported lazily so other platforms never load it + + bios_entries = wmi.WMI().Win32_BIOS() + if not bios_entries: + return None + serial = bios_entries[0].SerialNumber + return serial.strip() if serial else None diff --git a/pyproject.toml b/pyproject.toml index bfb34e90..a55a8d9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,8 @@ pathvalidate = ">=3.3.1,<4.0.0" tomli-w = ">=1.0.0,<2.0.0" tomli = {version = ">=2.0.0,<3.0.0", python = "<3.11"} anyio = ">=4.0.0, <4.13.0" +wmi = { version = "^1.5.1", markers = "sys_platform == 'win32'" } +pywin32 = { version = "^308", markers = "sys_platform == 'win32'" } [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" diff --git a/tests/utils/test_host_info.py b/tests/utils/test_host_info.py index 985dad58..2f29a17b 100644 --- a/tests/utils/test_host_info.py +++ b/tests/utils/test_host_info.py @@ -31,12 +31,6 @@ def test_get_serial_number_macos_parses_ioreg(mocker: MockerFixture) -> None: 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 @@ -59,8 +53,3 @@ def test_run_returns_none_on_failure(mocker: MockerFixture) -> 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 88c09a877ce1bc7b815f10e6dd6db47f530cf01f Mon Sep 17 00:00:00 2001 From: Roni Ku Date: Thu, 25 Jun 2026 17:38:52 +0300 Subject: [PATCH 4/5] CM-67459-address-review --- tests/utils/test_host_info.py | 55 ----------------------------------- 1 file changed, 55 deletions(-) delete mode 100644 tests/utils/test_host_info.py diff --git a/tests/utils/test_host_info.py b/tests/utils/test_host_info.py deleted file mode 100644 index 2f29a17b..00000000 --- a/tests/utils/test_host_info.py +++ /dev/null @@ -1,55 +0,0 @@ -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_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 From 2f978c37d9b01386b21c5dd69ca394dc2ca63aa6 Mon Sep 17 00:00:00 2001 From: Roni Ku Date: Sun, 28 Jun 2026 10:17:28 +0300 Subject: [PATCH 5/5] CM-67459-use-pywin-instead-of-wmi --- cycode/cli/utils/host_info.py | 17 +++++++++++------ pyproject.toml | 2 -- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index 6c3dc46f..23737b7a 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -113,10 +113,15 @@ def _get_macos_serial_number() -> Optional[str]: def _get_windows_serial_number() -> Optional[str]: - import wmi # Windows-only dependency; imported lazily so other platforms never load it + import pythoncom # from pywin32 + import win32com.client # from pywin32 - bios_entries = wmi.WMI().Win32_BIOS() - if not bios_entries: - return None - serial = bios_entries[0].SerialNumber - return serial.strip() if serial else None + pythoncom.CoInitialize() + try: + wmi_service = win32com.client.GetObject('winmgmts:') + for bios in wmi_service.InstancesOf('Win32_BIOS'): + serial = bios.SerialNumber + return serial.strip() if serial else None + finally: + pythoncom.CoUninitialize() + return None diff --git a/pyproject.toml b/pyproject.toml index a55a8d9e..bfb34e90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,6 @@ pathvalidate = ">=3.3.1,<4.0.0" tomli-w = ">=1.0.0,<2.0.0" tomli = {version = ">=2.0.0,<3.0.0", python = "<3.11"} anyio = ">=4.0.0, <4.13.0" -wmi = { version = "^1.5.1", markers = "sys_platform == 'win32'" } -pywin32 = { version = "^308", markers = "sys_platform == 'win32'" } [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0"