@caesarloo/dsh-skill-audit 0.1.2 → 0.2.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/README.md CHANGED
@@ -12,25 +12,48 @@ A DeepSeek Harness plugin that **audits your skills the moment they change**: af
12
12
 
13
13
  The audit rules live in the `skill-audit` skill's engine: frontmatter contract (`name` / `description` / `whenToUse` / `version`), script usability (UTF-8 BOM + parseable by Windows PowerShell 5.1), `SKILL.md` reference integrity, credential leakage, machine-specific paths and dangerous command patterns.
14
14
 
15
- - **Automatic** — audits after skill files are written or edited, and after `dsh_config_git_backup`'s `restore` / `backup`;
15
+ - **Automatic** — audits after skill files are written or edited, and after a bulk restore / backup (any tool invoked with `mode: 'restore'` or `mode: 'backup'`);
16
16
  - **Speaks only when there is something to act on** — findings are injected as context only when `fail` / `warn` exist; a clean run stays silent and just writes a log;
17
17
  - **Never blocks** — an audit cannot fail your tool call. The file is already written, so surfacing the problem to the model is the right move;
18
18
  - **On demand** — the `skill_audit` tool.
19
19
 
20
20
  ### Install
21
21
 
22
- Prerequisite: the `skill-audit` skill (the audit engine) must be installed — this plugin only triggers it and relays the results.
23
-
24
22
  ```sh
25
23
  dsh plugin --profile web add @caesarloo/dsh-skill-audit
26
24
  ```
27
25
 
26
+ **No prerequisite** — the package ships the audit engine *and* the skill body, so a bare install is enough. If your skills root already holds a full `skill-audit` skill, the plugin leaves it alone and uses that one instead.
27
+
28
28
  Restart dsh afterwards (bundle-level change, not hot-reloaded). Verify:
29
29
 
30
30
  ```powershell
31
31
  dsh --profile web --dump-config | Select-String tool-skill-audit
32
32
  ```
33
33
 
34
+ ### Engine resolution
35
+
36
+ | # | Source | Path | Notes |
37
+ |---|---|---|---|
38
+ | 1 | `config.auditScript` | whatever you set | Explicit. **If it is set but missing this is an error** — the plugin will not silently run a different engine. |
39
+ | 2 | an engine in your skills root | `<skillsRoot>/skill-audit/scripts/audit-skills.ps1` | Optional override slot, normally absent. Put one here to use your own rules instead of the shipped ones. |
40
+ | 3 | the package | `<package>/skill/scripts/audit-skills.ps1` | **The source of truth**, shipped with the plugin. One copy only — nothing to keep in sync. |
41
+
42
+ The plugin registers the `skill-audit` skill at runtime **unless your skills root already holds a full copy (i.e. a `SKILL.md`)**. That guard is mandatory rather than polite: DSH ranks `project > runtime > user`, and a skills root is the *user* layer (`source: 'user-dsh'`) — an unconditional runtime registration would **shadow your own skill**.
43
+
44
+ The same split decides what ships and what stays yours:
45
+
46
+ | Content | Lives in | Cost of a change |
47
+ |---|---|---|
48
+ | Skill body (boundaries, trigger rules, finding table, waiver & extension contracts) + the audit **engine** | **the package** — the body is registered at runtime, the engine is resolved from the package too | immediate on a linked install; **republish** for everyone else |
49
+ | **Machine-specific rules** — an `audit_extension` declared by one of your own skills | your skills root | none — live on the next audit, never needs a release |
50
+
51
+ So a bare install gets the whole thing, and the one part that genuinely keeps changing — your own local rules — stays on your side, editable without a rebuild or a restart.
52
+
53
+ Keeping the engine in the package is what makes a **single copy** possible: there is no second copy in your skills root that could silently drift out of date. You can still put one there to override the shipped engine — that slot is respected — but nothing needs it.
54
+
55
+ A registered skill is given the bundled directory as its resource base, so the relative script paths inside the skill body still resolve.
56
+
34
57
  ### Usage
35
58
 
36
59
  Nothing to do after installing — audits run automatically whenever you change skills. To re-check on demand:
@@ -45,9 +68,11 @@ Trigger scope:
45
68
  | Your action | Audit scope |
46
69
  |---|---|
47
70
  | `write` / `edit` on a skill file | that skill only (fast) |
48
- | `dsh_config_git_backup` `restore` / `backup` | everything (bulk overwrite / pre-commit check) |
71
+ | any tool invoked with `mode: 'restore'` or `mode: 'backup'` | everything (bulk overwrite / pre-commit check) |
49
72
  | a shell command rewriting the skills directory | everything |
50
73
 
74
+ Bulk detection keys on the **call shape**, not on a tool name — so the plugin works with any backup plugin, and with none. If yours names its mode argument differently, list its tool name in `fullAuditTools`.
75
+
51
76
  Reporting is tiered by scenario:
52
77
 
