cycode 3.15.3.dev7__py3-none-any.whl → 3.15.4.dev1__py3-none-any.whl

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