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,131 @@
|
|
|
1
|
+
"""AgentSeed config loading — zero dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
CONFIG_FILENAME = "agentseed.config.json"
|
|
9
|
+
_VALID_SEVERITIES = {"error", "warning", "info"}
|
|
10
|
+
VALID_GROUPS = {"stub_code", "oversold", "fabricated"}
|
|
11
|
+
|
|
12
|
+
# Every key load_config() understands; anything else is a likely typo and
|
|
13
|
+
# callers should surface a warning (silently ignoring typos = silent no-op).
|
|
14
|
+
KNOWN_CONFIG_KEYS = {
|
|
15
|
+
"allowlist",
|
|
16
|
+
"severities",
|
|
17
|
+
"timeout",
|
|
18
|
+
"extra_tokens",
|
|
19
|
+
"suppress_symbols",
|
|
20
|
+
"sandbox_allowed_prefixes",
|
|
21
|
+
"sandbox_env",
|
|
22
|
+
"known_packages",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
SANDBOX_ENV_MODES = ("inherit", "scrub")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sandbox_env_mode(config: dict, default: str = "inherit") -> str:
|
|
29
|
+
"""Validated sandbox_env mode from config ("inherit" | "scrub")."""
|
|
30
|
+
value = config.get("sandbox_env", default)
|
|
31
|
+
return value if value in SANDBOX_ENV_MODES else default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_config(explicit_path: str | None = None) -> dict:
|
|
35
|
+
"""Load the effective AgentSeed config (zero dependencies).
|
|
36
|
+
|
|
37
|
+
Search order (first hit wins):
|
|
38
|
+
1. ``explicit_path`` argument
|
|
39
|
+
2. ``AGENTSEED_CONFIG`` environment variable
|
|
40
|
+
3. ``${PLUGIN_DATA}/agentseed.config.json``
|
|
41
|
+
4. ``./agentseed.config.json`` in the current working directory.
|
|
42
|
+
|
|
43
|
+
Recognized keys (all optional):
|
|
44
|
+
allowlist : list[str] - scan exclusions (replaces DEFAULT_ALLOWLIST)
|
|
45
|
+
severities : dict[str, str] - group -> error|warning|info
|
|
46
|
+
timeout : int - default sandbox_run timeout in seconds
|
|
47
|
+
extra_tokens : dict[group, list[str]] - extra hallucination words
|
|
48
|
+
suppress_symbols : list[str] - names verify_code never flags
|
|
49
|
+
known_packages : list[str] - packages check_imports treats as known
|
|
50
|
+
(project-local / trusted third-party), beyond stdlib
|
|
51
|
+
sandbox_allowed_prefixes : list[str] - executable allowlist for sandbox_run
|
|
52
|
+
(absent/empty = unrestricted)
|
|
53
|
+
|
|
54
|
+
Returns {} when no config file exists or it cannot be parsed.
|
|
55
|
+
"""
|
|
56
|
+
candidates: list[str] = []
|
|
57
|
+
if explicit_path:
|
|
58
|
+
candidates.append(explicit_path)
|
|
59
|
+
env_path = os.environ.get("AGENTSEED_CONFIG")
|
|
60
|
+
if env_path:
|
|
61
|
+
candidates.append(env_path)
|
|
62
|
+
plugin_data = os.environ.get("PLUGIN_DATA")
|
|
63
|
+
if plugin_data:
|
|
64
|
+
candidates.append(os.path.join(plugin_data, CONFIG_FILENAME))
|
|
65
|
+
candidates.append(CONFIG_FILENAME)
|
|
66
|
+
|
|
67
|
+
for path in candidates:
|
|
68
|
+
if path and os.path.isfile(path):
|
|
69
|
+
try:
|
|
70
|
+
with open(path, encoding="utf-8") as fh:
|
|
71
|
+
data = json.load(fh)
|
|
72
|
+
if isinstance(data, dict):
|
|
73
|
+
return data
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
continue
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def config_str_list(config: dict, key: str) -> list[str] | None:
|
|
80
|
+
"""Extract a validated string-list value from config, or None."""
|
|
81
|
+
value = config.get(key)
|
|
82
|
+
if isinstance(value, list) and all(isinstance(v, str) for v in value):
|
|
83
|
+
return value
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def config_severities(config: dict) -> dict[str, str] | None:
|
|
88
|
+
"""Extract a validated severities map from config, or None."""
|
|
89
|
+
value = config.get("severities")
|
|
90
|
+
if isinstance(value, dict) and all(
|
|
91
|
+
isinstance(k, str) and isinstance(v, str) and v in _VALID_SEVERITIES
|
|
92
|
+
for k, v in value.items()
|
|
93
|
+
):
|
|
94
|
+
return value
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_timeout(config: dict, default: int = 30) -> int:
|
|
99
|
+
"""Extract and validate timeout from config dict."""
|
|
100
|
+
try:
|
|
101
|
+
return int(config.get("timeout", default))
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return default
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def unknown_config_keys(config: dict) -> list[str]:
|
|
107
|
+
"""Keys present in ``config`` that load_config() does not understand.
|
|
108
|
+
|
|
109
|
+
A typo'd key is silently ignored by every consumer — surfacing it turns
|
|
110
|
+
a silent no-op into an actionable warning.
|
|
111
|
+
"""
|
|
112
|
+
if not isinstance(config, dict):
|
|
113
|
+
return []
|
|
114
|
+
return sorted(k for k in config if k not in KNOWN_CONFIG_KEYS)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def config_extra_tokens(config: dict) -> dict[str, list[str]] | None:
|
|
118
|
+
"""Validate the extra_tokens mapping {group: [words]}, or None."""
|
|
119
|
+
value = config.get("extra_tokens")
|
|
120
|
+
if not isinstance(value, dict):
|
|
121
|
+
return None
|
|
122
|
+
out: dict[str, list[str]] = {}
|
|
123
|
+
for group, words in value.items():
|
|
124
|
+
if (
|
|
125
|
+
group in VALID_GROUPS
|
|
126
|
+
and isinstance(words, list)
|
|
127
|
+
and all(isinstance(w, str) and w for w in words)
|
|
128
|
+
and words
|
|
129
|
+
):
|
|
130
|
+
out[group] = words
|
|
131
|
+
return out or None
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""AgentSeed hallucination word scanning.
|
|
2
|
+
|
|
3
|
+
Flags tokens across three signal groups:
|
|
4
|
+
- stub_code: stub/mock/fake/placeholder/dummy/todo/...
|
|
5
|
+
- oversold: guaranteed/"all tests pass"/"production ready"/...
|
|
6
|
+
- fabricated: simulated/invented/fabricated/...
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from .config import _VALID_SEVERITIES
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Hallucination token pools (grouped by signal type).
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
STUB_TOKENS = [
|
|
20
|
+
"stub",
|
|
21
|
+
"mock",
|
|
22
|
+
"fake",
|
|
23
|
+
"placeholder",
|
|
24
|
+
"dummy",
|
|
25
|
+
"todo",
|
|
26
|
+
"fixme",
|
|
27
|
+
"xxx",
|
|
28
|
+
"tbd",
|
|
29
|
+
"tba",
|
|
30
|
+
"wip",
|
|
31
|
+
"not implemented",
|
|
32
|
+
"coming soon",
|
|
33
|
+
"to be implemented",
|
|
34
|
+
"not implemented yet",
|
|
35
|
+
"pending implementation",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# CJK tokens: \b word boundaries are meaningless between CJK chars, so these
|
|
39
|
+
# are matched as substrings (they are specific enough to stay low-noise).
|
|
40
|
+
STUB_TOKENS_ZH = [
|
|
41
|
+
"占位",
|
|
42
|
+
"待实现",
|
|
43
|
+
"未实现",
|
|
44
|
+
"待补充",
|
|
45
|
+
"稍后补",
|
|
46
|
+
"假数据",
|
|
47
|
+
"模拟数据",
|
|
48
|
+
"临时方案",
|
|
49
|
+
"先这样",
|
|
50
|
+
"待完成",
|
|
51
|
+
"尚未实现",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
OVERSOLD_TOKENS = [
|
|
55
|
+
"guaranteed",
|
|
56
|
+
"definitely works",
|
|
57
|
+
"all tests pass",
|
|
58
|
+
"everything works",
|
|
59
|
+
"fully tested",
|
|
60
|
+
"production ready",
|
|
61
|
+
"no bugs",
|
|
62
|
+
"works perfectly",
|
|
63
|
+
"should work",
|
|
64
|
+
"trust me",
|
|
65
|
+
"works on my machine",
|
|
66
|
+
"100% correct",
|
|
67
|
+
"bug free",
|
|
68
|
+
"zero errors",
|
|
69
|
+
"foolproof",
|
|
70
|
+
"bulletproof",
|
|
71
|
+
"cannot fail",
|
|
72
|
+
"guaranteed to pass",
|
|
73
|
+
"impossible to break",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
OVERSOLD_TOKENS_ZH = [
|
|
77
|
+
"保证通过",
|
|
78
|
+
"绝对没问题",
|
|
79
|
+
"肯定能跑",
|
|
80
|
+
"万无一失",
|
|
81
|
+
"完美运行",
|
|
82
|
+
"零缺陷",
|
|
83
|
+
"无需测试",
|
|
84
|
+
"包过",
|
|
85
|
+
"绝无问题",
|
|
86
|
+
"不可能失败",
|
|
87
|
+
"绝对可靠",
|
|
88
|
+
"稳过",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
FABRICATED_TOKENS = [
|
|
92
|
+
"simulated",
|
|
93
|
+
"invented",
|
|
94
|
+
"fabricated",
|
|
95
|
+
"fictional",
|
|
96
|
+
"pretend",
|
|
97
|
+
"made up",
|
|
98
|
+
"fictitious",
|
|
99
|
+
"nonexistent",
|
|
100
|
+
"non-existent",
|
|
101
|
+
"mythical",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
FABRICATED_TOKENS_ZH = [
|
|
105
|
+
"虚构",
|
|
106
|
+
"编造",
|
|
107
|
+
"凭空捏造",
|
|
108
|
+
"子虚乌有",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
# Full pool: token -> group (kept for backward compatibility).
|
|
112
|
+
HALLUCINATION_WORDS: dict[str, str] = {}
|
|
113
|
+
for _tokens, _group in [
|
|
114
|
+
(STUB_TOKENS + STUB_TOKENS_ZH, "stub_code"),
|
|
115
|
+
(OVERSOLD_TOKENS + OVERSOLD_TOKENS_ZH, "oversold"),
|
|
116
|
+
(FABRICATED_TOKENS + FABRICATED_TOKENS_ZH, "fabricated"),
|
|
117
|
+
]:
|
|
118
|
+
for _t in _tokens:
|
|
119
|
+
HALLUCINATION_WORDS[_t] = _group
|
|
120
|
+
|
|
121
|
+
_GROUP_LABELS = {
|
|
122
|
+
"stub_code": "placeholder / not-really-done code",
|
|
123
|
+
"oversold": "unverified confidence claim",
|
|
124
|
+
"fabricated": "fabricated / invented content",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Tokens that are legitimate in common testing/idiomatic contexts.
|
|
128
|
+
DEFAULT_ALLOWLIST = [
|
|
129
|
+
"unittest.mock",
|
|
130
|
+
"Mock(",
|
|
131
|
+
"MagicMock(",
|
|
132
|
+
"AsyncMock(",
|
|
133
|
+
"PropertyMock(",
|
|
134
|
+
"patch(",
|
|
135
|
+
"monkeypatch",
|
|
136
|
+
"mocker",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
_IMPORT_LINE_RE = re.compile(r"^\s*(?:from\s+[\w.]+\s+import\b|import\s+\w)", re.IGNORECASE)
|
|
140
|
+
|
|
141
|
+
# Default severity per signal group.
|
|
142
|
+
DEFAULT_SEVERITIES: dict[str, str] = {
|
|
143
|
+
"stub_code": "warning",
|
|
144
|
+
"oversold": "error",
|
|
145
|
+
"fabricated": "error",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Precompiled regex patterns (one per group, compiled once at import time).
|
|
150
|
+
# ASCII tokens use \b word boundaries; CJK tokens are substring matches
|
|
151
|
+
# (\b never fires between two CJK chars, so boundaries would miss 占位符 etc).
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _compile_group(tokens: list[str]) -> re.Pattern:
|
|
158
|
+
ascii_alts = [re.escape(t).replace(r"\ ", r"\s+") for t in tokens if not _CJK_RE.search(t)]
|
|
159
|
+
cjk_alts = [re.escape(t) for t in tokens if _CJK_RE.search(t)]
|
|
160
|
+
parts = []
|
|
161
|
+
if ascii_alts:
|
|
162
|
+
parts.append(rf"\b(?:{'|'.join(ascii_alts)})\b")
|
|
163
|
+
if cjk_alts:
|
|
164
|
+
parts.append("|".join(cjk_alts))
|
|
165
|
+
return re.compile("(?:" + "|".join(parts) + ")", re.IGNORECASE)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_HALLUCINATION_PATTERNS: list[tuple[re.Pattern, str]] = [
|
|
169
|
+
(_compile_group(STUB_TOKENS + STUB_TOKENS_ZH), "stub_code"),
|
|
170
|
+
(_compile_group(OVERSOLD_TOKENS + OVERSOLD_TOKENS_ZH), "oversold"),
|
|
171
|
+
(_compile_group(FABRICATED_TOKENS + FABRICATED_TOKENS_ZH), "fabricated"),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def scan_hallucination_words(
|
|
176
|
+
source: str,
|
|
177
|
+
allowlist: list[str] | None = None,
|
|
178
|
+
severities: dict[str, str] | None = None,
|
|
179
|
+
extra_tokens: dict[str, list[str]] | None = None,
|
|
180
|
+
) -> dict:
|
|
181
|
+
"""Scan source for tokens in the grouped hallucination pool.
|
|
182
|
+
|
|
183
|
+
To avoid flagging legitimate code, matches are skipped when:
|
|
184
|
+
- the line is an import statement;
|
|
185
|
+
- the match is part of a dotted path (``unittest.mock``, ``os.path``);
|
|
186
|
+
- the matched text starts with an entry of the effective allowlist.
|
|
187
|
+
|
|
188
|
+
``extra_tokens`` extends the pool at runtime (config: ``extra_tokens``
|
|
189
|
+
mapping group -> [words]); unknown groups are ignored.
|
|
190
|
+
|
|
191
|
+
Each hit carries a severity (``error`` | ``warning`` | ``info``) taken
|
|
192
|
+
from ``severities`` (group -> severity), falling back to
|
|
193
|
+
DEFAULT_SEVERITIES.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
{
|
|
197
|
+
"hits": [{"word": "stub", "group": "stub_code", "line": 12,
|
|
198
|
+
"severity": "warning"}, ...],
|
|
199
|
+
"clean": bool,
|
|
200
|
+
"blocking": bool,
|
|
201
|
+
"groups": {"stub_code": 2, "oversold": 1, "fabricated": 0},
|
|
202
|
+
"severities": {"error": 1, "warning": 2, "info": 0}
|
|
203
|
+
}
|
|
204
|
+
"""
|
|
205
|
+
if allowlist is None:
|
|
206
|
+
allowlist = DEFAULT_ALLOWLIST
|
|
207
|
+
elif isinstance(allowlist, str):
|
|
208
|
+
# MCP clients sometimes send a bare string instead of a list; a raw
|
|
209
|
+
# string would iterate characters and silently suppress every match.
|
|
210
|
+
allowlist = [allowlist]
|
|
211
|
+
elif not isinstance(allowlist, list):
|
|
212
|
+
allowlist = (
|
|
213
|
+
[a for a in allowlist if isinstance(a, str)] if hasattr(allowlist, "__iter__") else []
|
|
214
|
+
)
|
|
215
|
+
allowlist = [a for a in allowlist if isinstance(a, str) and a]
|
|
216
|
+
sev = dict(DEFAULT_SEVERITIES)
|
|
217
|
+
if severities:
|
|
218
|
+
for g, s in severities.items():
|
|
219
|
+
if g in _GROUP_LABELS and s in _VALID_SEVERITIES:
|
|
220
|
+
sev[g] = s
|
|
221
|
+
patterns = list(_HALLUCINATION_PATTERNS)
|
|
222
|
+
if extra_tokens:
|
|
223
|
+
for g, words in extra_tokens.items():
|
|
224
|
+
if g in _GROUP_LABELS and isinstance(words, list):
|
|
225
|
+
words = [w for w in words if isinstance(w, str) and w]
|
|
226
|
+
if words:
|
|
227
|
+
patterns.append((_compile_group(words), g))
|
|
228
|
+
hits: list[dict] = []
|
|
229
|
+
group_counts: dict[str, int] = {g: 0 for g in _GROUP_LABELS}
|
|
230
|
+
severity_counts: dict[str, int] = {"error": 0, "warning": 0, "info": 0}
|
|
231
|
+
for i, line in enumerate(source.splitlines(), start=1):
|
|
232
|
+
if _IMPORT_LINE_RE.match(line):
|
|
233
|
+
continue
|
|
234
|
+
for pattern, group in patterns:
|
|
235
|
+
for m in pattern.finditer(line):
|
|
236
|
+
before = line[max(0, m.start() - 1) : m.start()]
|
|
237
|
+
after = line[m.end() : m.end() + 1]
|
|
238
|
+
if before == "." or after == ".":
|
|
239
|
+
continue # part of a dotted path (module/attribute)
|
|
240
|
+
rest = line[m.start() :]
|
|
241
|
+
if any(rest.lower().startswith(a.lower()) for a in allowlist):
|
|
242
|
+
continue
|
|
243
|
+
word = m.group(0).lower()
|
|
244
|
+
severity = sev.get(group, "warning")
|
|
245
|
+
hits.append({"word": word, "group": group, "line": i, "severity": severity})
|
|
246
|
+
group_counts[group] += 1
|
|
247
|
+
severity_counts[severity] += 1
|
|
248
|
+
return {
|
|
249
|
+
"hits": hits,
|
|
250
|
+
"clean": len(hits) == 0,
|
|
251
|
+
"blocking": severity_counts["error"] > 0,
|
|
252
|
+
"groups": group_counts,
|
|
253
|
+
"severities": severity_counts,
|
|
254
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""AgentSeed import verification — package-hallucination (slopsquatting) guard.
|
|
2
|
+
|
|
3
|
+
Motivated by "We Have a Package for You!" (USENIX Security 2025,
|
|
4
|
+
arXiv:2406.10279): across 576k generated samples, LLMs invented non-existent
|
|
5
|
+
package names in ~5.2% (commercial) to ~21.7% (open-source) of outputs, and
|
|
6
|
+
~58% of those names recurred across runs — predictable enough that attackers
|
|
7
|
+
pre-register the exact hallucinated names with malicious payloads
|
|
8
|
+
("slopsquatting").
|
|
9
|
+
|
|
10
|
+
``check_imports`` flags top-level imports that are neither Python stdlib nor
|
|
11
|
+
in the project's ``known_packages`` allowlist, so a model-suggested phantom
|
|
12
|
+
package cannot reach a lockfile silently. It is a REPORT, not a hard gate:
|
|
13
|
+
a legit long-tail package will also be flagged for the human to confirm —
|
|
14
|
+
that is the intended cost. Python (AST) only; other languages return an
|
|
15
|
+
honest empty result.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
# Fallback for Python < 3.10 (``sys.stdlib_module_names`` is 3.10+): a curated
|
|
24
|
+
# list of the most commonly imported stdlib modules. On 3.10+ the exact set is
|
|
25
|
+
# used and this list is never consulted.
|
|
26
|
+
_STDLIB_FALLBACK = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"abc", "argparse", "array", "ast", "asyncio", "atexit", "base64",
|
|
29
|
+
"bisect", "builtins", "collections", "concurrent", "configparser",
|
|
30
|
+
"contextlib", "copy", "csv", "ctypes", "dataclasses", "datetime",
|
|
31
|
+
"decimal", "difflib", "enum", "errno", "functools", "gc", "getpass",
|
|
32
|
+
"glob", "gzip", "hashlib", "heapq", "hmac", "html", "http", "importlib",
|
|
33
|
+
"inspect", "io", "ipaddress", "itertools", "json", "logging", "math",
|
|
34
|
+
"multiprocessing", "os", "pathlib", "pickle", "platform", "queue",
|
|
35
|
+
"random", "re", "readline", "secrets", "shlex", "shutil", "signal",
|
|
36
|
+
"site", "socket", "sqlite3", "ssl", "stat", "statistics", "string",
|
|
37
|
+
"struct", "subprocess", "sys", "tempfile", "textwrap", "threading",
|
|
38
|
+
"time", "timeit", "token", "tokenize", "traceback", "types", "typing",
|
|
39
|
+
"unicodedata", "unittest", "urllib", "uuid", "venv", "warnings",
|
|
40
|
+
"weakref", "xml", "zipfile", "zoneinfo",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Common third-party packages treated as known by default (beyond stdlib).
|
|
45
|
+
# The full resolution for anything not listed is: user config ``known_packages``.
|
|
46
|
+
_DEFAULT_COMMON = frozenset(
|
|
47
|
+
{
|
|
48
|
+
"numpy", "pandas", "scipy", "sklearn", "matplotlib", "seaborn", "plotly",
|
|
49
|
+
"requests", "httpx", "aiohttp", "urllib3", "beautifulsoup4", "bs4",
|
|
50
|
+
"scrapy", "selenium", "playwright", "flask", "fastapi", "django",
|
|
51
|
+
"starlette", "uvicorn", "gunicorn", "jinja2", "sqlalchemy", "pymysql",
|
|
52
|
+
"psycopg2", "redis", "pymongo", "elasticsearch", "celery", "kafka",
|
|
53
|
+
"pydantic", "pydantic_settings", "click", "typer", "rich", "tqdm",
|
|
54
|
+
"pytest", "coverage", "black", "ruff", "mypy", "flake8", "isort",
|
|
55
|
+
"tox", "nox", "hypothesis", "unittest", "jupyter", "notebook",
|
|
56
|
+
"ipykernel", "ipython", "nbformat", "nbconvert", "tensorflow", "torch",
|
|
57
|
+
"keras", "transformers", "datasets", "tokenizers", "accelerate",
|
|
58
|
+
"sentence_transformers", "openai", "anthropic", "google", "googleapiclient",
|
|
59
|
+
"boto3", "botocore", "azure", "awscli", "paramiko", "cryptography",
|
|
60
|
+
"pycryptodome", "bcrypt", "jwt", "pyjwt", "passlib", "yaml", "pyyaml",
|
|
61
|
+
"tomllib", "tomli", "tomlkit", "jsonschema", "setuptools", "wheel",
|
|
62
|
+
"pip", "pipenv", "poetry", "uv", "pre_commit", "arrow", "pendulum",
|
|
63
|
+
"dateutil", "python_dateutil", "pytz", "tzlocal", "zoneinfo", "natsort",
|
|
64
|
+
"more_itertools", "toolz", "pydash", "tenacity", "structlog", "loguru",
|
|
65
|
+
"colorama", "clickhouse_driver", "duckdb", "polars", "dask", "ray",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _stdlib_modules() -> frozenset[str]:
|
|
71
|
+
names = getattr(sys, "stdlib_module_names", None)
|
|
72
|
+
return frozenset(names) if names is not None else _STDLIB_FALLBACK
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _imported_top_level(source: str) -> list[tuple[str, int]]:
|
|
76
|
+
"""(top-level module name, lineno) for every import / from-import."""
|
|
77
|
+
try:
|
|
78
|
+
tree = ast.parse(source)
|
|
79
|
+
except SyntaxError:
|
|
80
|
+
return []
|
|
81
|
+
out: list[tuple[str, int]] = []
|
|
82
|
+
for node in ast.walk(tree):
|
|
83
|
+
if isinstance(node, ast.Import):
|
|
84
|
+
for alias in node.names:
|
|
85
|
+
out.append((alias.name.split(".")[0], node.lineno))
|
|
86
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
87
|
+
out.append((node.module.split(".")[0], node.lineno))
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_imports(
|
|
92
|
+
source: str,
|
|
93
|
+
language: str = "python",
|
|
94
|
+
known_packages: list[str] | None = None,
|
|
95
|
+
) -> dict:
|
|
96
|
+
"""Flag top-level imports neither in stdlib nor in the known-package set.
|
|
97
|
+
|
|
98
|
+
``known_packages`` (config key of the same name) extends the default
|
|
99
|
+
common third-party allowlist with project-local packages. Python only
|
|
100
|
+
(AST); other languages return an honest empty result.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
{"language", "imports_ok", "suspicious": [{"package", "line"}, ...],
|
|
104
|
+
"note"}
|
|
105
|
+
"""
|
|
106
|
+
lang = (language or "python").strip().lower()
|
|
107
|
+
if lang != "python":
|
|
108
|
+
return {
|
|
109
|
+
"language": lang,
|
|
110
|
+
"imports_ok": True,
|
|
111
|
+
"suspicious": [],
|
|
112
|
+
"note": "Import verification is implemented for python (AST); "
|
|
113
|
+
"other languages are not covered yet.",
|
|
114
|
+
}
|
|
115
|
+
known = set(_DEFAULT_COMMON)
|
|
116
|
+
for pkg in known_packages or []:
|
|
117
|
+
if isinstance(pkg, str) and pkg.strip():
|
|
118
|
+
known.add(pkg.strip())
|
|
119
|
+
stdlib = _stdlib_modules()
|
|
120
|
+
suspicious = [
|
|
121
|
+
{"package": pkg, "line": line}
|
|
122
|
+
for pkg, line in _imported_top_level(source)
|
|
123
|
+
if pkg not in stdlib and pkg not in known
|
|
124
|
+
]
|
|
125
|
+
note = (
|
|
126
|
+
"Top-level import is neither Python stdlib nor in the known-package "
|
|
127
|
+
"set (stdlib + common third-party + config `known_packages`) — a "
|
|
128
|
+
"possible hallucinated package (slopsquatting). Verify the name exists "
|
|
129
|
+
"in the registry before installing. This is a report, not a gate."
|
|
130
|
+
)
|
|
131
|
+
return {
|
|
132
|
+
"language": "python",
|
|
133
|
+
"imports_ok": not suspicious,
|
|
134
|
+
"suspicious": suspicious,
|
|
135
|
+
"note": note,
|
|
136
|
+
}
|