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,17 @@
|
|
|
1
|
+
"""Server/plugin version — single source of truth (plugin.json ``version``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def plugin_version() -> str:
|
|
10
|
+
"""Return the ``version`` field of the root plugin.json, or ``"0.0.0"``."""
|
|
11
|
+
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
12
|
+
try:
|
|
13
|
+
with open(os.path.join(root, "plugin.json"), encoding="utf-8") as fh:
|
|
14
|
+
version = json.load(fh).get("version")
|
|
15
|
+
return version if isinstance(version, str) else "0.0.0"
|
|
16
|
+
except (OSError, ValueError):
|
|
17
|
+
return "0.0.0"
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""AgentSeed CLI — zero-dependency command-line entry point.
|
|
2
|
+
|
|
3
|
+
Enables CI gating for human PRs as well as agent sessions:
|
|
4
|
+
|
|
5
|
+
python guard_cli.py verify [source_or_path] [--language LANG]
|
|
6
|
+
python guard_cli.py scan [source_or_path] [--strict]
|
|
7
|
+
python guard_cli.py check [plugin_dir] [--ci]
|
|
8
|
+
python guard_cli.py sandbox -- <command> [args...]
|
|
9
|
+
|
|
10
|
+
Exit codes: 0 = pass, 1 = findings/errors, 2 = usage error.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
import guard_engine as engine # noqa: E402
|
|
21
|
+
from engine.symbols import SUPPORTED_LANGUAGES # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_source(path_or_source: str) -> str:
|
|
25
|
+
if os.path.isdir(path_or_source):
|
|
26
|
+
raise SystemExit(
|
|
27
|
+
f"agentseed: '{path_or_source}' is a directory, pass a file path or inline source text"
|
|
28
|
+
)
|
|
29
|
+
if os.path.isfile(path_or_source):
|
|
30
|
+
with open(path_or_source, encoding="utf-8", errors="replace") as fh:
|
|
31
|
+
return fh.read()
|
|
32
|
+
return path_or_source
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _warn_unknown_config(config: dict) -> None:
|
|
36
|
+
unknown = engine.unknown_config_keys(config)
|
|
37
|
+
for key in unknown:
|
|
38
|
+
print(
|
|
39
|
+
f"[agentseed] WARNING: unknown config key '{key}' ignored "
|
|
40
|
+
f"(known keys: {sorted(engine.KNOWN_CONFIG_KEYS)})",
|
|
41
|
+
file=sys.stderr,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
46
|
+
config = engine.load_config(args.config)
|
|
47
|
+
_warn_unknown_config(config)
|
|
48
|
+
suppress = args.suppress or engine.config_str_list(config, "suppress_symbols")
|
|
49
|
+
source = _read_source(args.source)
|
|
50
|
+
result = engine.detect_undefined_symbols(source, args.language, suppress=suppress)
|
|
51
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
52
|
+
if result.get("note", "").startswith("Cannot parse"):
|
|
53
|
+
# syntax error is reported, not a finding — unless gating strictly
|
|
54
|
+
return 1 if getattr(args, "strict", False) else 0
|
|
55
|
+
return 1 if result["suspects"] else 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_contract(args: argparse.Namespace) -> int:
|
|
59
|
+
"""Contract gate: exit 1 unless every `requires` symbol is defined and
|
|
60
|
+
no `prohibits` token appears."""
|
|
61
|
+
source = _read_source(args.source)
|
|
62
|
+
result = engine.check_contract(source, args.contract, args.language)
|
|
63
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
64
|
+
return 0 if result["contract_ok"] else 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_imports(args: argparse.Namespace) -> int:
|
|
68
|
+
"""Slopsquatting gate: exit 1 when a top-level import is neither stdlib
|
|
69
|
+
nor in the known-package set (defaults + config known_packages + --known)."""
|
|
70
|
+
config = engine.load_config(getattr(args, "config", None))
|
|
71
|
+
known = args.known or engine.config_str_list(config, "known_packages")
|
|
72
|
+
source = _read_source(args.source)
|
|
73
|
+
result = engine.check_imports(source, known_packages=known)
|
|
74
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
75
|
+
return 0 if result["imports_ok"] else 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _iter_source_files(root: str):
|
|
79
|
+
"""Deterministic walk of text sources worth scanning (skips VCS/cache)."""
|
|
80
|
+
skip_dirs = {".git", ".agentseed", "__pycache__", "node_modules", ".github", ".workbuddy"}
|
|
81
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
82
|
+
dirnames[:] = sorted(d for d in dirnames if d not in skip_dirs)
|
|
83
|
+
for fn in sorted(filenames):
|
|
84
|
+
if fn.endswith((".py", ".ts", ".tsx", ".js", ".jsx", ".md", ".json", ".yaml", ".yml")):
|
|
85
|
+
yield os.path.join(dirpath, fn)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _fingerprint_counts(source: str, allowlist, severities, extra_tokens) -> dict:
|
|
89
|
+
result = engine.scan_hallucination_words(
|
|
90
|
+
source, allowlist, severities, extra_tokens=extra_tokens
|
|
91
|
+
)
|
|
92
|
+
counts: dict[str, int] = {}
|
|
93
|
+
for h in result["hits"]:
|
|
94
|
+
key = f"{h['group']}|{h['word']}"
|
|
95
|
+
counts[key] = counts.get(key, 0) + 1
|
|
96
|
+
return counts
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_scan_baseline(args: argparse.Namespace) -> int:
|
|
100
|
+
"""Baseline mode: fail only on NEW hallucination signals vs a frozen
|
|
101
|
+
fingerprint. Deliberately line-number-free so ordinary edits don't
|
|
102
|
+
churn the baseline; only genuinely new occurrences block."""
|
|
103
|
+
target = os.path.abspath(args.source)
|
|
104
|
+
base_abs = os.path.abspath(args.baseline)
|
|
105
|
+
if os.path.isdir(target):
|
|
106
|
+
files = list(_iter_source_files(target))
|
|
107
|
+
|
|
108
|
+
def rel(p: str) -> str:
|
|
109
|
+
return os.path.relpath(p, target).replace(os.sep, "/")
|
|
110
|
+
else:
|
|
111
|
+
files = [target]
|
|
112
|
+
|
|
113
|
+
def rel(p: str) -> str:
|
|
114
|
+
return os.path.basename(p)
|
|
115
|
+
|
|
116
|
+
# never fingerprint the baseline file itself: its own content would
|
|
117
|
+
# re-enter every comparison as "new" signals (self-reference recursion)
|
|
118
|
+
files = [p for p in files if os.path.abspath(p) != base_abs]
|
|
119
|
+
|
|
120
|
+
config = engine.load_config(args.config)
|
|
121
|
+
_warn_unknown_config(config)
|
|
122
|
+
allowlist = (
|
|
123
|
+
[]
|
|
124
|
+
if args.strict
|
|
125
|
+
else (
|
|
126
|
+
args.allowlist
|
|
127
|
+
or engine.config_str_list(config, "allowlist")
|
|
128
|
+
or engine.DEFAULT_ALLOWLIST
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
severities = (
|
|
132
|
+
{"stub_code": "error"}
|
|
133
|
+
if (args.strict and not args.stub_ok)
|
|
134
|
+
else engine.config_severities(config)
|
|
135
|
+
)
|
|
136
|
+
extra = engine.config_extra_tokens(config)
|
|
137
|
+
|
|
138
|
+
current: dict[str, dict] = {}
|
|
139
|
+
for path in files:
|
|
140
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
141
|
+
counts = _fingerprint_counts(fh.read(), allowlist, severities, extra)
|
|
142
|
+
if counts:
|
|
143
|
+
current[rel(path)] = counts
|
|
144
|
+
|
|
145
|
+
exists = os.path.isfile(args.baseline)
|
|
146
|
+
if args.update_baseline or not exists:
|
|
147
|
+
payload = {"version": 1, "files": current}
|
|
148
|
+
with open(args.baseline, "w", encoding="utf-8") as fh:
|
|
149
|
+
json.dump(payload, fh, ensure_ascii=False, indent=1, sort_keys=True)
|
|
150
|
+
fh.write("\n")
|
|
151
|
+
n = sum(sum(c.values()) for c in current.values())
|
|
152
|
+
action = "updated" if exists else "created"
|
|
153
|
+
print(f"baseline {action}: {args.baseline} ({len(current)} files, {n} hits frozen)")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
with open(args.baseline, encoding="utf-8") as fh:
|
|
157
|
+
old = json.load(fh).get("files", {})
|
|
158
|
+
grew = []
|
|
159
|
+
for path, counts in sorted(current.items()):
|
|
160
|
+
base = old.get(path, {})
|
|
161
|
+
for key, cnt in counts.items():
|
|
162
|
+
if cnt > base.get(key, 0):
|
|
163
|
+
grew.append((path, key, cnt - base.get(key, 0)))
|
|
164
|
+
if grew:
|
|
165
|
+
print(f"NEW hallucination signals vs baseline ({args.baseline}):")
|
|
166
|
+
for path, key, delta in grew:
|
|
167
|
+
print(f" +{delta} {key} in {path}")
|
|
168
|
+
return 1
|
|
169
|
+
print("baseline check: no NEW hallucination signals")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
174
|
+
if getattr(args, "baseline", None):
|
|
175
|
+
return cmd_scan_baseline(args)
|
|
176
|
+
config = engine.load_config(args.config)
|
|
177
|
+
_warn_unknown_config(config)
|
|
178
|
+
allowlist = (
|
|
179
|
+
[]
|
|
180
|
+
if args.strict
|
|
181
|
+
else (
|
|
182
|
+
args.allowlist
|
|
183
|
+
or engine.config_str_list(config, "allowlist")
|
|
184
|
+
or engine.DEFAULT_ALLOWLIST
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
severities = (
|
|
188
|
+
{"stub_code": "error"}
|
|
189
|
+
if (args.strict and not args.stub_ok)
|
|
190
|
+
else engine.config_severities(config)
|
|
191
|
+
)
|
|
192
|
+
source = _read_source(args.source)
|
|
193
|
+
result = engine.scan_hallucination_words(
|
|
194
|
+
source,
|
|
195
|
+
allowlist,
|
|
196
|
+
severities,
|
|
197
|
+
extra_tokens=engine.config_extra_tokens(config),
|
|
198
|
+
)
|
|
199
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
200
|
+
return 1 if result["blocking"] else 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
204
|
+
path = os.path.abspath(args.plugin_dir or ".")
|
|
205
|
+
if not os.path.isdir(path):
|
|
206
|
+
print(
|
|
207
|
+
json.dumps(
|
|
208
|
+
{"ok": False, "errors": [f"not a directory: {path}"], "warnings": []},
|
|
209
|
+
ensure_ascii=False,
|
|
210
|
+
indent=2,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
return 1
|
|
214
|
+
result = engine.check_plugin_conformance(path)
|
|
215
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
216
|
+
return 0 if result["ok"] else 1
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_sandbox(args: argparse.Namespace) -> int:
|
|
220
|
+
config = engine.load_config()
|
|
221
|
+
_warn_unknown_config(config)
|
|
222
|
+
timeout = args.timeout if args.timeout is not None else engine.parse_timeout(config)
|
|
223
|
+
env_mode = getattr(args, "env", None) or engine.sandbox_env_mode(config)
|
|
224
|
+
result = engine.sandbox_run(
|
|
225
|
+
args.command,
|
|
226
|
+
timeout,
|
|
227
|
+
args.cwd,
|
|
228
|
+
allowed_prefixes=engine.config_str_list(config, "sandbox_allowed_prefixes"),
|
|
229
|
+
env_mode=env_mode,
|
|
230
|
+
)
|
|
231
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
232
|
+
if result["timed_out"]:
|
|
233
|
+
return 1
|
|
234
|
+
if result["exit_code"] < 0:
|
|
235
|
+
return 1 # -2 not found / -9 failed / -10 policy-blocked: never a "pass"
|
|
236
|
+
return result["exit_code"] # propagate the child's real exit code
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cmd_record(args: argparse.Namespace) -> int:
|
|
240
|
+
checks = []
|
|
241
|
+
for raw in args.check or []:
|
|
242
|
+
tool, _, status = raw.partition("=")
|
|
243
|
+
checks.append({"tool": tool or "manual", "status": status or "pass"})
|
|
244
|
+
result = engine.record_verification(
|
|
245
|
+
args.task,
|
|
246
|
+
checks,
|
|
247
|
+
summary="; ".join(args.note) if args.note else None,
|
|
248
|
+
data_dir=args.data_dir,
|
|
249
|
+
)
|
|
250
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
251
|
+
return 0 if result.get("ok") else 1
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_gate(args: argparse.Namespace) -> int:
|
|
255
|
+
"""Composite CI gate — the hard layer behind the soft skill:
|
|
256
|
+
1) plugin conformance (spec linter)
|
|
257
|
+
2) verify_code over every Python file (any suspect or unparseable file fails)
|
|
258
|
+
3) scan with baseline comparison (only NEW signals fail)
|
|
259
|
+
Single exit code: 0 = all gates pass, 1 = any failure."""
|
|
260
|
+
import time
|
|
261
|
+
|
|
262
|
+
root = os.path.abspath(args.root)
|
|
263
|
+
started = time.perf_counter()
|
|
264
|
+
summary: dict = {"root": root, "checks": {}}
|
|
265
|
+
failed = False
|
|
266
|
+
|
|
267
|
+
# -- 1. conformance ----------------------------------------------------
|
|
268
|
+
conf = engine.check_plugin_conformance(root)
|
|
269
|
+
ok = bool(conf.get("ok"))
|
|
270
|
+
summary["checks"]["conformance"] = {
|
|
271
|
+
"status": "pass" if ok else "fail",
|
|
272
|
+
"errors": conf.get("errors", []),
|
|
273
|
+
}
|
|
274
|
+
failed |= not ok
|
|
275
|
+
|
|
276
|
+
# -- 2. symbols over all Python sources --------------------------------
|
|
277
|
+
py_files = [p for p in _iter_source_files(root) if p.endswith(".py")]
|
|
278
|
+
suspects_total: dict[str, list] = {}
|
|
279
|
+
unparseable: list[str] = []
|
|
280
|
+
for path in py_files:
|
|
281
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
282
|
+
res = engine.detect_undefined_symbols(fh.read())
|
|
283
|
+
if res.get("note", "").startswith("Cannot parse"):
|
|
284
|
+
unparseable.append(os.path.relpath(path, root))
|
|
285
|
+
if res["suspects"]:
|
|
286
|
+
suspects_total[os.path.relpath(path, root)] = res["suspects"]
|
|
287
|
+
symbols_ok = not suspects_total and not unparseable
|
|
288
|
+
summary["checks"]["symbols"] = {
|
|
289
|
+
"status": "pass" if symbols_ok else "fail",
|
|
290
|
+
"files_checked": len(py_files),
|
|
291
|
+
"suspects": suspects_total,
|
|
292
|
+
"unparseable": unparseable,
|
|
293
|
+
}
|
|
294
|
+
failed |= not symbols_ok
|
|
295
|
+
|
|
296
|
+
# -- 3. hallucination scan vs baseline ---------------------------------
|
|
297
|
+
baseline = args.baseline
|
|
298
|
+
if baseline is None and not args.no_baseline:
|
|
299
|
+
candidate = os.path.join(root, "baseline-scan.json")
|
|
300
|
+
baseline = candidate if os.path.isfile(candidate) else None
|
|
301
|
+
if args.no_baseline:
|
|
302
|
+
scan_status = "skipped"
|
|
303
|
+
elif baseline is None:
|
|
304
|
+
scan_status = "fail"
|
|
305
|
+
summary["checks"]["scan"] = {
|
|
306
|
+
"status": scan_status,
|
|
307
|
+
"error": "no baseline provided (--baseline PATH or <root>/baseline-scan.json)",
|
|
308
|
+
}
|
|
309
|
+
failed = True
|
|
310
|
+
else:
|
|
311
|
+
ns = argparse.Namespace(
|
|
312
|
+
source=root,
|
|
313
|
+
config=args.config,
|
|
314
|
+
strict=False,
|
|
315
|
+
stub_ok=False,
|
|
316
|
+
allowlist=None,
|
|
317
|
+
baseline=baseline,
|
|
318
|
+
update_baseline=False,
|
|
319
|
+
)
|
|
320
|
+
rc = cmd_scan_baseline(ns)
|
|
321
|
+
scan_status = "pass" if rc == 0 else "fail"
|
|
322
|
+
summary["checks"]["scan"] = {"status": scan_status, "baseline": os.path.abspath(baseline)}
|
|
323
|
+
failed |= rc != 0
|
|
324
|
+
|
|
325
|
+
summary["verdict"] = "fail" if failed else "pass"
|
|
326
|
+
summary["elapsed_s"] = round(time.perf_counter() - started, 2)
|
|
327
|
+
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
328
|
+
return 1 if failed else 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def main(argv: list[str] | None = None) -> int:
|
|
332
|
+
parser = argparse.ArgumentParser(prog="agentseed", description=__doc__)
|
|
333
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
334
|
+
|
|
335
|
+
p_verify = sub.add_parser("verify", help="flag possibly-hallucinated symbols")
|
|
336
|
+
p_verify.add_argument("source", help="source code or a file path")
|
|
337
|
+
p_verify.add_argument(
|
|
338
|
+
"--language", default="python", choices=list(SUPPORTED_LANGUAGES)
|
|
339
|
+
)
|
|
340
|
+
p_verify.add_argument(
|
|
341
|
+
"--strict", action="store_true", help="exit 1 when the source cannot be parsed at all"
|
|
342
|
+
)
|
|
343
|
+
p_verify.add_argument(
|
|
344
|
+
"--suppress",
|
|
345
|
+
action="append",
|
|
346
|
+
metavar="NAME",
|
|
347
|
+
help="symbol name never to flag (repeatable; default: config suppress_symbols)",
|
|
348
|
+
)
|
|
349
|
+
p_verify.add_argument("--config", help="explicit config file path")
|
|
350
|
+
p_verify.set_defaults(func=cmd_verify)
|
|
351
|
+
|
|
352
|
+
p_contract = sub.add_parser(
|
|
353
|
+
"contract", help="verify source against a declared contract (requires/prohibits)"
|
|
354
|
+
)
|
|
355
|
+
p_contract.add_argument("source", help="source code or a file path")
|
|
356
|
+
p_contract.add_argument(
|
|
357
|
+
"--contract",
|
|
358
|
+
required=True,
|
|
359
|
+
help='JSON: {"requires": [...], "prohibits": [...]}',
|
|
360
|
+
)
|
|
361
|
+
p_contract.add_argument(
|
|
362
|
+
"--language", default="python", choices=list(SUPPORTED_LANGUAGES)
|
|
363
|
+
)
|
|
364
|
+
p_contract.set_defaults(func=cmd_contract)
|
|
365
|
+
|
|
366
|
+
p_imports = sub.add_parser(
|
|
367
|
+
"imports", help="flag imports not in stdlib / known packages (slopsquatting)"
|
|
368
|
+
)
|
|
369
|
+
p_imports.add_argument("source", help="source code or a file path")
|
|
370
|
+
p_imports.add_argument(
|
|
371
|
+
"--known",
|
|
372
|
+
action="append",
|
|
373
|
+
metavar="PKG",
|
|
374
|
+
help="extra known package (repeatable; default: config known_packages)",
|
|
375
|
+
)
|
|
376
|
+
p_imports.add_argument("--config", help="explicit config file path")
|
|
377
|
+
p_imports.set_defaults(func=cmd_imports)
|
|
378
|
+
|
|
379
|
+
p_scan = sub.add_parser("scan", help="scan for hallucination signals")
|
|
380
|
+
p_scan.add_argument("source", help="source text or a file path")
|
|
381
|
+
p_scan.add_argument("--allowlist", action="append", help="exclusion prefix (repeatable)")
|
|
382
|
+
p_scan.add_argument(
|
|
383
|
+
"--strict", action="store_true", help="disable default exclusions; stub hits become errors"
|
|
384
|
+
)
|
|
385
|
+
p_scan.add_argument(
|
|
386
|
+
"--stub-ok", action="store_true", help="with --strict: keep stub_code at warning severity"
|
|
387
|
+
)
|
|
388
|
+
p_scan.add_argument("--config", help="explicit config file path")
|
|
389
|
+
p_scan.add_argument(
|
|
390
|
+
"--baseline",
|
|
391
|
+
metavar="FILE",
|
|
392
|
+
help="compare against a frozen hit fingerprint; exit 1 "
|
|
393
|
+
"only on NEW signals (source may be a directory)",
|
|
394
|
+
)
|
|
395
|
+
p_scan.add_argument(
|
|
396
|
+
"--update-baseline",
|
|
397
|
+
action="store_true",
|
|
398
|
+
help="(with --baseline) write the current fingerprint and exit 0",
|
|
399
|
+
)
|
|
400
|
+
p_scan.set_defaults(func=cmd_scan)
|
|
401
|
+
|
|
402
|
+
p_check = sub.add_parser("check", help="validate a plugin directory")
|
|
403
|
+
p_check.add_argument("plugin_dir", nargs="?", default=".")
|
|
404
|
+
p_check.add_argument(
|
|
405
|
+
"--ci", action="store_true", help="(default in CI use) exit 1 on any conformance error"
|
|
406
|
+
)
|
|
407
|
+
p_check.set_defaults(func=cmd_check)
|
|
408
|
+
|
|
409
|
+
p_gate = sub.add_parser("gate", help="composite CI gate: conformance + symbols + baseline scan")
|
|
410
|
+
p_gate.add_argument("--root", default=".", help="plugin/repo root to gate")
|
|
411
|
+
p_gate.add_argument(
|
|
412
|
+
"--baseline", help="scan baseline JSON (default: <root>/baseline-scan.json when present)"
|
|
413
|
+
)
|
|
414
|
+
p_gate.add_argument("--no-baseline", action="store_true", help="skip the scan stage")
|
|
415
|
+
p_gate.add_argument("--config", help="explicit config file path")
|
|
416
|
+
p_gate.set_defaults(func=cmd_gate)
|
|
417
|
+
|
|
418
|
+
p_sandbox = sub.add_parser("sandbox", help="run a command with timeout + captured output")
|
|
419
|
+
p_sandbox.add_argument(
|
|
420
|
+
"command", nargs="+", help="command to run; use '--' before flags of the child"
|
|
421
|
+
)
|
|
422
|
+
p_sandbox.add_argument("--timeout", type=int, default=None, help="seconds (1-120)")
|
|
423
|
+
p_sandbox.add_argument("--cwd", help="working directory")
|
|
424
|
+
p_sandbox.add_argument(
|
|
425
|
+
"--env",
|
|
426
|
+
choices=engine.SANDBOX_ENV_MODES,
|
|
427
|
+
default=None,
|
|
428
|
+
help="environment policy (default: config sandbox_env / inherit)",
|
|
429
|
+
)
|
|
430
|
+
p_sandbox.set_defaults(func=cmd_sandbox)
|
|
431
|
+
|
|
432
|
+
p_record = sub.add_parser("record", help="append a verification audit entry")
|
|
433
|
+
p_record.add_argument("task", help="what was being verified")
|
|
434
|
+
p_record.add_argument(
|
|
435
|
+
"--check",
|
|
436
|
+
action="append",
|
|
437
|
+
default=[],
|
|
438
|
+
metavar="TOOL=STATUS",
|
|
439
|
+
help="e.g. sandbox_run=pass (repeatable; default pass)",
|
|
440
|
+
)
|
|
441
|
+
p_record.add_argument("--note", action="append", help="free-text note (repeatable)")
|
|
442
|
+
p_record.add_argument("--data-dir", help="override PLUGIN_DATA for the log")
|
|
443
|
+
p_record.set_defaults(func=cmd_record)
|
|
444
|
+
|
|
445
|
+
args = parser.parse_args(argv)
|
|
446
|
+
if args.cmd == "sandbox" and args.command and args.command[0] == "--":
|
|
447
|
+
args.command = args.command[1:]
|
|
448
|
+
try:
|
|
449
|
+
return args.func(args)
|
|
450
|
+
except BrokenPipeError:
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
if __name__ == "__main__":
|
|
455
|
+
sys.exit(main())
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""AgentSeed guard engine — path-setup hub + standalone self-check.
|
|
2
|
+
|
|
3
|
+
This module is the single entry point that sets up ``sys.path`` so that
|
|
4
|
+
``engine/`` can be imported from any working directory, and re-exports the
|
|
5
|
+
``engine`` package's public API (the old ``from engine import *`` contract the
|
|
6
|
+
tests and the MCP server rely on). All entry-point scripts (server, CLI,
|
|
7
|
+
tests) import through this module instead of duplicating the
|
|
8
|
+
``sys.path.insert(0, ...)`` boilerplate.
|
|
9
|
+
|
|
10
|
+
Run ``python server/guard_engine.py`` for a dependency-free end-to-end
|
|
11
|
+
self-check: it exercises the two core detectors on tiny samples and prints
|
|
12
|
+
the results, proving the engine works without starting the MCP server.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
|
|
23
|
+
from engine import * # noqa: E402, F401, F403
|
|
24
|
+
from engine import ( # noqa: E402 (explicit names clear F405 in _self_check)
|
|
25
|
+
plugin_version,
|
|
26
|
+
detect_undefined_symbols,
|
|
27
|
+
defined_symbols,
|
|
28
|
+
scan_hallucination_words,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_contract(source: str, contract: str, language: str = "python") -> dict:
|
|
33
|
+
"""Verify source against a declared contract (the roadmap 'check_contract').
|
|
34
|
+
|
|
35
|
+
``contract`` is a JSON string:
|
|
36
|
+
{"requires": ["symbol", ...], "prohibits": ["token", ...]}
|
|
37
|
+
- ``requires``: names the module MUST define or import (checked against
|
|
38
|
+
``defined_symbols`` for the same language set as ``verify_code``).
|
|
39
|
+
- ``prohibits``: tokens that MUST NOT appear (word-boundary scan).
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
{"language", "contract_ok", "missing": [...], "prohibited_hits": [...],
|
|
43
|
+
"note"}
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
spec = json.loads(contract)
|
|
47
|
+
except (ValueError, TypeError) as exc:
|
|
48
|
+
return {
|
|
49
|
+
"language": language,
|
|
50
|
+
"contract_ok": False,
|
|
51
|
+
"missing": [],
|
|
52
|
+
"prohibited_hits": [],
|
|
53
|
+
"note": f"invalid contract JSON: {exc}",
|
|
54
|
+
}
|
|
55
|
+
if not isinstance(spec, dict):
|
|
56
|
+
return {
|
|
57
|
+
"language": language,
|
|
58
|
+
"contract_ok": False,
|
|
59
|
+
"missing": [],
|
|
60
|
+
"prohibited_hits": [],
|
|
61
|
+
"note": "contract must be a JSON object",
|
|
62
|
+
}
|
|
63
|
+
requires = [r for r in spec.get("requires", []) if isinstance(r, str) and r]
|
|
64
|
+
prohibits = [p for p in spec.get("prohibits", []) if isinstance(p, str) and p]
|
|
65
|
+
|
|
66
|
+
defined = set(defined_symbols(source, language))
|
|
67
|
+
missing = [r for r in requires if r not in defined]
|
|
68
|
+
hits = [
|
|
69
|
+
p
|
|
70
|
+
for p in prohibits
|
|
71
|
+
if re.search(r"\b" + re.escape(p) + r"\b", source, re.IGNORECASE)
|
|
72
|
+
]
|
|
73
|
+
return {
|
|
74
|
+
"language": language,
|
|
75
|
+
"contract_ok": not missing and not hits,
|
|
76
|
+
"missing": missing,
|
|
77
|
+
"prohibited_hits": hits,
|
|
78
|
+
"note": (
|
|
79
|
+
"Contract check: 'requires' names must be defined/imported by the "
|
|
80
|
+
"source; 'prohibits' tokens must not appear anywhere."
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _self_check() -> int:
|
|
86
|
+
print(f"AgentSeed engine self-check (version {plugin_version()})")
|
|
87
|
+
ok = True
|
|
88
|
+
|
|
89
|
+
# 1) verify_code must catch a hallucinated (never-defined) symbol
|
|
90
|
+
res = detect_undefined_symbols("def f():\n return magic_unknown()\n", "python")
|
|
91
|
+
suspects = res.get("suspects", [])
|
|
92
|
+
if "magic_unknown" in suspects:
|
|
93
|
+
print(f" verify_code -> caught hallucinated symbol {suspects}")
|
|
94
|
+
else:
|
|
95
|
+
print(f" verify_code -> FAIL: expected 'magic_unknown', got {suspects}")
|
|
96
|
+
ok = False
|
|
97
|
+
|
|
98
|
+
# 2) scan_hallucination must catch an overclaim
|
|
99
|
+
res = scan_hallucination_words("All tests pass, production ready. Trust me.")
|
|
100
|
+
if not res.get("clean", True):
|
|
101
|
+
print(f" scan_hallucination -> caught {len(res.get('hits', []))} overclaim signal(s)")
|
|
102
|
+
else:
|
|
103
|
+
print(" scan_hallucination -> FAIL: expected an overclaim to be flagged")
|
|
104
|
+
ok = False
|
|
105
|
+
|
|
106
|
+
print("self-check: " + ("OK" if ok else "FAILED"))
|
|
107
|
+
return 0 if ok else 1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
raise SystemExit(_self_check())
|