@caesarloo/dsh-skill-audit 0.1.2 → 0.2.1

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,596 @@
1
+ #Requires -Version 5.1
2
+ <#
3
+ .SYNOPSIS
4
+ DSH 技能静态审核(skill-audit)—— 对技能目录做确定性检查,输出人读报告或 JSON。
5
+
6
+ .DESCRIPTION
7
+ 审核项(代码即判据,避免"看着像没问题"):
8
+ F1 frontmatter 契约:name / description 必填;name 须 kebab-case 且与目录名一致;
9
+ whenToUse 建议存在;version / last_updated 建议存在 → fail / warn
10
+ S1 脚本可用性:技能内所有 .ps1 必须是 UTF-8 with BOM,且能被
11
+ PowerShell 5.1 解析(errs=0)—— 无 BOM 的中文脚本在 5.1 下按 GBK
12
+ 解码会解析失败 → fail
13
+ R1 引用完整性:SKILL.md 里 `scripts/xxx.ps1` 这类相对路径引用必须真实存在;
14
+ 子目录在而文件缺 = fail(真断裂);引用落在**别的技能**里 = warn(跨技能引用,
15
+ 应改为点名技能名 + related_skills);都不在 = warn(运行时生成/外部来源) → fail / warn
16
+ R2 脚本被引用:技能内脚本未被 SKILL.md 提及 → info
17
+ F2 依赖声明:related_skills 的自依赖 / 重复项(**存在性不在此判定**:技能名可由
18
+ 插件运行时注册、磁盘无 SKILL.md,静态检查必误报 → 归插件侧) → warn
19
+ E1 审核扩展:audit_extension 声明的扩展缺失 / 无 BOM / 解析失败 / 抛错 → 记在
20
+ 声明者身上且只报一次(坏扩展立即停用) → warn
21
+ M1 豁免标记契约:audit:ignore 标记缺代码或理由不足 8 字符 → warn
22
+ X1 敏感信息:口令 / token / 私钥特征串 → fail
23
+ X2 机器专属硬编码路径(C:\Users\<具体用户名>) → info
24
+ X3 危险命令(递归强删、注册表删除等) → info
25
+
26
+ 例外豁免(详见 Get-AuditWaivers):
27
+ SKILL.md 里的 <!-- audit:ignore <代码> <目标> <理由,至少 8 字符> -->
28
+ 可跳过 warn / info 级误报(逐条、带理由);fail 级**不可**豁免,判据本身不放宽。
29
+
30
+ 退出码:0 = 无 fail;1 = 至少一项 fail(须修复);2 = 参数/路径错误。
31
+ 本脚本自身必须带 UTF-8 BOM,并由 powershell(5.1) 或 pwsh 执行。
32
+
33
+ .PARAMETER Skill
34
+ 技能名(可多个,逗号分隔);缺省审核 SkillsRoot 下全部技能。
35
+
36
+ .PARAMETER SkillsRoot
37
+ 技能根目录;缺省 $env:DSH_HOME\skills,再回落 ~\.dsh\skills。
38
+
39
+ .PARAMETER Json
40
+ 以 JSON 输出(供钩子/机器消费)。
41
+
42
+ .PARAMETER NoLog
43
+ 不写审核日志(默认写入 <DSH_HOME>\vet\skill-audits\)。
44
+
45
+ .EXAMPLE
46
+ powershell -NoProfile -ExecutionPolicy Bypass -File audit-skills.ps1
47
+ .EXAMPLE
48
+ powershell -NoProfile -ExecutionPolicy Bypass -File audit-skills.ps1 -Skill my-skill-a,my-skill-b -Json
49
+ #>
50
+ [CmdletBinding()]
51
+ param(
52
+ [string[]]$Skill,
53
+ [string]$SkillsRoot,
54
+ [switch]$Json,
55
+ [switch]$NoLog
56
+ )
57
+
58
+ # 审核日志是确定性检查的留痕(本机目录,不进同步面);只保留最近 40 份避免堆积。
59
+ $ErrorActionPreference = 'Continue'
60
+
61
+ # -Skill 的逗号兼容:`powershell -File script.ps1 -Skill a,b` 在 -File 模式下**不会**把逗号解析成
62
+ # 数组(得到单个 "a,b" 字符串),而 dsh-skill-audit 插件正是以子进程 argv 方式传入 skills.join(',')——
63
+ # 不兼容会让「多技能定向审核」直接报「技能不存在:a,b」(2026-09-17 实测)。这里统一按逗号拆分,
64
+ # 使 CLI(`.\audit-skills.ps1 -Skill a,b`)与插件子进程调用的行为一致。
65
+ if ($Skill) {
66
+ $Skill = @($Skill | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
67
+ }
68
+
69
+ # stdout 统一 UTF-8:5.1 默认按控制台代码页(GBK)写输出,消费方(插件用 Node 按 UTF-8 解码、
70
+ # 钩子按 UTF-8 读)会拿到乱码。显式设置后,本脚本在任何宿主里的输出编码都一致。
71
+ try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) } catch { }
72
+
73
+ function Get-DshHome {
74
+ if ($env:DSH_HOME) { return $env:DSH_HOME }
75
+ return (Join-Path $env:USERPROFILE '.dsh')
76
+ }
77
+
78
+ if (-not $SkillsRoot) { $SkillsRoot = Join-Path (Get-DshHome) 'skills' }
79
+ if (-not (Test-Path -LiteralPath $SkillsRoot)) {
80
+ Write-Error "技能根目录不存在:$SkillsRoot"
81
+ exit 2
82
+ }
83
+ $SkillsRoot = (Resolve-Path -LiteralPath $SkillsRoot).Path
84
+
85
+ # —— 审核项实现 ————————————————————————————————————————————————
86
+
87
+ function Get-FrontmatterText {
88
+ param([string]$Text)
89
+ if (-not $Text.StartsWith('---')) { return $null }
90
+ $end = $Text.IndexOf("`n---", 3)
91
+ if ($end -lt 0) { return $null }
92
+ return $Text.Substring(3, $end - 3)
93
+ }
94
+
95
+ function Get-FrontmatterField {
96
+ param([string]$Frontmatter, [string]$Key)
97
+ if (-not $Frontmatter) { return $null }
98
+ $m = [regex]::Match($Frontmatter, "(?m)^$([regex]::Escape($Key))\s*:\s*(.+?)\s*$")
99
+ if (-not $m.Success) { return $null }
100
+ $v = $m.Groups[1].Value.Trim()
101
+ $v = $v.Trim('"').Trim("'")
102
+ return $v
103
+ }
104
+
105
+ function Test-KebabCase {
106
+ param([string]$Name)
107
+ return ($Name -match '^[a-z0-9]+(-[a-z0-9]+)*$')
108
+ }
109
+
110
+ function Test-Utf8Bom {
111
+ param([string]$Path)
112
+ $b = [System.IO.File]::ReadAllBytes($Path)
113
+ return ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF)
114
+ }
115
+
116
+ function Test-PsParse {
117
+ param([string]$Path)
118
+ $errs = $null
119
+ [void][System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$errs)
120
+ return @($errs).Count
121
+ }
122
+
123
+ # 敏感串:特征明确才报,避免把文档里的占位符(<token>、sk-xxx)当泄漏。
124
+ $script:SecretPatterns = @(
125
+ @{ Name = 'OpenAI 风格 key'; Pattern = 'sk-[A-Za-z0-9_\-]{20,}' },
126
+ @{ Name = 'GitHub token'; Pattern = 'gh[pousr]_[A-Za-z0-9]{30,}' },
127
+ @{ Name = 'npm token'; Pattern = 'npm_[A-Za-z0-9]{30,}' },
128
+ @{ Name = 'AWS access key'; Pattern = 'AKIA[0-9A-Z]{16}' },
129
+ @{ Name = '私钥块'; Pattern = '-----BEGIN [A-Z ]*PRIVATE KEY-----' },
130
+ @{ Name = '明文口令赋值'; Pattern = '(?i)(password|passwd|pwd)\s*[:=]\s*[''"][^''"<>\s]{8,}[''"]' },
131
+ @{ Name = '明文 token 赋值'; Pattern = '(?i)(token|secret|apikey|api_key|accesskey)\s*[:=]\s*[''"][A-Za-z0-9_\-]{16,}[''"]' }
132
+ )
133
+
134
+ function Test-Secrets {
135
+ param([string]$Text)
136
+ $hits = @()
137
+ foreach ($p in $script:SecretPatterns) {
138
+ if ([regex]::IsMatch($Text, $p.Pattern)) { $hits += $p.Name }
139
+ }
140
+ return $hits
141
+ }
142
+
143
+ # 机器专属路径:具体用户名写死(通用写法 $env:USERPROFILE / %USERPROFILE% / ~ 不算)
144
+ function Get-MachinePaths {
145
+ param([string]$Text)
146
+ $hits = @()
147
+ foreach ($m in [regex]::Matches($Text, '(?i)C:\\+Users\\+([A-Za-z0-9_.\-]+)')) {
148
+ $u = $m.Groups[1].Value
149
+ if ($u -ne 'Public' -and $u -notmatch '^%' -and $u -ne '<user>') { $hits += $m.Value }
150
+ }
151
+ return @($hits | Sort-Object -Unique)
152
+ }
153
+
154
+ $script:DangerPatterns = @(
155
+ @{ Name = '递归强删'; Pattern = '(?i)Remove-Item[^\r\n]*-Recurse[^\r\n]*-Force' },
156
+ @{ Name = 'rm -rf'; Pattern = '(?i)\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r' },
157
+ @{ Name = '注册表删除'; Pattern = '(?i)reg\s+delete' },
158
+ @{ Name = '磁盘格式化'; Pattern = '(?i)Format-Volume|format\s+[A-Z]:' }
159
+ )
160
+
161
+ function Get-DangerHits {
162
+ param([string]$Text)
163
+ $hits = @()
164
+ foreach ($p in $script:DangerPatterns) {
165
+ if ([regex]::IsMatch($Text, $p.Pattern)) { $hits += $p.Name }
166
+ }
167
+ return $hits
168
+ }
169
+
170
+ # SKILL.md 中的相对资源引用(scripts\… / scripts/… / ./scripts/…)
171
+ function Get-RelRefs {
172
+ param([string]$Text)
173
+ $refs = @()
174
+ foreach ($m in [regex]::Matches($Text, '(?i)(?<![\w\\/])(?:\.?[\\/])?(scripts|assets|references)[\\/]([A-Za-z0-9_.\-]+)')) {
175
+ $refs += ($m.Groups[1].Value + '\' + $m.Groups[2].Value)
176
+ }
177
+ return @($refs | Sort-Object -Unique)
178
+ }
179
+
180
+ # 跨技能引用识别:某相对引用在本技能里没有、但在**同根下的另一个技能**里存在 —— 这不是
181
+ # "引用断裂",而是"抄了别的技能的内部路径"(耦合)。判据由此从"文件在不在"升级为"该不该由你
182
+ # 来指这个路径",并直接给出确定修法:正文点名技能名 + frontmatter 登记 related_skills。
183
+ # (2026-09-17:用户定下"技能间引用一律解耦"后新增,见 skill-audit §5.1。)
184
+ function Find-RefOwner {
185
+ param([string]$Ref, [string]$SelfSkill)
186
+ foreach ($d in @(Get-ChildItem -LiteralPath $SkillsRoot -Directory -ErrorAction SilentlyContinue)) {
187
+ if ($d.Name -eq $SelfSkill) { continue }
188
+ if (Test-Path -LiteralPath (Join-Path $d.FullName $Ref)) { return $d.Name }
189
+ }
190
+ return $null
191
+ }
192
+
193
+ # related_skills 声明解析(frontmatter 里 metadata.hermes.related_skills: [a, b])
194
+ function Get-RelatedSkills {
195
+ param([string]$Frontmatter)
196
+ if (-not $Frontmatter) { return @() }
197
+ $m = [regex]::Match($Frontmatter, '(?m)^\s*related_skills\s*:\s*\[(.*?)\]')
198
+ if (-not $m.Success) { return @() }
199
+ return @($m.Groups[1].Value -split ',' | ForEach-Object { $_.Trim().Trim('"').Trim("'") } | Where-Object { $_ })
200
+ }
201
+
202
+ function New-Finding {
203
+ param([string]$Code, [string]$Level, [string]$Message, [string]$File, [string]$Target = '', [string]$Source = '')
204
+ return [pscustomobject]@{ code = $Code; level = $Level; message = $Message; file = $File; target = $Target; source = $Source }
205
+ }
206
+
207
+ # —— 例外豁免(audit:ignore 标记)——
208
+ # 这不是"放宽判据":判据强度一律不变(真断裂依旧 fail),只是让**技能自己就地声明**某条判据不适用。
209
+ # 形式(写在 SKILL.md 里):<!-- audit:ignore <代码> <目标> <理由,至少 8 字符> -->
210
+ # 代码:F1 / S1 / R1 / X1 / X2 / X3 之一,或 *(全部)
211
+ # 目标:R1 用相对引用(references/core.md,斜杠两种写法等价);其它代码用文件名(SKILL.md 或脚本名)
212
+ # 为什么要它:skill-audit §五 早已要求"误报就在技能正文写明例外与理由",但此前写了并不生效——
213
+ # 规则与实现脱节,结果只剩两条歪路:要么忍受常驻噪音(久了审核被无视),要么改正文措辞回避正则
214
+ # (那是掩盖检测,更糟)。本机制把"写明例外"变成可执行的唯一正解:逐条、带理由、随技能进 git 可审计。
215
+ function Get-RefKey {
216
+ param([string]$Ref)
217
+ if (-not $Ref) { return '' }
218
+ return (($Ref -replace '/', '\').TrimStart('.').TrimStart('\').ToLowerInvariant())
219
+ }
220
+
221
+ function Get-AuditWaivers {
222
+ param([string]$Text)
223
+ $waivers = @(); $bad = @()
224
+ if (-not $Text) { return @{ waivers = @(); bad = @() } }
225
+ foreach ($m in [regex]::Matches($Text, '(?s)<!--\s*audit:ignore\s+(?<codes>[A-Za-z0-9_*\s,]+?)\s+(?<target>\S+)\s+(?<reason>.+?)\s*-->')) {
226
+ $codes = @($m.Groups['codes'].Value -split '[,\s]+' | Where-Object { $_ } | ForEach-Object { $_.ToUpperInvariant() })
227
+ $reason = $m.Groups['reason'].Value.Trim()
228
+ if ($codes.Count -eq 0 -or $reason.Length -lt 8) { $bad += $m.Value; continue }
229
+ $waivers += [pscustomobject]@{ codes = $codes; target = (Get-RefKey $m.Groups['target'].Value); reason = $reason }
230
+ }
231
+ return @{ waivers = @($waivers); bad = @($bad) }
232
+ }
233
+
234
+ function Test-Waived {
235
+ param($Finding, $Waivers)
236
+ # fail 级不可豁免:§五「fail 必须修」是硬线,豁免只用来消解 warn/info 的误报,
237
+ # 否则"真断裂仍是 fail"这条保证会被一个标记悄悄绕过。
238
+ if ($Finding.level -eq 'fail') { return $false }
239
+ $t = if ($Finding.target) { Get-RefKey $Finding.target } else { Get-RefKey $Finding.file }
240
+ foreach ($w in $Waivers) {
241
+ if ($w.codes -notcontains '*' -and $w.codes -notcontains $Finding.code) { continue }
242
+ if ($w.target -and $w.target -eq $t) { return $true }
243
+ }
244
+ return $false
245
+ }
246
+
247
+ function Invoke-SkillAudit {
248
+ param([string]$SkillDir, $Extensions = @(), $ExtErrors = @{}, $ExtRuntime = @{})
249
+
250
+ $name = Split-Path $SkillDir -Leaf
251
+ $findings = New-Object System.Collections.ArrayList
252
+ $skillMd = Join-Path $SkillDir 'SKILL.md'
253
+
254
+ # —— F1 frontmatter 契约 ——
255
+ $text = ''
256
+ if (-not (Test-Path -LiteralPath $skillMd)) {
257
+ [void]$findings.Add((New-Finding 'F1' 'fail' '缺少 SKILL.md(技能目录必须包含 SKILL.md)' $name))
258
+ }
259
+ else {
260
+ $text = [System.IO.File]::ReadAllText($skillMd)
261
+ $fm = Get-FrontmatterText $text
262
+ if (-not $fm) {
263
+ [void]$findings.Add((New-Finding 'F1' 'fail' 'SKILL.md 缺少 YAML frontmatter(--- 块)' 'SKILL.md'))
264
+ }
265
+ else {
266
+ $fname = Get-FrontmatterField $fm 'name'
267
+ $fdesc = Get-FrontmatterField $fm 'description'
268
+ $fwhen = Get-FrontmatterField $fm 'whenToUse'
269
+ $fver = Get-FrontmatterField $fm 'version'
270
+ $fupd = Get-FrontmatterField $fm 'last_updated'
271
+
272
+ if (-not $fname) {
273
+ [void]$findings.Add((New-Finding 'F1' 'fail' 'frontmatter 缺少必填字段 name' 'SKILL.md'))
274
+ }
275
+ else {
276
+ if (-not (Test-KebabCase $fname)) {
277
+ [void]$findings.Add((New-Finding 'F1' 'fail' "name 必须是 kebab-case:$fname" 'SKILL.md'))
278
+ }
279
+ if ($fname -ne $name) {
280
+ [void]$findings.Add((New-Finding 'F1' 'fail' "frontmatter name($fname) 与目录名($name) 不一致" 'SKILL.md'))
281
+ }
282
+ }
283
+ if (-not $fdesc) {
284
+ [void]$findings.Add((New-Finding 'F1' 'fail' 'frontmatter 缺少必填字段 description(模型据此决定是否加载)' 'SKILL.md'))
285
+ }
286
+ elseif ($fdesc.Length -lt 40) {
287
+ [void]$findings.Add((New-Finding 'F1' 'warn' "description 过短($($fdesc.Length) 字符),建议写清触发场景与触发词" 'SKILL.md'))
288
+ }
289
+ if (-not $fwhen) {
290
+ [void]$findings.Add((New-Finding 'F1' 'warn' '建议补 whenToUse:写清什么情况下该加载本技能' 'SKILL.md'))
291
+ }
292
+ if (-not $fver) {
293
+ [void]$findings.Add((New-Finding 'F1' 'warn' '建议补 version:便于多机同步时判断新旧' 'SKILL.md'))
294
+ }
295
+ if (-not $fupd) {
296
+ [void]$findings.Add((New-Finding 'F1' 'warn' '建议补 last_updated:便于判断内容是否过期' 'SKILL.md'))
297
+ }
298
+
299
+ # —— F2 依赖声明完整性(只做「文件系统可判定」的部分)——
300
+ # 为什么不查"指向的技能是否存在":DSH 允许**插件在运行时注册技能**——磁盘上没有 SKILL.md,
301
+ # 只在进程内的技能目录里可见。也就是说"技能名"的解析域是**运行时技能目录**,不是文件
302
+ # 系统;静态脚本查不到,查了必然误报(此检查一上线就误报过:某运行时注册的技能被当成
303
+ # 悬空声明)。故这里只报**一定错**的两种:自依赖、重复项。
304
+ # 「悬空声明」检测需要活的技能目录 → 属**插件侧**能力(见 SKILL.md §2.1 的分层表)。
305
+ $rs = @(Get-RelatedSkills $fm)
306
+ if ($rs -contains $name) {
307
+ [void]$findings.Add((New-Finding 'F2' 'warn' "related_skills 含技能自身($name)——自依赖无意义" 'SKILL.md' $name))
308
+ }
309
+ foreach ($dj in @($rs | Group-Object | Where-Object { $_.Count -gt 1 } | Select-Object -ExpandProperty Name)) {
310
+ [void]$findings.Add((New-Finding 'F2' 'warn' "related_skills 存在重复项:$dj" 'SKILL.md' $dj))
311
+ }
312
+ }
313
+ }
314
+
315
+ # —— S1 脚本可用性(BOM + 5.1 解析)——
316
+ $ps1 = @(Get-ChildItem -LiteralPath $SkillDir -Recurse -File -Filter *.ps1 -ErrorAction SilentlyContinue)
317
+ foreach ($f in $ps1) {
318
+ if (-not (Test-Utf8Bom $f.FullName)) {
319
+ [void]$findings.Add((New-Finding 'S1' 'fail' "脚本缺少 UTF-8 BOM(5.1 下中文会按 GBK 解码而解析失败):$($f.Name)" $f.FullName))
320
+ }
321
+ $errCount = Test-PsParse $f.FullName
322
+ if ($errCount -gt 0) {
323
+ [void]$findings.Add((New-Finding 'S1' 'fail' "脚本解析失败($errCount 个错误):$($f.Name)" $f.FullName))
324
+ }
325
+ }
326
+
327
+ # —— 例外豁免标记(audit:ignore),见文件头 Get-AuditWaivers 的说明 ——
328
+ $waivers = @()
329
+ $wi = Get-AuditWaivers $text
330
+ $waivers = $wi.waivers
331
+ foreach ($b in $wi.bad) {
332
+ $shown = if ($b.Length -gt 70) { $b.Substring(0, 70) + '…' } else { $b }
333
+ [void]$findings.Add((New-Finding 'M1' 'warn' "audit:ignore 标记无效(理由不足 8 字符或缺代码),已忽略:$shown" 'SKILL.md'))
334
+ }
335
+
336
+ # —— R1 引用完整性 ——
337
+ if ($text) {
338
+ # 判据收紧(2026-09-17 实测的误报来源):
339
+ # ① 正文举例(如 `scripts/xxx.ps1`)不是引用 → 含占位符的 token 跳过;
340
+ # ② 引用指向别的技能或外部来源(如 Hermes 的 references/…)时,本技能目录下
341
+ # 根本不存在该子目录 → 只记 warn(否则体检被噪音淹没,最后被无视);
342
+ # ③ 只有「子目录确实存在、而其中文件缺失」才是真引用断裂 → fail。
343
+ foreach ($ref in (Get-RelRefs $text)) {
344
+ if ($ref -match '(?i)xxx|<[^>]*>|\.\.\.|\*') { continue }
345
+ $target = Join-Path $SkillDir $ref
346
+ if (Test-Path -LiteralPath $target) { continue }
347
+ $subDir = Split-Path $ref -Parent
348
+ if (-not (Test-Path -LiteralPath (Join-Path $SkillDir $subDir))) {
349
+ # 先判是不是"抄了别的技能的路径"——这比"文件不存在"更具体、且有确定修法。
350
+ $owner = Find-RefOwner -Ref $ref -SelfSkill $name
351
+ if ($owner) {
352
+ [void]$findings.Add((New-Finding 'R1' 'warn' "跨技能引用:$ref 属于技能 $owner —— 正文应只点名技能名,并在 frontmatter 登记 metadata.hermes.related_skills,不要抄对方内部路径(对方改名即失效)" 'SKILL.md' $ref))
353
+ }
354
+ else {
355
+ [void]$findings.Add((New-Finding 'R1' 'warn' "SKILL.md 提到 $ref,但本技能没有 $subDir 目录(运行时生成或外部来源可忽略)" 'SKILL.md' $ref))
356
+ }
357
+ }
358
+ else {
359
+ [void]$findings.Add((New-Finding 'R1' 'fail' "SKILL.md 引用的资源不存在:$ref" 'SKILL.md' $ref))
360
+ }
361
+ }
362
+ # 反向:脚本资产未被任何地方引用(提示,不算失败)
363
+ foreach ($f in $ps1) {
364
+ $rel = $f.FullName.Substring($SkillDir.Length).TrimStart('\')
365
+ if ($text -notmatch [regex]::Escape($f.Name)) {
366
+ [void]$findings.Add((New-Finding 'R2' 'info' "脚本未被 SKILL.md 引用:$rel" $rel))
367
+ }
368
+ }
369
+ }
370
+
371
+ # —— X1 / X2 / X3 内容侧检查(SKILL.md + 全部文本资产)——
372
+ $textFiles = @()
373
+ if ($skillMd -and (Test-Path -LiteralPath $skillMd)) { $textFiles += $skillMd }
374
+ $textFiles += @(Get-ChildItem -LiteralPath $SkillDir -Recurse -File -Include *.ps1, *.md, *.json, *.sh, *.py, *.yml, *.yaml -ErrorAction SilentlyContinue |
375
+ Where-Object { $_.FullName -ne $skillMd } | Select-Object -ExpandProperty FullName)
376
+
377
+ $secretHits = @(); $machineHits = @(); $dangerHits = @()
378
+ foreach ($tf in $textFiles) {
379
+ $body = [System.IO.File]::ReadAllText($tf)
380
+ foreach ($h in (Test-Secrets $body)) { $secretHits += "$h @ $(Split-Path $tf -Leaf)" }
381
+ foreach ($h in (Get-MachinePaths $body)) { $machineHits += "$h @ $(Split-Path $tf -Leaf)" }
382
+ foreach ($h in (Get-DangerHits $body)) { $dangerHits += "$h @ $(Split-Path $tf -Leaf)" }
383
+ }
384
+ foreach ($h in @($secretHits | Sort-Object -Unique)) {
385
+ [void]$findings.Add((New-Finding 'X1' 'fail' "疑似凭据/密钥特征:$h" 'SKILL.md'))
386
+ }
387
+ foreach ($h in @($machineHits | Sort-Object -Unique)) {
388
+ [void]$findings.Add((New-Finding 'X2' 'info' "硬编码机器专属路径:$h" '' ($h -split ' @ ')[-1]))
389
+ }
390
+ foreach ($h in @($dangerHits | Sort-Object -Unique)) {
391
+ [void]$findings.Add((New-Finding 'X3' 'info' "含危险命令模式(确认用途):$h" '' ($h -split ' @ ')[-1]))
392
+ }
393
+
394
+ # —— 扩展点:由本地其他技能补充审核(只增不减,见文件头 Get-SkillAuditExtensions 的说明)——
395
+ foreach ($msg in @($ExtErrors[$name])) {
396
+ if ($msg) { [void]$findings.Add((New-Finding 'E1' 'warn' "审核扩展不可用:$msg" 'SKILL.md')) }
397
+ }
398
+ foreach ($e in @($Extensions)) {
399
+ # 已经抛过错的扩展直接停用:同一个坏扩展会作用于**每个**被审技能,逐技能报错会瞬间
400
+ # 淹没报告(2026-09-17 探针实测:一个抛错的扩展会让每个被审技能各多出一条 E1)。
401
+ if ($ExtRuntime.ContainsKey($e.owner)) { continue }
402
+ try {
403
+ foreach ($x in @(Invoke-SkillAuditExtension -Ext $e -SkillName $name -SkillDir $SkillDir -Root $SkillsRoot)) {
404
+ if ($null -eq $x -or -not $x.code) { continue }
405
+ # 级别白名单:扩展不能自造级别(未知值降级为 warn),避免绕过 status 的判定。
406
+ $lvl = if (@('fail', 'warn', 'info') -contains $x.level) { $x.level } else { 'warn' }
407
+ [void]$findings.Add((New-Finding $x.code $lvl $x.message `
408
+ $(if ($x.file) { $x.file } else { 'SKILL.md' }) `
409
+ $(if ($x.target) { $x.target } else { '' }) `
410
+ $e.owner))
411
+ }
412
+ }
413
+ catch {
414
+ # 错误归**声明扩展的那个技能**(谁写的扩展谁修),并带上触发时的被审技能名便于定位。
415
+ # 这里只登记,落地成 E1 由主流程在 owner 的结果上补齐——因为按审核顺序 owner 可能**还没轮到**;
416
+ # 顺手也就实现了"只报一次"。
417
+ $ExtRuntime[$e.owner] = "在审核 $name 时抛错:$($_.Exception.Message)"
418
+ }
419
+ }
420
+
421
+ # —— 应用 audit:ignore 例外(判据不放宽,只跳过被显式声明为不适用的条目)——
422
+ if (@($waivers).Count -gt 0) {
423
+ $survivors = @($findings | Where-Object { -not (Test-Waived $_ $waivers) })
424
+ $kept = New-Object System.Collections.ArrayList
425
+ foreach ($s in $survivors) { [void]$kept.Add($s) }
426
+ $findings = $kept
427
+ }
428
+
429
+ $fails = @($findings | Where-Object { $_.level -eq 'fail' }).Count
430
+ $warns = @($findings | Where-Object { $_.level -eq 'warn' }).Count
431
+ $status = if ($fails -gt 0) { 'fail' } elseif ($warns -gt 0) { 'warn' } else { 'pass' }
432
+
433
+ return [pscustomobject]@{
434
+ skill = $name
435
+ status = $status
436
+ fails = $fails
437
+ warns = $warns
438
+ scripts = $ps1.Count
439
+ findings = @($findings)
440
+ }
441
+ }
442
+
443
+ # —— 扩展点:由**本地其他技能**补充审核(audit_extension)——
444
+ #
445
+ # 声明方式(写在**提供扩展的那个技能**的 frontmatter 里):
446
+ # metadata:
447
+ # hermes:
448
+ # audit_extension: "scripts/audit-checks.ps1" # 相对该技能目录
449
+ # 契约:扩展脚本定义 Get-SkillAuditFindings,返回 finding 对象数组(code/level/message/file/target)。
450
+ #
451
+ # 三条设计约束:
452
+ # ① **只增不减**:扩展只能追加发现,不能移除或降级核心判据——核心判据的真源始终是本脚本。
453
+ # 故扩展点不破坏"判据单一真源",它只是让**新**判据各有各的家。
454
+ # ② **出错不拖垮审核**:扩展缺失 / 无 BOM / 解析失败 / 执行抛错 → 记一条 E1 warn 到**声明它的
455
+ # 技能**上,审核继续跑完。扩展是本地可信代码,但审核本身绝不能因它而失败。
456
+ # ③ **零影响**:没有任何技能声明 audit_extension 时,本机制完全不参与,行为与引入前一致。
457
+ function Get-SkillAuditExtensions {
458
+ param([string]$Root)
459
+ $list = @()
460
+ foreach ($d in @(Get-ChildItem -LiteralPath $Root -Directory -ErrorAction SilentlyContinue)) {
461
+ $md = Join-Path $d.FullName 'SKILL.md'
462
+ if (-not (Test-Path -LiteralPath $md)) { continue }
463
+ $fm = Get-FrontmatterText ([System.IO.File]::ReadAllText($md))
464
+ if (-not $fm) { continue }
465
+ $m = [regex]::Match($fm, '(?m)^\s*audit_extension\s*:\s*(.+?)\s*$')
466
+ if (-not $m.Success) { continue }
467
+ $rel = $m.Groups[1].Value.Trim().Trim('"').Trim("'")
468
+ $list += [pscustomobject]@{ owner = $d.Name; path = (Join-Path $d.FullName $rel) }
469
+ }
470
+ return @($list)
471
+ }
472
+
473
+ # dot-source 到**独立函数作用域**再调用:直接 . 进引擎作用域会让扩展脚本覆盖引擎自己的变量
474
+ # ($findings / $text / $SkillsRoot …),也会把它的函数定义泄漏到全局。
475
+ function Invoke-SkillAuditExtension {
476
+ param($Ext, [string]$SkillName, [string]$SkillDir, [string]$Root)
477
+ . $Ext.path
478
+ if (-not (Get-Command 'Get-SkillAuditFindings' -ErrorAction SilentlyContinue)) {
479
+ throw "扩展脚本未定义 Get-SkillAuditFindings:$($Ext.path)"
480
+ }
481
+ return @(Get-SkillAuditFindings -SkillName $SkillName -SkillDir $SkillDir -SkillsRoot $Root)
482
+ }
483
+
484
+ # —— 主流程 ————————————————————————————————————————————————
485
+
486
+ $targets = @()
487
+ if ($Skill) {
488
+ foreach ($s in $Skill) {
489
+ $d = Join-Path $SkillsRoot $s
490
+ if (Test-Path -LiteralPath $d) { $targets += (Resolve-Path -LiteralPath $d).Path }
491
+ else { Write-Error "技能不存在:$s(根:$SkillsRoot)"; exit 2 }
492
+ }
493
+ }
494
+ else {
495
+ $targets = @(Get-ChildItem -LiteralPath $SkillsRoot -Directory -ErrorAction SilentlyContinue |
496
+ Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'SKILL.md') } |
497
+ Select-Object -ExpandProperty FullName | Sort-Object)
498
+ }
499
+
500
+ # 扩展发现必须在审任何技能**之前**完成:扩展可能声明在本次范围之外的技能上(定向审核时尤其如此)。
501
+ $extensions = @()
502
+ $extErrors = @{}
503
+ foreach ($e in (Get-SkillAuditExtensions $SkillsRoot)) {
504
+ $why = $null
505
+ if (-not (Test-Path -LiteralPath $e.path)) {
506
+ $why = "声明了 audit_extension 但文件不存在:$($e.path)"
507
+ }
508
+ elseif (-not (Test-Utf8Bom $e.path)) {
509
+ $why = "扩展脚本缺少 UTF-8 BOM(5.1 下中文会按 GBK 解码而解析失败):$($e.path)"
510
+ }
511
+ elseif ((Test-PsParse $e.path) -gt 0) {
512
+ $why = "扩展脚本解析失败($(Test-PsParse $e.path) 个错误):$($e.path)"
513
+ }
514
+ if ($why) {
515
+ if (-not $extErrors.ContainsKey($e.owner)) { $extErrors[$e.owner] = @() }
516
+ $extErrors[$e.owner] += $why
517
+ }
518
+ else { $extensions += $e }
519
+ }
520
+
521
+ $ExtRuntime = @{}
522
+ $results = @()
523
+ foreach ($t in $targets) {
524
+ $results += (Invoke-SkillAudit $t -Extensions $extensions -ExtErrors $extErrors -ExtRuntime $ExtRuntime)
525
+ }
526
+
527
+ # 扩展运行时错误统一补到**声明它的技能**上(原因见 Invoke-SkillAudit 内的说明):
528
+ # 结果对象里的 findings 是定长数组,故这里重建对象并同步重算 status/fails/warns。
529
+ if ($ExtRuntime.Count -gt 0) {
530
+ $results = @($results | ForEach-Object {
531
+ $r = $_
532
+ if (-not $ExtRuntime.ContainsKey($r.skill)) { return $r }
533
+ $extra = @([pscustomobject]@{
534
+ code = 'E1'; level = 'warn'; message = "审核扩展执行失败:$($ExtRuntime[$r.skill])"
535
+ file = 'SKILL.md'; target = ''; source = ''
536
+ })
537
+ $all = @($r.findings) + $extra
538
+ $f = @($all | Where-Object { $_.level -eq 'fail' }).Count
539
+ $w = @($all | Where-Object { $_.level -eq 'warn' }).Count
540
+ [pscustomobject]@{
541
+ skill = $r.skill
542
+ status = $(if ($f -gt 0) { 'fail' } elseif ($w -gt 0) { 'warn' } else { 'pass' })
543
+ fails = $f; warns = $w; scripts = $r.scripts; findings = $all
544
+ }
545
+ })
546
+ }
547
+
548
+ $failTotal = @($results | Where-Object { $_.status -eq 'fail' }).Count
549
+ $warnTotal = @($results | Where-Object { $_.status -eq 'warn' }).Count
550
+
551
+ if (-not $NoLog) {
552
+ $logDir = Join-Path (Get-DshHome) 'vet\skill-audits'
553
+ try {
554
+ if (-not (Test-Path -LiteralPath $logDir)) { [void](New-Item -ItemType Directory -Force -Path $logDir) }
555
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
556
+ $payload = [pscustomobject]@{
557
+ auditedAt = (Get-Date).ToString('s')
558
+ skillsRoot = $SkillsRoot
559
+ fail = $failTotal
560
+ warn = $warnTotal
561
+ extensions = @($extensions | ForEach-Object { [pscustomobject]@{ owner = $_.owner; path = $_.path } })
562
+ results = $results
563
+ }
564
+ # 局部变量不能叫 $json —— PowerShell 变量名不区分大小写,会撞上本脚本的 [switch]$Json 参数,
565
+ # 赋值字符串时报 "Cannot convert value System.String to type SwitchParameter"(2026-09-17 踩坑)。
566
+ $jsonText = ($payload | ConvertTo-Json -Depth 8)
567
+ [System.IO.File]::WriteAllText((Join-Path $logDir "audit-$stamp.json"), $jsonText, (New-Object System.Text.UTF8Encoding($false)))
568
+ [System.IO.File]::WriteAllText((Join-Path $logDir 'latest.json'), $jsonText, (New-Object System.Text.UTF8Encoding($false)))
569
+ # 只保留最近 40 份
570
+ $old = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'audit-*.json' | Sort-Object LastWriteTime -Descending | Select-Object -Skip 40)
571
+ foreach ($o in $old) { Remove-Item -LiteralPath $o.FullName -Force -ErrorAction SilentlyContinue }
572
+ }
573
+ catch { Write-Warning "审核日志写入失败:$($_.Exception.Message)"; Write-Warning $_.InvocationInfo.PositionMessage }
574
+ }
575
+
576
+ if ($Json) {
577
+ $out = [pscustomobject]@{ auditedAt = (Get-Date).ToString('s'); skillsRoot = $SkillsRoot; fail = $failTotal; warn = $warnTotal; extensions = @($extensions | ForEach-Object { $_.owner }); results = $results }
578
+ $out | ConvertTo-Json -Depth 8
579
+ }
580
+ else {
581
+ Write-Host "==== 技能审核:$SkillsRoot ===="
582
+ Write-Host ("技能数 {0} | fail {1} | warn {2}{3}" -f $results.Count, $failTotal, $warnTotal,
583
+ $(if ($extensions.Count -gt 0) { " | 审核扩展 $($extensions.Count) 个:$(($extensions | ForEach-Object { $_.owner }) -join '、')" } else { '' }))
584
+ foreach ($r in $results) {
585
+ $mark = switch ($r.status) { 'pass' { '[通过]' } 'warn' { '[注意]' } default { '[失败]' } }
586
+ Write-Host ("`n$mark {0} (脚本 {1} 个, fail {2}, warn {3})" -f $r.skill, $r.scripts, $r.fails, $r.warns)
587
+ foreach ($f in $r.findings) {
588
+ if ($f.level -eq 'info') { continue }
589
+ Write-Host (" - [{0}] {1} ({2}{3})" -f $f.level, $f.message, $f.code, $(if ($f.source) { " @$($f.source)" } else { '' }))
590
+ }
591
+ }
592
+ if ($failTotal -gt 0) { Write-Host "`n存在 fail 项:按上面的 SKILL.md/脚本路径修复后重跑本脚本。" }
593
+ }
594
+
595
+ if ($failTotal -gt 0) { exit 1 }
596
+ exit 0