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