cycode 3.15.3.dev7__py3-none-any.whl → 3.15.4.dev1__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 +51 -138
- cycode/cli/apps/ai_guardrails/ides/__init__.py +44 -0
- cycode/cli/apps/ai_guardrails/ides/base.py +156 -0
- cycode/cli/apps/ai_guardrails/ides/claude_code.py +389 -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 +46 -89
- 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-3.15.3.dev7.dist-info → cycode-3.15.4.dev1.dist-info}/METADATA +36 -1
- {cycode-3.15.3.dev7.dist-info → cycode-3.15.4.dev1.dist-info}/RECORD +21 -20
- 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.dev7.dist-info → cycode-3.15.4.dev1.dist-info}/WHEEL +0 -0
- {cycode-3.15.3.dev7.dist-info → cycode-3.15.4.dev1.dist-info}/entry_points.txt +0 -0
- {cycode-3.15.3.dev7.dist-info → cycode-3.15.4.dev1.dist-info}/licenses/LICENCE +0 -0
|
@@ -5,14 +5,10 @@ from typing import Annotated, Optional
|
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
|
|
8
|
-
from cycode.cli.apps.ai_guardrails.command_utils import
|
|
9
|
-
|
|
10
|
-
resolve_repo_path,
|
|
11
|
-
validate_and_parse_ide,
|
|
12
|
-
validate_scope,
|
|
13
|
-
)
|
|
14
|
-
from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType, InstallMode, PolicyMode
|
|
8
|
+
from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope
|
|
9
|
+
from cycode.cli.apps.ai_guardrails.consts import InstallMode, PolicyMode
|
|
15
10
|
from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks
|
|
11
|
+
from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides
|
|
16
12
|
|
|
17
13
|
|
|
18
14
|
def install_command(
|
|
@@ -29,9 +25,9 @@ def install_command(
|
|
|
29
25
|
str,
|
|
30
26
|
typer.Option(
|
|
31
27
|
'--ide',
|
|
32
|
-
help='IDE to install hooks for (
|
|
28
|
+
help=f'IDE to install hooks for ({", ".join(IDES)}, or "all" for every supported IDE).',
|
|
33
29
|
),
|
|
34
|
-
] =
|
|
30
|
+
] = DEFAULT_IDE_NAME,
|
|
35
31
|
repo_path: Annotated[
|
|
36
32
|
Optional[Path],
|
|
37
33
|
typer.Option(
|
|
@@ -55,35 +51,30 @@ def install_command(
|
|
|
55
51
|
) -> None:
|
|
56
52
|
"""Install AI guardrails hooks for supported IDEs.
|
|
57
53
|
|
|
58
|
-
|
|
59
|
-
and MCP tool calls for secrets before they
|
|
54
|
+
Configures the specified IDE to use Cycode for scanning prompts, file reads,
|
|
55
|
+
and MCP tool calls for secrets before they reach the AI model.
|
|
60
56
|
|
|
61
57
|
Examples:
|
|
62
58
|
cycode ai-guardrails install # Install in report mode (default)
|
|
63
59
|
cycode ai-guardrails install --mode block # Install in block mode
|
|
64
60
|
cycode ai-guardrails install --scope repo # Install for current repo only
|
|
65
|
-
cycode ai-guardrails install --ide
|
|
66
|
-
cycode ai-guardrails install --ide all # Install for
|
|
67
|
-
cycode ai-guardrails install --scope repo --repo-path /path/to/repo
|
|
61
|
+
cycode ai-guardrails install --ide claude-code # Install for a specific IDE
|
|
62
|
+
cycode ai-guardrails install --ide all # Install for every supported IDE
|
|
68
63
|
"""
|
|
69
|
-
# Validate inputs
|
|
70
64
|
validate_scope(scope)
|
|
71
65
|
repo_path = resolve_repo_path(scope, repo_path)
|
|
72
|
-
|
|
66
|
+
ides_to_install = resolve_ides(ide)
|
|
73
67
|
|
|
74
|
-
|
|
68
|
+
report_mode = mode == InstallMode.REPORT
|
|
75
69
|
|
|
76
70
|
results: list[tuple[str, bool, str]] = []
|
|
77
71
|
for current_ide in ides_to_install:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode)
|
|
81
|
-
results.append((ide_name, success, message))
|
|
72
|
+
success, message = install_hooks(current_ide, scope, repo_path, report_mode=report_mode)
|
|
73
|
+
results.append((current_ide.display_name, success, message))
|
|
82
74
|
|
|
83
|
-
# Report results for each IDE
|
|
84
75
|
any_success = False
|
|
85
76
|
all_success = True
|
|
86
|
-
for
|
|
77
|
+
for _name, success, message in results:
|
|
87
78
|
if success:
|
|
88
79
|
console.print(f'[green]✓[/] {message}')
|
|
89
80
|
any_success = True
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
1
|
+
"""Hook handlers for AI IDE events.
|
|
2
|
+
|
|
3
|
+
Each handler receives a unified payload and policy, applies the scan + policy
|
|
4
|
+
logic, and returns a canonical ``HookDecision``. ``scan_command`` translates
|
|
5
|
+
that decision into the IDE-specific JSON response via ``IDE.build_hook_response``.
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
Handlers are agent-agnostic by design — adding a new IDE doesn't require
|
|
8
|
+
touching any handler in this module.
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
import json
|
|
@@ -14,9 +17,9 @@ from typing import Callable, Optional
|
|
|
14
17
|
import typer
|
|
15
18
|
|
|
16
19
|
from cycode.cli.apps.ai_guardrails.consts import PolicyMode
|
|
20
|
+
from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
|
|
17
21
|
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
18
22
|
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
|
|
19
|
-
from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder
|
|
20
23
|
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason
|
|
21
24
|
from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8
|
|
22
25
|
from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func
|
|
@@ -30,21 +33,17 @@ from cycode.logger import get_logger
|
|
|
30
33
|
logger = get_logger('AI Guardrails')
|
|
31
34
|
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
"""
|
|
35
|
-
Handle beforeSubmitPrompt hook.
|
|
36
|
+
HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision]
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"""
|
|
38
|
+
|
|
39
|
+
def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
|
|
40
|
+
"""Scan prompt text for secrets before it's sent to the AI model."""
|
|
40
41
|
ai_client = ctx.obj['ai_security_client']
|
|
41
|
-
ide = payload.ide_provider
|
|
42
|
-
response_builder = get_response_builder(ide)
|
|
43
42
|
|
|
44
43
|
prompt_config = get_policy_value(policy, 'prompt', default={})
|
|
45
44
|
if not get_policy_value(prompt_config, 'enabled', default=True):
|
|
46
45
|
ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
|
|
47
|
-
return
|
|
46
|
+
return HookDecision.allow(AiHookEventType.PROMPT)
|
|
48
47
|
|
|
49
48
|
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
|
|
50
49
|
prompt = payload.prompt or ''
|
|
@@ -66,9 +65,9 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
66
65
|
if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK:
|
|
67
66
|
outcome = AIHookOutcome.BLOCKED
|
|
68
67
|
user_message = f'{violation_summary}. Remove secrets before sending.'
|
|
69
|
-
return
|
|
68
|
+
return HookDecision.deny(AiHookEventType.PROMPT, user_message)
|
|
70
69
|
outcome = AIHookOutcome.WARNED
|
|
71
|
-
return
|
|
70
|
+
return HookDecision.allow(AiHookEventType.PROMPT)
|
|
72
71
|
except Exception as e:
|
|
73
72
|
outcome = (
|
|
74
73
|
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
@@ -87,21 +86,14 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
87
86
|
)
|
|
88
87
|
|
|
89
88
|
|
|
90
|
-
def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) ->
|
|
91
|
-
"""
|
|
92
|
-
Handle beforeReadFile hook.
|
|
93
|
-
|
|
94
|
-
Blocks sensitive files (via deny_globs) and scans file content for secrets.
|
|
95
|
-
Returns {"permission": "deny"} to block, {"permission": "allow"} to allow.
|
|
96
|
-
"""
|
|
89
|
+
def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
|
|
90
|
+
"""Block sensitive paths and scan file content for secrets."""
|
|
97
91
|
ai_client = ctx.obj['ai_security_client']
|
|
98
|
-
ide = payload.ide_provider
|
|
99
|
-
response_builder = get_response_builder(ide)
|
|
100
92
|
|
|
101
93
|
file_read_config = get_policy_value(policy, 'file_read', default={})
|
|
102
94
|
if not get_policy_value(file_read_config, 'enabled', default=True):
|
|
103
95
|
ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
|
|
104
|
-
return
|
|
96
|
+
return HookDecision.allow(AiHookEventType.FILE_READ)
|
|
105
97
|
|
|
106
98
|
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
|
|
107
99
|
file_path = payload.file_path or ''
|
|
@@ -113,20 +105,19 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
113
105
|
error_message = None
|
|
114
106
|
|
|
115
107
|
try:
|
|
116
|
-
# Check path-based denylist first
|
|
117
108
|
is_sensitive_path = is_denied_path(file_path, policy)
|
|
118
109
|
if is_sensitive_path:
|
|
119
110
|
block_reason = BlockReason.SENSITIVE_PATH
|
|
120
111
|
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
|
|
121
112
|
outcome = AIHookOutcome.BLOCKED
|
|
122
113
|
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
|
|
123
|
-
return
|
|
114
|
+
return HookDecision.deny(
|
|
115
|
+
AiHookEventType.FILE_READ,
|
|
124
116
|
user_message,
|
|
125
117
|
'This file path is classified as sensitive; do not read/send it to the model.',
|
|
126
118
|
)
|
|
127
|
-
# Warn mode
|
|
119
|
+
# Warn mode: if content scan is enabled, emit a separate event for the
|
|
128
120
|
# sensitive path so the finally block can independently track the scan result.
|
|
129
|
-
# If content scan is disabled, a single event (from finally) is enough.
|
|
130
121
|
outcome = AIHookOutcome.WARNED
|
|
131
122
|
if get_policy_value(file_read_config, 'scan_content', default=True):
|
|
132
123
|
ai_client.create_event(
|
|
@@ -136,11 +127,9 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
136
127
|
block_reason=BlockReason.SENSITIVE_PATH,
|
|
137
128
|
file_path=payload.file_path,
|
|
138
129
|
)
|
|
139
|
-
# Reset for the content scan result tracked by the finally block
|
|
140
130
|
block_reason = None
|
|
141
131
|
outcome = AIHookOutcome.ALLOWED
|
|
142
132
|
|
|
143
|
-
# Scan file content if enabled
|
|
144
133
|
if get_policy_value(file_read_config, 'scan_content', default=True):
|
|
145
134
|
violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy)
|
|
146
135
|
if violation_summary:
|
|
@@ -148,27 +137,28 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
148
137
|
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
|
|
149
138
|
outcome = AIHookOutcome.BLOCKED
|
|
150
139
|
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
|
|
151
|
-
return
|
|
140
|
+
return HookDecision.deny(
|
|
141
|
+
AiHookEventType.FILE_READ,
|
|
152
142
|
user_message,
|
|
153
143
|
'Secrets detected; do not send this file to the model.',
|
|
154
144
|
)
|
|
155
|
-
# Warn mode - ask user for permission
|
|
156
145
|
outcome = AIHookOutcome.WARNED
|
|
157
146
|
user_message = f'Cycode detected secrets in {file_path}. {violation_summary}'
|
|
158
|
-
return
|
|
147
|
+
return HookDecision.ask(
|
|
148
|
+
AiHookEventType.FILE_READ,
|
|
159
149
|
user_message,
|
|
160
150
|
'Possible secrets detected; proceed with caution.',
|
|
161
151
|
)
|
|
162
152
|
|
|
163
|
-
# If path was sensitive but content scan found no secrets (or scan disabled), still warn
|
|
164
153
|
if is_sensitive_path:
|
|
165
154
|
user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?'
|
|
166
|
-
return
|
|
155
|
+
return HookDecision.ask(
|
|
156
|
+
AiHookEventType.FILE_READ,
|
|
167
157
|
user_message,
|
|
168
158
|
'This file path is classified as sensitive; proceed with caution.',
|
|
169
159
|
)
|
|
170
160
|
|
|
171
|
-
return
|
|
161
|
+
return HookDecision.allow(AiHookEventType.FILE_READ)
|
|
172
162
|
except Exception as e:
|
|
173
163
|
outcome = (
|
|
174
164
|
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
@@ -188,22 +178,14 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
|
|
|
188
178
|
)
|
|
189
179
|
|
|
190
180
|
|
|
191
|
-
def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) ->
|
|
192
|
-
"""
|
|
193
|
-
Handle beforeMCPExecution hook.
|
|
194
|
-
|
|
195
|
-
Scans tool arguments for secrets before MCP tool execution.
|
|
196
|
-
Returns {"permission": "deny"} to block, {"permission": "ask"} to warn,
|
|
197
|
-
{"permission": "allow"} to allow.
|
|
198
|
-
"""
|
|
181
|
+
def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
|
|
182
|
+
"""Scan MCP tool arguments for secrets before execution."""
|
|
199
183
|
ai_client = ctx.obj['ai_security_client']
|
|
200
|
-
ide = payload.ide_provider
|
|
201
|
-
response_builder = get_response_builder(ide)
|
|
202
184
|
|
|
203
185
|
mcp_config = get_policy_value(policy, 'mcp', default={})
|
|
204
186
|
if not get_policy_value(mcp_config, 'enabled', default=True):
|
|
205
187
|
ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED)
|
|
206
|
-
return
|
|
188
|
+
return HookDecision.allow(AiHookEventType.MCP_EXECUTION)
|
|
207
189
|
|
|
208
190
|
mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
|
|
209
191
|
tool = payload.mcp_tool_name or 'unknown'
|
|
@@ -227,17 +209,19 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
227
209
|
if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
|
|
228
210
|
outcome = AIHookOutcome.BLOCKED
|
|
229
211
|
user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}'
|
|
230
|
-
return
|
|
212
|
+
return HookDecision.deny(
|
|
213
|
+
AiHookEventType.MCP_EXECUTION,
|
|
231
214
|
user_message,
|
|
232
215
|
'Do not pass secrets to tools. Use secret references (name/id) instead.',
|
|
233
216
|
)
|
|
234
217
|
outcome = AIHookOutcome.WARNED
|
|
235
|
-
return
|
|
218
|
+
return HookDecision.ask(
|
|
219
|
+
AiHookEventType.MCP_EXECUTION,
|
|
236
220
|
f'{violation_summary} in MCP tool call "{tool}". Allow execution?',
|
|
237
221
|
'Possible secrets detected in tool arguments; proceed with caution.',
|
|
238
222
|
)
|
|
239
223
|
|
|
240
|
-
return
|
|
224
|
+
return HookDecision.allow(AiHookEventType.MCP_EXECUTION)
|
|
241
225
|
except Exception as e:
|
|
242
226
|
outcome = (
|
|
243
227
|
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
@@ -256,16 +240,9 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
|
|
|
256
240
|
)
|
|
257
241
|
|
|
258
242
|
|
|
259
|
-
def get_handler_for_event(event_type: str) -> Optional[
|
|
260
|
-
"""
|
|
261
|
-
|
|
262
|
-
Args:
|
|
263
|
-
event_type: Canonical event type string (from AiHookEventType enum)
|
|
264
|
-
|
|
265
|
-
Returns:
|
|
266
|
-
Handler function or None if event type is not recognized
|
|
267
|
-
"""
|
|
268
|
-
handlers = {
|
|
243
|
+
def get_handler_for_event(event_type: str) -> Optional[HandlerFn]:
|
|
244
|
+
"""Look up the handler for a canonical event type."""
|
|
245
|
+
handlers: dict[str, HandlerFn] = {
|
|
269
246
|
AiHookEventType.PROMPT.value: handle_before_submit_prompt,
|
|
270
247
|
AiHookEventType.FILE_READ.value: handle_before_read_file,
|
|
271
248
|
AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution,
|
|
@@ -275,32 +252,24 @@ def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context,
|
|
|
275
252
|
|
|
276
253
|
def _setup_scan_context(ctx: typer.Context) -> typer.Context:
|
|
277
254
|
"""Set up minimal context for scan_documents without progress bars or printing."""
|
|
278
|
-
|
|
279
|
-
# Set up minimal required context
|
|
280
255
|
ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection])
|
|
281
|
-
ctx.obj['sync'] = True
|
|
282
|
-
ctx.obj['scan_type'] = ScanTypeOption.SECRET
|
|
283
|
-
ctx.obj['severity_threshold'] = SeverityOption.INFO
|
|
284
|
-
|
|
285
|
-
# Set command name for scan logic
|
|
256
|
+
ctx.obj['sync'] = True
|
|
257
|
+
ctx.obj['scan_type'] = ScanTypeOption.SECRET
|
|
258
|
+
ctx.obj['severity_threshold'] = SeverityOption.INFO
|
|
286
259
|
ctx.info_name = 'ai_guardrails'
|
|
287
|
-
|
|
288
260
|
return ctx
|
|
289
261
|
|
|
290
262
|
|
|
291
263
|
def _perform_scan(
|
|
292
264
|
ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float
|
|
293
265
|
) -> tuple[Optional[str], Optional[str]]:
|
|
294
|
-
"""
|
|
295
|
-
Perform a scan on documents and extract results.
|
|
266
|
+
"""Run a scan on documents, returning (violation_summary, scan_id).
|
|
296
267
|
|
|
297
|
-
|
|
298
|
-
Raises exception if scan fails or times out (triggers fail_open policy).
|
|
268
|
+
Raises on scan failure / timeout so the fail-open policy can take over.
|
|
299
269
|
"""
|
|
300
270
|
if not documents:
|
|
301
271
|
return None, None
|
|
302
272
|
|
|
303
|
-
# Get the thread function for scanning
|
|
304
273
|
scan_batch_thread_func = _get_scan_documents_thread_func(
|
|
305
274
|
ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters
|
|
306
275
|
)
|
|
@@ -324,7 +293,6 @@ def _perform_scan(
|
|
|
324
293
|
|
|
325
294
|
scan_id = local_scan_result.scan_id
|
|
326
295
|
|
|
327
|
-
# Check if there are any detections
|
|
328
296
|
if local_scan_result.detections_count > 0:
|
|
329
297
|
violation_summary = build_violation_summary([local_scan_result])
|
|
330
298
|
return violation_summary, scan_id
|
|
@@ -333,12 +301,7 @@ def _perform_scan(
|
|
|
333
301
|
|
|
334
302
|
|
|
335
303
|
def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tuple[Optional[str], Optional[str]]:
|
|
336
|
-
"""
|
|
337
|
-
Scan text content for secrets using Cycode CLI.
|
|
338
|
-
|
|
339
|
-
Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean.
|
|
340
|
-
Raises exception on error or timeout.
|
|
341
|
-
"""
|
|
304
|
+
"""Scan text content for secrets using Cycode CLI."""
|
|
342
305
|
if not text:
|
|
343
306
|
return None, None
|
|
344
307
|
|
|
@@ -349,12 +312,7 @@ def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tu
|
|
|
349
312
|
|
|
350
313
|
|
|
351
314
|
def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> tuple[Optional[str], Optional[str]]:
|
|
352
|
-
"""
|
|
353
|
-
Scan a file path for secrets.
|
|
354
|
-
|
|
355
|
-
Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean.
|
|
356
|
-
Raises exception on error or timeout.
|
|
357
|
-
"""
|
|
315
|
+
"""Scan a file path for secrets."""
|
|
358
316
|
if not file_path or not os.path.isfile(file_path):
|
|
359
317
|
return None, None
|
|
360
318
|
|
|
@@ -363,7 +321,6 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) ->
|
|
|
363
321
|
with open(file_path, encoding='utf-8', errors='replace') as f:
|
|
364
322
|
content = f.read(max_bytes)
|
|
365
323
|
|
|
366
|
-
# Get timeout from policy
|
|
367
324
|
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
|
|
368
325
|
timeout_seconds = timeout_ms / 1000.0
|
|
369
326
|
|
|
@@ -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
|