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.
Files changed (27) hide show
  1. cycode/__init__.py +1 -1
  2. cycode/cli/apps/ai_guardrails/command_utils.py +2 -45
  3. cycode/cli/apps/ai_guardrails/consts.py +3 -135
  4. cycode/cli/apps/ai_guardrails/hooks_manager.py +123 -152
  5. cycode/cli/apps/ai_guardrails/ides/__init__.py +45 -0
  6. cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +73 -0
  7. cycode/cli/apps/ai_guardrails/ides/base.py +176 -0
  8. cycode/cli/apps/ai_guardrails/ides/claude_code.py +369 -0
  9. cycode/cli/apps/ai_guardrails/ides/codex.py +310 -0
  10. cycode/cli/apps/ai_guardrails/ides/cursor.py +119 -0
  11. cycode/cli/apps/ai_guardrails/install_command.py +14 -23
  12. cycode/cli/apps/ai_guardrails/scan/handlers.py +102 -101
  13. cycode/cli/apps/ai_guardrails/scan/payload.py +14 -255
  14. cycode/cli/apps/ai_guardrails/scan/scan_command.py +60 -48
  15. cycode/cli/apps/ai_guardrails/scan/types.py +8 -30
  16. cycode/cli/apps/ai_guardrails/session_start_command.py +14 -78
  17. cycode/cli/apps/ai_guardrails/status_command.py +13 -16
  18. cycode/cli/apps/ai_guardrails/uninstall_command.py +12 -22
  19. cycode/cli/utils/jwt_utils.py +8 -0
  20. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/METADATA +3 -1
  21. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/RECORD +24 -21
  22. cycode/cli/apps/ai_guardrails/scan/claude_config.py +0 -159
  23. cycode/cli/apps/ai_guardrails/scan/cursor_config.py +0 -36
  24. cycode/cli/apps/ai_guardrails/scan/response_builders.py +0 -135
  25. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/WHEEL +0 -0
  26. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/entry_points.txt +0 -0
  27. {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/licenses/LICENCE +0 -0
@@ -1,12 +1,16 @@
1
- """
2
- Hook handlers for AI IDE events.
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
- Each handler receives a unified payload from an IDE, applies policy rules,
5
- and returns a response that either allows or blocks the action.
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
9
12
  import os
13
+ from dataclasses import dataclass
10
14
  from multiprocessing.pool import ThreadPool
11
15
  from multiprocessing.pool import TimeoutError as PoolTimeoutError
12
16
  from typing import Callable, Optional
@@ -14,9 +18,9 @@ from typing import Callable, Optional
14
18
  import typer
15
19
 
16
20
  from cycode.cli.apps.ai_guardrails.consts import PolicyMode
21
+ from cycode.cli.apps.ai_guardrails.ides.base import HookDecision
17
22
  from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
18
23
  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
24
  from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason
21
25
  from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8
22
26
  from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func
@@ -30,21 +34,17 @@ from cycode.logger import get_logger
30
34
  logger = get_logger('AI Guardrails')
31
35
 
32
36
 
33
- def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict:
34
- """
35
- Handle beforeSubmitPrompt hook.
37
+ HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision]
36
38
 
37
- Scans prompt text for secrets before it's sent to the AI model.
38
- Returns {"continue": False} to block, {"continue": True} to allow.
39
- """
39
+
40
+ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
41
+ """Scan prompt text for secrets before it's sent to the AI model."""
40
42
  ai_client = ctx.obj['ai_security_client']
41
- ide = payload.ide_provider
42
- response_builder = get_response_builder(ide)
43
43
 
44
44
  prompt_config = get_policy_value(policy, 'prompt', default={})
45
45
  if not get_policy_value(prompt_config, 'enabled', default=True):
46
46
  ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
47
- return response_builder.allow_prompt()
47
+ return HookDecision.allow(AiHookEventType.PROMPT)
48
48
 
49
49
  mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
50
50
  prompt = payload.prompt or ''
@@ -66,9 +66,9 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
66
66
  if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK:
67
67
  outcome = AIHookOutcome.BLOCKED
68
68
  user_message = f'{violation_summary}. Remove secrets before sending.'