53
78
  - single-skill edit → lists `fail` and `warn` in detail;
@@ -63,23 +88,37 @@ Usually none. To customize, add `config` to the entry in the profile's `cordis.p
63
88
  | Key | Default | Meaning |
64
89
  |---|---|---|
65
90
  | `skillsRoot` | `<DSH_HOME>/skills` | Skills root directory |
66
- | `auditScript` | `<skillsRoot>/skill-audit/scripts/audit-skills.ps1` | Audit engine script |
91
+ | `auditScript` | auto: your skills root → the package | Audit engine script; set it to pin one (a missing explicit path is an error) |
67
92
  | `autoAudit` | `true` | `false` disables automatic audits (the `skill_audit` tool stays available) |
68
93
  | `powershell` | Windows PowerShell / `pwsh` | PowerShell executable |
69
94
  | `timeoutMs` | `120000` | Per-audit timeout |
70
95
  | `maxContextChars` | `2000` | Cap on injected context length |
96
+ | `fullAuditTools` | `[]` | Extra tool names to treat as bulk rewrites of the skills tree. Usually unnecessary: the generic rule already covers calls whose `mode` is `restore` or `backup`. |
71
97
 
72
98
  ### Dependencies
73
99
 
74
100
  `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-subprocess` and `@deepseek-ai/dsh-llm` are declared as **optional peerDependencies** and provided by the host; the package bundles none of them. This is deliberate: a second copy inside the profile would create two module instances and break tool registration. `dsh-llm` is used only to build the context message — if it is unavailable, the audit still runs and only the context injection is skipped.
75
101
 
102
+ The **skills service** is a different kind of dependency: a service of the host process rather than a package, and it must be declared in `inject`. That declaration is mandatory rather than cosmetic — cordis **throws** on an undeclared service property instead of returning `undefined`, so reading `ctx.skills` without it crashes plugin loading (observed 2026-09-17: `cannot get property "skills" without inject` → `plugin tree failed to load`), and a defensive `undefined` check never gets the chance to run. The service itself is always present: `@deepseek-ai/dsh-base` provides it, and every profile is built on that.
103
+
76
104
  ### Boundaries
77
105
 
78
- - Does not implement the audit rules themselves (they live in the `skill-audit` skill);
106
+ - Contains no audit logic itself: it ships the skill body and the engine, and relays the engine's output. The editable sources of truth are the package (skill body + engine) and machine-specific rules in a local skill's `audit_extension`;
79
107
  - Does not rewrite tool input and never blocks a tool call;
80
108
  - Does not hook non-tool events (`SessionStart` / `Stop`);
81
109
  - Does not watch the skills directory — it triggers after tool calls only.
82
110
 
111
+ ### Maintaining `skill/` (maintainers)
112
+
113
+ Both halves of `skill/` — `SKILL.md` (the skill body) and `scripts/audit-skills.ps1` (the engine) — are **the source of truth**, edited right here in this repository. There is no snapshot step any more: the engine used to live in the author's skills root and had to be copied into the package, which meant a second copy that could silently drift out of date. That copy is gone, and with it the whole class of problem.
114
+
115
+ Two checks enforce the contract instead:
116
+
117
+ - `npm test` runs the **real** engine against the bundled skill — laid out exactly like a real skill — and asserts the engine kept its UTF-8 BOM. So the engine cannot ship unparseable and the body cannot ship broken;
118
+ - `prepublishOnly` runs `build && test`, so nothing ships without those checks.
119
+
120
+ One timing difference is worth remembering: the **engine** is read fresh on every audit, so an edit applies immediately; the **skill body** is read once at plugin startup, so an edit needs a dsh restart.
121
+
83
122
  ### License
84
123
 
85
124
  MIT
@@ -94,25 +133,48 @@ MIT
94
133
 
95
134
  审核规则由 `skill-audit` 技能的引擎执行,包含:frontmatter 契约(`name` / `description` / `whenToUse` / `version`)、技能内脚本的可用性(UTF-8 BOM + Windows PowerShell 5.1 可解析)、`SKILL.md` 引用完整性、凭据泄漏、机器专属路径与危险命令模式。
96
135
 
97
- - **自动** —— 技能文件被写入 / 编辑之后,或 `dsh_config_git_backup` `restore` / `backup` 之后自动审核;
136
+ - **自动** —— 技能文件被写入 / 编辑之后,或任何整批 restore / backup(以 `mode: 'restore'` `mode: 'backup'` 调用的工具)之后自动审核;
98
137
  - **有发现才提示** —— 只在存在 `fail` / `warn` 时把结论作为上下文回传给模型;全部通过时保持安静,只写审核日志;
99
138
  - **不阻塞** —— 审核不会让你的工具调用失败。文件已经写入,把问题摆到模型面前才是正确做法;
100
139
  - **可主动调用** —— `skill_audit` 工具。
101
140
 
102
141
  ### 安装
