dsh-rule-engine 0.5.8 → 0.5.9

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.9-blue)
5
5
 
6
6
  DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。
7
7
 
@@ -42,10 +42,24 @@ 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.9(2026-08-27 追加)
50
+
51
+ > 修复方向(用户"整体审查"要求,依据手册知识条目 K-01~K-06):静态工具清单漏分类官方工具
52
+ > (`run_code` 事故——code 模式会话一切被拦)→ 单一真源 + 前缀规则 + 覆盖门禁 + 白名单可视化。
53
+
54
+ - **工具分类单真源**:规则 24④ 守卫与 unknown 处置统一读 `lib/core/tool-catalog.js` 唯一分类表(废弃双表——run_code 正是双表都漏的受害者);
55
+ - **官方工具全集补全**:59 个官方 tool-catalog 工具名全覆盖(`cordis_*`/`terminal_*`/`session_*`/goal/jobs/子代理/团队/plan-mode/官方保留传输 `run_code` 等)——Code Mode 会话(模型只能直呼 `run_code`)不再被"未知工具"拦死;
56
+ - **前缀规则**:`mcp__`(变更类走授权,照 Claude Code `mcp__*` 范式)、`esr_`/`dev_` 等生态命名空间自动归类——**将来新增插件工具遵循惯例即被覆盖**(不再依赖静态枚举);
57
+ - **unknown 首调处置 `unknownPolicy`**:默认 `deny`(保守:`ask` 弹窗模式需真实场景实弹验证后才可作默认;配置 `"ask"` 可切换官方弹窗——approval 弹窗 allowed-once,无审批通道自动拒绝,官方 fail-closed 语义);
58
+ - **白名单 v2 带元数据**:`~/.dsh/rule-engine-tools.json` 升级为 `[{name,time,session}]`(**谁、何时、在哪个会话被放行**——可视化基础);旧 `["name"]` 格式兼容加载;历史条目显示"时间/来源未知";
59
+ - **`/guard tools` 命令**:查看白名单(永久 + 本会话新增,含时间/来源会话)+ `/guard tools revoke <工具名>` 撤销(持久化+会话集同步);
60
+ - **工具箱覆盖门禁**:`scripts/check-tool-coverage.mjs`(官方 tool-catalog vs 分类表,任一 unknown 即红——`run_code` 事故同类的机器防线)已挂入 `verify-all` 第五层;
61
+ - **测试**:全量单测(新增 whitelist 格式 5 用例)+ loader-smoke 新增 E 场景(白名单 v2 真实落盘断言:对象数组/时间/来源会话/审计留痕)+ verify-all **五层**全绿。
62
+
49
63
  ## 0.5.8(2026-08-26 追加)
50
64
 
51
65
  - **白名单持久化**:"允许使用 X"(未归类工具批准)落盘 `~/.dsh/rule-engine-tools.json`——此前为内存态,热重载/重启即清(用户需反复重发,实测 esr_task/esr_close 各被清一次);
@@ -77,6 +91,8 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
77
91
  | `/guard budget ...` | 设置预算(agents=N files=... deps=allow hash=allow) |
78
92
  | `/guard contract` | 查看当前任务契约 |
79
93
  | `/guard label <id> <label>` | 给审计记录打标(correct/incorrect/inconclusive) |
94
+ | `/guard tools` | 查看工具放行白名单(永久+本会话,含时间/来源会话) |
95
+ | `/guard tools revoke <名>` | 撤销白名单条目(持久化+会话集同步移除) |
80
96
 
81
97
  ## 装配方式
82
98
 
@@ -65,16 +65,9 @@ function makeHit(cfg, reason) {
65
65
  };
66
66
  }
67
67
 
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
- ]);
68
+ // 0.5.9(单真源):COVERED_MUTATION_TOOLS / SAFE_UNCOVERED_TOOLS 独立覆盖表已废弃——
69
+ // 工具分类以 lib/core/tool-catalog.js 的唯一分类表为准(24④/22/unknown 全部读它),
70
+ // 双表漂移即 run_code 事故根因(K-01/K-02/K-06 依据)。
78
71
 