69
- return response_builder.deny_prompt(user_message)
69
+ return HookDecision.deny(AiHookEventType.PROMPT, user_message)
70
70
  outcome = AIHookOutcome.WARNED
71
- return response_builder.allow_prompt()
71
+ return HookDecision.allow(AiHookEventType.PROMPT)
72
72
  except Exception as e:
73
73
  outcome = (
74
74
  AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
@@ -87,21 +87,14 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli
87
87
  )
88
88
 
89
89
 
90
- def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> 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
- """
90
+ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
91
+ """Block sensitive paths and scan file content for secrets."""
97
92
  ai_client = ctx.obj['ai_security_client']
98
- ide = payload.ide_provider
99
- response_builder = get_response_builder(ide)
100
93
 
101
94
  file_read_config = get_policy_value(policy, 'file_read', default={})
102
95
  if not get_policy_value(file_read_config, 'enabled', default=True):
103
96
  ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
104
- return response_builder.allow_permission()
97
+ return HookDecision.allow(AiHookEventType.FILE_READ)
105
98
 
106
99
  mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
107
100
  file_path = payload.file_path or ''
@@ -113,20 +106,19 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
113
106
  error_message = None
114
107
 
115
108
  try:
116
- # Check path-based denylist first
117
109
  is_sensitive_path = is_denied_path(file_path, policy)
118
110
  if is_sensitive_path:
119
111
  block_reason = BlockReason.SENSITIVE_PATH
120
112
  if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
121
113
  outcome = AIHookOutcome.BLOCKED
122
114
  user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
123
- return response_builder.deny_permission(
115
+ return HookDecision.deny(
116
+ AiHookEventType.FILE_READ,
124
117
  user_message,
125
118
  'This file path is classified as sensitive; do not read/send it to the model.',
126
119
  )
127
- # Warn mode - if content scan is enabled, emit a separate event for the
120
+ # Warn mode: if content scan is enabled, emit a separate event for the
128
121
  # 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
122
  outcome = AIHookOutcome.WARNED
131
123
  if get_policy_value(file_read_config, 'scan_content', default=True):
132
124
  ai_client.create_event(
@@ -136,11 +128,9 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
136
128
  block_reason=BlockReason.SENSITIVE_PATH,
137
129
  file_path=payload.file_path,
138
130
  )
139
- # Reset for the content scan result tracked by the finally block
140
131
  block_reason = None
141
132
  outcome = AIHookOutcome.ALLOWED
142
133
 
143
- # Scan file content if enabled
144
134
  if get_policy_value(file_read_config, 'scan_content', default=True):
145
135
  violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy)
146
136
  if violation_summary:
@@ -148,27 +138,28 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
148
138
  if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
149
139
  outcome = AIHookOutcome.BLOCKED
150
140
  user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
151
- return response_builder.deny_permission(
141
+ return HookDecision.deny(
142
+ AiHookEventType.FILE_READ,
152
143
  user_message,
153
144
  'Secrets detected; do not send this file to the model.',
154
145
  )
155
- # Warn mode - ask user for permission
156
146
  outcome = AIHookOutcome.WARNED
157
147
  user_message = f'Cycode detected secrets in {file_path}. {violation_summary}'
158
- return response_builder.ask_permission(
148
+ return HookDecision.ask(
149
+ AiHookEventType.FILE_READ,
159
150
  user_message,
160
151
  'Possible secrets detected; proceed with caution.',
161
152
  )
162
153
 
163
- # If path was sensitive but content scan found no secrets (or scan disabled), still warn
164
154
  if is_sensitive_path:
165
155
  user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?'
166
- return response_builder.ask_permission(
156
+ return HookDecision.ask(
157
+ AiHookEventType.FILE_READ,
167
158
  user_message,
168
159
  'This file path is classified as sensitive; proceed with caution.',
169
160
  )
170
161
 
171
- return response_builder.allow_permission()
162
+ return HookDecision.allow(AiHookEventType.FILE_READ)
172
163
  except Exception as e:
173
164
  outcome = (
174
165
  AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
@@ -188,31 +179,44 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy:
188
179
  )
189
180
 
190
181
 
191
- def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict:
192
- """
193
- Handle beforeMCPExecution hook.
182
+ @dataclass(frozen=True)
183
+ class _ArgScanFeature:
184
+ """Configuration for a "scan some text and decide" event.
194
185
 
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.
186
+ MCP execution and command exec share identical scan-and-decide logic;
187
+ only the policy key, event type, and user-facing messages differ.
198
188
  """
