dsh-rule-engine 0.5.8 → 0.5.10

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
@@ -1,7 +1,7 @@
1
1
  # dsh-rule-engine
2
2
 
3
3
  ![npm](https://img.shields.io/npm/v/dsh-rule-engine)
4
- ![version](https://img.shields.io/badge/version-0.5.8-blue)
4
+ ![version](https://img.shields.io/badge/version-0.5.10-blue)
5
5
 
6
6
  DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。
7
7
 
@@ -42,10 +42,36 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
42
42
  ## 质量与验证(2026-08-26,对齐官方 docs/testing.zh.md)
43
43
 
44
44
  - `npm run test`(全量单测;`test/run-all.mjs` 统一入口,注意 ESM 缓存顺序约定);
45
- - `node scripts/verify-all.mjs` —— 交付前四层体检:语法(lib 全文件 `node --check`)→ 单元(run-all)→ **组合冒烟**(`test/loader-smoke.e2e.mjs`:真实引擎代码 + 真实审计文件,仅 mock LLM 边界,断言**外部世界**——审计文件里真的出现 judge-false/judge-pass 记录,而非自我报告)→ **真实判例**(近 24h 台账 judge-pass/false 记录数,0 条 = WARN 提示需实弹);
45
+ - `node scripts/verify-all.mjs` —— 交付前**五层**体检:语法(lib 全文件 `node --check`)→ 单元(run-all)→ **组合冒烟**(`test/loader-smoke.e2e.mjs`:真实引擎代码 + 真实审计文件,仅 mock LLM 边界,断言**外部世界**——审计文件里真的出现 judge-false/judge-pass 记录,而非自我报告)→ **工具箱覆盖**(`scripts/check-tool-coverage.mjs`:官方 tool-catalog 全集 vs 分类表,出现 unknown 即红)→ **真实判例**(近 24h 台账 judge-pass/false 记录数,0 条 = WARN 提示需实弹);
46
46
  - `node scripts/health-audit.mjs` —— 找茬清单:近 24h 失败/降级类统计(intent-llm 失败、judge-unavailable、verify-gap、inject-skip…)+ 关键导出接线交叉(疑似未接线 = 告警)——"失败可见化",不再有静默躺 20 小时的降级;
47
47
  - 执行协议(本仓库自身交付纪律):方案冻结单(范围/影响面/测试计划/失败预测)→ todo 化 → 小步闭环(每改动立即 `node --check`)→ 对账交付(计划×实际逐项 ✅/❌/跳过原因)。
48
48
 
49
+ ## 0.5.10(2026-08-27 追加)
50
+
51
+ > 收编:用户审查后的 real-legal 会话建议(建议 2/3/4/5,依据本手册知识条目 K-01~K-06 批判性审查)+ 分析通道(用户多次提出的"只读分析需要写临时脚本/输出"痛点)+ 统一入口加固(2026-08-27 手册损伤事故复盘)。
52
+
53
+ - **分析通道(单真源 `isAnalysisOp`)**:严格只读 ∪ 分析脚本区(repo `scripts/*.mjs`)∪ 分析临时区写(`logs/` `.analysis-tmp/` `.backups/`)→ 任何回合放行 + 审计 `analysis-scratch`;**红线**(红线层在 guard-core):受保护文件名/工作区外(isOutsideWorkspace)/删除移动/覆盖正式文件一律不豁免;
54
+ - **写类判定单真源**:self-protect / 22 / 13A 统一走 `isMutationCommand`——修复 `Write-Output` 被 `write` 子串误杀(WGO654/ES3VCD 案例:纯读命令查受保护文件名被误拦);
55
+ - **只读词表补全**:ForEach-Object / Get-ItemProperty / Get-Variable / Get-FileHash(多行"读+筛选+循环"组合判只读);
56
+ - **统一入口加固**(dsh-manual-write.mjs):① 转义事故特征检测——表格行内字面 `\n` / `\$`(PowerShell 单引号转义事故模式,2026-08-27 手册坏行事故)→ 拒绝;② 写后结构校验——表格块列宽一致(±2 容忍)+ 手册版本记录行存在,不通过自动回滚;
57
+ - **误判打标闭环(建议 4)**:所有硬拦文案尾部附「误判可打标:/guard label <事件号> incorrect」指引;health-audit 新增 deny 总数 vs incorrect/correct 打标占比统计(词表迭代量化依据);
58
+ - **已知坑召回(建议 5)**:工具错误文本命中特征表(SEC_E_NO_CREDENTIALS / ECONNREFUSED:7890 / EPERM 管道 / ERR_MODULE_NOT_FOUND)→ 审计 `error-hint` + 注入指向知识库提示(本轮去重、走投递资格链,不违反注入噪音治理);
59
+ - **质保**:新增强力测试(写类判定/分析通道/临时区红线/转义检测 REFUSED 实测/召回特征)+ verify-all 五层全绿 + 工具箱覆盖门禁回归。
60
+
61
+ ## 0.5.9(2026-08-27 追加)
62
+
63
+ > 修复方向(用户"整体审查"要求,依据手册知识条目 K-01~K-06):静态工具清单漏分类官方工具
64
+ > (`run_code` 事故——code 模式会话一切被拦)→ 单一真源 + 前缀规则 + 覆盖门禁 + 白名单可视化。
65
+
66
+ - **工具分类单真源**:规则 24④ 守卫与 unknown 处置统一读 `lib/core/tool-catalog.js` 唯一分类表(废弃双表——run_code 正是双表都漏的受害者);
67
+ - **官方工具全集补全**:59 个官方 tool-catalog 工具名全覆盖(`cordis_*`/`terminal_*`/`session_*`/goal/jobs/子代理/团队/plan-mode/官方保留传输 `run_code` 等)——Code Mode 会话(模型只能直呼 `run_code`)不再被"未知工具"拦死;
68
+ - **前缀规则**:`mcp__`(变更类走授权,照 Claude Code `mcp__*` 范式)、`esr_`/`dev_` 等生态命名空间自动归类——**将来新增插件工具遵循惯例即被覆盖**(不再依赖静态枚举);
69
+ - **unknown 首调处置 `unknownPolicy`**:默认 `deny`(保守:`ask` 弹窗模式需真实场景实弹验证后才可作默认;配置 `"ask"` 可切换官方弹窗——approval 弹窗 allowed-once,无审批通道自动拒绝,官方 fail-closed 语义);
70
+ - **白名单 v2 带元数据**:`~/.dsh/rule-engine-tools.json` 升级为 `[{name,time,session}]`(**谁、何时、在哪个会话被放行**——可视化基础);旧 `["name"]` 格式兼容加载;历史条目显示"时间/来源未知";
71
+ - **`/guard tools` 命令**:查看白名单(永久 + 本会话新增,含时间/来源会话)+ `/guard tools revoke <工具名>` 撤销(持久化+会话集同步);
72
+ - **工具箱覆盖门禁**:`scripts/check-tool-coverage.mjs`(官方 tool-catalog vs 分类表,任一 unknown 即红——`run_code` 事故同类的机器防线)已挂入 `verify-all` 第五层;
73
+ - **测试**:全量单测(新增 whitelist 格式 5 用例)+ loader-smoke 新增 E 场景(白名单 v2 真实落盘断言:对象数组/时间/来源会话/审计留痕)+ verify-all **五层**全绿。
74
+
49
75
  ## 0.5.8(2026-08-26 追加)
50
76
 
51
77
  - **白名单持久化**:"允许使用 X"(未归类工具批准)落盘 `~/.dsh/rule-engine-tools.json`——此前为内存态,热重载/重启即清(用户需反复重发,实测 esr_task/esr_close 各被清一次);
@@ -77,6 +103,8 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
77
103
  | `/guard budget ...` | 设置预算(agents=N files=... deps=allow hash=allow) |
78
104
  | `/guard contract` | 查看当前任务契约 |
79
105
  | `/guard label <id> <label>` | 给审计记录打标(correct/incorrect/inconclusive) |
106
+ | `/guard tools` | 查看工具放行白名单(永久+本会话,含时间/来源会话) |
107
+ | `/guard tools revoke <名>` | 撤销白名单条目(持久化+会话集同步移除) |
80
108
 
81
109
  ## 装配方式
82
110
 
@@ -16,12 +16,17 @@ import {
16
16
  isBackupTool,
17
17
  isHighRiskEntryFile,
18
18
  isManualReadTool,
19
+ isMutationCommand,
20
+ isOutsideWorkspace,
19
21
  isProtectedConfigPath,
20
22
  isReadOnlyTool,
21
23
  isSensitiveToolCall,
22
24
  isVariablePath,
23
25
  isVerificationCommand,
24
26
  isLowRiskWorkspaceNew,
27
+ isAnalysisOp,
28
+ isAnalysisScratchPath,
29
+ extractAnalysisScratchPaths,
25
30
  pathTarget
26
31
  } from "./patterns.js";
27
32
  import { authMatches, describeAuth, describeOp, describeScopes, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText, scopesFromIntents } from "./authorization.js";
@@ -61,20 +66,13 @@ function makeHit(cfg, reason) {
61
66
  ruleId: cfg.ruleId,
62
67
  title: cfg.title,
63
68
  action: "deny",
64
- reason: `${reason}(规则 ${cfg.ruleId}|放行:${hint}|ERR-${errId})`
69
+ reason: `${reason}(规则 ${cfg.ruleId}|放行:${hint}|ERR-${errId}|误判可打标:/guard log 找该 ERR 码 → /guard label <事件号> incorrect)`
65
70
  };
66
71
  }
67
72
 
68
- export const COVERED_MUTATION_TOOLS = new Set([
69
- "edit", "write", "str_replace_editor", "pwsh", "bash",
70
- "dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote"
71
- ]);
72
- export const SAFE_UNCOVERED_TOOLS = new Set([
73
- "ask_user_question", "todo_write", "subagent", "workflow", "visualize", "skill",
74
- "read", "grep", "glob", "read_image", "job_list", "job_output", "list_agents",
75
- "get_goal", "dev_plugin_status", "dev_reload_package", "dev_injected_list",
76
- "dev_stage_list", "dev_router_status", "dev_self_test"
77
- ]);
73
+ // 0.5.9(单真源):COVERED_MUTATION_TOOLS / SAFE_UNCOVERED_TOOLS 独立覆盖表已废弃——
74
+ // 工具分类以 lib/core/tool-catalog.js 的唯一分类表为准(24④/22/unknown 全部读它),
75
+ // 双表漂移即 run_code 事故根因(K-01/K-02/K-06 依据)。
78
76
 
79
77
  function looksLikeFileMutation(name, args) {
80
78
  const a = args || {};
@@ -249,9 +247,10 @@ export function guardDecision(state, exec, now = Date.now(), opts = {}) {
249
247
 
250
248
  // 阶段 C:硬拦 pwsh/bash 绕过统一入口直接写受保护文件(规则 19⑧/21⑨ 的机器执行层)
251
249
  // 合法通道 = 整条命令仅调用 dsh-manual-write.mjs(无 ; | & 链式/换行,防注释文本伪造放行)
250
+ // 0.5.10:写类判定改 isMutationCommand(单真源)——修复 `Write-Output` 被 `write` 子串误杀(WGO654/ES3VCD 同类)
252
251
  if (!unlock && (name === "pwsh" || name === "bash")) {
253
252
  const cmd = commandText(args) || "";
254
- if (cmd && /(?:set-content|out-file|add-content|write|copy-item|move-item|rename-item|writefilesync|fs\.writefilesync)/i.test(cmd) && PROTECTED_FILENAME_RE.test(cmd)) {
253
+ if (cmd && isMutationCommand(cmd) && PROTECTED_FILENAME_RE.test(cmd)) {
255
254
  if (!isEntryChannelCommand(cmd)) {
256
255
  return makeHit(
257
256
  { ruleId: "__self-protect", title: "受保护文件禁止绕过统一入口直写", action: "deny" },
@@ -328,10 +327,20 @@ function matchRule(cfg, ctx) {
328
327
  //("你的执行方案难道没问题吗?"虽含"执行"仍是疑问句——同形词不构成指令,不豁免)
329
328
  if (cfg.handler === "rule22-7-direct") {
330
329
  // 机制 B(2026-08-24):工具分类制(纯函数,覆盖现有与未来插件注册的全部工具)——
331
- // control(SAFE 无条件放行)/ analysis(只读分析放行)/ artifact(产物放行+审计)/
332
- // unknown(物理不拦,由 pre-execute ask 层首调询问,防新插件绕过);mutating 维持原有严格逻辑。
333
- if (SAFE_UNCOVERED_TOOLS.has(name)) return null; // 授权/委托/技能/查询展示类 → 无条件放行
330
+ // analysis(只读分析放行)/ artifact(产物放行+审计)/ unknown(物理不拦,由 pre-execute
331
+ // ask/deny 层首调处置,防新插件绕过);mutating 维持原有严格逻辑。0.5.9 单真源:唯一分类表。
334
332
  const cls = toolClass(name, args);
333
+ // 0.5.10 分析通道(用户多次提出:只读分析需要写临时脚本/输出——解压副本/聚合日志等):
334
+ // 严格只读 ∪ 分析临时区写 ∪ 分析脚本区调用 → 任何回合放行 + 审计留痕。
335
+ // 红线:工作区外路径命中临时区段(如 C:\xxx\logs\)不豁免——落入常规变更判定。
336
+ if (isAnalysisOp(name, args)) {
337
+ const scratch = extractAnalysisScratchPaths(commandText(args) || "");
338
+ const outside = scratch.filter((p) => isOutsideWorkspace(p, sessionIdOf(exec)));
339
+ if (outside.length === 0) {
340
+ audit?.({ kind: "analysis-scratch", rule: cfg.ruleId, name: "分析通道放行", tool: name, reason: `分析类操作(只读/临时区/分析脚本区)放行:${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
341
+ return null;
342
+ }
343
+ }
335
344
  if (cls === "analysis") return null;
336
345
  if (cls === "artifact") {
337
346
  audit?.({ kind: "allow", rule: cfg.ruleId, name: "产物类工具放行", tool: name, reason: `分类 artifact(低风险产物写入,留痕可对账):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
@@ -359,7 +368,7 @@ function matchRule(cfg, ctx) {
359
368
  // 无用户消息/状态信号/ask 答复后回合仍不做规则 22 判定(由 12A/13A 把关)。
360
369
  if (!turnIntents || !turnIntents.hasExecute) return null;
361
370
  if (isReadOnlyTool(name, args)) return null;
362
- if (!COVERED_MUTATION_TOOLS.has(name) && !looksLikeFileMutation(name, args)) return null;
371
+ if (cls === "mutating" || looksLikeFileMutation(name, args)) {} else return null;
363
372
  const op = operationOf(name, args);
364
373
  // v0.5.7 P0-1(用户拍板 2026-08-26):验证类命令伴生放行——本回合已有 write/any/command
365
374
  // 授权(用户已批"修改/执行")时,运行测试/冷加载/审计/语法检查属于该变更的验证闭环,
@@ -557,10 +566,11 @@ function matchRule(cfg, ctx) {
557
566
  return makeHit(cfg, `【硬拦截】${p} 的 dsh.profile.bundles 包含非 bundle/无法确认类型:${badBundles.join(";")}(规则 24)`);
558
567
  }
559
568
  // 规则 24④:所有能产生文件写入/删除/移动效果的工具都必须纳入统一守卫——
560
- // 未覆盖的变更类工具运行时直接拒绝(防"改个新工具就绕过守卫")
569
+ // 0.5.9 单真源:已分类(tool-catalog 唯一表)= 已纳入守卫,放行;未分类且疑似变更 = 运行时拒绝
570
+ // (防"改个新工具就绕过守卫";与工具覆盖门禁 K-01/K-06 互补)
561
571
  if (isReadOnlyTool(name, args)) return null;
562
- if (COVERED_MUTATION_TOOLS.has(name)) return null;
563
- if (SAFE_UNCOVERED_TOOLS.has(name)) return null;
572
+ const cls24 = toolClass(name, args);
573
+ if (cls24 !== "unknown") return null;
564
574
  if (looksLikeFileMutation(name, args)) {
565
575
  return makeHit(cfg, `【硬拦截】未覆盖的变更类工具 ${name},违反规则 24④:请先纳入统一守卫覆盖`);
566
576
  }
@@ -237,10 +237,15 @@ export function isGetDateCommand(command) {
237
237
 
238
238
  const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "read_image"]);
239
239
  const READONLY_CMD_RE =
240
- /(?:\b(?:Get-Content|Get-ChildItem|Get-Item|Get-Command|Get-Date|Select-String|Find-String|Test-Path|Get-Process|Get-Service|cat|type|dir|ls|grep|findstr|more|netstat|where|echo|Write-Output|Write-Host|Get-Location|pwd|cwd|Get-Help|help|Select-Object|Out-String|Format-Table|Format-List|Format-Wide|Measure-Object|Sort-Object|Where-Object|Group-Object|Out-Null|ConvertTo-Json|ConvertFrom-Json)\b)|(?:\bgit\s+(?:-[^\s]+\s+[^\s]+\s+|--[^\s]+\s+)*\b(?:status|log|diff|show|branch|tag|remote|rev-parse|diff-tree|ls-files)(?:\s|$))|(?:\bdsh\s+(?:--version|--help|--dump-config|plugin\s+(?:list|show|status))(?:\s|$))|(?:\bdsh\s+--profile\s+[^\s]+\s+(?:--dump-config|plugin\s+(?:list|show|status))(?:\s|$))|(?:\bnode\s+(?:--check|--test|--version)(?:\s|$))|(?:\bnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bpnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bgh\s+(?:auth\s+status|repo\s+view)(?:\s|$))|(?:\bnpm\s+(?:ls|view)(?:\s|$))/i;
240
+ /(?:\b(?:Get-Content|Get-ChildItem|Get-Item|Get-Command|Get-Date|Select-String|Find-String|Test-Path|Get-Process|Get-Service|Get-ItemProperty|Get-Variable|Get-FileHash|cat|type|dir|ls|grep|findstr|more|netstat|where|echo|Write-Output|Write-Host|Get-Location|pwd|cwd|Get-Help|help|Select-Object|Out-String|Format-Table|Format-List|Format-Wide|Measure-Object|Sort-Object|Where-Object|Group-Object|ForEach-Object|Out-Null|ConvertTo-Json|ConvertFrom-Json)\b)|(?:\bgit\s+(?:-[^\s]+\s+[^\s]+\s+|--[^\s]+\s+)*\b(?:status|log|diff|show|branch|tag|remote|rev-parse|diff-tree|ls-files)(?:\s|$))|(?:\bdsh\s+(?:--version|--help|--dump-config|plugin\s+(?:list|show|status))(?:\s|$))|(?:\bdsh\s+--profile\s+[^\s]+\s+(?:--dump-config|plugin\s+(?:list|show|status))(?:\s|$))|(?:\bnode\s+(?:--check|--test|--version)(?:\s|$))|(?:\bnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bpnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bgh\s+(?:auth\s+status|repo\s+view)(?:\s|$))|(?:\bnpm\s+(?:ls|view)(?:\s|$))/i;
241
241
  const MUTATING_CMD_RE =
242
242
  /(?:Set-Content|Add-Content|Out-File|Remove-Item|Move-Item|Copy-Item|Rename-Item|New-Item|Clear-Content|git\s+(?:push|commit)|rm\s+-r|rmdir\s+\/s|del\s+\/s|(?:^|[^0-9])>>|(?:^|[^0-9])>)/i;
243
243
 
244
+ /** 写/删除/提交类命令判定(0.5.10 单真源:guard-core self-protect 等全部复用——修复 Write-Output 被 `write` 子串误杀) */
245
+ export function isMutationCommand(command) {
246
+ return typeof command === "string" && MUTATING_CMD_RE.test(command);
247
+ }
248
+
244
249
  /** 只读命令链按分隔符折段(机制 B,2026-08-24):`; 换行 && || |`(管道前后是命令链;`2>&1`/`2>$null` 的 stderr 重定向不拆分) */
245
250
  function splitCommandSegments(command) {
246
251
  return String(command)
@@ -273,6 +278,74 @@ export function isReadOnlyTool(toolName, args) {
273
278
  return false;
274
279
  }
275
280
 
281
+ // ═══════════ 0.5.10 分析通道(用户多次提出"只读分析需要写临时脚本/输出")═══════════
282
+ // 两类区:① 分析脚本区(scripts/analysis/*.mjs,仓库内可审查)② 分析临时区(logs/ .analysis-tmp/ .backups/,
283
+ // 输出/解压副本/分析脚本产物——不纳入 git 跟踪、不属正式变更)。红线由 guard-core 追加(工作区外/受保护名/覆盖既有)。
284
+ const ANALYSIS_SCRATCH_SEG_RE = /(?:^|[\\/])(?:logs|\.analysis-tmp|\.backups)[\\/]/i;
285
+ // 分析脚本区:仓库内 scripts/ 任意 .mjs(可审查工具库——unzstd/聚合日志/诊断脚本)+ scripts/analysis/ 专用
286
+ const ANALYSIS_SCRIPT_RE = /node\s+[^&;|]*[\\/]scripts[\\/][^\s&;|"]+\.mjs/i;
287
+
288
+ /** 是否分析临时区路径(logs/.analysis-tmp/.backups 段;工作区外红线由 guard-core 用 isOutsideWorkspace 把关) */
289
+ export function isAnalysisScratchPath(p) {
290
+ if (typeof p !== "string" || !p) return false;
291
+ return ANALYSIS_SCRATCH_SEG_RE.test(p.replace(/\\/g, "/"));
292
+ }
293
+
294
+ /** 提取命令文本中命中分析临时区的路径 token(保守:仅取带临时区特征的片段) */
295
+ export function extractAnalysisScratchPaths(command) {
296
+ const n = String(command || "");
297
+ const out = [];
298
+ const re = /(?:logs|\.analysis-tmp|\.backups)[\\/][^\s"'&|;]+/gi;
299
+ let m;
300
+ while ((m = re.exec(n))) out.push(m[0].replace(/\\/g, "/"));
301
+ return out;
302
+ }
303
+
304
+ /**
305
+ * 分析类操作判定(0.5.10 单真源):true = 可按 analysis 放行(无副作用,或副作用仅限分析临时区)。
306
+ * 场景:① 严格只读(isReadOnlyTool);② 分析脚本区调用(node scripts/analysis/*.mjs);
307
+ * ③ 临时区写(命令含写特征且提取到的目标路径全部命中分析临时区,且无受保护文件名)。
308
+ * 红线(guard-core 追加,本函数不含):工作区外路径(isOutsideWorkspace)、受保护名、覆盖既有正式文件。
309
+ */
310
+ export function isAnalysisOp(toolName, args) {
311
+ const name = String(toolName || "");
312
+ if (isReadOnlyTool(name, args)) return true;
313
+ const cmd = commandText(args) || "";
314
+ if (name === "node" || name === "pwsh" || name === "bash") {
315
+ // ② 分析脚本区调用(脚本自身可写临时区/读全局;命令无其他副作用特征即可)
316
+ if (ANALYSIS_SCRIPT_RE.test(cmd)) {
317
+ const paths = extractAnalysisScratchPaths(cmd);
318
+ if (paths.every((p) => isAnalysisScratchPath(p)) && !PROTECTED_FILENAME_RE.test(cmd)) return true;
319
+ }
320
+ // ③ 临时区写:写命令(不含删除/移动——删除/移动属 13A 红线,不走分析豁免)+ 提取路径全部命中临时区 + 无受保护名
321
+ if (isMutationCommand(cmd) && !/(?:Remove-Item|Move-Item|Rename-Item|del\s|rm\s|git\s+push|git\s+commit)/i.test(cmd)) {
322
+ const paths = extractAnalysisScratchPaths(cmd);
323
+ if (paths.length > 0 && paths.every((p) => isAnalysisScratchPath(p)) && !PROTECTED_FILENAME_RE.test(cmd)) {
324
+ return true;
325
+ }
326
+ }
327
+ }
328
+ return false;
329
+ }
330
+
331
+ // ═══════════ 0.5.10 已知环境坑特征表(建议5——错误码自动召回,K-01/K-05 依据)═══════════
332
+ // 工具/命令错误文本命中特征 → 审计 error-hint + 注入指向知识库的提示(低频:仅命中才提示;语义=指路不打扰)。
333
+ const KNOWN_PITFALLS = [
334
+ { key: "SEC_E_NO_CREDENTIALS", re: /SEC_E_NO_CREDENTIALS/i, hint: "TLS 凭据失败(常见于沙箱/受限会话)——先查手册踩坑 60/89;Node fetch 配 NODE_USE_ENV_PROXY 可绕" },
335
+ { key: "proxy-7890-refused", re: /ECONNREFUSED[^\n]*7890/i, hint: "代理端口 7890 无监听——确认代理软件已启动;git/gh 可直连,npm registry 直连本机不可用(发布脚本默认直连,DSH_RELEASE_PROXY 可指定)" },
336
+ { key: "sandbox-pipe-EPERM", re: /EPERM/i, hint: "受限沙箱下子进程管道受限(踩坑 22⑤)——需 danger-full-access 运行" },
337
+ { key: "ERR_MODULE_NOT_FOUND", re: /ERR_MODULE_NOT_FOUND/i, hint: "模块缺失——检查依赖闭包/空壳目录(踩坑 54)" }
338
+ ];
339
+
340
+ /** 已知坑特征匹配(纯函数):错误文本命中特征表 → 返回条目;未命中 → null */
341
+ export function matchKnownPitfall(text) {
342
+ const t = String(text || "");
343
+ for (const p of KNOWN_PITFALLS) if (p.re.test(t)) return p;
344
+ return null;
345
+ }
346
+
347
+ export { KNOWN_PITFALLS };
348
+
276
349
  /**
277
350
  * node -e 内联代码的危险特征判定(机制批 M4:diag-run 受控诊断工具使用)。
278
351
  * 黑名单命中任一 = 判定有写/网络/进程副作用(保守:宁可拒)。规则 9 正文不动(A2=B 方案)。
package/lib/core/state.js CHANGED
@@ -54,7 +54,9 @@ export function createState() {
54
54
  llmIntentLast: null, // { text, verdict, at }(最近一次 LLM 采用/失败,/guard status 观测)
55
55
  verifyPass: [], // { at, sessionId, what }(测试通过/冷加载探针输出采集,交付闸门 M3 用)
56
56
  deniedKeys: new Set(), // 引擎 deny 的调用 key(规则 1 计数解耦)
57
- unknownToolApproved: new Set() // 机制 B:已 ask 过的未归类工具(本会话内批准后不再重复询问)
57
+ unknownToolApproved: new Set(), // 机制 B:已 ask 过的未归类工具(本会话内批准后不再重复询问)
58
+ unknownToolApprovedMeta: new Map(), // v0.5.9:name → {name,time,session}(持久化元数据,/guard tools 可视化)
59
+ unknownToolSessionAdded: new Set() // v0.5.9:本会话新增的批准(与永久加载区分,/guard tools 展示)
58
60
  };
59
61
  }
60
62
 
@@ -1,15 +1,14 @@
1
- // tool-catalog.js - 工具分类制(机制 B,2026-08-24)
1
+ // tool-catalog.js - 工具分类制(机制 B,2026-08-24;0.5.9 升级 2026-08-27
2
2
  // 任何插件(现有/未来)注册的工具都按此分类:
3
3
  // analysis = 只读分析(无条件放行)
4
- // artifact = 写产物(低风险:放行 + 审计留痕;受保护路径仍由 13A/__self-protect 把关)
4
+ // artifact = 写产物/会话态(低风险:放行 + 审计留痕;受保护路径仍由 13A/__self-protect 把关)
5
5
  // mutating = 变更类(维持现有严格授权:规则 22 粒度 / 12A / 13A)
6
- // unknown = 未归类(guard 不物理拦;由 index.js 的 pre-execute deny 层首调拦截,
7
- // 批次 3:ask 被 approval 层自动放行 → 改 deny,用户"允许使用 X"白名放行)
8
- // 设计原则:
9
- // 1. 不依赖 ctx.tools 运行时 API(纯静态表 + 命令文本分析,可独立测试);
10
- // 2. 方向性修正:旧逻辑"按参数名猜写不写、猜不出就放行"(反向漏洞)→
11
- // "未知默认 ask"(fail-closed,用户 2026-08-24 确认);
12
- // 3. 分类表随实测/新插件逐步扩充(机制允许快速扩展,无需改逻辑)。
6
+ // unknown = 未归类(由 index.js 的 pre-execute 层处置:unknownPolicy=ask(官方弹窗)或 deny + 提示)
7
+ // 0.5.9 改动(用户"整体审查"要求,K-01~K-06 依据):
8
+ // 1. 补全官方全集(tool-catalog 镜像交叉:cordis_*/terminal_*/session_*/goal/jobs/子代理/团队/保留工具等);
9
+ // 2. 前缀规则(mcp__=mutating;esr_ 细化)——生态命名空间新工具自动覆盖,不再依赖静态枚举;
10
+ // 3. 单一真源:24④ 守卫与 unknown 决策均读本表(废弃 guard-core 的独立覆盖表,杜绝双表漂移);
11
+ // 设计原则:工具名不是安全边界,内容才是(K-04)——归类只决定"识别",内容审查(12C/13A)不因归类豁免。
13
12
  import { isReadOnlyTool } from "./patterns.js";
14
13
 
15
14
  // 只读分析类:无任何文件副作用(或仅有会话内状态),无条件放行
@@ -26,7 +25,15 @@ const ANALYSIS_TOOLS = new Set([
26
25
  "web_fetch", "web_search",
27
26
  // 子代理交付工具(官方 dsh-tool-subagent-report,2026-08-24 实测发现):仅子代理环境注册,
28
27
  // 参数仅 output 文本 → 直接交付父代理,无文件副作用 → analysis(避免子代理交付被 unknown deny 阻断)
29
- "report"
28
+ "report",
29
+ // 官方 0.5.9 补全(tool-catalog 镜像 2026-08-27 交叉)
30
+ "lsp",
31
+ "session_event_read", "session_event_search", "session_event_trace", "session_search", "session_trace",
32
+ "terminal_list", "terminal_read",
33
+ "cordis_inspect_list", "cordis_inspect_query", "cordis_inspect_self",
34
+ "ask_user_question",
35
+ // ESR 状态查询(读工作区记忆状态,无持久副作用)
36
+ "esr_status", "esr_ready", "esr_model"
30
37
  ]);
31
38
 
32
39
  // 产物类:工具自然行为会写文件(artifact/工作区/会话存储),低风险——放行 + 审计。
@@ -35,24 +42,56 @@ const ARTIFACT_TOOLS = new Set([
35
42
  // 视觉产物类(裁剪/截图/长图 OCR/矢量化/抠图/物化/展示 → 写 artifact)
36
43
  "vision_crop", "vision_pixel_diff", "vision_trace", "vision_extract_foreground",
37
44
  "vision_html_screenshot", "vision_long_screenshot_ocr", "vision_materialize", "vision_present",
38
- // 记忆/日程(写引擎存储,低风险)
45
+ // 记忆/日程/目标/技能(写引擎/会话存储,低风险)
39
46
  "engram_store", "schedule_create", "schedule_delete",
47
+ "create_goal", "update_goal",
48
+ "skill", "todo_write", "visualize",
49
+ // 子代理/工作流协调(会话/子会话态,无文件副作用;内容审查另有其责)
50
+ "subagent", "subagent_fork", "send_message", "interrupt_agent", "workflow", "ralph",
51
+ // 官方 plan-mode 交互工具(参数为 markdown 计划,批准/继续规划,无文件副作用)
52
+ "exit_plan_mode",
40
53
  // 开发侧 staging(写 staging 区,dev_* 工具的自然行为)
41
- "dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote"
54
+ "dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote",
55
+ // ESR 工程状态写操作(写工作区记忆存储,engram_store 同族)
56
+ "esr_task", "esr_node", "esr_link", "esr_claim", "esr_close", "esr_unclaim", "esr_dep", "esr_gc"
42
57
  ]);
43
58
 
44
59
  // 变更类:维持现有严格授权(规则 22 粒度比对 / 12A 敏感授权 / 13A 备份)
45
60
  const MUTATING_TOOLS = new Set([
46
- "edit", "write", "str_replace_editor", "pwsh", "bash",
61
+ "edit", "write", "str_replace_editor", "pwsh", "bash", "upload_file",
47
62
  // 插件装配类(规则 24/27 管辖)
48
63
  "dev_install_package", "dev_inject_plugin", "dev_uninject_plugin", "dev_reload_package",
49
64
  "dev_fix_patch", "dev_heal_links",
50
- // 提交/网络写类由命令文本与 12C 管辖,此处不重复枚举
51
- "upload_file"
65
+ "dev_scaffold_plugin", "dev_build_plugin", "dev_release_plugin", "dev_clear_routes",
66
+ // 官方 0.5.9 补全(tool-catalog 镜像 2026-08-27 交叉)
67
+ "run_code", // 官方保留传输(Code Mode 唯一入口,语义"不可限制"——归类=纳入统一守卫而非拦死)
68
+ "cordis_define", "cordis_run", "cordis_stop", "cordis_undefine",
69
+ "terminal_open", "terminal_send", "terminal_close", "terminal_signal",
70
+ "job_kill",
71
+ "followup_task", "spawn_teammate", "team_task_create", "team_task_get",
72
+ "team_task_list", "team_task_update", "wait_agent",
73
+ // 本机命名空间兜底(dev_ 前缀 = 开发侧工具,行为可写可管理;具体已枚举,前缀兜底未来新增)
74
+ "dev_router_status"
52
75
  ]);
53
76
 
77
+ // 前缀规则:生态命名空间的工具自动归类(0.5.9 新)——将来的新插件遵循惯例即被覆盖。
78
+ // 安全注记(K-03):mcp__ 服务器段不做通配(Claude Code 同款约束);mcp 工具按变更类走授权(保守)。
79
+ const PREFIX_RULES = [
80
+ { prefix: "mcp__", cls: "mutating" },
81
+ { prefix: "esr_", cls: "artifact" },
82
+ { prefix: "vision_", cls: "analysis" },
83
+ { prefix: "engram_", cls: "artifact" },
84
+ { prefix: "dev_", cls: "mutating" },
85
+ { prefix: "schedule_", cls: "artifact" },
86
+ { prefix: "job_", cls: "analysis" },
87
+ { prefix: "terminal_", cls: "mutating" },
88
+ { prefix: "cordis_", cls: "mutating" },
89
+ { prefix: "session_", cls: "analysis" },
90
+ { prefix: "team_", cls: "mutating" }
91
+ ];
92
+
54
93
  /**
55
- * 工具分类判定(纯函数)。
94
+ * 工具分类判定(纯函数)。0.5.9 单一真源:24④ 守卫与 unknown 处置均读此结果。
56
95
  * @param {string} name 工具名
57
96
  * @param {object} args 工具参数(pwsh/bash 需要命令文本判读写)
58
97
  * @returns {'analysis'|'artifact'|'mutating'|'unknown'}
@@ -65,14 +104,19 @@ export function toolClass(name, args = {}) {
65
104
  if (ANALYSIS_TOOLS.has(n)) return "analysis";
66
105
  if (ARTIFACT_TOOLS.has(n)) return "artifact";
67
106
  if (MUTATING_TOOLS.has(n)) return "mutating";
107
+ // 前缀规则(细粒度集合优先;esr_ 状态查询放宽为 analysis)
108
+ if (n.startsWith("esr_") && ["esr_status", "esr_ready", "esr_model"].includes(n)) return "analysis";
109
+ for (const rule of PREFIX_RULES) {
110
+ if (n.startsWith(rule.prefix)) return rule.cls;
111
+ }
68
112
  return "unknown";
69
113
  }
70
114
 
71
115
  /**
72
116
  * unknown 工具的处置决策(pre-execute 层调用)。
73
- * 批次 3(2026-08-24,用户拍板分支 A):ask deny——实测 DSH approval 层自动放行 ask
74
- *(guardian ask unknown 首调均无弹窗直接执行 = fail-open 洞),未知工具首调必须物理拦截;
75
- * 批准路径 = 用户明确"允许使用 X"(handleSessionEvent 写入会话白名);白名后本会话放行。
117
+ * 0.5.9 修订(K-02/K-03 依据):unknown 首调从物理 deny 改为由 index.js unknownPolicy 走
118
+ * 官方 ask(弹窗,allowed-once)或 deny;本函数保留"未分类且未白名 deny(需要处置)"的
119
+ * 对外契约(调用方翻译策略),已知分类或已白名 null(放行)。
76
120
  * @param {string} name 工具名
77
121
  * @param {object} args 工具参数
78
122
  * @param {Set<string>|null} whitelist 会话内已批准工具集(可选)
@@ -0,0 +1,34 @@
1
+ // whitelist.js - 工具白名单持久化 v2(0.5.9):纯函数,可独立测试。
2
+ // 存储格式:v2 = [{"name","time","session"}](带元数据,可视化"谁/何时/哪个会话放行");
3
+ // v1 = ["name"](字符串数组,兼容读取)。v1 升级=v2 时旧条目 time/session 为 null("未知")。
4
+ export function parseWhitelist(raw) {
5
+ try {
6
+ const arr = JSON.parse(raw || "[]");
7
+ if (!Array.isArray(arr)) return [];
8
+ return arr
9
+ .map((it) => {
10
+ if (typeof it === "string") return { name: it, time: null, session: null };
11
+ if (it && typeof it.name === "string") {
12
+ return {
13
+ name: it.name,
14
+ time: typeof it.time === "number" ? it.time : null,
15
+ session: typeof it.session === "string" ? it.session : null
16
+ };
17
+ }
18
+ return null;
19
+ })
20
+ .filter(Boolean);
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ /** 合并既有元数据与新增名称(新增带当前时间和会话来源),保持既有条目历史不变 */
27
+ export function mergeWhitelist(prevRows, names, sid, now = Date.now()) {
28
+ const map = new Map(prevRows.map((r) => [r.name, r]));
29
+ return names.map((n) => map.get(n) || { name: n, time: now, session: sid || null });
30
+ }
31
+
32
+ export function serializeWhitelist(rows) {
33
+ return JSON.stringify(rows);
34
+ }
package/lib/index.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  isGetDateCommand,
24
24
  isManualReadTool,
25
25
  isReadOnlyTool,
26
+ matchKnownPitfall,
26
27
  needsApprovalReminder,
27
28
  PRIVATE_NETWORK_RE,
28
29
  setWorkspaceRoot,
@@ -54,8 +55,8 @@ import {
54
55
  reloadRules,
55
56
  resetTurn
56
57
  } from "./core/state.js";
57
- import { SAFE_UNCOVERED_TOOLS } from "./core/guard-core.js";
58
58
  import { toolClass } from "./core/tool-catalog.js";
59
+ import { parseWhitelist, mergeWhitelist, serializeWhitelist } from "./core/whitelist.js";
59
60
  import { state } from "./core/runtime.js";
60
61
  import { detectViolations, extractAssistantText } from "./core/text-detect.js";
61
62
  import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
@@ -306,15 +307,29 @@ function persistVerifyPass(state) {
306
307
  }
307
308
  }
308
309
 
309
- /** v0.5.7 后续(用户批准):unknown 工具白名单持久化(~/.dsh/rule-engine-tools.json——"允许使用 X"重启/热重载不丢) */
310
- function persistToolWhitelist(state) {
310
+ /** v0.5.7 后续(用户批准):unknown 工具白名单持久化(~/.dsh/rule-engine-tools.json——"允许使用 X"重启/热重载不丢)
311
+ * 0.5.9 升级:存储格式 v2 带元数据 [{name,time,session}]——可视化"谁、何时、哪个会话放行";
312
+ * 旧格式(字符串数组)兼容读取;持久化时合并既有 meta(历史条目不丢时间/来源)。 */
313
+ function persistToolWhitelist(state, sid) {
311
314
  try {
312
- writeFileSync(toolsWhitelistFilePath(), JSON.stringify([...(state.unknownToolApproved || [])]), "utf8");
315
+ const names = [...(state.unknownToolApproved || [])];
316
+ const prev = readWhitelistMeta();
317
+ const rows = mergeWhitelist([...prev.values()], names, sid);
318
+ writeFileSync(toolsWhitelistFilePath(), serializeWhitelist(rows), "utf8");
313
319
  } catch {
314
320
  // 持久化失败仅降级(内存态仍有效),不阻断
315
321
  }
316
322
  }
317
323
 
324
+ /** 读取白名单文件并按元数据归 Map(v1 字符串数组 → {name,time:null,session:null};v2 对象数组原样) */
325
+ function readWhitelistMeta() {
326
+ const m = new Map();
327
+ let raw = null;
328
+ try { raw = readFileSync(toolsWhitelistFilePath(), "utf8"); } catch {}
329
+ for (const row of parseWhitelist(raw || "")) m.set(row.name, row);
330
+ return m;
331
+ }
332
+
318
333
  export function handleSessionEvent(ctx, session, event) {
319
334
  maybeReloadIfChanged(state);
320
335
  const sid = session?.id || "global";
@@ -386,7 +401,11 @@ export function handleSessionEvent(ctx, session, event) {
386
401
  if (toolClass(toolName, {}) !== "unknown") continue; // 只对未归类工具有意义(已知工具不走 unknown 分支)
387
402
  const isNew = !state.unknownToolApproved.has(toolName);
388
403
  state.unknownToolApproved.add(toolName);
389
- persistToolWhitelist(state); // v0.5.7 后续:白名单持久化(重启/热重载不丢——用户批准"这句不用每次发")
404
+ if (isNew) {
405
+ if (!state.unknownToolSessionAdded) state.unknownToolSessionAdded = new Set();
406
+ state.unknownToolSessionAdded.add(toolName); // /guard tools 区分"本会话新增"
407
+ }
408
+ persistToolWhitelist(state, sid); // v0.5.7 后续:白名单持久化(重启/热重载不丢——用户批准"这句不用每次发")
390
409
  audit({
391
410
  kind: "unknown-tool-whitelist",
392
411
  rule: "__unknown-tool",
@@ -558,6 +577,33 @@ export function handleSessionEvent(ctx, session, event) {
558
577
  }
559
578
  }
560
579
 
580
+ // 0.5.10 建议5(已知坑错误码召回):错误结果文本命中特征表 → 审计 + 注入指向知识库的提示
581
+ //(本轮去重;仍走 maybeInject 资格链——真实用户在场/预算内才投递,不违反注入噪音治理)
582
+ if (isError) {
583
+ const errText = String(
584
+ resultBlock?.content?.map?.((c) => c?.text || "").join(" ") ||
585
+ d?.message?.content?.map?.((c) => c?.text || "").join(" ") ||
586
+ ""
587
+ );
588
+ const pit = matchKnownPitfall(errText);
589
+ if (pit) {
590
+ if (!s.turn.errorHints) s.turn.errorHints = new Set();
591
+ if (!s.turn.errorHints.has(pit.key)) {
592
+ s.turn.errorHints.add(pit.key);
593
+ audit({
594
+ kind: "error-hint",
595
+ rule: "__error-hint",
596
+ name: "已知坑召回",
597
+ event: "tool/result",
598
+ tool: pendingCall?.name || undefined,
599
+ reason: `${pit.key} 命中特征表(建议5:错误码→知识库)`,
600
+ session: sid
601
+ });
602
+ maybeInject(ctx, sid, { ruleId: "__error-hint", reason: pit.hint });
603
+ }
604
+ }
605
+ }
606
+
561
607
  // M8 双通道机制(2026-08-24):dsh-manual-write 落盘成功 → 标记;同轮 engram_store 成功 → 标记
562
608
  if (!isError && pendingCall) {
563
609
  const cmd = pendingCall.args?.command || pendingCall.args?.code || "";
@@ -720,7 +766,11 @@ export function handleSessionEvent(ctx, session, event) {
720
766
  if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
721
767
  const isNew = !state.unknownToolApproved.has(wname);
722
768
  state.unknownToolApproved.add(wname);
723
- persistToolWhitelist(state); // v0.5.7 后续:白名单持久化(见上)
769
+ if (isNew) {
770
+ if (!state.unknownToolSessionAdded) state.unknownToolSessionAdded = new Set();
771
+ state.unknownToolSessionAdded.add(wname);
772
+ }
773
+ persistToolWhitelist(state, sid); // v0.5.7 后续:白名单持久化(见上)
724
774
  audit({
725
775
  kind: "unknown-tool-whitelist",
726
776
  rule: "__unknown-tool",
@@ -877,6 +927,8 @@ const USAGE = [
877
927
  " /guard bypass [N] 临时整体放行 N 分钟(默认 5,仅用户)",
878
928
  " /guard lock 立即恢复全部守卫(取消解锁/放行)",
879
929
  " /guard revoke 撤销全部授权记录",
930
+ " /guard tools 工具放行白名单(永久+本会话,含时间/来源会话)",
931
+ " /guard tools revoke <工具名> 撤销白名单条目(持久化+会话集)",
880
932
  " /guard reload 强制重解析 AGENTS.md",
881
933
  " /guard mode <模式> 设置任务契约模式(review/answer/change/monitor/watch/off)",
882
934
  " /guard budget ... 设置预算(agents=N files=... deps=allow hash=allow)",
@@ -904,6 +956,9 @@ function parseCommand(rawInput) {
904
956
  m = text.match(/^log\s*(\d+)?$/i);
905
957
  if (m) return { kind: "log", n: m[1] ? Number(m[1]) : 10 };
906
958
  if (/^reload$/i.test(text)) return { kind: "reload" };
959
+ if (/^tools$/i.test(text)) return { kind: "tools", action: "list" };
960
+ m = text.match(/^tools revoke\s+([A-Za-z_][A-Za-z0-9_:.-]*)$/i);
961
+ if (m) return { kind: "tools", action: "revoke", target: m[1] };
907
962
  if (/^mode\b/i.test(text)) {
908
963
  const parsed = parseModeCommand(text);
909
964
  if (parsed) return { kind: "mode", ...parsed };
@@ -1034,6 +1089,40 @@ async function executeGuard(ctx, invocation) {
1034
1089
  (saved.ok ? `\n理解产物已写入:${saved.path}` : `\n理解产物写入失败:${saved.error}`)
1035
1090
  };
1036
1091
  }
1092
+ case "tools": {
1093
+ // v0.5.9:白名单可视化——永久(元数据)+ 本会话新增;revoke = 从持久化文件与会话集移除
1094
+ if (command.action === "revoke") {
1095
+ const target = command.target;
1096
+ const prev = readWhitelistMeta();
1097
+ const existed = prev.delete(target);
1098
+ writeFileSync(toolsWhitelistFilePath(), JSON.stringify([...prev.values()]), "utf8");
1099
+ state.unknownToolApproved?.delete(target);
1100
+ state.unknownToolSessionAdded?.delete(target);
1101
+ audit({
1102
+ kind: "guard-command",
1103
+ rule: "__whitelist",
1104
+ name: "白名单撤销",
1105
+ event: "command",
1106
+ reason: `/guard tools revoke ${target}(持久化+会话集同步移除)`,
1107
+ session: invocation?.session?.id || "global"
1108
+ });
1109
+ return { kind: "success", text: `已撤销 ${target}${existed ? "" : "(原不在白名单)"}。` };
1110
+ }
1111
+ const meta = state.unknownToolApprovedMeta || new Map();
1112
+ const sessionAdded = state.unknownToolSessionAdded || new Set();
1113
+ const rows = [...(state.unknownToolApproved || [])].map((n) => {
1114
+ const m = meta.get(n) || {};
1115
+ let when = "未知";
1116
+ if (typeof m.time === "number") {
1117
+ try { when = new Date(m.time).toISOString(); } catch { when = "非法时间"; }
1118
+ }
1119
+ return `- ${n}${sessionAdded.has(n) ? "(本会话新增)" : ""} 时间=${when} 来源会话=${m.session || "未知"}`;
1120
+ });
1121
+ const text = rows.length
1122
+ ? `工具放行白名单(/guard tools):\n${rows.join("\n")}\n\n撤销:/guard tools revoke <工具名>`
1123
+ : "工具放行白名单为空。";
1124
+ return { kind: "success", text };
1125
+ }
1037
1126
  case "mode": {
1038
1127
  if (!state.taskContract?.taskContractEnabled) return { kind: "error", text: "任务契约未启用:请先在规则引擎设置页开启总开关。" };
1039
1128
  const sid = invocation?.session?.id || "global";
@@ -1100,12 +1189,12 @@ export function apply(ctx) {
1100
1189
  }
1101
1190
 
1102
1191
  // v0.5.7 后续(用户批准):加载白名单持久化("允许使用 X"重启/热重载不丢)
1192
+ // 0.5.9:v2 对象数组(带 meta)与 v1 字符串数组兼容;meta 供 /guard tools 可视化
1103
1193
  try {
1104
1194
  const wlRaw = readFileSync(toolsWhitelistFilePath(), "utf8");
1105
- const wlArr = JSON.parse(wlRaw);
1106
- if (Array.isArray(wlArr)) {
1107
- state.unknownToolApproved = new Set(wlArr.filter((n) => typeof n === "string"));
1108
- }
1195
+ const rows = parseWhitelist(wlRaw);
1196
+ state.unknownToolApproved = new Set(rows.map((r) => r.name));
1197
+ state.unknownToolApprovedMeta = new Map(rows.map((r) => [r.name, r]));
1109
1198
  } catch {
1110
1199
  // 首次运行/无文件:空白名单
1111
1200
  }
@@ -1267,30 +1356,40 @@ export function apply(ctx) {
1267
1356
  return next();
1268
1357
  });
1269
1358
 
1270
- // 0.9 机制 B(2026-08-24):未归类工具(新插件/新工具)首调 deny——fail-closed,防"approval 层自动放行 ask"的洞。
1271
- // 批次 3(2026-08-24 用户拍板分支 A):实测未知工具首调 ask DSH approval 层自动放行(无弹窗直接执行),
1272
- // 因此 unknown 物理 deny;批准路径 = 用户明确"允许使用 X"(handleSessionEvent 写入会话白名),本会话内放行。
1359
+ // 0.9 机制 B(2026-08-24;0.5.9 修订——K-02/K-03 依据):未归类工具首调按 unknownPolicy 处置:
1360
+ // "ask" = 官方 pre-execute ask 语义(approval 弹窗,allowed-once 才续行;无审批通道 官方自动 deny,
1361
+ // fail-closed 由官方保证);"deny" = 物理拒绝 + 提示("允许使用 X" 会话白名路径恒可用)。
1362
+ // 历史教训(08-24 实测 ask 被自动放行 → 当时改 deny):0.5.9 发布前必须实弹验证 ask 弹窗真实出现
1363
+ // (K-02 门禁),验证不过则发布时默认保持 deny。
1273
1364
  ctx.on("tools/pre-execute", async (exec, next) => {
1274
1365
  try {
1275
- if (SAFE_UNCOVERED_TOOLS.has(exec?.name)) return next(); // 控制类不进 unknown 判定(避免 ask 自身被 ask)
1366
+ if ((state.unknownToolApproved || new Set()).has(exec?.name)) return next(); // 会话内用户已批准
1276
1367
  if (toolClass(exec?.name, exec?.arguments || {}) !== "unknown") return next();
1277
- if ((state.unknownToolApproved || new Set()).has(exec.name)) return next(); // 会话内用户已批准
1368
+ // 0.5.9:unknownPolicy 默认 "deny"(K-02 门禁:ask 需实弹验证弹窗真实出现后才可默认;
1369
+ // 当前无真实 unknown 工具可触发实弹——分类表已全覆盖,等待未来真实场景验证后经配置切换)
1370
+ const policy = pluginConfig.unknownPolicy === "ask" ? "ask" : "deny";
1278
1371
  audit({
1279
1372
  kind: "unknown-tool",
1280
1373
  rule: "__unknown-tool",
1281
- name: "未归类工具拦截",
1374
+ name: "未归类工具首调处置",
1282
1375
  event: "tools/pre-execute",
1283
1376
  tool: exec?.name,
1284
1377
  args: summarizeArgs(exec?.arguments),
1285
- reason: `工具 ${exec.name} 不在规则引擎分类表(可能是新装的插件):已拦截(unknown→deny)。批准路径:用户明确"允许使用 ${exec.name}"后本会话放行;长期规则请补充工具分类表`,
1378
+ reason: `工具 ${exec.name} 不在规则引擎分类表:按 unknownPolicy=${policy} 首调处置(批准路径:官方弹窗批准 / 用户明确"允许使用 ${exec.name}"写入会话白名单;长期规则请补充工具分类表)`,
1286
1379
  session: sessionIdOfExec(exec)
1287
1380
  });
1381
+ if (policy === "ask") {
1382
+ return {
1383
+ kind: "ask",
1384
+ reason: `工具 ${exec.name} 未登记在规则引擎分类表:批准本次后放行;如需本会话持续放行请说"允许使用 ${exec.name}"。`
1385
+ };
1386
+ }
1288
1387
  return {
1289
1388
  kind: "deny",
1290
1389
  reason: `工具 ${exec.name} 尚未归类(新装插件?):已拦截。如需使用请向用户说明用途并获取其"允许使用 ${exec.name}"的明确答复后重试(批准后本会话内放行)。`
1291
1390
  };
1292
1391
  } catch (error) {
1293
- ctx.logger?.warn?.("[dsh-rule-engine] unknown-tool deny error", error);
1392
+ ctx.logger?.warn?.("[dsh-rule-engine] unknown-tool handling error", error);
1294
1393
  return next();
1295
1394
  }
1296
1395
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rule-engine",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,41 @@
1
+ // check-tool-coverage.mjs — 0.5.9 工具覆盖门禁(K-01/K-06 依据,仿官方 verify-tool-catalog):
2
+ // 官方 tool-catalog(生成器产物=权威全集)中的每个工具名都必须在分类表/前缀规则内被识别,
3
+ // 任何一个 unknown = 该工具调用会被未知工具首调处置(ask/deny)→ 工程失败。
4
+ // 用法:node scripts/check-tool-coverage.mjs
5
+ import { readFileSync } from "node:fs";
6
+ import { toolClass } from "../lib/core/tool-catalog.js";
7
+
8
+ const CATALOG = "D:\\DeepSeek harness\\dsh-project\\docs-site-text\\en\\reference\\tool-catalog.txt";
9
+
10
+ function parseNames() {
11
+ const names = new Set();
12
+ for (const line of readFileSync(CATALOG, "utf8").split("\n")) {
13
+ if (!line.includes("@deepseek-ai/dsh-")) continue;
14
+ const cols = line.split("|").map((c) => c.trim());
15
+ // 表列格式:| | <package> | <model-visible names> | <requires> | <writes> | <shipped aliases> | <note> |
16
+ if (!cols[2] || !cols[2].startsWith("@deepseek-ai")) continue;
17
+ const add = (s) => {
18
+ if (!s || s === "-") return;
19
+ for (const x of s.split(",")) {
20
+ const n = x.trim();
21
+ if (/^[A-Za-z_][A-Za-z0-9_:.-]*$/.test(n)) names.add(n);
22
+ }
23
+ };
24
+ add(cols[3]);
25
+ add(cols[6]);
26
+ }
27
+ return names;
28
+ }
29
+
30
+ const official = parseNames();
31
+ const missing = [];
32
+ for (const n of official) {
33
+ if (toolClass(n, {}) === "unknown") missing.push(n);
34
+ }
35
+
36
+ if (missing.length) {
37
+ console.error(`COVERAGE-FAIL:官方 tool-catalog 有 ${missing.length} 个工具未分类(会被未知工具首调处置):`);
38
+ for (const n of missing) console.error(" - " + n);
39
+ process.exit(1);
40
+ }
41
+ console.log(`COVERAGE-OK:官方 tool-catalog 工具全覆盖(${official.size} 个工具名均被分类表/前缀规则识别)`);
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
12
12
  const auditPath = join(process.env.DSH_HOME || join(process.env.USERPROFILE || "", ".dsh"), "rule-engine.log.jsonl");
13
13
  const now = Date.now();
14
- const cuts = { intentFail: 0, intentOk: 0, judgePass: 0, judgeFalse: 0, judgeUnavail: 0, verifyGap: 0, injectSkip: 0, sourceSkip: 0 };
14
+ const cuts = { intentFail: 0, intentOk: 0, judgePass: 0, judgeFalse: 0, judgeUnavail: 0, verifyGap: 0, injectSkip: 0, sourceSkip: 0, denyTotal: 0, labelIncorrect: 0, labelCorrect: 0, errorHint: 0 };
15
15
 
16
16
  try {
17
17
  for (const line of readFileSync(auditPath, "utf8").split("\n")) {
@@ -24,6 +24,10 @@ try {
24
24
  if (line.includes('"kind":"verify-gap"')) cuts.verifyGap++;
25
25
  if (line.includes('"kind":"inject-skip"')) cuts.injectSkip++;
26
26
  if (line.includes('"kind":"source-skip"')) cuts.sourceSkip++;
27
+ if (line.includes('"kind":"deny"')) cuts.denyTotal++;
28
+ if (line.includes('"kind":"task-label"') && line.includes("incorrect")) cuts.labelIncorrect++;
29
+ if (line.includes('"kind":"task-label"') && line.includes("correct")) cuts.labelCorrect++;
30
+ if (line.includes('"kind":"error-hint"')) cuts.errorHint++;
27
31
  }
28
32
  } catch {
29
33
  // 无日志文件:一切 0
@@ -49,6 +53,8 @@ console.log("== 健康审计(近 24h)==");
49
53
  console.log(`intent-llm 失败降级 ${cuts.intentFail} 条 / 成功 ${cuts.intentOk} 条`);
50
54
  console.log(`judge-pass ${cuts.judgePass} 条 / judge-false ${cuts.judgeFalse} 条 / judge-unavailable ${cuts.judgeUnavail} 条`);
51
55
  console.log(`verify-gap ${cuts.verifyGap} 条 / inject-skip ${cuts.injectSkip} 条 / source-skip ${cuts.sourceSkip} 条`);
56
+ console.log(`误判打标(建议4):deny 总数 ${cuts.denyTotal} 条 → label incorrect ${cuts.labelIncorrect} 条 / correct ${cuts.labelCorrect} 条${cuts.denyTotal > 0 && cuts.labelIncorrect > 0 ? `(incorrect 占比 ${(cuts.labelIncorrect / cuts.denyTotal * 100).toFixed(1)}%)` : ""}——词表迭代量化依据`);
57
+ console.log(`已知坑召回(建议5):error-hint ${cuts.errorHint} 条`);
52
58
  console.log(`接线交叉:${EXPORTS.length} 个关键导出 → 疑似未接线 ${orphans.length ? orphans.join("、") : "0 个"}`);
53
59
  if (cuts.judgePass + cuts.judgeFalse === 0) {
54
60
  console.log("⚠️ 问题:近 24h 无真实判例——裁决器无运行证据(需实弹)");
@@ -1,9 +1,11 @@
1
- // verify-all.mjs - 交付前体检报告(2026-08-26,对齐官方 docs/testing.zh.md 分层验证)。
2
- // 四层:① 语法(lib 全部 .js node --check)
1
+ // verify-all.mjs - 交付前体检报告(2026-08-26,对齐官方 docs/testing.zh.md 分层验证;0.5.9 增第⑤层)。
2
+ // 五层:① 语法(lib 全部 .js node --check)
3
3
  // ② 单元(test/run-all.mjs——单元+机制层,不含 e2e)
4
4
  // ③ 组合冒烟(test/loader-smoke.e2e.mjs——真实接线 + 外部世界断言)
5
5
  // ④ 真实判例(外部世界:近 24h 台账中 judge-pass/judge-false 记录数;
6
6
  // 0 条 = WARN 提示需实弹,≥1 条 = 有真实裁决证据——读文件,不是自我报告)
7
+ // ⑤ 工具箱覆盖(scripts/check-tool-coverage.mjs——官方 tool-catalog 全集 vs 分类表,
8
+ // 任何 unknown = 未知工具首调处置会拦用户 → 红色(run_code 事故同类,K-01/K-06 门禁))
7
9
  // 任一 ❌ → exit 1;⚠️(WARN)不阻塞但必须明示。
8
10
  // 注意:子进程输出捕获需完整权限运行(受限模式 EPERM)。
9
11
  import { execFileSync } from "node:child_process";
@@ -53,6 +55,9 @@ step("单元(test/run-all.mjs)", process.execPath, [join(root, "test", "run-
53
55
  // ── ③ 组合冒烟 ──
54
56
  step("组合冒烟(loader-smoke,真实接线+外部世界断言)", process.execPath, [join(root, "test", "loader-smoke.e2e.mjs")]);
55
57
 
58
+ // ── ⑤ 工具箱覆盖(0.5.9 门禁) ──
59
+ step("工具箱覆盖(官方 tool-catalog vs 分类表,缺失即红)", process.execPath, [join(root, "scripts", "check-tool-coverage.mjs")]);
60
+
56
61
  // ── ④ 真实判例(外部世界) ──
57
62
  let judgeReal = 0;
58
63
  const auditPath = join(process.env.DSH_HOME || join(process.env.USERPROFILE || "", ".dsh"), "rule-engine.log.jsonl");