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.
Files changed (76) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/LICENSE +202 -0
  3. package/README.ja.md +320 -0
  4. package/README.md +318 -0
  5. package/README.zh.md +306 -0
  6. package/bin/cli.js +37 -0
  7. package/mcp.json +12 -0
  8. package/package.json +30 -0
  9. package/plugin.json +22 -0
  10. package/server/.agentseed/verification-log.jsonl +2 -0
  11. package/server/__pycache__/guard_cli.cpython-313.pyc +0 -0
  12. package/server/__pycache__/guard_engine.cpython-313.pyc +0 -0
  13. package/server/__pycache__/test_cli.cpython-313-pytest-9.1.1.pyc +0 -0
  14. package/server/__pycache__/test_cli.cpython-313.pyc +0 -0
  15. package/server/__pycache__/test_features.cpython-313-pytest-9.1.1.pyc +0 -0
  16. package/server/__pycache__/test_features.cpython-313.pyc +0 -0
  17. package/server/__pycache__/test_guard.cpython-313-pytest-9.1.1.pyc +0 -0
  18. package/server/__pycache__/test_guard.cpython-313.pyc +0 -0
  19. package/server/__pycache__/test_hook.cpython-313-pytest-9.1.1.pyc +0 -0
  20. package/server/__pycache__/test_hook.cpython-313.pyc +0 -0
  21. package/server/__pycache__/test_manifests.cpython-313-pytest-9.1.1.pyc +0 -0
  22. package/server/__pycache__/test_manifests.cpython-313.pyc +0 -0
  23. package/server/__pycache__/test_server.cpython-313-pytest-9.1.1.pyc +0 -0
  24. package/server/__pycache__/test_server.cpython-313.pyc +0 -0
  25. package/server/engine/__init__.py +64 -0
  26. package/server/engine/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/server/engine/__pycache__/audit.cpython-313.pyc +0 -0
  28. package/server/engine/__pycache__/config.cpython-313.pyc +0 -0
  29. package/server/engine/__pycache__/hallucination.cpython-313.pyc +0 -0
  30. package/server/engine/__pycache__/imports.cpython-313.pyc +0 -0
  31. package/server/engine/__pycache__/plugin.cpython-313.pyc +0 -0
  32. package/server/engine/__pycache__/sandbox.cpython-313.pyc +0 -0
  33. package/server/engine/__pycache__/schema.cpython-313.pyc +0 -0
  34. package/server/engine/__pycache__/symbols.cpython-313.pyc +0 -0
  35. package/server/engine/__pycache__/version.cpython-313.pyc +0 -0
  36. package/server/engine/audit.py +84 -0
  37. package/server/engine/config.py +131 -0
  38. package/server/engine/hallucination.py +254 -0
  39. package/server/engine/imports.py +136 -0
  40. package/server/engine/plugin.py +367 -0
  41. package/server/engine/sandbox.py +287 -0
  42. package/server/engine/schema.py +193 -0
  43. package/server/engine/symbols.py +984 -0
  44. package/server/engine/version.py +17 -0
  45. package/server/guard_cli.py +455 -0
  46. package/server/guard_engine.py +111 -0
  47. package/server/guard_hook.py +404 -0
  48. package/server/guard_server.py +472 -0
  49. package/server/requirements.txt +7 -0
  50. package/server/test_cli.py +132 -0
  51. package/server/test_features.py +426 -0
  52. package/server/test_guard.py +828 -0
  53. package/server/test_hook.py +331 -0
  54. package/server/test_manifests.py +70 -0
  55. package/server/test_server.py +247 -0
  56. package/skills/verify-before-code/SKILL.ja.md +116 -0
  57. package/skills/verify-before-code/SKILL.md +140 -0
  58. package/skills/verify-before-code/SKILL.zh.md +117 -0
  59. package/skills/verify-before-code/references/DEFAULT-NORMS.md +52 -0
  60. package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.ja.md +121 -0
  61. package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.md +166 -0
  62. package/skills/verify-before-code/references/HALLUCINATION-PATTERNS.zh.md +145 -0
  63. package/skills/verify-before-code/references/PROMPT-POOL.ja.md +248 -0
  64. package/skills/verify-before-code/references/PROMPT-POOL.md +282 -0
  65. package/skills/verify-before-code/references/PROMPT-POOL.zh.md +252 -0
  66. package/skills/verify-before-code/references/SDD-CONTRACT.ja.md +61 -0
  67. package/skills/verify-before-code/references/SDD-CONTRACT.md +66 -0
  68. package/skills/verify-before-code/references/SDD-CONTRACT.zh.md +58 -0
  69. package/skills/verify-before-code/references/VENDOR-SOLUTIONS.ja.md +62 -0
  70. package/skills/verify-before-code/references/VENDOR-SOLUTIONS.md +62 -0
  71. package/skills/verify-before-code/references/VENDOR-SOLUTIONS.zh.md +54 -0
  72. package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.ja.md +68 -0
  73. package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.md +73 -0
  74. package/skills/verify-before-code/references/VERIFICATION-CHECKLIST.zh.md +68 -0
  75. package/skills/verify-before-code/scripts/check.ps1 +52 -0
  76. 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())