103
142
 
104
- 前置:本机已安装 `skill-audit` 技能(审核引擎在它里面,插件只负责触发与回传)。
105
-
106
143
  ```sh
107
144
  dsh plugin --profile web add @caesarloo/dsh-skill-audit
108
145
  ```
109
146
 
147
+ **无前置条件** —— 包内自带审核引擎与技能正文,装上即可用。若你的技能根里已有完整的 `skill-audit` 技能,插件不打扰它、直接用你那份。
148
+
110
149
  装完**重启 dsh**(插件属于 bundle 层变更,不随热重载生效)。验证:
111
150
 
112
151
  ```powershell
113
152
  dsh --profile web --dump-config | Select-String tool-skill-audit
114
153
  ```
115
154
 
155
+ ### 引擎解析顺序
156
+
157
+ | 优先级 | 来源 | 路径 | 说明 |
158
+ |---|---|---|---|
159
+ | 1 | `config.auditScript` | 你指定的路径 | 显式指定。**指定了却不存在会直接报错**——不会偷偷换一个引擎跑。 |
160
+ | 2 | 你技能根里的一份 | `<skillsRoot>/skill-audit/scripts/audit-skills.ps1` | 可选覆盖位,默认不存在。放一份在这就能用你自己的判据替代包里那份。 |
161
+ | 3 | 包内 | `<package>/skill/scripts/audit-skills.ps1` | **真源**,随插件发布。只有一份,没有需要同步的东西。 |
162
+
163
+ 插件**只在你的技能根里还没有完整副本(即没有 `SKILL.md`)时**才在运行时注册 `skill-audit`。这个保护是硬性要求而非客气:DSH 的层级是 `project > runtime > user`,而技能根属于 **user 层**(`source: 'user-dsh'`)——无条件注册会**遮蔽你自己的技能**。
164
+
165
+ 同一条切分决定什么进包、什么留给你:
166
+
167
+ | 内容 | 住在哪 | 改一次的代价 |
168
+ |---|---|---|
169
+ | 技能正文(边界、触发规则、判据表、豁免与扩展点契约)+ 审核**引擎** | **都在包内**——正文由运行时注册,引擎也从包内解析 | link 安装下立即生效;对他人要**重发一版** |
170
+ | **机器专属规则**——你自己某个技能声明的 `audit_extension` | 你的技能根 | 无——下一次审核即生效,永不需要发版 |
171
+
172
+ 所以裸装即可拿到全部能力,而真正会一直变的那部分——你自己的本机规则——留在自己手里,不必重建、不必重启。
173
+
174
+ 引擎留在包内才使"**只有一份**"成为可能:技能根下不再有第二份副本可以悄悄变旧。你仍然可以放一份在那里覆盖包内引擎(那个位置被尊重),但没有任何东西依赖它。
175
+
176
+ 注册的技能以包内目录作为资源基准(resource base),因此技能正文里的相对脚本路径仍然可解析。
177
+
116
178
  ### 使用
117
179
 
118
180
  装好之后**无需任何操作**——改动技能时会自动审核。需要主动复验时调用工具:
@@ -127,9 +189,11 @@ skill_audit({ skill: 'a,b' }) # 只审指定技能(逗号分隔)
127
189
  | 你的操作 | 审核范围 |
128
190
  |---|---|
129
191
  | 用 `write` / `edit` 改某个技能的文件 | 只审**该技能**(快) |
130
- | `dsh_config_git_backup` 的 `restore` / `backup` | **全量**(整批覆盖 / 入库前体检) |
192
+ | 任何以 `mode: 'restore'` `mode: 'backup'` 调用的工具 | **全量**(整批覆盖 / 入库前体检) |
131
193
  | 用 shell 命令改写技能目录 | **全量** |
132
194
 
195
+ 「整批改写」判定的是**调用形态**而不是工具名——所以本插件配合任何备份插件都能工作,没有也不影响。若你所用工具的模式参数不叫 `mode`,把它的工具名列进 `fullAuditTools` 即可。
196
+
133
197
  提示强度按场景分级:
134
198
 
135
199
  - 定向单技能改动 → 详列 `fail` 与 `warn`;
@@ -145,23 +209,37 @@ skill_audit({ skill: 'a,b' }) # 只审指定技能(逗号分隔)
145
209
  | 配置键 | 缺省值 | 含义 |
146
210
  |---|---|---|
147
211
  | `skillsRoot` | `<DSH_HOME>/skills` | 技能根目录 |
148
- | `auditScript` | `<skillsRoot>/skill-audit/scripts/audit-skills.ps1` | 审核引擎脚本 |
212
+ | `auditScript` | 自动:技能根 → 包内 | 审核引擎脚本;显式指定可钉住(指定却不存在会报错) |
149
213
  | `autoAudit` | `true` | `false` 关闭自动审核(`skill_audit` 工具仍可用) |
