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,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))