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.

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 @@ -187,7 +185,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
106 changes: 106 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,106 @@
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.payload import AIHookPayload, _extract_from_claude_transcript
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:
ide_version, model, _ = _extract_from_claude_transcript(payload.get('transcript_path'))
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 should probably get rid of _extract_from_claude_transcript now, i created it because other hooks didn't provide the model, as far as i know session start provides the model in stdin
Regarding ide_version, do we have it in stdin / claude.json file?

claude_config = load_claude_config()
ide_user_email = get_user_email(claude_config) if claude_config else None

return AIHookPayload(
event_name='session_start',
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.

no need - its not used later when creating conversation

conversation_id=payload.get('session_id'),
ide_user_email=ide_user_email,
model=payload.get('model') or model,
ide_provider=AIIDEType.CLAUDE_CODE.value,
ide_version=ide_version,
)

# Cursor
return AIHookPayload(
event_name='session_start',
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 session_start_command(

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

View workflow job for this annotation

GitHub Actions / ruff

Ruff (C901)

cycode/cli/apps/ai_guardrails/session_start_command.py:46:5: C901 `session_start_command` is too complex (11 > 10)

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

View workflow job for this annotation

GitHub Actions / ruff

Ruff (C901)

cycode/cli/apps/ai_guardrails/session_start_command.py:46:5: C901 `session_start_command` is too complex (11 > 10)
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 +71 to +75
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

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, Claude Code only)
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 should handle cursor too, also extract to func

if ide == AIIDEType.CLAUDE_CODE:
claude_config = load_claude_config()
if claude_config:
mcp_servers = get_mcp_servers(claude_config)
if mcp_servers:
try:
ai_client.report_data_flow(mcp_servers)
except Exception as e:
logger.debug('Failed to report MCP servers', exc_info=e)
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'
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.

i dont know if i like that route name


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