150
214
  | `powershell` | Windows PowerShell / `pwsh` | PowerShell 可执行文件 |
151
215
  | `timeoutMs` | `120000` | 单次审核超时 |
152
216
  | `maxContextChars` | `2000` | 回传上下文的字符上限 |
217
+ | `fullAuditTools` | `[]` | 额外视为「整批改写技能目录」的工具名。通常不需要:通用规则已覆盖 `mode` 为 `restore` / `backup` 的调用。 |
153
218
 
154
219
  ### 依赖约定
155
220
 
156
221
  `@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-subprocess`、`@deepseek-ai/dsh-llm` 声明为 **optional peerDependencies**,由宿主提供,本包不打包。这是刻意的:它们若与主包各装一份会形成两个模块实例,导致工具注册失败。`dsh-llm` 仅用于构造回传消息,缺失时插件降级为「审核照跑、不注入上下文」。
157
222
 
223
+ **技能服务**是另一类依赖:它是宿主进程的服务而非包,且**必须**在 `inject` 里声明。这个声明不是形式——cordis 对未声明的服务属性会**直接抛错**而不是返回 `undefined`,所以不声明就读 `ctx.skills` 会让插件装载失败(2026-09-17 实测:`cannot get property "skills" without inject` → `plugin tree failed to load`),"读了再判空"的兜底根本没机会执行。该服务本身在所有环境都存在:由 `@deepseek-ai/dsh-base` 提供,而每个 profile 都基于它。
224
+
158
225
  ### 边界(明确不做)
159
226
 
160
- - 不实现审核规则本身(规则在 `skill-audit` 技能里);
227
+ - 本身不含审核逻辑:它携带技能正文与引擎、并回传引擎的输出;可编辑的真源有两处——包内(技能正文 + 引擎),以及本机技能里的 `audit_extension`;
161
228
  - 不改写工具输入、不阻塞工具调用;
162
229
  - 不覆盖 `SessionStart` / `Stop` 等非工具事件;
163
230
  - 不监视技能目录的文件变化(只在工具调用后触发)。
164
231
 
232
+ ### 维护 `skill/`(插件作者)
233
+
234
+ `skill/` 里两部分——`SKILL.md`(技能正文)与 `scripts/audit-skills.ps1`(引擎)——**都是真源**,就在本仓库里直接编辑。**同步步骤已经取消**:引擎过去住在作者技能根里、必须复制进包,于是存在第二份副本、随时可能悄悄变旧。那份副本连同这一整类问题都已消失。
235
+
236
+ 现在由两道检查守住契约:
237
+
238
+ - `npm test` 用**真引擎**审包内技能(按真实技能布局摆好),并断言引擎的 UTF-8 BOM 还在——所以引擎不可能以"解析不了"的状态发布,技能正文也不可能带着断裂发布;
239
+ - `prepublishOnly` 跑 `build && test`,没通过这两道就发不出去。
240
+
241
+ 一个生效时机差异值得记住:**引擎**每次审核现读,改完立即生效;**技能正文**只在插件启动时读一次,改完要重启 dsh。
242
+
165
243
  ### License
166
244
 
167
245
  MIT
package/dist/index.js CHANGED
@@ -2,27 +2,55 @@
2
2
  //
3
3
  // 为什么必须是插件,而不是 hooks 桥接:
4
4
  // @deepseek-ai/dsh-hooks-claude-code 通过 `ctx.shell` 运行钩子命令。当宿主没有可用的沙箱
5
- // runner 时(本机 Windows 实测:`SANDBOX_UNAVAILABLE`,执行器按设计 fail-closed、绝不静默
6
- // 降级),钩子命令根本无法启动 —— hooks.json 配得再对也没用。本插件在 harness 进程内用
7
- // `ctx.subprocess`(host 层)直接跑审核脚本,绕开该限制;这也是官方对"没有 Claude Code
8
- // 对应物的定制行为"给出的推荐形态。
5
+ // runner 时(执行器按设计 fail-closed、绝不静默降级,表现为 `SANDBOX_UNAVAILABLE`),钩子
6
+ // 命令根本无法启动 —— 钩子配置配得再对也没用。本插件在 harness 进程内用 `ctx.subprocess`
7
+ // host 层)直接跑审核脚本,绕开该限制;这也是官方对"没有 Claude Code 对应物的定制行为"
8
+ // 给出的推荐形态。
9
9
  //
10
10
  // 两条通道:
11
11
  // 1) `tools/post-execute` 自动触发(写入**之后**,审的是新内容):
12
12
  // · write / edit 命中 <DSH_HOME>/skills/<技能>/ → 只审该技能
13
- // · dsh_config_git_backup restore / backup 全量(整批覆盖 / 入库前)
13
+ // · 调用形态为「整批改写」的工具(参数 mode restore / backup)→ 全量(整批覆盖 / 入库前)
14
14
  // · pwsh 等 shell,命令行同时含 skills 与写操作迹象 → 全量
