@yottameta/yotta-verify-mcp-plugin 0.4.1 → 0.4.3
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/.agents/plugins/marketplace.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/yotta-verify-mcp/SKILL.md +1 -1
- package/skills/yotta-verify-mcp/scripts/yotta_verify.py +131 -26
- package/skills/yotta-verify-mcp/scripts/yotta_verify_mcp.py +2 -6
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"authentication": "ON_INSTALL"
|
|
13
13
|
},
|
|
14
14
|
"category": "Security",
|
|
15
|
-
"version": "0.4.
|
|
15
|
+
"version": "0.4.3",
|
|
16
16
|
"description": "YuanXinMCP (元信MCP) - the pre-install security scanner for Agent skills, exposed as a stdio MCP server: scan_skill (dir/package -> verdict + findings), generate_badge (audited badge), gate_check (CI gate), get_report (JSON/Markdown). Local offline static scan; no upload of scanned content, no execution of scanned code; human confirmation required.",
|
|
17
17
|
"interface": {
|
|
18
18
|
"displayName": "元信MCP yotta-verify-mcp",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"authentication": "ON_INSTALL"
|
|
13
13
|
},
|
|
14
14
|
"category": "Security",
|
|
15
|
-
"version": "0.4.
|
|
15
|
+
"version": "0.4.3",
|
|
16
16
|
"description": "YuanXinMCP (元信MCP) - the pre-install security scanner for Agent skills, exposed as a stdio MCP server: scan_skill (dir/package -> verdict + findings), generate_badge (audited badge), gate_check (CI gate), get_report (JSON/Markdown). Local offline static scan; no upload of scanned content, no execution of scanned code; human confirmation required.",
|
|
17
17
|
"interface": {
|
|
18
18
|
"displayName": "元信MCP yotta-verify-mcp",
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "yotta-verify-mcp",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.3",
|
|
5
5
|
"description": "YuanXinMCP (元信MCP) - the pre-install security scanner for Agent skills, exposed as a stdio MCP server: scan_skill (dir/package -> verdict + findings), generate_badge (audited badge), gate_check (CI gate), get_report (JSON/Markdown). Local offline static scan; no upload of scanned content, no execution of scanned code; human confirmation required.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "YottaMeta",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yotta-verify-mcp
|
|
3
3
|
description: 元信MCP(yotta-verify-mcp)—— 把元信(yotta-verify)装前安全扫描暴露为 stdio MCP server,提供 scan_skill(目录/包 → verdict+发现)、generate_badge(audited 徽章)、gate_check(CI 闸门)、get_report(JSON/Markdown 双视角综合报告)四个 MCP 工具。触发:给 MCP 客户端配置元信MCP、把装前信任扫描接入智能体/工作流、调用上列 MCP 工具做扫描;或用户说 MCP元信/元信MCP/trust-mcp/scan-mcp 等。边界:本地离线静态扫描,不上传被测内容、不执行被测代码、不联网;结论需人工确认。MCP 配置与全局/永久记忆写入均为可选,必须先获得用户明确同意;未获同意前不写任何文件,可直接降级 CLI。
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.3
|
|
5
5
|
license: MIT
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -30,6 +30,7 @@ exit code 语义(与元安/元审一致):
|
|
|
30
30
|
"""
|
|
31
31
|
import argparse
|
|
32
32
|
import base64
|
|
33
|
+
import hashlib
|
|
33
34
|
import json
|
|
34
35
|
import re
|
|
35
36
|
import sys
|
|
@@ -52,7 +53,7 @@ sys.path.insert(0, str(_HERE))
|
|
|
52
53
|
import verify_rules # noqa: E402
|
|
53
54
|
import threat_engine # noqa: E402
|
|
54
55
|
|
|
55
|
-
VERSION = "0.4.
|
|
56
|
+
VERSION = "0.4.3"
|
|
56
57
|
TOOL_NAME = "yotta-verify"
|
|
57
58
|
CN_NAME = "元信"
|
|
58
59
|
|
|
@@ -74,8 +75,49 @@ DOTFILE_NAMES = {
|
|
|
74
75
|
MAX_FILE_SIZE = 1_000_000
|
|
75
76
|
MAX_LINE_LEN = verify_rules.MAX_LINE_LEN
|
|
76
77
|
MAX_FILES = 2000
|
|
77
|
-
#
|
|
78
|
+
# 签名数据文件:规则表是扫描器自身与家族检测技能的签名数据库,不是被测技能行为。
|
|
79
|
+
# v0.3.2 收紧(旧行为 = 按文件名全局豁免,`scripts/<同名>.py` 可被随意伪造以逃避扫描):
|
|
80
|
+
# ① 相对路径必须恰为 scripts/<规则表名>.py;
|
|
81
|
+
# ② 文件 SHA-256(CRLF 归一化)必须命中「已发布规则表摘要表」。
|
|
82
|
+
# 任一条不满足 → 正常扫描(fail-closed),杜绝「改名即豁免」与「放标记文件即降级」。
|
|
78
83
|
SIGNATURE_DATA_FILES = {"verify_rules.py", "audit_rules.py", "vetter_rules.py", "hardening_rules.py"}
|
|
84
|
+
SIGNATURE_DATA_DIGESTS = {
|
|
85
|
+
"verify_rules.py": {"8d8c128911b05b24413cc3aec8a2d1c36c1ccf66f656330f723cd68718bda8bd"},
|
|
86
|
+
"vetter_rules.py": {"fc5bad6a7705f9fde60f4fdaf540a3aaa6490448d1fe22ed264e3f812c8ba092"},
|
|
87
|
+
"audit_rules.py": {"475ba1daee436589997260291917126218ae7cae572d16eb59459ca5a2192d23"},
|
|
88
|
+
"hardening_rules.py": {"6b9cbdaa106ae7f5827e60f83c016c237d4cd28785426930abc24cde00bcf5c7"},
|
|
89
|
+
"guardian_rules.py": {"a9fc0da3e0344d485b20c55378b58bc81d78ecbb28a7f2b3bd44bcbed06aebe3"},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _sha256_normalized(path):
|
|
94
|
+
"""CRLF 归一化后的 SHA-256(跨 Windows / Linux 检出结果一致)。"""
|
|
95
|
+
try:
|
|
96
|
+
data = path.read_bytes().replace(b"\r\n", b"\n")
|
|
97
|
+
except OSError:
|
|
98
|
+
return None
|
|
99
|
+
return hashlib.sha256(data).hexdigest()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_signature_data(rel, path):
|
|
103
|
+
"""是否为「已发布」的家族规则表:路径 + 内容摘要双绑定。"""
|
|
104
|
+
known = SIGNATURE_DATA_DIGESTS.get(path.name)
|
|
105
|
+
if not known:
|
|
106
|
+
return False
|
|
107
|
+
parts = str(rel).replace("\\", "/").split("/")
|
|
108
|
+
if len(parts) != 2 or parts[0] != "scripts":
|
|
109
|
+
return False
|
|
110
|
+
digest = _sha256_normalized(path)
|
|
111
|
+
return bool(digest) and digest in known
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def has_published_signature_data(root):
|
|
115
|
+
"""目标包是否持有已发布签名数据(用于测试夹具跳过与文档降级判定)。"""
|
|
116
|
+
for name in SIGNATURE_DATA_DIGESTS:
|
|
117
|
+
p = Path(root) / "scripts" / name
|
|
118
|
+
if p.is_file() and is_signature_data("scripts/" + name, p):
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
79
121
|
|
|
80
122
|
# ── 严重级 / verdict ───────────────────────────────────────────────────────
|
|
81
123
|
_SEVERITY_VALUE = verify_rules.SEVERITY_VALUE
|
|
@@ -143,19 +185,26 @@ def is_text_file(name):
|
|
|
143
185
|
return Path(p).suffix in TEXT_EXTENSIONS
|
|
144
186
|
|
|
145
187
|
|
|
146
|
-
def walk_files(root, base=""):
|
|
147
|
-
"""递归收集可扫描文本文件(跳过 SKIP_DIRS /
|
|
188
|
+
def walk_files(root, base="", trusted_tests=None, scan_root=None):
|
|
189
|
+
"""递归收集可扫描文本文件(跳过 SKIP_DIRS / 已发布签名数据 / 超限)。
|
|
190
|
+
|
|
191
|
+
trusted_tests 只在「目标包持有已发布签名数据」时为真——此时 `test_*.py`
|
|
192
|
+
视为自家测试夹具跳过;第三方包的同名文件照常扫描(v0.3.2 起)。
|
|
193
|
+
"""
|
|
148
194
|
out = []
|
|
195
|
+
if scan_root is None:
|
|
196
|
+
scan_root = root
|
|
197
|
+
trusted_tests = has_published_signature_data(scan_root)
|
|
149
198
|
try:
|
|
150
199
|
entries = sorted(root.iterdir())
|
|
151
200
|
except OSError:
|
|
152
201
|
return out
|
|
153
202
|
for entry in entries:
|
|
154
|
-
if entry.name in SKIP_DIRS
|
|
203
|
+
if entry.name in SKIP_DIRS:
|
|
155
204
|
continue
|
|
156
205
|
rel = entry.name if not base else base + "/" + entry.name
|
|
157
206
|
if entry.is_dir():
|
|
158
|
-
out.extend(walk_files(entry, rel))
|
|
207
|
+
out.extend(walk_files(entry, rel, trusted_tests, scan_root))
|
|
159
208
|
elif entry.is_file():
|
|
160
209
|
try:
|
|
161
210
|
size = entry.stat().st_size
|
|
@@ -163,8 +212,11 @@ def walk_files(root, base=""):
|
|
|
163
212
|
continue
|
|
164
213
|
if size > MAX_FILE_SIZE:
|
|
165
214
|
continue
|
|
166
|
-
#
|
|
167
|
-
if
|
|
215
|
+
# 已发布规则表(路径 + 摘要绑定)不是被测技能行为,跳过
|
|
216
|
+
if is_signature_data(rel, entry):
|
|
217
|
+
continue
|
|
218
|
+
# 自家包的测试夹具(含构造样例)跳过;第三方包不豁免
|
|
219
|
+
if trusted_tests and entry.name.startswith("test_") and entry.name.endswith(".py"):
|
|
168
220
|
continue
|
|
169
221
|
if is_text_file(entry.name):
|
|
170
222
|
out.append((entry, rel))
|
|
@@ -420,11 +472,12 @@ _DOC_EXT = {".md", ".txt", ".markdown", ".rst"}
|
|
|
420
472
|
|
|
421
473
|
|
|
422
474
|
def is_detector_skill(root):
|
|
423
|
-
"""
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
475
|
+
"""目标是否为家族检测技能(v0.3.2:须持「已发布」规则表,路径 + 摘要绑定)。
|
|
476
|
+
|
|
477
|
+
旧行为 = 只看目录里有没有同名文件(放一个标记文件即可把整包文档命中降级为
|
|
478
|
+
info)。现改为内容摘要绑定:未验证的同名文件不构成检测器签名。
|
|
479
|
+
"""
|
|
480
|
+
return has_published_signature_data(root)
|
|
428
481
|
|
|
429
482
|
|
|
430
483
|
def downgrade_detector_docs(findings, root):
|
|
@@ -436,6 +489,8 @@ def downgrade_detector_docs(findings, root):
|
|
|
436
489
|
if not is_detector_skill(root):
|
|
437
490
|
return
|
|
438
491
|
for f in findings:
|
|
492
|
+
if f.detector == "Structure":
|
|
493
|
+
continue
|
|
439
494
|
if f.severity in ("critical", "high", "medium"):
|
|
440
495
|
if Path(f.file_path).suffix.lower() in _DOC_EXT:
|
|
441
496
|
f.severity = "info"
|
|
@@ -492,19 +547,71 @@ def exit_code_of(verdict):
|
|
|
492
547
|
# ── 扫描主流程 ─────────────────────────────────────────────────────────────
|
|
493
548
|
|
|
494
549
|
def _safe_extract(tf, dest):
|
|
495
|
-
"""提取 tarball(Python 3.8
|
|
550
|
+
"""提取 tarball(Python 3.8 兼容;拒绝路径穿越、链接与特殊文件)。"""
|
|
551
|
+
safe_members = []
|
|
552
|
+
root = Path(dest).resolve()
|
|
496
553
|
for member in tf.getmembers():
|
|
497
|
-
name = member.name
|
|
498
|
-
|
|
554
|
+
name = member.name.replace("\\", "/")
|
|
555
|
+
drive_path = len(name) >= 2 and name[1] == ":" and name[0].isalpha()
|
|
556
|
+
if (not name or name.startswith("/") or drive_path
|
|
557
|
+
or any(part == ".." for part in name.split("/"))):
|
|
499
558
|
raise ValueError("tarball 含危险路径: %s" % name)
|
|
500
|
-
|
|
559
|
+
if member.issym() or member.islnk() or member.isdev() or member.isfifo():
|
|
560
|
+
raise ValueError("tarball 含链接或特殊文件成员: %s" % member.name)
|
|
561
|
+
parts = [part for part in name.split("/") if part not in ("", ".")]
|
|
562
|
+
target = root.joinpath(*parts).resolve()
|
|
563
|
+
try:
|
|
564
|
+
target.relative_to(root)
|
|
565
|
+
except ValueError:
|
|
566
|
+
raise ValueError("tarball 成员越出临时目录: %s" % member.name)
|
|
567
|
+
member.mode = member.mode & 0o777
|
|
568
|
+
safe_members.append(member)
|
|
569
|
+
if sys.version_info >= (3, 12):
|
|
570
|
+
tf.extractall(dest, members=safe_members, filter="data")
|
|
571
|
+
else:
|
|
572
|
+
tf.extractall(dest, members=safe_members)
|
|
501
573
|
|
|
502
574
|
|
|
503
|
-
def
|
|
575
|
+
def _package_slug_from_root(root):
|
|
576
|
+
"""从包根 package.json 读取 npm 包名并归一为技能 slug。"""
|
|
577
|
+
try:
|
|
578
|
+
data = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
579
|
+
except (OSError, ValueError):
|
|
580
|
+
return None
|
|
581
|
+
name = data.get("name") if isinstance(data, dict) else None
|
|
582
|
+
if not isinstance(name, str):
|
|
583
|
+
return None
|
|
584
|
+
slug = name.strip().rsplit("/", 1)[-1]
|
|
585
|
+
if not slug or slug in (".", "..") or "/" in slug or "\\" in slug:
|
|
586
|
+
return None
|
|
587
|
+
return slug
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def resolve_name_hint(target, root, from_archive=False):
|
|
591
|
+
"""解析 STR-004 的期望名称:安装目录用目录名,npm 包输入用包名。"""
|
|
592
|
+
if from_archive:
|
|
593
|
+
package_root = root / "package"
|
|
594
|
+
if package_root.is_dir():
|
|
595
|
+
root = package_root
|
|
596
|
+
slug = _package_slug_from_root(root)
|
|
597
|
+
if slug:
|
|
598
|
+
return slug
|
|
599
|
+
return Path(target).name
|
|
600
|
+
if root.name == "package":
|
|
601
|
+
slug = _package_slug_from_root(root)
|
|
602
|
+
if slug:
|
|
603
|
+
return slug
|
|
604
|
+
return root.name
|
|
605
|
+
return root.name
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def scan_core(target, name_hint=None, auto_name_hint=False):
|
|
504
609
|
"""扫描目录/tarball,返回 (findings, counts, verdict, scan_meta)。"""
|
|
505
610
|
tmpdir = None
|
|
506
611
|
root = Path(target)
|
|
612
|
+
from_archive = False
|
|
507
613
|
if root.is_file() and str(root).lower().endswith((".tgz", ".tar.gz")):
|
|
614
|
+
from_archive = True
|
|
508
615
|
tmpdir = tempfile.mkdtemp(prefix="yotta-verify-")
|
|
509
616
|
with tarfile.open(str(root), "r:gz") as tf:
|
|
510
617
|
_safe_extract(tf, tmpdir)
|
|
@@ -514,6 +621,8 @@ def scan_core(target, name_hint=None):
|
|
|
514
621
|
import shutil
|
|
515
622
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
516
623
|
raise SystemExit("目标不存在或不是目录: %s" % target)
|
|
624
|
+
if auto_name_hint and name_hint is None:
|
|
625
|
+
name_hint = resolve_name_hint(target, root, from_archive=from_archive)
|
|
517
626
|
files = walk_files(root)
|
|
518
627
|
findings = scan_patterns(files)
|
|
519
628
|
check_skill_integrity(root, findings, name_hint)
|
|
@@ -748,12 +857,8 @@ def shields_url(verdict):
|
|
|
748
857
|
|
|
749
858
|
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
750
859
|
|
|
751
|
-
def _name_hint(target):
|
|
752
|
-
return Path(target).name
|
|
753
|
-
|
|
754
|
-
|
|
755
860
|
def cmd_scan(args):
|
|
756
|
-
findings, counts, verdict, meta = scan_core(args.path,
|
|
861
|
+
findings, counts, verdict, meta = scan_core(args.path, auto_name_hint=True)
|
|
757
862
|
code = exit_code_of(verdict)
|
|
758
863
|
# gate 模式
|
|
759
864
|
if args.max_severity:
|
|
@@ -801,7 +906,7 @@ def cmd_badge(args):
|
|
|
801
906
|
}
|
|
802
907
|
# 若给目录:先扫描拿 verdict;否则默认 SAFE
|
|
803
908
|
if args.path and Path(args.path).exists():
|
|
804
|
-
findings, counts, verdict, meta = scan_core(args.path,
|
|
909
|
+
findings, counts, verdict, meta = scan_core(args.path, auto_name_hint=True)
|
|
805
910
|
else:
|
|
806
911
|
verdict = VERDICT_SAFE
|
|
807
912
|
counts = {s: 0 for s in _SEVERITY_ORDER}
|
|
@@ -815,7 +920,7 @@ def cmd_badge(args):
|
|
|
815
920
|
|
|
816
921
|
|
|
817
922
|
def cmd_report(args):
|
|
818
|
-
findings, counts, verdict, meta = scan_core(args.path,
|
|
923
|
+
findings, counts, verdict, meta = scan_core(args.path, auto_name_hint=True)
|
|
819
924
|
if args.json:
|
|
820
925
|
print(render_json(findings, counts, verdict, meta))
|
|
821
926
|
else:
|
|
@@ -830,7 +935,7 @@ def cmd_report(args):
|
|
|
830
935
|
|
|
831
936
|
|
|
832
937
|
def cmd_gate(args):
|
|
833
|
-
findings, counts, verdict, meta = scan_core(args.path,
|
|
938
|
+
findings, counts, verdict, meta = scan_core(args.path, auto_name_hint=True)
|
|
834
939
|
code = exit_code_of(verdict)
|
|
835
940
|
limit = _SEVERITY_VALUE.get((args.max_severity or "medium").lower(), 1)
|
|
836
941
|
worst = _SEVERITY_VALUE.get(verdict_worst(findings), 0)
|
|
@@ -27,7 +27,7 @@ sys.path.insert(0, str(_HERE))
|
|
|
27
27
|
|
|
28
28
|
import yotta_verify as yv # noqa: E402
|
|
29
29
|
|
|
30
|
-
VERSION = "0.4.
|
|
30
|
+
VERSION = "0.4.3"
|
|
31
31
|
TOOL_NAME = "yotta-verify-mcp"
|
|
32
32
|
CN_NAME = "元信"
|
|
33
33
|
MCP_PROTOCOL_MODERN = "2026-07-28"
|
|
@@ -45,10 +45,6 @@ def _tool_error(message, extra=None):
|
|
|
45
45
|
"isError": True}
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def _name_hint(path):
|
|
49
|
-
return Path(path).name
|
|
50
|
-
|
|
51
|
-
|
|
52
48
|
def _resolve_target(target, tmp_dirs):
|
|
53
49
|
"""把 target 解析为本地路径/压缩包路径;npm 包名则 npm pack 到临时目录。"""
|
|
54
50
|
p = Path(target)
|
|
@@ -77,7 +73,7 @@ def _scan(target):
|
|
|
77
73
|
tmp_dirs = []
|
|
78
74
|
try:
|
|
79
75
|
path = _resolve_target(target, tmp_dirs)
|
|
80
|
-
findings, counts, verdict, meta = yv.scan_core(path,
|
|
76
|
+
findings, counts, verdict, meta = yv.scan_core(path, auto_name_hint=True)
|
|
81
77
|
return findings, counts, verdict, meta, None
|
|
82
78
|
except SystemExit as e:
|
|
83
79
|
return None, None, None, None, str(e)
|