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.
Files changed (34) hide show
  1. cycode/__init__.py +1 -1
  2. cycode/cli/app.py +2 -1
  3. cycode/cli/apps/ai_guardrails/__init__.py +19 -0
  4. cycode/cli/apps/ai_guardrails/command_utils.py +66 -0
  5. cycode/cli/apps/ai_guardrails/consts.py +78 -0
  6. cycode/cli/apps/ai_guardrails/hooks_manager.py +200 -0
  7. cycode/cli/apps/ai_guardrails/install_command.py +78 -0
  8. cycode/cli/apps/ai_guardrails/scan/__init__.py +1 -0
  9. cycode/cli/apps/ai_guardrails/scan/consts.py +48 -0
  10. cycode/cli/apps/ai_guardrails/scan/handlers.py +341 -0
  11. cycode/cli/apps/ai_guardrails/scan/payload.py +72 -0
  12. cycode/cli/apps/ai_guardrails/scan/policy.py +85 -0
  13. cycode/cli/apps/ai_guardrails/scan/response_builders.py +86 -0
  14. cycode/cli/apps/ai_guardrails/scan/scan_command.py +134 -0
  15. cycode/cli/apps/ai_guardrails/scan/types.py +54 -0
  16. cycode/cli/apps/ai_guardrails/scan/utils.py +72 -0
  17. cycode/cli/apps/ai_guardrails/status_command.py +92 -0
  18. cycode/cli/apps/ai_guardrails/uninstall_command.py +73 -0
  19. cycode/cli/apps/scan/code_scanner.py +1 -1
  20. cycode/cli/cli_types.py +13 -0
  21. cycode/cli/printers/tables/table_printer.py +3 -1
  22. cycode/cli/printers/utils/code_snippet_syntax.py +3 -1
  23. cycode/cli/printers/utils/rich_helpers.py +3 -1
  24. cycode/cli/utils/get_api_client.py +15 -2
  25. cycode/cli/utils/scan_utils.py +24 -0
  26. cycode/cli/utils/string_utils.py +9 -0
  27. cycode/cyclient/ai_security_manager_client.py +86 -0
  28. cycode/cyclient/ai_security_manager_service_config.py +27 -0
  29. cycode/cyclient/client_creator.py +20 -0
  30. {cycode-3.8.9.dev1.dist-info → cycode-3.8.11.dev1.dist-info}/METADATA +1 -1
  31. {cycode-3.8.9.dev1.dist-info → cycode-3.8.11.dev1.dist-info}/RECORD +34 -16
  32. {cycode-3.8.9.dev1.dist-info → cycode-3.8.11.dev1.dist-info}/WHEEL +0 -0
  33. {cycode-3.8.9.dev1.dist-info → cycode-3.8.11.dev1.dist-info}/entry_points.txt +0 -0
  34. {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