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.
- cycode/__init__.py +1 -1
- cycode/cli/apps/ai_guardrails/command_utils.py +2 -45
- cycode/cli/apps/ai_guardrails/consts.py +3 -135
- cycode/cli/apps/ai_guardrails/hooks_manager.py +123 -152
- cycode/cli/apps/ai_guardrails/ides/__init__.py +45 -0
- cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +73 -0
- cycode/cli/apps/ai_guardrails/ides/base.py +176 -0
- cycode/cli/apps/ai_guardrails/ides/claude_code.py +369 -0
- cycode/cli/apps/ai_guardrails/ides/codex.py +310 -0
- cycode/cli/apps/ai_guardrails/ides/cursor.py +119 -0
- cycode/cli/apps/ai_guardrails/install_command.py +14 -23
- cycode/cli/apps/ai_guardrails/scan/handlers.py +102 -101
- cycode/cli/apps/ai_guardrails/scan/payload.py +14 -255
- cycode/cli/apps/ai_guardrails/scan/scan_command.py +60 -48
- cycode/cli/apps/ai_guardrails/scan/types.py +8 -30
- cycode/cli/apps/ai_guardrails/session_start_command.py +14 -78
- cycode/cli/apps/ai_guardrails/status_command.py +13 -16
- cycode/cli/apps/ai_guardrails/uninstall_command.py +12 -22
- cycode/cli/utils/jwt_utils.py +8 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/METADATA +3 -1
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/RECORD +24 -21
- cycode/cli/apps/ai_guardrails/scan/claude_config.py +0 -159
- cycode/cli/apps/ai_guardrails/scan/cursor_config.py +0 -36
- cycode/cli/apps/ai_guardrails/scan/response_builders.py +0 -135
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/WHEEL +0 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev2.dist-info}/entry_points.txt +0 -0
- {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
|