189
+
190
+ policy_key: str # 'mcp' or 'command_exec'
191
+ scan_key: str # 'scan_arguments' or 'scan_command'
192
+ event_type: AiHookEventType
193
+ block_reason: BlockReason
194
+ deny_message: Callable[[str], str]
195
+ deny_agent_message: str
196
+ ask_message: Callable[[str], str]
197
+ ask_agent_message: str
198
+
199
+
200
+ def _handle_arg_scan(
201
+ ctx: typer.Context,
202
+ payload: AIHookPayload,
203
+ policy: dict,
204
+ feature: _ArgScanFeature,
205
+ scan_text: str,
206
+ ) -> HookDecision:
207
+ """Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events."""
199
208
  ai_client = ctx.obj['ai_security_client']
200
- ide = payload.ide_provider
201
- response_builder = get_response_builder(ide)
202
209
 
203
- mcp_config = get_policy_value(policy, 'mcp', default={})
204
- if not get_policy_value(mcp_config, 'enabled', default=True):
205
- ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED)
206
- return response_builder.allow_permission()
210
+ feature_config = get_policy_value(policy, feature.policy_key, default={})
211
+ if not get_policy_value(feature_config, 'enabled', default=True):
212
+ ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED)
213
+ return HookDecision.allow(feature.event_type)
207
214
 
208
215
  mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK)
209
- tool = payload.mcp_tool_name or 'unknown'
210
- args = payload.mcp_arguments or {}
211
- args_text = args if isinstance(args, str) else json.dumps(args)
212
216
  max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
213
217
  timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
214
- clipped = truncate_utf8(args_text, max_bytes)
215
- action = get_policy_value(mcp_config, 'action', default=PolicyMode.BLOCK)
218
+ clipped = truncate_utf8(scan_text, max_bytes)
219
+ action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK)
216
220
 
217
221
  scan_id = None
218
222
  block_reason = None
@@ -220,24 +224,25 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
220
224
  error_message = None
221
225
 
222
226
  try:
223
- if get_policy_value(mcp_config, 'scan_arguments', default=True):
227
+ if get_policy_value(feature_config, feature.scan_key, default=True):
224
228
  violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
225
229
  if violation_summary:
226
- block_reason = BlockReason.SECRETS_IN_MCP_ARGS
230
+ block_reason = feature.block_reason
227
231
  if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK:
228
232
  outcome = AIHookOutcome.BLOCKED
