Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bd250a4
CM-58022-cycode-guardrails-support-cursor-scan-via-hooks
Ilanlido Jan 26, 2026
5adc5c4
CM-58022-lint
Ilanlido Jan 26, 2026
11d0879
CM-58022-format
Ilanlido Jan 26, 2026
c09a652
CM-58022-fix strenum
Ilanlido Jan 26, 2026
d239d9f
CM-58022-format
Ilanlido Jan 26, 2026
4ecb761
CM-58022-fix
Ilanlido Jan 26, 2026
14afe21
CM-58022 skip scan configuration fetching for prompt command
Ilanlido Jan 26, 2026
744911f
Merge branch 'main' into CM-58022-cycode-guardrails-support-cursor-sc…
Ilanlido Jan 26, 2026
d95b19b
CM-58248 - CM-58022 skip scan configuration fetching for prompt command
Ilanlido Jan 27, 2026
29114c2
CM-58248 format
Ilanlido Jan 27, 2026
d183e2e
CM-58022-fix-types
Ilanlido Jan 27, 2026
f7a2b30
Merge branch 'CM-58248-deny-prompts-if-not-authenticated' into CM-580…
Ilanlido Jan 27, 2026
678967f
CM-58022-review
Ilanlido Jan 28, 2026
9970be7
CM-58022-change units to fakefs
Ilanlido Jan 28, 2026
d794938
CM-58022-rename scan type name
Ilanlido Jan 28, 2026
48d9d2f
CM-58022-added mcp server name
Ilanlido Jan 29, 2026
0dccae8
CM-58022-lint
Ilanlido Jan 29, 2026
87cd5b2
CM-58022-hide ai-guardrails help for now
Ilanlido Jan 29, 2026
d1ba80d
Merge branch 'refs/heads/main' into CM-58331-support-claude-code
Ilanlido Feb 1, 2026
3418f87
CM-58331-support-claude-code
Ilanlido Feb 2, 2026
1789349
CM-58331-remove test
Ilanlido Feb 2, 2026
b0625ac
CM-58331-send error if available
Ilanlido Feb 2, 2026
5ad5aa5
CM-58331: Add --ide all option for AI guardrails commands
Ilanlido Feb 3, 2026
17db1ab
CM-58331-review
Ilanlido Feb 3, 2026
3e7e1bf
CM-58331 - Style: apply ruff formatting
Ilanlido Feb 3, 2026
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
74 changes: 61 additions & 13 deletions cycode/cli/apps/ai_guardrails/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@

Currently supports:
- Cursor

To add a new IDE (e.g., Claude Code):
1. Add new value to AIIDEType enum
2. Create _get_<ide>_hooks_dir() function with platform-specific paths
3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names
4. Unhide --ide option in commands (install, uninstall, status)
- Claude Code
"""

import platform
Expand All @@ -20,6 +15,7 @@ class AIIDEType(str, Enum):
"""Supported AI IDE types."""

CURSOR = 'cursor'
CLAUDE_CODE = 'claude-code'


class IDEConfig(NamedTuple):
Expand All @@ -42,6 +38,14 @@ def _get_cursor_hooks_dir() -> Path:
return Path.home() / '.config' / 'Cursor'


def _get_claude_code_hooks_dir() -> Path:
"""Get Claude Code hooks directory.

Claude Code uses ~/.claude on all platforms.
"""
return Path.home() / '.claude'


# IDE-specific configurations
IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
AIIDEType.CURSOR: IDEConfig(
Expand All @@ -51,6 +55,13 @@ def _get_cursor_hooks_dir() -> Path:
hooks_file_name='hooks.json',
hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'],
),
AIIDEType.CLAUDE_CODE: IDEConfig(
name='Claude Code',
hooks_dir=_get_claude_code_hooks_dir(),
repo_hooks_subdir='.claude',
hooks_file_name='settings.json',
hook_events=['UserPromptSubmit', 'PreToolUse'],
),
}

# Default IDE
Expand All @@ -60,6 +71,47 @@ def _get_cursor_hooks_dir() -> Path:
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'


def _get_cursor_hooks_config() -> dict:
"""Get Cursor-specific hooks configuration."""
config = IDE_CONFIGS[AIIDEType.CURSOR]
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}

return {
'version': 1,
'hooks': hooks,
}


def _get_claude_code_hooks_config() -> dict:
"""Get Claude Code-specific hooks configuration.

Claude Code uses a different hook format with nested structure:
- hooks are arrays of objects with 'hooks' containing command arrays
- PreToolUse uses 'matcher' field to specify which tools to intercept
"""
command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'

return {
'hooks': {
'UserPromptSubmit': [
{
'hooks': [{'type': 'command', 'command': command}],
}
],
'PreToolUse': [
{
'matcher': 'Read',
'hooks': [{'type': 'command', 'command': command}],
},
{
'matcher': 'mcp__.*',
'hooks': [{'type': 'command', 'command': command}],
},
],
},
}


def get_hooks_config(ide: AIIDEType) -> dict:
"""Get the hooks configuration for a specific IDE.

Expand All @@ -69,10 +121,6 @@ def get_hooks_config(ide: AIIDEType) -> dict:
Returns:
Dict with hooks configuration for the specified IDE
"""
config = IDE_CONFIGS[ide]
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}

return {
'version': 1,
'hooks': hooks,
}
if ide == AIIDEType.CLAUDE_CODE:
return _get_claude_code_hooks_config()
return _get_cursor_hooks_config()
22 changes: 20 additions & 2 deletions cycode/cli/apps/ai_guardrails/hooks_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,27 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:


def is_cycode_hook_entry(entry: dict) -> bool:
"""Check if a hook entry is from cycode-cli."""
"""Check if a hook entry is from cycode-cli.

