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,331 @@
|
|
|
1
|
+
"""AgentSeed client-enforcement hook tests (stdlib unittest, subprocess)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
11
|
+
HOOK = os.path.join(HERE, "guard_hook.py")
|
|
12
|
+
PLUGIN_ROOT = os.path.dirname(HERE)
|
|
13
|
+
PY = sys.executable
|
|
14
|
+
|
|
15
|
+
OVERSOLD = "all tests pass, guaranteed"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_hook(payload=None, *extra: str) -> subprocess.CompletedProcess:
|
|
19
|
+
stdin = "" if payload is None else json.dumps(payload)
|
|
20
|
+
return subprocess.run(
|
|
21
|
+
[PY, HOOK, *extra],
|
|
22
|
+
input=stdin,
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
encoding="utf-8",
|
|
26
|
+
errors="replace",
|
|
27
|
+
timeout=60,
|
|
28
|
+
cwd=PLUGIN_ROOT,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestHookMode(unittest.TestCase):
|
|
33
|
+
def test_pretooluse_clean_write_passes(self):
|
|
34
|
+
r = run_hook(
|
|
35
|
+
{
|
|
36
|
+
"hook_event_name": "PreToolUse",
|
|
37
|
+
"tool_name": "Write",
|
|
38
|
+
"tool_input": {"file_path": "mod.py", "content": "x = 1\n"},
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
42
|
+
v = json.loads(r.stdout)
|
|
43
|
+
self.assertEqual(v["status"], "pass")
|
|
44
|
+
self.assertFalse(v["blocking"])
|
|
45
|
+
|
|
46
|
+
def test_pretooluse_blocks_oversold_write(self):
|
|
47
|
+
r = run_hook(
|
|
48
|
+
{
|
|
49
|
+
"hook_event_name": "PreToolUse",
|
|
50
|
+
"tool_name": "Write",
|
|
51
|
+
"tool_input": {"file_path": "notes.md", "content": OVERSOLD + "\n"},
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
self.assertEqual(r.returncode, 2, r.stdout)
|
|
55
|
+
v = json.loads(r.stdout)
|
|
56
|
+
self.assertEqual(v["status"], "blocked")
|
|
57
|
+
self.assertTrue(v["blocking"])
|
|
58
|
+
self.assertTrue(v["hits"])
|
|
59
|
+
self.assertIn(OVERSOLD.split(",")[0], r.stderr)
|
|
60
|
+
|
|
61
|
+
def test_pretooluse_blocks_hallucinated_symbol(self):
|
|
62
|
+
r = run_hook(
|
|
63
|
+
{
|
|
64
|
+
"hook_event_name": "PreToolUse",
|
|
65
|
+
"tool_name": "Write",
|
|
66
|
+
"tool_input": {"file_path": "m.py", "content": "return magic_unknown()\n"},
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
self.assertEqual(r.returncode, 2, r.stdout)
|
|
70
|
+
v = json.loads(r.stdout)
|
|
71
|
+
self.assertIn("magic_unknown", v["suspects"])
|
|
72
|
+
|
|
73
|
+
def test_edit_new_string_is_scanned(self):
|
|
74
|
+
r = run_hook(
|
|
75
|
+
{
|
|
76
|
+
"hook_event_name": "PreToolUse",
|
|
77
|
+
"tool_name": "Edit",
|
|
78
|
+
"tool_input": {"file_path": "a.md", "old_string": "x", "new_string": OVERSOLD},
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
self.assertEqual(r.returncode, 2, r.stdout)
|
|
82
|
+
v = json.loads(r.stdout)
|
|
83
|
+
self.assertEqual(v["status"], "blocked")
|
|
84
|
+
|
|
85
|
+
def test_multiedit_edits_list_is_scanned(self):
|
|
86
|
+
r = run_hook(
|
|
87
|
+
{
|
|
88
|
+
"hook_event_name": "PreToolUse",
|
|
89
|
+
"tool_name": "MultiEdit",
|
|
90
|
+
"tool_input": {
|
|
91
|
+
"file_path": "a.md",
|
|
92
|
+
"edits": [{"old_string": "x", "new_string": OVERSOLD}],
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
self.assertEqual(r.returncode, 2, r.stdout)
|
|
97
|
+
|
|
98
|
+
def test_warning_only_does_not_block(self):
|
|
99
|
+
r = run_hook(
|
|
100
|
+
{
|
|
101
|
+
"hook_event_name": "PreToolUse",
|
|
102
|
+
"tool_name": "Write",
|
|
103
|
+
"tool_input": {"file_path": "m.py", "content": "# TODO: later\n"},
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
107
|
+
v = json.loads(r.stdout)
|
|
108
|
+
self.assertFalse(v["blocking"])
|
|
109
|
+
self.assertTrue(v["hits"]) # reported, just not blocking
|
|
110
|
+
|
|
111
|
+
def test_posttooluse_scans_file_from_disk(self):
|
|
112
|
+
with tempfile.TemporaryDirectory() as d:
|
|
113
|
+
path = os.path.join(d, "doc.md")
|
|
114
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
115
|
+
fh.write("this module is production ready now\n")
|
|
116
|
+
# no inline content key -> falls back to reading the file on disk
|
|
117
|
+
r = run_hook(
|
|
118
|
+
{
|
|
119
|
+
"hook_event_name": "PostToolUse",
|
|
120
|
+
"tool_name": "NotebookSave",
|
|
121
|
+
"tool_input": {"file_path": path},
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
self.assertEqual(r.returncode, 2, r.stdout)
|
|
125
|
+
v = json.loads(r.stdout)
|
|
126
|
+
self.assertEqual(v["status"], "blocked")
|
|
127
|
+
|
|
128
|
+
def test_unsupported_extension_skips(self):
|
|
129
|
+
r = run_hook(
|
|
130
|
+
{
|
|
131
|
+
"hook_event_name": "PreToolUse",
|
|
132
|
+
"tool_name": "Write",
|
|
133
|
+
"tool_input": {"file_path": "img.png", "content": OVERSOLD},
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
137
|
+
v = json.loads(r.stdout)
|
|
138
|
+
self.assertEqual(v["status"], "skipped")
|
|
139
|
+
|
|
140
|
+
def test_unknown_tool_shape_skips(self):
|
|
141
|
+
r = run_hook({"hook_event_name": "PostToolUse", "tool_name": "WebFetch", "tool_input": {}})
|
|
142
|
+
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
143
|
+
v = json.loads(r.stdout)
|
|
144
|
+
self.assertEqual(v["status"], "skipped")
|
|
145
|
+
|
|
146
|
+
def test_malformed_stdin_fails_open(self):
|
|
147
|
+
r = subprocess.run(
|
|
148
|
+
[PY, HOOK],
|
|
149
|
+
input="this is not json",
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
encoding="utf-8",
|
|
153
|
+
errors="replace",
|
|
154
|
+
timeout=60,
|
|
155
|
+
cwd=PLUGIN_ROOT,
|
|
156
|
+
)
|
|
157
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
158
|
+
v = json.loads(r.stdout)
|
|
159
|
+
self.assertEqual(v["status"], "skipped")
|
|
160
|
+
self.assertIn("not valid JSON", v["reason"])
|
|
161
|
+
|
|
162
|
+
def test_empty_stdin_fails_open(self):
|
|
163
|
+
r = run_hook(None)
|
|
164
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
165
|
+
v = json.loads(r.stdout)
|
|
166
|
+
self.assertEqual(v["status"], "skipped")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestDirectFileScan(unittest.TestCase):
|
|
170
|
+
def test_file_flag_scans_disk(self):
|
|
171
|
+
with tempfile.TemporaryDirectory() as d:
|
|
172
|
+
path = os.path.join(d, "m.py")
|
|
173
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
174
|
+
fh.write("return magic_unknown()\n")
|
|
175
|
+
r = run_hook(None, "--file", path)
|
|
176
|
+
self.assertEqual(r.returncode, 2, r.stdout)
|
|
177
|
+
v = json.loads(r.stdout)
|
|
178
|
+
self.assertIn("magic_unknown", v["suspects"])
|
|
179
|
+
|
|
180
|
+
def test_file_flag_missing_file_skips(self):
|
|
181
|
+
r = run_hook(None, "--file", os.path.join(tempfile.gettempdir(), "no-such-file-xyz.py"))
|
|
182
|
+
self.assertEqual(r.returncode, 0, r.stderr)
|
|
183
|
+
v = json.loads(r.stdout)
|
|
184
|
+
self.assertEqual(v["status"], "skipped")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TestRegister(unittest.TestCase):
|
|
188
|
+
def test_register_merges_idempotent_and_preserves_unrelated(self):
|
|
189
|
+
with tempfile.TemporaryDirectory() as d:
|
|
190
|
+
settings = os.path.join(d, "settings.json")
|
|
191
|
+
with open(settings, "w", encoding="utf-8") as fh:
|
|
192
|
+
json.dump(
|
|
193
|
+
{
|
|
194
|
+
"model": "opus",
|
|
195
|
+
"hooks": {
|
|
196
|
+
"Stop": [{"hooks": [{"type": "command", "command": "notify.sh"}]}]
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
fh,
|
|
200
|
+
)
|
|
201
|
+
r1 = run_hook(None, "register", "--client", "claude", "--settings", settings)
|
|
202
|
+
self.assertEqual(r1.returncode, 0, r1.stdout + r1.stderr)
|
|
203
|
+
r2 = run_hook(None, "register", "--client", "claude", "--settings", settings)
|
|
204
|
+
self.assertEqual(r2.returncode, 0, r2.stdout + r2.stderr)
|
|
205
|
+
with open(settings, encoding="utf-8") as fh:
|
|
206
|
+
data = json.load(fh)
|
|
207
|
+
self.assertEqual(data["model"], "opus")
|
|
208
|
+
stop = data["hooks"]["Stop"]
|
|
209
|
+
self.assertEqual(len(stop), 1)
|
|
210
|
+
self.assertEqual(stop[0]["hooks"][0]["command"], "notify.sh")
|
|
211
|
+
for ev in ("PreToolUse", "PostToolUse"):
|
|
212
|
+
groups = data["hooks"][ev]
|
|
213
|
+
agentseed_groups = [
|
|
214
|
+
g
|
|
215
|
+
for g in groups
|
|
216
|
+
if any("guard_hook.py" in str(h.get("command", "")) for h in g.get("hooks", []))
|
|
217
|
+
]
|
|
218
|
+
self.assertEqual(len(agentseed_groups), 1, ev)
|
|
219
|
+
self.assertEqual(agentseed_groups[0]["matcher"], "Write|Edit|MultiEdit")
|
|
220
|
+
|
|
221
|
+
def test_register_replaces_stale_agentseed_entries(self):
|
|
222
|
+
with tempfile.TemporaryDirectory() as d:
|
|
223
|
+
settings = os.path.join(d, "settings.json")
|
|
224
|
+
stale = {"type": "command", "command": '"old-python" "old/guard_hook.py"'}
|
|
225
|
+
with open(settings, "w", encoding="utf-8") as fh:
|
|
226
|
+
json.dump({"hooks": {"PreToolUse": [{"matcher": "Write", "hooks": [stale]}]}}, fh)
|
|
227
|
+
r = run_hook(None, "register", "--client", "claude", "--settings", settings)
|
|
228
|
+
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
229
|
+
with open(settings, encoding="utf-8") as fh:
|
|
230
|
+
data = json.load(fh)
|
|
231
|
+
groups = data["hooks"]["PreToolUse"]
|
|
232
|
+
commands = [
|
|
233
|
+
h["command"]
|
|
234
|
+
for g in groups
|
|
235
|
+
for h in g.get("hooks", [])
|
|
236
|
+
if "guard_hook.py" in h.get("command", "")
|
|
237
|
+
]
|
|
238
|
+
self.assertEqual(len(commands), 1)
|
|
239
|
+
self.assertNotIn("old-python", commands[0])
|
|
240
|
+
|
|
241
|
+
def test_register_cursor_merges_hooks_json_idempotent(self):
|
|
242
|
+
with tempfile.TemporaryDirectory() as d:
|
|
243
|
+
hooks_json = os.path.join(d, "hooks.json")
|
|
244
|
+
with open(hooks_json, "w", encoding="utf-8") as fh:
|
|
245
|
+
json.dump(
|
|
246
|
+
{"version": 1, "hooks": {"stop": [{"command": "notify.sh"}]}},
|
|
247
|
+
fh,
|
|
248
|
+
)
|
|
249
|
+
r1 = run_hook(None, "register", "--client", "cursor", "--settings", hooks_json)
|
|
250
|
+
self.assertEqual(r1.returncode, 0, r1.stdout + r1.stderr)
|
|
251
|
+
r2 = run_hook(None, "register", "--client", "cursor", "--settings", hooks_json)
|
|
252
|
+
self.assertEqual(r2.returncode, 0, r2.stdout + r2.stderr)
|
|
253
|
+
with open(hooks_json, encoding="utf-8") as fh:
|
|
254
|
+
data = json.load(fh)
|
|
255
|
+
self.assertEqual(data["version"], 1)
|
|
256
|
+
self.assertEqual(data["hooks"]["stop"], [{"command": "notify.sh"}])
|
|
257
|
+
for ev in ("afterFileEdit", "preToolUse"):
|
|
258
|
+
groups = data["hooks"][ev]
|
|
259
|
+
mine = [
|
|
260
|
+
g
|
|
261
|
+
for g in groups
|
|
262
|
+
if any("guard_hook.py" in str(h.get("command", "")) for h in g.get("hooks", []))
|
|
263
|
+
]
|
|
264
|
+
self.assertEqual(len(mine), 1, ev)
|
|
265
|
+
|
|
266
|
+
def test_register_opencode_installs_plugin_file(self):
|
|
267
|
+
with tempfile.TemporaryDirectory() as d:
|
|
268
|
+
dest = os.path.join(d, "plugin", "agentseed-guard.js")
|
|
269
|
+
r = run_hook(None, "register", "--client", "opencode", "--settings", dest)
|
|
270
|
+
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
|
271
|
+
self.assertTrue(os.path.isfile(dest))
|
|
272
|
+
with open(dest, encoding="utf-8") as fh:
|
|
273
|
+
body = fh.read()
|
|
274
|
+
self.assertIn("tool.execute.before", body)
|
|
275
|
+
self.assertIn("guard_hook.py", body)
|
|
276
|
+
|
|
277
|
+
def test_register_bad_settings_json_fails_cleanly(self):
|
|
278
|
+
with tempfile.TemporaryDirectory() as d:
|
|
279
|
+
settings = os.path.join(d, "settings.json")
|
|
280
|
+
with open(settings, "w", encoding="utf-8") as fh:
|
|
281
|
+
fh.write("{broken")
|
|
282
|
+
r = run_hook(None, "register", "--client", "claude", "--settings", settings)
|
|
283
|
+
self.assertEqual(r.returncode, 1)
|
|
284
|
+
self.assertIn("cannot parse", r.stdout)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class TestCursorProtocol(unittest.TestCase):
|
|
288
|
+
def test_afterfileedit_toplevel_shape_blocks_with_deny_json(self):
|
|
289
|
+
event = {
|
|
290
|
+
"hook_event_name": "afterFileEdit",
|
|
291
|
+
"cursor_version": "1.7.2",
|
|
292
|
+
"workspace_roots": ["C:/proj"],
|
|
293
|
+
"file_path": "report.md",
|
|
294
|
+
"edits": [{"old_string": "x", "new_string": OVERSOLD}],
|
|
295
|
+
}
|
|
296
|
+
r = run_hook(event)
|
|
297
|
+
self.assertEqual(r.returncode, 2, r.stdout)
|
|
298
|
+
v = json.loads(r.stdout.split('{"continue"')[0]) # verdict JSON first
|
|
299
|
+
self.assertEqual(v["protocol"], "cursor")
|
|
300
|
+
self.assertEqual(v["status"], "blocked")
|
|
301
|
+
deny = r.stdout[r.stdout.index('{"continue"') :]
|
|
302
|
+
self.assertIn('"permission": "deny"', deny)
|
|
303
|
+
self.assertIn("agent_message", deny)
|
|
304
|
+
|
|
305
|
+
def test_cursor_pretooluse_toolinput_shape_blocks(self):
|
|
306
|
+
event = {
|
|
307
|
+
"hook_event_name": "preToolUse",
|
|
308
|
+
"cursor_version": "1.7.2",
|
|
309
|
+
"workspace_roots": ["C:/proj"],
|
|
310
|
+
"tool_name": "Write",
|
|
311
|
+
"tool_input": {"file_path": "m.md", "content": OVERSOLD},
|
|
312
|
+
}
|
|
313
|
+
r = run_hook(event)
|
|
314
|
+
self.assertEqual(r.returncode, 2, r.stdout)
|
|
315
|
+
|
|
316
|
+
def test_claude_payload_has_no_cursor_deny_json(self):
|
|
317
|
+
r = run_hook(
|
|
318
|
+
{
|
|
319
|
+
"hook_event_name": "PreToolUse",
|
|
320
|
+
"tool_name": "Write",
|
|
321
|
+
"tool_input": {"file_path": "m.md", "content": OVERSOLD},
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
self.assertEqual(r.returncode, 2)
|
|
325
|
+
v = json.loads(r.stdout)
|
|
326
|
+
self.assertEqual(v["protocol"], "claude")
|
|
327
|
+
self.assertNotIn('"permission"', r.stdout)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == "__main__":
|
|
331
|
+
unittest.main()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Cross-platform manifest consistency tests.
|
|
2
|
+
|
|
3
|
+
AgentSeed ships as one artifact to GitHub Releases and GitCode Releases
|
|
4
|
+
(npm is advertised in server.json only once the package actually exists).
|
|
5
|
+
These tests fail the build when plugin.json / package.json / server.json
|
|
6
|
+
drift apart (version, license, files list) — the exact failure mode that
|
|
7
|
+
shipped 1.3.3 vs 0.1.0 in the past.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import unittest
|
|
15
|
+
|
|
16
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load(name: str) -> dict:
|
|
20
|
+
with open(os.path.join(ROOT, name), encoding="utf-8") as fh:
|
|
21
|
+
return json.load(fh)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestManifestConsistency(unittest.TestCase):
|
|
25
|
+
def test_versions_match_across_manifests(self):
|
|
26
|
+
plugin_version = _load("plugin.json").get("version")
|
|
27
|
+
package_version = _load("package.json").get("version")
|
|
28
|
+
self.assertEqual(
|
|
29
|
+
plugin_version,
|
|
30
|
+
package_version,
|
|
31
|
+
f"version drift plugin.json vs package.json: {plugin_version} != {package_version}",
|
|
32
|
+
)
|
|
33
|
+
# server.json may legitimately list zero registry packages while no
|
|
34
|
+
# npm artifact exists; anything it DOES list must agree on version.
|
|
35
|
+
packages = _load("server.json").get("packages") or []
|
|
36
|
+
for i, pkg in enumerate(packages):
|
|
37
|
+
self.assertEqual(
|
|
38
|
+
pkg.get("version"),
|
|
39
|
+
plugin_version,
|
|
40
|
+
f"server.json packages[{i}] version drift",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def test_license_matches_across_manifests_and_file(self):
|
|
44
|
+
licenses = {
|
|
45
|
+
"plugin.json": _load("plugin.json").get("license"),
|
|
46
|
+
"package.json": _load("package.json").get("license"),
|
|
47
|
+
"server.json": _load("server.json").get("license"),
|
|
48
|
+
}
|
|
49
|
+
self.assertEqual(
|
|
50
|
+
len(set(licenses.values())), 1, f"license drift across platforms: {licenses}"
|
|
51
|
+
)
|
|
52
|
+
declared = next(iter(set(licenses.values())))
|
|
53
|
+
with open(os.path.join(ROOT, "LICENSE"), encoding="utf-8") as fh:
|
|
54
|
+
first_line = fh.readline().strip().lower()
|
|
55
|
+
self.assertIn(
|
|
56
|
+
declared.split("-")[0].lower(),
|
|
57
|
+
first_line,
|
|
58
|
+
f"LICENSE file does not match declared '{declared}'",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def test_package_files_entries_exist(self):
|
|
62
|
+
for entry in _load("package.json").get("files", []):
|
|
63
|
+
self.assertTrue(
|
|
64
|
+
os.path.exists(os.path.join(ROOT, entry)),
|
|
65
|
+
f"package.json 'files' entry missing on disk: {entry}",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
unittest.main()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""AgentSeed MCP server protocol tests (spawns the real stdio server)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
SERVER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "guard_server.py")
|
|
10
|
+
|
|
11
|
+
# plugin.json is the single source of truth for the server version
|
|
12
|
+
# (guard_server reads it via engine.plugin_version at startup).
|
|
13
|
+
with open(os.path.join(os.path.dirname(SERVER), "..", "plugin.json"), encoding="utf-8") as _fh:
|
|
14
|
+
EXPECTED_VERSION = json.load(_fh)["version"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestServerProtocol(unittest.TestCase):
|
|
18
|
+
proc: subprocess.Popen
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def setUpClass(cls):
|
|
22
|
+
cls.proc = subprocess.Popen(
|
|
23
|
+
[sys.executable, SERVER],
|
|
24
|
+
stdin=subprocess.PIPE,
|
|
25
|
+
stdout=subprocess.PIPE,
|
|
26
|
+
stderr=subprocess.DEVNULL,
|
|
27
|
+
text=True,
|
|
28
|
+
encoding="utf-8",
|
|
29
|
+
errors="replace",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def tearDownClass(cls):
|
|
34
|
+
proc = cls.proc
|
|
35
|
+
if proc.poll() is None:
|
|
36
|
+
# close stdin first so the server's line-read loop hits EOF and exits
|
|
37
|
+
try:
|
|
38
|
+
proc.stdin.close()
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
try:
|
|
42
|
+
proc.wait(timeout=10)
|
|
43
|
+
except subprocess.TimeoutExpired:
|
|
44
|
+
proc.kill()
|
|
45
|
+
proc.wait()
|
|
46
|
+
# always release the pipe handles; otherwise ResourceWarning fires on GC
|
|
47
|
+
for _pipe in (proc.stdin, proc.stdout, proc.stderr):
|
|
48
|
+
if _pipe is not None and not _pipe.closed:
|
|
49
|
+
_pipe.close()
|
|
50
|
+
|
|
51
|
+
def _rpc(self, payload: dict) -> dict:
|
|
52
|
+
self.proc.stdin.write(json.dumps(payload) + "\n")
|
|
53
|
+
self.proc.stdin.flush()
|
|
54
|
+
line = self.proc.stdout.readline()
|
|
55
|
+
self.assertTrue(line.strip(), "server closed the stream unexpectedly")
|
|
56
|
+
return json.loads(line)
|
|
57
|
+
|
|
58
|
+
def test_initialize_reports_version(self):
|
|
59
|
+
r = self._rpc(
|
|
60
|
+
{
|
|
61
|
+
"jsonrpc": "2.0",
|
|
62
|
+
"id": 1,
|
|
63
|
+
"method": "initialize",
|
|
64
|
+
"params": {"protocolVersion": "2024-11-05", "capabilities": {}},
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
self.assertEqual(r["result"]["serverInfo"]["version"], EXPECTED_VERSION)
|
|
68
|
+
|
|
69
|
+
def test_initialize_echoes_supported_protocol(self):
|
|
70
|
+
r = self._rpc(
|
|
71
|
+
{
|
|
72
|
+
"jsonrpc": "2.0",
|
|
73
|
+
"id": 6,
|
|
74
|
+
"method": "initialize",
|
|
75
|
+
"params": {"protocolVersion": "2025-06-18"},
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
self.assertEqual(r["result"]["protocolVersion"], "2025-06-18")
|
|
79
|
+
|
|
80
|
+
def test_initialize_falls_back_on_unknown_protocol(self):
|
|
81
|
+
r = self._rpc(
|
|
82
|
+
{
|
|
83
|
+
"jsonrpc": "2.0",
|
|
84
|
+
"id": 7,
|
|
85
|
+
"method": "initialize",
|
|
86
|
+
"params": {"protocolVersion": "1999-01-01"},
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
self.assertEqual(r["result"]["protocolVersion"], "2024-11-05")
|
|
90
|
+
|
|
91
|
+
def test_ping_returns_empty_result(self):
|
|
92
|
+
r = self._rpc({"jsonrpc": "2.0", "id": 2, "method": "ping"})
|
|
93
|
+
self.assertEqual(r["result"], {})
|
|
94
|
+
|
|
95
|
+
def test_unknown_method_is_error_32601(self):
|
|
96
|
+
r = self._rpc({"jsonrpc": "2.0", "id": 3, "method": "resources/list"})
|
|
97
|
+
self.assertEqual(r["error"]["code"], -32601)
|
|
98
|
+
self.assertNotIn("result", r)
|
|
99
|
+
|
|
100
|
+
def test_tools_list_and_call(self):
|
|
101
|
+
r = self._rpc({"jsonrpc": "2.0", "id": 4, "method": "tools/list"})
|
|
102
|
+
names = {t["name"] for t in r["result"]["tools"]}
|
|
103
|
+
self.assertEqual(
|
|
104
|
+
names,
|
|
105
|
+
{
|
|
106
|
+
"verify_code",
|
|
107
|
+
"check_contract",
|
|
108
|
+
"check_imports",
|
|
109
|
+
"scan_hallucination",
|
|
110
|
+
"check_plugin",
|
|
111
|
+
"sandbox_run",
|
|
112
|
+
"schema_validate",
|
|
113
|
+
"record_verification",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
r = self._rpc(
|
|
117
|
+
{
|
|
118
|
+
"jsonrpc": "2.0",
|
|
119
|
+
"id": 5,
|
|
120
|
+
"method": "tools/call",
|
|
121
|
+
"params": {
|
|
122
|
+
"name": "scan_hallucination",
|
|
123
|
+
"arguments": {"source": "from unittest.mock import Mock\n"},
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
result = json.loads(r["result"]["content"][0]["text"])
|
|
128
|
+
self.assertTrue(result["clean"])
|
|
129
|
+
|
|
130
|
+
def test_record_verification_via_protocol(self):
|
|
131
|
+
r = self._rpc(
|
|
132
|
+
{
|
|
133
|
+
"jsonrpc": "2.0",
|
|
134
|
+
"id": 12,
|
|
135
|
+
"method": "tools/call",
|
|
136
|
+
"params": {
|
|
137
|
+
"name": "record_verification",
|
|
138
|
+
"arguments": {
|
|
139
|
+
"task": "protocol test",
|
|
140
|
+
"checks": [{"tool": "manual", "status": "pass"}],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
result = json.loads(r["result"]["content"][0]["text"])
|
|
146
|
+
# server writes into its own PLUGIN_DATA; ok flag is what matters
|
|
147
|
+
self.assertTrue(result["ok"], result)
|
|
148
|
+
|
|
149
|
+
def test_malformed_json_line_ignored_session_continues(self):
|
|
150
|
+
self.proc.stdin.write("this is definitely not json\n")
|
|
151
|
+
self.proc.stdin.flush()
|
|
152
|
+
r = self._rpc({"jsonrpc": "2.0", "id": 40, "method": "ping"})
|
|
153
|
+
self.assertEqual(r["id"], 40)
|
|
154
|
+
|
|
155
|
+
def test_unknown_tool_returns_is_error_result(self):
|
|
156
|
+
frame = self._rpc(
|
|
157
|
+
{
|
|
158
|
+
"jsonrpc": "2.0",
|
|
159
|
+
"id": 41,
|
|
160
|
+
"method": "tools/call",
|
|
161
|
+
"params": {"name": "no_such_tool", "arguments": {}},
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
text = frame["result"]["content"][0]["text"]
|
|
165
|
+
self.assertIn("Unknown tool", text)
|
|
166
|
+
|
|
167
|
+
def test_oversized_frame_rejected_session_survives(self):
|
|
168
|
+
# A >2MB line must get -32600 with null id, and the session must
|
|
169
|
+
# keep serving normal requests afterwards.
|
|
170
|
+
big = json.dumps(
|
|
171
|
+
{"jsonrpc": "2.0", "id": 30, "method": "ping", "params": {"pad": "a" * 2_100_000}}
|
|
172
|
+
)
|
|
173
|
+
self.proc.stdin.write(big + "\n")
|
|
174
|
+
self.proc.stdin.flush()
|
|
175
|
+
frame = json.loads(self.proc.stdout.readline())
|
|
176
|
+
self.assertEqual(frame["error"]["code"], -32600)
|
|
177
|
+
r = self._rpc({"jsonrpc": "2.0", "id": 31, "method": "ping"})
|
|
178
|
+
self.assertEqual(r["id"], 31)
|
|
179
|
+
|
|
180
|
+
def test_async_sandbox_completes_normally(self):
|
|
181
|
+
# Regression guard for the Windows stdin-inheritance deadlock:
|
|
182
|
+
# spawned children must complete, never stall to their timeout.
|
|
183
|
+
r = self._rpc(
|
|
184
|
+
{
|
|
185
|
+
"jsonrpc": "2.0",
|
|
186
|
+
"id": 20,
|
|
187
|
+
"method": "tools/call",
|
|
188
|
+
"params": {
|
|
189
|
+
"name": "sandbox_run",
|
|
190
|
+
"arguments": {
|
|
191
|
+
"command": [sys.executable, "-c", "print('async-ok')"],
|
|
192
|
+
"timeout": 15,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
result = json.loads(r["result"]["content"][0]["text"])
|
|
198
|
+
self.assertEqual(result["exit_code"], 0, result)
|
|
199
|
+
self.assertFalse(result["timed_out"])
|
|
200
|
+
self.assertIn("async-ok", result["stdout"])
|
|
201
|
+
|
|
202
|
+
def test_sandbox_run_is_cancellable(self):
|
|
203
|
+
# start a long sleep, cancel it, verify: no result frame for id 9,
|
|
204
|
+
# and the session stays alive afterwards.
|
|
205
|
+
self.proc.stdin.write(
|
|
206
|
+
json.dumps(
|
|
207
|
+
{
|
|
208
|
+
"jsonrpc": "2.0",
|
|
209
|
+
"id": 9,
|
|
210
|
+
"method": "tools/call",
|
|
211
|
+
"params": {
|
|
212
|
+
"name": "sandbox_run",
|
|
213
|
+
"arguments": {
|
|
214
|
+
"command": [sys.executable, "-c", "import time; time.sleep(20)"],
|
|
215
|
+
"timeout": 30,
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
+ "\n"
|
|
221
|
+
)
|
|
222
|
+
self.proc.stdin.flush()
|
|
223
|
+
import time as _t
|
|
224
|
+
|
|
225
|
+
_t.sleep(0.6) # let the child actually spawn
|
|
226
|
+
self.proc.stdin.write(
|
|
227
|
+
json.dumps(
|
|
228
|
+
{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 9}}
|
|
229
|
+
)
|
|
230
|
+
+ "\n"
|
|
231
|
+
)
|
|
232
|
+
self.proc.stdin.flush()
|
|
233
|
+
# FIFO discipline: if the worker wrongly emitted a result for the
|
|
234
|
+
# cancelled id 9, it MUST appear before this ping's reply.
|
|
235
|
+
r = self._rpc({"jsonrpc": "2.0", "id": 10, "method": "ping"})
|
|
236
|
+
self.assertEqual(r["id"], 10)
|
|
237
|
+
import time as _t
|
|
238
|
+
|
|
239
|
+
_t.sleep(1.0)
|
|
240
|
+
r = self._rpc({"jsonrpc": "2.0", "id": 11, "method": "ping"})
|
|
241
|
+
self.assertEqual(
|
|
242
|
+
r["id"], 11, "unexpected late frame for cancelled request leaked into the stream"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
unittest.main()
|