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
@@ -0,0 +1,73 @@
1
+ """Shared plugin-resolution helpers for IDE integrations.
2
+
3
+ Both Claude Code and Codex use the same ``<plugin>@<marketplace>`` key convention
4
+ and emit the same telemetry shape — only the marketplace layout and manifest
5
+ location differ. ``walk_enabled_plugins`` is the IDE-agnostic loop; each IDE
6
+ supplies the two callables that vary (``locate_dir`` + ``read_plugin``).
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Optional
12
+
13
+ from cycode.logger import get_logger
14
+
15
+ logger = get_logger('AI Guardrails Plugins')
16
+
17
+
18
+ def load_plugin_json(path: Path) -> Optional[dict]:
19
+ """Load a JSON file inside a plugin directory; None if missing or invalid."""
20
+ if not path.exists():
21
+ return None
22
+ try:
23
+ return json.loads(path.read_text(encoding='utf-8'))
24
+ except Exception as e:
25
+ logger.debug('Failed to load plugin file, %s', {'path': str(path)}, exc_info=e)
26
+ return None
27
+
28
+
29
+ def walk_enabled_plugins(
30
+ plugin_entries: dict[str, Any],
31
+ is_enabled: Callable[[Any], bool],
32
+ locate_dir: Callable[[str, str], Optional[Path]],
33
+ read_plugin: Callable[[Path], tuple[dict, dict]],
34
+ ) -> tuple[dict, dict]:
35
+ """Iterate enabled plugins; merge their MCP servers and metadata.
36
+
37
+ Args:
38
+ plugin_entries: ``{<plugin>@<marketplace>: settings}`` map from the IDE config.
39
+ is_enabled: returns True if ``settings`` indicates the plugin is on
40
+ (e.g. ``bool(settings)`` for Claude, ``settings.get('enabled')`` for Codex).
41
+ locate_dir: given ``(plugin_name, marketplace)``, returns the plugin's
42
+ filesystem path or None if it can't be resolved.
43
+ read_plugin: given the plugin path, returns ``(entry_fields, servers)``:
44
+ ``entry_fields`` are extra metadata to attach to the inventory entry
45
+ (name/version/description/...), ``servers`` are MCP servers contributed.
46
+
47
+ Returns ``(merged_mcp_servers, enriched_plugins)``. Plugin keys without
48
+ ``@`` (or that fail to resolve to a directory) still appear in the
49
+ inventory with just ``{'enabled': True}`` so we don't silently drop them.
50
+ """
51
+ merged_mcp: dict = {}
52
+ enriched: dict = {}
53
+
54
+ for plugin_key, settings in plugin_entries.items():
55
+ if not is_enabled(settings):
56
+ continue
57
+
58
+ entry: dict = {'enabled': True}
59
+ enriched[plugin_key] = entry
60
+
61
+ if '@' not in plugin_key:
62
+ continue
63
+ plugin_name, marketplace = plugin_key.split('@', 1)
64
+
65
+ plugin_dir = locate_dir(plugin_name, marketplace)
66
+ if plugin_dir is None:
67
+ continue
68
+
69
+ plugin_fields, servers = read_plugin(plugin_dir)
70
+ entry.update(plugin_fields)
71
+ merged_mcp.update(servers)
72
+
73
+ return merged_mcp, enriched
@@ -0,0 +1,176 @@
1
+ """Base abstractions for AI guardrails IDE integrations.
2
+
3
+ Each AI IDE (Cursor, Claude Code, …) is represented by a subclass of `IDE`
4
+ that consolidates every IDE-specific concern in a single module: settings file
5
+ paths, hooks template rendering, payload parsing, response building, and any
6
+ IDE-specific session-context lookup.
7
+
8
+ Adding a new IDE is a matter of:
9
+ 1. Subclassing `IDE` and implementing the abstract methods.
10
+ 2. Registering the instance in `cycode/cli/apps/ai_guardrails/ides/__init__.py`.
11
+
12
+ The `HookDecision` dataclass is the canonical, IDE-agnostic return type for
13
+ event handlers; `IDE.build_hook_response` translates it into the IDE-specific
14
+ JSON response shape that the IDE expects on stdout.
15
+ """
16
+
17
+ from abc import ABC, abstractmethod
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import ClassVar, Optional
22
+
23
+ from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
24
+ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
25
+
26
+
27
+ class DecisionAction(str, Enum):
28
+ """Canonical decision action returned by event handlers."""
29
+
30
+ ALLOW = 'allow'
31
+ DENY = 'deny'
32
+ ASK = 'ask'
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class HookDecision:
37
+ """Canonical, IDE-agnostic decision returned by event handlers.
38
+
39
+ Carries the event type so `IDE.build_hook_response` can pick the right
40
+ IDE-specific response shape (Cursor's "permission" style for tool events
41
+ vs. "continue" style for prompts; Claude Code's "hookSpecificOutput"
42
+ vs. "decision: block").
43
+ """
44
+
45
+ action: DecisionAction
46
+ event_type: AiHookEventType
47
+ user_message: Optional[str] = None
48
+ agent_message: Optional[str] = None
49
+
50
+ @classmethod
51
+ def allow(cls, event_type: AiHookEventType) -> 'HookDecision':
52
+ return cls(action=DecisionAction.ALLOW, event_type=event_type)
53
+
54
+ @classmethod
55
+ def deny(
56
+ cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None
57
+ ) -> 'HookDecision':
58
+ return cls(
59
+ action=DecisionAction.DENY,
60
+ event_type=event_type,
61
+ user_message=user_message,
62
+ agent_message=agent_message,
63
+ )
64
+
65
+ @classmethod
66
+ def ask(cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None) -> 'HookDecision':
67
+ return cls(
68
+ action=DecisionAction.ASK,
69
+ event_type=event_type,
70
+ user_message=user_message,
71
+ agent_message=agent_message,
72
+ )
73
+
74
+
75
+ class IDE(ABC):
76
+ """Per-IDE integration. Owns every IDE-specific concern in a single module.
77
+
78
+ Subclasses declare identity via class attributes and implement the abstract
79
+ methods. Defaults are provided for `get_user_email` and `get_session_context`
80
+ so IDEs without those capabilities (e.g. no plugin system, no local
81
+ account file) can skip them.
82
+ """
83
+
84
+ # CLI value passed to --ide (e.g. 'cursor', 'claude-code').
85
+ name: ClassVar[str]
86
+ # Human-friendly name for output ('Cursor', 'Claude Code').
87
+ display_name: ClassVar[str]
88
+ # Event names for status display. Use '<event>:<matcher>' for IDEs that
89
+ # qualify a single hook by a sub-matcher (e.g. Claude Code's PreToolUse:Read).
90
+ hook_events: ClassVar[list[str]]
91
+
92
+ # --- install / status ---
93
+
94
+ @abstractmethod
95
+ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
96
+ """Return the hooks/settings file path for the given scope.
97
+
98
+ `scope` is 'user' or 'repo'. `repo_path` is required when scope == 'repo'.
99
+ """
100
+
101
+ @abstractmethod
102
+ def render_hooks_config(self, async_mode: bool = False) -> dict:
103
+ """Return the settings blob to merge into the IDE's settings file.
104
+
105
+ Shape is IDE-specific (Cursor uses a flat ``{event: [{command}]}`` dict;
106
+ Claude Code uses a nested ``{event: [{hooks: [{type, command}]}]}``
107
+ dict). Both share the outer ``{"hooks": ...}`` wrapper so
108
+ ``hooks_manager`` can treat them uniformly.
109
+ """
110
+
111
+ def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
112
+ """Run IDE-specific actions after the hooks file is written.
113
+
114
+ Default: no-op success. Override to perform extra setup that doesn't
115
+ belong in the hooks file itself — e.g. Codex enables a
116
+ ``[features] codex_hooks = true`` flag in its TOML config.
117
+
118
+ Returns ``(success, message)``. If ``success`` is False, the overall
119
+ install is considered failed.
120
+ """
121
+ return True, ''
122
+
123
+ def post_uninstall(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]:
124
+ """Run IDE-specific cleanup after the hooks file is removed.
125
+
126
+ Default: no-op success. Override to undo whatever ``post_install``
127
+ wrote outside the hooks file.
128
+ """
129
+ return True, ''
130
+
131
+ # --- runtime scan ---
132
+
133
+ @abstractmethod
134
+ def matches_payload(self, raw_payload: dict) -> bool:
135
+ """Return True if ``raw_payload`` originated from this IDE.
136
+
137
+ Prevents double-processing when an IDE forwards another IDE's hook
138
+ event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json).
139
+ """
140
+
141
+ @abstractmethod
142
+ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
143
+ """Normalize a raw stdin payload into the canonical ``AIHookPayload``."""
144
+
145
+ @abstractmethod
146
+ def build_hook_response(self, decision: HookDecision) -> dict:
147
+ """Translate a canonical ``HookDecision`` into the IDE-specific JSON.
148
+
149
+ The result is what ``scan_command`` writes to stdout for the IDE to
150
+ act on.
151
+ """
152
+
153
+ # --- session lifecycle (optional; sensible defaults) ---
154
+
155
+ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
156
+ """Build a session-start payload from the raw stdin payload.
157
+
158
+ Default: a minimal payload tagged with this IDE's ``name``. IDEs
159
+ that need to enrich with transcript/version info should override.
160
+ """
161
+ return AIHookPayload(ide_provider=self.name)
162
+
163
+ def get_user_email(self) -> Optional[str]:
164
+ """Best-effort read of the user's email from IDE-specific config.
165
+
166
+ Default: None. Override if the IDE stores a usable account locally.
167
+ """
168
+ return None
169
+
170
+ def get_session_context(self) -> tuple[dict, dict]:
171
+ """Return ``(mcp_servers, enabled_plugins)`` for session-context reporting.
172
+
173
+ Default: empty dicts (no plugin system, no discoverable MCP config).
174
+ Override to surface MCP/plugin inventory.
175
+ """
176
+ return {}, {}
@@ -0,0 +1,369 @@
1
+ """Claude Code IDE integration for AI guardrails."""
2
+
3
+ import json
4
+ from collections.abc import Iterator
5
+ from copy import deepcopy
6
+ from pathlib import Path
7
+ from typing import ClassVar, Optional
8
+
9
+ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
10
+ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins
11
+ from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
12
+ from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
13
+ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
14
+ from cycode.logger import get_logger
15
+
16
+ logger = get_logger('AI Guardrails Claude Code')
17
+
18
+ _CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'})
19
+
20
+ _USER_HOOKS_DIR = Path.home() / '.claude'
21
+ _HOOKS_FILE_NAME = 'settings.json'
22
+ _REPO_SUBDIR = '.claude'
23
+ _HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp']
24
+
25
+ _CLAUDE_CONFIG_PATH = Path.home() / '.claude.json'
26
+ _CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json'
27
+
28
+ _SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
29
+ _SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'
30
+
31
+
32
+ # --- transcript JSONL parsing -------------------------------------------------
33
+
34
+
35
+ def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]:
36
+ """Yield lines of `path` from end to start without loading the file.
37
+
38
+ The Claude Code transcript can be very large; reading from the tail keeps
39
+ memory bounded since we only care about the most recent entries.
40
+ """
41
+ with path.open('rb') as f:
42
+ f.seek(0, 2)
43
+ file_size = f.tell()
44
+ if file_size == 0:
45
+ return
46
+
47
+ remaining = file_size
48
+ buffer = b''
49
+
50
+ while remaining > 0:
51
+ read_size = min(buf_size, remaining)
52
+ remaining -= read_size
53
+ f.seek(remaining)
54
+ chunk = f.read(read_size)
55
+ buffer = chunk + buffer
56
+
57
+ while b'\n' in buffer:
58
+ newline_pos = buffer.rfind(b'\n')
59
+ if newline_pos == len(buffer) - 1:
60
+ newline_pos = buffer.rfind(b'\n', 0, newline_pos)
61
+ if newline_pos == -1:
62
+ break
63
+ line = buffer[newline_pos + 1 :]
64
+ buffer = buffer[: newline_pos + 1]
65
+ if line.strip():
66
+ yield line.decode('utf-8', errors='replace')
67
+
68
+ if buffer.strip():
69
+ yield buffer.decode('utf-8', errors='replace')
70
+
71
+
72
+ def _extract_model(entry: dict) -> Optional[str]:
73
+ """Extract model from a transcript entry (top level or nested in message)."""
74
+ return entry.get('model') or (entry.get('message') or {}).get('model')
75
+
76
+
77
+ def _extract_generation_id(entry: dict) -> Optional[str]:
78
+ """Extract generation ID from a user-type transcript entry."""
79
+ if entry.get('type') == 'user':
80
+ return entry.get('uuid')
81
+ return None
82
+
83
+
84
+ def extract_from_claude_transcript(
85
+ transcript_path: str,
86
+ ) -> tuple[Optional[str], Optional[str], Optional[str]]:
87
+ """Extract ``(ide_version, model, generation_id)`` from a transcript.
88
+
89
+ The transcript is a JSONL file scanned from end → start so the most recent
90
+ entries are read first. Any field may come back ``None`` if not found.
91
+ """
92
+ if not transcript_path:
93
+ return None, None, None
94
+
95
+ path = Path(transcript_path)
96
+ if not path.exists():
97
+ return None, None, None
98
+
99
+ ide_version = None
100
+ model = None
101
+ generation_id = None
102
+
103
+ try:
104
+ for line in _reverse_readline(path):
105
+ line = line.strip()
106
+ if not line:
107
+ continue
108
+ try:
109
+ entry = json.loads(line)
110
+ ide_version = ide_version or entry.get('version')
111
+ model = model or _extract_model(entry)
112
+ generation_id = generation_id or _extract_generation_id(entry)
113
+
114
+ if ide_version and model and generation_id:
115
+ break
116
+ except json.JSONDecodeError:
117
+ continue
118
+ except OSError:
119
+ pass
120
+
121
+ return ide_version, model, generation_id
122
+
123
+
124
+ # --- ~/.claude.json + ~/.claude/settings.json parsing -------------------------
125
+
126
+
127
+ def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
128
+ """Load and parse `~/.claude.json`. Returns None if missing/invalid."""
129
+ path = config_path or _CLAUDE_CONFIG_PATH
130
+ if not path.exists():
131
+ logger.debug('Claude config file not found, %s', {'path': str(path)})
132
+ return None
133
+ try:
134
+ return json.loads(path.read_text(encoding='utf-8'))
135
+ except Exception as e:
136
+ logger.debug('Failed to load Claude config file', exc_info=e)
137
+ return None
138
+
139
+
140
+ def _email_from_config(config: dict) -> Optional[str]:
141
+ """Read ``oauthAccount.emailAddress`` from a parsed Claude config."""
142
+ return config.get('oauthAccount', {}).get('emailAddress')
143
+
144
+
145
+ def get_mcp_servers(config: dict) -> Optional[dict]:
146
+ """Read ``mcpServers`` from a parsed Claude config."""
147
+ return config.get('mcpServers')
148
+
149
+
150
+ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]:
151
+ """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid."""
152
+ path = settings_path or _CLAUDE_SETTINGS_PATH
153
+ if not path.exists():
154
+ logger.debug('Claude settings file not found, %s', {'path': str(path)})
155
+ return None
156
+ try:
157
+ return json.loads(path.read_text(encoding='utf-8'))
158
+ except Exception as e:
159
+ logger.debug('Failed to load Claude settings file', exc_info=e)
160
+ return None
161
+
162
+
163
+ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
164
+ """Resolve filesystem path for a directory-type marketplace."""
165
+ source = marketplace.get('source', {})
166
+ if source.get('source') != 'directory':
167
+ return None
168
+ raw = source.get('path')
169
+ if not raw:
170
+ return None
171
+ path = Path(raw)
172
+ return path if path.is_dir() else None
173
+
174
+
175
+ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]:
176
+ """Read one Claude Code plugin's manifest + MCP servers.
177
+
178
+ Claude hardcodes the MCP file at ``<plugin_dir>/.mcp.json`` and always
179
+ wraps it as ``{"mcpServers": {...}}``.
180
+ """
181
+ manifest = load_plugin_json(plugin_dir / '.claude-plugin' / 'plugin.json') or {}
182
+ entry: dict = {}
183
+ for field in ('name', 'version', 'description'):
184
+ if field in manifest:
185
+ entry[field] = manifest[field]
186
+
187
+ mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {}
188
+ servers: dict = mcp_config.get('mcpServers') or {}
189
+ if servers:
190
+ entry['mcp_server_names'] = list(servers.keys())
191
+ return entry, servers
192
+
193
+
194
+ def resolve_plugins(settings: dict) -> tuple[dict, dict]:
195
+ """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker.
196
+
197
+ Each enabled plugin's marketplace is resolved through
198
+ ``extraKnownMarketplaces`` to a directory; the rest of the work
199
+ (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``.
200
+ """
201
+ enabled = settings.get('enabledPlugins') or {}
202
+ marketplaces = settings.get('extraKnownMarketplaces') or {}
203
+
204
+ def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]:
205
+ marketplace = marketplaces.get(marketplace_name)
206
+ if not marketplace:
207
+ return None
208
+ return _resolve_marketplace_path(marketplace)
209
+
210
+ return walk_enabled_plugins(
211
+ plugin_entries=enabled,
212
+ is_enabled=bool,
213
+ locate_dir=_locate,
214
+ read_plugin=_read_claude_plugin,
215
+ )
216
+
217
+
218
+ # --- IDE integration ----------------------------------------------------------
219
+
220
+
221
+ class ClaudeCode(IDE):
222
+ name: ClassVar[str] = 'claude-code'
223
+ display_name: ClassVar[str] = 'Claude Code'
224
+ hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS)
225
+
226
+ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
227
+ if scope == 'repo' and repo_path:
228
+ return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME
229
+ return _USER_HOOKS_DIR / _HOOKS_FILE_NAME
230
+
231
+ def render_hooks_config(self, async_mode: bool = False) -> dict:
232
+ # Claude Code uses a nested hook structure with optional async/timeout.
233
+ hook_entry: dict = {'type': 'command', 'command': _SCAN_COMMAND}
234
+ if async_mode:
235
+ hook_entry['async'] = True
236
+ hook_entry['timeout'] = 20
237
+
238
+ return {
239
+ 'hooks': {
240
+ 'SessionStart': [
241
+ {
242
+ 'matcher': 'startup|clear',
243
+ 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
244
+ }
245
+ ],
246
+ 'UserPromptSubmit': [
247
+ {
248
+ 'hooks': [deepcopy(hook_entry)],
249
+ }
250
+ ],
251
+ 'PreToolUse': [
252
+ {
253
+ 'matcher': 'Read',
254
+ 'hooks': [deepcopy(hook_entry)],
255
+ },
256
+ {
257
+ 'matcher': 'mcp__.*',
258
+ 'hooks': [deepcopy(hook_entry)],
259
+ },
260
+ ],
261
+ },
262
+ }
263
+
264
+ def matches_payload(self, raw_payload: dict) -> bool:
265
+ return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES
266
+
267
+ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
268
+ hook_event_name = raw_payload.get('hook_event_name', '')
269
+ tool_name = raw_payload.get('tool_name', '')
270
+ tool_input = raw_payload.get('tool_input')
271
+
272
+ if hook_event_name == 'UserPromptSubmit':
273
+ canonical_event: AiHookEventType | str = AiHookEventType.PROMPT
274
+ elif hook_event_name == 'PreToolUse':
275
+ canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION
276
+ else:
277
+ canonical_event = hook_event_name
278
+
279
+ # Extract file_path from tool_input for the Read tool.
280
+ file_path = None
281
+ if tool_name == 'Read' and isinstance(tool_input, dict):
282
+ file_path = tool_input.get('file_path')
283
+
284
+ # For MCP tools, the entire tool_input is the arguments.
285
+ mcp_arguments = tool_input if tool_name.startswith('mcp__') else None
286
+
287
+ # MCP tool name format: mcp__<server>__<tool>
288
+ mcp_server_name = None
289
+ mcp_tool_name = None
290
+ if tool_name.startswith('mcp__'):
291
+ parts = tool_name.split('__')
292
+ if len(parts) >= 2:
293
+ mcp_server_name = parts[1]
294
+ if len(parts) >= 3:
295
+ mcp_tool_name = parts[2]
296
+
297
+ ide_version, model, generation_id = extract_from_claude_transcript(raw_payload.get('transcript_path'))
298
+
299
+ config = load_claude_config()
300
+ ide_user_email = _email_from_config(config) if config else None
301
+
302
+ return AIHookPayload(
303
+ event_name=canonical_event,
304
+ conversation_id=raw_payload.get('session_id'),
305
+ generation_id=generation_id,
306
+ ide_user_email=ide_user_email,
307
+ model=model,
308
+ ide_provider=self.name,
309
+ ide_version=ide_version,
310
+ prompt=raw_payload.get('prompt', ''),
311
+ file_path=file_path,
312
+ mcp_server_name=mcp_server_name,
313
+ mcp_tool_name=mcp_tool_name,
314
+ mcp_arguments=mcp_arguments,
315
+ )
316
+
317
+ def build_hook_response(self, decision: HookDecision) -> dict:
318
+ if decision.event_type == AiHookEventType.PROMPT:
319
+ if decision.action == DecisionAction.ALLOW:
320
+ return {}
321
+ # Both DENY and (unexpected) ASK on prompts collapse to a block.
322
+ return {'decision': 'block', 'reason': decision.user_message or ''}
323
+
324
+ # FILE_READ / MCP_EXECUTION → hookSpecificOutput shape.
325
+ if decision.action == DecisionAction.ALLOW:
326
+ return {
327
+ 'hookSpecificOutput': {
328
+ 'hookEventName': 'PreToolUse',
329
+ 'permissionDecision': 'allow',
330
+ }
331
+ }
332
+ return {
333
+ 'hookSpecificOutput': {
334
+ 'hookEventName': 'PreToolUse',
335
+ 'permissionDecision': decision.action.value, # 'deny' or 'ask'
336
+ 'permissionDecisionReason': decision.user_message or '',
337
+ }
338
+ }
339
+
340
+ def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
341
+ config = load_claude_config()
342
+ ide_user_email = _email_from_config(config) if config else None
343
+ ide_version, _, _ = extract_from_claude_transcript(raw_payload.get('transcript_path'))
344
+
345
+ return AIHookPayload(
346
+ conversation_id=raw_payload.get('session_id'),
347
+ ide_user_email=ide_user_email,
348
+ model=raw_payload.get('model'),
349
+ ide_provider=self.name,
350
+ ide_version=ide_version,
351
+ source=raw_payload.get('source'),
352
+ )
353
+
354
+ def get_user_email(self) -> Optional[str]:
355
+ config = load_claude_config()
356
+ return _email_from_config(config) if config else None
357
+
358
+ def get_session_context(self) -> tuple[dict, dict]:
359
+ config = load_claude_config()
360
+ mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {}
361
+
362
+ settings = load_claude_settings()
363
+ if settings:
364
+ plugin_mcp, enriched_plugins = resolve_plugins(settings)
365
+ mcp_servers.update(plugin_mcp)
366
+ else:
367
+ enriched_plugins = {}
368
+
369
+ return mcp_servers, enriched_plugins