Handles both Cursor format (flat) and Claude Code format (nested).

Cursor format: {"command": "cycode ai-guardrails scan"}
Claude Code format: {"hooks": [{"type": "command", "command": "cycode ai-guardrails scan --ide claude-code"}]}
"""
# Check Cursor format (flat command)
command = entry.get('command', '')
return CYCODE_SCAN_PROMPT_COMMAND in command
if CYCODE_SCAN_PROMPT_COMMAND in command:
return True

# Check Claude Code format (nested hooks array)
hooks = entry.get('hooks', [])
for hook in hooks:
if isinstance(hook, dict):
hook_command = hook.get('command', '')
if CYCODE_SCAN_PROMPT_COMMAND in hook_command:
return True

return False


def install_hooks(
Expand Down
75 changes: 46 additions & 29 deletions cycode/cli/apps/ai_guardrails/scan/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,29 +55,25 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
scan_id = None
block_reason = None
outcome = AIHookOutcome.ALLOWED
error_message = None

try:
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)

if (
violation_summary
and get_policy_value(prompt_config, 'action', default='block') == 'block'
and mode == 'block'
):
outcome = AIHookOutcome.BLOCKED
if violation_summary:
block_reason = BlockReason.SECRETS_IN_PROMPT
user_message = f'{violation_summary}. Remove secrets before sending.'
response = response_builder.deny_prompt(user_message)
else:
if violation_summary:
outcome = AIHookOutcome.WARNED
response = response_builder.allow_prompt()
return response
if get_policy_value(prompt_config, 'action', default='block') == 'block' and mode == 'block':
outcome = AIHookOutcome.BLOCKED
user_message = f'{violation_summary}. Remove secrets before sending.'
return response_builder.deny_prompt(user_message)
outcome = AIHookOutcome.WARNED
return response_builder.allow_prompt()
except Exception as e:
outcome = (
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
)
block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None
block_reason = BlockReason.SCAN_FAILURE
error_message = str(e)
raise e
finally:
ai_client.create_event(
Expand All @@ -86,6 +82,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
outcome,
scan_id=scan_id,
block_reason=block_reason,
error_message=error_message,
)


Expand Down Expand Up @@ -113,39 +110,55 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
scan_id = None
block_reason = None
outcome = AIHookOutcome.ALLOWED
error_message = None

try:
# Check path-based denylist first
if is_denied_path(file_path, policy) and action == 'block':
outcome = AIHookOutcome.BLOCKED
if is_denied_path(file_path, policy):
block_reason = BlockReason.SENSITIVE_PATH
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
return response_builder.deny_permission(
if mode == 'block' and action == 'block':
outcome = AIHookOutcome.BLOCKED
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
return response_builder.deny_permission(
user_message,
'This file path is classified as sensitive; do not read/send it to the model.',
)
# Warn mode - ask user for permission
outcome = AIHookOutcome.WARNED
user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?'
return response_builder.ask_permission(
user_message,
'This file path is classified as sensitive; do not read/send it to the model.',
'This file path is classified as sensitive; proceed with caution.',
)

# Scan file content if enabled
if get_policy_value(file_read_config, 'scan_content', default=True):
violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy)
if violation_summary and action == 'block' and mode == 'block':
outcome = AIHookOutcome.BLOCKED
if violation_summary:
block_reason = BlockReason.SECRETS_IN_FILE
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
return response_builder.deny_permission(
if mode == 'block' and action == 'block':
Comment thread
gotbadger marked this conversation as resolved.
Outdated
outcome = AIHookOutcome.BLOCKED
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
return response_builder.deny_permission(
user_message,
'Secrets detected; do not send this file to the model.',
)
# Warn mode - ask user for permission
outcome = AIHookOutcome.WARNED
user_message = f'Cycode detected secrets in {file_path}. {violation_summary}'
return response_builder.ask_permission(
user_message,
'Secrets detected; do not send this file to the model.',
'Possible secrets detected; proceed with caution.',
)
if violation_summary:
outcome = AIHookOutcome.WARNED
return response_builder.allow_permission()

return response_builder.allow_permission()
except Exception as e:
outcome = (
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
)
block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None
block_reason = BlockReason.SCAN_FAILURE
error_message = str(e)
raise e
finally:
ai_client.create_event(
Expand All @@ -154,6 +167,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
outcome,
scan_id=scan_id,
block_reason=block_reason,
error_message=error_message,
)


Expand Down Expand Up @@ -187,14 +201,15 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
scan_id = None
block_reason = None
outcome = AIHookOutcome.ALLOWED
error_message = None

try:
if get_policy_value(mcp_config, 'scan_arguments', default=True):
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
if violation_summary:
block_reason = BlockReason.SECRETS_IN_MCP_ARGS
if mode == 'block' and action == 'block':
outcome = AIHookOutcome.BLOCKED
block_reason = BlockReason.SECRETS_IN_MCP_ARGS
user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}'
return response_builder.deny_permission(
user_message,
Expand All @@ -211,7 +226,8 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
outcome = (
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
)
block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None
block_reason = BlockReason.SCAN_FAILURE
error_message = str(e)
raise e
finally:
ai_client.create_event(
Expand All @@ -220,6 +236,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
outcome,
scan_id=scan_id,
block_reason=block_reason,
error_message=error_message,
)


Expand Down
Loading