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..23737b7a --- /dev/null +++ b/cycode/cli/utils/host_info.py @@ -0,0 +1,127 @@ +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 + 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 _get_windows_serial_number() + 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 + + +def _get_windows_serial_number() -> Optional[str]: + import pythoncom # from pywin32 + import win32com.client # from pywin32 + + 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/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}),