@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.
@@ -12,7 +12,7 @@
12
12
  "authentication": "ON_INSTALL"
13
13
  },
14
14
  "category": "Security",
15
- "version": "0.4.1",
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.1",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yottameta/yotta-verify-mcp-plugin",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "YuanXinMCP (元信MCP) — pre-install security scanning for Agent skills, packaged as a YottaMeta Agent Plugin.",
5
5
  "license": "MIT",
6
6
  "keywords": [
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.1",
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.1
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.0"
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 or entry.name in SIGNATURE_DATA_FILES:
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 entry.name.startswith("test_") and entry.name.endswith(".py"):
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
- """目标目录是否含检测器签名文件(audit_rules/verify_rules/vetter_rules/hardening_rules)。"""
424
- for name in _DETECTOR_SIG_FILES:
425
- if (root / name).is_file() or (root / "scripts" / name).is_file():
426
- return True
427
- return False
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
- if name.startswith(("/", "\\")) or ".." in Path(name).parts:
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
- tf.extractall(dest)
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 scan_core(target, name_hint=None):
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, name_hint=_name_hint(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, name_hint=_name_hint(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, name_hint=_name_hint(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, name_hint=_name_hint(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.1"
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, name_hint=_name_hint(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)