cycode 3.9.0__py3-none-any.whl → 3.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cycode/__init__.py +1 -1
- cycode/cli/apps/ai_guardrails/command_utils.py +7 -5
- cycode/cli/apps/ai_guardrails/consts.py +68 -13
- cycode/cli/apps/ai_guardrails/hooks_manager.py +29 -3
- cycode/cli/apps/ai_guardrails/install_command.py +29 -10
- cycode/cli/apps/ai_guardrails/scan/handlers.py +54 -35
- cycode/cli/apps/ai_guardrails/scan/payload.py +203 -7
- cycode/cli/apps/ai_guardrails/scan/response_builders.py +57 -5
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +12 -1
- cycode/cli/apps/ai_guardrails/scan/types.py +11 -0
- cycode/cli/apps/ai_guardrails/status_command.py +32 -23
- cycode/cli/apps/ai_guardrails/uninstall_command.py +29 -10
- cycode/cyclient/ai_security_manager_client.py +2 -0
- {cycode-3.9.0.dist-info → cycode-3.9.1.dist-info}/METADATA +1 -1
- {cycode-3.9.0.dist-info → cycode-3.9.1.dist-info}/RECORD +18 -18
- {cycode-3.9.0.dist-info → cycode-3.9.1.dist-info}/WHEEL +0 -0
- {cycode-3.9.0.dist-info → cycode-3.9.1.dist-info}/entry_points.txt +0 -0
- {cycode-3.9.0.dist-info → cycode-3.9.1.dist-info}/licenses/LICENCE +0 -0
cycode/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '3.9.
|
|
1
|
+
__version__ = '3.9.1' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag
|
|
@@ -12,24 +12,26 @@ from cycode.cli.apps.ai_guardrails.consts import AIIDEType
|
|
|
12
12
|
console = Console()
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def validate_and_parse_ide(ide: str) -> AIIDEType:
|
|
16
|
-
"""Validate IDE parameter
|
|
15
|
+
def validate_and_parse_ide(ide: str) -> Optional[AIIDEType]:
|
|
16
|
+
"""Validate IDE parameter, returning None for 'all'.
|
|
17
17
|
|
|
18
18
|
Args:
|
|
19
|
-
ide: IDE name string (e.g., 'cursor')
|
|
19
|
+
ide: IDE name string (e.g., 'cursor', 'claude-code', 'all')
|
|
20
20
|
|
|
21
21
|
Returns:
|
|
22
|
-
AIIDEType enum value
|
|
22
|
+
AIIDEType enum value, or None if 'all' was specified
|
|
23
23
|
|
|
24
24
|
Raises:
|
|
25
25
|
typer.Exit: If IDE is invalid
|
|
26
26
|
"""
|
|
27
|
+
if ide.lower() == 'all':
|
|
28
|
+
return None
|
|
27
29
|
try:
|
|
28
30
|
return AIIDEType(ide.lower())
|
|
29
31
|
except ValueError:
|
|
30
32
|
valid_ides = ', '.join([ide_type.value for ide_type in AIIDEType])
|
|
31
33
|
console.print(
|
|
32
|
-
f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}',
|
|
34
|
+
f'[red]Error:[/] Invalid IDE "{ide}". Supported IDEs: {valid_ides}, all',
|
|
33
35
|
style='bold red',
|
|
34
36
|
)
|
|
35
37
|
raise typer.Exit(1) from None
|
|
@@ -2,12 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Currently supports:
|
|
4
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)
|
|
5
|
+
- Claude Code
|
|
11
6
|
"""
|
|
12
7
|
|
|
13
8
|
import platform
|
|
@@ -20,6 +15,14 @@ class AIIDEType(str, Enum):
|
|
|
20
15
|
"""Supported AI IDE types."""
|
|
21
16
|
|
|
22
17
|
CURSOR = 'cursor'
|
|
18
|
+
CLAUDE_CODE = 'claude-code'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PolicyMode(str, Enum):
|
|
22
|
+
"""Policy enforcement mode for global mode and per-feature actions."""
|
|
23
|
+
|
|
24
|
+
BLOCK = 'block'
|
|
25
|
+
WARN = 'warn'
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
class IDEConfig(NamedTuple):
|
|
@@ -42,6 +45,14 @@ def _get_cursor_hooks_dir() -> Path:
|
|
|
42
45
|
return Path.home() / '.config' / 'Cursor'
|
|
43
46
|
|
|
44
47
|
|
|
48
|
+
def _get_claude_code_hooks_dir() -> Path:
|
|
49
|
+
"""Get Claude Code hooks directory.
|
|
50
|
+
|
|
51
|
+
Claude Code uses ~/.claude on all platforms.
|
|
52
|
+
"""
|
|
53
|
+
return Path.home() / '.claude'
|
|
54
|
+
|
|
55
|
+
|
|
45
56
|
# IDE-specific configurations
|
|
46
57
|
IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
|
|
47
58
|
AIIDEType.CURSOR: IDEConfig(
|
|
@@ -51,6 +62,13 @@ IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
|
|
|
51
62
|
hooks_file_name='hooks.json',
|
|
52
63
|
hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'],
|
|
53
64
|
),
|
|
65
|
+
AIIDEType.CLAUDE_CODE: IDEConfig(
|
|
66
|
+
name='Claude Code',
|
|
67
|
+
hooks_dir=_get_claude_code_hooks_dir(),
|
|
68
|
+
repo_hooks_subdir='.claude',
|
|
69
|
+
hooks_file_name='settings.json',
|
|
70
|
+
hook_events=['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'],
|
|
71
|
+
),
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
# Default IDE
|
|
@@ -60,6 +78,47 @@ DEFAULT_IDE = AIIDEType.CURSOR
|
|
|
60
78
|
CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan'
|
|
61
79
|
|
|
62
80
|
|
|
81
|
+
def _get_cursor_hooks_config() -> dict:
|
|
82
|
+
"""Get Cursor-specific hooks configuration."""
|
|
83
|
+
config = IDE_CONFIGS[AIIDEType.CURSOR]
|
|
84
|
+
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
'version': 1,
|
|
88
|
+
'hooks': hooks,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _get_claude_code_hooks_config() -> dict:
|
|
93
|
+
"""Get Claude Code-specific hooks configuration.
|
|
94
|
+
|
|
95
|
+
Claude Code uses a different hook format with nested structure:
|
|
96
|
+
- hooks are arrays of objects with 'hooks' containing command arrays
|
|
97
|
+
- PreToolUse uses 'matcher' field to specify which tools to intercept
|
|
98
|
+
"""
|
|
99
|
+
command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
'hooks': {
|
|
103
|
+
'UserPromptSubmit': [
|
|
104
|
+
{
|
|
105
|
+
'hooks': [{'type': 'command', 'command': command}],
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
'PreToolUse': [
|
|
109
|
+
{
|
|
110
|
+
'matcher': 'Read',
|
|
111
|
+
'hooks': [{'type': 'command', 'command': command}],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
'matcher': 'mcp__.*',
|
|
115
|
+
'hooks': [{'type': 'command', 'command': command}],
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
63
122
|
def get_hooks_config(ide: AIIDEType) -> dict:
|
|
64
123
|
"""Get the hooks configuration for a specific IDE.
|
|
65
124
|
|
|
@@ -69,10 +128,6 @@ def get_hooks_config(ide: AIIDEType) -> dict:
|
|
|
69
128
|
Returns:
|
|
70
129
|
Dict with hooks configuration for the specified IDE
|
|
71
130
|
"""
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
'version': 1,
|
|
77
|
-
'hooks': hooks,
|
|
78
|
-
}
|
|
131
|
+
if ide == AIIDEType.CLAUDE_CODE:
|
|
132
|
+
return _get_claude_code_hooks_config()
|
|
133
|
+
return _get_cursor_hooks_config()
|
|
@@ -59,9 +59,27 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def is_cycode_hook_entry(entry: dict) -> bool:
|
|
62
|
-
"""Check if a hook entry is from cycode-cli.
|
|
62
|
+
"""Check if a hook entry is from cycode-cli.
|
|
63
|
+
|
|
64
|
+
Handles both Cursor format (flat) and Claude Code format (nested).
|
|
65
|
+
|
|
66
|
+
Cursor format: {"command": "cycode ai-guardrails scan"}
|
|
67
|
+
Claude Code format: {"hooks": [{"type": "command", "command": "cycode ai-guardrails scan --ide claude-code"}]}
|
|
68
|
+
"""
|
|
69
|
+
# Check Cursor format (flat command)
|
|
63
70
|
command = entry.get('command', '')
|
|
64
|
-
|
|
71
|
+
if CYCODE_SCAN_PROMPT_COMMAND in command:
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
# Check Claude Code format (nested hooks array)
|
|
75
|
+
hooks = entry.get('hooks', [])
|
|
76
|
+
for hook in hooks:
|
|
77
|
+
if isinstance(hook, dict):
|
|
78
|
+
hook_command = hook.get('command', '')
|
|
79
|
+
if CYCODE_SCAN_PROMPT_COMMAND in hook_command:
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
return False
|
|
65
83
|
|
|
66
84
|
|
|
67
85
|
def install_hooks(
|
|
@@ -185,7 +203,15 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide:
|
|
|
185
203
|
ide_config = IDE_CONFIGS[ide]
|
|
186
204
|
has_cycode_hooks = False
|
|
187
205
|
for event in ide_config.hook_events:
|
|
188
|
-
|
|
206
|
+
# Handle event:matcher format
|
|
207
|
+
if ':' in event:
|
|
208
|
+
actual_event, matcher_prefix = event.split(':', 1)
|
|
209
|
+
all_entries = existing.get('hooks', {}).get(actual_event, [])
|
|
210
|
+
# Filter entries by matcher
|
|
211
|
+
entries = [e for e in all_entries if e.get('matcher', '').startswith(matcher_prefix)]
|
|
212
|
+
else:
|
|
213
|
+
entries = existing.get('hooks', {}).get(event, [])
|
|
214
|
+
|
|
189
215
|
cycode_entries = [e for e in entries if is_cycode_hook_entry(e)]
|
|
190
216
|
if cycode_entries:
|
|
191
217
|
has_cycode_hooks = True
|
|
@@ -11,7 +11,7 @@ from cycode.cli.apps.ai_guardrails.command_utils import (
|
|
|
11
11
|
validate_and_parse_ide,
|
|
12
12
|
validate_scope,
|
|
13
13
|
)
|
|
14
|
-
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS
|
|
14
|
+
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType
|
|
15
15
|
from cycode.cli.apps.ai_guardrails.hooks_manager import install_hooks
|
|
16
16
|
from cycode.cli.utils.sentry import add_breadcrumb
|
|
17
17
|
|
|
@@ -30,9 +30,9 @@ def install_command(
|
|
|
30
30
|
str,
|
|
31
31
|
typer.Option(
|
|
32
32
|
'--ide',
|
|
33
|
-
help='IDE to install hooks for (e.g., "cursor"). Defaults to cursor.',
|
|
33
|
+
help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.',
|
|
34
34
|
),
|
|
35
|
-
] =
|
|
35
|
+
] = AIIDEType.CURSOR,
|
|
36
36
|
repo_path: Annotated[
|
|
37
37
|
Optional[Path],
|
|
38
38
|
typer.Option(
|
|
@@ -54,6 +54,7 @@ def install_command(
|
|
|
54
54
|
cycode ai-guardrails install # Install for all projects (user scope)
|
|
55
55
|
cycode ai-guardrails install --scope repo # Install for current repo only
|
|
56
56
|
cycode ai-guardrails install --ide cursor # Install for Cursor IDE
|
|
57
|
+
cycode ai-guardrails install --ide all # Install for all supported IDEs
|
|
57
58
|
cycode ai-guardrails install --scope repo --repo-path /path/to/repo
|
|
58
59
|
"""
|
|
59
60
|
add_breadcrumb('ai-guardrails-install')
|
|
@@ -62,17 +63,35 @@ def install_command(
|
|
|
62
63
|
validate_scope(scope)
|
|
63
64
|
repo_path = resolve_repo_path(scope, repo_path)
|
|
64
65
|
ide_type = validate_and_parse_ide(ide)
|
|
65
|
-
ide_name = IDE_CONFIGS[ide_type].name
|
|
66
|
-
success, message = install_hooks(scope, repo_path, ide=ide_type)
|
|
67
66
|
|
|
68
|
-
if
|
|
69
|
-
|
|
67
|
+
ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type]
|
|
68
|
+
|
|
69
|
+
results: list[tuple[str, bool, str]] = []
|
|
70
|
+
for current_ide in ides_to_install:
|
|
71
|
+
ide_name = IDE_CONFIGS[current_ide].name
|
|
72
|
+
success, message = install_hooks(scope, repo_path, ide=current_ide)
|
|
73
|
+
results.append((ide_name, success, message))
|
|
74
|
+
|
|
75
|
+
# Report results for each IDE
|
|
76
|
+
any_success = False
|
|
77
|
+
all_success = True
|
|
78
|
+
for _ide_name, success, message in results:
|
|
79
|
+
if success:
|
|
80
|
+
console.print(f'[green]✓[/] {message}')
|
|
81
|
+
any_success = True
|
|
82
|
+
else:
|
|
83
|
+
console.print(f'[red]✗[/] {message}', style='bold red')
|
|
84
|
+
all_success = False
|
|
85
|
+
|
|
86
|
+
if any_success:
|
|
70
87
|
console.print()
|
|
71
88
|
console.print('[bold]Next steps:[/]')
|
|
72
|
-
|
|
89
|
+
successful_ides = [name for name, success, _ in results if success]
|
|
90
|
+
ide_list = ', '.join(successful_ides)
|
|
91
|
+
console.print(f'1. Restart {ide_list} to activate the hooks')
|
|
73
92
|
console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml')
|
|
74
93
|
console.print()
|
|
75
94
|
console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]')
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
|
|
96
|
+
if not all_success:
|
|
78
97
|
raise typer.Exit(1)
|
|
@@ -13,6 +13,7 @@ from typing import Callable, Optional
|
|
|
13
13
|
|
|
14
14
|
import typer
|
|
15
15
|
|
|
16
|
+
from cycode.cli.apps.ai_guardrails.consts import PolicyMode
|
|
16
17
|
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
17
18
|
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
|
|
18
19
|
from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder
|
|
@@ -46,7 +47,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
46
47
|
ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
|
|
47
48
|
return response_builder.allow_prompt()
|
|
48
49
|
|
|
49
|
-
mode = get_policy_value(policy, 'mode', default=
|
|
50
|
+
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
|
|
50
51
|
prompt = payload.prompt or ''
|
|
51
52
|
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
|
|
52
53
|
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
|
|
@@ -55,29 +56,26 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
55
56
|
scan_id = None
|
|
56
57
|
block_reason = None
|
|
57
58
|
outcome = AIHookOutcome.ALLOWED
|
|
59
|
+
error_message = None
|
|
58
60
|
|
|
59
61
|
try:
|
|
60
62
|
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
|
|
61
63
|
|
|
62
|
-
if
|
|
63
|
-
violation_summary
|
|
64
|
-
and get_policy_value(prompt_config, 'action', default='block') == 'block'
|
|
65
|
-
and mode == 'block'
|
|
66
|
-
):
|
|
67
|
-
outcome = AIHookOutcome.BLOCKED
|
|
64
|
+
if violation_summary:
|
|
68
65
|
block_reason = BlockReason.SECRETS_IN_PROMPT
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return
|
|
66
|
+
action = get_policy_value(prompt_config, 'action', default=PolicyMode.BLOCK)
|
|
67
|
+
if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK:
|
|
68
|
+
outcome = AIHookOutcome.BLOCKED
|
|
69
|
+
user_message = f'{violation_summary}. Remove secrets before sending.'
|
|
70
|
+
return response_builder.deny_prompt(user_message)
|
|
71
|
+
outcome = AIHookOutcome.WARNED
|
|
72
|
+
return response_builder.allow_prompt()
|
|
76
73
|
except Exception as e:
|
|
77
74
|
outcome = (
|
|
78
75
|
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
79
76
|
)
|
|
80
|
-
block_reason = BlockReason.SCAN_FAILURE
|
|
77
|
+
block_reason = BlockReason.SCAN_FAILURE
|
|
78
|
+
error_message = str(e)
|
|
81
79
|
raise e
|
|
82
80
|
finally:
|
|
83
81
|
ai_client.create_event(
|
|
@@ -86,6 +84,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
86
84
|
outcome,
|
|
87
85
|
scan_id=scan_id,
|
|
88
86
|
block_reason=block_reason,
|
|
87
|
+
error_message=error_message,
|
|
89
88
|
)
|
|
90
89
|
|
|
91
90
|
|
|
@@ -106,38 +105,53 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
106
105
|
ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
|
|
107
106
|
return response_builder.allow_permission()
|
|
108
107
|
|
|
109
|
-
mode = get_policy_value(policy, 'mode', default=
|
|
108
|
+
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
|
|
110
109
|
file_path = payload.file_path or ''
|
|
111
|
-
action = get_policy_value(file_read_config, 'action', default=
|
|
110
|
+
action = get_policy_value(file_read_config, 'action', default=PolicyMode.BLOCK)
|
|
112
111
|
|
|
113
112
|
scan_id = None
|
|
114
113
|
block_reason = None
|
|
115
114
|
outcome = AIHookOutcome.ALLOWED
|
|
115
|
+
error_message = None
|
|
116
116
|
|
|
117
117
|
try:
|
|
118
118
|
# Check path-based denylist first
|
|
119
|
-
if is_denied_path(file_path, policy)
|
|
120
|
-
outcome = AIHookOutcome.BLOCKED
|
|
119
|
+
if is_denied_path(file_path, policy):
|
|
121
120
|
block_reason = BlockReason.SENSITIVE_PATH
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
|
|
122
|
+
outcome = AIHookOutcome.BLOCKED
|
|
123
|
+
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
|
|
124
|
+
return response_builder.deny_permission(
|
|
125
|
+
user_message,
|
|
126
|
+
'This file path is classified as sensitive; do not read/send it to the model.',
|
|
127
|
+
)
|
|
128
|
+
# Warn mode - ask user for permission
|
|
129
|
+
outcome = AIHookOutcome.WARNED
|
|
130
|
+
user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?'
|
|
131
|
+
return response_builder.ask_permission(
|
|
124
132
|
user_message,
|
|
125
|
-
'This file path is classified as sensitive;
|
|
133
|
+
'This file path is classified as sensitive; proceed with caution.',
|
|
126
134
|
)
|
|
127
135
|
|
|
128
136
|
# Scan file content if enabled
|
|
129
137
|
if get_policy_value(file_read_config, 'scan_content', default=True):
|
|
130
138
|
violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy)
|
|
131
|
-
if violation_summary
|
|
132
|
-
outcome = AIHookOutcome.BLOCKED
|
|
139
|
+
if violation_summary:
|
|
133
140
|
block_reason = BlockReason.SECRETS_IN_FILE
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
|
|
142
|
+
outcome = AIHookOutcome.BLOCKED
|
|
143
|
+
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
|
|
144
|
+
return response_builder.deny_permission(
|
|
145
|
+
user_message,
|
|
146
|
+
'Secrets detected; do not send this file to the model.',
|
|
147
|
+
)
|
|
148
|
+
# Warn mode - ask user for permission
|
|
149
|
+
outcome = AIHookOutcome.WARNED
|
|
150
|
+
user_message = f'Cycode detected secrets in {file_path}. {violation_summary}'
|
|
151
|
+
return response_builder.ask_permission(
|
|
136
152
|
user_message,
|
|
137
|
-
'
|
|
153
|
+
'Possible secrets detected; proceed with caution.',
|
|
138
154
|
)
|
|
139
|
-
if violation_summary:
|
|
140
|
-
outcome = AIHookOutcome.WARNED
|
|
141
155
|
return response_builder.allow_permission()
|
|
142
156
|
|
|
143
157
|
return response_builder.allow_permission()
|
|
@@ -145,7 +159,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
145
159
|
outcome = (
|
|
146
160
|
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
147
161
|
)
|
|
148
|
-
block_reason = BlockReason.SCAN_FAILURE
|
|
162
|
+
block_reason = BlockReason.SCAN_FAILURE
|
|
163
|
+
error_message = str(e)
|
|
149
164
|
raise e
|
|
150
165
|
finally:
|
|
151
166
|
ai_client.create_event(
|
|
@@ -154,6 +169,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
154
169
|
outcome,
|
|
155
170
|
scan_id=scan_id,
|
|
156
171
|
block_reason=block_reason,
|
|
172
|
+
error_message=error_message,
|
|
157
173
|
)
|
|
158
174
|
|
|
159
175
|
|
|
@@ -175,26 +191,27 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
175
191
|
ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED)
|
|
176
192
|
return response_builder.allow_permission()
|
|
177
193
|
|
|
178
|
-
mode = get_policy_value(policy, 'mode', default=
|
|
194
|
+
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
|
|
179
195
|
tool = payload.mcp_tool_name or 'unknown'
|
|
180
196
|
args = payload.mcp_arguments or {}
|
|
181
197
|
args_text = args if isinstance(args, str) else json.dumps(args)
|
|
182
198
|
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
|
|
183
199
|
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
|
|
184
200
|
clipped = truncate_utf8(args_text, max_bytes)
|
|
185
|
-
action = get_policy_value(mcp_config, 'action', default=
|
|
201
|
+
action = get_policy_value(mcp_config, 'action', default=PolicyMode.BLOCK)
|
|
186
202
|
|
|
187
203
|
scan_id = None
|
|
188
204
|
block_reason = None
|
|
189
205
|
outcome = AIHookOutcome.ALLOWED
|
|
206
|
+
error_message = None
|
|
190
207
|
|
|
191
208
|
try:
|
|
192
209
|
if get_policy_value(mcp_config, 'scan_arguments', default=True):
|
|
193
210
|
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
|
|
194
211
|
if violation_summary:
|
|
195
|
-
|
|
212
|
+
block_reason = BlockReason.SECRETS_IN_MCP_ARGS
|
|
213
|
+
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
|
|
196
214
|
outcome = AIHookOutcome.BLOCKED
|
|
197
|
-
block_reason = BlockReason.SECRETS_IN_MCP_ARGS
|
|
198
215
|
user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}'
|
|
199
216
|
return response_builder.deny_permission(
|
|
200
217
|
user_message,
|
|
@@ -211,7 +228,8 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
211
228
|
outcome = (
|
|
212
229
|
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
213
230
|
)
|
|
214
|
-
block_reason = BlockReason.SCAN_FAILURE
|
|
231
|
+
block_reason = BlockReason.SCAN_FAILURE
|
|
232
|
+
error_message = str(e)
|
|
215
233
|
raise e
|
|
216
234
|
finally:
|
|
217
235
|
ai_client.create_event(
|
|
@@ -220,6 +238,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
220
238
|
outcome,
|
|
221
239
|
scan_id=scan_id,
|
|
222
240
|
block_reason=block_reason,
|
|
241
|
+
error_message=error_message,
|
|
223
242
|
)
|
|
224
243
|
|
|
225
244
|
|
|
@@ -1,9 +1,120 @@
|
|
|
1
1
|
"""Unified payload object for AI hook events from different tools."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterator
|
|
3
5
|
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
4
7
|
from typing import Optional
|
|
5
8
|
|
|
6
|
-
from cycode.cli.apps.ai_guardrails.
|
|
9
|
+
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
|
|
10
|
+
from cycode.cli.apps.ai_guardrails.scan.types import (
|
|
11
|
+
CLAUDE_CODE_EVENT_MAPPING,
|
|
12
|
+
CLAUDE_CODE_EVENT_NAMES,
|
|
13
|
+
CURSOR_EVENT_MAPPING,
|
|
14
|
+
CURSOR_EVENT_NAMES,
|
|
15
|
+
AiHookEventType,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]:
|
|
20
|
+
"""Read a file line by line from the end without loading entire file into memory.
|
|
21
|
+
|
|
22
|
+
Yields lines in reverse order (last line first).
|
|
23
|
+
"""
|
|
24
|
+
with path.open('rb') as f:
|
|
25
|
+
f.seek(0, 2) # Seek to end
|
|
26
|
+
file_size = f.tell()
|
|
27
|
+
if file_size == 0:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
remaining = file_size
|
|
31
|
+
buffer = b''
|
|
32
|
+
|
|
33
|
+
while remaining > 0:
|
|
34
|
+
# Read a chunk from the end
|
|
35
|
+
read_size = min(buf_size, remaining)
|
|
36
|
+
remaining -= read_size
|
|
37
|
+
f.seek(remaining)
|
|
38
|
+
chunk = f.read(read_size)
|
|
39
|
+
buffer = chunk + buffer
|
|
40
|
+
|
|
41
|
+
# Yield complete lines from buffer
|
|
42
|
+
while b'\n' in buffer:
|
|
43
|
+
# Find the last newline
|
|
44
|
+
newline_pos = buffer.rfind(b'\n')
|
|
45
|
+
if newline_pos == len(buffer) - 1:
|
|
46
|
+
# Trailing newline, look for previous one
|
|
47
|
+
newline_pos = buffer.rfind(b'\n', 0, newline_pos)
|
|
48
|
+
if newline_pos == -1:
|
|
49
|
+
break
|
|
50
|
+
# Yield the line after this newline
|
|
51
|
+
line = buffer[newline_pos + 1 :]
|
|
52
|
+
buffer = buffer[: newline_pos + 1]
|
|
53
|
+
if line.strip():
|
|
54
|
+
yield line.decode('utf-8', errors='replace')
|
|
55
|
+
|
|
56
|
+
# Yield any remaining content as the first line of the file
|
|
57
|
+
if buffer.strip():
|
|
58
|
+
yield buffer.decode('utf-8', errors='replace')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_model(entry: dict) -> Optional[str]:
|
|
62
|
+
"""Extract model from a transcript entry (top level or nested in message)."""
|
|
63
|
+
return entry.get('model') or (entry.get('message') or {}).get('model')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_generation_id(entry: dict) -> Optional[str]:
|
|
67
|
+
"""Extract generation ID from a user-type transcript entry."""
|
|
68
|
+
if entry.get('type') == 'user':
|
|
69
|
+
return entry.get('uuid')
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_from_claude_transcript(
|
|
74
|
+
transcript_path: str,
|
|
75
|
+
) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
|
76
|
+
"""Extract IDE version, model, and latest generation ID from Claude Code transcript file.
|
|
77
|
+
|
|
78
|
+
The transcript is a JSONL file where each line is a JSON object.
|
|
79
|
+
We look for 'version' (IDE version), 'model', and 'uuid' (generation ID) fields.
|
|
80
|
+
The generation_id is the UUID of the latest 'user' type message.
|
|
81
|
+
|
|
82
|
+
Scans from end to start since latest entries are at the end.
|
|
83
|
+
Uses reverse reading to avoid loading entire file into memory.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Tuple of (ide_version, model, generation_id), any may be None if not found.
|
|
87
|
+
"""
|
|
88
|
+
if not transcript_path:
|
|
89
|
+
return None, None, None
|
|
90
|
+
|
|
91
|
+
path = Path(transcript_path)
|
|
92
|
+
if not path.exists():
|
|
93
|
+
return None, None, None
|
|
94
|
+
|
|
95
|
+
ide_version = None
|
|
96
|
+
model = None
|
|
97
|
+
generation_id = None
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
for line in _reverse_readline(path):
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if not line:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
entry = json.loads(line)
|
|
106
|
+
ide_version = ide_version or entry.get('version')
|
|
107
|
+
model = model or _extract_model(entry)
|
|
108
|
+
generation_id = generation_id or _extract_generation_id(entry)
|
|
109
|
+
|
|
110
|
+
if ide_version and model and generation_id:
|
|
111
|
+
break
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
continue
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
return ide_version, model, generation_id
|
|
7
118
|
|
|
8
119
|
|
|
9
120
|
@dataclass
|
|
@@ -18,7 +129,7 @@ class AIHookPayload:
|
|
|
18
129
|
# User and IDE information
|
|
19
130
|
ide_user_email: Optional[str] = None
|
|
20
131
|
model: Optional[str] = None
|
|
21
|
-
ide_provider: str = None # e.g., 'cursor', 'claude-code'
|
|
132
|
+
ide_provider: str = None # AIIDEType value (e.g., 'cursor', 'claude-code')
|
|
22
133
|
ide_version: Optional[str] = None
|
|
23
134
|
|
|
24
135
|
# Event-specific data
|
|
@@ -44,7 +155,7 @@ class AIHookPayload:
|
|
|
44
155
|
generation_id=payload.get('generation_id'),
|
|
45
156
|
ide_user_email=payload.get('user_email'),
|
|
46
157
|
model=payload.get('model'),
|
|
47
|
-
ide_provider=
|
|
158
|
+
ide_provider=AIIDEType.CURSOR,
|
|
48
159
|
ide_version=payload.get('cursor_version'),
|
|
49
160
|
prompt=payload.get('prompt', ''),
|
|
50
161
|
file_path=payload.get('file_path') or payload.get('path'),
|
|
@@ -54,12 +165,95 @@ class AIHookPayload:
|
|
|
54
165
|
)
|
|
55
166
|
|
|
56
167
|
@classmethod
|
|
57
|
-
def
|
|
168
|
+
def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload':
|
|
169
|
+
"""Create AIHookPayload from Claude Code IDE payload.
|
|
170
|
+
|
|
171
|
+
Claude Code has a different structure:
|
|
172
|
+
- hook_event_name: 'UserPromptSubmit' or 'PreToolUse'
|
|
173
|
+
- For PreToolUse: tool_name determines if it's file read ('Read') or MCP ('mcp__*')
|
|
174
|
+
- tool_input contains tool arguments (e.g., file_path for Read tool)
|
|
175
|
+
- transcript_path points to JSONL file with version and model info
|
|
176
|
+
"""
|
|
177
|
+
hook_event_name = payload.get('hook_event_name', '')
|
|
178
|
+
tool_name = payload.get('tool_name', '')
|
|
179
|
+
tool_input = payload.get('tool_input')
|
|
180
|
+
|
|
181
|
+
if hook_event_name == 'UserPromptSubmit':
|
|
182
|
+
canonical_event = AiHookEventType.PROMPT
|
|
183
|
+
elif hook_event_name == 'PreToolUse':
|
|
184
|
+
canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION
|
|
185
|
+
else:
|
|
186
|
+
# Unknown event, use the raw event name
|
|
187
|
+
canonical_event = CLAUDE_CODE_EVENT_MAPPING.get(hook_event_name, hook_event_name)
|
|
188
|
+
|
|
189
|
+
# Extract file_path from tool_input for Read tool
|
|
190
|
+
file_path = None
|
|
191
|
+
if tool_name == 'Read' and isinstance(tool_input, dict):
|
|
192
|
+
file_path = tool_input.get('file_path')
|
|
193
|
+
|
|
194
|
+
# For MCP tools, the entire tool_input is the arguments
|
|
195
|
+
mcp_arguments = tool_input if tool_name.startswith('mcp__') else None
|
|
196
|
+
|
|
197
|
+
# Extract MCP server and tool name from tool_name (format: mcp__<server>__<tool>)
|
|
198
|
+
mcp_server_name = None
|
|
199
|
+
mcp_tool_name = None
|
|
200
|
+
if tool_name.startswith('mcp__'):
|
|
201
|
+
parts = tool_name.split('__')
|
|
202
|
+
if len(parts) >= 2:
|
|
203
|
+
mcp_server_name = parts[1]
|
|
204
|
+
if len(parts) >= 3:
|
|
205
|
+
mcp_tool_name = parts[2]
|
|
206
|
+
|
|
207
|
+
# Extract IDE version, model, and generation ID from transcript file
|
|
208
|
+
ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path'))
|
|
209
|
+
|
|
210
|
+
return cls(
|
|
211
|
+
event_name=canonical_event,
|
|
212
|
+
conversation_id=payload.get('session_id'),
|
|
213
|
+
generation_id=generation_id,
|
|
214
|
+
ide_user_email=None, # Claude Code doesn't provide this in hook payload
|
|
215
|
+
model=model,
|
|
216
|
+
ide_provider=AIIDEType.CLAUDE_CODE,
|
|
217
|
+
ide_version=ide_version,
|
|
218
|
+
prompt=payload.get('prompt', ''),
|
|
219
|
+
file_path=file_path,
|
|
220
|
+
mcp_server_name=mcp_server_name,
|
|
221
|
+
mcp_tool_name=mcp_tool_name,
|
|
222
|
+
mcp_arguments=mcp_arguments,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def is_payload_for_ide(payload: dict, ide: str) -> bool:
|
|
227
|
+
"""Check if the payload's event name matches the expected IDE.
|
|
228
|
+
|
|
229
|
+
This prevents double-processing when Cursor reads Claude Code hooks
|
|
230
|
+
or vice versa. If the payload's hook_event_name doesn't match the
|
|
231
|
+
expected IDE's event names, we should skip processing.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
payload: The raw payload from the IDE
|
|
235
|
+
ide: The IDE name or AIIDEType enum value
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
True if the payload matches the IDE, False otherwise.
|
|
239
|
+
"""
|
|
240
|
+
hook_event_name = payload.get('hook_event_name', '')
|
|
241
|
+
|
|
242
|
+
if ide == AIIDEType.CLAUDE_CODE:
|
|
243
|
+
return hook_event_name in CLAUDE_CODE_EVENT_NAMES
|
|
244
|
+
if ide == AIIDEType.CURSOR:
|
|
245
|
+
return hook_event_name in CURSOR_EVENT_NAMES
|
|
246
|
+
|
|
247
|
+
# Unknown IDE, allow processing
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR) -> 'AIHookPayload':
|
|
58
252
|
"""Create AIHookPayload from any tool's payload.
|
|
59
253
|
|
|
60
254
|
Args:
|
|
61
255
|
payload: The raw payload from the IDE
|
|
62
|
-
tool: The IDE/tool name
|
|
256
|
+
tool: The IDE/tool name or AIIDEType enum value
|
|
63
257
|
|
|
64
258
|
Returns:
|
|
65
259
|
AIHookPayload instance
|
|
@@ -67,6 +261,8 @@ class AIHookPayload:
|
|
|
67
261
|
Raises:
|
|
68
262
|
ValueError: If the tool is not supported
|
|
69
263
|
"""
|
|
70
|
-
if tool ==
|
|
264
|
+
if tool == AIIDEType.CURSOR:
|
|
71
265
|
return cls.from_cursor_payload(payload)
|
|
72
|
-
|
|
266
|
+
if tool == AIIDEType.CLAUDE_CODE:
|
|
267
|
+
return cls.from_claude_code_payload(payload)
|
|
268
|
+
raise ValueError(f'Unsupported IDE/tool: {tool}')
|
|
@@ -7,6 +7,8 @@ an abstract interface and concrete implementations for each supported IDE.
|
|
|
7
7
|
|
|
8
8
|
from abc import ABC, abstractmethod
|
|
9
9
|
|
|
10
|
+
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
class IDEResponseBuilder(ABC):
|
|
12
14
|
"""Abstract base class for IDE-specific response builders."""
|
|
@@ -62,17 +64,64 @@ class CursorResponseBuilder(IDEResponseBuilder):
|
|
|
62
64
|
return {'continue': False, 'user_message': user_message}
|
|
63
65
|
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
class ClaudeCodeResponseBuilder(IDEResponseBuilder):
|
|
68
|
+
"""Response builder for Claude Code IDE hooks.
|
|
69
|
+
|
|
70
|
+
Claude Code hook response formats:
|
|
71
|
+
- UserPromptSubmit: {} for allow, {"decision": "block", "reason": str} for deny
|
|
72
|
+
- PreToolUse: hookSpecificOutput with permissionDecision (allow/deny/ask)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def allow_permission(self) -> dict:
|
|
76
|
+
"""Allow file read or MCP execution."""
|
|
77
|
+
return {
|
|
78
|
+
'hookSpecificOutput': {
|
|
79
|
+
'hookEventName': 'PreToolUse',
|
|
80
|
+
'permissionDecision': 'allow',
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def deny_permission(self, user_message: str, agent_message: str) -> dict:
|
|
85
|
+
"""Deny file read or MCP execution."""
|
|
86
|
+
return {
|
|
87
|
+
'hookSpecificOutput': {
|
|
88
|
+
'hookEventName': 'PreToolUse',
|
|
89
|
+
'permissionDecision': 'deny',
|
|
90
|
+
'permissionDecisionReason': user_message,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def ask_permission(self, user_message: str, agent_message: str) -> dict:
|
|
95
|
+
"""Ask user for permission (warn mode)."""
|
|
96
|
+
return {
|
|
97
|
+
'hookSpecificOutput': {
|
|
98
|
+
'hookEventName': 'PreToolUse',
|
|
99
|
+
'permissionDecision': 'ask',
|
|
100
|
+
'permissionDecisionReason': user_message,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def allow_prompt(self) -> dict:
|
|
105
|
+
"""Allow prompt submission (empty response means allow)."""
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
def deny_prompt(self, user_message: str) -> dict:
|
|
109
|
+
"""Deny prompt submission."""
|
|
110
|
+
return {'decision': 'block', 'reason': user_message}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Registry of response builders by IDE type
|
|
66
114
|
_RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = {
|
|
67
|
-
|
|
115
|
+
AIIDEType.CURSOR: CursorResponseBuilder(),
|
|
116
|
+
AIIDEType.CLAUDE_CODE: ClaudeCodeResponseBuilder(),
|
|
68
117
|
}
|
|
69
118
|
|
|
70
119
|
|
|
71
|
-
def get_response_builder(ide: str =
|
|
120
|
+
def get_response_builder(ide: str = AIIDEType.CURSOR) -> IDEResponseBuilder:
|
|
72
121
|
"""Get the response builder for a specific IDE.
|
|
73
122
|
|
|
74
123
|
Args:
|
|
75
|
-
ide: The IDE name (e.g., 'cursor', 'claude-code')
|
|
124
|
+
ide: The IDE name (e.g., 'cursor', 'claude-code') or AIIDEType enum
|
|
76
125
|
|
|
77
126
|
Returns:
|
|
78
127
|
IDEResponseBuilder instance for the specified IDE
|
|
@@ -80,7 +129,10 @@ def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder:
|
|
|
80
129
|
Raises:
|
|
81
130
|
ValueError: If the IDE is not supported
|
|
82
131
|
"""
|
|
83
|
-
|
|
132
|
+
# Normalize to AIIDEType if string passed
|
|
133
|
+
if isinstance(ide, str):
|
|
134
|
+
ide = ide.lower()
|
|
135
|
+
builder = _RESPONSE_BUILDERS.get(ide)
|
|
84
136
|
if not builder:
|
|
85
137
|
raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}')
|
|
86
138
|
return builder
|
|
@@ -16,6 +16,7 @@ from typing import Annotated
|
|
|
16
16
|
import click
|
|
17
17
|
import typer
|
|
18
18
|
|
|
19
|
+
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
|
|
19
20
|
from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event
|
|
20
21
|
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
21
22
|
from cycode.cli.apps.ai_guardrails.scan.policy import load_policy
|
|
@@ -69,7 +70,7 @@ def scan_command(
|
|
|
69
70
|
help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.',
|
|
70
71
|
hidden=True,
|
|
71
72
|
),
|
|
72
|
-
] =
|
|
73
|
+
] = AIIDEType.CURSOR,
|
|
73
74
|
) -> None:
|
|
74
75
|
"""Scan content from AI IDE hooks for secrets.
|
|
75
76
|
|
|
@@ -96,6 +97,16 @@ def scan_command(
|
|
|
96
97
|
output_json(response_builder.allow_prompt())
|
|
97
98
|
return
|
|
98
99
|
|
|
100
|
+
# Check if the payload matches the expected IDE - prevents double-processing
|
|
101
|
+
# when Cursor reads Claude Code hooks from ~/.claude/settings.json
|
|
102
|
+
if not AIHookPayload.is_payload_for_ide(payload, tool):
|
|
103
|
+
logger.debug(
|
|
104
|
+
'Payload event does not match expected IDE, skipping',
|
|
105
|
+
extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': tool},
|
|
106
|
+
)
|
|
107
|
+
output_json(response_builder.allow_prompt())
|
|
108
|
+
return
|
|
109
|
+
|
|
99
110
|
unified_payload = AIHookPayload.from_payload(payload, tool=tool)
|
|
100
111
|
event_name = unified_payload.event_name
|
|
101
112
|
logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool})
|
|
@@ -31,6 +31,17 @@ CURSOR_EVENT_MAPPING = {
|
|
|
31
31
|
'beforeMCPExecution': AiHookEventType.MCP_EXECUTION,
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
# Claude Code event mapping - note that PreToolUse requires tool_name inspection
|
|
35
|
+
# to determine the actual event type (file read vs MCP execution)
|
|
36
|
+
CLAUDE_CODE_EVENT_MAPPING = {
|
|
37
|
+
'UserPromptSubmit': AiHookEventType.PROMPT,
|
|
38
|
+
'PreToolUse': None, # Requires tool_name inspection to determine actual type
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Set of known event names per IDE (for IDE detection)
|
|
42
|
+
CURSOR_EVENT_NAMES = set(CURSOR_EVENT_MAPPING.keys())
|
|
43
|
+
CLAUDE_CODE_EVENT_NAMES = set(CLAUDE_CODE_EVENT_MAPPING.keys())
|
|
44
|
+
|
|
34
45
|
|
|
35
46
|
class AIHookOutcome(StrEnum):
|
|
36
47
|
"""Outcome of an AI hook event evaluation."""
|
|
@@ -8,6 +8,7 @@ import typer
|
|
|
8
8
|
from rich.table import Table
|
|
9
9
|
|
|
10
10
|
from cycode.cli.apps.ai_guardrails.command_utils import console, validate_and_parse_ide, validate_scope
|
|
11
|
+
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType
|
|
11
12
|
from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status
|
|
12
13
|
from cycode.cli.utils.sentry import add_breadcrumb
|
|
13
14
|
|
|
@@ -26,9 +27,9 @@ def status_command(
|
|
|
26
27
|
str,
|
|
27
28
|
typer.Option(
|
|
28
29
|
'--ide',
|
|
29
|
-
help='IDE to check status for (e.g., "cursor"). Defaults to cursor.',
|
|
30
|
+
help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.',
|
|
30
31
|
),
|
|
31
|
-
] =
|
|
32
|
+
] = AIIDEType.CURSOR,
|
|
32
33
|
repo_path: Annotated[
|
|
33
34
|
Optional[Path],
|
|
34
35
|
typer.Option(
|
|
@@ -50,6 +51,7 @@ def status_command(
|
|
|
50
51
|
cycode ai-guardrails status --scope user # Show only user-level status
|
|
51
52
|
cycode ai-guardrails status --scope repo # Show only repo-level status
|
|
52
53
|
cycode ai-guardrails status --ide cursor # Check status for Cursor IDE
|
|
54
|
+
cycode ai-guardrails status --ide all # Check status for all supported IDEs
|
|
53
55
|
"""
|
|
54
56
|
add_breadcrumb('ai-guardrails-status')
|
|
55
57
|
|
|
@@ -59,34 +61,41 @@ def status_command(
|
|
|
59
61
|
repo_path = Path(os.getcwd())
|
|
60
62
|
ide_type = validate_and_parse_ide(ide)
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
ides_to_check: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type]
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=ide_type)
|
|
66
|
+
scopes_to_check = ['user', 'repo'] if scope == 'all' else [scope]
|
|
66
67
|
|
|
68
|
+
for current_ide in ides_to_check:
|
|
69
|
+
ide_name = IDE_CONFIGS[current_ide].name
|
|
67
70
|
console.print()
|
|
68
|
-
console.print(f'[bold]{
|
|
69
|
-
|
|
71
|
+
console.print(f'[bold cyan]═══ {ide_name} ═══[/]')
|
|
72
|
+
|
|
73
|
+
for check_scope in scopes_to_check:
|
|
74
|
+
status = get_hooks_status(check_scope, repo_path if check_scope == 'repo' else None, ide=current_ide)
|
|
75
|
+
|
|
76
|
+
console.print()
|
|
77
|
+
console.print(f'[bold]{check_scope.upper()} SCOPE[/]')
|
|
78
|
+
console.print(f'Path: {status["hooks_path"]}')
|
|
70
79
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
if not status['file_exists']:
|
|
81
|
+
console.print('[dim]No hooks file found[/]')
|
|
82
|
+
continue
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
if status['cycode_installed']:
|
|
85
|
+
console.print('[green]✓ Cycode AI guardrails: INSTALLED[/]')
|
|
86
|
+
else:
|
|
87
|
+
console.print('[yellow]○ Cycode AI guardrails: NOT INSTALLED[/]')
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
# Show hook details
|
|
90
|
+
table = Table(show_header=True, header_style='bold')
|
|
91
|
+
table.add_column('Hook Event')
|
|
92
|
+
table.add_column('Cycode Enabled')
|
|
93
|
+
table.add_column('Total Hooks')
|
|
85
94
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
for event, info in status['hooks'].items():
|
|
96
|
+
enabled = '[green]Yes[/]' if info['enabled'] else '[dim]No[/]'
|
|
97
|
+
table.add_row(event, enabled, str(info['total_entries']))
|
|
89
98
|
|
|
90
|
-
|
|
99
|
+
console.print(table)
|
|
91
100
|
|
|
92
101
|
console.print()
|
|
@@ -11,7 +11,7 @@ from cycode.cli.apps.ai_guardrails.command_utils import (
|
|
|
11
11
|
validate_and_parse_ide,
|
|
12
12
|
validate_scope,
|
|
13
13
|
)
|
|
14
|
-
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS
|
|
14
|
+
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType
|
|
15
15
|
from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks
|
|
16
16
|
from cycode.cli.utils.sentry import add_breadcrumb
|
|
17
17
|
|
|
@@ -30,9 +30,9 @@ def uninstall_command(
|
|
|
30
30
|
str,
|
|
31
31
|
typer.Option(
|
|
32
32
|
'--ide',
|
|
33
|
-
help='IDE to uninstall hooks from (e.g., "cursor"). Defaults to cursor.',
|
|
33
|
+
help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.',
|
|
34
34
|
),
|
|
35
|
-
] =
|
|
35
|
+
] = AIIDEType.CURSOR,
|
|
36
36
|
repo_path: Annotated[
|
|
37
37
|
Optional[Path],
|
|
38
38
|
typer.Option(
|
|
@@ -54,6 +54,7 @@ def uninstall_command(
|
|
|
54
54
|
cycode ai-guardrails uninstall # Remove user-level hooks
|
|
55
55
|
cycode ai-guardrails uninstall --scope repo # Remove repo-level hooks
|
|
56
56
|
cycode ai-guardrails uninstall --ide cursor # Uninstall from Cursor IDE
|
|
57
|
+
cycode ai-guardrails uninstall --ide all # Uninstall from all supported IDEs
|
|
57
58
|
"""
|
|
58
59
|
add_breadcrumb('ai-guardrails-uninstall')
|
|
59
60
|
|
|
@@ -61,13 +62,31 @@ def uninstall_command(
|
|
|
61
62
|
validate_scope(scope)
|
|
62
63
|
repo_path = resolve_repo_path(scope, repo_path)
|
|
63
64
|
ide_type = validate_and_parse_ide(ide)
|
|
64
|
-
ide_name = IDE_CONFIGS[ide_type].name
|
|
65
|
-
success, message = uninstall_hooks(scope, repo_path, ide=ide_type)
|
|
66
65
|
|
|
67
|
-
if
|
|
68
|
-
|
|
66
|
+
ides_to_uninstall: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type]
|
|
67
|
+
|
|
68
|
+
results: list[tuple[str, bool, str]] = []
|
|
69
|
+
for current_ide in ides_to_uninstall:
|
|
70
|
+
ide_name = IDE_CONFIGS[current_ide].name
|
|
71
|
+
success, message = uninstall_hooks(scope, repo_path, ide=current_ide)
|
|
72
|
+
results.append((ide_name, success, message))
|
|
73
|
+
|
|
74
|
+
# Report results for each IDE
|
|
75
|
+
any_success = False
|
|
76
|
+
all_success = True
|
|
77
|
+
for _ide_name, success, message in results:
|
|
78
|
+
if success:
|
|
79
|
+
console.print(f'[green]✓[/] {message}')
|
|
80
|
+
any_success = True
|
|
81
|
+
else:
|
|
82
|
+
console.print(f'[red]✗[/] {message}', style='bold red')
|
|
83
|
+
all_success = False
|
|
84
|
+
|
|
85
|
+
if any_success:
|
|
69
86
|
console.print()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
console.print(f'[
|
|
87
|
+
successful_ides = [name for name, success, _ in results if success]
|
|
88
|
+
ide_list = ', '.join(successful_ides)
|
|
89
|
+
console.print(f'[dim]Restart {ide_list} for changes to take effect.[/]')
|
|
90
|
+
|
|
91
|
+
if not all_success:
|
|
73
92
|
raise typer.Exit(1)
|
|
@@ -61,6 +61,7 @@ class AISecurityManagerClient:
|
|
|
61
61
|
outcome: 'AIHookOutcome',
|
|
62
62
|
scan_id: Optional[str] = None,
|
|
63
63
|
block_reason: Optional['BlockReason'] = None,
|
|
64
|
+
error_message: Optional[str] = None,
|
|
64
65
|
) -> None:
|
|
65
66
|
"""Create an AI hook event from hook payload."""
|
|
66
67
|
conversation_id = payload.conversation_id
|
|
@@ -77,6 +78,7 @@ class AISecurityManagerClient:
|
|
|
77
78
|
'cli_scan_id': scan_id,
|
|
78
79
|
'mcp_server_name': payload.mcp_server_name,
|
|
79
80
|
'mcp_tool_name': payload.mcp_tool_name,
|
|
81
|
+
'error_message': error_message,
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
try:
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
cycode/__init__.py,sha256=
|
|
1
|
+
cycode/__init__.py,sha256=COYqmiJKyEDoNPdMU1QWil4k91dLcVgOyUEvwlAxMBo,109
|
|
2
2
|
cycode/__main__.py,sha256=Z3bD5yrA7yPvAChcADQrqCaZd0ChGI1gdiwALwbWJ6U,104
|
|
3
3
|
cycode/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
cycode/cli/app.py,sha256=j7jXFJwVbXROvi6LeBcf7MxhY9q-O7-XkLOkfqOqL3I,6460
|
|
5
5
|
cycode/cli/apps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
cycode/cli/apps/ai_guardrails/__init__.py,sha256=7VsEUYYyqiuJJAV-_Ti_Wudr4SWQZGnoVLBuq8NTvVQ,902
|
|
7
|
-
cycode/cli/apps/ai_guardrails/command_utils.py,sha256=
|
|
8
|
-
cycode/cli/apps/ai_guardrails/consts.py,sha256=
|
|
9
|
-
cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=
|
|
10
|
-
cycode/cli/apps/ai_guardrails/install_command.py,sha256=
|
|
7
|
+
cycode/cli/apps/ai_guardrails/command_utils.py,sha256=itWoARiiqC-kCJuppBxBwKDjCSnci2m0EG95GQPy3r4,1924
|
|
8
|
+
cycode/cli/apps/ai_guardrails/consts.py,sha256=6V6jdT2R5wR-_lI7h3pJuWkcTWlVsSnssc0K9KOpOA4,3580
|
|
9
|
+
cycode/cli/apps/ai_guardrails/hooks_manager.py,sha256=YiLY8AnNSmPerMCJpjbJkybnkmhZZ_A93TM3glNZpIM,7448
|
|
10
|
+
cycode/cli/apps/ai_guardrails/install_command.py,sha256=pgE5USLVil1D4Jkeuo4Wxf3t_knL0zn4o1367crz3RU,3439
|
|
11
11
|
cycode/cli/apps/ai_guardrails/scan/__init__.py,sha256=qJc82XiQGiAuc1sYY8Ij_A-qXpxgLPuayQq8xWlouMA,48
|
|
12
12
|
cycode/cli/apps/ai_guardrails/scan/consts.py,sha256=drAslw6vW3kxmbCs2qPCUbUPR7PJouT2lsXtu5sD-lQ,1094
|
|
13
|
-
cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=
|
|
14
|
-
cycode/cli/apps/ai_guardrails/scan/payload.py,sha256=
|
|
13
|
+
cycode/cli/apps/ai_guardrails/scan/handlers.py,sha256=nVFMGtxNFkfiWlhQlIHmx2T1J4jJcOe-WIFiqxxMgu4,14902
|
|
14
|
+
cycode/cli/apps/ai_guardrails/scan/payload.py,sha256=Zxzd1OkM11aCssg7gwwR9OpEjdQOzYklFvEkOqLnb3I,10160
|
|
15
15
|
cycode/cli/apps/ai_guardrails/scan/policy.py,sha256=39s8hnxgjny1l6XAO59wsRcAlpW-LG00GUnO0PfqvuY,2566
|
|
16
|
-
cycode/cli/apps/ai_guardrails/scan/response_builders.py,sha256=
|
|
17
|
-
cycode/cli/apps/ai_guardrails/scan/scan_command.py,sha256=
|
|
18
|
-
cycode/cli/apps/ai_guardrails/scan/types.py,sha256=
|
|
16
|
+
cycode/cli/apps/ai_guardrails/scan/response_builders.py,sha256=VZvMQ3pxqq4ROpT8rvuEF1y9SiX6tVYvgjhh5UBhz78,4740
|
|
17
|
+
cycode/cli/apps/ai_guardrails/scan/scan_command.py,sha256=lAkb-8txg722NjfJXgXT7gJpJp732dm7-vXWxkT3_EQ,5720
|
|
18
|
+
cycode/cli/apps/ai_guardrails/scan/types.py,sha256=H25MKJhAXmp7Mz1YeCIRmAY1Zg5GSpgBq8G1TEI9PFk,1868
|
|
19
19
|
cycode/cli/apps/ai_guardrails/scan/utils.py,sha256=KVfX-NrcM-QW4quLtoNqfmz4GF0FlDs-TkqUOu1hAWM,2057
|
|
20
|
-
cycode/cli/apps/ai_guardrails/status_command.py,sha256=
|
|
21
|
-
cycode/cli/apps/ai_guardrails/uninstall_command.py,sha256=
|
|
20
|
+
cycode/cli/apps/ai_guardrails/status_command.py,sha256=Vqys2-Tp_VerldXfLnXtjq66XU3AQ9h0sbbXkfQ4CoQ,3628
|
|
21
|
+
cycode/cli/apps/ai_guardrails/uninstall_command.py,sha256=dC3U9DBQs3zznvFAMeITcdmBeYApb3cJbrH9RtGmLfI,3061
|
|
22
22
|
cycode/cli/apps/ai_remediation/__init__.py,sha256=8vYthY9RQeJqEni3AIF5sryz8n-XJQ6VNqG4aEFBAdY,553
|
|
23
23
|
cycode/cli/apps/ai_remediation/ai_remediation_command.py,sha256=u1EdebaKCEmzv9fXmnIN0xDSLcCmGyjueYKvYfLOj_8,1549
|
|
24
24
|
cycode/cli/apps/ai_remediation/apply_fix.py,sha256=9zgqiqF9HBQXi7Oz9ZIiANIAuKAMTji1PlNncCEOf5Q,817
|
|
@@ -166,7 +166,7 @@ cycode/cli/utils/version_checker.py,sha256=0f5PaTk02ZkDxzBqZOeMV9mU_CWcx6HKW80jU
|
|
|
166
166
|
cycode/cli/utils/yaml_utils.py,sha256=R-tqzl0C-zoa42rS7nfWeHu3GJ0jpbQUyyqYYU2hleM,1818
|
|
167
167
|
cycode/config.py,sha256=jHORGZQcAXkAGSf2XreC-RQoc8sdNWja69QKtPWTbWo,1044
|
|
168
168
|
cycode/cyclient/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
169
|
-
cycode/cyclient/ai_security_manager_client.py,sha256=
|
|
169
|
+
cycode/cyclient/ai_security_manager_client.py,sha256=btkphNQPCwJUdm9N7eLFhFBHbKOu1nOGnPfUHS8xTvo,3416
|
|
170
170
|
cycode/cyclient/ai_security_manager_service_config.py,sha256=83pQzgOb93JW6E-dznJkI4c0NEXmQRlx9YZKMmjVwp8,808
|
|
171
171
|
cycode/cyclient/auth_client.py,sha256=TwbmZ358Ancf-Q-IZolvfljZ8691_6botsqd0R0PLPk,2105
|
|
172
172
|
cycode/cyclient/base_token_auth_client.py,sha256=3JIrSz0-ywVTIfxIs2zs5aGcE-x5GW3AgPHm9qA4ZDE,3857
|
|
@@ -186,8 +186,8 @@ cycode/cyclient/report_client.py,sha256=Scq30NeJPzgXv0hPLO1U05AdE9i_2iu6cIrSKpEJ
|
|
|
186
186
|
cycode/cyclient/scan_client.py,sha256=uTBEjgfaCVuJREo73p_zkIVA23NQfdJ1d1-bzc7nSKk,12682
|
|
187
187
|
cycode/cyclient/scan_config_base.py,sha256=mXsPZGYCtp85rv5GIige40yQZXuRcEKUW-VQJ0vgFzk,1201
|
|
188
188
|
cycode/logger.py,sha256=xAzpkWLZhixO4egRcYn4HXM9lIfx5wHdpkHxNc5jrX8,2225
|
|
189
|
-
cycode-3.9.
|
|
190
|
-
cycode-3.9.
|
|
191
|
-
cycode-3.9.
|
|
192
|
-
cycode-3.9.
|
|
193
|
-
cycode-3.9.
|
|
189
|
+
cycode-3.9.1.dist-info/METADATA,sha256=kvWHRVBFZQNl34xm263wYUijWeWHXqO1cxRiS43fLp4,79032
|
|
190
|
+
cycode-3.9.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
191
|
+
cycode-3.9.1.dist-info/entry_points.txt,sha256=iDcVJM8ByLElVgvBgtYxDjw1kT7O8Mo0LcWZIT5L3Ig,45
|
|
192
|
+
cycode-3.9.1.dist-info/licenses/LICENCE,sha256=2Wx4N6mD_4xB7-E3hPkZ3MPhpJy__k_I8MaCSO-PDRo,1068
|
|
193
|
+
cycode-3.9.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|