Skip to content

Commit 043ab3b

Browse files
authored
CM-58022: cycode guardrails support cursor scan via hooks (#377)
1 parent 26d13d2 commit 043ab3b

32 files changed

+2631
-4
lines changed

cycode/cli/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typer.completion import install_callback, show_callback
1010

1111
from cycode import __version__
12-
from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, report_import, scan, status
12+
from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status
1313

1414
if sys.version_info >= (3, 10):
1515
from cycode.cli.apps import mcp
@@ -45,6 +45,7 @@
4545
add_completion=False, # we add it manually to control the rich help panel
4646
)
4747

48+
app.add_typer(ai_guardrails.app)
4849
app.add_typer(ai_remediation.app)
4950
app.add_typer(auth.app)
5051
app.add_typer(configure.app)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import typer
2+
3+
from cycode.cli.apps.ai_guardrails.install_command import install_command
4+
from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command
5+
from cycode.cli.apps.ai_guardrails.status_command import status_command
6+
from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command
7+
8+
app = typer.Typer(name='ai-guardrails', no_args_is_help=True, hidden=True)
9+
10+
app.command(hidden=True, name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command)
11+
app.command(hidden=True, name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')(
12+
uninstall_command
13+
)
14+
app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(status_command)
15+
app.command(
16+
hidden=True,
17+
name='scan',
18+
short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).',
19+
)(scan_command)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Common utilities for AI guardrails commands."""
2+
3+
import os
4+
from pathlib import Path
5+
from typing import Optional
6+
7+
import typer
8+
from rich.console import Console
9+
10+
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
11+
12+
console = Console()
13+
14+
15+
def validate_and_parse_ide(ide: str) -> AIIDEType:
16+
"""Validate IDE parameter and convert to AIIDEType enum.
17+
18+
Args:
19+
ide: IDE name string (e.g., 'cursor')
20+
21+
Returns:
22+
AIIDEType enum value
23+
24+
Raises:
25+
typer.Exit: If IDE is invalid
26+
"""
27+
try:
28+
return AIIDEType(ide.lower())
29+
except ValueError:
30+
valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType])
31+
console.print(
32+
f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}',
33+
style='bold red',
34+
)
35+
raise typer.Exit(1) from None
36+
37+
38+
def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo')) -> None:
39+
"""Validate scope parameter.
40+
41+
Args:
42+
scope: Scope string to validate
43+
allowed_scopes: Tuple of allowed scope values
44+
45+
Raises:
46+
typer.Exit: If scope is invalid
47+
"""
48+
if scope not in allowed_scopes:
49+
scopes_list = ', '.join(f'"{s}"' for s in allowed_scopes)
50+
console.print(f'[red]Error:[/] Invalid scope. Use {scopes_list}.', style='bold red')
51+
raise typer.Exit(1)
52+
53+
54+
def resolve_repo_path(scope: str, repo_path: Optional[Path]) -> Optional[Path]:
55+
"""Resolve repository path, defaulting to current directory for repo scope.
56+
57+
Args:
58+
scope: The command scope ('user' or 'repo')
59+
repo_path: Provided repo path or None
60+
61+
Returns:
62+
Resolved Path for repo scope, None for user scope
63+
"""
64+
if scope == 'repo' and repo_path is None:
65+
return Path(os.getcwd())
66+
return repo_path
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Constants for AI guardrails hooks management.
2+
3+
Currently supports:
4+
- Cursor
5+
6+
To add a new IDE (e.g., Claude Code):
7+
1. Add new value to AIIDEType enum
8+
2. Create _get_<ide>_hooks_dir() function with platform-specific paths
9+
3. Add entry to IDE_CONFIGS dict with IDE-specific hook event names
10+
4. Unhide --ide option in commands (install, uninstall, status)
11+
"""
12+
13+
import platform
14+
from enum import Enum
15+
from pathlib import Path
16+
from typing import NamedTuple
17+
18+
19+
class AIIDEType(str, Enum):
20+
"""Supported AI IDE types."""
21+
22+
CURSOR = 'cursor'
23+
24+
25+
class IDEConfig(NamedTuple):
26+
"""Configuration for an AI IDE."""
27+
28+
name: str
29+
hooks_dir: Path
30+
repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor')
31+
hooks_file_name: str
32+
hook_events: list[str] # List of supported hook event names for this IDE
33+
34+
35+
def _get_cursor_hooks_dir() -> Path:
36+
"""Get Cursor hooks directory based on platform."""
37+
if platform.system() == 'Darwin':
38+
return Path.home() / '.cursor'
39+
if platform.system() == 'Windows':
40+
return Path.home() / 'AppData' / 'Roaming' / 'Cursor'
41+
# Linux
42+
return Path.home() / '.config' / 'Cursor'
43+
44+
45+
# IDE-specific configurations
46+
IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
47+
AIIDEType.CURSOR: IDEConfig(
48+
name='Cursor',
49+
hooks_dir=_get_cursor_hooks_dir(),
50+
repo_hooks_subdir='.cursor',
51+
hooks_file_name='hooks.json',
52+
hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'],
53+
),
54+
}
55+
56+
# Default IDE
57+
DEFAULT_IDE = AIIDEType.CURSOR
58+
59+
# Command used in hooks
60+
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
61+
62+
63+
def get_hooks_config(ide: AIIDEType) -> dict:
64+
"""Get the hooks configuration for a specific IDE.
65+
66+
Args:
67+
ide: The AI IDE type
68+
69+
Returns:
70+
Dict with hooks configuration for the specified IDE
71+
"""
72+
config = IDE_CONFIGS[ide]
73+
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}
74+
75+
return {
76+
'version': 1,
77+
'hooks': hooks,
78+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""
2+
Hooks manager for AI guardrails.
3+
4+
Handles installation, removal, and status checking of AI IDE hooks.
5+
Supports multiple IDEs: Cursor, Claude Code (future).
6+
"""
7+
8+
import json
9+
from pathlib import Path
10+
from typing import Optional
11+
12+
from cycode.cli.apps.ai_guardrails.consts import (
13+
CYCODE_SCAN_PROMPT_COMMAND,
14+
DEFAULT_IDE,
15+
IDE_CONFIGS,
16+
AIIDEType,
17+
get_hooks_config,
18+
)
19+
from cycode.logger import get_logger
20+
21+
logger = get_logger('AI Guardrails Hooks')
22+
23+
24+
def get_hooks_path(scope: str, repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> Path:
25+
"""Get the hooks.json path for the given scope and IDE.
26+
27+
Args:
28+
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
29+
repo_path: Repository path (required if scope is 'repo')
30+
ide: The AI IDE type (default: Cursor)
31+
"""
32+
config = IDE_CONFIGS[ide]
33+
if scope == 'repo' and repo_path:
34+
return repo_path / config.repo_hooks_subdir / config.hooks_file_name
35+
return config.hooks_dir / config.hooks_file_name
36+
37+
38+
def load_hooks_file(hooks_path: Path) -> Optional[dict]:
39+
"""Load existing hooks.json file."""
40+
if not hooks_path.exists():
41+
return None
42+
try:
43+
content = hooks_path.read_text(encoding='utf-8')
44+
return json.loads(content)
45+
except Exception as e:
46+
logger.debug('Failed to load hooks file', exc_info=e)
47+
return None
48+
49+
50+
def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
51+
"""Save hooks.json file."""
52+
try:
53+
hooks_path.parent.mkdir(parents=True, exist_ok=True)
54+
hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8')
55+
return True
56+
except Exception as e:
57+
logger.error('Failed to save hooks file', exc_info=e)
58+
return False
59+
60+
61+
def is_cycode_hook_entry(entry: dict) -> bool:
62+
"""Check if a hook entry is from cycode-cli."""
63+
command = entry.get('command', '')
64+
return CYCODE_SCAN_PROMPT_COMMAND in command
65+
66+
67+
def install_hooks(
68+
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
69+
) -> tuple[bool, str]:
70+
"""
71+
Install Cycode AI guardrails hooks.
72+
73+
Args:
74+
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
75+
repo_path: Repository path (required if scope is 'repo')
76+
ide: The AI IDE type (default: Cursor)
77+
78+
Returns:
79+
Tuple of (success, message)
80+
"""
81+
hooks_path = get_hooks_path(scope, repo_path, ide)
82+
83+
# Load existing hooks or create new
84+
existing = load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}}
85+
existing.setdefault('version', 1)
86+
existing.setdefault('hooks', {})
87+
88+
# Get IDE-specific hooks configuration
89+
hooks_config = get_hooks_config(ide)
90+
91+
# Add/update Cycode hooks
92+
for event, entries in hooks_config['hooks'].items():
93+
existing['hooks'].setdefault(event, [])
94+
95+
# Remove any existing Cycode entries for this event
96+
existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]
97+
98+
# Add new Cycode entries
99+
for entry in entries:
100+
existing['hooks'][event].append(entry)
101+
102+
# Save
103+
if save_hooks_file(hooks_path, existing):
104+
return True, f'AI guardrails hooks installed: {hooks_path}'
105+
return False, f'Failed to install hooks to {hooks_path}'
106+
107+
108+
def uninstall_hooks(
109+
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
110+
) -> tuple[bool, str]:
111+
"""
112+
Remove Cycode AI guardrails hooks.
113+
114+
Args:
115+
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
116+
repo_path: Repository path (required if scope is 'repo')
117+
ide: The AI IDE type (default: Cursor)
118+
119+
Returns:
120+
Tuple of (success, message)
121+
"""
122+
hooks_path = get_hooks_path(scope, repo_path, ide)
123+
124+
existing = load_hooks_file(hooks_path)
125+
if existing is None:
126+
return True, f'No hooks file found at {hooks_path}'
127+
128+
# Remove Cycode entries from all events
129+
modified = False
130+
for event in list(existing.get('hooks', {}).keys()):
131+
original_count = len(existing['hooks'][event])
132+
existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]
133+
if len(existing['hooks'][event]) != original_count:
134+
modified = True
135+
# Remove empty event lists
136+
if not existing['hooks'][event]:
137+
del existing['hooks'][event]
138+
139+
if not modified:
140+
return True, 'No Cycode hooks found to remove'
141+
142+
# Save or delete if empty
143+
if not existing.get('hooks'):
144+
try:
145+
hooks_path.unlink()
146+
return True, f'Removed hooks file: {hooks_path}'
147+
except Exception as e:
148+
logger.debug('Failed to delete hooks file', exc_info=e)
149+
return False, f'Failed to remove hooks file: {hooks_path}'
150+
151+
if save_hooks_file(hooks_path, existing):
152+
return True, f'Cycode hooks removed from: {hooks_path}'
153+
return False, f'Failed to update hooks file: {hooks_path}'
154+
155+
156+
def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> dict:
157+
"""
158+
Get the status of AI guardrails hooks.
159+
160+
Args:
161+
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
162+
repo_path: Repository path (required if scope is 'repo')
163+
ide: The AI IDE type (default: Cursor)
164+
165+
Returns:
166+
Dict with status information
167+
"""
168+
hooks_path = get_hooks_path(scope, repo_path, ide)
169+
170+
status = {
171+
'scope': scope,
172+
'ide': ide.value,
173+
'ide_name': IDE_CONFIGS[ide].name,
174+
'hooks_path': str(hooks_path),
175+
'file_exists': hooks_path.exists(),
176+
'cycode_installed': False,
177+
'hooks': {},
178+
}
179+
180+
existing = load_hooks_file(hooks_path)
181+
if existing is None:
182+
return status
183+
184+
# Check each hook event for this IDE
185+
ide_config = IDE_CONFIGS[ide]
186+
has_cycode_hooks = False
187+
for event in ide_config.hook_events:
188+
entries = existing.get('hooks', {}).get(event, [])
189+
cycode_entries = [e for e in entries if is_cycode_hook_entry(e)]
190+
if cycode_entries:
191+
has_cycode_hooks = True
192+
status['hooks'][event] = {
193+
'total_entries': len(entries),
194+
'cycode_entries': len(cycode_entries),
195+
'enabled': len(cycode_entries) > 0,
196+
}
197+
198+
status['cycode_installed'] = has_cycode_hooks
199+
200+
return status

0 commit comments

Comments
 (0)