Skip to content

Commit bd250a4

Browse files
committed
CM-58022-cycode-guardrails-support-cursor-scan-via-hooks
1 parent 348852c commit bd250a4

33 files changed

+2625
-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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import typer
2+
3+
from cycode.cli.apps.ai_guardrails.install_command import install_command
4+
from cycode.cli.apps.ai_guardrails.status_command import status_command
5+
from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command
6+
7+
app = typer.Typer(name='ai-guardrails', no_args_is_help=True)
8+
9+
app.command(name='install', short_help='Install AI guardrails hooks for supported IDEs.')(install_command)
10+
app.command(name='uninstall', short_help='Remove AI guardrails hooks from supported IDEs.')(uninstall_command)
11+
app.command(name='status', short_help='Show AI guardrails hook installation status.')(status_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)
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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
CURSOR = 'cursor'
22+
23+
24+
class IDEConfig(NamedTuple):
25+
"""Configuration for an AI IDE."""
26+
name: str
27+
hooks_dir: Path
28+
repo_hooks_subdir: str # Subdirectory in repo for hooks (e.g., '.cursor')
29+
hooks_file_name: str
30+
hook_events: list[str] # List of supported hook event names for this IDE
31+
32+
33+
def _get_cursor_hooks_dir() -> Path:
34+
"""Get Cursor hooks directory based on platform."""
35+
if platform.system() == 'Darwin':
36+
return Path.home() / '.cursor'
37+
elif platform.system() == 'Windows':
38+
return Path.home() / 'AppData' / 'Roaming' / 'Cursor'
39+
else: # Linux
40+
return Path.home() / '.config' / 'Cursor'
41+
42+
43+
# IDE-specific configurations
44+
IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
45+
AIIDEType.CURSOR: IDEConfig(
46+
name='Cursor',
47+
hooks_dir=_get_cursor_hooks_dir(),
48+
repo_hooks_subdir='.cursor',
49+
hooks_file_name='hooks.json',
50+
hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'],
51+
),
52+
}
53+
54+
# Default IDE
55+
DEFAULT_IDE = AIIDEType.CURSOR
56+
57+
# Marker to identify Cycode hooks
58+
CYCODE_MARKER = 'cycode_guardrails'
59+
60+
# Command used in hooks
61+
CYCODE_SCAN_PROMPT_COMMAND = 'cycode scan prompt'
62+
63+
64+
def get_hooks_config(ide: AIIDEType) -> dict:
65+
"""Get the hooks configuration for a specific IDE.
66+
67+
Args:
68+
ide: The AI IDE type
69+
70+
Returns:
71+
Dict with hooks configuration for the specified IDE
72+
"""
73+
config = IDE_CONFIGS[ide]
74+
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}
75+
76+
return {
77+
'version': 1,
78+
'hooks': hooks,
79+
CYCODE_MARKER: True,
80+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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+
AIIDEType,
14+
CYCODE_MARKER,
15+
CYCODE_SCAN_PROMPT_COMMAND,
16+
DEFAULT_IDE,
17+
IDE_CONFIGS,
18+
get_hooks_config,
19+
)
20+
from cycode.logger import get_logger
21+
22+
logger = get_logger('AI Guardrails Hooks')
23+
24+
25+
def get_hooks_path(scope: str, repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE) -> Path:
26+
"""Get the hooks.json path for the given scope and IDE.
27+
28+
Args:
29+
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
30+
repo_path: Repository path (required if scope is 'repo')
31+
ide: The AI IDE type (default: Cursor)
32+
"""
33+
config = IDE_CONFIGS[ide]
34+
if scope == 'repo' and repo_path:
35+
return repo_path / config.repo_hooks_subdir / config.hooks_file_name
36+
return config.hooks_dir / config.hooks_file_name
37+
38+
39+
def load_hooks_file(hooks_path: Path) -> Optional[dict]:
40+
"""Load existing hooks.json file."""
41+
if not hooks_path.exists():
42+
return None
43+
try:
44+
content = hooks_path.read_text(encoding='utf-8')
45+
return json.loads(content)
46+
except Exception as e:
47+
logger.debug('Failed to load hooks file', exc_info=e)
48+
return None
49+
50+
51+
def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
52+
"""Save hooks.json file."""
53+
try:
54+
hooks_path.parent.mkdir(parents=True, exist_ok=True)
55+
hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8')
56+
return True
57+
except Exception as e:
58+
logger.error('Failed to save hooks file', exc_info=e)
59+
return False
60+
61+
62+
def is_cycode_hook_entry(entry: dict) -> bool:
63+
"""Check if a hook entry is from cycode-cli."""
64+
command = entry.get('command', '')
65+
return CYCODE_SCAN_PROMPT_COMMAND in command
66+
67+
68+
def install_hooks(
69+
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
70+
) -> tuple[bool, str]:
71+
"""
72+
Install Cycode AI guardrails hooks.
73+
74+
Args:
75+
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
76+
repo_path: Repository path (required if scope is 'repo')
77+
ide: The AI IDE type (default: Cursor)
78+
79+
Returns:
80+
Tuple of (success, message)
81+
"""
82+
hooks_path = get_hooks_path(scope, repo_path, ide)
83+
84+
# Load existing hooks or create new
85+
existing = load_hooks_file(hooks_path) or {'version': 1, 'hooks': {}}
86+
existing.setdefault('version', 1)
87+
existing.setdefault('hooks', {})
88+
89+
# Get IDE-specific hooks configuration
90+
hooks_config = get_hooks_config(ide)
91+
92+
# Add/update Cycode hooks
93+
for event, entries in hooks_config['hooks'].items():
94+
existing['hooks'].setdefault(event, [])
95+
96+
# Remove any existing Cycode entries for this event
97+
existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]
98+
99+
# Add new Cycode entries
100+
for entry in entries:
101+
existing['hooks'][event].append(entry)
102+
103+
# Add marker
104+
existing[CYCODE_MARKER] = True
105+
106+
# Save
107+
if save_hooks_file(hooks_path, existing):
108+
return True, f'AI guardrails hooks installed: {hooks_path}'
109+
return False, f'Failed to install hooks to {hooks_path}'
110+
111+
112+
def uninstall_hooks(
113+
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
114+
) -> tuple[bool, str]:
115+
"""
116+
Remove Cycode AI guardrails hooks.
117+
118+
Args:
119+
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
120+
repo_path: Repository path (required if scope is 'repo')
121+
ide: The AI IDE type (default: Cursor)
122+
123+
Returns:
124+
Tuple of (success, message)
125+
"""
126+
hooks_path = get_hooks_path(scope, repo_path, ide)
127+
128+
existing = load_hooks_file(hooks_path)
129+
if existing is None:
130+
return True, f'No hooks file found at {hooks_path}'
131+
132+
# Remove Cycode entries from all events
133+
modified = False
134+
for event in list(existing.get('hooks', {}).keys()):
135+
original_count = len(existing['hooks'][event])
136+
existing['hooks'][event] = [e for e in existing['hooks'][event] if not is_cycode_hook_entry(e)]
137+
if len(existing['hooks'][event]) != original_count:
138+
modified = True
139+
# Remove empty event lists
140+
if not existing['hooks'][event]:
141+
del existing['hooks'][event]
142+
143+
# Remove marker
144+
if CYCODE_MARKER in existing:
145+
del existing[CYCODE_MARKER]
146+
modified = True
147+
148+
if not modified:
149+
return True, 'No Cycode hooks found to remove'
150+
151+
# Save or delete if empty
152+
if not existing.get('hooks'):
153+
try:
154+
hooks_path.unlink()
155+
return True, f'Removed hooks file: {hooks_path}'
156+
except Exception as e:
157+
logger.debug('Failed to delete hooks file', exc_info=e)
158+
return False, f'Failed to remove hooks file: {hooks_path}'
159+
160+
if save_hooks_file(hooks_path, existing):
161+
return True, f'Cycode hooks removed from: {hooks_path}'
162+
return False, f'Failed to update hooks file: {hooks_path}'
163+
164+
165+
def get_hooks_status(
166+
scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE
167+
) -> dict:
168+
"""
169+
Get the status of AI guardrails hooks.
170+
171+
Args:
172+
scope: 'user' for user-level hooks, 'repo' for repository-level hooks
173+
repo_path: Repository path (required if scope is 'repo')
174+
ide: The AI IDE type (default: Cursor)
175+
176+
Returns:
177+
Dict with status information
178+
"""
179+
hooks_path = get_hooks_path(scope, repo_path, ide)
180+
181+
status = {
182+
'scope': scope,
183+
'ide': ide.value,
184+
'ide_name': IDE_CONFIGS[ide].name,
185+
'hooks_path': str(hooks_path),
186+
'file_exists': hooks_path.exists(),
187+
'cycode_installed': False,
188+
'hooks': {},
189+
}
190+
191+
existing = load_hooks_file(hooks_path)
192+
if existing is None:
193+
return status
194+
195+
status['cycode_installed'] = existing.get(CYCODE_MARKER, False)
196+
197+
# Check each hook event for this IDE
198+
ide_config = IDE_CONFIGS[ide]
199+
for event in ide_config.hook_events:
200+
entries = existing.get('hooks', {}).get(event, [])
201+
cycode_entries = [e for e in entries if is_cycode_hook_entry(e)]
202+
status['hooks'][event] = {
203+
'total_entries': len(entries),
204+
'cycode_entries': len(cycode_entries),
205+
'enabled': len(cycode_entries) > 0,
206+
}
207+
208+
return status

0 commit comments

Comments
 (0)