229
- user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}'
230
- return response_builder.deny_permission(
231
- user_message,
232
- 'Do not pass secrets to tools. Use secret references (name/id) instead.',
233
+ return HookDecision.deny(
234
+ feature.event_type,
235
+ feature.deny_message(violation_summary),
236
+ feature.deny_agent_message,
233
237
  )
234
238
  outcome = AIHookOutcome.WARNED
235
- return response_builder.ask_permission(
236
- f'{violation_summary} in MCP tool call "{tool}". Allow execution?',
237
- 'Possible secrets detected in tool arguments; proceed with caution.',
239
+ return HookDecision.ask(
240
+ feature.event_type,
241
+ feature.ask_message(violation_summary),
242
+ feature.ask_agent_message,
238
243
  )
239
244
 
240
- return response_builder.allow_permission()
245
+ return HookDecision.allow(feature.event_type)
241
246
  except Exception as e:
242
247
  outcome = (
243
248
  AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
@@ -248,7 +253,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
248
253
  finally:
249
254
  ai_client.create_event(
250
255
  payload,
251
- AiHookEventType.MCP_EXECUTION,
256
+ feature.event_type,
252
257
  outcome,
253
258
  scan_id=scan_id,
254
259
  block_reason=block_reason,
@@ -256,16 +261,32 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli
256
261
  )
257
262
 
258
263
 
259
- def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, AIHookPayload, dict], dict]]:
260
- """Get the appropriate handler function for a canonical event type.
264
+ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision:
265
+ """Scan MCP tool arguments for secrets before execution."""
266
+ tool = payload.mcp_tool_name or 'unknown'
267
+ args = payload.mcp_arguments or {}
268
+ args_text = args if isinstance(args, str) else json.dumps(args)
269
+ return _handle_arg_scan(
270
+ ctx,
271
+ payload,
272
+ policy,
273
+ _ArgScanFeature(
274
+ policy_key='mcp',
275
+ scan_key='scan_arguments',
276
+ event_type=AiHookEventType.MCP_EXECUTION,
277
+ block_reason=BlockReason.SECRETS_IN_MCP_ARGS,
278
+ deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}',
279
+ deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.',
280
+ ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?',
281
+ ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.',
282
+ ),
283
+ scan_text=args_text,
284
+ )
261
285
 
262
- Args:
263
- event_type: Canonical event type string (from AiHookEventType enum)
264
286
 
265
- Returns:
266
- Handler function or None if event type is not recognized
267
- """
268
- handlers = {
287
+ def get_handler_for_event(event_type: str) -> Optional[HandlerFn]:
288
+ """Look up the handler for a canonical event type."""
289
+ handlers: dict[str, HandlerFn] = {
269
290
  AiHookEventType.PROMPT.value: handle_before_submit_prompt,
270
291
  AiHookEventType.FILE_READ.value: handle_before_read_file,
271
292
  AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution,
@@ -275,32 +296,24 @@ def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context,
275
296
 
276
297
  def _setup_scan_context(ctx: typer.Context) -> typer.Context:
277
298
  """Set up minimal context for scan_documents without progress bars or printing."""
278
-
279
- # Set up minimal required context
280
299
  ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection])
281
- ctx.obj['sync'] = True # Synchronous scan
282
- ctx.obj['scan_type'] = ScanTypeOption.SECRET # AI guardrails always scans for secrets
283
- ctx.obj['severity_threshold'] = SeverityOption.INFO # Report all severities
284
-
285
- # Set command name for scan logic
300
+ ctx.obj['sync'] = True
301
+ ctx.obj['scan_type'] = ScanTypeOption.SECRET
302
+ ctx.obj['severity_threshold'] = SeverityOption.INFO
286
303
  ctx.info_name = 'ai_guardrails'
287
-
288
304
  return ctx
289
305
 
290
306
 
291
307
  def _perform_scan(
292
308
  ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float
293
309
  ) -> tuple[Optional[str], Optional[str]]:
294
- """
295
- Perform a scan on documents and extract results.
310
+ """Run a scan on documents, returning (violation_summary, scan_id).
296
311
 
297
- Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean.
298
- Raises exception if scan fails or times out (triggers fail_open policy).
312
+ Raises on scan failure / timeout so the fail-open policy can take over.
299
313
  """
300
314
  if not documents:
301
315
  return None, None
302
316
 
303
- # Get the thread function for scanning
304
317
  scan_batch_thread_func = _get_scan_documents_thread_func(
305
318
  ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters
306
319
  )
@@ -324,7 +337,6 @@ def _perform_scan(
324
337
 
325
338
  scan_id = local_scan_result.scan_id
326
339
 
327
- # Check if there are any detections
328
340
  if local_scan_result.detections_count > 0:
329
341
  violation_summary = build_violation_summary([local_scan_result])
330
342
  return violation_summary, scan_id
@@ -333,12 +345,7 @@ def _perform_scan(
333
345
 
334
346
 
335
347
  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
- """
348
+ """Scan text content for secrets using Cycode CLI."""
342
349
  if not text:
343
350
  return None, None
344
351
 
@@ -349,12 +356,7 @@ def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tu
349
356
 
350
357
 
351
358
  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
- """
359
+ """Scan a file path for secrets."""
358
360
  if not file_path or not os.path.isfile(file_path):
359
361
  return None, None
360
362
 
@@ -363,7 +365,6 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) ->
363
365
  with open(file_path, encoding='utf-8', errors='replace') as f:
364
366
  content = f.read(max_bytes)
365
367
 
366
- # Get timeout from policy
367
368
  timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
368
369
  timeout_seconds = timeout_ms / 1000.0
369
370