Skip to content

Commit 17db1ab

Browse files
committed
CM-58331-review
1 parent 5ad5aa5 commit 17db1ab

File tree

8 files changed

+65
-42
lines changed

8 files changed

+65
-42
lines changed

cycode/cli/apps/ai_guardrails/consts.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ class AIIDEType(str, Enum):
1818
CLAUDE_CODE = 'claude-code'
1919

2020

21+
class PolicyMode(str, Enum):
22+
"""Policy enforcement mode for global mode and per-feature actions."""
23+
24+
BLOCK = 'block'
25+
WARN = 'warn'
26+
27+
2128
class IDEConfig(NamedTuple):
2229
"""Configuration for an AI IDE."""
2330

cycode/cli/apps/ai_guardrails/install_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def install_command(
3232
'--ide',
3333
help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.',
3434
),
35-
] = 'cursor',
35+
] = AIIDEType.CURSOR,
3636
repo_path: Annotated[
3737
Optional[Path],
3838
typer.Option(

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import typer
1515

16+
from cycode.cli.apps.ai_guardrails.consts import PolicyMode
1617
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
1718
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
1819
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
4647
ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
4748
return response_builder.allow_prompt()
4849

49-
mode = get_policy_value(policy, 'mode', default='block')
50+
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
5051
prompt = payload.prompt or ''
5152
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
5253
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
@@ -62,7 +63,8 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
6263

6364
if violation_summary:
6465
block_reason = BlockReason.SECRETS_IN_PROMPT
65-
if get_policy_value(prompt_config, 'action', default='block') == 'block' and mode == 'block':
66+
action = get_policy_value(prompt_config, 'action', default=PolicyMode.BLOCK)
67+
if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK:
6668
outcome = AIHookOutcome.BLOCKED
6769
user_message = f'{violation_summary}. Remove secrets before sending.'
6870
return response_builder.deny_prompt(user_message)
@@ -103,9 +105,9 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
103105
ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
104106
return response_builder.allow_permission()
105107

106-
mode = get_policy_value(policy, 'mode', default='block')
108+
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
107109
file_path = payload.file_path or ''
108-
action = get_policy_value(file_read_config, 'action', default='block')
110+
action = get_policy_value(file_read_config, 'action', default=PolicyMode.BLOCK)
109111

110112
scan_id = None
111113
block_reason = None
@@ -116,7 +118,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
116118
# Check path-based denylist first
117119
if is_denied_path(file_path, policy):
118120
block_reason = BlockReason.SENSITIVE_PATH
119-
if mode == 'block' and action == 'block':
121+
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
120122
outcome = AIHookOutcome.BLOCKED
121123
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
122124
return response_builder.deny_permission(
@@ -136,7 +138,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
136138
violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy)
137139
if violation_summary:
138140
block_reason = BlockReason.SECRETS_IN_FILE
139-
if mode == 'block' and action == 'block':
141+
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
140142
outcome = AIHookOutcome.BLOCKED
141143
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
142144
return response_builder.deny_permission(
@@ -189,14 +191,14 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
189191
ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED)
190192
return response_builder.allow_permission()
191193

192-
mode = get_policy_value(policy, 'mode', default='block')
194+
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
193195
tool = payload.mcp_tool_name or 'unknown'
194196
args = payload.mcp_arguments or {}
195197
args_text = args if isinstance(args, str) else json.dumps(args)
196198
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
197199
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
198200
clipped = truncate_utf8(args_text, max_bytes)
199-
action = get_policy_value(mcp_config, 'action', default='block')
201+
action = get_policy_value(mcp_config, 'action', default=PolicyMode.BLOCK)
200202

201203
scan_id = None
202204
block_reason = None
@@ -208,7 +210,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
208210
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
209211
if violation_summary:
210212
block_reason = BlockReason.SECRETS_IN_MCP_ARGS
211-
if mode == 'block' and action == 'block':
213+
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
212214
outcome = AIHookOutcome.BLOCKED
213215
user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}'
214216
return response_builder.deny_permission(

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

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import Path
77
from typing import Optional
88

9+
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
910
from cycode.cli.apps.ai_guardrails.scan.types import (
1011
CLAUDE_CODE_EVENT_MAPPING,
1112
CLAUDE_CODE_EVENT_NAMES,
@@ -47,7 +48,7 @@ def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]:
4748
if newline_pos == -1:
4849
break
4950
# Yield the line after this newline
50-
line = buffer[newline_pos + 1 :]
51+
line = buffer[newline_pos + 1:]
5152
buffer = buffer[: newline_pos + 1]
5253
if line.strip():
5354
yield line.decode('utf-8', errors='replace')
@@ -57,8 +58,20 @@ def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]:
5758
yield buffer.decode('utf-8', errors='replace')
5859

5960

60-
def _extract_from_claude_transcript( # noqa: C901
61-
transcript_path: str,
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,
6275
) -> tuple[Optional[str], Optional[str], Optional[str]]:
6376
"""Extract IDE version, model, and latest generation ID from Claude Code transcript file.
6477
@@ -90,16 +103,11 @@ def _extract_from_claude_transcript( # noqa: C901
90103
continue
91104
try:
92105
entry = json.loads(line)
93-
if ide_version is None and 'version' in entry:
94-
ide_version = entry['version']
95-
# Model can be at top level or nested in message.model
96-
if model is None:
97-
model = entry.get('model') or (entry.get('message') or {}).get('model')
98-
# Get the latest user message UUID as generation_id
99-
if generation_id is None and entry.get('type') == 'user' and entry.get('uuid'):
100-
generation_id = entry['uuid']
101-
# Stop early if we found all values
102-
if ide_version is not None and model is not None and generation_id is not None:
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:
103111
break
104112
except json.JSONDecodeError:
105113
continue
@@ -121,7 +129,7 @@ class AIHookPayload:
121129
# User and IDE information
122130
ide_user_email: Optional[str] = None
123131
model: Optional[str] = None
124-
ide_provider: str = None # e.g., 'cursor', 'claude-code'
132+
ide_provider: str = None # AIIDEType value (e.g., 'cursor', 'claude-code')
125133
ide_version: Optional[str] = None
126134

127135
# Event-specific data
@@ -147,7 +155,7 @@ def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload':
147155
generation_id=payload.get('generation_id'),
148156
ide_user_email=payload.get('user_email'),
149157
model=payload.get('model'),
150-
ide_provider='cursor',
158+
ide_provider=AIIDEType.CURSOR,
151159
ide_version=payload.get('cursor_version'),
152160
prompt=payload.get('prompt', ''),
153161
file_path=payload.get('file_path') or payload.get('path'),
@@ -205,7 +213,7 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload':
205213
generation_id=generation_id,
206214
ide_user_email=None, # Claude Code doesn't provide this in hook payload
207215
model=model,
208-
ide_provider='claude-code',
216+
ide_provider=AIIDEType.CLAUDE_CODE,
209217
ide_version=ide_version,
210218
prompt=payload.get('prompt', ''),
211219
file_path=file_path,
@@ -224,37 +232,37 @@ def is_payload_for_ide(payload: dict, ide: str) -> bool:
224232
225233
Args:
226234
payload: The raw payload from the IDE
227-
ide: The IDE name (e.g., 'cursor', 'claude-code')
235+
ide: The IDE name or AIIDEType enum value
228236
229237
Returns:
230238
True if the payload matches the IDE, False otherwise.
231239
"""
232240
hook_event_name = payload.get('hook_event_name', '')
233241

234-
if ide == 'claude-code':
242+
if ide == AIIDEType.CLAUDE_CODE:
235243
return hook_event_name in CLAUDE_CODE_EVENT_NAMES
236-
if ide == 'cursor':
244+
if ide == AIIDEType.CURSOR:
237245
return hook_event_name in CURSOR_EVENT_NAMES
238246

239247
# Unknown IDE, allow processing
240248
return True
241249

242250
@classmethod
243-
def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload':
251+
def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR) -> 'AIHookPayload':
244252
"""Create AIHookPayload from any tool's payload.
245253
246254
Args:
247255
payload: The raw payload from the IDE
248-
tool: The IDE/tool name (e.g., 'cursor', 'claude-code')
256+
tool: The IDE/tool name or AIIDEType enum value
249257
250258
Returns:
251259
AIHookPayload instance
252260
253261
Raises:
254262
ValueError: If the tool is not supported
255263
"""
256-
if tool == 'cursor':
264+
if tool == AIIDEType.CURSOR:
257265
return cls.from_cursor_payload(payload)
258-
if tool == 'claude-code':
266+
if tool == AIIDEType.CLAUDE_CODE:
259267
return cls.from_claude_code_payload(payload)
260268
raise ValueError(f'Unsupported IDE/tool: {tool}')

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from abc import ABC, abstractmethod
99