79
72
  function looksLikeFileMutation(name, args) {
80
73
  const a = args || {};
@@ -328,9 +321,8 @@ function matchRule(cfg, ctx) {
328
321
  //("你的执行方案难道没问题吗?"虽含"执行"仍是疑问句——同形词不构成指令,不豁免)
329
322
  if (cfg.handler === "rule22-7-direct") {
330
323
  // 机制 B(2026-08-24):工具分类制(纯函数,覆盖现有与未来插件注册的全部工具)——
331
- // control(SAFE 无条件放行)/ analysis(只读分析放行)/ artifact(产物放行+审计)/
332
- // unknown(物理不拦,由 pre-execute ask 层首调询问,防新插件绕过);mutating 维持原有严格逻辑。
333
- if (SAFE_UNCOVERED_TOOLS.has(name)) return null; // 授权/委托/技能/查询展示类 → 无条件放行
324
+ // analysis(只读分析放行)/ artifact(产物放行+审计)/ unknown(物理不拦,由 pre-execute
325
+ // ask/deny 层首调处置,防新插件绕过);mutating 维持原有严格逻辑。0.5.9 单真源:唯一分类表。
334
326
  const cls = toolClass(name, args);
335
327
  if (cls === "analysis") return null;
336
328
  if (cls === "artifact") {
@@ -359,7 +351,7 @@ function matchRule(cfg, ctx) {
359
351
  // 无用户消息/状态信号/ask 答复后回合仍不做规则 22 判定(由 12A/13A 把关)。
360
352
  if (!turnIntents || !turnIntents.hasExecute) return null;
361
353
  if (isReadOnlyTool(name, args)) return null;
362
- if (!COVERED_MUTATION_TOOLS.has(name) && !looksLikeFileMutation(name, args)) return null;
354
+ if (cls === "mutating" || looksLikeFileMutation(name, args)) {} else return null;
363
355
  const op = operationOf(name, args);
364
356
  // v0.5.7 P0-1(用户拍板 2026-08-26):验证类命令伴生放行——本回合已有 write/any/command
365
357
  // 授权(用户已批"修改/执行")时,运行测试/冷加载/审计/语法检查属于该变更的验证闭环,
@@ -557,10 +549,11 @@ function matchRule(cfg, ctx) {
557
549
  return makeHit(cfg, `【硬拦截】${p} 的 dsh.profile.bundles 包含非 bundle/无法确认类型:${badBundles.join(";")}(规则 24)`);
558
550
  }
559
551
  // 规则 24④:所有能产生文件写入/删除/移动效果的工具都必须纳入统一守卫——
560
- // 未覆盖的变更类工具运行时直接拒绝(防"改个新工具就绕过守卫")
552
+ // 0.5.9 单真源:已分类(tool-catalog 唯一表)= 已纳入守卫,放行;未分类且疑似变更 = 运行时拒绝
553
+ // (防"改个新工具就绕过守卫";与工具覆盖门禁 K-01/K-06 互补)
561
554
  if (isReadOnlyTool(name, args)) return null;
562
- if (COVERED_MUTATION_TOOLS.has(name)) return null;
563
- if (SAFE_UNCOVERED_TOOLS.has(name)) return null;
555
+ const cls24 = toolClass(name, args);
556
+ if (cls24 !== "unknown") return null;
564
557
  if (looksLikeFileMutation(name, args)) {
565
558
  return makeHit(cfg, `【硬拦截】未覆盖的变更类工具 ${name},违反规则 24④:请先纳入统一守卫覆盖`);
566
559
  }
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
@@ -54,8 +54,8 @@ import {
54
54
  reloadRules,
55
55
  resetTurn
56
56
  } from "./core/state.js";
57
- import { SAFE_UNCOVERED_TOOLS } from "./core/guard-core.js";
58
57
  import { toolClass } from "./core/tool-catalog.js";
58
+ import { parseWhitelist, mergeWhitelist, serializeWhitelist } from "./core/whitelist.js";
59
59
  import { state } from "./core/runtime.js";
60
60
  import { detectViolations, extractAssistantText } from "./core/text-detect.js";
61
61
  import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
@@ -306,15 +306,29 @@ function persistVerifyPass(state) {
306
306
  }
307
307
  }
308
308
 
309
- /** v0.5.7 后续(用户批准):unknown 工具白名单持久化(~/.dsh/rule-engine-tools.json——"允许使用 X"重启/热重载不丢) */
310
- function persistToolWhitelist(state) {
309
+ /** v0.5.7 后续(用户批准):unknown 工具白名单持久化(~/.dsh/rule-engine-tools.json——"允许使用 X"重启/热重载不丢)
310
+ * 0.5.9 升级:存储格式 v2 带元数据 [{name,time,session}]——可视化"谁、何时、哪个会话放行";
311
+ * 旧格式(字符串数组)兼容读取;持久化时合并既有 meta(历史条目不丢时间/来源)。 */
312
+ function persistToolWhitelist(state, sid) {
311
313
  try {
312
- writeFileSync(toolsWhitelistFilePath(), JSON.stringify([...(state.unknownToolApproved || [])]), "utf8");
314
+ const names = [...(state.unknownToolApproved || [])];
315
+ const prev = readWhitelistMeta();
316
+ const rows = mergeWhitelist([...prev.values()], names, sid);
317
+ writeFileSync(toolsWhitelistFilePath(), serializeWhitelist(rows), "utf8");
313
318
  } catch {
314
319
  // 持久化失败仅降级(内存态仍有效),不阻断
315
320
  }
316
321
  }
317
322
 
323
+ /** 读取白名单文件并按元数据归 Map(v1 字符串数组 → {name,time:null,session:null};v2 对象数组原样) */
324
+ function readWhitelistMeta() {
325
+ const m = new Map();
326
+ let raw = null;
327
+ try { raw = readFileSync(toolsWhitelistFilePath(), "utf8"); } catch {}
328
+ for (const row of parseWhitelist(raw || "")) m.set(row.name, row);
329
+ return m;
330
+ }
331
+
318
332
  export function handleSessionEvent(ctx, session, event) {
319
333
  maybeReloadIfChanged(state);
320
334
  const sid = session?.id || "global";
@@ -386,7 +400,11 @@ export function handleSessionEvent(ctx, session, event) {
386
400
  if (toolClass(toolName, {}) !== "unknown") continue; // 只对未归类工具有意义(已知工具不走 unknown 分支)
387
401
  const isNew = !state.unknownToolApproved.has(toolName);
388
402
  state.unknownToolApproved.add(toolName);
389
- persistToolWhitelist(state); // v0.5.7 后续:白名单持久化(重启/热重载不丢——用户批准"这句不用每次发")
403
+ if (isNew) {
404
+ if (!state.unknownToolSessionAdded) state.unknownToolSessionAdded = new Set();
405
+ state.unknownToolSessionAdded.add(toolName); // /guard tools 区分"本会话新增"
406
+ }
407
+ persistToolWhitelist(state, sid); // v0.5.7 后续:白名单持久化(重启/热重载不丢——用户批准"这句不用每次发")
390
408
  audit({
391
409
  kind: "unknown-tool-whitelist",
392
410
  rule: "__unknown-tool",
@@ -720,7 +738,11 @@ export function handleSessionEvent(ctx, session, event) {
720
738
  if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
721
739
  const isNew = !state.unknownToolApproved.has(wname);
722
740
  state.unknownToolApproved.add(wname);
723
- persistToolWhitelist(state); // v0.5.7 后续:白名单持久化(见上)
741
+ if (isNew) {
742
+ if (!state.unknownToolSessionAdded) state.unknownToolSessionAdded = new Set();
743
+ state.unknownToolSessionAdded.add(wname);
744
+ }
745
+ persistToolWhitelist(state, sid); // v0.5.7 后续:白名单持久化(见上)
724
746
  audit({
725
747
  kind: "unknown-tool-whitelist",
726
748
  rule: "__unknown-tool",
@@ -877,6 +899,8 @@ const USAGE = [
877
899
  " /guard bypass [N] 临时整体放行 N 分钟(默认 5,仅用户)",
878
900
  " /guard lock 立即恢复全部守卫(取消解锁/放行)",
879
901
  " /guard revoke 撤销全部授权记录",
902
+ " /guard tools 工具放行白名单(永久+本会话,含时间/来源会话)",
903
+ " /guard tools revoke <工具名> 撤销白名单条目(持久化+会话集)",
880
904
  " /guard reload 强制重解析 AGENTS.md",
881
905
  " /guard mode <模式> 设置任务契约模式(review/answer/change/monitor/watch/off)",
882
906
  " /guard budget ... 设置预算(agents=N files=... deps=allow hash=allow)",
@@ -904,6 +928,9 @@ function parseCommand(rawInput) {
904
928
  m = text.match(/^log\s*(\d+)?$/i);
905
929
  if (m) return { kind: "log", n: m[1] ? Number(m[1]) : 10 };
906
930
  if (/^reload$/i.test(text)) return { kind: "reload" };
931
+ if (/^tools$/i.test(text)) return { kind: "tools", action: "list" };
932
+ m = text.match(/^tools revoke\s+([A-Za-z_][A-Za-z0-9_:.-]*)$/i);
933
+ if (m) return { kind: "tools", action: "revoke", target: m[1] };
907
934
  if (/^mode\b/i.test(text)) {
908
935
  const parsed = parseModeCommand(text);
909
936
  if (parsed) return { kind: "mode", ...parsed };
@@ -1034,6 +1061,40 @@ async function executeGuard(ctx, invocation) {
1034
1061
  (saved.ok ? `\n理解产物已写入:${saved.path}` : `\n理解产物写入失败:${saved.error}`)
1035
1062
  };
1036
1063
  }
1064
+ case "tools": {
1065
+ // v0.5.9:白名单可视化——永久(元数据)+ 本会话新增;revoke = 从持久化文件与会话集移除
1066
+ if (command.action === "revoke") {
1067
+ const target = command.target;
1068
+ const prev = readWhitelistMeta();
1069
+ const existed = prev.delete(target);
1070
+ writeFileSync(toolsWhitelistFilePath(), JSON.stringify([...prev.values()]), "utf8");
1071
+ state.unknownToolApproved?.delete(target);
1072
+ state.unknownToolSessionAdded?.delete(target);
1073
+ audit({
1074
+ kind: "guard-command",
1075
+ rule: "__whitelist",
1076
+ name: "白名单撤销",
1077
+ event: "command",
1078
+ reason: `/guard tools revoke ${target}(持久化+会话集同步移除)`,
1079
+ session: invocation?.session?.id || "global"
1080
+ });
1081
+ return { kind: "success", text: `已撤销 ${target}${existed ? "" : "(原不在白名单)"}。` };
1082
+ }
1083
+ const meta = state.unknownToolApprovedMeta || new Map();
1084
+ const sessionAdded = state.unknownToolSessionAdded || new Set();
1085
+ const rows = [...(state.unknownToolApproved || [])].map((n) => {
1086
+ const m = meta.get(n) || {};
1087
+ let when = "未知";
1088
+ if (typeof m.time === "number") {
1089
+ try { when = new Date(m.time).toISOString(); } catch { when = "非法时间"; }
1090
+ }
1091
+ return `- ${n}${sessionAdded.has(n) ? "(本会话新增)" : ""} 时间=${when} 来源会话=${m.session || "未知"}`;
1092
+ });
1093
+ const text = rows.length
1094
+ ? `工具放行白名单(/guard tools):\n${rows.join("\n")}\n\n撤销:/guard tools revoke <工具名>`
1095
+ : "工具放行白名单为空。";
1096
+ return { kind: "success", text };
1097
+ }
1037
1098
  case "mode": {
1038
1099
  if (!state.taskContract?.taskContractEnabled) return { kind: "error", text: "任务契约未启用:请先在规则引擎设置页开启总开关。" };
1039
1100
  const sid = invocation?.session?.id || "global";
@@ -1100,12 +1161,12 @@ export function apply(ctx) {
1100
1161
  }
1101
1162
 
1102
1163
  // v0.5.7 后续(用户批准):加载白名单持久化("允许使用 X"重启/热重载不丢)
1164
+ // 0.5.9:v2 对象数组(带 meta)与 v1 字符串数组兼容;meta 供 /guard tools 可视化
1103
1165
  try {
1104
1166
  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
- }
1167
+ const rows = parseWhitelist(wlRaw);
1168
+ state.unknownToolApproved = new Set(rows.map((r) => r.name));
1169
+ state.unknownToolApprovedMeta = new Map(rows.map((r) => [r.name, r]));
1109
1170
  } catch {
1110
1171
  // 首次运行/无文件:空白名单
1111
1172
  }
@@ -1267,30 +1328,40 @@ export function apply(ctx) {
1267
1328
  return next();
1268
1329
  });
1269
1330
 
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 写入会话白名),本会话内放行。
1331
+ // 0.9 机制 B(2026-08-24;0.5.9 修订——K-02/K-03 依据):未归类工具首调按 unknownPolicy 处置:
1332
+ // "ask" = 官方 pre-execute ask 语义(approval 弹窗,allowed-once 才续行;无审批通道 官方自动 deny,
1333
+ // fail-closed 由官方保证);"deny" = 物理拒绝 + 提示("允许使用 X" 会话白名路径恒可用)。
1334
+ // 历史教训(08-24 实测 ask 被自动放行 → 当时改 deny):0.5.9 发布前必须实弹验证 ask 弹窗真实出现
1335
+ // (K-02 门禁),验证不过则发布时默认保持 deny。
1273
1336
  ctx.on("tools/pre-execute", async (exec, next) => {
1274
1337
  try {
1275
- if (SAFE_UNCOVERED_TOOLS.has(exec?.name)) return next(); // 控制类不进 unknown 判定(避免 ask 自身被 ask)
1338
+ if ((state.unknownToolApproved || new Set()).has(exec?.name)) return next(); // 会话内用户已批准
1276
1339
  if (toolClass(exec?.name, exec?.arguments || {}) !== "unknown") return next();
1277
- if ((state.unknownToolApproved || new Set()).has(exec.name)) return next(); // 会话内用户已批准
1340
+ // 0.5.9:unknownPolicy 默认 "deny"(K-02 门禁:ask 需实弹验证弹窗真实出现后才可默认;
1341
+ // 当前无真实 unknown 工具可触发实弹——分类表已全覆盖,等待未来真实场景验证后经配置切换)
1342
+ const policy = pluginConfig.unknownPolicy === "ask" ? "ask" : "deny";
1278
1343
  audit({
1279
1344
  kind: "unknown-tool",
1280
1345
  rule: "__unknown-tool",
1281
- name: "未归类工具拦截",
1346
+ name: "未归类工具首调处置",
1282
1347
  event: "tools/pre-execute",
1283
1348
  tool: exec?.name,
1284
1349
  args: summarizeArgs(exec?.arguments),
1285
- reason: `工具 ${exec.name} 不在规则引擎分类表(可能是新装的插件):已拦截(unknown→deny)。批准路径:用户明确"允许使用 ${exec.name}"后本会话放行;长期规则请补充工具分类表`,
1350
+ reason: `工具 ${exec.name} 不在规则引擎分类表:按 unknownPolicy=${policy} 首调处置(批准路径:官方弹窗批准 / 用户明确"允许使用 ${exec.name}"写入会话白名单;长期规则请补充工具分类表)`,
1286
1351
  session: sessionIdOfExec(exec)
1287
1352
  });
1353
+ if (policy === "ask") {
1354
+ return {
1355
+ kind: "ask",
1356
+ reason: `工具 ${exec.name} 未登记在规则引擎分类表:批准本次后放行;如需本会话持续放行请说"允许使用 ${exec.name}"。`
1357
+ };
1358
+ }
1288
1359
  return {
1289
1360
  kind: "deny",
1290
1361
  reason: `工具 ${exec.name} 尚未归类(新装插件?):已拦截。如需使用请向用户说明用途并获取其"允许使用 ${exec.name}"的明确答复后重试(批准后本会话内放行)。`
1291
1362
  };
1292
1363
  } catch (error) {
1293
- ctx.logger?.warn?.("[dsh-rule-engine] unknown-tool deny error", error);
1364
+ ctx.logger?.warn?.("[dsh-rule-engine] unknown-tool handling error", error);
1294
1365
  return next();
1295
1366
  }
1296
1367
  });
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.9",
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} 个工具名均被分类表/前缀规则识别)`);
@@ -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");