@caesarloo/dsh-skill-audit 0.1.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 +107 -0
- package/cordis.patch.yml +6 -0
- package/dist/index.js +377 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @caesarloo/dsh-skill-audit
|
|
2
|
+
|
|
3
|
+
让 **DSH 技能一改就自动被审核** 的插件:监听 `tools/post-execute`,在技能文件被 `write`/`edit`/shell 改动之后、或在 `dsh_config_git_backup` 的 `restore`/`backup` 之后,跑一遍技能审核,并把结论作为上下文回传给模型。同时注册 `skill_audit` 工具供按需调用。
|
|
4
|
+
|
|
5
|
+
## 为什么需要插件(而不是 hooks.json)
|
|
6
|
+
|
|
7
|
+
DSH 自带的 Claude Code 钩子桥接(`@deepseek-ai/dsh-hooks-claude-code`)通过 **`ctx.shell`** 执行钩子命令。当宿主没有可用的沙箱 runner 时(Windows 上 ACL 后端未挂载时的实测情形),执行器按设计 **fail-closed**:命令根本不会启动,会话日志里只留下
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
hook/invoked PostToolUse ...
|
|
11
|
+
hook/result decision=pass stderr=sandbox mode "workspace-write" is requested
|
|
12
|
+
but no sandbox backend is usable on this host; refusing to run the command unconfined
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
—— 也就是说 `hooks.json` 配置得再正确也没用。同类第三方 hooks 插件(如 `dsh-hooks-plugin`、`dsh-plugin-hooks`)同样走 `ctx.shell`,会撞同一堵墙。
|
|
16
|
+
|
|
17
|
+
本插件在 **harness 进程内**用 `ctx.subprocess`(host 层)直接跑审核脚本,**不经过 `ctx.shell`**,因此不受沙箱策略限制;这也是官方对"没有 Claude Code 对应物的定制行为"给出的推荐形态。
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
# 从 npm(推荐)
|
|
23
|
+
dsh plugin --profile web add @caesarloo/dsh-skill-audit
|
|
24
|
+
|
|
25
|
+
# 本地开发(link 到工作副本)
|
|
26
|
+
dsh plugin --profile web add C:\workspace\dsh-skill-audit
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
装完**重启 dsh**(bundle 层变更无 HMR;`dsh --profile web --dump-config` 应能看到 `id: tool-skill-audit`)。
|
|
30
|
+
|
|
31
|
+
## 前置
|
|
32
|
+
|
|
33
|
+
审核逻辑不在本插件内(**单一真源**),默认调用:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
<DSH_HOME>/skills/skill-audit/scripts/audit-skills.ps1
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
即 `skill-audit` 技能的静态审核引擎(frontmatter 契约、脚本 UTF-8 BOM + PowerShell 5.1 可解析、SKILL.md 引用完整性、凭据泄漏、机器专属路径、危险命令)。该脚本不存在时:`skill_audit` 工具返回明确错误,自动触发**静默跳过**(不会因为审核引擎缺失而干扰正常写文件)。
|
|
40
|
+
|
|
41
|
+
## 配置
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
- insert:
|
|
45
|
+
- id: tool-skill-audit
|
|
46
|
+
name: '@caesarloo/dsh-skill-audit'
|
|
47
|
+
config:
|
|
48
|
+
skillsRoot: 'C:\Users\me\.dsh\skills' # 可选,缺省 <DSH_HOME>/skills
|
|
49
|
+
auditScript: '...\audit-skills.ps1' # 可选,缺省 <skillsRoot>/skill-audit/scripts/audit-skills.ps1
|
|
50
|
+
autoAudit: true # 可选,false = 只保留工具、不做自动触发
|
|
51
|
+
powershell: 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
52
|
+
timeoutMs: 120000 # 可选,单次审核超时
|
|
53
|
+
maxContextChars: 2000 # 可选,回传上下文的字符上限
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
正常使用无需任何配置(路径从 `DSH_HOME` 推导)。
|
|
57
|
+
|
|
58
|
+
## 自动触发规则
|
|
59
|
+
|
|
60
|
+
| 工具调用 | 审核范围 |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| **写入类**文件工具(`write`/`edit`/`multi_edit`/`notebook_edit`/`apply_patch` …),目标路径落在 `<skillsRoot>/<技能>/` 下 | **只审该技能**(快,约 1 秒) |
|
|
63
|
+
| 只读工具(`read`/`glob`/`grep`) | **不触发** —— 它们同样携带 `file_path` 却不修改内容;不加这条白名单,每读一次技能文件就会注入一次审核上下文 |
|
|
64
|
+
| `dsh_config_git_backup`,`mode` 为 `restore` 或 `backup` | **全量**(整批覆盖 / 入库前体检) |
|
|
65
|
+
| `pwsh` / `bash` 等 shell,命令行同时含 `skills` 与写操作迹象(`Set-Content`/`Copy-Item`/`Remove-Item`/`robocopy`/`git checkout` …) | **全量**(shell 里改了哪个文件无法精确判定,宁可全量) |
|
|
66
|
+
| 其它工具、或路径不在技能目录内 | 不触发,静默 `next()` |
|
|
67
|
+
|
|
68
|
+
- 审核在 **`tools/post-execute`(写入之后)** 执行,因此审的是**新内容**(对比:`pre-execute` 时文件还没落盘,只能审到旧版本)。
|
|
69
|
+
- **上下文分级**:定向单技能(`write`/`edit`)时详列 `fail` + `warn`;**全量场景**(restore/backup、shell 批量改写)只详列 `fail`,`warn` 压成一行汇总 —— 本机 11 个技能里 9 个各有 1~3 条元数据类 warn,逐条列出会把上下文挤爆并失去焦点。**全量且只有 warn 时完全不注入**(背景噪音不该打断写入);全部通过时同样保持安静,只写 `<DSH_HOME>/vet/skill-audits/`。
|
|
70
|
+
- 自动触发**永远不会**影响工具调用本身:任何异常都被吞掉并委托 `next()`。
|
|
71
|
+
|
|
72
|
+
## `skill_audit` 工具
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
skill_audit() # 审计 skillsRoot 下全部技能
|
|
76
|
+
skill_audit({ skill: 'a,b' }) # 只审指定技能(逗号分隔)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
返回逐技能的通过/注意/失败与逐条发现,并把同样的 JSON 结论写入 `<DSH_HOME>/vet/skill-audits/`(`latest.json` + 时间戳档,保留最近 40 份)。
|
|
80
|
+
|
|
81
|
+
## 依赖约定
|
|
82
|
+
|
|
83
|
+
`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-subprocess`、`@deepseek-ai/dsh-llm` 一律声明为 **optional peerDependencies**,由宿主提供,本包不打包它们。这是刻意的:这两个包若与主包各装一份,会形成**两个模块实例**,而 `TOOL_RUNTIME_SCHEDULER` 是 `Symbol()`(非 `Symbol.for`),跨实例注册失败会导致**所有工具调用崩**。`dsh-llm` 仅用于构造回传上下文消息,缺失时插件降级为"审核照跑、不注入上下文"。
|
|
84
|
+
|
|
85
|
+
## 开发
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
pnpm install
|
|
89
|
+
pnpm run build # tsc → dist/
|
|
90
|
+
pnpm test # node test/smoke.mjs:假 ctx + 真实审核引擎,端到端
|
|
91
|
+
pnpm run typecheck
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
冒烟测试在临时目录里造两个技能(一个只有 warn、一个含 fail),用假 `ctx`(`tools.register` 捕获工具、`subprocess.spawn` 委托 `node:child_process`、`on()` 捕获 waterfall 处理器)驱动真实实现,断言:工具注册、`planAudit` 的六类范围判定、`parseReport` 的容错、定向/全量审核输出、上下文注入与静默路径、引擎缺失时的 fail-closed。
|
|
95
|
+
|
|
96
|
+
> `SMOKE_PLUGIN_DIST` 可指向另一份构建产物,但该副本必须位于**能解析 `@deepseek-ai/dsh-tools` 的树**中;不要指向 dsh 安装树(`~/.dsh/profiles/web/node_modules/...`)——裸 node 在那里会解析到版本不匹配的 `dsh-llm` 副本。
|
|
97
|
+
|
|
98
|
+
## 边界(明确不做)
|
|
99
|
+
|
|
100
|
+
- 不实现审核规则本身(在 `skill-audit` 技能的脚本里,此处只负责触发与回传);
|
|
101
|
+
- 不改写工具输入、不阻塞工具调用(`fail` 也只在上下文里告知模型);
|
|
102
|
+
- 不覆盖 `SessionStart`/`Stop` 等非工具事件;
|
|
103
|
+
- 不做技能目录的文件系统监视(`dsh-skill-filesystem` 已有 watcher;本插件只在工具调用后触发)。
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// dsh-skill-audit — 技能审核的自动触发器(host 层执行,不经 ctx.shell,因此不受沙箱限制)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么必须是插件,而不是 hooks 桥接:
|
|
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
|
+
// 对应物的定制行为"给出的推荐形态。
|
|
9
|
+
//
|
|
10
|
+
// 两条通道:
|
|
11
|
+
// 1) `tools/post-execute` 自动触发(写入**之后**,审的是新内容):
|
|
12
|
+
// · write / edit 命中 <DSH_HOME>/skills/<技能>/ → 只审该技能
|
|
13
|
+
// · dsh_config_git_backup 的 restore / backup → 全量(整批覆盖 / 入库前)
|
|
14
|
+
// · pwsh 等 shell,命令行同时含 skills 与写操作迹象 → 全量
|
|
15
|
+
// 2) `skill_audit` 工具 —— agent 可主动定向或全量审核。
|
|
16
|
+
//
|
|
17
|
+
// 审核逻辑不在本插件内(单一真源):默认调用
|
|
18
|
+
// <DSH_HOME>/skills/skill-audit/scripts/audit-skills.ps1
|
|
19
|
+
// 该脚本缺失时工具报明确错误、自动触发静默跳过(不打扰正常写文件)。
|
|
20
|
+
import { stat } from 'node:fs/promises';
|
|
21
|
+
import { isAbsolute, join, resolve as resolvePath } from 'node:path';
|
|
22
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
23
|
+
// Plugin display name, shown in loader diagnostics.
|
|
24
|
+
export const name = 'tool-skill-audit';
|
|
25
|
+
export const inject = ['tools', 'subprocess'];
|
|
26
|
+
const POWERSHELL = process.platform === 'win32'
|
|
27
|
+
? 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
|
28
|
+
: 'pwsh';
|
|
29
|
+
const RAW_OUTPUT_MAX_BYTES = 4 * 1024 * 1024; // 全量审核的 JSON 报告可达数十 KB,留足余量
|
|
30
|
+
const STDERR_MAX_BYTES = 256 * 1024;
|
|
31
|
+
const GRACE_MS = 3000;
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
33
|
+
const DEFAULT_MAX_CONTEXT_CHARS = 2000;
|
|
34
|
+
function dshHome() {
|
|
35
|
+
if (process.env.DSH_HOME)
|
|
36
|
+
return process.env.DSH_HOME;
|
|
37
|
+
const home = process.env.USERPROFILE ?? process.env.HOME ?? '.';
|
|
38
|
+
return join(home, '.dsh');
|
|
39
|
+
}
|
|
40
|
+
async function fileExists(path) {
|
|
41
|
+
try {
|
|
42
|
+
return (await stat(path)).isFile();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 从工具参数里尽力提取被操作的路径(不同工具键名不同,另有兜底扫描)。 */
|
|
49
|
+
export function candidatePaths(args) {
|
|
50
|
+
const out = [];
|
|
51
|
+
if (!args || typeof args !== 'object')
|
|
52
|
+
return out;
|
|
53
|
+
const rec = args;
|
|
54
|
+
for (const key of ['file_path', 'filePath', 'path', 'target_file', 'file', 'notebook_path']) {
|
|
55
|
+
const value = rec[key];
|
|
56
|
+
if (typeof value === 'string' && value.length > 0)
|
|
57
|
+
out.push(value);
|
|
58
|
+
}
|
|
59
|
+
for (const value of Object.values(rec)) {
|
|
60
|
+
if (typeof value === 'string' && /skills[\\/]/i.test(value))
|
|
61
|
+
out.push(value);
|
|
62
|
+
else if (Array.isArray(value)) {
|
|
63
|
+
for (const item of value) {
|
|
64
|
+
if (typeof item === 'string' && /skills[\\/]/i.test(item))
|
|
65
|
+
out.push(item);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...new Set(out)];
|
|
70
|
+
}
|
|
71
|
+
const SHELL_TOOLS = new Set(['pwsh', 'powershell', 'bash', 'sh', 'cmd']);
|
|
72
|
+
/**
|
|
73
|
+
* 只有写入类文件工具才触发审核。read / glob / grep 等只读工具同样携带 `file_path`,
|
|
74
|
+
* 但它们不修改内容 —— 2026-09-17 实测:不做这个白名单,每读一次技能文件就会注入一次
|
|
75
|
+
* 审核上下文(纯噪音)。新出现的写入工具名在此登记即可。
|
|
76
|
+
*/
|
|
77
|
+
const FILE_WRITE_TOOLS = new Set([
|
|
78
|
+
'write',
|
|
79
|
+
'edit',
|
|
80
|
+
'multi_edit',
|
|
81
|
+
'multiedit',
|
|
82
|
+
'notebook_edit',
|
|
83
|
+
'apply_patch',
|
|
84
|
+
'create_file',
|
|
85
|
+
'str_replace_editor',
|
|
86
|
+
]);
|
|
87
|
+
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;
|
|
88
|
+
/** 决定这次工具调用要不要触发审核;返回 null 表示与该工具无关。 */
|
|
89
|
+
export function planAudit(toolName, args, skillsRoot) {
|
|
90
|
+
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 || '?'}) → 全量` };
|
|
96
|
+
}
|
|
97
|
+
if (SHELL_TOOLS.has(lower)) {
|
|
98
|
+
const command = String(args?.command ?? '');
|
|
99
|
+
if (!command || !/skills/i.test(command) || !WRITE_HINTS.test(command))
|
|
100
|
+
return null;
|
|
101
|
+
return { skills: null, scope: `${toolName} → 命令行涉及 skills 目录(全量)` };
|
|
102
|
+
}
|
|
103
|
+
// 只读工具(read/glob/grep …)也带 file_path,但不改内容 → 不触发
|
|
104
|
+
if (!FILE_WRITE_TOOLS.has(lower))
|
|
105
|
+
return null;
|
|
106
|
+
const hits = [];
|
|
107
|
+
const root = skillsRoot.toLowerCase();
|
|
108
|
+
for (const raw of candidatePaths(args)) {
|
|
109
|
+
const abs = isAbsolute(raw) ? raw : resolvePath(raw);
|
|
110
|
+
if (!abs.toLowerCase().startsWith(root))
|
|
111
|
+
continue;
|
|
112
|
+
const seg = abs.slice(skillsRoot.length).replace(/^[\\/]+/, '').split(/[\\/]/)[0];
|
|
113
|
+
if (seg)
|
|
114
|
+
hits.push(seg);
|
|
115
|
+
}
|
|
116
|
+
if (hits.length === 0)
|
|
117
|
+
return null;
|
|
118
|
+
const skills = [...new Set(hits)];
|
|
119
|
+
return { skills, scope: `${toolName} → 技能 ${skills.join(', ')}` };
|
|
120
|
+
}
|
|
121
|
+
export function parseReport(text) {
|
|
122
|
+
const raw = text.trim();
|
|
123
|
+
if (!raw)
|
|
124
|
+
return null;
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(raw);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// 输出可能带前置警告/ BOM:从第一个 { 起再试一次
|
|
130
|
+
const idx = raw.indexOf('{');
|
|
131
|
+
if (idx <= 0)
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(raw.slice(idx));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function apply(ctx, config = {}) {
|
|
142
|
+
const skillsRoot = (config.skillsRoot ?? join(dshHome(), 'skills')).replace(/[\\/]+$/, '');
|
|
143
|
+
const auditScript = config.auditScript ?? join(skillsRoot, 'skill-audit', 'scripts', 'audit-skills.ps1');
|
|
144
|
+
const powershell = config.powershell ?? POWERSHELL;
|
|
145
|
+
const autoAudit = config.autoAudit !== false;
|
|
146
|
+
const timeoutMs = config.timeoutMs && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
147
|
+
const maxContextChars = config.maxContextChars && config.maxContextChars > 0
|
|
148
|
+
? config.maxContextChars
|
|
149
|
+
: DEFAULT_MAX_CONTEXT_CHARS;
|
|
150
|
+
async function runAudit(skills, signal) {
|
|
151
|
+
if (!(await fileExists(auditScript))) {
|
|
152
|
+
return { exitCode: -1, stdout: '', stderr: `审核脚本不存在: ${auditScript}` };
|
|
153
|
+
}
|
|
154
|
+
const argv = [
|
|
155
|
+
powershell,
|
|
156
|
+
'-NoProfile',
|
|
157
|
+
'-ExecutionPolicy',
|
|
158
|
+
'Bypass',
|
|
159
|
+
'-File',
|
|
160
|
+
auditScript,
|
|
161
|
+
'-Json',
|
|
162
|
+
'-SkillsRoot',
|
|
163
|
+
skillsRoot,
|
|
164
|
+
];
|
|
165
|
+
if (skills && skills.length > 0)
|
|
166
|
+
argv.push('-Skill', skills.join(','));
|
|
167
|
+
let handle;
|
|
168
|
+
try {
|
|
169
|
+
handle = ctx.subprocess.spawn({
|
|
170
|
+
argv,
|
|
171
|
+
cwd: skillsRoot,
|
|
172
|
+
stdio: {
|
|
173
|
+
stdin: 'ignore',
|
|
174
|
+
stdout: { maxBytes: RAW_OUTPUT_MAX_BYTES },
|
|
175
|
+
stderr: { maxBytes: STDERR_MAX_BYTES },
|
|
176
|
+
},
|
|
177
|
+
graceMs: GRACE_MS,
|
|
178
|
+
signal,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
return { exitCode: -1, stdout: '', stderr: `无法启动审核脚本: ${String(error)}` };
|
|
183
|
+
}
|
|
184
|
+
let outcome;
|
|
185
|
+
try {
|
|
186
|
+
outcome = await handle.done;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
return { exitCode: -1, stdout: '', stderr: `审核脚本执行失败: ${String(error)}` };
|
|
190
|
+
}
|
|
191
|
+
if (outcome.signal !== null || outcome.exitCode === null) {
|
|
192
|
+
return {
|
|
193
|
+
exitCode: -1,
|
|
194
|
+
stdout: '',
|
|
195
|
+
stderr: `审核脚本被信号终止: ${outcome.signal ?? '(unknown)'}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const stdout = handle.collected.stdout?.readFrom(0);
|
|
199
|
+
const stderr = handle.collected.stderr?.readFrom(0);
|
|
200
|
+
return {
|
|
201
|
+
exitCode: outcome.exitCode,
|
|
202
|
+
stdout: stdout?.text ?? '',
|
|
203
|
+
stderr: stderr?.text ?? '',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** 审核并把结论渲染成人读文本(供工具返回);无法解析时返回原始输出。 */
|
|
207
|
+
async function auditAndRender(skills, signal) {
|
|
208
|
+
const run = await runAudit(skills, signal);
|
|
209
|
+
if (run.exitCode === -1)
|
|
210
|
+
return { ok: false, text: run.stderr, report: null };
|
|
211
|
+
const report = parseReport(run.stdout);
|
|
212
|
+
if (!report) {
|
|
213
|
+
const fallback = [run.stdout.trim(), run.stderr.trim()].filter(Boolean).join('\n');
|
|
214
|
+
return { ok: false, text: fallback || '(审核脚本无输出)', report: null };
|
|
215
|
+
}
|
|
216
|
+
const problem = (report.results ?? []).filter((r) => r.status !== 'pass');
|
|
217
|
+
const lines = [];
|
|
218
|
+
lines.push(`技能审核:${report.results?.length ?? 0} 个技能,fail ${report.fail ?? 0},warn ${report.warn ?? 0}`);
|
|
219
|
+
for (const r of report.results ?? []) {
|
|
220
|
+
const mark = r.status === 'pass' ? '[通过]' : r.status === 'warn' ? '[注意]' : '[失败]';
|
|
221
|
+
lines.push(`${mark} ${r.skill}(脚本 ${r.scripts} 个,fail ${r.fails},warn ${r.warns})`);
|
|
222
|
+
for (const f of r.findings ?? []) {
|
|
223
|
+
if (f.level === 'info')
|
|
224
|
+
continue;
|
|
225
|
+
lines.push(` - [${f.level}] ${f.message}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (problem.length === 0)
|
|
229
|
+
lines.push('全部技能通过。');
|
|
230
|
+
return { ok: true, text: lines.join('\n'), report };
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 构造回传上下文;只有存在需要行动的问题时才返回消息。
|
|
234
|
+
*
|
|
235
|
+
* `failsOnly` 用于**全量场景**(restore/backup、shell 批量改写):那里 warn 的绝对数量很大
|
|
236
|
+
* (本机 11 个技能里 9 个各有 1~3 条元数据类 warn),逐条列出会把上下文挤爆且失去焦点 ——
|
|
237
|
+
* 全量时只详列 fail,warn 压成一行汇总;定向单技能时(通常 1~3 条)才 fail+warn 都列。
|
|
238
|
+
* 全量且**只有 warn**时直接返回 undefined:那属于背景噪音,不该打断任何一次写入。
|
|
239
|
+
*/
|
|
240
|
+
async function buildContextMessage(report, scope, failsOnly = false) {
|
|
241
|
+
const problem = (report.results ?? []).filter((r) => r.status !== 'pass');
|
|
242
|
+
if (problem.length === 0)
|
|
243
|
+
return undefined;
|
|
244
|
+
const lines = [`【技能审核 skill-audit】范围:${scope}`];
|
|
245
|
+
const warnOnly = [];
|
|
246
|
+
for (const r of problem) {
|
|
247
|
+
const detail = (r.findings ?? []).filter((f) => f.level !== 'info');
|
|
248
|
+
const fails = detail.filter((f) => f.level === 'fail');
|
|
249
|
+
if (failsOnly && fails.length === 0) {
|
|
250
|
+
warnOnly.push(r.skill);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const mark = r.status === 'fail' ? '未通过' : '有注意项';
|
|
254
|
+
lines.push(`· ${r.skill}:${mark}(fail ${r.fails} / warn ${r.warns})`);
|
|
255
|
+
const list = failsOnly ? fails : detail;
|
|
256
|
+
const shown = list.slice(0, 4);
|
|
257
|
+
for (const f of shown)
|
|
258
|
+
lines.push(` - [${f.level}] ${f.message}`);
|
|
259
|
+
if (list.length > shown.length) {
|
|
260
|
+
lines.push(` … 另有 ${list.length - shown.length} 项,见 ~/.dsh/vet/skill-audits/latest.json`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// 全量场景下一条 fail 都没有 → 完全不打扰(warn 是背景噪音,只留在日志里)。
|
|
264
|
+
// 注意必须在这里早退:一旦先 push 了下面的汇总行,再判断"只剩标题"就永远不成立。
|
|
265
|
+
if (failsOnly && lines.length === 1)
|
|
266
|
+
return undefined;
|
|
267
|
+
if (warnOnly.length > 0) {
|
|
268
|
+
const head = warnOnly.slice(0, 8).join('、');
|
|
269
|
+
lines.push(`· 另有 ${warnOnly.length} 个技能仅有 warn(多为缺 version/whenToUse 等元数据):${head}${warnOnly.length > 8 ? ' …' : ''}`);
|
|
270
|
+
}
|
|
271
|
+
lines.push('修复后可调用 skill_audit 工具复验;判据与误报处置见 skill-audit 技能。');
|
|
272
|
+
let text = lines.join('\n');
|
|
273
|
+
if (text.length > maxContextChars) {
|
|
274
|
+
text = `${text.slice(0, maxContextChars)}\n…(已截断;完整结论见 ~/.dsh/vet/skill-audits/latest.json)`;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const llm = (await import('@deepseek-ai/dsh-llm'));
|
|
278
|
+
if (typeof llm.createUserMessage !== 'function')
|
|
279
|
+
return undefined;
|
|
280
|
+
return llm.createUserMessage({
|
|
281
|
+
content: [{ type: 'text', text }],
|
|
282
|
+
source: { kind: 'plugin', plugin: 'tool-skill-audit' },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// dsh-llm 不可用时降级:审核照常执行并留日志,只是不注入上下文
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
ctx.tools.register(defineTool({
|
|
291
|
+
name: 'skill_audit',
|
|
292
|
+
description: 'Statically audit DSH skills: frontmatter contract (name/description/whenToUse/version), ' +
|
|
293
|
+
'script usability (UTF-8 BOM + PowerShell 5.1 parse), SKILL.md reference integrity, ' +
|
|
294
|
+
'credential leakage, machine-specific paths and dangerous command patterns. ' +
|
|
295
|
+
'Pass "skill" to audit one or more skills (comma separated); omit it to audit every skill ' +
|
|
296
|
+
'under the skills root. Findings are also logged to <DSH_HOME>/vet/skill-audits/.',
|
|
297
|
+
parameters: {
|
|
298
|
+
skill: {
|
|
299
|
+
type: 'string',
|
|
300
|
+
description: 'Skill name(s) to audit, comma separated. Omit to audit all skills in the skills root.',
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
output: {
|
|
304
|
+
schema: {
|
|
305
|
+
type: 'object',
|
|
306
|
+
properties: {
|
|
307
|
+
stdout: { type: 'string', required: true, description: 'Human-readable audit report.' },
|
|
308
|
+
stderr: { type: 'string', required: true, description: 'Diagnostics when the run failed.' },
|
|
309
|
+
},
|
|
310
|
+
additionalProperties: false,
|
|
311
|
+
},
|
|
312
|
+
render: (_args, value) => [
|
|
313
|
+
{
|
|
314
|
+
type: 'text',
|
|
315
|
+
text: (value.stdout?.trim?.()?.length ?? 0) > 0
|
|
316
|
+
? value.stdout
|
|
317
|
+
: value.stderr?.trim?.()?.length > 0
|
|
318
|
+
? `(no stdout) ${value.stderr}`
|
|
319
|
+
: '(empty output)',
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
timeoutMs,
|
|
324
|
+
async execute(args, exec) {
|
|
325
|
+
if (exec.signal.aborted)
|
|
326
|
+
throw new Error('skill_audit was aborted before completion');
|
|
327
|
+
const raw = typeof args.skill === 'string' ? args.skill.trim() : '';
|
|
328
|
+
const skills = raw
|
|
329
|
+
? raw
|
|
330
|
+
.split(',')
|
|
331
|
+
.map((s) => s.trim())
|
|
332
|
+
.filter(Boolean)
|
|
333
|
+
: null;
|
|
334
|
+
const { ok, text } = await auditAndRender(skills, exec.signal);
|
|
335
|
+
if (exec.signal.aborted)
|
|
336
|
+
throw new Error('skill_audit was aborted before completion');
|
|
337
|
+
if (!ok)
|
|
338
|
+
throw new Error(`skill_audit 执行失败:\n${text}`);
|
|
339
|
+
return { stdout: text, stderr: '' };
|
|
340
|
+
},
|
|
341
|
+
}));
|
|
342
|
+
if (autoAudit) {
|
|
343
|
+
const on = ctx.on;
|
|
344
|
+
if (typeof on === 'function') {
|
|
345
|
+
on('tools/post-execute', async (...handlerArgs) => {
|
|
346
|
+
// waterfall 契约:(exec, result, next)
|
|
347
|
+
const exec = handlerArgs[0];
|
|
348
|
+
const next = handlerArgs[2];
|
|
349
|
+
try {
|
|
350
|
+
const toolName = typeof exec?.name === 'string' ? exec.name : '';
|
|
351
|
+
if (!toolName || typeof next !== 'function')
|
|
352
|
+
return await next?.();
|
|
353
|
+
const plan = planAudit(toolName, exec?.arguments, skillsRoot);
|
|
354
|
+
if (!plan)
|
|
355
|
+
return await next();
|
|
356
|
+
const run = await runAudit(plan.skills, exec?.signal);
|
|
357
|
+
const report = run.exitCode === -1 ? null : parseReport(run.stdout);
|
|
358
|
+
const context = report
|
|
359
|
+
? await buildContextMessage(report, plan.scope, plan.skills === null)
|
|
360
|
+
: undefined;
|
|
361
|
+
const downstream = (await next());
|
|
362
|
+
if (!context)
|
|
363
|
+
return downstream;
|
|
364
|
+
const existing = Array.isArray(downstream?.additionalContexts)
|
|
365
|
+
? downstream.additionalContexts
|
|
366
|
+
: [];
|
|
367
|
+
return { ...(downstream ?? {}), additionalContexts: [context, ...existing] };
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// 自动触发永远不能影响工具调用本身
|
|
371
|
+
return await next?.();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
ctx.logger.info(`[tool-skill-audit] registered "skill_audit" — script=${auditScript} skillsRoot=${skillsRoot} autoAudit=${autoAudit}`);
|
|
377
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@caesarloo/dsh-skill-audit",
|
|
3
|
+
"version": "0.1.0",
|
|
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.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh-plugin",
|
|
9
|
+
"skill",
|
|
10
|
+
"audit",
|
|
11
|
+
"lint",
|
|
12
|
+
"quality"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/caesarloo/dsh-skill-audit.git"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"cordis.patch.yml"
|
|
27
|
+
],
|
|
28
|
+
"dsh": {
|
|
29
|
+
"bundle": {
|
|
30
|
+
"patch": "./cordis.patch.yml"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
36
|
+
"test": "node test/smoke.mjs"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
40
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
|
|
41
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.5-rc.2",
|
|
42
|
+
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@deepseek-ai/dsh-llm": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"@deepseek-ai/dsh-subprocess": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"@deepseek-ai/dsh-tools": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
57
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.5-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
|
|
60
|
+
"@types/node": "^22.10.0",
|
|
61
|
+
"typescript": "^5.6.0"
|
|
62
|
+
}
|
|
63
|
+
}
|