15
15
  // 2) `skill_audit` 工具 —— agent 可主动定向或全量审核。
16
16
  //
17
- // 审核逻辑不在本插件内(单一真源):默认调用
18
- // <DSH_HOME>/skills/skill-audit/scripts/audit-skills.ps1
19
- // 该脚本缺失时工具报明确错误、自动触发静默跳过(不打扰正常写文件)。
17
+ // 审核逻辑不在本插件内(单一真源),按优先级三选一:
18
+ // 1) config.auditScript(显式;指定了却不存在 → 直接报错,不静默回落)
19
+ // 2) 用户态技能 <DSH_HOME>/skills/skill-audit/scripts/audit-skills.ps1 —— 可选覆盖位
20
+ // 3) 包内 <pkg>/skill/scripts/audit-skills.ps1 —— 随版本发布的真源
21
+ //
22
+ // 分层(2026-09-17 定):**常改的**与**不常改的**分开住,各自只有一个真源。
23
+ // · 技能正文(边界、触发规则、判据表、豁免与扩展点契约)+ **判据引擎** → 都在**包内**:
24
+ // 正文由运行时注册提供,引擎即包内那份脚本。开发态(把本包 link 进 profile)下改它们
25
+ // 立即生效,但要发版才对他人生效——**因此不再有"包内 vs 技能根"两份引擎可以分叉**。
26
+ // · 机器专属规则 → **本地**扩展技能的 audit_extension(改完即生效,永远不必发版)。
27
+ // 注意包内引擎与技能正文**在不同时机生效**:引擎是每次审核现读的文件(改完即生效,不必重启),
28
+ // 而技能正文只在插件启动注册时读一次(改完要重启 dsh)。
29
+ // 用户态那一档仍然保留:某台机器若想用自己的引擎(或想覆盖包内判据),放一份在那即可;
30
+ // 若放的是**完整技能**(含 SKILL.md),则连注册都让位——层级是 project > runtime > user,
31
+ // 无条件注册会遮蔽它(硬约束,见 registerFallbackSkill)。
32
+ // 引擎缺失时工具报明确错误、自动触发静默跳过(不打扰正常写文件)。
33
+ import { existsSync, readFileSync } from 'node:fs';
20
34
  import { stat } from 'node:fs/promises';
21
- import { isAbsolute, join, resolve as resolvePath } from 'node:path';
35
+ import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
22
37
  import { defineTool } from '@deepseek-ai/dsh-tools';
23
38
  // Plugin display name, shown in loader diagnostics.
24
39
  export const name = 'tool-skill-audit';
25
- export const inject = ['tools', 'subprocess'];
40
+ // 'skills' 必须在 inject 里声明,**不能靠"读了再判空"**:cordis 对未在 inject 声明过的
41
+ // 服务属性直接抛错,而不是返回 undefined(报错形如
42
+ // `cannot get property "skills" without inject` → `plugin tree failed to load`,
43
+ // 即整个插件装载失败、进程起不来),
44
+ // 于是 registerFallbackSkill 里 `skills === undefined` 的兜底根本到不了。
45
+ // 代价:cordis 4 的 inject 只有**必需**语义(没有 required/optional 之分),本插件会等
46
+ // skills 服务就绪才 apply。本项目里无风险——skills 由 `@deepseek-ai/dsh-base` 提供
47
+ // (实测 web / headless 两个 profile 都基于它),而本 bundle 排在 bundles 末尾。
48
+ // 备选:cordis 另有 `ctx.get('skills')`,它**不需要 inject**、未提供时返回 undefined;
49
+ // 若将来要让"审核为主、注册技能为辅"彻底解耦(审核不因注册失败而受累),改用它才是正解。
50
+ export const inject = ['tools', 'subprocess', 'skills'];
51
+ const SKILL_NAME = 'skill-audit';
52
+ const ENGINE_RELATIVE = join('scripts', 'audit-skills.ps1');
53
+ const BUNDLED_SKILL_DIRNAME = 'skill';
26
54
  const POWERSHELL = process.platform === 'win32'
27
55
  ? 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
28
56
  : 'pwsh';
@@ -45,6 +73,91 @@ async function fileExists(path) {
45
73
  return false;
46
74
  }
47
75
  }
