Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 7 additions & 5 deletions cycode/cli/apps/ai_guardrails/command_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,26 @@
console = Console()


def validate_and_parse_ide(ide: str) -> AIIDEType:
"""Validate IDE parameter and convert to AIIDEType enum.
def validate_and_parse_ide(ide: str) -> Optional[AIIDEType]:
"""Validate IDE parameter, returning None for 'all'.

Args:
ide: IDE name string (e.g., 'cursor')
ide: IDE name string (e.g., 'cursor', 'claude-code', 'all')

Returns:
AIIDEType enum value
AIIDEType enum value, or None if 'all' was specified

Raises:
typer.Exit: If IDE is invalid
"""
if ide.lower() == 'all':
return None
try:
return AIIDEType(ide.lower())
except ValueError:
valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType])
console.print(
f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}',
f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}, all',
style='bold red',
)
raise typer.Exit(1) from None
Expand Down
81 changes: 68 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,14 @@ class AIIDEType(str, Enum):
"""Supported AI IDE types."""

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


class PolicyMode(str, Enum):
"""Policy enforcement mode for global mode and per-feature actions."""

BLOCK = 'block'
WARN = 'warn'


class IDEConfig(NamedTuple):
Expand All @@ -42,6 +45,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 +62,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:Read', 'PreToolUse:mcp'],
),
}

# Default IDE
Expand All @@ -60,6 +78,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 +128,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()
32 changes: 29 additions & 3 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 Expand Up @@ -185,7 +203,15 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide:
ide_config = IDE_CONFIGS[ide]
has_cycode_hooks = False
for event in ide_config.hook_events:
entries = existing.get('hooks', {}).get(event, [])
# Handle event:matcher format
if ':' in event:
actual_event, matcher_prefix = event.split(':', 1)
all_entries = existing.get('hooks', {}).get(actual_event, [])
# Filter entries by matcher
entries = [e for e in all_entries if e.get('matcher', '').startswith(matcher_prefix)]
else:
entries = existing.get('hooks', {}).get(event, [])

cycode_entries = [e for e in entries if is_cycode_hook_entry(e)]
if cycode_entries:
has_cycode_hooks = True
Expand Down
39 changes: 29 additions & 10 deletions cycode/cli/apps/ai_guardrails/install_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
validate_and_parse_ide,
validate_scope,
)
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType
from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks
from cycode.cli.utils.sentry import add_breadcrumb

Expand All @@ -30,9 +30,9 @@ def install_command(
str,
typer.Option(
'--ide',
help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.',
help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.',
),
] = 'cursor',
] = AIIDEType.CURSOR,
repo_path: Annotated[
Optional[Path],
typer.Option(
Expand All @@ -54,6 +54,7 @@ def install_command(
cycode ai-guardrails install # Install for all projects (user scope)
cycode ai-guardrails install --scope repo # Install for current repo only
cycode ai-guardrails install --ide cursor # Install for Cursor IDE
cycode ai-guardrails install --ide all # Install for all supported IDEs
cycode ai-guardrails install --scope repo --repo-path /path/to/repo
"""
add_breadcrumb('ai-guardrails-install')
Expand All @@ -62,17 +63,35 @@ def install_command(
validate_scope(scope)
repo_path = resolve_repo_path(scope, repo_path)
ide_type = validate_and_parse_ide(ide)
ide_name = IDE_CONFIGS[ide_type].name
success, message = install_hooks(scope, repo_path, ide=ide_type)

if success:
console.print(f'[green]✓[/] {message}')
ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type]

results: list[tuple[str, bool, str]] = []
for current_ide in ides_to_install:
ide_name = IDE_CONFIGS[current_ide].name
success, message = install_hooks(scope, repo_path, ide=current_ide)
results.append((ide_name, success, message))

# Report results for each IDE
any_success = False
all_success = True
for _ide_name, success, message in results:
if success:
console.print(f'[green]✓[/] {message}')
any_success = True
else:
console.print(f'[red]✗[/] {message}', style='bold red')
all_success = False

if any_success:
console.print()
console.print('[bold]Next steps:[/]')
console.print(f'1. Restart {ide_name} to activate the hooks')
successful_ides = [name for name, success, _ in results if success]
ide_list = ', '.join(successful_ides)
console.print(f'1. Restart {ide_list} to activate the hooks')
console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml')
console.print()
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')
else:
console.print(f'[red]✗[/] {message}', style='bold red')

if not all_success:
raise typer.Exit(1)
Loading