10+
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
11+
1012

1113
class IDEResponseBuilder(ABC):
1214
"""Abstract base class for IDE-specific response builders."""
@@ -108,26 +110,29 @@ def deny_prompt(self, user_message: str) -> dict:
108110
return {'decision': 'block', 'reason': user_message}
109111

110112

111-
# Registry of response builders by IDE name
113+
# Registry of response builders by IDE type
112114
_RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = {
113-
'cursor': CursorResponseBuilder(),
114-
'claude-code': ClaudeCodeResponseBuilder(),
115+
AIIDEType.CURSOR: CursorResponseBuilder(),
116+
AIIDEType.CLAUDE_CODE: ClaudeCodeResponseBuilder(),
115117
}
116118

117119

118-
def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder:
120+
def get_response_builder(ide: str = AIIDEType.CURSOR) -> IDEResponseBuilder:
119121
"""Get the response builder for a specific IDE.
120122
121123
Args:
122-
ide: The IDE name (e.g., 'cursor', 'claude-code')
124+
ide: The IDE name (e.g., 'cursor', 'claude-code') or AIIDEType enum
123125
124126
Returns:
125127
IDEResponseBuilder instance for the specified IDE
126128
127129
Raises:
128130
ValueError: If the IDE is not supported
129131
"""
130-
builder = _RESPONSE_BUILDERS.get(ide.lower())
132+
# Normalize to AIIDEType if string passed
133+
if isinstance(ide, str):
134+
ide = ide.lower()
135+
builder = _RESPONSE_BUILDERS.get(ide)
131136
if not builder:
132137
raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}')
133138
return builder

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import click
1717
import typer
1818

19+
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
1920
from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event
2021
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
2122
from cycode.cli.apps.ai_guardrails.scan.policy import load_policy
@@ -69,7 +70,7 @@ def scan_command(
6970
help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.',
7071
hidden=True,
7172
),
72-
] = 'cursor',
73+
] = AIIDEType.CURSOR,
7374
) -> None:
7475
"""Scan content from AI IDE hooks for secrets.
7576

cycode/cli/apps/ai_guardrails/status_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def status_command(
2929
'--ide',
3030
help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.',
3131
),
32-
] = 'cursor',
32+
] = AIIDEType.CURSOR,
3333
repo_path: Annotated[
3434
Optional[Path],
3535
typer.Option(

cycode/cli/apps/ai_guardrails/uninstall_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def uninstall_command(
3232
'--ide',
3333
help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.',
3434
),
35-
] = 'cursor',
35+
] = AIIDEType.CURSOR,
3636
repo_path: Annotated[
3737
Optional[Path],
3838
typer.Option(

0 commit comments

Comments
 (0)