agentseed-mcp 0.3.0
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.
- package/CHANGELOG.md +178 -0
- package/LICENSE +202 -0
- package/README.ja.md +320 -0
- package/README.md +318 -0
- package/README.zh.md +306 -0
- package/bin/cli.js +37 -0
- package/mcp.json +12 -0
- package/package.json +30 -0
- package/plugin.json +22 -0
- package/server/.agentseed/verification-log.jsonl +2 -0
- package/server/__pycache__/guard_cli.cpython-313.pyc +0 -0
- package/server/__pycache__/guard_engine.cpython-313.pyc +0 -0
- package/server/__pycache__/test_cli.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_cli.cpython-313.pyc +0 -0
- package/server/__pycache__/test_features.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_features.cpython-313.pyc +0 -0
- package/server/__pycache__/test_guard.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_guard.cpython-313.pyc +0 -0
- package/server/__pycache__/test_hook.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_hook.cpython-313.pyc +0 -0
- package/server/__pycache__/test_manifests.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_manifests.cpython-313.pyc +0 -0
- package/server/__pycache__/test_server.cpython-313-pytest-9.1.1.pyc +0 -0
- package/server/__pycache__/test_server.cpython-313.pyc +0 -0
- package/server/engine/__init__.py +64 -0
- package/server/engine/__pycache__/__init__.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/audit.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/config.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/hallucination.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/imports.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/plugin.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/schema.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/symbols.cpython-313.pyc +0 -0
- package/server/engine/__pycache__/version.cpython-313.pyc +0 -0
- package/server/engine/audit.py +84 -0
- package/server/engine/config.py +131 -0
- package/server/engine/hallucination.py +254 -0
- package/server/engine/imports.py +136 -0
- package/server/engine/plugin.py +367 -0
- package/server/engine/sandbox.py +287 -0
- package/server/engine/schema.py +193 -0
- package/server/engine/symbols.py +984 -0
- package/server/engine/version.py +17 -0
- package/server/guard_cli.py +455 -0
- package/server/guard_engine.py +111 -0
- package/server/guard_hook.py +404 -0
- package/server/guard_server.py +472 -0
- package/server/requirements.txt +7 -0
- package/server/test_cli.py +132 -0
- package/server/test_features.py +426 -0
- package/server/test_guard.py +828 -0
- package/server/test_hook.py +331 -0
- package/server/test_manifests.py +70 -0
- package/server/test_server.py +247 -0
- package/skills/verify-before-code/SKILL.ja.md +116 -0
- package/skills/verify-before-code/SKILL.md +140 -0
- package/skills/verify-before-code/SKILL.zh.md +117 -0
- package/skills/verify-before-code/references/DEFAULT-NORMS.md +52 -0
- package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.ja.md +121 -0
- package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.md +166 -0
- package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.zh.md +145 -0
- package/skills/verify-before-code/references/PROMPT-POOL.ja.md +248 -0
- package/skills/verify-before-code/references/PROMPT-POOL.md +282 -0
- package/skills/verify-before-code/references/PROMPT-POOL.zh.md +252 -0
- package/skills/verify-before-code/references/SDD-CONTRACT.ja.md +61 -0
- package/skills/verify-before-code/references/SDD-CONTRACT.md +66 -0
- package/skills/verify-before-code/references/SDD-CONTRACT.zh.md +58 -0
- package/skills/verify-before-code/references/VENDOR-SOLUTIONS.ja.md +62 -0
- package/skills/verify-before-code/references/VENDOR-SOLUTIONS.md +62 -0
- package/skills/verify-before-code/references/VENDOR-SOLUTIONS.zh.md +54 -0
- package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.ja.md +68 -0
- package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.md +73 -0
- package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.zh.md +68 -0
- package/skills/verify-before-code/scripts/check.ps1 +52 -0
- package/skills/verify-before-code/scripts/check.sh +44 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""AgentSeed client-enforcement hook for Claude Code.
|
|
2
|
+
|
|
3
|
+
The skill teaches the workflow; hooks enforce it at the client boundary.
|
|
4
|
+
Registered as a Claude Code hook, every Write/Edit/MultiEdit tool call is
|
|
5
|
+
scanned here — PreToolUse inspects the incoming ``content``/``new_string``
|
|
6
|
+
BEFORE anything lands on disk, PostToolUse re-checks the saved file.
|
|
7
|
+
|
|
8
|
+
Modes:
|
|
9
|
+
hook mode (default) one event JSON on stdin -> JSON verdict on stdout.
|
|
10
|
+
Exit codes follow the Claude Code hook contract:
|
|
11
|
+
0 = pass / skipped / warning-only,
|
|
12
|
+
2 = blocking findings (stderr carries the reason).
|
|
13
|
+
--file PATH scan one file directly instead of reading stdin.
|
|
14
|
+
register --client claude [--settings PATH]
|
|
15
|
+
merge this hook into Claude settings.json (idempotent).
|
|
16
|
+
register --client cursor [--settings PATH]
|
|
17
|
+
merge into Cursor hooks.json (afterFileEdit + preToolUse,
|
|
18
|
+
idempotent; schema per cursor.com/docs/agent/hooks).
|
|
19
|
+
register --client opencode [--settings DEST]
|
|
20
|
+
install plugin/opencode/agentseed-guard.js into
|
|
21
|
+
~/.config/opencode/plugin/ (or DEST).
|
|
22
|
+
|
|
23
|
+
Failure policy (honest scope): infrastructure problems — malformed stdin,
|
|
24
|
+
unreadable files, unrecognized tool shapes — never block work (skipped,
|
|
25
|
+
exit 0). Only positive scan findings block. Zero dependencies: stdlib plus
|
|
26
|
+
the local guard_engine package.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import shutil
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
import guard_engine as engine
|
|
38
|
+
|
|
39
|
+
SCAN_SUFFIXES = (
|
|
40
|
+
".py",
|
|
41
|
+
".ts",
|
|
42
|
+
".tsx",
|
|
43
|
+
".js",
|
|
44
|
+
".jsx",
|
|
45
|
+
".mjs",
|
|
46
|
+
".cjs",
|
|
47
|
+
".md",
|
|
48
|
+
".json",
|
|
49
|
+
".yaml",
|
|
50
|
+
".yml",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
LANG_BY_SUFFIX = {
|
|
54
|
+
".py": "python",
|
|
55
|
+
".ts": "typescript",
|
|
56
|
+
".tsx": "typescript",
|
|
57
|
+
".js": "javascript",
|
|
58
|
+
".jsx": "javascript",
|
|
59
|
+
".mjs": "javascript",
|
|
60
|
+
".cjs": "javascript",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
MATCHER = "Write|Edit|MultiEdit"
|
|
64
|
+
HOOK_EVENTS = ("PreToolUse", "PostToolUse")
|
|
65
|
+
|
|
66
|
+
# Cursor marks its payloads with these top-level fields (common schema,
|
|
67
|
+
# cursor.com/docs/agent/hooks); its afterFileEdit event puts file_path at
|
|
68
|
+
# the top level and carries edits as a sibling list.
|
|
69
|
+
CURSOR_MARKERS = ("cursor_version", "workspace_roots")
|
|
70
|
+
CURSOR_EDIT_EVENTS = ("afterFileEdit", "afterTabFileEdit")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _detect_protocol(event: dict) -> str:
|
|
74
|
+
if any(k in event for k in CURSOR_MARKERS):
|
|
75
|
+
return "cursor"
|
|
76
|
+
if event.get("hook_event_name") in CURSOR_EDIT_EVENTS:
|
|
77
|
+
return "cursor"
|
|
78
|
+
return "claude"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _inline_content(tool_input: dict) -> str | None:
|
|
82
|
+
"""New text a PreToolUse event would write, from documented fields only:
|
|
83
|
+
Write.content, Edit.new_string, MultiEdit.edits[].new_string."""
|
|
84
|
+
parts: list[str] = []
|
|
85
|
+
for key in ("content", "new_string"):
|
|
86
|
+
val = tool_input.get(key)
|
|
87
|
+
if isinstance(val, str):
|
|
88
|
+
parts.append(val)
|
|
89
|
+
edits = tool_input.get("edits")
|
|
90
|
+
if isinstance(edits, list):
|
|
91
|
+
for e in edits:
|
|
92
|
+
if isinstance(e, dict) and isinstance(e.get("new_string"), str):
|
|
93
|
+
parts.append(e["new_string"])
|
|
94
|
+
if parts:
|
|
95
|
+
return "\n".join(parts)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _extract_target(event: dict) -> tuple[str | None, str | None]:
|
|
100
|
+
"""Return (file_path_or_None, inline_text_or_None) for an event."""
|
|
101
|
+
tool_input = event.get("tool_input")
|
|
102
|
+
if not isinstance(tool_input, dict):
|
|
103
|
+
tool_input = {}
|
|
104
|
+
path = tool_input.get("file_path")
|
|
105
|
+
if not isinstance(path, str) or not path:
|
|
106
|
+
# Cursor afterFileEdit: {file_path, edits[]} at the top level
|
|
107
|
+
path = event.get("file_path")
|
|
108
|
+
if not isinstance(path, str) or not path:
|
|
109
|
+
path = None
|
|
110
|
+
inline = _inline_content(tool_input)
|
|
111
|
+
if inline is None:
|
|
112
|
+
edits = event.get("edits")
|
|
113
|
+
if isinstance(edits, list):
|
|
114
|
+
parts = [
|
|
115
|
+
e["new_string"]
|
|
116
|
+
for e in edits
|
|
117
|
+
if isinstance(e, dict) and isinstance(e.get("new_string"), str)
|
|
118
|
+
]
|
|
119
|
+
inline = "\n".join(parts) if parts else None
|
|
120
|
+
return path, inline
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_file(path: str) -> str:
|
|
124
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
125
|
+
return fh.read()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def scan_source(text: str, label: str, config: dict) -> dict:
|
|
129
|
+
"""Run the two detection engines over one source text."""
|
|
130
|
+
allowlist = engine.config_str_list(config, "allowlist") or engine.DEFAULT_ALLOWLIST
|
|
131
|
+
severities = engine.config_severities(config)
|
|
132
|
+
scan = engine.scan_hallucination_words(
|
|
133
|
+
text, allowlist, severities, extra_tokens=engine.config_extra_tokens(config)
|
|
134
|
+
)
|
|
135
|
+
suffix = os.path.splitext(label)[1].lower()
|
|
136
|
+
suspects: list[str] = []
|
|
137
|
+
lang = LANG_BY_SUFFIX.get(suffix)
|
|
138
|
+
if lang:
|
|
139
|
+
res = engine.detect_undefined_symbols(
|
|
140
|
+
text,
|
|
141
|
+
lang,
|
|
142
|
+
suppress=engine.config_str_list(config, "suppress_symbols"),
|
|
143
|
+
)
|
|
144
|
+
suspects = list(res.get("suspects", []))
|
|
145
|
+
hits = [
|
|
146
|
+
{k: h[k] for k in ("word", "group", "line", "severity") if k in h}
|
|
147
|
+
for h in scan.get("hits", [])
|
|
148
|
+
]
|
|
149
|
+
blocking = bool(scan.get("blocking")) or bool(suspects)
|
|
150
|
+
return {"suspects": suspects, "hits": hits, "blocking": blocking}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_hook(event: dict, config_path: str | None = None) -> tuple[dict, int]:
|
|
154
|
+
"""Evaluate one hook event; returns (verdict, exit_code)."""
|
|
155
|
+
config = engine.load_config(config_path)
|
|
156
|
+
verdict: dict = {
|
|
157
|
+
"event": event.get("hook_event_name"),
|
|
158
|
+
"tool": event.get("tool_name"),
|
|
159
|
+
"file": None,
|
|
160
|
+
"protocol": _detect_protocol(event),
|
|
161
|
+
"status": "pass",
|
|
162
|
+
"suspects": [],
|
|
163
|
+
"hits": [],
|
|
164
|
+
"blocking": False,
|
|
165
|
+
}
|
|
166
|
+
path, inline = _extract_target(event)
|
|
167
|
+
verdict["file"] = path
|
|
168
|
+
if path is None and inline is None:
|
|
169
|
+
verdict["status"] = "skipped"
|
|
170
|
+
verdict["reason"] = "no file_path and no inline content in tool_input"
|
|
171
|
+
return verdict, 0
|
|
172
|
+
target = path or "<inline>"
|
|
173
|
+
if path is not None and os.path.splitext(path)[1].lower() not in SCAN_SUFFIXES:
|
|
174
|
+
verdict["status"] = "skipped"
|
|
175
|
+
verdict["reason"] = f"extension not scannable: {os.path.splitext(path)[1] or '<none>'}"
|
|
176
|
+
return verdict, 0
|
|
177
|
+
try:
|
|
178
|
+
if inline is not None:
|
|
179
|
+
text = inline
|
|
180
|
+
elif isinstance(path, str):
|
|
181
|
+
text = _load_file(path)
|
|
182
|
+
else:
|
|
183
|
+
verdict["status"] = "skipped"
|
|
184
|
+
verdict["reason"] = "nothing to scan"
|
|
185
|
+
return verdict, 0
|
|
186
|
+
except OSError as exc:
|
|
187
|
+
verdict["status"] = "skipped"
|
|
188
|
+
verdict["reason"] = f"cannot read target: {exc}"
|
|
189
|
+
return verdict, 0
|
|
190
|
+
findings = scan_source(text, target, config)
|
|
191
|
+
verdict.update(findings)
|
|
192
|
+
if not findings["blocking"]:
|
|
193
|
+
return verdict, 0
|
|
194
|
+
verdict["status"] = "blocked"
|
|
195
|
+
reasons = []
|
|
196
|
+
if findings["suspects"]:
|
|
197
|
+
reasons.append("possibly-hallucinated symbol(s): " + ", ".join(findings["suspects"]))
|
|
198
|
+
for h in findings["hits"]:
|
|
199
|
+
reasons.append(
|
|
200
|
+
f"{h.get('severity', '?')} {h.get('group', '?')} '{h.get('word')}' "
|
|
201
|
+
f"(line {h.get('line', '?')})"
|
|
202
|
+
)
|
|
203
|
+
message = f"[agentseed] blocked edit to {target}: fix these before proceeding — " + "; ".join(
|
|
204
|
+
reasons
|
|
205
|
+
)
|
|
206
|
+
verdict["reason"] = message
|
|
207
|
+
try:
|
|
208
|
+
engine.record_verification(
|
|
209
|
+
f"hook:{event.get('tool_name', '?')}:{os.path.basename(target)}",
|
|
210
|
+
[{"tool": "guard_hook", "status": "fail"}],
|
|
211
|
+
summary=message,
|
|
212
|
+
)
|
|
213
|
+
except Exception:
|
|
214
|
+
pass # audit trail is best-effort; enforcement never depends on it
|
|
215
|
+
return verdict, 2
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_agentseed_entry(entry: dict) -> bool:
|
|
219
|
+
return "guard_hook.py" in str(entry.get("command", ""))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _clean_groups(groups: list, command: str) -> list:
|
|
223
|
+
"""Drop every previous agentseed entry so re-registration is idempotent."""
|
|
224
|
+
kept: list = []
|
|
225
|
+
for group in groups:
|
|
226
|
+
if not isinstance(group, dict) or not isinstance(group.get("hooks"), list):
|
|
227
|
+
continue
|
|
228
|
+
hooks = [h for h in group["hooks"] if not (isinstance(h, dict) and _is_agentseed_entry(h))]
|
|
229
|
+
if hooks:
|
|
230
|
+
group["hooks"] = hooks
|
|
231
|
+
kept.append(group)
|
|
232
|
+
kept.append({"matcher": MATCHER, "hooks": [{"type": "command", "command": command}]})
|
|
233
|
+
return kept
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
CURSOR_HOOK_EVENTS = ("preToolUse", "afterFileEdit")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _register_cursor(command: str, path: str) -> int:
|
|
240
|
+
data: dict = {}
|
|
241
|
+
if os.path.isfile(path):
|
|
242
|
+
try:
|
|
243
|
+
with open(path, encoding="utf-8") as fh:
|
|
244
|
+
data = json.load(fh)
|
|
245
|
+
except (OSError, ValueError) as exc:
|
|
246
|
+
print(json.dumps({"ok": False, "error": f"cannot parse {path}: {exc}"}, indent=2))
|
|
247
|
+
return 1
|
|
248
|
+
if not isinstance(data, dict):
|
|
249
|
+
data = {}
|
|
250
|
+
data["version"] = 1
|
|
251
|
+
hooks = data.get("hooks")
|
|
252
|
+
if not isinstance(hooks, dict):
|
|
253
|
+
hooks = {}
|
|
254
|
+
data["hooks"] = hooks
|
|
255
|
+
for ev in CURSOR_HOOK_EVENTS: # Cursor's own event names (docs: agent/hooks)
|
|
256
|
+
groups = hooks.get(ev)
|
|
257
|
+
if not isinstance(groups, list):
|
|
258
|
+
groups = []
|
|
259
|
+
hooks[ev] = _clean_groups(groups, command)
|
|
260
|
+
try:
|
|
261
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
262
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
263
|
+
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
264
|
+
fh.write("\n")
|
|
265
|
+
except OSError as exc:
|
|
266
|
+
print(json.dumps({"ok": False, "error": f"cannot write {path}: {exc}"}, indent=2))
|
|
267
|
+
return 1
|
|
268
|
+
print(
|
|
269
|
+
json.dumps({"ok": True, "registered": list(CURSOR_HOOK_EVENTS), "settings": path}, indent=2)
|
|
270
|
+
)
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _opencode_plugin_src() -> str:
|
|
275
|
+
return os.path.join(
|
|
276
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
277
|
+
"plugin",
|
|
278
|
+
"opencode",
|
|
279
|
+
"agentseed-guard.js",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _register_opencode(dest: str) -> int:
|
|
284
|
+
src = _opencode_plugin_src()
|
|
285
|
+
if not os.path.isfile(src):
|
|
286
|
+
print(json.dumps({"ok": False, "error": f"plugin file missing: {src}"}, indent=2))
|
|
287
|
+
return 1
|
|
288
|
+
try:
|
|
289
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
290
|
+
shutil.copyfile(src, dest)
|
|
291
|
+
except OSError as exc:
|
|
292
|
+
print(json.dumps({"ok": False, "error": f"cannot install plugin: {exc}"}, indent=2))
|
|
293
|
+
return 1
|
|
294
|
+
print(json.dumps({"ok": True, "registered": ["tool.execute.before"], "plugin": dest}, indent=2))
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_register(client: str, settings_path: str | None = None) -> int:
|
|
299
|
+
command = f'"{sys.executable}" "{os.path.abspath(__file__)}"'
|
|
300
|
+
if client == "cursor":
|
|
301
|
+
path = settings_path or os.path.join(os.path.expanduser("~"), ".cursor", "hooks.json")
|
|
302
|
+
return _register_cursor(command, path)
|
|
303
|
+
if client == "opencode":
|
|
304
|
+
dest = settings_path or os.path.join(
|
|
305
|
+
os.path.expanduser("~"), ".config", "opencode", "plugin", "agentseed-guard.js"
|
|
306
|
+
)
|
|
307
|
+
return _register_opencode(dest)
|
|
308
|
+
path = settings_path or os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
|
|
309
|
+
data: dict = {}
|
|
310
|
+
if os.path.isfile(path):
|
|
311
|
+
try:
|
|
312
|
+
with open(path, encoding="utf-8") as fh:
|
|
313
|
+
data = json.load(fh)
|
|
314
|
+
except (OSError, ValueError) as exc:
|
|
315
|
+
print(json.dumps({"ok": False, "error": f"cannot parse {path}: {exc}"}, indent=2))
|
|
316
|
+
return 1
|
|
317
|
+
if not isinstance(data, dict):
|
|
318
|
+
data = {}
|
|
319
|
+
hooks = data.get("hooks")
|
|
320
|
+
if not isinstance(hooks, dict):
|
|
321
|
+
hooks = {}
|
|
322
|
+
data["hooks"] = hooks
|
|
323
|
+
for ev in HOOK_EVENTS:
|
|
324
|
+
groups = hooks.get(ev)
|
|
325
|
+
if not isinstance(groups, list):
|
|
326
|
+
groups = []
|
|
327
|
+
hooks[ev] = _clean_groups(groups, command)
|
|
328
|
+
try:
|
|
329
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
330
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
331
|
+
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
332
|
+
fh.write("\n")
|
|
333
|
+
except OSError as exc:
|
|
334
|
+
print(json.dumps({"ok": False, "error": f"cannot write {path}: {exc}"}, indent=2))
|
|
335
|
+
return 1
|
|
336
|
+
print(json.dumps({"ok": True, "registered": list(HOOK_EVENTS), "settings": path}, indent=2))
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main(argv: list[str] | None = None) -> int:
|
|
341
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
342
|
+
try:
|
|
343
|
+
if argv and argv[0] == "register":
|
|
344
|
+
parser = argparse.ArgumentParser(prog="agentseed-hook register")
|
|
345
|
+
parser.add_argument(
|
|
346
|
+
"--client", default="claude", choices=["claude", "opencode", "cursor"]
|
|
347
|
+
)
|
|
348
|
+
parser.add_argument(
|
|
349
|
+
"--settings",
|
|
350
|
+
help="explicit target path (claude/cursor settings or hooks json; "
|
|
351
|
+
"opencode destination plugin file)",
|
|
352
|
+
)
|
|
353
|
+
ns = parser.parse_args(argv[1:])
|
|
354
|
+
return cmd_register(ns.client, ns.settings)
|
|
355
|
+
parser = argparse.ArgumentParser(prog="agentseed-hook", description=__doc__)
|
|
356
|
+
parser.add_argument("--file", help="scan this file directly (skip stdin)")
|
|
357
|
+
parser.add_argument("--config", help="explicit agentseed config path")
|
|
358
|
+
ns = parser.parse_args(argv)
|
|
359
|
+
if ns.file:
|
|
360
|
+
event = {
|
|
361
|
+
"hook_event_name": "ManualScan",
|
|
362
|
+
"tool_name": "manual",
|
|
363
|
+
"tool_input": {"file_path": ns.file},
|
|
364
|
+
}
|
|
365
|
+
else:
|
|
366
|
+
raw = sys.stdin.buffer.read().decode("utf-8", "replace")
|
|
367
|
+
try:
|
|
368
|
+
event = json.loads(raw) if raw.strip() else {}
|
|
369
|
+
except ValueError:
|
|
370
|
+
print(
|
|
371
|
+
json.dumps({"status": "skipped", "reason": "stdin is not valid JSON"}, indent=2)
|
|
372
|
+
)
|
|
373
|
+
return 0
|
|
374
|
+
verdict, code = run_hook(event, ns.config)
|
|
375
|
+
print(json.dumps(verdict, ensure_ascii=False, indent=2))
|
|
376
|
+
if code == 2:
|
|
377
|
+
reason = verdict.get("reason", "blocked by agentseed")
|
|
378
|
+
# exit 2 blocks in BOTH clients; Claude Code feeds stderr back to the
|
|
379
|
+
# model, while Cursor's documented deny flow reads agent_message
|
|
380
|
+
# from stdout — emit both so each client gets its channel
|
|
381
|
+
if verdict.get("protocol") == "cursor":
|
|
382
|
+
print(
|
|
383
|
+
json.dumps(
|
|
384
|
+
{
|
|
385
|
+
"continue": True,
|
|
386
|
+
"permission": "deny",
|
|
387
|
+
"user_message": reason,
|
|
388
|
+
"agent_message": reason,
|
|
389
|
+
},
|
|
390
|
+
ensure_ascii=False,
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
print(reason, file=sys.stderr)
|
|
394
|
+
return code
|
|
395
|
+
except Exception as exc: # fail-open: broken tooling must not block editors
|
|
396
|
+
print(
|
|
397
|
+
json.dumps({"status": "skipped", "reason": f"internal error: {exc!r}"}, indent=2),
|
|
398
|
+
file=sys.stdout,
|
|
399
|
+
)
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
if __name__ == "__main__":
|
|
404
|
+
sys.exit(main())
|