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,367 @@
|
|
|
1
|
+
"""AgentSeed Agent Plugins 1.0.0 conformance checker.
|
|
2
|
+
|
|
3
|
+
Validates plugin.json / skills / mcp.json against the Agent Plugins spec
|
|
4
|
+
(§5, §6, §7). Acts as the spec's missing official linter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ipaddress
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Agent Plugins 1.0.0 conformance constants
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
PLUGIN_TOP_LEVEL_FIELDS = {
|
|
19
|
+
"$schema", "name", "version", "description",
|
|
20
|
+
"author", "homepage", "repository", "license", "keywords", "extensions",
|
|
21
|
+
}
|
|
22
|
+
AUTHOR_FIELDS = {"name", "email", "url"}
|
|
23
|
+
MCP_TOP_LEVEL_FIELDS = {"$schema", "mcpServers"}
|
|
24
|
+
_PLUGIN_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9.\-]*[a-z0-9])?$")
|
|
25
|
+
_SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_plugin_name(name: str) -> list[str]:
|
|
29
|
+
"""Validate plugin.json ``name`` against §5.5 naming constraints."""
|
|
30
|
+
errs: list[str] = []
|
|
31
|
+
if not isinstance(name, str):
|
|
32
|
+
return ["plugin.json 'name' must be a string"]
|
|
33
|
+
if not (1 <= len(name) <= 64):
|
|
34
|
+
errs.append(f"plugin.json 'name' length {len(name)} not in 1..64")
|
|
35
|
+
if not _PLUGIN_NAME_RE.match(name):
|
|
36
|
+
errs.append(
|
|
37
|
+
f"plugin.json 'name' ({name!r}) must be lowercase alphanumeric "
|
|
38
|
+
"with - and . only, start and end alphanumeric, no '--' or '..'"
|
|
39
|
+
)
|
|
40
|
+
if "--" in name:
|
|
41
|
+
errs.append("plugin.json 'name' must not contain consecutive hyphens '--'")
|
|
42
|
+
if ".." in name:
|
|
43
|
+
errs.append("plugin.json 'name' must not contain consecutive dots '..'")
|
|
44
|
+
return errs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_plugin_json(plugin_dir: str) -> tuple[dict, list[str]]:
|
|
48
|
+
"""Load and return (plugin_data, errors) from plugin.json."""
|
|
49
|
+
pj = os.path.join(plugin_dir, "plugin.json")
|
|
50
|
+
errors: list[str] = []
|
|
51
|
+
if not os.path.isfile(pj):
|
|
52
|
+
errors.append("Missing root plugin.json (spec requires checking root plugin.json)")
|
|
53
|
+
return {}, errors
|
|
54
|
+
try:
|
|
55
|
+
with open(pj, encoding="utf-8") as fh:
|
|
56
|
+
data = json.load(fh)
|
|
57
|
+
except Exception as exc: # noqa: BLE001
|
|
58
|
+
errors.append(f"plugin.json is not valid JSON: {exc}")
|
|
59
|
+
return {}, errors
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
errors.append("plugin.json top level must be a JSON object")
|
|
62
|
+
return {}, errors
|
|
63
|
+
return data, errors
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_frontmatter(skill_md_path: str) -> dict:
|
|
67
|
+
"""Extract name/description from a SKILL.md frontmatter block.
|
|
68
|
+
|
|
69
|
+
Uses PyYAML when available for full YAML coverage (nested maps, lists,
|
|
70
|
+
quoting rules); falls back to a zero-dependency YAML-lite parser that
|
|
71
|
+
handles ``key: value`` and folded scalars (``description: >-`` followed
|
|
72
|
+
by indented lines).
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
with open(skill_md_path, encoding="utf-8") as fh:
|
|
76
|
+
content = fh.read()
|
|
77
|
+
except OSError:
|
|
78
|
+
return {}
|
|
79
|
+
if not content.startswith("---"):
|
|
80
|
+
return {}
|
|
81
|
+
end_m = re.search(r"^---\s*$", content[3:], re.MULTILINE)
|
|
82
|
+
if end_m is None:
|
|
83
|
+
return {}
|
|
84
|
+
block = content[3:3 + end_m.start()]
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
import yaml # type: ignore import-not-found
|
|
88
|
+
|
|
89
|
+
parsed = yaml.safe_load(block)
|
|
90
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
91
|
+
except ImportError:
|
|
92
|
+
pass
|
|
93
|
+
except Exception: # malformed YAML -> fall through to the lite parser
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
out: dict = {}
|
|
97
|
+
lines = block.splitlines()
|
|
98
|
+
i = 0
|
|
99
|
+
while i < len(lines):
|
|
100
|
+
line = lines[i]
|
|
101
|
+
m = re.match(r"^([A-Za-z0-9\-]+):\s*(.*)$", line)
|
|
102
|
+
if not m:
|
|
103
|
+
i += 1
|
|
104
|
+
continue
|
|
105
|
+
key, value = m.group(1), m.group(2).strip()
|
|
106
|
+
if value in (">", ">-", "|", "|-"):
|
|
107
|
+
folded: list[str] = []
|
|
108
|
+
i += 1
|
|
109
|
+
while i < len(lines) and (lines[i].startswith(" ") or lines[i] == ""):
|
|
110
|
+
if lines[i].strip():
|
|
111
|
+
folded.append(lines[i].strip())
|
|
112
|
+
i += 1
|
|
113
|
+
out[key] = " ".join(folded)
|
|
114
|
+
continue
|
|
115
|
+
out[key] = value
|
|
116
|
+
i += 1
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_skill_dir(skill_root: str, entry: str) -> list[str]:
|
|
121
|
+
"""Validate one skills/<entry>/ against Agent Skills spec (via §7.1)."""
|
|
122
|
+
errs: list[str] = []
|
|
123
|
+
skill_md = os.path.join(skill_root, entry, "SKILL.md")
|
|
124
|
+
if not os.path.isfile(skill_md):
|
|
125
|
+
errs.append(f"skills/{entry}/ missing required SKILL.md")
|
|
126
|
+
return errs
|
|
127
|
+
fm = _parse_frontmatter(skill_md)
|
|
128
|
+
name = fm.get("name", "")
|
|
129
|
+
if not name:
|
|
130
|
+
errs.append(f"skills/{entry}/SKILL.md missing required frontmatter 'name'")
|
|
131
|
+
elif name != entry:
|
|
132
|
+
errs.append(
|
|
133
|
+
f"skills/{entry}/SKILL.md frontmatter 'name' ({name}) must match "
|
|
134
|
+
f"directory name ({entry})"
|
|
135
|
+
)
|
|
136
|
+
elif not _SKILL_NAME_RE.match(name):
|
|
137
|
+
errs.append(
|
|
138
|
+
f"skills/{entry}/SKILL.md 'name' must be lowercase alphanumeric "
|
|
139
|
+
"with hyphens only, not starting/ending with a hyphen"
|
|
140
|
+
)
|
|
141
|
+
desc = fm.get("description", "")
|
|
142
|
+
if not desc:
|
|
143
|
+
errs.append(f"skills/{entry}/SKILL.md missing required frontmatter 'description'")
|
|
144
|
+
elif len(desc) > 1024:
|
|
145
|
+
errs.append(f"skills/{entry}/SKILL.md 'description' exceeds 1024 chars")
|
|
146
|
+
return errs
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_loopback_address(host: str) -> bool:
|
|
150
|
+
"""Check if host is loopback (localhost hostname, 127.0.0.0/8, ::1).
|
|
151
|
+
|
|
152
|
+
Uses stdlib ``ipaddress`` instead of a hand-rolled IPv4-only check.
|
|
153
|
+
"""
|
|
154
|
+
if host.lower() in ("localhost",):
|
|
155
|
+
return True
|
|
156
|
+
try:
|
|
157
|
+
return ipaddress.ip_address(host).is_loopback
|
|
158
|
+
except ValueError:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _check_mcp_servers(m: dict, errors: list[str]) -> None:
|
|
163
|
+
"""Validate the mcpServers section of mcp.json."""
|
|
164
|
+
servers = m.get("mcpServers", {})
|
|
165
|
+
if not isinstance(servers, dict):
|
|
166
|
+
errors.append("mcp.json 'mcpServers' must be an object")
|
|
167
|
+
return
|
|
168
|
+
for sname, cfg in servers.items():
|
|
169
|
+
if not isinstance(cfg, dict):
|
|
170
|
+
errors.append(f"mcp.json server '{sname}' must be an object")
|
|
171
|
+
continue
|
|
172
|
+
stype = cfg.get("type")
|
|
173
|
+
if stype not in ("stdio", "streamable-http", "sse"):
|
|
174
|
+
errors.append(
|
|
175
|
+
f"mcp.json server '{sname}' missing/unknown 'type' "
|
|
176
|
+
"(must be stdio | streamable-http | sse)"
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
allowed_fields = {"stdio": {"type", "command", "args", "env", "cwd"},
|
|
180
|
+
"streamable-http": {"type", "url", "headers"},
|
|
181
|
+
"sse": {"type", "url", "headers"}}[stype]
|
|
182
|
+
for key in cfg:
|
|
183
|
+
if key not in allowed_fields:
|
|
184
|
+
errors.append(
|
|
185
|
+
f"mcp.json server '{sname}' has unknown field '{key}' "
|
|
186
|
+
f"for type '{stype}' (closed variant: only "
|
|
187
|
+
f"{sorted(allowed_fields)})"
|
|
188
|
+
)
|
|
189
|
+
if stype == "stdio":
|
|
190
|
+
command = cfg.get("command")
|
|
191
|
+
if not command:
|
|
192
|
+
errors.append(f"mcp.json server '{sname}' (stdio) missing required 'command'")
|
|
193
|
+
elif not isinstance(command, str):
|
|
194
|
+
errors.append(f"mcp.json server '{sname}' 'command' must be a string")
|
|
195
|
+
elif not (command.startswith("./")
|
|
196
|
+
or re.fullmatch(r"[A-Za-z0-9._\-]+", command)):
|
|
197
|
+
errors.append(
|
|
198
|
+
f"mcp.json server '{sname}' 'command' must be a single "
|
|
199
|
+
"executable token or a plugin-relative './...' path"
|
|
200
|
+
)
|
|
201
|
+
args = cfg.get("args")
|
|
202
|
+
if args is not None and (
|
|
203
|
+
not isinstance(args, list)
|
|
204
|
+
or any(not isinstance(a, str) for a in args)
|
|
205
|
+
):
|
|
206
|
+
errors.append(f"mcp.json server '{sname}' 'args' must be an array of strings")
|
|
207
|
+
env = cfg.get("env")
|
|
208
|
+
if env is not None:
|
|
209
|
+
if not isinstance(env, dict) or any(
|
|
210
|
+
not isinstance(v, str) for v in env.values()
|
|
211
|
+
):
|
|
212
|
+
errors.append(
|
|
213
|
+
f"mcp.json server '{sname}' 'env' must be an object of strings"
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
for reserved in ("PLUGIN_ROOT", "PLUGIN_DATA"):
|
|
217
|
+
if reserved in env:
|
|
218
|
+
errors.append(
|
|
219
|
+
f"mcp.json server '{sname}' 'env' must not define "
|
|
220
|
+
f"reserved variable '{reserved}'"
|
|
221
|
+
)
|
|
222
|
+
cwd = cfg.get("cwd")
|
|
223
|
+
if cwd is not None and not (
|
|
224
|
+
cwd == "${PLUGIN_ROOT}"
|
|
225
|
+
or cwd.startswith("${PLUGIN_ROOT}/")
|
|
226
|
+
or cwd == "${PLUGIN_DATA}"
|
|
227
|
+
or cwd.startswith("${PLUGIN_DATA}/")
|
|
228
|
+
or cwd.startswith("./")
|
|
229
|
+
):
|
|
230
|
+
errors.append(
|
|
231
|
+
f"mcp.json server '{sname}' 'cwd' must be './relative', "
|
|
232
|
+
"${PLUGIN_ROOT}[-rooted] or ${PLUGIN_DATA}[-rooted]"
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
url = cfg.get("url")
|
|
236
|
+
if not url:
|
|
237
|
+
errors.append(f"mcp.json server '{sname}' missing required 'url'")
|
|
238
|
+
elif isinstance(url, str):
|
|
239
|
+
parsed = re.match(r"^https?://([^/?#]*)(.*)$", url)
|
|
240
|
+
if "#" in url:
|
|
241
|
+
errors.append(
|
|
242
|
+
f"mcp.json server '{sname}' 'url' must not contain a fragment"
|
|
243
|
+
)
|
|
244
|
+
if "@" in parsed.group(1):
|
|
245
|
+
errors.append(
|
|
246
|
+
f"mcp.json server '{sname}' 'url' must not contain user information"
|
|
247
|
+
)
|
|
248
|
+
host = (parsed.group(1).split("@")[-1] or "").rsplit(":", 1)[0].strip("[]")
|
|
249
|
+
if (
|
|
250
|
+
url.startswith("http://")
|
|
251
|
+
and host.lower() not in ("localhost", "::1")
|
|
252
|
+
and not _is_loopback_address(host)
|
|
253
|
+
):
|
|
254
|
+
errors.append(
|
|
255
|
+
f"mcp.json server '{sname}' non-loopback 'url' must use HTTPS"
|
|
256
|
+
)
|
|
257
|
+
headers = cfg.get("headers")
|
|
258
|
+
if headers is not None and (
|
|
259
|
+
not isinstance(headers, dict)
|
|
260
|
+
or any(not isinstance(v, str) for v in headers.values())
|
|
261
|
+
or len({k.lower() for k in headers}) != len(headers)
|
|
262
|
+
):
|
|
263
|
+
errors.append(
|
|
264
|
+
f"mcp.json server '{sname}' 'headers' must be an object of "
|
|
265
|
+
"strings without duplicate (case-insensitive) names"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def check_plugin_conformance(plugin_dir: str) -> dict:
|
|
270
|
+
"""Validate a directory against Agent Plugins 1.0.0 (§5/§6/§7).
|
|
271
|
+
|
|
272
|
+
Strict linter: closed-schema top-level fields, ``name`` constraints,
|
|
273
|
+
SKILL.md frontmatter, mcp.json fields and cwd form.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
{"ok": bool, "errors": [...], "warnings": [...]}
|
|
277
|
+
"""
|
|
278
|
+
errors: list[str] = []
|
|
279
|
+
warnings: list[str] = []
|
|
280
|
+
|
|
281
|
+
# ---- §5 manifest ------------------------------------------------------
|
|
282
|
+
data, pe = _parse_plugin_json(plugin_dir)
|
|
283
|
+
errors.extend(pe)
|
|
284
|
+
|
|
285
|
+
if data:
|
|
286
|
+
for key in data:
|
|
287
|
+
if key not in PLUGIN_TOP_LEVEL_FIELDS:
|
|
288
|
+
errors.append(
|
|
289
|
+
f"plugin.json has unknown top-level field '{key}' "
|
|
290
|
+
f"(closed schema: only {sorted(PLUGIN_TOP_LEVEL_FIELDS)})"
|
|
291
|
+
)
|
|
292
|
+
if data.get("$schema") != "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json":
|
|
293
|
+
errors.append("plugin.json $schema missing or not the 1.0.0 address")
|
|
294
|
+
if "name" not in data:
|
|
295
|
+
errors.append("plugin.json missing required field 'name'")
|
|
296
|
+
else:
|
|
297
|
+
errors.extend(_check_plugin_name(data["name"]))
|
|
298
|
+
for field, ftype in (("version", str), ("description", str),
|
|
299
|
+
("homepage", str), ("repository", str), ("license", str)):
|
|
300
|
+
if field in data and not isinstance(data[field], ftype):
|
|
301
|
+
errors.append(f"plugin.json '{field}' must be a string")
|
|
302
|
+
if "keywords" in data and (
|
|
303
|
+
not isinstance(data["keywords"], list)
|
|
304
|
+
or any(not isinstance(k, str) for k in data["keywords"])
|
|
305
|
+
):
|
|
306
|
+
errors.append("plugin.json 'keywords' must be an array of strings")
|
|
307
|
+
if "author" in data:
|
|
308
|
+
author = data["author"]
|
|
309
|
+
if not isinstance(author, dict):
|
|
310
|
+
errors.append("plugin.json 'author' must be an object")
|
|
311
|
+
else:
|
|
312
|
+
for key in author:
|
|
313
|
+
if key not in AUTHOR_FIELDS:
|
|
314
|
+
errors.append(
|
|
315
|
+
f"plugin.json 'author' has unknown field '{key}' "
|
|
316
|
+
f"(only {sorted(AUTHOR_FIELDS)} allowed)"
|
|
317
|
+
)
|
|
318
|
+
for key, value in author.items():
|
|
319
|
+
if not isinstance(value, str):
|
|
320
|
+
errors.append(f"plugin.json 'author.{key}' must be a string")
|
|
321
|
+
|
|
322
|
+
# ---- §6 skills ---------------------------------------------------------
|
|
323
|
+
skills_dir = os.path.join(plugin_dir, "skills")
|
|
324
|
+
if os.path.isdir(skills_dir):
|
|
325
|
+
found_skill = False
|
|
326
|
+
for entry in sorted(os.listdir(skills_dir)):
|
|
327
|
+
if os.path.isdir(os.path.join(skills_dir, entry)):
|
|
328
|
+
found_skill = True
|
|
329
|
+
errors.extend(_check_skill_dir(skills_dir, entry))
|
|
330
|
+
if not found_skill:
|
|
331
|
+
msg = (
|
|
332
|
+
"No skills/ directory (pure-MCP plugin is conformant, but this "
|
|
333
|
+
"plugin is designed hybrid)"
|
|
334
|
+
)
|
|
335
|
+
warnings.append(msg)
|
|
336
|
+
else:
|
|
337
|
+
msg = (
|
|
338
|
+
"No skills/ directory (pure-MCP plugin is conformant, but this "
|
|
339
|
+
"plugin is designed hybrid)"
|
|
340
|
+
)
|
|
341
|
+
warnings.append(msg)
|
|
342
|
+
|
|
343
|
+
# ---- §7 mcp.json -------------------------------------------------------
|
|
344
|
+
mcp = os.path.join(plugin_dir, "mcp.json")
|
|
345
|
+
if os.path.isfile(mcp):
|
|
346
|
+
try:
|
|
347
|
+
with open(mcp, encoding="utf-8") as fh:
|
|
348
|
+
m = json.load(fh)
|
|
349
|
+
except Exception as exc: # noqa: BLE001
|
|
350
|
+
errors.append(f"mcp.json is not valid JSON: {exc}")
|
|
351
|
+
m = {}
|
|
352
|
+
if not isinstance(m, dict):
|
|
353
|
+
errors.append("mcp.json top level must be a JSON object")
|
|
354
|
+
m = {}
|
|
355
|
+
for key in m:
|
|
356
|
+
if key not in MCP_TOP_LEVEL_FIELDS:
|
|
357
|
+
errors.append(
|
|
358
|
+
f"mcp.json has unknown top-level field '{key}' "
|
|
359
|
+
f"(only {sorted(MCP_TOP_LEVEL_FIELDS)} allowed)"
|
|
360
|
+
)
|
|
361
|
+
if m.get("$schema") != "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json":
|
|
362
|
+
errors.append("mcp.json $schema missing or not the 1.0.0 address")
|
|
363
|
+
_check_mcp_servers(m, errors)
|
|
364
|
+
else:
|
|
365
|
+
warnings.append("No mcp.json (pure-skill plugin is conformant)")
|
|
366
|
+
|
|
367
|
+
return {"ok": len(errors) == 0, "errors": errors, "warnings": warnings}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""AgentSeed deterministic execution channel.
|
|
2
|
+
|
|
3
|
+
Runs a command (no shell) in a subprocess with timeout and captured output.
|
|
4
|
+
Turns "tests pass" into an observed fact.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import deque
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _decode(raw) -> str:
|
|
17
|
+
return raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else (raw or "")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read_bounded(stream, cap: int, sink: list[str]) -> None:
|
|
21
|
+
"""Drain a pipe stream into ``sink`` keeping only the last ``cap`` chars.
|
|
22
|
+
|
|
23
|
+
Truncation happens WHILE streaming (drop-from-front ring buffer), so a
|
|
24
|
+
child that emits gigabytes of output costs O(cap) memory instead of
|
|
25
|
+
O(output). ``sink`` receives the final tail once EOF is reached.
|
|
26
|
+
"""
|
|
27
|
+
chunks: deque[str] = deque()
|
|
28
|
+
size = 0
|
|
29
|
+
while True:
|
|
30
|
+
data = stream.read(65536)
|
|
31
|
+
if not data:
|
|
32
|
+
break
|
|
33
|
+
text = data.decode("utf-8", errors="replace")
|
|
34
|
+
chunks.append(text)
|
|
35
|
+
size += len(text)
|
|
36
|
+
while chunks and size - len(chunks[0]) >= cap:
|
|
37
|
+
size -= len(chunks[0])
|
|
38
|
+
chunks.popleft()
|
|
39
|
+
sink.append("".join(chunks)[-cap:])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_executable(command_name: str, base_dir: str | None = None) -> str | None:
|
|
43
|
+
"""Resolve a command token to the absolute path the OS would execute.
|
|
44
|
+
|
|
45
|
+
Path-qualified tokens resolve against ``base_dir`` (the ``cwd`` the
|
|
46
|
+
command will actually run in) so the policy-checked path IS the executed
|
|
47
|
+
path — resolving a relative command against the server's own working
|
|
48
|
+
directory would let a caller-controlled ``cwd`` swap the binary after
|
|
49
|
+
the allowlist check. Bare names (``python``, ``pytest``) go through
|
|
50
|
+
``PATH`` lookup via ``shutil.which`` — deliberately NOT the raw name:
|
|
51
|
+
on Windows, spawning a relative name lets CreateProcess search the
|
|
52
|
+
process's working directory first, so a malicious ``cwd`` could shadow
|
|
53
|
+
an allowlisted basename with a planted executable. Returns None when a
|
|
54
|
+
bare name cannot be resolved anywhere.
|
|
55
|
+
"""
|
|
56
|
+
if not isinstance(command_name, str) or not command_name:
|
|
57
|
+
return None
|
|
58
|
+
has_sep = os.path.sep in command_name or (os.altsep and os.altsep in command_name)
|
|
59
|
+
if not has_sep:
|
|
60
|
+
found = shutil.which(command_name)
|
|
61
|
+
return os.path.abspath(found) if found else None
|
|
62
|
+
if os.path.isabs(command_name):
|
|
63
|
+
return command_name
|
|
64
|
+
if base_dir:
|
|
65
|
+
return os.path.abspath(os.path.join(base_dir, command_name))
|
|
66
|
+
return os.path.abspath(command_name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _norm_path(path: str) -> str:
|
|
70
|
+
return os.path.normcase(os.path.normpath(os.path.abspath(path)))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _path_matches(resolved: str, entry: str) -> bool:
|
|
74
|
+
"""Directory-prefix match with a mandatory separator boundary."""
|
|
75
|
+
entry_norm = _norm_path(entry)
|
|
76
|
+
return resolved == entry_norm or resolved.startswith(entry_norm + os.sep)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _name_matches(resolved: str, entry: str) -> bool:
|
|
80
|
+
"""Bare-name entry: exact basename equality (with .exe tolerance)."""
|
|
81
|
+
res_base = os.path.basename(resolved)
|
|
82
|
+
entry_base = os.path.basename(os.path.normcase(entry.strip()))
|
|
83
|
+
return res_base == entry_base or res_base == f"{entry_base}.exe"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _matches_allowlist(resolved: str, prefixes: list[str]) -> bool:
|
|
87
|
+
"""Pure check (never executes): basename or bounded path-prefix match."""
|
|
88
|
+
res_norm = _norm_path(resolved)
|
|
89
|
+
for entry in prefixes:
|
|
90
|
+
if not isinstance(entry, str) or not entry.strip():
|
|
91
|
+
continue
|
|
92
|
+
stripped = entry.strip()
|
|
93
|
+
if os.path.sep in stripped or (os.altsep and os.altsep in stripped):
|
|
94
|
+
if _path_matches(res_norm, stripped):
|
|
95
|
+
return True
|
|
96
|
+
elif _name_matches(res_norm, stripped):
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _blocked(command_head, prefixes: list[str]) -> dict:
|
|
102
|
+
return {
|
|
103
|
+
"exit_code": -10,
|
|
104
|
+
"stdout": "",
|
|
105
|
+
"stderr": (f"blocked: '{command_head}' is not in sandbox_allowed_prefixes {prefixes}"),
|
|
106
|
+
"timed_out": False,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def kill_tree(proc) -> None:
|
|
111
|
+
"""Best-effort process-TREE termination, children included.
|
|
112
|
+
|
|
113
|
+
Windows: ``taskkill /F /T`` walks the PID tree. POSIX: the child was
|
|
114
|
+
spawned in its own session (``start_new_session=True``), so SIGKILL on
|
|
115
|
+
the process group reaps the whole tree. Falls back to killing the leader
|
|
116
|
+
alone when either mechanism is unavailable."""
|
|
117
|
+
if proc.poll() is not None:
|
|
118
|
+
return
|
|
119
|
+
if os.name == "nt":
|
|
120
|
+
try:
|
|
121
|
+
subprocess.run(
|
|
122
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
timeout=15,
|
|
125
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
126
|
+
)
|
|
127
|
+
return
|
|
128
|
+
except Exception: # noqa: BLE001 - fall through to leader kill
|
|
129
|
+
pass
|
|
130
|
+
else:
|
|
131
|
+
try:
|
|
132
|
+
import signal
|
|
133
|
+
|
|
134
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
135
|
+
return
|
|
136
|
+
except Exception: # noqa: BLE001 - fall through to leader kill
|
|
137
|
+
pass
|
|
138
|
+
try:
|
|
139
|
+
proc.kill()
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Environment scrubbing (opt-in via config ``sandbox_env: "scrub"``): drop
|
|
145
|
+
# variables whose NAMES look credential-bearing. Best-effort denylist — a
|
|
146
|
+
# courtesy leak-reduction measure, NOT a security boundary.
|
|
147
|
+
_SECRET_MARKERS = (
|
|
148
|
+
"TOKEN",
|
|
149
|
+
"SECRET",
|
|
150
|
+
"PASSWD",
|
|
151
|
+
"PASSWORD",
|
|
152
|
+
"CREDENTIAL",
|
|
153
|
+
"API_KEY",
|
|
154
|
+
"ACCESS_KEY",
|
|
155
|
+
"PRIVATE_KEY",
|
|
156
|
+
"AUTH",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_env(env_mode: str | None):
|
|
161
|
+
if env_mode == "scrub":
|
|
162
|
+
return {
|
|
163
|
+
k: v
|
|
164
|
+
for k, v in os.environ.items()
|
|
165
|
+
if not any(marker in k.upper() for marker in _SECRET_MARKERS)
|
|
166
|
+
}
|
|
167
|
+
return None # inherit
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _run_command(command: list[str], timeout: int, cwd: str | None, on_proc=None, env=None) -> dict:
|
|
171
|
+
"""Spawn + wait + capture. Shared by sync and async callers.
|
|
172
|
+
|
|
173
|
+
``on_proc`` (optional callable taking the Popen) lets callers register the
|
|
174
|
+
live process for cancellation. stdin is DEVNULL: sandbox commands never read
|
|
175
|
+
interactive input, and inheriting a piped stdin can deadlock children at
|
|
176
|
+
startup on Windows (observed with MCP stdio servers). POSIX children get
|
|
177
|
+
their own session so ``kill_tree`` can signal the whole group.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
proc = subprocess.Popen(
|
|
181
|
+
command,
|
|
182
|
+
cwd=cwd,
|
|
183
|
+
env=env,
|
|
184
|
+
stdin=subprocess.DEVNULL,
|
|
185
|
+
stdout=subprocess.PIPE,
|
|
186
|
+
stderr=subprocess.PIPE,
|
|
187
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
188
|
+
start_new_session=(os.name != "nt"),
|
|
189
|
+
close_fds=True,
|
|
190
|
+
)
|
|
191
|
+
except FileNotFoundError as exc:
|
|
192
|
+
return {
|
|
193
|
+
"exit_code": -2,
|
|
194
|
+
"stdout": "",
|
|
195
|
+
"stderr": f"command not found: {exc}",
|
|
196
|
+
"timed_out": False,
|
|
197
|
+
}
|
|
198
|
+
except Exception as exc: # noqa: BLE001
|
|
199
|
+
return {"exit_code": -9, "stdout": "", "stderr": f"run failed: {exc}", "timed_out": False}
|
|
200
|
+
if callable(on_proc):
|
|
201
|
+
on_proc(proc)
|
|
202
|
+
# Two reader threads keep the response bounded in MEMORY, not just at the
|
|
203
|
+
# end: each pipe is drained incrementally into a tail ring buffer, so a
|
|
204
|
+
# child writing unlimited output cannot balloon the process. Threads (not
|
|
205
|
+
# selectors) because Windows select() does not work on pipes.
|
|
206
|
+
stdout_buf: list[str] = []
|
|
207
|
+
stderr_buf: list[str] = []
|
|
208
|
+
reader_out = threading.Thread(
|
|
209
|
+
target=_read_bounded, args=(proc.stdout, 8000, stdout_buf), daemon=True
|
|
210
|
+
)
|
|
211
|
+
reader_err = threading.Thread(
|
|
212
|
+
target=_read_bounded, args=(proc.stderr, 4000, stderr_buf), daemon=True
|
|
213
|
+
)
|
|
214
|
+
reader_out.start()
|
|
215
|
+
reader_err.start()
|
|
216
|
+
timed_out = False
|
|
217
|
+
try:
|
|
218
|
+
proc.wait(timeout=max(1, min(int(timeout), 120)))
|
|
219
|
+
except subprocess.TimeoutExpired:
|
|
220
|
+
timed_out = True
|
|
221
|
+
kill_tree(proc)
|
|
222
|
+
reader_out.join(timeout=5)
|
|
223
|
+
reader_err.join(timeout=5)
|
|
224
|
+
return {
|
|
225
|
+
"exit_code": -1 if timed_out else proc.returncode,
|
|
226
|
+
"stdout": stdout_buf[0] if stdout_buf else "",
|
|
227
|
+
"stderr": stderr_buf[0] if stderr_buf else "",
|
|
228
|
+
"timed_out": timed_out,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def sandbox_run(
|
|
233
|
+
command: list[str],
|
|
234
|
+
timeout: int = 30,
|
|
235
|
+
cwd: str | None = None,
|
|
236
|
+
allowed_prefixes: list[str] | None = None,
|
|
237
|
+
on_proc=None,
|
|
238
|
+
env_mode: str | None = None,
|
|
239
|
+
) -> dict:
|
|
240
|
+
"""Run a command as a subprocess (no shell) with a timeout.
|
|
241
|
+
|
|
242
|
+
Deterministic verification channel: turns "the test passes" into an
|
|
243
|
+
observed fact (exit code + output). No shell means no injection via args;
|
|
244
|
+
output is truncated to a bounded tail WHILE streaming, so even a child
|
|
245
|
+
that floods output cannot blow the server's memory.
|
|
246
|
+
|
|
247
|
+
``allowed_prefixes`` (config: ``sandbox_allowed_prefixes``): when a
|
|
248
|
+
non-empty list, the first argument is resolved to the absolute path the
|
|
249
|
+
OS would actually run (PATH lookup for bare names — never a relative
|
|
250
|
+
spawn, which Windows resolves against the attacker-controllable ``cwd``),
|
|
251
|
+
then checked against the allowlist: entries without a path separator
|
|
252
|
+
match the resolved basename exactly (``python`` also accepts
|
|
253
|
+
``python.exe``); entries WITH a separator must equal the resolved path or
|
|
254
|
+
be a directory-prefix of it with a separator boundary (so ``C:\\tools\\s``
|
|
255
|
+
cannot match ``C:\\tools\\safe\\x.exe``). Anything unresolved or
|
|
256
|
+
unmatched is refused with exit code -10 WITHOUT running, and matched
|
|
257
|
+
commands execute under their RESOLVED absolute path. None/empty =
|
|
258
|
+
unrestricted (command is passed through verbatim).
|
|
259
|
+
|
|
260
|
+
``env_mode`` (config: ``sandbox_env``): "inherit" (default) passes the
|
|
261
|
+
server environment through; "scrub" drops credential-looking variable
|
|
262
|
+
names before spawn (best-effort denylist, see ``build_env``).
|
|
263
|
+
|
|
264
|
+
``on_proc`` (advanced): invoked with the live Popen so async callers can
|
|
265
|
+
register it for cancellation. Timeouts and cancellations kill the whole
|
|
266
|
+
process tree (POSIX process group / Windows taskkill /T), not just the
|
|
267
|
+
direct child.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
{"exit_code": int, "stdout": str, "stderr": str, "timed_out": bool}
|
|
271
|
+
"""
|
|
272
|
+
if not isinstance(command, list) or not command:
|
|
273
|
+
return {
|
|
274
|
+
"exit_code": -3,
|
|
275
|
+
"stdout": "",
|
|
276
|
+
"stderr": "command must be a non-empty list",
|
|
277
|
+
"timed_out": False,
|
|
278
|
+
}
|
|
279
|
+
if allowed_prefixes:
|
|
280
|
+
head = command[0]
|
|
281
|
+
resolved = resolve_executable(head, cwd)
|
|
282
|
+
if resolved is None or not _matches_allowlist(resolved, allowed_prefixes):
|
|
283
|
+
return _blocked(head, list(allowed_prefixes))
|
|
284
|
+
argv = [resolved, *command[1:]]
|
|
285
|
+
else:
|
|
286
|
+
argv = list(command)
|
|
287
|
+
return _run_command(argv, timeout, cwd, on_proc=on_proc, env=build_env(env_mode))
|