Skip to content

Commit 3418f87

Browse files
committed
CM-58331-support-claude-code
1 parent d1ba80d commit 3418f87

File tree

12 files changed

+883
-49
lines changed

12 files changed

+883
-49
lines changed

cycode/cli/apps/ai_guardrails/consts.py

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@
22
33
Currently supports:
44
- 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
116
"""
127

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

2217
CURSOR = 'cursor'
18+
CLAUDE_CODE = 'claude-code'
2319

2420

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

4440

41+
def _get_claude_code_hooks_dir() -> Path:
42+
"""Get Claude Code hooks directory.
43+
44+
Claude Code uses ~/.claude on all platforms.
45+
"""
46+
return Path.home() / '.claude'
47+
48+
4549
# IDE-specific configurations
4650
IDE_CONFIGS: dict[AIIDEType, IDEConfig] = {
4751
AIIDEType.CURSOR: IDEConfig(
@@ -51,6 +55,13 @@ def _get_cursor_hooks_dir() -> Path:
5155
hooks_file_name='hooks.json',
5256
hook_events=['beforeSubmitPrompt', 'beforeReadFile', 'beforeMCPExecution'],
5357
),
58+
AIIDEType.CLAUDE_CODE: IDEConfig(
59+
name='Claude Code',
60+
hooks_dir=_get_claude_code_hooks_dir(),
61+
repo_hooks_subdir='.claude',
62+
hooks_file_name='settings.json',
63+
hook_events=['UserPromptSubmit', 'PreToolUse'],
64+
),
5465
}
5566

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

6273

74+
def _get_cursor_hooks_config() -> dict:
75+
"""Get Cursor-specific hooks configuration."""
76+
config = IDE_CONFIGS[AIIDEType.CURSOR]
77+
hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events}
78+
79+
return {
80+
'version': 1,
81+
'hooks': hooks,
82+
}
83+
84+
85+
def _get_claude_code_hooks_config() -> dict:
86+
"""Get Claude Code-specific hooks configuration.
87+
88+
Claude Code uses a different hook format with nested structure:
89+
- hooks are arrays of objects with 'hooks' containing command arrays
90+
- PreToolUse uses 'matcher' field to specify which tools to intercept
91+
"""
92+
command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
93+
94+
return {
95+
'hooks': {
96+
'UserPromptSubmit': [
97+
{
98+
'hooks': [{'type': 'command', 'command': command}],
99+
}
100+
],
101+
'PreToolUse': [
102+
{
103+
'matcher': 'Read',
104+
'hooks': [{'type': 'command', 'command': command}],
105+
},
106+
{
107+
'matcher': 'mcp__.*',
108+
'hooks': [{'type': 'command', 'command': command}],
109+
},
110+
],
111+
},
112+
}
113+
114+
63115
def get_hooks_config(ide: AIIDEType) -> dict:
64116
"""Get the hooks configuration for a specific IDE.
65117
@@ -69,10 +121,6 @@ def get_hooks_config(ide: AIIDEType) -> dict:
69121
Returns:
70122
Dict with hooks configuration for the specified IDE
71123
"""
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-
}
124+
if ide == AIIDEType.CLAUDE_CODE:
125+
return _get_claude_code_hooks_config()
126+
return _get_cursor_hooks_config()

cycode/cli/apps/ai_guardrails/hooks_manager.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,27 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool:
5959

6060

6161
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)
6370
command = entry.get('command', '')
64-
return CYCODE_SCAN_PROMPT_COMMAND in command
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
6583

6684

6785
def install_hooks(

cycode/cli/apps/ai_guardrails/scan/handlers.py

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -59,25 +59,19 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
5959
try:
6060
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
6161

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
62+
if violation_summary:
6863
block_reason = BlockReason.SECRETS_IN_PROMPT
69-
user_message = f'{violation_summary}. Remove secrets before sending.'
70-
response = response_builder.deny_prompt(user_message)
71-
else:
72-
if violation_summary:
73-
outcome = AIHookOutcome.WARNED
74-
response = response_builder.allow_prompt()
75-
return response
64+
if get_policy_value(prompt_config, 'action', default='block') == 'block' and mode == 'block':
65+
outcome = AIHookOutcome.BLOCKED
66+
user_message = f'{violation_summary}. Remove secrets before sending.'
67+
return response_builder.deny_prompt(user_message)
68+
outcome = AIHookOutcome.WARNED
69+
return response_builder.allow_prompt()
7670
except Exception as e:
7771
outcome = (
7872
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
7973
)
80-
block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None
74+
block_reason = BlockReason.SCAN_FAILURE
8175
raise e
8276
finally:
8377
ai_client.create_event(
@@ -116,36 +110,50 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
116110

117111
try:
118112
# Check path-based denylist first
119-
if is_denied_path(file_path, policy) and action == 'block':
120-
outcome = AIHookOutcome.BLOCKED
113+
if is_denied_path(file_path, policy):
121114
block_reason = BlockReason.SENSITIVE_PATH
122-
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
123-
return response_builder.deny_permission(
115+
if mode == 'block' and action == 'block':
116+
outcome = AIHookOutcome.BLOCKED
117+
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
118+
return response_builder.deny_permission(
119+
user_message,
120+
'This file path is classified as sensitive; do not read/send it to the model.',
121+
)
122+
# Warn mode - ask user for permission
123+
outcome = AIHookOutcome.WARNED
124+
user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?'
125+
return response_builder.ask_permission(
124126
user_message,
125-
'This file path is classified as sensitive; do not read/send it to the model.',
127+
'This file path is classified as sensitive; proceed with caution.',
126128
)
127129

128130
# Scan file content if enabled
129131
if get_policy_value(file_read_config, 'scan_content', default=True):
130132
violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy)
131-
if violation_summary and action == 'block' and mode == 'block':
132-
outcome = AIHookOutcome.BLOCKED
133+
if violation_summary:
133134
block_reason = BlockReason.SECRETS_IN_FILE
134-
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
135-
return response_builder.deny_permission(
135+
if mode == 'block' and action == 'block':
136+
outcome = AIHookOutcome.BLOCKED
137+
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
138+
return response_builder.deny_permission(
139+
user_message,
140+
'Secrets detected; do not send this file to the model.',
141+
)
142+
# Warn mode - ask user for permission
143+
outcome = AIHookOutcome.WARNED
144+
user_message = f'Cycode detected secrets in {file_path}. {violation_summary}'
145+
return response_builder.ask_permission(
136146
user_message,
137-
'Secrets detected; do not send this file to the model.',
147+
'Possible secrets detected; proceed with caution.',
138148
)
139-
if violation_summary:
140-
outcome = AIHookOutcome.WARNED
141149
return response_builder.allow_permission()
142150

143151
return response_builder.allow_permission()
144152
except Exception as e:
145153
outcome = (
146154
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
147155
)
148-
block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None
156+
block_reason = BlockReason.SCAN_FAILURE
149157
raise e
150158
finally:
151159
ai_client.create_event(
@@ -192,9 +200,9 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
192200
if get_policy_value(mcp_config, 'scan_arguments', default=True):
193201
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
194202
if violation_summary:
203+
block_reason = BlockReason.SECRETS_IN_MCP_ARGS
195204
if mode == 'block' and action == 'block':
196205
outcome = AIHookOutcome.BLOCKED
197-
block_reason = BlockReason.SECRETS_IN_MCP_ARGS
198206
user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}'
199207
return response_builder.deny_permission(
200208
user_message,
@@ -211,7 +219,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
211219
outcome = (
212220
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
213221
)
214-
block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None
222+
block_reason = BlockReason.SCAN_FAILURE
215223
raise e
216224
finally:
217225
ai_client.create_event(

0 commit comments

Comments
 (0)