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
127 changes: 127 additions & 0 deletions cycode/cli/utils/host_info.py
Original file line number Diff line number Diff line change
@@ -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

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 _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
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
Loading