76
+ export function resolveEngine(options) {
77
+ const isFile = options.isFile ?? ((p) => existsSync(p));
78
+ if (options.configured) {
79
+ return isFile(options.configured) ? { path: options.configured, source: 'config' } : null;
80
+ }
81
+ const userEngine = join(options.skillsRoot, SKILL_NAME, ENGINE_RELATIVE);
82
+ if (isFile(userEngine))
83
+ return { path: userEngine, source: 'user-skill' };
84
+ if (options.bundledSkillDir) {
85
+ const bundled = join(options.bundledSkillDir, ENGINE_RELATIVE);
86
+ if (isFile(bundled))
87
+ return { path: bundled, source: 'bundled' };
88
+ }
89
+ return null;
90
+ }
91
+ /** 包内 skill/ 目录(技能正文与引擎的真源):从本模块向上找含 skill/SKILL.md 的目录(逐文件 dist/ 与打包形态都可定位)。 */
92
+ export function resolveBundledSkillDir(startUrl = import.meta.url) {
93
+ let dir = dirname(fileURLToPath(startUrl));
94
+ for (let depth = 0; depth < 6; depth++) {
95
+ const candidate = join(dir, BUNDLED_SKILL_DIRNAME);
96
+ if (existsSync(join(candidate, 'SKILL.md')))
97
+ return candidate;
98
+ const parent = dirname(dir);
99
+ if (parent === dir)
100
+ break;
101
+ dir = parent;
102
+ }
103
+ return null;
104
+ }
105
+ /** 极简 frontmatter 取值(只认单行 `key: value`,够用且不为此引 YAML 依赖)。 */
106
+ function frontmatterField(content, key) {
107
+ // 注意:行内标志 (?m) 是 .NET/PCRE 写法,JS 的 RegExp 会抛 "Invalid group" —— 多行标志必须走第二参数。
108
+ const match = content.match(new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, 'm'));
109
+ if (!match)
110
+ return undefined;
111
+ return match[1].replace(/^["']|["']$/g, '');
112
+ }
113
+ /**
114
+ * 把回退技能正文里的"用户态引擎路径"改写成实际生效的路径。
115
+ * 只替换**精确字面量**:真源改了措辞就自然不再匹配,不会误伤别的路径。
116
+ */
117
+ export function rewriteEnginePaths(content, enginePath) {
118
+ return content
119
+ .replace(/\$env:USERPROFILE\\\.dsh\\skills\\skill-audit\\scripts\\audit-skills\.ps1/g, enginePath)
120
+ .replace(/<DSH_HOME>[\\/]skills[\\/]skill-audit[\\/]scripts[\\/]audit-skills\.ps1/g, enginePath);
121
+ }
122
+ /**
123
+ * 注册包内技能——**仅当用户态没有一份完整的同名技能(含 SKILL.md)时**。
124
+ *
125
+ * 守卫的用意:该位置只要出现一份**完整技能**(含 SKILL.md),就说明那台机器要自己维护它,
126
+ * 插件必须让位。默认情形(该位置不存在,或只有脚本)则由包内提供技能正文——技能正文与判据
127
+ * 引擎的真源都在包内,故这一支是常态。
128
+ *
129
+ * 硬约束:dsh-skill 的 `register()` 层级是 **project > runtime > user**,而
130
+ * `<DSH_HOME>/skills` 属于 user 层(`source: 'user-dsh'`)——若某台机器上保留了**完整的**
131
+ * 用户态技能,无条件注册同名 runtime 技能会**遮蔽它**(2026-09-17 查 dsh-skill 的
132
+ * `lib/types/index.d.ts` 确认)。故这里先做文件系统判定,存在就一步都不做。
133
+ *
134
+ * `skipped-no-service` 是**防御性**分支:skills 已在 `inject` 里声明,服务必然就绪,
135
+ * 正常跑不到;留着是因为"服务在、但形态变了(没有 register)"无法由 inject 保证。
136
+ */
137
+ export function registerFallbackSkill(ctx, options) {
138
+ if (existsSync(join(options.skillsRoot, SKILL_NAME, 'SKILL.md')))
139
+ return 'skipped-user-skill';
140
+ const skills = ctx.skills;
141
+ if (skills === undefined || typeof skills.register !== 'function')
142
+ return 'skipped-no-service';
143
+ if (!options.bundledSkillDir)
144
+ return 'skipped-no-bundle';
145
+ const skillMd = join(options.bundledSkillDir, 'SKILL.md');
146
+ if (!existsSync(skillMd))
147
+ return 'skipped-no-bundle';
148
+ const content = readFileSync(skillMd, 'utf8');
149
+ skills.register({
150
+ name: SKILL_NAME,
151
+ description: frontmatterField(content, 'description') ??
152
+ 'DSH skill audit: deterministic static checks over skill frontmatter, script usability, reference integrity, credential leakage, machine-specific paths and dangerous commands.',
153
+ whenToUse: frontmatterField(content, 'whenToUse') ??
154
+ 'A skill was created, edited, or restored from a backup repository; you need to know whether a skill is usable, self-consistent, and free of leaked credentials.',
155
+ source: 'runtime',
156
+ content: rewriteEnginePaths(content, options.enginePath),
157
+ resourceBase: { kind: 'directory', path: options.bundledSkillDir },
158
+ });
159
+ return 'registered';
160
+ }
48
161
  /** 从工具参数里尽力提取被操作的路径(不同工具键名不同,另有兜底扫描)。 */
49
162
  export function candidatePaths(args) {
50
163
  const out = [];
@@ -85,14 +198,25 @@ const FILE_WRITE_TOOLS = new Set([
85
198
  'str_replace_editor',
86
199
  ]);
87
200
  const WRITE_HINTS = /(?:Set-Content|Out-File|Add-Content|Clear-Content|Copy-Item|Move-Item|Remove-Item|New-Item|robocopy|git\s+(?:checkout|restore|apply))/i;
201
+ /**
202
+ * 「整批改写」的调用形态:这类工具会大范围改动技能目录(备份 / 恢复 / 同步),但**参数里不带路径**,
203
+ * 没法靠路径判定,只能靠调用形态。判据是**参数**而不是工具名——插件因此不绑定任何具体备份插件。
204
+ *
205
+ * 2026-09-17 去硬依赖:原先写成按**工具名**硬匹配,对没有装那个备份插件的用户是纯死逻辑。
206
+ * 现在只要参数是 `mode: 'restore' | 'backup'` 就触发,无论工具叫什么(真实备份操作必然带 `mode`,
207
+ * 故行为不变);模式名不叫 `mode` 的场景用 config.fullAuditTools 显式补充。
208
+ */
209
+ const BULK_MODES = new Set(['restore', 'backup']);
88
210
  /** 决定这次工具调用要不要触发审核;返回 null 表示与该工具无关。 */
89
- export function planAudit(toolName, args, skillsRoot) {
211
+ export function planAudit(toolName, args, skillsRoot, fullAuditTools = [], isSkillDir = (dir) => existsSync(join(dir, 'SKILL.md'))) {
90
212
  const lower = toolName.toLowerCase();
91
- if (lower === 'dsh_config_git_backup') {
92
- const mode = String(args?.mode ?? '').toLowerCase();
93
- if (mode && mode !== 'restore' && mode !== 'backup')
94
- return null;
95
- return { skills: null, scope: `dsh_config_git_backup(${mode || '?'}) 全量` };
213
+ const record = (args ?? {});
214
+ if (fullAuditTools.some((t) => t.toLowerCase() === lower)) {
215
+ return { skills: null, scope: `${toolName}(配置为整批改写) 全量` };
216
+ }
217
+ const mode = typeof record.mode === 'string' ? record.mode.toLowerCase() : '';
218
+ if (BULK_MODES.has(mode)) {
219
+ return { skills: null, scope: `${toolName}(${mode}) → 全量` };
96
220
  }
97
221
  if (SHELL_TOOLS.has(lower)) {
98
222
  const command = String(args?.command ?? '');
@@ -115,7 +239,23 @@ export function planAudit(toolName, args, skillsRoot) {
115
239
  }
116
240
  if (hits.length === 0)
117
241
  return null;
118
- const skills = [...new Set(hits)];
242
+ // 命中项分两类。**只有含 SKILL.md 的目录才是技能**,可作为定向审核目标;
243
+ // skillsRoot 下一旦出现"无 SKILL.md 的目录被改写",它就不是技能(技能目录还没成形,
244
+ // 或只是有人把脚本放进了技能根),此时定向审核无从谈起 → 一律升级为**全量重审**。
245
+ // 顺带消除一个真误报:若仍按目录名送 `-Skill`,引擎会因该目录无 SKILL.md 报 `F1 缺少 SKILL.md`。
246
+ let nonSkillTouched = false;
247
+ const skills = [];
248
+ for (const name of [...new Set(hits)]) {
249
+ if (isSkillDir(join(skillsRoot, name)))
250
+ skills.push(name);
251
+ else
252
+ nonSkillTouched = true;
253
+ }
254
+ if (nonSkillTouched) {
255
+ return { skills: null, scope: `${toolName} → 引擎/资产目录改动(非技能目录)→ 全量` };
256
+ }
257
+ if (skills.length === 0)
258
+ return null;
119
259
  return { skills, scope: `${toolName} → 技能 ${skills.join(', ')}` };
120
260
  }
121
261
  export function parseReport(text) {
@@ -140,13 +280,17 @@ export function parseReport(text) {
140
280
  }
141
281
  export function apply(ctx, config = {}) {
142
282
  const skillsRoot = (config.skillsRoot ?? join(dshHome(), 'skills')).replace(/[\\/]+$/, '');
143
- const auditScript = config.auditScript ?? join(skillsRoot, 'skill-audit', 'scripts', 'audit-skills.ps1');
283
+ const bundledSkillDir = resolveBundledSkillDir();
284
+ const resolved = resolveEngine({ configured: config.auditScript, skillsRoot, bundledSkillDir });
285
+ // 解析不到时仍算出"期望路径"作为报错文案(显式配置优先展示用户给的那条)。
286
+ const auditScript = resolved?.path ?? config.auditScript ?? join(skillsRoot, SKILL_NAME, ENGINE_RELATIVE);
144
287
  const powershell = config.powershell ?? POWERSHELL;
145
288
  const autoAudit = config.autoAudit !== false;
146
289
  const timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
147
290
  const maxContextChars = config.maxContextChars && config.maxContextChars > 0
148
291
  ? config.maxContextChars
149
292
  : DEFAULT_MAX_CONTEXT_CHARS;
293
+ const fullAuditTools = Array.isArray(config.fullAuditTools) ? config.fullAuditTools : [];
150
294
  async function runAudit(skills, signal) {
151
295
  if (!(await fileExists(auditScript))) {
152
296
  return { exitCode: -1, stdout: '', stderr: `审核脚本不存在: ${auditScript}` };
@@ -232,9 +376,9 @@ export function apply(ctx, config = {}) {
232
376
  /**
233
377
  * 构造回传上下文;只有存在需要行动的问题时才返回消息。
234
378
  *
235
- * `failsOnly` 用于**全量场景**(restore/backup、shell 批量改写):那里 warn 的绝对数量很大
236
- * (本机 11 个技能里 9 个各有 1~3 条元数据类 warn),逐条列出会把上下文挤爆且失去焦点 ——
237
- * 全量时只详列 fail,warn 压成一行汇总;定向单技能时(通常 1~3 条)才 fail+warn 都列。
379
+ * `failsOnly` 用于**全量场景**(restore/backup、shell 批量改写):那里 warn 的绝对数量很大,
380
+ * 逐条列出会把上下文挤爆且失去焦点 —— 全量时只详列 fail,warn 压成一行汇总;
381
+ * 定向单技能时(通常只有个位数条)才 fail + warn 都列。
238
382
  * 全量且**只有 warn**时直接返回 undefined:那属于背景噪音,不该打断任何一次写入。
239
383
  */
240
384
  async function buildContextMessage(report, scope, failsOnly = false) {
@@ -350,7 +494,7 @@ export function apply(ctx, config = {}) {
350
494
  const toolName = typeof exec?.name === 'string' ? exec.name : '';
351
495
  if (!toolName || typeof next !== 'function')
352
496
  return await next?.();
353
- const plan = planAudit(toolName, exec?.arguments, skillsRoot);
497
+ const plan = planAudit(toolName, exec?.arguments, skillsRoot, fullAuditTools);
354
498
  if (!plan)
355
499
  return await next();
356
500
  const run = await runAudit(plan.skills, exec?.signal);
@@ -373,5 +517,12 @@ export function apply(ctx, config = {}) {
373
517
  });
374
518
  }
375
519
  }
376
- ctx.logger.info(`[tool-skill-audit] registered "skill_audit" — script=${auditScript} skillsRoot=${skillsRoot} autoAudit=${autoAudit}`);
520
+ // 回退技能注册与 autoAudit 无关:它决定"模型能不能加载到 skill-audit 技能"
521
+ // 而不是"要不要自动跑审核"。用户态技能在场时这一步是空操作。
522
+ const fallbackSkill = registerFallbackSkill(ctx, {
523
+ skillsRoot,
524
+ bundledSkillDir,
525
+ enginePath: auditScript,
526
+ });
527
+ ctx.logger.info(`[tool-skill-audit] registered "skill_audit" — engine=${auditScript} (${resolved?.source ?? 'MISSING'}) skillsRoot=${skillsRoot} autoAudit=${autoAudit} fallbackSkill=${fallbackSkill}`);
377
528
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@caesarloo/dsh-skill-audit",
3
- "version": "0.1.2",
4
- "description": "Audit DSH skills automatically: a host-layer tools/post-execute plugin that runs the skill-audit engine after skill files change (write/edit/shell) or after a dsh_config_git_backup restore, feeding findings back to the model as context; also registers the skill_audit tool.",
3
+ "version": "0.2.0",
4
+ "description": "Audit DSH skills automatically: a host-layer tools/post-execute plugin that ships both the skill body and the audit engine, runs the engine after skill files change (write/edit/shell) or after a bulk restore/backup (any tool invoked with mode: restore|backup), feeding findings back to the model as context; also registers the skill_audit tool.",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "deepseek-harness",
@@ -23,7 +23,8 @@
23
23
  },
24
24
  "files": [
25
25
  "dist",
26
- "cordis.patch.yml"
26
+ "cordis.patch.yml",
27
+ "skill"
27
28
  ],
28
29
  "dsh": {
29
30
  "bundle": {
@@ -33,7 +34,9 @@
33
34
  "scripts": {
34
35
  "build": "tsc -p tsconfig.json",
35
36
  "typecheck": "tsc -p tsconfig.json --noEmit",
36
- "test": "node test/smoke.mjs"
37
+ "test": "node test/smoke.mjs && node scripts/check-leaks.mjs",
38
+ "check-leaks": "node scripts/check-leaks.mjs",
39
+ "prepublishOnly": "npm run build && npm test"
37
40
  },
38
41
  "peerDependencies": {
39
42
  "@deepseek-ai/cordis": "^4.0.1",