cycode 3.8.9.dev1__py3-none-any.whl → 3.8.11.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/app.py +2 -1
- cycode/cli/apps/ai_guardrails/__init__.py +19 -0
- cycode/cli/apps/ai_guardrails/command_utils.py +66 -0
- cycode/cli/apps/ai_guardrails/consts.py +78 -0
- cycode/cli/apps/ai_guardrails/hooks_manager.py +200 -0
- cycode/cli/apps/ai_guardrails/install_command.py +78 -0
- cycode/cli/apps/ai_guardrails/scan/__init__.py +1 -0
- cycode/cli/apps/ai_guardrails/scan/consts.py +48 -0
- cycode/cli/apps/ai_guardrails/scan/handlers.py +341 -0
- cycode/cli/apps/ai_guardrails/scan/payload.py +72 -0
- cycode/cli/apps/ai_guardrails/scan/policy.py +85 -0
- cycode/cli/apps/ai_guardrails/scan/response_builders.py +86 -0
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +134 -0
- cycode/cli/apps/ai_guardrails/scan/types.py +54 -0
- cycode/cli/apps/ai_guardrails/scan/utils.py +72 -0
- cycode/cli/apps/ai_guardrails/status_command.py +92 -0
- cycode/cli/apps/ai_guardrails/uninstall_command.py +73 -0
- cycode/cli/apps/scan/code_scanner.py +1 -1
- cycode/cli/cli_types.py +13 -0
- cycode/cli/printers/tables/table_printer.py +3 -1
- cycode/cli/printers/utils/code_snippet_syntax.py +3 -1
- cycode/cli/printers/utils/rich_helpers.py +3 -1
- cycode/cli/utils/get_api_client.py +15 -2
- cycode/cli/utils/scan_utils.py +24 -0
- cycode/cli/utils/string_utils.py +9 -0
- cycode/cyclient/ai_security_manager_client.py +86 -0
- cycode/cyclient/ai_security_manager_service_config.py +27 -0
- cycode/cyclient/client_creator.py +20 -0
- {cycode-3.8.9.dev1.dist-info → cycode-3.8.11.dev1.dist-info}/METADATA +1 -1
- {cycode-3.8.9.dev1.dist-info → cycode-3.8.11.dev1.dist-info}/RECORD +34 -16
- {cycode-3.8.9.dev1.dist-info → cycode-3.8.11.dev1.dist-info}/WHEEL +0 -0
- {cycode-3.8.9.dev1.dist-info → cycode-3.8.11.dev1.dist-info}/entry_points.txt +0 -0
- {cycode-3.8.9.dev1.dist-info → cycode-3.8.11.dev1.dist-info}/licenses/LICENCE +0 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hook handlers for AI IDE events.
|
|
3
|
+
|
|
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.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from multiprocessing.pool import ThreadPool
|
|
11
|
+
from multiprocessing.pool import TimeoutError as PoolTimeoutError
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
17
|
+
from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value
|
|
18
|
+
from cycode.cli.apps.ai_guardrails.scan.response_builders import get_response_builder
|
|
19
|
+
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason
|
|
20
|
+
from cycode.cli.apps.ai_guardrails.scan.utils import is_denied_path, truncate_utf8
|
|
21
|
+
from cycode.cli.apps.scan.code_scanner import _get_scan_documents_thread_func
|
|
22
|
+
from cycode.cli.apps.scan.scan_parameters import get_scan_parameters
|
|
23
|
+
from cycode.cli.cli_types import ScanTypeOption, SeverityOption
|
|
24
|
+
from cycode.cli.models import Document
|
|
25
|
+
from cycode.cli.utils.progress_bar import DummyProgressBar, ScanProgressBarSection
|
|
26
|
+
from cycode.cli.utils.scan_utils import build_violation_summary
|
|
27
|
+
from cycode.logger import get_logger
|
|
28
|
+
|
|
29
|
+
logger = get_logger('AI Guardrails')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict:
|
|
33
|
+
"""
|
|
34
|
+
Handle beforeSubmitPrompt hook.
|
|
35
|
+
|
|
36
|
+
Scans prompt text for secrets before it's sent to the AI model.
|
|
37
|
+
Returns {"continue": False} to block, {"continue": True} to allow.
|
|
38
|
+
"""
|
|
39
|
+
ai_client = ctx.obj['ai_security_client']
|
|
40
|
+
ide = payload.ide_provider
|
|
41
|
+
response_builder = get_response_builder(ide)
|
|
42
|
+
|
|
43
|
+
prompt_config = get_policy_value(policy, 'prompt', default={})
|
|
44
|
+
ai_client.create_conversation(payload)
|
|
45
|
+
if not get_policy_value(prompt_config, 'enabled', default=True):
|
|
46
|
+
ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED)
|
|
47
|
+
return response_builder.allow_prompt()
|
|
48
|
+
|
|
49
|
+
mode = get_policy_value(policy, 'mode', default='block')
|
|
50
|
+
prompt = payload.prompt or ''
|
|
51
|
+
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
|
|
52
|
+
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
|
|
53
|
+
clipped = truncate_utf8(prompt, max_bytes)
|
|
54
|
+
|
|
55
|
+
scan_id = None
|
|
56
|
+
block_reason = None
|
|
57
|
+
outcome = AIHookOutcome.ALLOWED
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
violation_summary
|
|
64
|
+
and get_policy_value(prompt_config, 'action', default='block') == 'block'
|
|
65
|
+
and mode == 'block'
|
|
66
|
+
):
|
|
67
|
+
outcome = AIHookOutcome.BLOCKED
|
|
68
|
+
block_reason = BlockReason.SECRETS_IN_PROMPT
|
|
69
|
+
user_message = f'{violation_summary}. Remove secrets before sending.'
|
|
70
|
+
response = response_builder.deny_prompt(user_message)
|
|
71
|
+
else:
|
|
72
|
+
if violation_summary:
|
|
73
|
+
outcome = AIHookOutcome.WARNED
|
|
74
|
+
response = response_builder.allow_prompt()
|
|
75
|
+
return response
|
|
76
|
+
except Exception as e:
|
|
77
|
+
outcome = (
|
|
78
|
+
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
79
|
+
)
|
|
80
|
+
block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None
|
|
81
|
+
raise e
|
|
82
|
+
finally:
|
|
83
|
+
ai_client.create_event(
|
|
84
|
+
payload,
|
|
85
|
+
AiHookEventType.PROMPT,
|
|
86
|
+
outcome,
|
|
87
|
+
scan_id=scan_id,
|
|
88
|
+
block_reason=block_reason,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Handle beforeReadFile hook.
|
|
95
|
+
|
|
96
|
+
Blocks sensitive files (via deny_globs) and scans file content for secrets.
|
|
97
|
+
Returns {"permission": "deny"} to block, {"permission": "allow"} to allow.
|
|
98
|
+
"""
|
|
99
|
+
ai_client = ctx.obj['ai_security_client']
|
|
100
|
+
ide = payload.ide_provider
|
|
101
|
+
response_builder = get_response_builder(ide)
|
|
102
|
+
|
|
103
|
+
file_read_config = get_policy_value(policy, 'file_read', default={})
|
|
104
|
+
ai_client.create_conversation(payload)
|
|
105
|
+
if not get_policy_value(file_read_config, 'enabled', default=True):
|
|
106
|
+
ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED)
|
|
107
|
+
return response_builder.allow_permission()
|
|
108
|
+
|
|
109
|
+
mode = get_policy_value(policy, 'mode', default='block')
|
|
110
|
+
file_path = payload.file_path or ''
|
|
111
|
+
action = get_policy_value(file_read_config, 'action', default='block')
|
|
112
|
+
|
|
113
|
+
scan_id = None
|
|
114
|
+
block_reason = None
|
|
115
|
+
outcome = AIHookOutcome.ALLOWED
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
# Check path-based denylist first
|
|
119
|
+
if is_denied_path(file_path, policy) and action == 'block':
|
|
120
|
+
outcome = AIHookOutcome.BLOCKED
|
|
121
|
+
block_reason = BlockReason.SENSITIVE_PATH
|
|
122
|
+
user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).'
|
|
123
|
+
return response_builder.deny_permission(
|
|
124
|
+
user_message,
|
|
125
|
+
'This file path is classified as sensitive; do not read/send it to the model.',
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Scan file content if enabled
|
|
129
|
+
if get_policy_value(file_read_config, 'scan_content', default=True):
|
|
130
|
+
violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy)
|
|
131
|
+
if violation_summary and action == 'block' and mode == 'block':
|
|
132
|
+
outcome = AIHookOutcome.BLOCKED
|
|
133
|
+
block_reason = BlockReason.SECRETS_IN_FILE
|
|
134
|
+
user_message = f'Cycode blocked reading {file_path}. {violation_summary}'
|
|
135
|
+
return response_builder.deny_permission(
|
|
136
|
+
user_message,
|
|
137
|
+
'Secrets detected; do not send this file to the model.',
|
|
138
|
+
)
|
|
139
|
+
if violation_summary:
|
|
140
|
+
outcome = AIHookOutcome.WARNED
|
|
141
|
+
return response_builder.allow_permission()
|
|
142
|
+
|
|
143
|
+
return response_builder.allow_permission()
|
|
144
|
+
except Exception as e:
|
|
145
|
+
outcome = (
|
|
146
|
+
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
147
|
+
)
|
|
148
|
+
block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None
|
|
149
|
+
raise e
|
|
150
|
+
finally:
|
|
151
|
+
ai_client.create_event(
|
|
152
|
+
payload,
|
|
153
|
+
AiHookEventType.FILE_READ,
|
|
154
|
+
outcome,
|
|
155
|
+
scan_id=scan_id,
|
|
156
|
+
block_reason=block_reason,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict:
|
|
161
|
+
"""
|
|
162
|
+
Handle beforeMCPExecution hook.
|
|
163
|
+
|
|
164
|
+
Scans tool arguments for secrets before MCP tool execution.
|
|
165
|
+
Returns {"permission": "deny"} to block, {"permission": "ask"} to warn,
|
|
166
|
+
{"permission": "allow"} to allow.
|
|
167
|
+
"""
|
|
168
|
+
ai_client = ctx.obj['ai_security_client']
|
|
169
|
+
ide = payload.ide_provider
|
|
170
|
+
response_builder = get_response_builder(ide)
|
|
171
|
+
|
|
172
|
+
mcp_config = get_policy_value(policy, 'mcp', default={})
|
|
173
|
+
ai_client.create_conversation(payload)
|
|
174
|
+
if not get_policy_value(mcp_config, 'enabled', default=True):
|
|
175
|
+
ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED)
|
|
176
|
+
return response_builder.allow_permission()
|
|
177
|
+
|
|
178
|
+
mode = get_policy_value(policy, 'mode', default='block')
|
|
179
|
+
tool = payload.mcp_tool_name or 'unknown'
|
|
180
|
+
args = payload.mcp_arguments or {}
|
|
181
|
+
args_text = args if isinstance(args, str) else json.dumps(args)
|
|
182
|
+
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
|
|
183
|
+
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
|
|
184
|
+
clipped = truncate_utf8(args_text, max_bytes)
|
|
185
|
+
action = get_policy_value(mcp_config, 'action', default='block')
|
|
186
|
+
|
|
187
|
+
scan_id = None
|
|
188
|
+
block_reason = None
|
|
189
|
+
outcome = AIHookOutcome.ALLOWED
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
if get_policy_value(mcp_config, 'scan_arguments', default=True):
|
|
193
|
+
violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms)
|
|
194
|
+
if violation_summary:
|
|
195
|
+
if mode == 'block' and action == 'block':
|
|
196
|
+
outcome = AIHookOutcome.BLOCKED
|
|
197
|
+
block_reason = BlockReason.SECRETS_IN_MCP_ARGS
|
|
198
|
+
user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}'
|
|
199
|
+
return response_builder.deny_permission(
|
|
200
|
+
user_message,
|
|
201
|
+
'Do not pass secrets to tools. Use secret references (name/id) instead.',
|
|
202
|
+
)
|
|
203
|
+
outcome = AIHookOutcome.WARNED
|
|
204
|
+
return response_builder.ask_permission(
|
|
205
|
+
f'{violation_summary} in MCP tool call "{tool}". Allow execution?',
|
|
206
|
+
'Possible secrets detected in tool arguments; proceed with caution.',
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return response_builder.allow_permission()
|
|
210
|
+
except Exception as e:
|
|
211
|
+
outcome = (
|
|
212
|
+
AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED
|
|
213
|
+
)
|
|
214
|
+
block_reason = BlockReason.SCAN_FAILURE if outcome == AIHookOutcome.BLOCKED else None
|
|
215
|
+
raise e
|
|
216
|
+
finally:
|
|
217
|
+
ai_client.create_event(
|
|
218
|
+
payload,
|
|
219
|
+
AiHookEventType.MCP_EXECUTION,
|
|
220
|
+
outcome,
|
|
221
|
+
scan_id=scan_id,
|
|
222
|
+
block_reason=block_reason,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, AIHookPayload, dict], dict]]:
|
|
227
|
+
"""Get the appropriate handler function for a canonical event type.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
event_type: Canonical event type string (from AiHookEventType enum)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Handler function or None if event type is not recognized
|
|
234
|
+
"""
|
|
235
|
+
handlers = {
|
|
236
|
+
AiHookEventType.PROMPT.value: handle_before_submit_prompt,
|
|
237
|
+
AiHookEventType.FILE_READ.value: handle_before_read_file,
|
|
238
|
+
AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution,
|
|
239
|
+
}
|
|
240
|
+
return handlers.get(event_type)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _setup_scan_context(ctx: typer.Context) -> typer.Context:
|
|
244
|
+
"""Set up minimal context for scan_documents without progress bars or printing."""
|
|
245
|
+
|
|
246
|
+
# Set up minimal required context
|
|
247
|
+
ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection])
|
|
248
|
+
ctx.obj['sync'] = True # Synchronous scan
|
|
249
|
+
ctx.obj['scan_type'] = ScanTypeOption.SECRET # AI guardrails always scans for secrets
|
|
250
|
+
ctx.obj['severity_threshold'] = SeverityOption.INFO # Report all severities
|
|
251
|
+
|
|
252
|
+
# Set command name for scan logic
|
|
253
|
+
ctx.info_name = 'ai_guardrails'
|
|
254
|
+
|
|
255
|
+
return ctx
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _perform_scan(
|
|
259
|
+
ctx: typer.Context, documents: list[Document], scan_parameters: dict, timeout_seconds: float
|
|
260
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
261
|
+
"""
|
|
262
|
+
Perform a scan on documents and extract results.
|
|
263
|
+
|
|
264
|
+
Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean.
|
|
265
|
+
Raises exception if scan fails or times out (triggers fail_open policy).
|
|
266
|
+
"""
|
|
267
|
+
if not documents:
|
|
268
|
+
return None, None
|
|
269
|
+
|
|
270
|
+
# Get the thread function for scanning
|
|
271
|
+
scan_batch_thread_func = _get_scan_documents_thread_func(
|
|
272
|
+
ctx, is_git_diff=False, is_commit_range=False, scan_parameters=scan_parameters
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Use ThreadPool.apply_async with timeout to abort if scan takes too long
|
|
276
|
+
# This uses the same ThreadPool mechanism as run_parallel_batched_scan but with timeout support
|
|
277
|
+
with ThreadPool(processes=1) as pool:
|
|
278
|
+
result = pool.apply_async(scan_batch_thread_func, (documents,))
|
|
279
|
+
try:
|
|
280
|
+
scan_id, error, local_scan_result = result.get(timeout=timeout_seconds)
|
|
281
|
+
except PoolTimeoutError:
|
|
282
|
+
logger.debug('Scan timed out after %s seconds', timeout_seconds)
|
|
283
|
+
raise RuntimeError(f'Scan timed out after {timeout_seconds} seconds') from None
|
|
284
|
+
|
|
285
|
+
# Check if scan failed - raise exception to trigger fail_open policy
|
|
286
|
+
if error:
|
|
287
|
+
raise RuntimeError(error.message)
|
|
288
|
+
|
|
289
|
+
if not local_scan_result:
|
|
290
|
+
return None, None
|
|
291
|
+
|
|
292
|
+
scan_id = local_scan_result.scan_id
|
|
293
|
+
|
|
294
|
+
# Check if there are any detections
|
|
295
|
+
if local_scan_result.detections_count > 0:
|
|
296
|
+
violation_summary = build_violation_summary([local_scan_result])
|
|
297
|
+
return violation_summary, scan_id
|
|
298
|
+
|
|
299
|
+
return None, scan_id
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tuple[Optional[str], Optional[str]]:
|
|
303
|
+
"""
|
|
304
|
+
Scan text content for secrets using Cycode CLI.
|
|
305
|
+
|
|
306
|
+
Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean.
|
|
307
|
+
Raises exception on error or timeout.
|
|
308
|
+
"""
|
|
309
|
+
if not text:
|
|
310
|
+
return None, None
|
|
311
|
+
|
|
312
|
+
document = Document(path='prompt-content.txt', content=text, is_git_diff_format=False)
|
|
313
|
+
scan_ctx = _setup_scan_context(ctx)
|
|
314
|
+
timeout_seconds = timeout_ms / 1000.0
|
|
315
|
+
return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, None), timeout_seconds)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> tuple[Optional[str], Optional[str]]:
|
|
319
|
+
"""
|
|
320
|
+
Scan a file path for secrets.
|
|
321
|
+
|
|
322
|
+
Returns tuple of (violation_summary, scan_id) if secrets found, (None, scan_id) if clean.
|
|
323
|
+
Raises exception on error or timeout.
|
|
324
|
+
"""
|
|
325
|
+
if not file_path or not os.path.exists(file_path):
|
|
326
|
+
return None, None
|
|
327
|
+
|
|
328
|
+
with open(file_path, encoding='utf-8', errors='replace') as f:
|
|
329
|
+
content = f.read()
|
|
330
|
+
|
|
331
|
+
# Truncate content based on policy max_bytes
|
|
332
|
+
max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000)
|
|
333
|
+
content = truncate_utf8(content, max_bytes)
|
|
334
|
+
|
|
335
|
+
# Get timeout from policy
|
|
336
|
+
timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000)
|
|
337
|
+
timeout_seconds = timeout_ms / 1000.0
|
|
338
|
+
|
|
339
|
+
document = Document(path=os.path.basename(file_path), content=content, is_git_diff_format=False)
|
|
340
|
+
scan_ctx = _setup_scan_context(ctx)
|
|
341
|
+
return _perform_scan(scan_ctx, [document], get_scan_parameters(scan_ctx, (file_path,)), timeout_seconds)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Unified payload object for AI hook events from different tools."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from cycode.cli.apps.ai_guardrails.scan.types import CURSOR_EVENT_MAPPING
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class AIHookPayload:
|
|
11
|
+
"""Unified payload object that normalizes field names from different AI tools."""
|
|
12
|
+
|
|
13
|
+
# Event identification
|
|
14
|
+
event_name: str # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution')
|
|
15
|
+
conversation_id: Optional[str] = None
|
|
16
|
+
generation_id: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
# User and IDE information
|
|
19
|
+
ide_user_email: Optional[str] = None
|
|
20
|
+
model: Optional[str] = None
|
|
21
|
+
ide_provider: str = None # e.g., 'cursor', 'claude-code'
|
|
22
|
+
ide_version: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
# Event-specific data
|
|
25
|
+
prompt: Optional[str] = None # For prompt events
|
|
26
|
+
file_path: Optional[str] = None # For file_read events
|
|
27
|
+
mcp_server_name: Optional[str] = None # For mcp_execution events
|
|
28
|
+
mcp_tool_name: Optional[str] = None # For mcp_execution events
|
|
29
|
+
mcp_arguments: Optional[dict] = None # For mcp_execution events
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_cursor_payload(cls, payload: dict) -> 'AIHookPayload':
|
|
33
|
+
"""Create AIHookPayload from Cursor IDE payload.
|
|
34
|
+
|
|
35
|
+
Maps Cursor-specific event names to canonical event types.
|
|
36
|
+
"""
|
|
37
|
+
cursor_event_name = payload.get('hook_event_name', '')
|
|
38
|
+
# Map Cursor event name to canonical type, fallback to original if not found
|
|
39
|
+
canonical_event = CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name)
|
|
40
|
+
|
|
41
|
+
return cls(
|
|
42
|
+
event_name=canonical_event,
|
|
43
|
+
conversation_id=payload.get('conversation_id'),
|
|
44
|
+
generation_id=payload.get('generation_id'),
|
|
45
|
+
ide_user_email=payload.get('user_email'),
|
|
46
|
+
model=payload.get('model'),
|
|
47
|
+
ide_provider='cursor',
|
|
48
|
+
ide_version=payload.get('cursor_version'),
|
|
49
|
+
prompt=payload.get('prompt', ''),
|
|
50
|
+
file_path=payload.get('file_path') or payload.get('path'),
|
|
51
|
+
mcp_server_name=payload.get('command'), # MCP server name
|
|
52
|
+
mcp_tool_name=payload.get('tool_name') or payload.get('tool'),
|
|
53
|
+
mcp_arguments=payload.get('arguments') or payload.get('tool_input') or payload.get('input'),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_payload(cls, payload: dict, tool: str = 'cursor') -> 'AIHookPayload':
|
|
58
|
+
"""Create AIHookPayload from any tool's payload.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
payload: The raw payload from the IDE
|
|
62
|
+
tool: The IDE/tool name (e.g., 'cursor')
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
AIHookPayload instance
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If the tool is not supported
|
|
69
|
+
"""
|
|
70
|
+
if tool == 'cursor':
|
|
71
|
+
return cls.from_cursor_payload(payload)
|
|
72
|
+
raise ValueError(f'Unsupported IDE/tool: {tool}.')
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Policy loading and configuration management for AI guardrails.
|
|
3
|
+
|
|
4
|
+
Policies are loaded and merged in order (later overrides earlier):
|
|
5
|
+
1. Built-in defaults (consts.DEFAULT_POLICY)
|
|
6
|
+
2. User-level config (~/.cycode/ai-guardrails.yaml)
|
|
7
|
+
3. Repo-level config (<workspace>/.cycode/ai-guardrails.yaml)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def deep_merge(base: dict, override: dict) -> dict:
|
|
20
|
+
"""Deep merge two dictionaries, with override taking precedence."""
|
|
21
|
+
result = base.copy()
|
|
22
|
+
for key, value in override.items():
|
|
23
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
24
|
+
result[key] = deep_merge(result[key], value)
|
|
25
|
+
else:
|
|
26
|
+
result[key] = value
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_yaml_file(path: Path) -> Optional[dict]:
|
|
31
|
+
"""Load a YAML or JSON config file."""
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
content = path.read_text(encoding='utf-8')
|
|
36
|
+
if path.suffix in ('.yaml', '.yml'):
|
|
37
|
+
return yaml.safe_load(content)
|
|
38
|
+
return json.loads(content)
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_defaults() -> dict:
|
|
44
|
+
"""Load built-in defaults."""
|
|
45
|
+
return DEFAULT_POLICY.copy()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_policy_value(policy: dict, *keys: str, default: Any = None) -> Any:
|
|
49
|
+
"""Get a nested value from the policy dict."""
|
|
50
|
+
current = policy
|
|
51
|
+
for key in keys:
|
|
52
|
+
if not isinstance(current, dict):
|
|
53
|
+
return default
|
|
54
|
+
current = current.get(key)
|
|
55
|
+
if current is None:
|
|
56
|
+
return default
|
|
57
|
+
return current
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_policy(workspace_root: Optional[str] = None) -> dict:
|
|
61
|
+
"""
|
|
62
|
+
Load policy by merging configs in order of precedence.
|
|
63
|
+
|
|
64
|
+
Merge order: defaults <- user config <- repo config
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
workspace_root: Workspace root path for repo-level config lookup.
|
|
68
|
+
"""
|
|
69
|
+
# Start with defaults
|
|
70
|
+
policy = load_defaults()
|
|
71
|
+
|
|
72
|
+
# Merge user-level config (if exists)
|
|
73
|
+
user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME
|
|
74
|
+
user_config = load_yaml_file(user_policy_path)
|
|
75
|
+
if user_config:
|
|
76
|
+
policy = deep_merge(policy, user_config)
|
|
77
|
+
|
|
78
|
+
# Merge repo-level config (if exists) - highest precedence
|
|
79
|
+
if workspace_root:
|
|
80
|
+
repo_policy_path = Path(workspace_root) / '.cycode' / POLICY_FILE_NAME
|
|
81
|
+
repo_config = load_yaml_file(repo_policy_path)
|
|
82
|
+
if repo_config:
|
|
83
|
+
policy = deep_merge(policy, repo_config)
|
|
84
|
+
|
|
85
|
+
return policy
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response builders for different AI IDE hooks.
|
|
3
|
+
|
|
4
|
+
Each IDE has its own response format for hooks. This module provides
|
|
5
|
+
an abstract interface and concrete implementations for each supported IDE.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IDEResponseBuilder(ABC):
|
|
12
|
+
"""Abstract base class for IDE-specific response builders."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def allow_permission(self) -> dict:
|
|
16
|
+
"""Build response to allow file read or MCP execution."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def deny_permission(self, user_message: str, agent_message: str) -> dict:
|
|
20
|
+
"""Build response to deny file read or MCP execution."""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def ask_permission(self, user_message: str, agent_message: str) -> dict:
|
|
24
|
+
"""Build response to ask user for permission (warn mode)."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def allow_prompt(self) -> dict:
|
|
28
|
+
"""Build response to allow prompt submission."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def deny_prompt(self, user_message: str) -> dict:
|
|
32
|
+
"""Build response to deny prompt submission."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CursorResponseBuilder(IDEResponseBuilder):
|
|
36
|
+
"""Response builder for Cursor IDE hooks.
|
|
37
|
+
|
|
38
|
+
Cursor hook response formats:
|
|
39
|
+
- beforeSubmitPrompt: {"continue": bool, "user_message": str}
|
|
40
|
+
- beforeReadFile: {"permission": str, "user_message": str, "agent_message": str}
|
|
41
|
+
- beforeMCPExecution: {"permission": str, "user_message": str, "agent_message": str}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def allow_permission(self) -> dict:
|
|
45
|
+
"""Allow file read or MCP execution."""
|
|
46
|
+
return {'permission': 'allow'}
|
|
47
|
+
|
|
48
|
+
def deny_permission(self, user_message: str, agent_message: str) -> dict:
|
|
49
|
+
"""Deny file read or MCP execution."""
|
|
50
|
+
return {'permission': 'deny', 'user_message': user_message, 'agent_message': agent_message}
|
|
51
|
+
|
|
52
|
+
def ask_permission(self, user_message: str, agent_message: str) -> dict:
|
|
53
|
+
"""Ask user for permission (warn mode)."""
|
|
54
|
+
return {'permission': 'ask', 'user_message': user_message, 'agent_message': agent_message}
|
|
55
|
+
|
|
56
|
+
def allow_prompt(self) -> dict:
|
|
57
|
+
"""Allow prompt submission."""
|
|
58
|
+
return {'continue': True}
|
|
59
|
+
|
|
60
|
+
def deny_prompt(self, user_message: str) -> dict:
|
|
61
|
+
"""Deny prompt submission."""
|
|
62
|
+
return {'continue': False, 'user_message': user_message}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Registry of response builders by IDE name
|
|
66
|
+
_RESPONSE_BUILDERS: dict[str, IDEResponseBuilder] = {
|
|
67
|
+
'cursor': CursorResponseBuilder(),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_response_builder(ide: str = 'cursor') -> IDEResponseBuilder:
|
|
72
|
+
"""Get the response builder for a specific IDE.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
ide: The IDE name (e.g., 'cursor', 'claude-code')
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
IDEResponseBuilder instance for the specified IDE
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If the IDE is not supported
|
|
82
|
+
"""
|
|
83
|
+
builder = _RESPONSE_BUILDERS.get(ide.lower())
|
|
84
|
+
if not builder:
|
|
85
|
+
raise ValueError(f'Unsupported IDE: {ide}. Supported IDEs: {list(_RESPONSE_BUILDERS.keys())}')
|
|
86
|
+
return builder
|