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
9 changes: 6 additions & 3 deletions cycode/cli/apps/ai_guardrails/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import typer

from cycode.cli.apps.ai_guardrails.ensure_auth_command import ensure_auth_command
from cycode.cli.apps.ai_guardrails.install_command import install_command
from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command
from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command
from cycode.cli.apps.ai_guardrails.status_command import status_command
from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command

Check failure on line 7 in cycode/cli/apps/ai_guardrails/__init__.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (I001)

cycode/cli/apps/ai_guardrails/__init__.py:1:1: I001 Import block is un-sorted or un-formatted

Check failure on line 7 in cycode/cli/apps/ai_guardrails/__init__.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (I001)

cycode/cli/apps/ai_guardrails/__init__.py:1:1: I001 Import block is un-sorted or un-formatted

app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True)

Expand All @@ -18,6 +18,9 @@
name='scan',
short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).',
)(scan_command)
app.command(hidden=True, name='ensure-auth', short_help='Ensure authentication, triggering auth if needed.')(
ensure_auth_command
app.command(hidden=True, name='session-start', short_help='Handle session start: auth, conversation, data flow.')(
session_start_command
)
app.command(hidden=True, name='ensure-auth', short_help='[Deprecated] Alias for session-start.')(
session_start_command
)
6 changes: 3 additions & 3 deletions cycode/cli/apps/ai_guardrails/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,15 @@ def _get_claude_code_hooks_dir() -> Path:

# Command used in hooks
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
CYCODE_ENSURE_AUTH_COMMAND = 'cycode ai-guardrails ensure-auth'
CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start'


def _get_cursor_hooks_config(async_mode: bool = False) -> dict:
"""Get Cursor-specific hooks configuration."""
config = IDE_CONFIGS[AIIDEType.CURSOR]
command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND
hooks = {event: [{'command': command}] for event in config.hook_events}
hooks['sessionStart'] = [{'command': CYCODE_ENSURE_AUTH_COMMAND}]
hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}]

