cycode 3.15.3.dev8__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.
- 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 +51 -138
- cycode/cli/apps/ai_guardrails/ides/__init__.py +44 -0
- cycode/cli/apps/ai_guardrails/ides/base.py +156 -0
- cycode/cli/apps/ai_guardrails/ides/claude_code.py +389 -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 +46 -89
- 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-3.15.3.dev8.dist-info → cycode-3.15.4.dev1.dist-info}/METADATA +1 -1
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev1.dist-info}/RECORD +21 -20
- 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.dev1.dist-info}/WHEEL +0 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev1.dist-info}/entry_points.txt +0 -0
- {cycode-3.15.3.dev8.dist-info → cycode-3.15.4.dev1.dist-info}/licenses/LICENCE +0 -0
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
# --- runtime scan ---
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def matches_payload(self, raw_payload: dict) -> bool:
|
|
115
|
+
"""Return True if ``raw_payload`` originated from this IDE.
|
|
116
|
+
|
|
117
|
+
Prevents double-processing when an IDE forwards another IDE's hook
|
|
118
|
+
event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json).
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
123
|
+
"""Normalize a raw stdin payload into the canonical ``AIHookPayload``."""
|
|
124
|
+
|
|
125
|
+
@abstractmethod
|
|
126
|
+
def build_hook_response(self, decision: HookDecision) -> dict:
|
|
127
|
+
"""Translate a canonical ``HookDecision`` into the IDE-specific JSON.
|
|
128
|
+
|
|
129
|
+
The result is what ``scan_command`` writes to stdout for the IDE to
|
|
130
|
+
act on.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
# --- session lifecycle (optional; sensible defaults) ---
|
|
134
|
+
|
|
135
|
+
def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
136
|
+
"""Build a session-start payload from the raw stdin payload.
|
|
137
|
+
|
|
138
|
+
Default: a minimal payload tagged with this IDE's ``name``. IDEs
|
|
139
|
+
that need to enrich with transcript/version info should override.
|
|
140
|
+
"""
|
|
141
|
+
return AIHookPayload(ide_provider=self.name)
|
|
142
|
+
|
|
143
|
+
def get_user_email(self) -> Optional[str]:
|
|
144
|
+
"""Best-effort read of the user's email from IDE-specific config.
|
|
145
|
+
|
|
146
|
+
Default: None. Override if the IDE stores a usable account locally.
|
|
147
|
+
"""
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def get_session_context(self) -> tuple[dict, dict]:
|
|
151
|
+
"""Return ``(mcp_servers, enabled_plugins)`` for session-context reporting.
|
|
152
|
+
|
|
153
|
+
Default: empty dicts (no plugin system, no discoverable MCP config).
|
|
154
|
+
Override to surface MCP/plugin inventory.
|
|
155
|
+
"""
|
|
156
|
+
return {}, {}
|
|
@@ -0,0 +1,389 @@
|
|
|
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.base import IDE, DecisionAction, HookDecision
|
|
11
|
+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
12
|
+
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
|
|
13
|
+
from cycode.logger import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger('AI Guardrails Claude Code')
|
|
16
|
+
|
|
17
|
+
_CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'})
|
|
18
|
+
|
|
19
|
+
_USER_HOOKS_DIR = Path.home() / '.claude'
|
|
20
|
+
_HOOKS_FILE_NAME = 'settings.json'
|
|
21
|
+
_REPO_SUBDIR = '.claude'
|
|
22
|
+
_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp']
|
|
23
|
+
|
|
24
|
+
_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json'
|
|
25
|
+
_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json'
|
|
26
|
+
|
|
27
|
+
_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code'
|
|
28
|
+
_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- transcript JSONL parsing -------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]:
|
|
35
|
+
"""Yield lines of `path` from end to start without loading the file.
|
|
36
|
+
|
|
37
|
+
The Claude Code transcript can be very large; reading from the tail keeps
|
|
38
|
+
memory bounded since we only care about the most recent entries.
|
|
39
|
+
"""
|
|
40
|
+
with path.open('rb') as f:
|
|
41
|
+
f.seek(0, 2)
|
|
42
|
+
file_size = f.tell()
|
|
43
|
+
if file_size == 0:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
remaining = file_size
|
|
47
|
+
buffer = b''
|
|
48
|
+
|
|
49
|
+
while remaining > 0:
|
|
50
|
+
read_size = min(buf_size, remaining)
|
|
51
|
+
remaining -= read_size
|
|
52
|
+
f.seek(remaining)
|
|
53
|
+
chunk = f.read(read_size)
|
|
54
|
+
buffer = chunk + buffer
|
|
55
|
+
|
|
56
|
+
while b'\n' in buffer:
|
|
57
|
+
newline_pos = buffer.rfind(b'\n')
|
|
58
|
+
if newline_pos == len(buffer) - 1:
|
|
59
|
+
newline_pos = buffer.rfind(b'\n', 0, newline_pos)
|
|
60
|
+
if newline_pos == -1:
|
|
61
|
+
break
|
|
62
|
+
line = buffer[newline_pos + 1 :]
|
|
63
|
+
buffer = buffer[: newline_pos + 1]
|
|
64
|
+
if line.strip():
|
|
65
|
+
yield line.decode('utf-8', errors='replace')
|
|
66
|
+
|
|
67
|
+
if buffer.strip():
|
|
68
|
+
yield buffer.decode('utf-8', errors='replace')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_model(entry: dict) -> Optional[str]:
|
|
72
|
+
"""Extract model from a transcript entry (top level or nested in message)."""
|
|
73
|
+
return entry.get('model') or (entry.get('message') or {}).get('model')
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _extract_generation_id(entry: dict) -> Optional[str]:
|
|
77
|
+
"""Extract generation ID from a user-type transcript entry."""
|
|
78
|
+
if entry.get('type') == 'user':
|
|
79
|
+
return entry.get('uuid')
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_from_claude_transcript(
|
|
84
|
+
transcript_path: str,
|
|
85
|
+
) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
|
86
|
+
"""Extract ``(ide_version, model, generation_id)`` from a transcript.
|
|
87
|
+
|
|
88
|
+
The transcript is a JSONL file scanned from end → start so the most recent
|
|
89
|
+
entries are read first. Any field may come back ``None`` if not found.
|
|
90
|
+
"""
|
|
91
|
+
if not transcript_path:
|
|
92
|
+
return None, None, None
|
|
93
|
+
|
|
94
|
+
path = Path(transcript_path)
|
|
95
|
+
if not path.exists():
|
|
96
|
+
return None, None, None
|
|
97
|
+
|
|
98
|
+
ide_version = None
|
|
99
|
+
model = None
|
|
100
|
+
generation_id = None
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
for line in _reverse_readline(path):
|
|
104
|
+
line = line.strip()
|
|
105
|
+
if not line:
|
|
106
|
+
continue
|
|
107
|
+
try:
|
|
108
|
+
entry = json.loads(line)
|
|
109
|
+
ide_version = ide_version or entry.get('version')
|
|
110
|
+
model = model or _extract_model(entry)
|
|
111
|
+
generation_id = generation_id or _extract_generation_id(entry)
|
|
112
|
+
|
|
113
|
+
if ide_version and model and generation_id:
|
|
114
|
+
break
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
continue
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
return ide_version, model, generation_id
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- ~/.claude.json + ~/.claude/settings.json parsing -------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]:
|
|
127
|
+
"""Load and parse `~/.claude.json`. Returns None if missing/invalid."""
|
|
128
|
+
path = config_path or _CLAUDE_CONFIG_PATH
|
|
129
|
+
if not path.exists():
|
|
130
|
+
logger.debug('Claude config file not found', extra={'path': str(path)})
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.debug('Failed to load Claude config file', exc_info=e)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _email_from_config(config: dict) -> Optional[str]:
|
|
140
|
+
"""Read ``oauthAccount.emailAddress`` from a parsed Claude config."""
|
|
141
|
+
return config.get('oauthAccount', {}).get('emailAddress')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_mcp_servers(config: dict) -> Optional[dict]:
|
|
145
|
+
"""Read ``mcpServers`` from a parsed Claude config."""
|
|
146
|
+
return config.get('mcpServers')
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]:
|
|
150
|
+
"""Load and parse `~/.claude/settings.json`. Returns None if missing/invalid."""
|
|
151
|
+
path = settings_path or _CLAUDE_SETTINGS_PATH
|
|
152
|
+
if not path.exists():
|
|
153
|
+
logger.debug('Claude settings file not found', extra={'path': str(path)})
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.debug('Failed to load Claude settings file', exc_info=e)
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]:
|
|
163
|
+
"""Resolve filesystem path for a directory-type marketplace."""
|
|
164
|
+
source = marketplace.get('source', {})
|
|
165
|
+
if source.get('source') != 'directory':
|
|
166
|
+
return None
|
|
167
|
+
raw = source.get('path')
|
|
168
|
+
if not raw:
|
|
169
|
+
return None
|
|
170
|
+
path = Path(raw)
|
|
171
|
+
return path if path.is_dir() else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]:
|
|
175
|
+
"""Load and parse a JSON file inside a plugin directory.
|
|
176
|
+
|
|
177
|
+
Returns None if the file is missing, unreadable, or has invalid JSON.
|
|
178
|
+
"""
|
|
179
|
+
target = plugin_path / relative_path
|
|
180
|
+
if not target.exists():
|
|
181
|
+
return None
|
|
182
|
+
try:
|
|
183
|
+
return json.loads(target.read_text(encoding='utf-8'))
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e)
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def resolve_plugins(settings: dict) -> tuple[dict, dict]:
|
|
190
|
+
"""Resolve enabled plugins to their MCP servers and metadata.
|
|
191
|
+
|
|
192
|
+
Walks ``enabledPlugins`` from claude settings, resolves each plugin's
|
|
193
|
+
marketplace directory via ``extraKnownMarketplaces``, and reads:
|
|
194
|
+
- ``<path>/.mcp.json`` for MCP servers (merged into a flat dict)
|
|
195
|
+
- ``<path>/.claude-plugin/plugin.json`` for metadata (name, version, description)
|
|
196
|
+
|
|
197
|
+
Returns ``(merged_mcp_servers, enriched_plugins)``.
|
|
198
|
+
"""
|
|
199
|
+
enabled = settings.get('enabledPlugins') or {}
|
|
200
|
+
marketplaces = settings.get('extraKnownMarketplaces') or {}
|
|
201
|
+
merged_mcp: dict = {}
|
|
202
|
+
enriched: dict = {}
|
|
203
|
+
|
|
204
|
+
for plugin_key, is_enabled in enabled.items():
|
|
205
|
+
if not is_enabled:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
entry: dict = {'enabled': True}
|
|
209
|
+
enriched[plugin_key] = entry
|
|
210
|
+
|
|
211
|
+
if '@' not in plugin_key:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
_plugin_name, marketplace_name = plugin_key.split('@', 1)
|
|
215
|
+
marketplace = marketplaces.get(marketplace_name)
|
|
216
|
+
if not marketplace:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
plugin_path = _resolve_marketplace_path(marketplace)
|
|
220
|
+
if plugin_path is None:
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {}
|
|
224
|
+
for field in ('name', 'version', 'description'):
|
|
225
|
+
if field in metadata:
|
|
226
|
+
entry[field] = metadata[field]
|
|
227
|
+
|
|
228
|
+
mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {}
|
|
229
|
+
plugin_server_names = []
|
|
230
|
+
for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items():
|
|
231
|
+
merged_mcp[server_name] = server_cfg
|
|
232
|
+
plugin_server_names.append(server_name)
|
|
233
|
+
if plugin_server_names:
|
|
234
|
+
entry['mcp_server_names'] = plugin_server_names
|
|
235
|
+
|
|
236
|
+
return merged_mcp, enriched
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --- IDE integration ----------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class ClaudeCode(IDE):
|
|
243
|
+
name: ClassVar[str] = 'claude-code'
|
|
244
|
+
display_name: ClassVar[str] = 'Claude Code'
|
|
245
|
+
hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS)
|
|
246
|
+
|
|
247
|
+
def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
|
|
248
|
+
if scope == 'repo' and repo_path:
|
|
249
|
+
return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME
|
|
250
|
+
return _USER_HOOKS_DIR / _HOOKS_FILE_NAME
|
|
251
|
+
|
|
252
|
+
def render_hooks_config(self, async_mode: bool = False) -> dict:
|
|
253
|
+
# Claude Code uses a nested hook structure with optional async/timeout.
|
|
254
|
+
hook_entry: dict = {'type': 'command', 'command': _SCAN_COMMAND}
|
|
255
|
+
if async_mode:
|
|
256
|
+
hook_entry['async'] = True
|
|
257
|
+
hook_entry['timeout'] = 20
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
'hooks': {
|
|
261
|
+
'SessionStart': [
|
|
262
|
+
{
|
|
263
|
+
'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}],
|
|
264
|
+
}
|
|
265
|
+
],
|
|
266
|
+
'UserPromptSubmit': [
|
|
267
|
+
{
|
|
268
|
+
'hooks': [deepcopy(hook_entry)],
|
|
269
|
+
}
|
|
270
|
+
],
|
|
271
|
+
'PreToolUse': [
|
|
272
|
+
{
|
|
273
|
+
'matcher': 'Read',
|
|
274
|
+
'hooks': [deepcopy(hook_entry)],
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
'matcher': 'mcp__.*',
|
|
278
|
+
'hooks': [deepcopy(hook_entry)],
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
def matches_payload(self, raw_payload: dict) -> bool:
|
|
285
|
+
return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES
|
|
286
|
+
|
|
287
|
+
def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
288
|
+
hook_event_name = raw_payload.get('hook_event_name', '')
|
|
289
|
+
tool_name = raw_payload.get('tool_name', '')
|
|
290
|
+
tool_input = raw_payload.get('tool_input')
|
|
291
|
+
|
|
292
|
+
if hook_event_name == 'UserPromptSubmit':
|
|
293
|
+
canonical_event: AiHookEventType | str = AiHookEventType.PROMPT
|
|
294
|
+
elif hook_event_name == 'PreToolUse':
|
|
295
|
+
canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION
|
|
296
|
+
else:
|
|
297
|
+
canonical_event = hook_event_name
|
|
298
|
+
|
|
299
|
+
# Extract file_path from tool_input for the Read tool.
|
|
300
|
+
file_path = None
|
|
301
|
+
if tool_name == 'Read' and isinstance(tool_input, dict):
|
|
302
|
+
file_path = tool_input.get('file_path')
|
|
303
|
+
|
|
304
|
+
# For MCP tools, the entire tool_input is the arguments.
|
|
305
|
+
mcp_arguments = tool_input if tool_name.startswith('mcp__') else None
|
|
306
|
+
|
|
307
|
+
# MCP tool name format: mcp__<server>__<tool>
|
|
308
|
+
mcp_server_name = None
|
|
309
|
+
mcp_tool_name = None
|
|
310
|
+
if tool_name.startswith('mcp__'):
|
|
311
|
+
parts = tool_name.split('__')
|
|
312
|
+
if len(parts) >= 2:
|
|
313
|
+
mcp_server_name = parts[1]
|
|
314
|
+
if len(parts) >= 3:
|
|
315
|
+
mcp_tool_name = parts[2]
|
|
316
|
+
|
|
317
|
+
ide_version, model, generation_id = extract_from_claude_transcript(raw_payload.get('transcript_path'))
|
|
318
|
+
|
|
319
|
+
config = load_claude_config()
|
|
320
|
+
ide_user_email = _email_from_config(config) if config else None
|
|
321
|
+
|
|
322
|
+
return AIHookPayload(
|
|
323
|
+
event_name=canonical_event,
|
|
324
|
+
conversation_id=raw_payload.get('session_id'),
|
|
325
|
+
generation_id=generation_id,
|
|
326
|
+
ide_user_email=ide_user_email,
|
|
327
|
+
model=model,
|
|
328
|
+
ide_provider=self.name,
|
|
329
|
+
ide_version=ide_version,
|
|
330
|
+
prompt=raw_payload.get('prompt', ''),
|
|
331
|
+
file_path=file_path,
|
|
332
|
+
mcp_server_name=mcp_server_name,
|
|
333
|
+
mcp_tool_name=mcp_tool_name,
|
|
334
|
+
mcp_arguments=mcp_arguments,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def build_hook_response(self, decision: HookDecision) -> dict:
|
|
338
|
+
if decision.event_type == AiHookEventType.PROMPT:
|
|
339
|
+
if decision.action == DecisionAction.ALLOW:
|
|
340
|
+
return {}
|
|
341
|
+
# Both DENY and (unexpected) ASK on prompts collapse to a block.
|
|
342
|
+
return {'decision': 'block', 'reason': decision.user_message or ''}
|
|
343
|
+
|
|
344
|
+
# FILE_READ / MCP_EXECUTION → hookSpecificOutput shape.
|
|
345
|
+
if decision.action == DecisionAction.ALLOW:
|
|
346
|
+
return {
|
|
347
|
+
'hookSpecificOutput': {
|
|
348
|
+
'hookEventName': 'PreToolUse',
|
|
349
|
+
'permissionDecision': 'allow',
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
'hookSpecificOutput': {
|
|
354
|
+
'hookEventName': 'PreToolUse',
|
|
355
|
+
'permissionDecision': decision.action.value, # 'deny' or 'ask'
|
|
356
|
+
'permissionDecisionReason': decision.user_message or '',
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
361
|
+
config = load_claude_config()
|
|
362
|
+
ide_user_email = _email_from_config(config) if config else None
|
|
363
|
+
ide_version, _, _ = extract_from_claude_transcript(raw_payload.get('transcript_path'))
|
|
364
|
+
|
|
365
|
+
return AIHookPayload(
|
|
366
|
+
conversation_id=raw_payload.get('session_id'),
|
|
367
|
+
ide_user_email=ide_user_email,
|
|
368
|
+
model=raw_payload.get('model'),
|
|
369
|
+
ide_provider=self.name,
|
|
370
|
+
ide_version=ide_version,
|
|
371
|
+
source=raw_payload.get('source'),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def get_user_email(self) -> Optional[str]:
|
|
375
|
+
config = load_claude_config()
|
|
376
|
+
return _email_from_config(config) if config else None
|
|
377
|
+
|
|
378
|
+
def get_session_context(self) -> tuple[dict, dict]:
|
|
379
|
+
config = load_claude_config()
|
|
380
|
+
mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {}
|
|
381
|
+
|
|
382
|
+
settings = load_claude_settings()
|
|
383
|
+
if settings:
|
|
384
|
+
plugin_mcp, enriched_plugins = resolve_plugins(settings)
|
|
385
|
+
mcp_servers.update(plugin_mcp)
|
|
386
|
+
else:
|
|
387
|
+
enriched_plugins = {}
|
|
388
|
+
|
|
389
|
+
return mcp_servers, enriched_plugins
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Cursor IDE integration for AI guardrails."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import platform
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar, Optional
|
|
7
|
+
|
|
8
|
+
from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND
|
|
9
|
+
from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision
|
|
10
|
+
from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload
|
|
11
|
+
from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType
|
|
12
|
+
from cycode.logger import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger('AI Guardrails Cursor')
|
|
15
|
+
|
|
16
|
+
_CURSOR_EVENT_MAPPING: dict[str, AiHookEventType] = {
|
|
17
|
+
'beforeSubmitPrompt': AiHookEventType.PROMPT,
|
|
18
|
+
'beforeReadFile': AiHookEventType.FILE_READ,
|
|
19
|
+
'beforeMCPExecution': AiHookEventType.MCP_EXECUTION,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_HOOKS_FILE_NAME = 'hooks.json'
|
|
23
|
+
_REPO_SUBDIR = '.cursor'
|
|
24
|
+
_MCP_CONFIG_FILENAME = 'mcp.json'
|
|
25
|
+
|
|
26
|
+
# Cursor was the original default IDE — its scan command omits --ide to stay
|
|
27
|
+
# byte-identical with already-installed hooks.json files. Session-start is
|
|
28
|
+
# always explicit because it was introduced after Claude Code support.
|
|
29
|
+
_SCAN_COMMAND = CYCODE_SCAN_PROMPT_COMMAND
|
|
30
|
+
_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide cursor'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _user_hooks_dir() -> Path:
|
|
34
|
+
"""Per-platform Cursor user-scope settings directory."""
|
|
35
|
+
if platform.system() == 'Darwin':
|
|
36
|
+
return Path.home() / '.cursor'
|
|
37
|
+
if platform.system() == 'Windows':
|
|
38
|
+
return Path.home() / 'AppData' / 'Roaming' / 'Cursor'
|
|
39
|
+
return Path.home() / '.config' / 'Cursor'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]:
|
|
43
|
+
"""Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid."""
|
|
44
|
+
path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
logger.debug('Cursor MCP config file not found', extra={'path': str(path)})
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.debug('Failed to load Cursor MCP config file', exc_info=e)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Cursor(IDE):
|
|
56
|
+
name: ClassVar[str] = 'cursor'
|
|
57
|
+
display_name: ClassVar[str] = 'Cursor'
|
|
58
|
+
hook_events: ClassVar[list[str]] = list(_CURSOR_EVENT_MAPPING)
|
|
59
|
+
|
|
60
|
+
def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path:
|
|
61
|
+
if scope == 'repo' and repo_path:
|
|
62
|
+
return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME
|
|
63
|
+
return _user_hooks_dir() / _HOOKS_FILE_NAME
|
|
64
|
+
|
|
65
|
+
def render_hooks_config(self, async_mode: bool = False) -> dict:
|
|
66
|
+
command = f'{_SCAN_COMMAND} &' if async_mode else _SCAN_COMMAND
|
|
67
|
+
hooks = {event: [{'command': command}] for event in self.hook_events}
|
|
68
|
+
hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}]
|
|
69
|
+
return {'version': 1, 'hooks': hooks}
|
|
70
|
+
|
|
71
|
+
def matches_payload(self, raw_payload: dict) -> bool:
|
|
72
|
+
return raw_payload.get('hook_event_name', '') in _CURSOR_EVENT_MAPPING
|
|
73
|
+
|
|
74
|
+
def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
75
|
+
cursor_event_name = raw_payload.get('hook_event_name', '')
|
|
76
|
+
canonical_event = _CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name)
|
|
77
|
+
return AIHookPayload(
|
|
78
|
+
event_name=canonical_event,
|
|
79
|
+
conversation_id=raw_payload.get('conversation_id'),
|
|
80
|
+
generation_id=raw_payload.get('generation_id'),
|
|
81
|
+
ide_user_email=raw_payload.get('user_email'),
|
|
82
|
+
model=raw_payload.get('model'),
|
|
83
|
+
ide_provider=self.name,
|
|
84
|
+
ide_version=raw_payload.get('cursor_version'),
|
|
85
|
+
prompt=raw_payload.get('prompt', ''),
|
|
86
|
+
file_path=raw_payload.get('file_path') or raw_payload.get('path'),
|
|
87
|
+
mcp_server_name=raw_payload.get('command'),
|
|
88
|
+
mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'),
|
|
89
|
+
mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def build_hook_response(self, decision: HookDecision) -> dict:
|
|
93
|
+
if decision.event_type == AiHookEventType.PROMPT:
|
|
94
|
+
if decision.action == DecisionAction.ALLOW:
|
|
95
|
+
return {'continue': True}
|
|
96
|
+
return {'continue': False, 'user_message': decision.user_message or ''}
|
|
97
|
+
|
|
98
|
+
# FILE_READ / MCP_EXECUTION → permission shape
|
|
99
|
+
if decision.action == DecisionAction.ALLOW:
|
|
100
|
+
return {'permission': 'allow'}
|
|
101
|
+
return {
|
|
102
|
+
'permission': decision.action.value, # 'deny' or 'ask'
|
|
103
|
+
'user_message': decision.user_message or '',
|
|
104
|
+
'agent_message': decision.agent_message or '',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def build_session_payload(self, raw_payload: dict) -> AIHookPayload:
|
|
108
|
+
return AIHookPayload(
|
|
109
|
+
conversation_id=raw_payload.get('conversation_id'),
|
|
110
|
+
ide_user_email=raw_payload.get('user_email'),
|
|
111
|
+
model=raw_payload.get('model'),
|
|
112
|
+
ide_provider=self.name,
|
|
113
|
+
ide_version=raw_payload.get('cursor_version'),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def get_session_context(self) -> tuple[dict, dict]:
|
|
117
|
+
config = _load_cursor_mcp_config()
|
|
118
|
+
mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {}
|
|
119
|
+
return mcp_servers, {}
|