cycode 3.15.3.dev8__py3-none-any.whl → 3.15.4.dev2__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 +2 -45
- cycode/cli/apps/ai_guardrails/consts.py +3 -135
- cycode/cli/apps/ai_guardrails/hooks_manager.py +123 -152
- cycode/cli/apps/ai_guardrails/ides/__init__.py +45 -0
- cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +73 -0
- cycode/cli/apps/ai_guardrails/ides/base.py +176 -0
- cycode/cli/apps/ai_guardrails/ides/claude_code.py +369 -0
- cycode/cli/apps/ai_guardrails/ides/codex.py +310 -0
- cycode/cli/apps/ai_guardrails/ides/cursor.py +119 -0
- cycode/cli/apps/ai_guardrails/install_command.py +14 -23
- cycode/cli/apps/ai_guardrails/scan/handlers.py +102 -101
- cycode/cli/apps/ai_guardrails/scan/payload.py +14 -255
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +60 -48
- cycode/cli/apps/ai_guardrails/scan/types.py +8 -30
- cycode/cli/apps/ai_guardrails/session_start_command.py +14 -78
- cycode/cli/apps/ai_guardrails/status_command.py +13 -16
- cycode/cli/apps/ai_guardrails/uninstall_command.py +12 -22
- cycode/cli/utils/jwt_utils.py +8 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/METADATA +3 -1
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/RECORD +24 -21
- cycode/cli/apps/ai_guardrails/scan/claude_config.py +0 -159
- cycode/cli/apps/ai_guardrails/scan/cursor_config.py +0 -36
- cycode/cli/apps/ai_guardrails/scan/response_builders.py +0 -135
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/WHEEL +0 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/entry_points.txt +0 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/licenses/LICENCE +0 -0
|
@@ -1,275 +1,34 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Canonical AI hook payload shared across IDE integrations.
|
|
2
|
+
|
|
3
|
+
The dataclass is populated by `IDE.parse_hook_payload` (see
|
|
4
|
+
`cycode/cli/apps/ai_guardrails/ides/`). Per-IDE parsing logic lives on the
|
|
5
|
+
respective IDE class.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
|
-
import json
|
|
4
|
-
from collections.abc import Iterator
|
|
5
8
|
from dataclasses import dataclass
|
|
6
|
-
from pathlib import Path
|
|
7
9
|
from typing import Optional
|
|
8
10
|
|
|
9
|
-
from cycode.cli.apps.ai_guardrails.consts import AIIDEType
|
|
10
|
-
from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config
|
|
11
|
-
from cycode.cli.apps.ai_guardrails.scan.types import (
|
|
12
|
-
CLAUDE_CODE_EVENT_MAPPING,
|
|
13
|
-
CLAUDE_CODE_EVENT_NAMES,
|
|
14
|
-
CURSOR_EVENT_MAPPING,
|
|
15
|
-
CURSOR_EVENT_NAMES,
|
|
16
|
-
AiHookEventType,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]:
|
|
21
|
-
"""Read a file line by line from the end without loading entire file into memory.
|
|
22
|
-
|
|
23
|
-
Yields lines in reverse order (last line first).
|
|
24
|
-
"""
|
|
25
|
-
with path.open('rb') as f:
|
|
26
|
-
f.seek(0, 2) # Seek to end
|
|
27
|
-
file_size = f.tell()
|
|
28
|
-
if file_size == 0:
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
remaining = file_size
|
|
32
|
-
buffer = b''
|
|
33
|
-
|
|
34
|
-
while remaining > 0:
|
|
35
|
-
# Read a chunk from the end
|
|
36
|
-
read_size = min(buf_size, remaining)
|
|
37
|
-
remaining -= read_size
|
|
38
|
-
f.seek(remaining)
|
|
39
|
-
chunk = f.read(read_size)
|
|
40
|
-
buffer = chunk + buffer
|
|
41
|
-
|
|
42
|
-
# Yield complete lines from buffer
|
|
43
|
-
while b'\n' in buffer:
|
|
44
|
-
# Find the last newline
|
|
45
|
-
newline_pos = buffer.rfind(b'\n')
|
|
46
|
-
if newline_pos == len(buffer) - 1:
|
|
47
|
-
# Trailing newline, look for previous one
|
|
48
|
-
newline_pos = buffer.rfind(b'\n', 0, newline_pos)
|
|
49
|
-
if newline_pos == -1:
|
|
50
|
-
break
|
|
51
|
-
# Yield the line after this newline
|
|
52
|
-
line = buffer[newline_pos + 1 :]
|
|
53
|
-
buffer = buffer[: newline_pos + 1]
|
|
54
|
-
if line.strip():
|
|
55
|
-
yield line.decode('utf-8', errors='replace')
|
|
56
|
-
|
|
57
|
-
# Yield any remaining content as the first line of the file
|
|
58
|
-
if buffer.strip():
|
|
59
|
-
yield buffer.decode('utf-8', errors='replace')
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _extract_model(entry: dict) -> Optional[str]:
|
|
63
|
-
"""Extract model from a transcript entry (top level or nested in message)."""
|
|
64
|
-
return entry.get('model') or (entry.get('message') or {}).get('model')
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _extract_generation_id(entry: dict) -> Optional[str]:
|
|
68
|
-
"""Extract generation ID from a user-type transcript entry."""
|
|
69
|
-
if entry.get('type') == 'user':
|
|
70
|
-
return entry.get('uuid')
|
|
71
|
-
return None
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def extract_from_claude_transcript(
|
|
75
|
-
transcript_path: str,
|
|
76
|
-
) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
|
77
|
-
"""Extract IDE version, model, and latest generation ID from Claude Code transcript file.
|
|
78
|
-
|
|
79
|
-
The transcript is a JSONL file where each line is a JSON object.
|
|
80
|
-
We look for 'version' (IDE version), 'model', and 'uuid' (generation ID) fields.
|
|
81
|
-
The generation_id is the UUID of the latest 'user' type message.
|
|
82
|
-
|
|
83
|
-
Scans from end to start since latest entries are at the end.
|
|
84
|
-
Uses reverse reading to avoid loading entire file into memory.
|
|
85
|
-
|
|
86
|
-
Returns:
|
|
87
|
-
Tuple of (ide_version, model, generation_id), any may be None if not found.
|
|
88
|
-
"""
|
|
89
|
-
if not transcript_path:
|
|
90
|
-
return None, None, None
|
|
91
|
-
|
|
92
|
-
path = Path(transcript_path)
|
|
93
|
-
if not path.exists():
|
|
94
|
-
return None, None, None
|
|
95
|
-
|
|
96
|
-
ide_version = None
|
|
97
|
-
model = None
|
|
98
|
-
generation_id = None
|
|
99
|
-
|
|
100
|
-
try:
|
|
101
|
-
for line in _reverse_readline(path):
|
|
102
|
-
line = line.strip()
|
|
103
|
-
if not line:
|
|
104
|
-
continue
|
|
105
|
-
try:
|
|
106
|
-
entry = json.loads(line)
|
|
107
|
-
ide_version = ide_version or entry.get('version')
|
|
108
|
-
model = model or _extract_model(entry)
|
|
109
|
-
generation_id = generation_id or _extract_generation_id(entry)
|
|
110
|
-
|
|
111
|
-
if ide_version and model and generation_id:
|
|
112
|
-
break
|
|
113
|
-
except json.JSONDecodeError:
|
|
114
|
-
continue
|
|
115
|
-
except OSError:
|
|
116
|
-
pass
|
|
117
|
-
|
|
118
|
-
return ide_version, model, generation_id
|
|
119
|
-
|
|
120
11
|
|
|
121
12
|
@dataclass
|
|
122
13
|
class AIHookPayload:
|
|
123
|
-
"""Unified payload
|
|
14
|
+
"""Unified payload that normalizes field names across IDEs."""
|
|
124
15
|
|
|
125
16
|
# Event identification
|
|
126
|
-
event_name: Optional[str] = None # Canonical event type
|
|
17
|
+
event_name: Optional[str] = None # Canonical event type from AiHookEventType
|
|
127
18
|
conversation_id: Optional[str] = None
|
|
128
19
|
generation_id: Optional[str] = None
|
|
129
20
|
|
|
130
21
|
# User and IDE information
|
|
131
22
|
ide_user_email: Optional[str] = None
|
|
132
23
|
model: Optional[str] = None
|
|
133
|
-
ide_provider: str = None #
|
|
24
|
+
ide_provider: Optional[str] = None # Matches IDE.name (e.g. 'cursor', 'claude-code')
|
|
134
25
|
ide_version: Optional[str] = None
|
|
135
26
|
|
|
136
27
|
source: Optional[str] = None
|
|
137
28
|
|
|
138
29
|
# Event-specific data
|
|
139
|
-
prompt: Optional[str] = None #
|
|
140
|
-
file_path: Optional[str] = None #
|
|
141
|
-
mcp_server_name: Optional[str] = None #
|
|
142
|
-
mcp_tool_name: Optional[str] = None
|
|
143
|
-
mcp_arguments: Optional[dict] = None
|
|
144
|
-
|
|
145
|
-
@classmethod
|
|
146
|
-
def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload':
|
|
147
|
-
"""Create AIHookPayload from Cursor IDE payload.
|
|
148
|
-
|
|
149
|
-
Maps Cursor-specific event names to canonical event types.
|
|
150
|
-
"""
|
|
151
|
-
cursor_event_name = payload.get('hook_event_name', '')
|
|
152
|
-
# Map Cursor event name to canonical type, fallback to original if not found
|
|
153
|
-
canonical_event = CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name)
|
|
154
|
-
|
|
155
|
-
return cls(
|
|
156
|
-
event_name=canonical_event,
|
|
157
|
-
conversation_id=payload.get('conversation_id'),
|
|
158
|
-
generation_id=payload.get('generation_id'),
|
|
159
|
-
ide_user_email=payload.get('user_email'),
|
|
160
|
-
model=payload.get('model'),
|
|
161
|
-
ide_provider=AIIDEType.CURSOR.value,
|
|
162
|
-
ide_version=payload.get('cursor_version'),
|
|
163
|
-
prompt=payload.get('prompt', ''),
|
|
164
|
-
file_path=payload.get('file_path') or payload.get('path'),
|
|
165
|
-
mcp_server_name=payload.get('command'), # MCP server name
|
|
166
|
-
mcp_tool_name=payload.get('tool_name') or payload.get('tool'),
|
|
167
|
-
mcp_arguments=payload.get('arguments') or payload.get('tool_input') or payload.get('input'),
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
@classmethod
|
|
171
|
-
def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload':
|
|
172
|
-
"""Create AIHookPayload from Claude Code IDE payload.
|
|
173
|
-
|
|
174
|
-
Claude Code has a different structure:
|
|
175
|
-
- hook_event_name: 'UserPromptSubmit' or 'PreToolUse'
|
|
176
|
-
- For PreToolUse: tool_name determines if it's file read ('Read') or MCP ('mcp__*')
|
|
177
|
-
- tool_input contains tool arguments (e.g., file_path for Read tool)
|
|
178
|
-
- transcript_path points to JSONL file with version and model info
|
|
179
|
-
"""
|
|
180
|
-
hook_event_name = payload.get('hook_event_name', '')
|
|
181
|
-
tool_name = payload.get('tool_name', '')
|
|
182
|
-
tool_input = payload.get('tool_input')
|
|
183
|
-
|
|
184
|
-
if hook_event_name == 'UserPromptSubmit':
|
|
185
|
-
canonical_event = AiHookEventType.PROMPT
|
|
186
|
-
elif hook_event_name == 'PreToolUse':
|
|
187
|
-
canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION
|
|
188
|
-
else:
|
|
189
|
-
# Unknown event, use the raw event name
|
|
190
|
-
canonical_event = CLAUDE_CODE_EVENT_MAPPING.get(hook_event_name, hook_event_name)
|
|
191
|
-
|
|
192
|
-
# Extract file_path from tool_input for Read tool
|
|
193
|
-
file_path = None
|
|
194
|
-
if tool_name == 'Read' and isinstance(tool_input, dict):
|
|
195
|
-
file_path = tool_input.get('file_path')
|
|
196
|
-
|
|
197
|
-
# For MCP tools, the entire tool_input is the arguments
|
|
198
|
-
mcp_arguments = tool_input if tool_name.startswith('mcp__') else None
|
|
199
|
-
|
|
200
|
-
# Extract MCP server and tool name from tool_name (format: mcp__<server>__<tool>)
|
|
201
|
-
mcp_server_name = None
|
|
202
|
-
mcp_tool_name = None
|
|
203
|
-
if tool_name.startswith('mcp__'):
|
|
204
|
-
parts = tool_name.split('__')
|
|
205
|
-
if len(parts) >= 2:
|
|
206
|
-
mcp_server_name = parts[1]
|
|
207
|
-
if len(parts) >= 3:
|
|
208
|
-
mcp_tool_name = parts[2]
|
|
209
|
-
|
|
210
|
-
# Extract IDE version, model, and generation ID from transcript file
|
|
211
|
-
ide_version, model, generation_id = extract_from_claude_transcript(payload.get('transcript_path'))
|
|
212
|
-
|
|
213
|
-
# Extract user email from ~/.claude.json
|
|
214
|
-
claude_config = load_claude_config()
|
|
215
|
-
ide_user_email = get_user_email(claude_config) if claude_config else None
|
|
216
|
-
|
|
217
|
-
return cls(
|
|
218
|
-
event_name=canonical_event,
|
|
219
|
-
conversation_id=payload.get('session_id'),
|
|
220
|
-
generation_id=generation_id,
|
|
221
|
-
ide_user_email=ide_user_email,
|
|
222
|
-
model=model,
|
|
223
|
-
ide_provider=AIIDEType.CLAUDE_CODE.value,
|
|
224
|
-
ide_version=ide_version,
|
|
225
|
-
prompt=payload.get('prompt', ''),
|
|
226
|
-
file_path=file_path,
|
|
227
|
-
mcp_server_name=mcp_server_name,
|
|
228
|
-
mcp_tool_name=mcp_tool_name,
|
|
229
|
-
mcp_arguments=mcp_arguments,
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
@staticmethod
|
|
233
|
-
def is_payload_for_ide(payload: dict, ide: str) -> bool:
|
|
234
|
-
"""Check if the payload's event name matches the expected IDE.
|
|
235
|
-
|
|
236
|
-
This prevents double-processing when Cursor reads Claude Code hooks
|
|
237
|
-
or vice versa. If the payload's hook_event_name doesn't match the
|
|
238
|
-
expected IDE's event names, we should skip processing.
|
|
239
|
-
|
|
240
|
-
Args:
|
|
241
|
-
payload: The raw payload from the IDE
|
|
242
|
-
ide: The IDE name or AIIDEType enum value
|
|
243
|
-
|
|
244
|
-
Returns:
|
|
245
|
-
True if the payload matches the IDE, False otherwise.
|
|
246
|
-
"""
|
|
247
|
-
hook_event_name = payload.get('hook_event_name', '')
|
|
248
|
-
|
|
249
|
-
if ide == AIIDEType.CLAUDE_CODE:
|
|
250
|
-
return hook_event_name in CLAUDE_CODE_EVENT_NAMES
|
|
251
|
-
if ide == AIIDEType.CURSOR:
|
|
252
|
-
return hook_event_name in CURSOR_EVENT_NAMES
|
|
253
|
-
|
|
254
|
-
# Unknown IDE, allow processing
|
|
255
|
-
return True
|
|
256
|
-
|
|
257
|
-
@classmethod
|
|
258
|
-
def from_payload(cls, payload: dict, tool: str = AIIDEType.CURSOR.value) -> 'AIHookPayload':
|
|
259
|
-
"""Create AIHookPayload from any tool's payload.
|
|
260
|
-
|
|
261
|
-
Args:
|
|
262
|
-
payload: The raw payload from the IDE
|
|
263
|
-
tool: The IDE/tool name or AIIDEType enum value
|
|
264
|
-
|
|
265
|
-
Returns:
|
|
266
|
-
AIHookPayload instance
|
|
267
|
-
|
|
268
|
-
Raises:
|
|
269
|
-
ValueError: If the tool is not supported
|
|
270
|
-
"""
|
|
271
|
-
if tool == AIIDEType.CURSOR:
|
|
272
|
-
return cls.from_cursor_payload(payload)
|
|
273
|
-
if tool == AIIDEType.CLAUDE_CODE:
|
|
274
|
-
return cls.from_claude_code_payload(payload)
|
|
275
|
-
raise ValueError(f'Unsupported IDE/tool: {tool}')
|
|
30
|
+
prompt: Optional[str] = None # PROMPT events
|
|
31
|
+
file_path: Optional[str] = None # FILE_READ events
|
|
32
|
+
mcp_server_name: Optional[str] = None # MCP_EXECUTION events
|
|
33
|
+
mcp_tool_name: Optional[str] = None
|
|
34
|
+
mcp_arguments: Optional[dict] = None
|
|
@@ -1,26 +1,22 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Scan command for AI guardrails.
|
|
1
|
+
"""Scan command for AI guardrails IDE hooks.
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
for secrets before they are sent to AI models.
|
|
3
|
+
Reads a JSON payload from stdin, routes it through the IDE-specific parser and
|
|
4
|
+
the shared event handlers, then writes an IDE-specific JSON response to stdout.
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
beforeReadFile, beforeMCPExecution).
|
|
6
|
+
The handlers in ``handlers.py`` are agent-agnostic (they return
|
|
7
|
+
``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step.
|
|
11
8
|
"""
|
|
12
9
|
|
|
13
10
|
import sys
|
|
14
|
-
from typing import Annotated
|
|
11
|
+
from typing import Annotated, Optional, Union
|
|
15
12
|
|
|
16
13
|
import click
|
|
17
14
|
import typer
|
|
18
15
|
|
|
19
|
-
from cycode.cli.apps.ai_guardrails.
|
|
16
|
+
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide
|
|
17
|
+
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
|
|
20
18
|
from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event
|
|
21
|
-
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
22
19
|
from cycode.cli.apps.ai_guardrails.scan.policy import load_policy
|
|
23
|
-
from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder
|
|
24
20
|
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
|
|
25
21
|
from cycode.cli.apps.ai_guardrails.scan.utils import output_json, safe_json_parse
|
|
26
22
|
from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError
|
|
@@ -31,7 +27,7 @@ logger = get_logger('AI Guardrails')
|
|
|
31
27
|
|
|
32
28
|
|
|
33
29
|
def _get_auth_error_message(error: Exception) -> str:
|
|
34
|
-
"""
|
|
30
|
+
"""User-friendly message for authentication errors."""
|
|
35
31
|
if isinstance(error, click.ClickException):
|
|
36
32
|
# Missing credentials
|
|
37
33
|
return f'{error.message} Please run `cycode auth` to set up your credentials.'
|
|
@@ -47,6 +43,23 @@ def _get_auth_error_message(error: Exception) -> str:
|
|
|
47
43
|
return 'Authentication failed. Please run `cycode auth` to set up your credentials.'
|
|
48
44
|
|
|
49
45
|
|
|
46
|
+
def _deny_for_event(
|
|
47
|
+
event_name: Optional[Union[str, AiHookEventType]],
|
|
48
|
+
user_message: str,
|
|
49
|
+
agent_message: Optional[str] = None,
|
|
50
|
+
) -> HookDecision:
|
|
51
|
+
"""Build a deny decision matched to ``event_name``'s response shape.
|
|
52
|
+
|
|
53
|
+
PROMPT events use the prompt-block shape (no agent_message). For anything
|
|
54
|
+
else — including unknown event names — fall back to FILE_READ since
|
|
55
|
+
FILE_READ and MCP_EXECUTION share the same response shape on both IDEs.
|
|
56
|
+
"""
|
|
57
|
+
if event_name == AiHookEventType.PROMPT:
|
|
58
|
+
return HookDecision.deny(AiHookEventType.PROMPT, user_message)
|
|
59
|
+
target = event_name if isinstance(event_name, AiHookEventType) else AiHookEventType.FILE_READ
|
|
60
|
+
return HookDecision.deny(target, user_message, agent_message)
|
|
61
|
+
|
|
62
|
+
|
|
50
63
|
def _initialize_clients(ctx: typer.Context) -> None:
|
|
51
64
|
"""Initialize API clients.
|
|
52
65
|
|
|
@@ -69,44 +82,36 @@ def scan_command(
|
|
|
69
82
|
help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.',
|
|
70
83
|
hidden=True,
|
|
71
84
|
),
|
|
72
|
-
] =
|
|
85
|
+
] = DEFAULT_IDE_NAME,
|
|
73
86
|
) -> None:
|
|
74
87
|
"""Scan content from AI IDE hooks for secrets.
|
|
75
88
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
The hook event type is determined from the event field in the payload (field name
|
|
80
|
-
varies by IDE). Each IDE may support different hook events for scanning prompts,
|
|
81
|
-
file access, and tool executions.
|
|
82
|
-
|
|
83
|
-
Example usage (from IDE hooks configuration):
|
|
84
|
-
{ "command": "cycode ai-guardrails scan" }
|
|
89
|
+
Reads a JSON payload from stdin and outputs a JSON response to stdout
|
|
90
|
+
indicating whether to allow or block the action.
|
|
85
91
|
"""
|
|
92
|
+
ide_integration = get_ide(ide)
|
|
93
|
+
|
|
86
94
|
stdin_data = sys.stdin.read().strip()
|
|
87
95
|
payload = safe_json_parse(stdin_data)
|
|
88
96
|
|
|
89
|
-
tool = ide.lower()
|
|
90
|
-
response_builder = get_response_builder(tool)
|
|
91
|
-
|
|
92
97
|
if not payload:
|
|
93
98
|
logger.debug('Empty or invalid JSON payload received')
|
|
94
|
-
output_json(
|
|
99
|
+
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
|
|
95
100
|
return
|
|
96
101
|
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
if not
|
|
102
|
+
# Prevent cross-IDE processing (e.g. Cursor reading Claude Code hooks
|
|
103
|
+
# from ~/.claude/settings.json).
|
|
104
|
+
if not ide_integration.matches_payload(payload):
|
|
100
105
|
logger.debug(
|
|
101
106
|
'Payload event does not match expected IDE, skipping',
|
|
102
|
-
extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide':
|
|
107
|
+
extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': ide_integration.name},
|
|
103
108
|
)
|
|
104
|
-
output_json(
|
|
109
|
+
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
|
|
105
110
|
return
|
|
106
111
|
|
|
107
|
-
unified_payload =
|
|
112
|
+
unified_payload = ide_integration.parse_hook_payload(payload)
|
|
108
113
|
event_name = unified_payload.event_name
|
|
109
|
-
logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, '
|
|
114
|
+
logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'ide': ide_integration.name})
|
|
110
115
|
|
|
111
116
|
workspace_roots = payload.get('workspace_roots', ['.'])
|
|
112
117
|
policy = load_policy(workspace_roots[0])
|
|
@@ -117,26 +122,33 @@ def scan_command(
|
|
|
117
122
|
handler = get_handler_for_event(event_name)
|
|
118
123
|
if handler is None:
|
|
119
124
|
logger.debug('Unknown hook event, allowing by default', extra={'event_name': event_name})
|
|
120
|
-
output_json(
|
|
125
|
+
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
|
|
121
126
|
return
|
|
122
127
|
|
|
123
|
-
|
|
124
|
-
logger.debug('Hook handler completed', extra={'event_name': event_name, '
|
|
125
|
-
output_json(
|
|
128
|
+
decision = handler(ctx, unified_payload, policy)
|
|
129
|
+
logger.debug('Hook handler completed', extra={'event_name': event_name, 'action': decision.action.value})
|
|
130
|
+
output_json(ide_integration.build_hook_response(decision))
|
|
126
131
|
|
|
127
132
|
except (click.ClickException, HttpUnauthorizedError) as e:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
output_json(
|
|
134
|
+
ide_integration.build_hook_response(
|
|
135
|
+
_deny_for_event(event_name, _get_auth_error_message(e), 'Authentication required')
|
|
136
|
+
)
|
|
137
|
+
)
|
|
133
138
|
|
|
134
139
|
except Exception as e:
|
|
135
140
|
logger.error('Hook handler failed', exc_info=e)
|
|
136
141
|
if policy.get('fail_open', True):
|
|
137
|
-
output_json(
|
|
138
|
-
return
|
|
139
|
-
if event_name == AiHookEventType.PROMPT:
|
|
140
|
-
output_json(response_builder.deny_prompt('Cycode guardrails error - blocking due to fail-closed policy'))
|
|
142
|
+
output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)))
|
|
141
143
|
return
|
|
142
|
-
output_json(
|
|
144
|
+
output_json(
|
|
145
|
+
ide_integration.build_hook_response(
|
|
146
|
+
_deny_for_event(
|
|
147
|
+
event_name,
|
|
148
|
+
'Cycode guardrails error - blocking due to fail-closed policy'
|
|
149
|
+
if event_name == AiHookEventType.PROMPT
|
|
150
|
+
else 'Cycode guardrails error',
|
|
151
|
+
'Blocking due to fail-closed policy',
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
)
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Canonical event types and outcome enums for AI guardrails.
|
|
2
|
+
|
|
3
|
+
Per-IDE event-name mappings live on the IDE class (in
|
|
4
|
+
`cycode/cli/apps/ai_guardrails/ides/`); only the IDE-agnostic enums are kept
|
|
5
|
+
here.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
import sys
|
|
4
9
|
|
|
@@ -13,36 +18,13 @@ else:
|
|
|
13
18
|
|
|
14
19
|
|
|
15
20
|
class AiHookEventType(StrEnum):
|
|
16
|
-
"""Canonical event types
|
|
17
|
-
|
|
18
|
-
These are IDE-agnostic event types. Each IDE's specific event names
|
|
19
|
-
are mapped to these canonical types using the mapping dictionaries below.
|
|
20
|
-
"""
|
|
21
|
+
"""Canonical, IDE-agnostic hook event types."""
|
|
21
22
|
|
|
22
23
|
PROMPT = 'Prompt'
|
|
23
24
|
FILE_READ = 'FileRead'
|
|
24
25
|
MCP_EXECUTION = 'McpExecution'
|
|
25
26
|
|
|
26
27
|
|
|
27
|
-
# IDE-specific event name mappings to canonical types
|
|
28
|
-
CURSOR_EVENT_MAPPING = {
|
|
29
|
-
'beforeSubmitPrompt': AiHookEventType.PROMPT,
|
|
30
|
-
'beforeReadFile': AiHookEventType.FILE_READ,
|
|
31
|
-
'beforeMCPExecution': AiHookEventType.MCP_EXECUTION,
|
|
32
|
-
}
|
|
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
|
-
|
|
45
|
-
|
|
46
28
|
class AIHookOutcome(StrEnum):
|
|
47
29
|
"""Outcome of an AI hook event evaluation."""
|
|
48
30
|
|
|
@@ -52,11 +34,7 @@ class AIHookOutcome(StrEnum):
|
|
|
52
34
|
|
|
53
35
|
|
|
54
36
|
class BlockReason(StrEnum):
|
|
55
|
-
"""
|
|
56
|
-
|
|
57
|
-
These are categorical reasons sent to the backend for tracking/analytics,
|
|
58
|
-
separate from the detailed user-facing messages.
|
|
59
|
-
"""
|
|
37
|
+
"""Categorical reason for blocking (sent to backend for tracking)."""
|
|
60
38
|
|
|
61
39
|
SECRETS_IN_PROMPT = 'secrets_in_prompt'
|
|
62
40
|
SECRETS_IN_FILE = 'secrets_in_file'
|
|
@@ -1,18 +1,12 @@
|
|
|
1
|
+
"""Handle AI guardrails session start: auth, conversation creation, session context."""
|
|
2
|
+
|
|
1
3
|
import sys
|
|
2
4
|
from typing import TYPE_CHECKING, Annotated, Optional
|
|
3
5
|
|
|
4
6
|
import typer
|
|
5
7
|
|
|
6
|
-
from cycode.cli.apps.ai_guardrails.
|
|
7
|
-
from cycode.cli.apps.ai_guardrails.
|
|
8
|
-
get_mcp_servers,
|
|
9
|
-
get_user_email,
|
|
10
|
-
load_claude_config,
|
|
11
|
-
load_claude_settings,
|
|
12
|
-
resolve_plugins,
|
|
13
|
-
)
|
|
14
|
-
from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config
|
|
15
|
-
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload, extract_from_claude_transcript
|
|
8
|
+
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide
|
|
9
|
+
from cycode.cli.apps.ai_guardrails.ides.base import IDE
|
|
16
10
|
from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse
|
|
17
11
|
from cycode.cli.apps.auth.auth_common import get_authorization_info
|
|
18
12
|
from cycode.cli.apps.auth.auth_manager import AuthManager
|
|
@@ -26,69 +20,10 @@ if TYPE_CHECKING:
|
|
|
26
20
|
logger = get_logger('AI Guardrails')
|
|
27
21
|
|
|
28
22
|
|
|
29
|
-
def
|
|
30
|
-
"""Build an AIHookPayload from a session-start stdin payload."""
|
|
31
|
-
if ide == AIIDEType.CLAUDE_CODE:
|
|
32
|
-
claude_config = load_claude_config()
|
|
33
|
-
ide_user_email = get_user_email(claude_config) if claude_config else None
|
|
34
|
-
ide_version, _, _ = extract_from_claude_transcript(payload.get('transcript_path'))
|
|
35
|
-
|
|
36
|
-
return AIHookPayload(
|
|
37
|
-
conversation_id=payload.get('session_id'),
|
|
38
|
-
ide_user_email=ide_user_email,
|
|
39
|
-
model=payload.get('model'),
|
|
40
|
-
ide_provider=AIIDEType.CLAUDE_CODE.value,
|
|
41
|
-
ide_version=ide_version,
|
|
42
|
-
source=payload.get('source'),
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
# Cursor
|
|
46
|
-
return AIHookPayload(
|
|
47
|
-
conversation_id=payload.get('conversation_id'),
|
|
48
|
-
ide_user_email=payload.get('user_email'),
|
|
49
|
-
model=payload.get('model'),
|
|
50
|
-
ide_provider=AIIDEType.CURSOR.value,
|
|
51
|
-
ide_version=payload.get('cursor_version'),
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _get_claude_code_session_context() -> tuple[dict, dict]:
|
|
56
|
-
"""Return (mcp_servers, enabled_plugins) for Claude Code.
|
|
57
|
-
|
|
58
|
-
Merges MCP servers from ~/.claude.json (user-configured) with those contributed
|
|
59
|
-
by enabled plugins. Plugin metadata (name, version, description) is included in
|
|
60
|
-
the enabled_plugins dict when resolvable.
|
|
61
|
-
"""
|
|
62
|
-
config = load_claude_config()
|
|
63
|
-
mcp_servers = dict(get_mcp_servers(config) or {}) if config else {}
|
|
64
|
-
|
|
65
|
-
settings = load_claude_settings()
|
|
66
|
-
if settings:
|
|
67
|
-
plugin_mcp, enriched_plugins = resolve_plugins(settings)
|
|
68
|
-
mcp_servers.update(plugin_mcp)
|
|
69
|
-
else:
|
|
70
|
-
enriched_plugins = {}
|
|
71
|
-
|
|
72
|
-
return mcp_servers, enriched_plugins
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _get_cursor_session_context() -> tuple[dict, dict]:
|
|
76
|
-
"""Return (mcp_servers, enabled_plugins) for Cursor. Cursor has no plugin system."""
|
|
77
|
-
config = load_cursor_config()
|
|
78
|
-
mcp_servers = dict(get_mcp_servers(config) or {}) if config else {}
|
|
79
|
-
return mcp_servers, {}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str, user_email: Optional[str]) -> None:
|
|
23
|
+
def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None:
|
|
83
24
|
"""Report IDE session context to the AI security manager. Never raises."""
|
|
84
25
|
try:
|
|
85
|
-
|
|
86
|
-
mcp_servers, enabled_plugins = _get_claude_code_session_context()
|
|
87
|
-
elif ide == AIIDEType.CURSOR:
|
|
88
|
-
mcp_servers, enabled_plugins = _get_cursor_session_context()
|
|
89
|
-
else:
|
|
90
|
-
return
|
|
91
|
-
|
|
26
|
+
mcp_servers, enabled_plugins = ide.get_session_context()
|
|
92
27
|
if not mcp_servers and not enabled_plugins:
|
|
93
28
|
return
|
|
94
29
|
ai_client.report_session_context(
|
|
@@ -109,16 +44,17 @@ def session_start_command(
|
|
|
109
44
|
help='IDE that triggered the session start.',
|
|
110
45
|
hidden=True,
|
|
111
46
|
),
|
|
112
|
-
] =
|
|
47
|
+
] = DEFAULT_IDE_NAME,
|
|
113
48
|
) -> None:
|
|
114
49
|
"""Handle session start: ensure auth, create conversation, report session context."""
|
|
50
|
+
ide_integration = get_ide(ide)
|
|
51
|
+
|
|
115
52
|
# Step 1: Ensure authentication
|
|
116
53
|
auth_info = get_authorization_info(ctx)
|
|
117
54
|
if auth_info is None:
|
|
118
55
|
logger.debug('Not authenticated, starting authentication')
|
|
119
56
|
try:
|
|
120
|
-
|
|
121
|
-
auth_manager.authenticate()
|
|
57
|
+
AuthManager().authenticate()
|
|
122
58
|
except Exception as err:
|
|
123
59
|
handle_auth_exception(ctx, err)
|
|
124
60
|
return
|
|
@@ -136,8 +72,8 @@ def session_start_command(
|
|
|
136
72
|
logger.debug('Empty or invalid stdin payload, skipping session initialization')
|
|
137
73
|
return
|
|
138
74
|
|
|
139
|
-
# Step 3: Build session payload
|
|
140
|
-
session_payload =
|
|
75
|
+
# Step 3: Build session payload + initialize API client
|
|
76
|
+
session_payload = ide_integration.build_session_payload(payload)
|
|
141
77
|
|
|
142
78
|
try:
|
|
143
79
|
ai_client = get_ai_security_manager_client(ctx)
|
|
@@ -151,5 +87,5 @@ def session_start_command(
|
|
|
151
87
|
except Exception as e:
|
|
152
88
|
logger.debug('Failed to create conversation during session start', exc_info=e)
|
|
153
89
|
|
|
154
|
-
# Step 5: Report session context (MCP servers)
|
|
155
|
-
_report_session_context(ai_client,
|
|
90
|
+
# Step 5: Report session context (MCP servers, enabled plugins)
|
|
91
|
+
_report_session_context(ai_client, ide_integration, session_payload.ide_user_email)
|