return {
'version': 1,
Expand All @@ -119,7 +119,7 @@ def _get_claude_code_hooks_config(async_mode: bool = False) -> dict:
'SessionStart': [
{
'matcher': 'startup',
'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}],
'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}],
}
],
'UserPromptSubmit': [
Expand Down
21 changes: 0 additions & 21 deletions cycode/cli/apps/ai_guardrails/ensure_auth_command.py

This file was deleted.

8 changes: 8 additions & 0 deletions cycode/cli/apps/ai_guardrails/scan/claude_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,11 @@ def get_user_email(config: dict) -> Optional[str]:
Reads oauthAccount.emailAddress from the config dict.
"""
return config.get('oauthAccount', {}).get('emailAddress')


def get_mcp_servers(config: dict) -> Optional[dict]:
"""Extract MCP servers from Claude config.

Reads mcpServers from the config dict.
"""
return config.get('mcpServers')
36 changes: 36 additions & 0 deletions cycode/cli/apps/ai_guardrails/scan/cursor_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Reader for ~/.cursor/mcp.json configuration file.

Extracts MCP server definitions from the Cursor global config file
for use in AI guardrails data-flow reporting.
"""

import json
from pathlib import Path
from typing import Optional

from cycode.logger import get_logger

logger = get_logger('AI Guardrails Cursor Config')

_CURSOR_MCP_CONFIG_PATH = Path.home() / '.cursor' / 'mcp.json'


def load_cursor_config(config_path: Optional[Path] = None) -> Optional[dict]:
"""Load and parse ~/.cursor/mcp.json.

Args:
config_path: Override path for testing. Defaults to ~/.cursor/mcp.json.

Returns:
Parsed dict or None if file is missing or invalid.
"""
path = config_path or _CURSOR_MCP_CONFIG_PATH
if not path.exists():
logger.debug('Cursor MCP config file not found', extra={'path': str(path)})
return None
try:
content = path.read_text(encoding='utf-8')
return json.loads(content)
except Exception as e:
logger.debug('Failed to load Cursor MCP config file', exc_info=e)
return None
3 changes: 0 additions & 3 deletions cycode/cli/apps/ai_guardrails/scan/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
response_builder = get_response_builder(ide)

prompt_config = get_policy_value(policy, 'prompt', default={})
ai_client.create_conversation(payload)
if not get_policy_value(prompt_config, 'enabled', default=True):
ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
return response_builder.allow_prompt()
Expand Down Expand Up @@ -100,7 +99,6 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
response_builder = get_response_builder(ide)

file_read_config = get_policy_value(policy, 'file_read', default={})
ai_client.create_conversation(payload)
if not get_policy_value(file_read_config, 'enabled', default=True):
ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
return response_builder.allow_permission()
Expand Down Expand Up @@ -203,7 +201,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
response_builder = get_response_builder(ide)

mcp_config = get_policy_value(policy, 'mcp', default={})
ai_client.create_conversation(payload)
if not get_policy_value(mcp_config, 'enabled', default=True):
ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED)
return response_builder.allow_permission()
Expand Down
2 changes: 1 addition & 1 deletion cycode/cli/apps/ai_guardrails/scan/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class AIHookPayload:
"""Unified payload object that normalizes field names from different AI tools."""

# Event identification
event_name: str # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution')
event_name: Optional[str] = None # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution')
conversation_id: Optional[str] = None
generation_id: Optional[str] = None

Expand Down
118 changes: 118 additions & 0 deletions cycode/cli/apps/ai_guardrails/session_start_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import sys
from typing import Annotated

import typer

from cycode.cli.apps.ai_guardrails.consts import AIIDEType
from cycode.cli.apps.ai_guardrails.scan.claude_config import get_mcp_servers, get_user_email, load_claude_config
from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse
from cycode.cli.apps.auth.auth_common import get_authorization_info
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.logger import get_logger

logger = get_logger('AI Guardrails')


def _build_session_payload(payload: dict, ide: str) -> AIHookPayload:
"""Build an AIHookPayload from a session-start stdin payload."""
if ide == AIIDEType.CLAUDE_CODE:
claude_config = load_claude_config()
ide_user_email = get_user_email(claude_config) if claude_config else None

return AIHookPayload(
conversation_id=payload.get('session_id'),
ide_user_email=ide_user_email,
model=payload.get('model'),
ide_provider=AIIDEType.CLAUDE_CODE.value,
ide_version=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.

we still need to get the version

)

# Cursor
return AIHookPayload(
conversation_id=payload.get('conversation_id'),
ide_user_email=payload.get('user_email'),
model=payload.get('model'),
ide_provider=AIIDEType.CURSOR.value,
ide_version=payload.get('cursor_version'),
)


def _get_mcp_servers_for_ide(ide: str) -> dict:
"""Return configured MCP servers for the given IDE, or empty dict."""
if ide == AIIDEType.CLAUDE_CODE:
config = load_claude_config()
elif ide == AIIDEType.CURSOR:
config = load_cursor_config()
else:
return {}
return get_mcp_servers(config) or {} if config else {}


def _report_data_flow(ai_client, ide: str) -> None:

Check failure on line 55 in cycode/cli/apps/ai_guardrails/session_start_command.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (ANN001)

cycode/cli/apps/ai_guardrails/session_start_command.py:55:23: ANN001 Missing type annotation for function argument `ai_client`

Check failure on line 55 in cycode/cli/apps/ai_guardrails/session_start_command.py

View workflow job for this annotation

GitHub Actions / ruff

Ruff (ANN001)

cycode/cli/apps/ai_guardrails/session_start_command.py:55:23: ANN001 Missing type annotation for function argument `ai_client`
"""Report IDE MCP servers to the AI security manager. Never raises."""
mcp_servers = _get_mcp_servers_for_ide(ide)
if not mcp_servers:
return
try:
ai_client.report_data_flow(mcp_servers)
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.

rename

except Exception as e:
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.

you already have try catch inside report_data_flow

logger.debug('Failed to report MCP servers', exc_info=e)


def session_start_command(
ctx: typer.Context,
ide: Annotated[
str,
typer.Option(
'--ide',
help='IDE that triggered the session start.',
hidden=True,
),
] = AIIDEType.CURSOR.value,
) -> None:
"""Handle session start: ensure auth, create conversation, report data flow."""
# Step 1: Ensure authentication
auth_info = get_authorization_info(ctx)
if auth_info is None:
logger.debug('Not authenticated, starting authentication')
try:
auth_manager = AuthManager()
auth_manager.authenticate()
except Exception as err:
handle_auth_exception(ctx, err)
return
else:
logger.debug('Already authenticated')

# Step 2: Read stdin payload (backward compat: old hooks pipe no stdin)
if sys.stdin.isatty():
logger.debug('No stdin payload (TTY), skipping session initialization')
return

Comment on lines +91 to +95
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.

can you explain

Copy link
Copy Markdown
Collaborator Author

@RoniCycode RoniCycode Apr 13, 2026

Choose a reason for hiding this comment

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

What it does: sys.stdin.isatty() returns True when stdin is attached to a terminal — i.e. nothing is piped in. If we didn't have this guard, sys.stdin.read() on the next line would block forever waiting
for input that never arrives.

Why it's needed:

  1. Manual/interactive invocation. If you run cycode ai-guardrails session-start in a shell to debug it, stdin is the terminal. Without this check, the command would hang.
  2. Backward compat with the old ensure-auth hook. In init.py we kept ensure-auth as a deprecated alias:
    app.command(hidden=True, name='ensure-auth', ...)(session_start_command)
  3. Users who installed hooks before this branch have cycode ai-guardrails ensure-auth wired into their IDE config. The old command only ran auth and didn't read stdin. After they upgrade the CLI, that same
    hook now invokes session_start_command. The guard ensures that even if the invocation context somehow doesn't provide a JSON payload on stdin, we still do the auth step (which is what the old command did)
    and then gracefully skip the conversation/MCP reporting steps instead of hanging.
    Flow:
  • Hook invoked by IDE → stdin is a pipe → isatty() is False → read JSON, create conversation, report MCP
  • Hook invoked with no stdin (manual / edge case) → isatty() is True → auth happens above, then we return early

If you want to make the intent clearer, the comment could be:

  • No piped input: would block forever on read(). Happens when run manually,
  • or with the legacy ensure-auth hook which didn't supply stdin.

stdin_data = sys.stdin.read().strip()
payload = safe_json_parse(stdin_data)
if not payload:
logger.debug('Empty or invalid stdin payload, skipping session initialization')
return

# Step 3: Build session payload and initialize API client
session_payload = _build_session_payload(payload, ide)

try:
ai_client = get_ai_security_manager_client(ctx)
except Exception as e:
logger.debug('Failed to initialize AI security client', exc_info=e)
return

# Step 4: Create conversation
try:
ai_client.create_conversation(session_payload)
except Exception as e:
logger.debug('Failed to create conversation during session start', exc_info=e)

# Step 5: Report data flow (MCP servers)
_report_data_flow(ai_client, ide)
13 changes: 13 additions & 0 deletions cycode/cyclient/ai_security_manager_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class AISecurityManagerClient:

_CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations'
_EVENTS_PATH = 'v4/ai-security/interactions/events'
_DATA_FLOW_PATH = 'v4/ai-security/interactions/data-flow'

def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None:
self.client = client
Expand Down Expand Up @@ -88,3 +89,15 @@ def create_event(
except Exception as e:
logger.debug('Failed to create AI hook event', exc_info=e)
# Don't fail the hook if tracking fails

def report_data_flow(self, mcp_servers: Optional[dict] = None) -> None:
"""Report session data flow to the backend."""
body: dict = {
'mcp_servers': mcp_servers,
}

try:
self.client.post(self._build_endpoint_path(self._DATA_FLOW_PATH), body=body)
except Exception as e:
logger.debug('Failed to report data flow', exc_info=e)
# Don't fail the session if reporting fails
2 changes: 2 additions & 0 deletions tests/cli/commands/ai_guardrails/scan/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def test_handle_before_submit_prompt_disabled(

assert result == {'continue': True}
mock_ctx.obj['ai_security_client'].create_event.assert_called_once()
mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called()


@patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets')
Expand All @@ -80,6 +81,7 @@ def test_handle_before_submit_prompt_no_secrets(

assert result == {'continue': True}
mock_ctx.obj['ai_security_client'].create_event.assert_called_once()
mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called()
call_args = mock_ctx.obj['ai_security_client'].create_event.call_args
# outcome is arg[2], scan_id and block_reason are kwargs
assert call_args.args[2] == AIHookOutcome.ALLOWED
Expand Down
12 changes: 7 additions & 5 deletions tests/cli/commands/ai_guardrails/test_hooks_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from pyfakefs.fake_filesystem import FakeFilesystem

from cycode.cli.apps.ai_guardrails.consts import (
CYCODE_ENSURE_AUTH_COMMAND,
CYCODE_SCAN_PROMPT_COMMAND,
CYCODE_SESSION_START_COMMAND,
AIIDEType,
PolicyMode,
get_hooks_config,
Expand Down Expand Up @@ -88,12 +88,13 @@ def test_get_hooks_config_cursor_async() -> None:


def test_get_hooks_config_cursor_session_start() -> None:
"""Test Cursor hooks config includes sessionStart auth check."""
"""Test Cursor hooks config includes sessionStart with --ide flag."""
config = get_hooks_config(AIIDEType.CURSOR)
assert 'sessionStart' in config['hooks']
entries = config['hooks']['sessionStart']
assert len(entries) == 1
assert entries[0]['command'] == CYCODE_ENSURE_AUTH_COMMAND
assert CYCODE_SESSION_START_COMMAND in entries[0]['command']
assert '--ide cursor' in entries[0]['command']


def test_get_hooks_config_claude_code_sync() -> None:
Expand All @@ -118,12 +119,13 @@ def test_get_hooks_config_claude_code_async() -> None:


def test_get_hooks_config_claude_code_session_start() -> None:
"""Test Claude Code hooks config includes SessionStart auth check."""
"""Test Claude Code hooks config includes SessionStart with --ide flag."""
config = get_hooks_config(AIIDEType.CLAUDE_CODE)
assert 'SessionStart' in config['hooks']
entries = config['hooks']['SessionStart']
assert len(entries) == 1
assert entries[0]['hooks'][0]['command'] == CYCODE_ENSURE_AUTH_COMMAND
assert CYCODE_SESSION_START_COMMAND in entries[0]['hooks'][0]['command']
assert '--ide claude-code' in entries[0]['hooks'][0]['command']


def test_create_policy_file_warn(fs: FakeFilesystem) -> None:
Expand Down
Loading
Loading