skillverify 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,32 @@
1
+ """skillverify —— Agent Skill 生命周期验证套件(个人小团队版)。
2
+
3
+ 设计前提:≤2 人团队、宿主无关、纯标准库优先、强制 UTF-8。
4
+ 对外只暴露必要的入口,内部模块按职责分层:
5
+
6
+ encoding 强制 UTF-8 的 IO 辅助(根治 Windows GBK 崩溃)
7
+ spec 官方规范校验(对齐 skills-ref 0.1.1 行为)
8
+ lint 官方留白的补检(引用/预算/卫生/脚本契约/安全/依赖)
9
+ evalx 评测资产校验(官方 evals.json schema + 评测工作区产物)
10
+ trigger 触发评测资产校验(查询集与运行记录;官方不覆盖这一层)
11
+ review 语义评审:提示词目录、任务包、回写校验与汇总(三档执行器同一 schema)
12
+ material 为"评的不是技能文件本身"的提示词产出材料(描述 diff/修订信号/盲评/工作区数字)
13
+ discover 技能发现 + 声明式宿主适配(读 data/hosts.toml,代码内无宿主名)
14
+ watch 文件监听与增量复跑(纯标准库轮询)
15
+ deliver 交付门禁、交付记录与 git hook(`check` 只管日常,`deliver` 才管交付)
16
+ report 统一结果模型与人读/机读输出(含整库汇总)
17
+ cli/ 命令行入口(装配层;共用工具在 common.py,各命令在 commands.py)
18
+ 子命令:spec / lint / evals / review / discover / check / watch / deliver / hook
19
+
20
+ 数据文件(`data/`,宿主名与提示词都是数据,不是代码):
21
+ hosts.toml 宿主布局声明
22
+ review-prompts.json 语义评审提示词目录(旧体系 29 条 + 新增 W-17 指令注入;
23
+ 每条含 PASS/FAIL 判据与证据要求)
24
+
25
+ 版本前提:`discover` / `check` / `watch` / `deliver` / `hook` 需要 Python ≥3.11
26
+ (标准库 tomllib 解析 hosts.toml);`spec` / `lint` / `evals` 不依赖它,
27
+ 在更早版本仍可用(tomllib 为延迟导入)。
28
+ """
29
+
30
+ __version__ = "0.1.0"
31
+
32
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """支持 `python -m skillverify`(等价于 `python -m skillverify.cli`)。"""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
skillverify/audit.py ADDED
@@ -0,0 +1,476 @@
1
+ """audit —— 外来技能审计(供应链的个人子集)。
2
+
3
+ **场景**:技能是从网上/别人那里拿来的,用之前想知道"它是什么、能干什么、跟上次比变了没有"。
4
+ `spec`/`lint`/`evals` 技术上都能指向任意目录,但它们是**分阶段的技术报告**,
5
+ 不是"要不要用它"这个决定所需要的那张单子。审计把已有结论组合成一张单子,另加三件
6
+ 只有"外来技能"才需要的东西:**来源可追溯**、**内容指纹(下次比对)**、**名字近似提示**。
7
+
8
+ **明确不做**(写清楚,免得被当成"审计过了就安全"):
9
+ - 不做**行为审计**:不跑它的脚本、不模拟它的触发、不判断它是否真的会做坏事;
10
+ - 不做**信任分级**:机械层判定不了"信任"。审计单里留一栏给人填(来源与信任级别),
11
+ 工具只保证"同一份内容"与"内容变了"这两件事可被机械地认出来;
12
+ - 不做**依赖链哈希自校验**:技能间依赖的完整性交给 git;这里只对**技能内容**取指纹。
13
+
14
+ 判定口径与其它阶段一致:`FAIL` 阻断(例如发现脚本里有硬编码密钥,沿用 lint 的结论)、
15
+ `WARN` 需人工甄别(名字近似、内容与上次不同)、`INFO` 不适用(无 git、无脚本等)。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from .encoding import read_json, write_text
25
+ from .lint import lint_skill, load_context
26
+ from .lint import deps, scripts, security
27
+ from .lint.shared import LintContext
28
+ from .report import FAIL, INFO, PASS, SKIP, WARN, Report, Result, Rule
29
+ from .spec import check_spec
30
+ from .watch import fingerprint
31
+
32
+ #: 旧体系方案(语料已归档到仓库外,这里只作**文档级**引用,不依赖任何本地路径)
33
+ _LEGACY = "旧体系《Agent-Skill 生命周期验证方案》v1.3(供应链:安装前审计)"
34
+
35
+ #: 审计单的落点(相对 trace_dir)
36
+ AUDIT_DIR = "audit"
37
+
38
+ #: 运行台账(可选):人工记录运行期观测,替代自动遥测
39
+ RUN_LOG_NAME = "run-log.md"
40
+ #: 台账超过这么多天没更新就给个提醒(不是错误:技能可能就是不常用)
41
+ RUN_LOG_STALE_DAYS = 30
42
+
43
+
44
+ RULES: dict[str, Rule] = {
45
+ "AUDIT-001": Rule(
46
+ "AUDIT-001",
47
+ "已记录技能内容指纹(下次可比对)",
48
+ "HOUSE",
49
+ _LEGACY,
50
+ "审计记录里带内容指纹,才能回答「这与上次审计的是同一份吗」",
51
+ ),
52
+ "AUDIT-002": Rule(
53
+ "AUDIT-002",
54
+ "与上次审计相比内容一致",
55
+ "HOUSE",
56
+ _LEGACY,
57
+ "内容变了就要重看一遍:别人可以在你审计之后替换技能内容",
58
+ ),
59
+ "AUDIT-003": Rule(
60
+ "AUDIT-003",
61
+ "技能名与库内其它技能不构成近似(防混淆/仿冒)",
62
+ "HOUSE",
63
+ _LEGACY,
64
+ "名字近似会让 Agent 或人选错技能。确认是同一个技能的不同版本,就删掉多余的那份",
65
+ ),
66
+ "AUDIT-004": Rule(
67
+ "AUDIT-004",
68
+ "来源可追溯(git 远端与提交)",
69
+ "HOUSE",
70
+ _LEGACY,
71
+ "外来技能最好记下从哪来、哪一版。没有 git 信息时人工在审计单里写清来源",
72
+ ),
73
+ "AUDIT-005": Rule(
74
+ "AUDIT-005",
75
+ "能力清单:脚本 / 网络端点 / 破坏性操作 / 密钥 / 依赖",
76
+ "HOUSE",
77
+ _LEGACY,
78
+ "清单来自各族的**结构化扫描**;看到不认识的网络端点或破坏性操作就要人工确认。"
79
+ "清单为空说明这是纯文档技能(记 INFO),而不是「已审过」",
80
+ ),
81
+ "AUDIT-006": Rule(
82
+ "AUDIT-006",
83
+ f"运行台账里不许有「没处置的异常」且不许长期未更新(>{RUN_LOG_STALE_DAYS} 天)",
84
+ "HOUSE",
85
+ "旧体系 V9/V24 的个人版降级方案(人工 run-log.md 台账 + 每周回顾,不建自动埋点)",
86
+ f"把异常观察的处置写清楚(接受/整改 + 依据);长期没更新就说明原因或恢复观测。"
87
+ f"台账模板见 examples/{RUN_LOG_NAME},放在中央留痕目录而不是技能包里",
88
+ ),
89
+ }
90
+
91
+ #: 名字近似的判据:编辑距离不超过这个值,或去掉分隔符后完全相同
92
+ MAX_NAME_DISTANCE = 1
93
+
94
+
95
+ def check_run_log(trace_dir: Path, skill: str | None = None) -> Result:
96
+ """AUDIT-006:运行台账的机械部分——**异常有没有处置、台账是不是长期没更新**。
97
+
98
+ 来历:运行期观测(触发误报/漏报、脚本失败率、异常外部请求)没法自动化,旧体系的答案是
99
+ 「人工台账 + 每周回顾」。工具能做的只有一件事:**盯着这张表别变成摆设**——
100
+ 异常观察写了却没人处置、或者几周没动过,都要说出来。
101
+ 台账模板见 `examples/run-log.md`;放在中央留痕目录(不是技能包里)。
102
+ """
103
+ path = trace_dir / RUN_LOG_NAME
104
+ if not path.is_file():
105
+ return _res(RULES["AUDIT-006"], INFO,
106
+ f"未提供运行台账(可选):放一份 {RUN_LOG_NAME}(模板见 examples/)"
107
+ f"就会检查「异常是否处置」与「是否长期未更新」")
108
+ try:
109
+ text = path.read_text(encoding="utf-8")
110
+ except OSError as exc:
111
+ return _res(RULES["AUDIT-006"], WARN, f"{RUN_LOG_NAME} 读不出来:{exc}")
112
+
113
+ rows: list[tuple[int, list[str]]] = []
114
+ for lineno, line in enumerate(text.splitlines(), 1):
115
+ stripped = line.strip()
116
+ if not stripped.startswith("|"):
117
+ continue
118
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
119
+ if len(cells) < 8 or not cells[0] or set(cells[0]) <= set("-: "):
120
+ continue
121
+ if cells[0] == "日期":
122
+ continue
123
+ rows.append((lineno, cells))
124
+ if not rows:
125
+ return _res(RULES["AUDIT-006"], WARN,
126
+ f"{RUN_LOG_NAME} 里没有可解析的台账行(表头见 examples/run-log.md)")
127
+
128
+ empty_marks = ("", "—", "-", "待定", "无")
129
+ unhandled: list[str] = []
130
+ parsed_rows = 0
131
+ seen_dates: list[str] = []
132
+ for lineno, cells in rows:
133
+ date, row_skill, _task, trigger, _script, _cost, anomaly, action = cells[:8]
134
+ if skill and row_skill and skill not in row_skill:
135
+ continue
136
+ parsed_rows += 1
137
+ seen_dates.append(date)
138
+ if anomaly not in empty_marks and action in empty_marks:
139
+ unhandled.append(f"{RUN_LOG_NAME}:{lineno}(异常观察没写处置)")
140
+ if ("误触发" in trigger or "漏触发" in trigger) and action in empty_marks:
141
+ unhandled.append(f"{RUN_LOG_NAME}:{lineno}(触发问题没写处置)")
142
+
143
+ newest, unparsable = newest_ledger_date(seen_dates)
144
+ stale = ""
145
+ if newest is not None:
146
+ from datetime import date as _date
147
+
148
+ age = (_date.today() - newest).days
149
+ if age > RUN_LOG_STALE_DAYS:
150
+ stale = f"最近一条是 {newest.isoformat()}({age} 天前)"
151
+
152
+ if unhandled:
153
+ return _res(RULES["AUDIT-006"], WARN, "台账里有没处置的条目:" + ";".join(unhandled[:4]))
154
+ if stale:
155
+ return _res(RULES["AUDIT-006"], WARN,
156
+ f"运行台账 {stale}:超过 {RUN_LOG_STALE_DAYS} 天没更新——"
157
+ f"要么技能没人用(可接受),要么观测停了(该恢复)")
158
+ if unparsable:
159
+ # 有台账行、但一条日期都认不出来 → 陈旧检查**未执行**:必须说出来,
160
+ # 不能因为"没算出 stale"就当作通过(早先 strptime 抛错被吞掉,就是这个效果)。
161
+ head = ";".join(unparsable[:3]) + ("…" if len(unparsable) > 3 else "")
162
+ return _res(RULES["AUDIT-006"], WARN,
163
+ f"台账有 {parsed_rows} 条但日期都认不出来({head}):"
164
+ f"陈旧检查**未执行**——请按 `YYYY-MM-DD` 写日期(模板见 examples/{RUN_LOG_NAME})")
165
+ return _res(RULES["AUDIT-006"], PASS, f"运行台账 {parsed_rows} 条,异常均已处置")
166
+
167
+
168
+ def newest_ledger_date(texts: list[str]):
169
+ """返回 (最新日期, 认不出来的原始值列表)。
170
+
171
+ **独立成函数是为了能被直接断言**:这里曾经写成"把所有日期当字符串取最大,
172
+ 再 strptime 解析",于是 `"2026-9-5" > "2026-10-05"`(逐字符比 `9` 与 `1`),
173
+ 一条该报陈旧的台账会被静默判成新鲜;而"哪种写法更大"取决于今天是几月,
174
+ 靠集成用例很难稳定地复现——所以把这条逻辑做成纯函数,用固定输入断言。
175
+ """
176
+ parsed: list[tuple[object, str]] = []
177
+ bad: list[str] = []
178
+ for text in texts:
179
+ day = _parse_ledger_date(text)
180
+ if day is None:
181
+ bad.append(text)
182
+ else:
183
+ parsed.append((day, text))
184
+ if not parsed:
185
+ return None, bad
186
+ return max(parsed, key=lambda item: item[0])[0], bad
187
+
188
+
189
+ def _parse_ledger_date(text: str):
190
+ """台账里的日期:接受 `YYYY-MM-DD`、`YYYY-M-D`、`YYYY/M/D`;认不出返回 None。
191
+
192
+ **为什么不能直接拿字符串比大小**:本机实测过 `"2026-9-5" > "2026-10-05"` 为真
193
+ (逐字符比 `9` 与 `1`),于是一条该报"30 天没更新"的台账被静默判成新鲜。
194
+ """
195
+ from datetime import date as _date
196
+
197
+ cleaned = (text or "").strip().replace("/", "-")
198
+ parts = cleaned.split("-")
199
+ if len(parts) != 3:
200
+ return None
201
+ try:
202
+ return _date(int(parts[0]), int(parts[1]), int(parts[2]))
203
+ except ValueError:
204
+ return None
205
+
206
+
207
+ def _res(rule: Rule, status: str, evidence: str = "") -> Result:
208
+ return Result(rid=rule.rid, title=rule.title, status=status, level=rule.level,
209
+ evidence=evidence,
210
+ remediation=rule.remediation if status in (FAIL, WARN) else "")
211
+
212
+
213
+ # --------------------------------------------------------------------------- #
214
+ # 名字近似
215
+ # --------------------------------------------------------------------------- #
216
+
217
+
218
+ def _normalized_name(name: str) -> str:
219
+ return name.lower().replace("-", "").replace("_", "").replace(".", "")
220
+
221
+
222
+ def edit_distance(left: str, right: str) -> int:
223
+ """Levenshtein 距离(短字符串够用;纯标准库)。"""
224
+ if left == right:
225
+ return 0
226
+ if not left:
227
+ return len(right)
228
+ if not right:
229
+ return len(left)
230
+ previous = list(range(len(right) + 1))
231
+ for i, lch in enumerate(left, 1):
232
+ current = [i]
233
+ for j, rch in enumerate(right, 1):
234
+ current.append(min(previous[j] + 1, current[j - 1] + 1,
235
+ previous[j - 1] + (lch != rch)))
236
+ previous = current
237
+ return previous[-1]
238
+
239
+
240
+ def name_lookalikes(name: str, others: list[str]) -> list[tuple[str, int]]:
241
+ """找出与 `name` 近似的其它技能名(区分大小写/分隔符的"同名"也算)。"""
242
+ target = _normalized_name(name)
243
+ hits: list[tuple[str, int]] = []
244
+ for other in others:
245
+ if other == name:
246
+ continue
247
+ candidate = _normalized_name(other)
248
+ if candidate == target:
249
+ hits.append((other, 0))
250
+ continue
251
+ distance = edit_distance(target, candidate)
252
+ if distance <= MAX_NAME_DISTANCE:
253
+ hits.append((other, distance))
254
+ return sorted(hits, key=lambda item: (item[1], item[0]))
255
+
256
+
257
+ # --------------------------------------------------------------------------- #
258
+ # 记录(指纹比对)
259
+ # --------------------------------------------------------------------------- #
260
+
261
+
262
+ @dataclass
263
+ class AuditRecord:
264
+ """一次审计的留痕。"""
265
+
266
+ skill: str
267
+ path: str
268
+ fingerprint: str
269
+ verdict: str
270
+ generated_at: str
271
+ source: dict = field(default_factory=dict)
272
+ capabilities: dict = field(default_factory=dict)
273
+
274
+ def to_dict(self) -> dict:
275
+ return {
276
+ "skill": self.skill,
277
+ "path": self.path,
278
+ "fingerprint": self.fingerprint,
279
+ "verdict": self.verdict,
280
+ "generated_at": self.generated_at,
281
+ "source": self.source,
282
+ "capabilities": self.capabilities,
283
+ }
284
+
285
+
286
+ def load_previous(trace_dir: Path, skill: str) -> dict | None:
287
+ path = trace_dir / AUDIT_DIR / f"{skill}.json"
288
+ if not path.is_file():
289
+ return None
290
+ data, error = read_json(path)
291
+ return data if isinstance(data, dict) and not error else None
292
+
293
+
294
+ def write_record(trace_dir: Path, record: AuditRecord, markdown: str) -> list[Path]:
295
+ """写审计单:`<trace_dir>/audit/<技能名>.md`(人读)与 `.json`(机器比对)。"""
296
+ out = trace_dir / AUDIT_DIR
297
+ out.mkdir(parents=True, exist_ok=True)
298
+ json_path = out / f"{record.skill}.json"
299
+ md_path = out / f"{record.skill}.md"
300
+ write_text(json_path, json.dumps(record.to_dict(), ensure_ascii=False, indent=2) + "\n")
301
+ write_text(md_path, markdown if markdown.endswith("\n") else markdown + "\n")
302
+ return [json_path, md_path]
303
+
304
+
305
+ # --------------------------------------------------------------------------- #
306
+ # 能力清单(把各阶段结论汇总成一节)
307
+ # --------------------------------------------------------------------------- #
308
+
309
+
310
+ def capabilities(ctx: LintContext | None) -> dict:
311
+ """从各族的**结构化事实**汇总「这个技能能干什么」;`ctx is None` → 空字典。
312
+
313
+ 早先这里是 `r.evidence.split(":", 1)[-1]`——从**证据文本**里抠数据。那正是本项目在
314
+ deliver 里批评并改掉的反模式:措辞一改就静默解析出垃圾。而且映射还错了一层:
315
+ 「网络端点」取自 `SEC-006`(URL 携带凭据参数),真正的端点扫描是 `SEC-007`。
316
+ 现在事实由各族的 `facts(ctx)` 提供,两边共用同一处扫描实现。
317
+ """
318
+ if ctx is None:
319
+ return {}
320
+ security_facts = security.facts(ctx)
321
+ script_facts = scripts.facts(ctx)
322
+ dep_facts = deps.facts(ctx)
323
+ return {
324
+ "脚本": list(script_facts.scripts),
325
+ "网络端点": list(security_facts.endpoints),
326
+ "破坏性/有状态操作": [f"{rp}: {'、'.join(kinds)}"
327
+ for rp, kinds in script_facts.destructive],
328
+ "疑似硬编码密钥": list(security_facts.strong_secrets),
329
+ "外部依赖": list(dep_facts.third_party) + list(dep_facts.inline),
330
+ }
331
+
332
+
333
+ def git_source(skill_dir: Path) -> dict:
334
+ """尽力找出来源(远端与当前提交)。找不到就空着,由人填写。"""
335
+ import subprocess
336
+
337
+ source: dict = {}
338
+ try:
339
+ inside = subprocess.run(["git", "-C", str(skill_dir), "rev-parse", "--show-toplevel"],
340
+ capture_output=True, text=True, timeout=10)
341
+ if inside.returncode == 0:
342
+ root = inside.stdout.strip()
343
+ source["仓库"] = root
344
+ for key, args in (("远端", ["git", "-C", root, "remote", "get-url", "origin"]),
345
+ ("提交", ["git", "-C", root, "rev-parse", "HEAD"])):
346
+ proc = subprocess.run(args, capture_output=True, text=True, timeout=10)
347
+ if proc.returncode == 0 and proc.stdout.strip():
348
+ source[key] = proc.stdout.strip()
349
+ except (OSError, subprocess.SubprocessError):
350
+ pass
351
+ return source
352
+
353
+
354
+ # --------------------------------------------------------------------------- #
355
+ # 主入口
356
+ # --------------------------------------------------------------------------- #
357
+
358
+
359
+ def audit_skill(skill_dir: Path, *, trace_dir: Path, others: list[str] | None = None,
360
+ run_scripts: bool = False, now: str | None = None) -> tuple[Report, AuditRecord]:
361
+ """审计一个技能:组合 spec + lint 结论,并补上来源/指纹/名字近似/能力清单。"""
362
+ from datetime import datetime
363
+
364
+ skill_dir = skill_dir.resolve()
365
+ report = Report(target=str(skill_dir), stage="audit")
366
+ doc, spec_report = check_spec(skill_dir)
367
+ ctx, _lint_doc = load_context(skill_dir, run_scripts=run_scripts)
368
+ lint_report = lint_skill(skill_dir, run_scripts=run_scripts)
369
+ for res in spec_report.results:
370
+ report.add(res)
371
+ for res in lint_report.results:
372
+ report.add(res)
373
+
374
+ name = doc.name or skill_dir.name
375
+ digest = fingerprint(skill_dir)
376
+ report.add(_res(RULES["AUDIT-001"], PASS, f"{name} 内容指纹 {digest[:16]}(完整值见记录)"))
377
+ # 运行台账的机械部分:异常有没有处置、台账是不是长期没更新(AUDIT-006)
378
+ report.add(check_run_log(trace_dir, name))
379
+
380
+ previous = load_previous(trace_dir, name)
381
+ if previous is None:
382
+ report.add(_res(RULES["AUDIT-002"], INFO, "这是第一次审计该技能,没有可比的上一份"))
383
+ elif previous.get("fingerprint") == digest:
384
+ report.add(_res(RULES["AUDIT-002"], PASS,
385
+ f"与上次审计({previous.get('generated_at', '?')})内容一致"))
386
+ else:
387
+ report.add(_res(RULES["AUDIT-002"], WARN,
388
+ f"内容与上次审计({previous.get('generated_at', '?')})**不同**:"
389
+ f"{str(previous.get('fingerprint'))[:16]} → {digest[:16]};"
390
+ f"别人可以在你审计之后替换内容,请重新过一遍"))
391
+
392
+ lookalikes = name_lookalikes(name, list(others or []))
393
+ if lookalikes:
394
+ detail = "、".join(f"{other}(距离 {distance})" for other, distance in lookalikes[:5])
395
+ report.add(_res(RULES["AUDIT-003"], WARN, f"与库内技能名近似:{detail}"))
396
+ elif others:
397
+ report.add(_res(RULES["AUDIT-003"], PASS, f"与库内 {len(others)} 个技能名无近似"))
398
+ else:
399
+ report.add(_res(RULES["AUDIT-003"], INFO, "库内没有其它技能可比对名字"))
400
+
401
+ source = git_source(skill_dir)
402
+ if source.get("远端") or source.get("提交"):
403
+ report.add(_res(RULES["AUDIT-004"], PASS,
404
+ "来源:" + ";".join(f"{k}={v}" for k, v in source.items())))
405
+ else:
406
+ report.add(_res(RULES["AUDIT-004"], INFO,
407
+ "未找到 git 来源信息(外来技能常见);请在审计单里人工写明来源"))
408
+
409
+ caps = capabilities(ctx)
410
+ summary = ";".join(f"{key} {len(value)} 项" + (f"({', '.join(value[:3])})" if value else "")
411
+ for key, value in caps.items())
412
+ if ctx is None:
413
+ # 前置检查失败 → 能力清单**没生成**:记 SKIP(覆盖有洞),不许假装拿到了事实
414
+ report.add(_res(RULES["AUDIT-005"], SKIP,
415
+ "未执行:lint 前置检查失败(先修好 spec 阶段的问题)"))
416
+ elif any(caps.values()):
417
+ report.add(_res(RULES["AUDIT-005"], PASS, summary))
418
+ else:
419
+ # 全空是**有意义**的结论(纯文档技能),但不该记 PASS——"清单已生成"是恒真的,
420
+ # 恒真的判定等于没判定(本项目的老教训)。
421
+ report.add(_res(RULES["AUDIT-005"], INFO,
422
+ "不适用:纯文档技能,没有脚本/网络端点/破坏性操作/依赖可列"))
423
+
424
+ stamp = now or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
425
+ record = AuditRecord(skill=name, path=str(skill_dir), fingerprint=digest,
426
+ verdict=report.verdict(), generated_at=stamp,
427
+ source=source, capabilities=caps)
428
+ return report, record
429
+
430
+
431
+ def audit_markdown(record: AuditRecord, report: Report, lookalikes_note: str = "") -> str:
432
+ """生成审计单(人读)。含一栏留给人填的"来源与信任级别"。"""
433
+ lines = [
434
+ f"# 审计单:{record.skill}",
435
+ "",
436
+ f"- 路径:`{record.path}`",
437
+ f"- 时间:{record.generated_at}",
438
+ f"- 结论:**{record.verdict}**({report.counts()})",
439
+ f"- 内容指纹:`{record.fingerprint}`",
440
+ f"- 来源(自动):{record.source or '未自动获取到'}",
441
+ "",
442
+ "> 这张单子是**机械结论的汇总**,不是安全背书:本工具不跑技能脚本、不模拟触发,",
443
+ "> 也不判断技能的真实行为。它只保证「这是同一份内容」与「内容变了」可被认出来。",
444
+ "",
445
+ "## 来源与信任级别(人工填写)",
446
+ "",
447
+ "- 从哪来:",
448
+ "- 信任级别(自研 / 已读过的第三方 / 未读过的第三方):",
449
+ "- 复核人 / 日期:",
450
+ "",
451
+ "## 能力清单(自动汇总)",
452
+ "",
453
+ ]
454
+ for key, value in record.capabilities.items():
455
+ lines.append(f"- **{key}**({len(value)}):" + ("、".join(f"`{v}`" for v in value[:8])
456
+ if value else "无"))
457
+ lines += [
458
+ "",
459
+ "> 各类的**口径**(避免误读):网络端点 = **代码文件**里出现、且未在 frontmatter 声明的主机;",
460
+ "> 破坏性/有状态操作 = 脚本里的删除/覆盖/移动等形态(含是否声明防护旗标);",
461
+ "> 疑似密钥 = 高置信度特征命中(如私钥头、云厂商密钥前缀);",
462
+ "> 外部依赖 = Python 脚本导入的第三方模块。它们都来自**结构化扫描**,不是从报告文字里猜的。",
463
+ ]
464
+ lines += ["", "## 判定明细", "", "| 规则 | 判级 | 证据 |", "|---|---|---|"]
465
+ for res in report.sorted_results():
466
+ if res.status == INFO:
467
+ continue
468
+ lines.append(f"| `{res.rid}` {res.title} | {res.status} | {res.evidence[:160]} |")
469
+ if lookalikes_note:
470
+ lines += ["", lookalikes_note]
471
+ return "\n".join(lines)
472
+
473
+
474
+ __all__ = ["AUDIT_DIR", "MAX_NAME_DISTANCE", "RULES", "AuditRecord", "audit_markdown",
475
+ "audit_skill", "capabilities", "edit_distance", "git_source", "load_previous",
476
+ "name_lookalikes", "write_record"]