dsh-rule-engine 0.5.9 → 0.5.11
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 +86 -7
- package/lib/core/authorization.js +64 -19
- package/lib/core/guard-core.js +100 -13
- package/lib/core/hotwords.js +86 -0
- package/lib/core/intent.js +51 -14
- package/lib/core/judge.js +72 -0
- package/lib/core/lexicon.js +72 -0
- package/lib/core/llm-intent.js +10 -2
- package/lib/core/llm-understander.js +15 -0
- package/lib/core/patterns.js +162 -12
- package/lib/core/text-detect.js +67 -11
- package/lib/core/tool-catalog.js +3 -0
- package/lib/core/understander.js +1 -1
- package/lib/core/version-guard.js +6 -3
- package/lib/index.js +235 -50
- package/package.json +1 -1
- package/scripts/health-audit.mjs +7 -1
- package/scripts/probe-home.mjs +3 -0
- package/scripts/probe-intent.mjs +14 -0
- package/scripts/verify-all.mjs +44 -2
- package/scripts/verify-guard-live.mjs +30 -0
- package/scripts/verify-v474.mjs +14 -0
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# dsh-rule-engine
|
|
2
2
|
|
|
3
3
|

|
|
4
|
-

|
|
5
5
|
|
|
6
6
|
DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。
|
|
7
7
|
|
|
8
|
-
> 当前版本 **0.5.
|
|
8
|
+
> 当前版本 **0.5.11-pre**(工作区未发布;对外最新发布 = 0.5.10——2026-08-27 三通道:npm=0.5.10 / git 23a3c0f / Release v0.5.10)。本插件面向"规则机器化执行":规则写在 AGENTS.md 里,引擎负责让它们真的被遵守;所有规则动态解析,规则增删改后无需重写插件。
|
|
9
9
|
|
|
10
10
|
## 项目背景
|
|
11
11
|
|
|
@@ -42,9 +42,88 @@ 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` ——
|
|
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 即红)→ **变更工具守卫链覆盖**(`test/guardchain-coverage.test.mjs`:写/删/移工具问句回合不静默逃逸,规则 24④ 机器执行)→ **关联一致性**(测试全部收录 run-all / 版本成对 / 核心能力有落点——0.5.11 新增,防改完不看关联产物)→ **真实判例**(近 24h 台账 judge-pass/false 记录数,0 条 = WARN 提示需实弹);
|
|
46
46
|
- `node scripts/health-audit.mjs` —— 找茬清单:近 24h 失败/降级类统计(intent-llm 失败、judge-unavailable、verify-gap、inject-skip…)+ 关键导出接线交叉(疑似未接线 = 告警)——"失败可见化",不再有静默躺 20 小时的降级;
|
|
47
|
-
- 执行协议(本仓库自身交付纪律):方案冻结单(范围/影响面/测试计划/失败预测)→ todo 化 → 小步闭环(每改动立即 `node --check`)→ 对账交付(计划×实际逐项
|
|
47
|
+
- 执行协议(本仓库自身交付纪律):方案冻结单(范围/影响面/测试计划/失败预测)→ todo 化 → 小步闭环(每改动立即 `node --check`)→ 对账交付(计划×实际逐项 ✅/❌/跳过原因)。**验收五查(0.5.11 用户定稿:验收 = 改动关联产物全做一遍,不是只跑测试)**:① 本次改动的**全部**测试/用例在 run-all 或对应门禁中收录;② README/手册/版本记录与引擎语义成对(新增行为必写文档);③ 调用点 grep:改公共函数签名/导出 → 全部调用点逐一核对;④ 相关测试断言同步(本例:consistency 死映射 17→16);⑤ 误删/错改产物清理(错误版测试/脚本删除后无残留引用)。**⑥ 实弹验证(0.5.11 用户定稿)**:改动在运行态真实生效 = 重启 DSH 加载(profile 为 link: 时重启即生效)→ 运行态验证(行为/守卫/日志实拍)→ 通过后**才**允许进入发布——**发布必须以"重启生效+验证通过"为前提,未生效验证不得发布**(顺序:改 → 测试 → 重启生效 → 实弹验证 → 发布)。任一未做 = 交付不算完成。**⑦ 发布前置(2026-08-28 用户定稿)**:发布前必须先本地跑测试——本项目测试(test-service/npm test)+ 验证脚本 + 运行态实测(截图/输出证据),未全过不得发布;发布后核对三处成对——README 徽章=package.json 版本(release 脚本自动 bump,需验证)、三通道(npm/git/Release)逐个确认成功(含 Release 带正式 tgz asset);发布后遗留(徽章/README 成对缺口)必须当场修。**发布流程三阶段(2026-08-29 用户定稿)**:阶段 A **内容验证(发布前,只读/本地)**——A1 测试全过(run-all+verify-all+loader+运行态实测)、A2 dry-run 推导正确(`release-plugin.mjs <pkg> --dry-run`:oldVer→nextVer+徽章变化)、A3 版本三处一致(徽章=package.json=发布目标号)、A4 Asset/tag 预检(tgz 已 pack;目标 tag 预检不存在——防 ㉙ 同 tag 占位);阶段 A0 **授权**——展示改动清单+版本号 → ask_user_question 明确授权(规则 26⑤:先授权→运行脚本);阶段 B **一键三通道**——`release-plugin.mjs`(publish→push→Release 带 tgz);阶段 C **事后核对**——三通道确认(npm view/git tag/gh api asset)+ 徽章/README 成对复核 + 本机 link 不重装 + 沉淀(版本记录/engram)。**顺序铁律:A→A0→B→C,验证在发布前(A),不是发布后补(C)——发布内容经确认无误才允许三通道。**
|
|
48
|
+
|
|
49
|
+
> **版本状态说明(2026-08-28)**:下方 0.5.11-0.5.14 章节均为**工作区迭代记录(未对外发布)**——对外最新发布 = 0.5.10(2026-08-27 三通道:npm=0.5.10 / git 23a3c0f / Release v0.5.10);工作区当前代号 **0.5.11-pre**(= 对外 0.5.10 的下一个未发布修订)。0.5.11→0.5.14 是工作区内部迭代号,发布时统一以对外号为准。
|
|
50
|
+
|
|
51
|
+
## 0.5.14(2026-08-28 追加,阶段三收尾:D1-D3 注入 / F1-F2 时序收紧 / 回归集第 8 层)
|
|
52
|
+
|
|
53
|
+
> 背景:阶段二之后剩余全部任务(用户"请按顺序执行剩下的任务")——注入语义、时序竞态、交付闸门收紧,+真实场景回归常驻化。
|
|
54
|
+
|
|
55
|
+
- **D1 注入文案去命令化 + 静态检查**(index.js):`[规则引擎]` 注入若含"请直接执行/请立即/不要再"类命令式短语会驱动模型越过用户直接行动(实弹:节流注入后模型跳过用户回复执行);修法 = maybeInject 入口命令词守卫(INJECT_COMMAND_RE 命中即拒绝投递+审计 `inject-command-gated`),历史文案全量改写陈述式(__ask-rejected/__ask-throttle/__engram-gap 三处漏网被守卫抓出修复);
|
|
56
|
+
- **D2 注入投递降噪**(index.js):不可投递 agent(子代理/已销毁)审计 reason 标注"(D2 预期降噪)",与真实失败区分;
|
|
57
|
+
- **D3 注入授权断言真值驱动**(2026-08-29 测试锁定,B2):注入不再断言"已有授权"(描述权交给裁决器 describeAuth 真实池);锁定 = guard.test.mjs「D3 真值驱动锁定」段(有授权 → 文案含真实池描述 write|路径 d:/allowed;无授权 → 显式"无");
|
|
58
|
+
- **F1 规则 2 时序竞态**(text-detect.js/index.js):assistant/message 检出时间词违规不再立即投递——Get-Date 工具常在本回合后续步骤才执行(实弹:correct 早于 Get-Date 放行);改为 turn/end 复核(getDateSeen 定案 → 已调用即撤销不投递,审计 `rule2-resolved`);detectViolations 保留判定、新增 detectTimeRule 独立函数;
|
|
59
|
+
- **F2 verify-gap 词面收紧**(index.js):`/完成/` 裸词太宽("完成社区检索/尚未完成"误触)→ 强完成声明模式 + 否定/进行态排除(LLM 裁决层兜底不变);
|
|
60
|
+
- **回归集第 8 层**:`test/realcase-regression.test.mjs`——真实场景常驻(顿号路径+doc 读取 COM / ask 未响应≠拒绝 / 子代理委派 / 注入文案命令词静态检查 / 规则 2 时序 / 意图事故原文),收录 run-all;**下次任何改动必过这层**;
|
|
61
|
+
- **质保**:verify-all 七层 VERIFY ALL OK + loader-smoke PASS。
|
|
62
|
+
|
|
63
|
+
## 0.5.11-pre 交接执行(2026-08-29 追加:A1 审计误报 / B2 D3 锁定 / B3 引述转述)
|
|
64
|
+
|
|
65
|
+
- **A1 审计命令被拦 ≠ 审计失败**(index.js tool/result 分支拆分):旧逻辑 `isError || auditOutputFailed` 合并两者——审计命令被规则 22 拦截/执行失败(无输出)也记 mount-audit-fail 并注入"先移除多余挂载"(误导:审计根本没执行,曾引导模型误移除正确挂载);修复 = auditOutputFailed(真 DUPLICATES)→ mount-audit-fail + 注入不变;isError(无审计输出)→ mount-audit-error 留痕、不注入;通过 → revision 同步不变;测试锁定 session-events 场景 A/B/C;
|
|
66
|
+
- **B3 引述/转述语境通用豁免**(patterns.js `isQuoteOrParaphraseContext` + text-detect.js):原规则 7 仅认引号 + 白名单标记词,"用户之前说万无一失"(无引号转述)误报;规则 2 无任何引述豁免(引述"你昨天说…"也报未核对);修复 = 通用判定(引号包裹 8 字符 / 第三人称对象+说/表示/提到… before 14 字符 / 引述转述原文改成类标记),**第一人称"我说保证/我昨天完成"不豁免**(承诺转述不了自己);规则 2 两处(detectViolations + detectTimeRule)接入;
|
|
67
|
+
- **B1 词表热词学习(方案甲,2026-08-29 用户拍板)**:终结"每次手动补词"(v4.74/82 连补 4 轮仍漏);机制 = LLM 意图兜底 prompt 新增 `actionWord` 字段,裁决 hasExecute=true 且词表未命中 → 学动作词入 `~/.dsh/rule-engine-hotwords.json`(`lib/core/hotwords.js`:上限 500/原子写/损坏容错/审计 `hotword-learn`)→ 下次 `intent.js` hasAction 直接命中(`ACTION_RE ∪ 热词`);**热词只服务意图判定,绝不 bypass deny/授权**;手动补词(lexicon.js)保留且优先;测试 `test/hotwords.test.mjs`(容错/去重/拒收/命中/接入)已收录 run-all;
|
|
68
|
+
- 质保:run-all ALL TESTS PASSED(含 A1 段 4 断言 + D3 锁定 4 断言 + B3 检测层 4 断言 + hotwords 10 断言)+ 冷加载探针 PASS;**运行态生效 = 重启 DSH 后实弹**(bundle 装配,热重载不生效);
|
|
69
|
+
|
|
70
|
+
## 0.5.13(2026-08-28 追加,阶段二:B2/B2 委派豁免 / C2 中文路径 / C3 ask 节流)
|
|
71
|
+
|
|
72
|
+
> 背景:2026-08-28 事故链三处遗留(用户逐项拍板"先做 B2+C2+C3")——子代理读文件被 ERR-L8QAXS 拦、
|
|
73
|
+
> `D:\1、工作\...` 授权时被劈成 `d:/1`(ERR-LU50QQ)、**未回复 ask 被当"拒绝"节流**(用户"你也没给我时间回复啊")。
|
|
74
|
+
|
|
75
|
+
- **B2 委派/子代理回合豁免"无执行分点"**(guard-core `isDelegatedSession`):官方子代理把任务 prompt 以 `source.kind="user"` 注入子会话(dsh-subagent-in-process-driver L204-207 实证),引擎按用户消息处理 → 任务书被判"无执行分点"→ 读文件被拦;修复 = 规则 22④"委派回合不做判定"的识别层落地(会话 meta.delegationDepth>0 / origin=subagent / parentSession 存在 → 跳过,审计 `delegated-skip` 留痕;**12A/13A 独立把关安全不降级**);
|
|
76
|
+
- **C2 中文顿号路径提取**(authorization.js PATH_TOKEN_RE):`、`(中文顿号)从"非路径字符"黑名单移除——Windows 中文目录名常见(`D:\1、工作\...`),原正则在此截断 → 授权记录成 `d:/1`,真实操作匹配失败;稳妥边界:顿号后跟空格+新词可能合并(fail-closed 方向:提取过长 → 匹配失败 → 拦而非放,不放大授权);
|
|
77
|
+
- **C3 ask 节流区分"明确拒绝"与"未响应"**:新导出 `askResultRejected`(结果文本含拒绝词才算拒绝)+ REJECTION_WORDS_RE 修正(`否`不再误命中"是否");旧逻辑把**任何未批准**记入 askRejections 池 → 5 分钟内再 ask 被 __ask-throttle 拦——用户没回 ≠ 用户拒绝;现在未响应/超时仅审计 `auth-noanswer`,明确拒绝才入池(12A 授权证据把关不降级);
|
|
78
|
+
- **测试**:phase1-fix 扩展 B2/C2/C3 用例(子代理 meta 放行/顿号路径提取+授权闭环/拒绝词 vs 空结果);makeState 防 mtime 重解析注入全套规则(测试洁净性);
|
|
79
|
+
- **质保**:verify-all 七层 VERIFY ALL OK + loader-smoke PASS。
|
|
80
|
+
|
|
81
|
+
## 0.5.12(2026-08-28 追加,阶段一:事故驱动修复 / 用户逐项拍板)
|
|
82
|
+
|
|
83
|
+
> 背景:2026-08-28 real-legal 会话事故——用户"请将建议书内容转化到业务通告"被规则 22 连拦 7 次(无执行分点);
|
|
84
|
+
> 授权后读取仍被拦(ERR-L8QAXS/LU50QQ);注入提示被节流后模型越过用户回复直接执行。全部根因如下,逐条修复。
|
|
85
|
+
|
|
86
|
+
- **A1 意图优先级修正**(intent.js):动作词优先于方案词——"将建议书内容转化到业务通告中"含"建议"(PLAN_RE)却被判 plan → 无执行分点 → 拦;修正:`hasAction` 分支前置(含 `SPECIFIC_ACTION_RE` 区分宽泛动作词——"给出执行方案"仍是方案请求);词表补 `转化/转换/读取/读(完|出|一下…)/展示/打开/提取/还原/导入/导出`(VERB_RE/TYPE_HINTS 同步);
|
|
87
|
+
- **A2 LLM 意图兜底同步等待**(index.js):预取异步 vs 守卫同步的竞态——词表未命中时 LLM 兜底结果未到即按词表拦截;修正:turn 记录 `llmIntentPromise`,`tools/pre-execute` 首钩子对 `llm-pending` 回合等待(上限 300ms,超时降级词表);
|
|
88
|
+
- **A3 @文件引用信号**(intent.js):用户消息含 DSH 官方 @file 引用语法(`@path`/`@"path with spaces"`/`@file.docx`)+ 处理动词 → 判执行分点;邮箱(a@b.com)不误伤(@ 前需非词字符边界);
|
|
89
|
+
- **B1 只读判定补强**(patterns.js):读取 .doc 的 COM 命令(`$kwps.Documents.Open($dst,0,$true)` / `$doc.Content.Text`)与变量赋值段被"每段必须命中白名单 cmdlet"误判为写 → 只读豁免失效;修正:三档判定(写特征拒 / 白名单+分明无害段过 / 不认识的段保守拒),新增 MUTATING_METHOD_RE(.NET 写方法/SaveAs 等)与 COM_CONTENT_ASSIGN_RE(`$doc.Text=...` 写文档不被豁免);只读优先于一切失拦链;
|
|
90
|
+
- **C1 规则 22 粒度并入会话授权**(guard-core):12D 已记录的 TTL 授权在下一回合被 22 粒度忽略("只认本回合 scopes")——授权分裂;修正:粒度检查并入 `session.authorizations` 中**带 TTL 且未过期**的授权;安全边界:无 expiresAt 的长期授权不放宽本轮(08-24 语义保留);
|
|
91
|
+
- **E1 打标链路修复**(guard-core/index.js/audit.js):ERR 码随机生成但**不进日志**、/guard label 只认 UUID——打标断链(用户实测无法操作);修正:makeHit 返回 errId → deny 审计条目落 `errId` 字段 → `/guard log` 显示 `ERR-xxxxxx` → `/guard label ERR-xxxxxx incorrect` 直接定位(新拦截文案引导到 ERR 码,不再让用户找事件号);
|
|
92
|
+
- **测试**:`test/phase1-fix.test.mjs`(事故原文回放:转化消息 execute / COM 只读放行 / 写命令仍拦 / 授权覆盖 / ERR 码链路),收录 run-all;
|
|
93
|
+
- **质保**:run-all 全绿 + loader-smoke PASS + 热重载生效 + 运行时实弹(日志 errId 落盘实证)。
|
|
94
|
+
|
|
95
|
+
## 0.5.11(2026-08-28 追加)
|
|
96
|
+
|
|
97
|
+
> 0.5.11 = 判定内核第一轮(用户定稿"主从翻转"第一步):安全洞两处修复 + 12A/22-7 判据同源。
|
|
98
|
+
> 行为变化目标:问句回合不再能覆盖已存在文件;执行类脚本不再借"分析"通道放行。
|
|
99
|
+
|
|
100
|
+
- **新建豁免补"新建"语义**(isLowRiskWorkspaceNew):豁免原文 = 工作区内低风险**新建**——目标存在即不算新建,edit/write 已存在文件走正常授权(此前问句回合可覆盖既有文件,审计曾见 `edit service.js` 被判"低风险新建豁免");相对路径无法判定存在性 → 保守不豁免;
|
|
101
|
+
- **分析通道移除脚本区语义豁免**(isAnalysisOp):删除"repo `scripts/*.mjs` = 分析"分支——脚本名判不了脚本干什么(release-plugin.mjs 等执行脚本曾借道放行);通道保留物理判据(严格只读/临时区写/下载到临时区),脚本语义判给模型;
|
|
102
|
+
- **12A 与 22-7 判据同源**(guard-core rule12a-approval):12A 分支接入 isAnalysisOp + isLowRiskWorkspaceNew 同一组豁免(此前 22-7 放行、12A 仍要求授权的不一致);红线不变(工作区外不豁免);
|
|
103
|
+
- **后台判定模型 = 当前会话模型**(resolveRoute):judge 裁决 / llm-intent 意图兜底 / 规则理解三处统一走 resolveRoute——此前取"模型列表第一个"(=dsf 基础模型,与主会话 dsfve 能力不一致,裁决结果与主会话判断脱节);改为优先 `agentDefaultModel.currentSelection()`(官方当前模型选择,provider+model)→ 环境变量 → 列表兜底;
|
|
104
|
+
- **测试**:patterns 新增存在性 4 断言;analysis-channel 改为新行为(脚本区不放行/执行脚本不放行/临时区写仍放行);llm-understander 新增 resolveRoute 当前模型优先 2 断言(dsfve 优先、无当前选择回退列表兜底);
|
|
105
|
+
- **质保**:verify-all 七层全绿 + 冷加载探针 PASS。
|
|
106
|
+
|
|
107
|
+
## 0.5.11 续批(2026-08-28,AGENTS.md 成对,用户逐项拍板)
|
|
108
|
+
|
|
109
|
+
- **规则 13A 触发/检查行**:触发改为"删除/覆盖/迁移/批量删除**文件**等操作。修改**受保护**配置文件等操作";硬拦检查同步为"删除/覆盖/迁移/批量删除文件、修改受保护配置文件类操作且无备份 → 拒绝"——引擎当初收窄到受保护配置是有意(普通配置修改海量,全纳入会过度打扰);正解 = 正文向引擎对齐;
|
|
110
|
+
- **规则 22 正文三处**:豁免明细由引擎清单维护;执行许可 = 执行许可词 + 执行语(词表引擎唯一维护,正文不枚举);自证③括号去重指向时序⑤;
|
|
111
|
+
- **词表唯一源(`lib/core/lexicon.js`)**:五表(执行许可/执行动作/疑问/状态信号/豁免工具清单)集中唯一;intent.js/authorization.js 删除各自重复定义改 import——消灭"正文 1 套 + 引擎 9 张"同语义多处定义的漂移根因(用户定稿:可用词表但不能有两套);
|
|
112
|
+
- **rule14 handler 删除**:纯 D 级自证、无执行分支 = 空转映射(consistency 断言 17→16 同步);
|
|
113
|
+
- **规则 19 保持 D 级**:实测升 C 会让 19 整条变 deny(C 级默认给硬动作)——⑧ 统一入口机器执行已由 __self-protect 独立承担(guard-core L241/248);
|
|
114
|
+
- **规则 24 机器执行修正**(用户两次纠偏):24 目的 = 控制插件和工具装配、避免改坏(v3.37/踩坑 36 背景)——工具面正解 = 原文④"纳入同一套敏感/授权/备份/版本守卫"(跨工具一致性测试),**不是"归类必须 mutating"**(错误版曾枚举 35 工具名断言=mutating、且把 dev_stage_promote/demote 移出 artifact——查分类名不查守卫链,违反手册 v3.39"dev_stage 四件套纳入敏感授权检查、不改分类")。修正:`test/guardchain-coverage.test.mjs`(33 个写/删/移工具 × 问句回合+无授权真实场景 → guardDecision,断言被拦或属豁免面,静默逃逸=红);dev_stage 四件套恢复 artifact(授权检查由 12A/13A/24 链上覆盖);verify-all 第 ⑥ 层接入。
|
|
115
|
+
|
|
116
|
+
## 0.5.10(2026-08-27 追加)
|
|
117
|
+
|
|
118
|
+
> 收编:用户审查后的 real-legal 会话建议(建议 2/3/4/5,依据本手册知识条目 K-01~K-06 批判性审查)+ 分析通道(用户多次提出的"只读分析需要写临时脚本/输出"痛点)+ 统一入口加固(2026-08-27 手册损伤事故复盘)。
|
|
119
|
+
|
|
120
|
+
- **分析通道(单真源 `isAnalysisOp`)**:严格只读 ∪ 分析临时区写(`logs/` `.analysis-tmp/` `.backups/`)→ 任何回合放行 + 审计 `analysis-scratch`;**红线**(红线层在 guard-core):受保护文件名/工作区外(isOutsideWorkspace)/删除移动/覆盖正式文件一律不豁免;**0.5.11(用户定稿)**:移除"分析脚本区 repo `scripts/*.mjs` 一律=分析"的语义豁免——脚本名判不了脚本干什么,执行类脚本(release/inject/uninject 等)曾借道放行(执行口径漏洞);脚本语义判给模型,通道只保留物理判据(只读/临时区写/下载到临时区);
|
|
121
|
+
- **写类判定单真源**:self-protect / 22 / 13A 统一走 `isMutationCommand`——修复 `Write-Output` 被 `write` 子串误杀(WGO654/ES3VCD 案例:纯读命令查受保护文件名被误拦);
|
|
122
|
+
- **只读词表补全**:ForEach-Object / Get-ItemProperty / Get-Variable / Get-FileHash(多行"读+筛选+循环"组合判只读);
|
|
123
|
+
- **统一入口加固**(dsh-manual-write.mjs):① 转义事故特征检测——表格行内字面 `\n` / `\$`(PowerShell 单引号转义事故模式,2026-08-27 手册坏行事故)→ 拒绝;② 写后结构校验——表格块列宽一致(±2 容忍)+ 手册版本记录行存在,不通过自动回滚;
|
|
124
|
+
- **误判打标闭环(建议 4)**:所有硬拦文案尾部附「误判可打标:/guard label <事件号> incorrect」指引;health-audit 新增 deny 总数 vs incorrect/correct 打标占比统计(词表迭代量化依据);
|
|
125
|
+
- **已知坑召回(建议 5)**:工具错误文本命中特征表(SEC_E_NO_CREDENTIALS / ECONNREFUSED:7890 / EPERM 管道 / ERR_MODULE_NOT_FOUND)→ 审计 `error-hint` + 注入指向知识库提示(本轮去重、走投递资格链,不违反注入噪音治理);
|
|
126
|
+
- **质保**:新增强力测试(写类判定/分析通道/临时区红线/转义检测 REFUSED 实测/召回特征)+ verify-all 七层全绿 + 工具箱覆盖门禁回归。
|
|
48
127
|
|
|
49
128
|
## 0.5.9(2026-08-27 追加)
|
|
50
129
|
|
|
@@ -58,13 +137,13 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
|
|
|
58
137
|
- **白名单 v2 带元数据**:`~/.dsh/rule-engine-tools.json` 升级为 `[{name,time,session}]`(**谁、何时、在哪个会话被放行**——可视化基础);旧 `["name"]` 格式兼容加载;历史条目显示"时间/来源未知";
|
|
59
138
|
- **`/guard tools` 命令**:查看白名单(永久 + 本会话新增,含时间/来源会话)+ `/guard tools revoke <工具名>` 撤销(持久化+会话集同步);
|
|
60
139
|
- **工具箱覆盖门禁**:`scripts/check-tool-coverage.mjs`(官方 tool-catalog vs 分类表,任一 unknown 即红——`run_code` 事故同类的机器防线)已挂入 `verify-all` 第五层;
|
|
61
|
-
- **测试**:全量单测(新增 whitelist 格式 5 用例)+ loader-smoke 新增 E 场景(白名单 v2 真实落盘断言:对象数组/时间/来源会话/审计留痕)+ verify-all
|
|
140
|
+
- **测试**:全量单测(新增 whitelist 格式 5 用例)+ loader-smoke 新增 E 场景(白名单 v2 真实落盘断言:对象数组/时间/来源会话/审计留痕)+ verify-all **七层**全绿。
|
|
62
141
|
|
|
63
142
|
## 0.5.8(2026-08-26 追加)
|
|
64
143
|
|
|
65
144
|
- **白名单持久化**:"允许使用 X"(未归类工具批准)落盘 `~/.dsh/rule-engine-tools.json`——此前为内存态,热重载/重启即清(用户需反复重发,实测 esr_task/esr_close 各被清一次);
|
|
66
145
|
- **只读命令词表补全**:`Select-Object / Out-String / Format-Table / Measure-Object / Sort-Object / Where-Object / Group-Object / Out-Null / ConvertTo-Json` 等常见只读 cmdlet 纳入只读判定(此前 `Get-Item | Select-Object` 被误判为变更拦截)——含"管道内含写操作仍判变更"的安全回归;
|
|
67
|
-
- **`npm run verify`** 注册(= `scripts/verify-all.mjs`
|
|
146
|
+
- **`npm run verify`** 注册(= `scripts/verify-all.mjs` 七层体检别名);
|
|
68
147
|
- 工作区内低风险变更豁免(12A 正文对齐:只读诊断脚本不再被拦——0.5.7 的延续项)。
|
|
69
148
|
|
|
70
149
|
## 任务契约与反过度工程(可选)
|
|
@@ -196,7 +275,7 @@ npm test
|
|
|
196
275
|
bash scripts/build.sh
|
|
197
276
|
```
|
|
198
277
|
|
|
199
|
-
交付前体检(0.5.7 起):`node scripts/verify-all.mjs
|
|
278
|
+
交付前体检(0.5.7 起):`node scripts/verify-all.mjs`(七层:语法/单元/组合冒烟/工具箱覆盖/守卫链覆盖/关联一致性/真实判例)与 `node scripts/health-audit.mjs`(找茬)——详见「质量与验证」。
|
|
200
279
|
|
|
201
280
|
发布:`node ../../scripts/release-plugin.mjs <插件目录> <版本号>`(一键 npm + git + GitHub Release,见 `scripts/release-plugin.mjs` 注释)。
|
|
202
281
|
|
|
@@ -2,30 +2,39 @@
|
|
|
2
2
|
// 纯函数,可独立测试。
|
|
3
3
|
import { GENERIC_EXEC_TOOLS, pathTarget, commandText, writeTargetPathsFromCommand, extractCopyPaths } from "./patterns.js";
|
|
4
4
|
import { QUESTION_RE } from "./intent.js";
|
|
5
|
+
import {
|
|
6
|
+
APPROVAL_EXEC_RE,
|
|
7
|
+
APPROVAL_WORDS_RE,
|
|
8
|
+
DIRECTIVE_WORDS_RE,
|
|
9
|
+
REJECTION_WORDS_RE,
|
|
10
|
+
PLAN_ONLY_RE,
|
|
11
|
+
ACTION_WORDS_RE
|
|
12
|
+
} from "./lexicon.js";
|
|
5
13
|
|
|
6
14
|
export const AUTH_TTL_MS = 10 * 60 * 1000;
|
|
7
15
|
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
const APPROVAL_RE = /(?:允许|同意|可以|是|好|确认|授权|执行|批准|去吧|开始|ok|yes)/i;
|
|
12
|
-
const REJECTION_RE = /(?:不允许|拒绝|不要|否|取消|不同意|no|No)/i;
|
|
16
|
+
// 0.5.11(用户拍板):词表唯一源 = lexicon.js——授权判定所用的许可词/指令词/拒绝词/动作词
|
|
17
|
+
// 一律从那里 import;本文件不再自持定义(原 AUTH_WORDS_RE/DIRECTIVE_RE/APPROVAL_RE/
|
|
18
|
+
// REJECTION_RE/PLAN_ONLY_RE/EXEC_ACTION_RE 删除,同语义四处定义的历史漂移根因清除)。
|
|
13
19
|
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
// 授权/批准判定用:许可词(允许/同意/可以/是/好/确认/授权/执行/批准/去吧/开始/ok/yes)
|
|
21
|
+
const AUTH_WORDS_RE = APPROVAL_WORDS_RE;
|
|
22
|
+
// 指令式动作词(用户命令式指令 = 视为授权执行)
|
|
23
|
+
const DIRECTIVE_RE = DIRECTIVE_WORDS_RE;
|
|
24
|
+
// 纯批准词(选项/自由文本判定用)
|
|
25
|
+
const APPROVAL_RE = /(?:允许|同意|可以|是|好|确认|授权|执行|批准|去吧|开始|ok|yes)/i;
|
|
26
|
+
const REJECTION_RE = REJECTION_WORDS_RE;
|
|
19
27
|
|
|
20
|
-
//
|
|
21
|
-
|
|
28
|
+
// 0.5.11:执行许可确认词唯一源(lexicon.js APPROVAL_EXEC_RE,组合=许可词[0,16]执行语);
|
|
29
|
+
// 纯"确认方案/理解/方向"不构成(PLAN_ONLY_RE 亦唯一源)。
|
|
22
30
|
|
|
23
31
|
// 选项/自由文本里的明确动作词(用于 ask 结果判定:选择"执行/调整/修复…"即视为批准)
|
|
24
|
-
|
|
32
|
+
// 0.5.11:唯一源 = lexicon.js ACTION_WORDS_RE(与 intent 意图判定同表,不再二份)
|
|
33
|
+
const EXEC_ACTION_RE = ACTION_WORDS_RE;
|
|
25
34
|
|
|
26
35
|
const TYPE_HINTS = [
|
|
27
36
|
{ re: /删除|移除|remove|delete/i, type: "delete" },
|
|
28
|
-
{ re: /修改|编辑|替换|写入|写(?:入|文件|作|下|好|完|成)?|改(?:为|成)
|
|
37
|
+
{ re: /修改|编辑|替换|写入|写(?:入|文件|作|下|好|完|成)?|改(?:为|成)?|保存|另存为|转化|转换|edit|write|replace/i, type: "write" },
|
|
29
38
|
{ re: /备份|backup/i, type: "backup" },
|
|
30
39
|
{ re: /git\s+(push|commit)|提交|推送/i, type: "git" },
|
|
31
40
|
// 命令类只在有明确命令上下文时推断;"执行/开始/做"等宽泛指令保持 any,避免把普通文件变更误限为 command
|
|
@@ -35,10 +44,15 @@ const TYPE_HINTS = [
|
|
|
35
44
|
// 只读/审查类(置末位,2026-08-25 RB-05):不覆盖在前类别的变更语义(“修改并审查”仍按 write/delete),
|
|
36
45
|
// 仅当无其它类型命中时给只读类型——配合 ACTION_RE 只读词(审查/阅读/验证等),使“审查文件”类执行分点
|
|
37
46
|
// 授权类型为 analysis(只读),不再落入 any 宽泛授权。
|
|
38
|
-
{ re:
|
|
47
|
+
{ re: /审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|评估|分析|研究|排查|核实|梳理|调查|诊断|读取|读(?:出|一下|一遍|完)?|展示|打开|提取/i, type: "analysis" }
|
|
39
48
|
];
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
// C2(2026-08-28 阶段二):`、`(中文顿号)从非路径字符黑名单移除——Windows 中文目录名
|
|
51
|
+
// 常见顿号("D:\1、工作\..."——用户工作目录),原正则在此截断 → 授权记录成 "d:/1",
|
|
52
|
+
// 真实操作路径匹配失败(ERR-LU50QQ:已授权范围 [write d:/1] 拦 d:/1、工作/...)。
|
|
53
|
+
// 保守边界:顿号后跟空格+新词时可能合并(fail-closed——提取过长 → 授权匹配失败 → 拦而非放),
|
|
54
|
+
// 不放大授权;引号包裹分支本就不受此影响。
|
|
55
|
+
const PATH_TOKEN_RE = /["'][A-Za-z]:[\\/](?!\/)[^"']+["']|[A-Za-z]:[\\/](?!\/)[^\s'"`,。;:!?()【】《》]+|(?:^|[\s"'`])\/(?:[^\s"'`,。;:!?()【】《》、]+)/g;
|
|
42
56
|
|
|
43
57
|
export function normalizePath(p) {
|
|
44
58
|
if (typeof p !== "string") return "";
|
|
@@ -54,6 +68,19 @@ export function inferTypeFromText(text) {
|
|
|
54
68
|
return "any";
|
|
55
69
|
}
|
|
56
70
|
|
|
71
|
+
/** 0.5.10 建议1①(用户审查采纳):ask 答复文本 → 结构化操作类型(修复"弹窗授权宽泛 any 接不住真操作")。
|
|
72
|
+
* 返回 write | command | any(analysis/artifact 类本就不需授权,不落入授权记录)。
|
|
73
|
+
* 明确操作词 → 具体类型;含糊/纯查看 → any(保守:TTL 短兜底)。 */
|
|
74
|
+
export function classifyAskScopeType(text) {
|
|
75
|
+
const s = String(text || "");
|
|
76
|
+
const hasWrite = /(?:修改|编辑|写入|覆盖|保存|删除|丢弃|移除|移动|改名|创建|新建|上传|复制|下载到|发布|推送|提交|写文件)/i.test(s);
|
|
77
|
+
const hasCommand = /(?:pwsh|powershell|命令|脚本|执行|运行|诊断|排查|体检)/i.test(s);
|
|
78
|
+
if (hasWrite && !hasCommand) return "write";
|
|
79
|
+
if (hasCommand && !hasWrite) return "command";
|
|
80
|
+
// 同时含/都不含 → any(保守;不含时 TTL 短)
|
|
81
|
+
return "any";
|
|
82
|
+
}
|
|
83
|
+
|
|
57
84
|
/** 从文本推断全部命中的操作类型(用于一条子句含多个操作,如“修改 A 并删除 B”) */
|
|
58
85
|
export function inferTypesFromText(text) {
|
|
59
86
|
const s = String(text || "");
|
|
@@ -139,15 +166,15 @@ export function scopesFromIntents(intents) {
|
|
|
139
166
|
|
|
140
167
|
// 动作词 → 操作类型(精确配对用;"执行/运行"保持 any,避免把宽泛指令误限为 command)
|
|
141
168
|
const VERB_TYPE_RE = [
|
|
142
|
-
[
|
|
169
|
+
[/删除|移除|清理|清空|丢弃/i, "delete"],
|
|
143
170
|
[/备份/i, "backup"],
|
|
144
171
|
[/提交|推送/i, "git"],
|
|
145
172
|
[/下载/i, "network"],
|
|
146
173
|
[/执行|运行/i, "any"],
|
|
147
|
-
[
|
|
174
|
+
[/写|保存|另存为|创建|复制|移动|修改|编辑|替换|改|补|修|做|安装|卸载|修复|重建|启动|停止|调整|改进|优化|升级|迁移|整理|添加|增加/i, "write"]
|
|
148
175
|
];
|
|
149
176
|
const VERB_RE =
|
|
150
|
-
|
|
177
|
+
/删除|移除|修改|编辑|替换|写入|写|保存|另存为|创建|复制|移动|备份|提交|推送|下载|执行|运行|安装|卸载|清理|修复|重建|启动|停止|调整|改进|优化|升级|迁移|整理|添加|增加|改|补|修|做|实施|推进|继续|处理|解决|转化|转换|读取|读(?:完|出|一下|一遍|出来)?|展示|打开|提取|还原|导入|导出|落盘|落地/g;
|
|
151
178
|
|
|
152
179
|
function verbType(word, fullText) {
|
|
153
180
|
for (const [re, type] of VERB_TYPE_RE) {
|
|
@@ -424,6 +451,24 @@ export function askResultApproved(result) {
|
|
|
424
451
|
return approved && !rejected;
|
|
425
452
|
}
|
|
426
453
|
|
|
454
|
+
/**
|
|
455
|
+
* C3(2026-08-28 阶段二):ask 结果是否为【明确拒绝】——区别于"未响应/超时/无选择"。
|
|
456
|
+
* 用户说"你也没给我时间回复啊":engine 旧逻辑把「任何未批准」一律记为拒绝(auth-reject +
|
|
457
|
+
* askRejections 池)→ 5 分钟内再有 ask 被 __ask-throttle 拦 —— 用户没回 ≠ 用户拒绝,
|
|
458
|
+
* 节流烧掉的是"用户可能还没来得及看"。仅当结果文本含明确拒绝词才记拒绝。
|
|
459
|
+
*/
|
|
460
|
+
export function askResultRejected(result) {
|
|
461
|
+
const answers = extractAnswers(result);
|
|
462
|
+
if (!answers || answers.length === 0) return false;
|
|
463
|
+
for (const item of answers) {
|
|
464
|
+
const texts = [...(item.selected || []), item.custom || ""].filter(Boolean);
|
|
465
|
+
for (const t of texts) {
|
|
466
|
+
if (isRejectionText(t)) return true;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
427
472
|
function extractToolResultText(result) {
|
|
428
473
|
const msg = result?.message ?? result;
|
|
429
474
|
const content = Array.isArray(msg?.content)
|
package/lib/core/guard-core.js
CHANGED
|
@@ -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";
|
|
@@ -42,6 +47,28 @@ function sessionIdOf(exec) {
|
|
|
42
47
|
return "global";
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
/**
|
|
51
|
+
* B2(2026-08-28 阶段二):委派/子代理会话判定(规则 22④"无用户消息回合(委派…)"的识别层)。
|
|
52
|
+
* 官方子代理(dsh-subagent-in-process-driver)把任务 prompt 以 source.kind="user" 注入子会话,
|
|
53
|
+
* 引擎按真实用户消息处理 → 子代理的"任务书"被判"无执行分点"→ 读文件等操作被 ERR-L8QAXS 拦
|
|
54
|
+
* (2026-08-28 实弹)。规则 22④ 承诺委派回合不做此判定;判定依据 = 会话元数据
|
|
55
|
+
* (agent.session.meta.delegationDepth>0 / origin==="subagent" / 存在 parentSession)。
|
|
56
|
+
*/
|
|
57
|
+
function isDelegatedSession(exec) {
|
|
58
|
+
const agent = exec?.agent;
|
|
59
|
+
const sess = agent?.session;
|
|
60
|
+
if (!sess || typeof sess !== "object") return false;
|
|
61
|
+
const meta = sess.meta;
|
|
62
|
+
if (meta && typeof meta === "object") {
|
|
63
|
+
if (Number(meta.delegationDepth) > 0) return true;
|
|
64
|
+
if (meta.origin === "subagent") return true;
|
|
65
|
+
if (typeof meta.parentSession === "string") return true;
|
|
66
|
+
}
|
|
67
|
+
// 兜底:agent 层(部分驱动结构 meta 在 agent 侧)
|
|
68
|
+
if (Number(agent?.meta?.delegationDepth) > 0 || agent?.meta?.origin === "subagent") return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
const RULE_HINTS = {
|
|
46
73
|
"1": "先分析根因,确认问题后再继续",
|
|
47
74
|
"9": "改用脚本文件或显式 UTF-8 BOM 流程",
|
|
@@ -61,7 +88,8 @@ function makeHit(cfg, reason) {
|
|
|
61
88
|
ruleId: cfg.ruleId,
|
|
62
89
|
title: cfg.title,
|
|
63
90
|
action: "deny",
|
|
64
|
-
|
|
91
|
+
errId,
|
|
92
|
+
reason: `${reason}(规则 ${cfg.ruleId}|放行:${hint}|ERR-${errId}|误判可打标:/guard label ERR-${errId} incorrect)`
|
|
65
93
|
};
|
|
66
94
|
}
|
|
67
95
|
|
|
@@ -74,6 +102,18 @@ function looksLikeFileMutation(name, args) {
|
|
|
74
102
|
return Boolean(a.file_path || a.path || a.command || a.code || a.execute || a.script || a.fn);
|
|
75
103
|
}
|
|
76
104
|
|
|
105
|
+
/** 统计 substring 在 text 中出现的次数(0.5.11 唯一性前提辅助) */
|
|
106
|
+
function countOccurrences(text, sub) {
|
|
107
|
+
if (typeof text !== "string" || typeof sub !== "string" || sub.length === 0) return 0;
|
|
108
|
+
let count = 0;
|
|
109
|
+
let idx = text.indexOf(sub);
|
|
110
|
+
while (idx !== -1) {
|
|
111
|
+
count++;
|
|
112
|
+
idx = text.indexOf(sub, idx + sub.length);
|
|
113
|
+
}
|
|
114
|
+
return count;
|
|
115
|
+
}
|
|
116
|
+
|
|
77
117
|
export function isProfilePackageJson(p) {
|
|
78
118
|
return typeof p === "string" && /profiles[\\/][^\\/]+[\\/]package\.json$/i.test(p);
|
|
79
119
|
}
|
|
@@ -218,15 +258,16 @@ export function guardDecision(state, exec, now = Date.now(), opts = {}) {
|
|
|
218
258
|
if (session.turn.askRejected) {
|
|
219
259
|
return makeHit(
|
|
220
260
|
{ ruleId: "__ask-rejected", title: "本回合 ask 已被拒绝", action: "deny" },
|
|
221
|
-
`【提示】本回合的 ask
|
|
261
|
+
`【提示】本回合的 ask 已遭拒绝(规则 22 沟通直接性)。用户已明确答复,无需再次弹窗询问;用普通文本说明即可。`
|
|
222
262
|
);
|
|
223
263
|
}
|
|
224
264
|
// 5 分钟内已有被拒的 ask 记录 → 拦(防连环弹窗 → not-pending 诱因链)
|
|
265
|
+
// C3(2026-08-28):节流池仅含"明确拒绝"(未响应不入池,见 index.js ask 结果处理)
|
|
225
266
|
const recentReject = (state.askRejections || []).some((r) => r.sessionId === session.id && now - r.at < 5 * 60 * 1000);
|
|
226
267
|
if (recentReject) {
|
|
227
268
|
return makeHit(
|
|
228
269
|
{ ruleId: "__ask-throttle", title: "ask 请求过频", action: "deny" },
|
|
229
|
-
`【提示】5
|
|
270
|
+
`【提示】5 分钟内已有一次被明确拒绝的 ask(规则 22 沟通直接性)。再次弹窗可能无法送达;用普通文本说明即可。`
|
|
230
271
|
);
|
|
231
272
|
}
|
|
232
273
|
}
|
|
@@ -242,9 +283,10 @@ export function guardDecision(state, exec, now = Date.now(), opts = {}) {
|
|
|
242
283
|
|
|
243
284
|
// 阶段 C:硬拦 pwsh/bash 绕过统一入口直接写受保护文件(规则 19⑧/21⑨ 的机器执行层)
|
|
244
285
|
// 合法通道 = 整条命令仅调用 dsh-manual-write.mjs(无 ; | & 链式/换行,防注释文本伪造放行)
|
|
286
|
+
// 0.5.10:写类判定改 isMutationCommand(单真源)——修复 `Write-Output` 被 `write` 子串误杀(WGO654/ES3VCD 同类)
|
|
245
287
|
if (!unlock && (name === "pwsh" || name === "bash")) {
|
|
246
288
|
const cmd = commandText(args) || "";
|
|
247
|
-
if (cmd &&
|
|
289
|
+
if (cmd && isMutationCommand(cmd) && PROTECTED_FILENAME_RE.test(cmd)) {
|
|
248
290
|
if (!isEntryChannelCommand(cmd)) {
|
|
249
291
|
return makeHit(
|
|
250
292
|
{ ruleId: "__self-protect", title: "受保护文件禁止绕过统一入口直写", action: "deny" },
|
|
@@ -261,7 +303,11 @@ export function guardDecision(state, exec, now = Date.now(), opts = {}) {
|
|
|
261
303
|
try {
|
|
262
304
|
const current = readFileSync(p, "utf8");
|
|
263
305
|
const simulated = resultingFileContent(name, args) ?? current;
|
|
264
|
-
|
|
306
|
+
// 0.5.11(用户定稿):单行整句重写放行需 old 唯一匹配——工具层已拒多处匹配;
|
|
307
|
+
// 唯一时传给 validateEditedFile/append 作为放行前提(唯一 = 替换位置正确 = 语义编辑非覆盖)。
|
|
308
|
+
const oldStr = args?.old_string ?? args?.old_str ?? "";
|
|
309
|
+
const uniqueMatch = oldStr.length > 0 ? countOccurrences(current, oldStr) === 1 : false;
|
|
310
|
+
const check = validateEditedFile(current, simulated, oldStr, args?.new_string ?? args?.new_str ?? "", uniqueMatch);
|
|
265
311
|
if (!check.ok) {
|
|
266
312
|
return makeHit(
|
|
267
313
|
{ ruleId: "__version-guard", title: "版本守卫:写入前校验", action: "deny" },
|
|
@@ -320,10 +366,27 @@ function matchRule(cfg, ctx) {
|
|
|
320
366
|
// 规则 22 机器化:无执行分点/存在歧义 → 本回合禁止变更类工具调用
|
|
321
367
|
//("你的执行方案难道没问题吗?"虽含"执行"仍是疑问句——同形词不构成指令,不豁免)
|
|
322
368
|
if (cfg.handler === "rule22-7-direct") {
|
|
369
|
+
// B2(2026-08-28 阶段二):委派/子代理回合不做"无执行分点"判定(规则 22④ 的识别层落地)——
|
|
370
|
+
// 子代理任务书以 source.kind="user" 注入,会被当用户消息判"无执行分点"而误拦(ERR-L8QAXS 实弹)。
|
|
371
|
+
if (isDelegatedSession(exec)) {
|
|
372
|
+
audit?.({ kind: "delegated-skip", rule: cfg.ruleId, name: "委派回合豁免规则22", event: "tool/guard", reason: `委派会话(meta 判定)跳过"无执行分点",敏感操作仍由 12A/13A 把关`, session: sessionIdOf(exec) });
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
323
375
|
// 机制 B(2026-08-24):工具分类制(纯函数,覆盖现有与未来插件注册的全部工具)——
|
|
324
376
|
// analysis(只读分析放行)/ artifact(产物放行+审计)/ unknown(物理不拦,由 pre-execute
|
|
325
377
|
// ask/deny 层首调处置,防新插件绕过);mutating 维持原有严格逻辑。0.5.9 单真源:唯一分类表。
|
|
326
378
|
const cls = toolClass(name, args);
|
|
379
|
+
// 0.5.10 分析通道(用户多次提出:只读分析需要写临时脚本/输出——解压副本/聚合日志等):
|
|
380
|
+
// 严格只读 ∪ 分析临时区写 ∪ 分析脚本区调用 → 任何回合放行 + 审计留痕。
|
|
381
|
+
// 红线:工作区外路径命中临时区段(如 C:\xxx\logs\)不豁免——落入常规变更判定。
|
|
382
|
+
if (isAnalysisOp(name, args)) {
|
|
383
|
+
const scratch = extractAnalysisScratchPaths(commandText(args) || "");
|
|
384
|
+
const outside = scratch.filter((p) => isOutsideWorkspace(p, sessionIdOf(exec)));
|
|
385
|
+
if (outside.length === 0) {
|
|
386
|
+
audit?.({ kind: "analysis-scratch", rule: cfg.ruleId, name: "分析通道放行", tool: name, reason: `分析类操作(只读/临时区/分析脚本区)放行:${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
327
390
|
if (cls === "analysis") return null;
|
|
328
391
|
if (cls === "artifact") {
|
|
329
392
|
audit?.({ kind: "allow", rule: cfg.ruleId, name: "产物类工具放行", tool: name, reason: `分类 artifact(低风险产物写入,留痕可对账):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
|
|
@@ -331,11 +394,12 @@ function matchRule(cfg, ctx) {
|
|
|
331
394
|
}
|
|
332
395
|
if (cls === "unknown") return null; // 物理不拦;pre-execute ask 层首调询问
|
|
333
396
|
if (denyMutation) {
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
397
|
+
// A 方案(2026-08-28 用户拍板):方案/调研回合(无执行分点)写工作区正式路径 = 落盘/产出动作
|
|
398
|
+
// → 拦 + "确认后落盘"文案(不静默放行——原 isLowRiskWorkspaceNew 在此静默放行,属越权)。
|
|
399
|
+
// 方案性指令不构成落盘授权(规则 22 自证③);分析通道/临时区在 isAnalysisOp 已放行(工具不拦);
|
|
400
|
+
// _inbox 按分类文本定位=暂存待归位的产物,同正式路径(拦);工作区外由下方"询问"分支统一兜底。
|
|
401
|
+
if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && !isAnalysisOp(name, args) && !isOutsideWorkspace(p, sessionIdOf(exec))) {
|
|
402
|
+
return makeHit(cfg, `【硬拦截】本回合是方案/调研指令,写工作区文件属于"落盘/产出"动作——方案性指令不构成落盘授权(规则 22 自证③)。落盘需你明确确认:请回复"落盘"或"确认后保存"(或对产出位置确认)`);
|
|
339
403
|
}
|
|
340
404
|
// LLM 意图兜底(方案 A,2026-08-24):词表判拦 + LLM 高/低置信判 execute → 放行
|
|
341
405
|
//(非对称:LLM 只解救不收紧;审计在预取时已记 intent-llm)
|
|
@@ -368,11 +432,19 @@ function matchRule(cfg, ctx) {
|
|
|
368
432
|
// 保护性备份豁免(2026-08-26,backup 口径修复):规则 12A 豁免③ / 13A ⑦ 均豁免
|
|
369
433
|
// 复制到 .backups/.bak 的保护性备份——与 13A 分支口径一致,backup 类型不再被 22 粒度误拦。
|
|
370
434
|
if (op.type === "backup") return null;
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
435
|
+
// 本回合 scopes(execute 子句 + 本回合 ask 授权已由 handleSessionEvent 写入 turn.scopes)
|
|
436
|
+
// C1(2026-08-28 阶段一):并入 session.authorizations 中【带 TTL 且未过期】的授权——规则 22
|
|
437
|
+
// 粒度与 12A/13A 同口径(findMatchingAuth 均考虑 TTL)。修复"12D 已记录授权、下一回合操作
|
|
438
|
+
// 仍被 22 粒度拦"的授权分裂(2026-08-28 实弹:ask 授权后读取仍被拒)。
|
|
439
|
+
// 安全边界:只并入显式 TTL 授权(=同任务窗口);无 expiresAt 的长期授权不放宽本轮粒度
|
|
440
|
+
// (2026-08-24 旧语义"历史授权不得放宽本轮"对永久授权仍成立——防上轮授权越权本轮跨操作)。
|
|
441
|
+
const turnScopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
|
|
374
442
|
? session.turn.scopes
|
|
375
443
|
: scopesFromIntents(turnIntents);
|
|
444
|
+
const ttlAuths = Array.isArray(session.authorizations)
|
|
445
|
+
? session.authorizations.filter((a) => a && typeof a.expiresAt === "number" && a.expiresAt > now)
|
|
446
|
+
: [];
|
|
447
|
+
const scopes = [...turnScopes, ...ttlAuths];
|
|
376
448
|
if (!scopes.some((scope) => authMatches(scope, op))) {
|
|
377
449
|
return makeHit(cfg, `【硬拦截】本次变更操作不在本回合执行分点/授权范围内(规则 22):已授权范围 [${describeScopes(scopes)}];本次操作 [${describeOp(op)}]。请补充明确授权(可用 ask_user_question)后再执行`);
|
|
378
450
|
}
|
|
@@ -499,6 +571,21 @@ function matchRule(cfg, ctx) {
|
|
|
499
571
|
// 规则 12A:敏感操作需要匹配授权证据
|
|
500
572
|
if (cfg.handler === "rule12a-approval") {
|
|
501
573
|
if (isSensitiveToolCall(name, args, sessionIdOf(exec))) {
|
|
574
|
+
// 0.5.11(用户定稿):12A 与 22-7 判据同源——分析通道/低风险新建豁免在两条分支
|
|
575
|
+
// 使用同一组判据(isAnalysisOp/isLowRiskWorkspaceNew),不再各写一套(此前 22-7 放行、
|
|
576
|
+
// 12A 仍要求授权的不一致)。红线不变:工作区外(isOutsideWorkspace)不豁免。
|
|
577
|
+
if (isAnalysisOp(name, args)) {
|
|
578
|
+
const scratch = extractAnalysisScratchPaths(commandText(args) || "");
|
|
579
|
+
const outside = scratch.filter((p) => isOutsideWorkspace(p, sessionIdOf(exec)));
|
|
580
|
+
if (outside.length === 0) {
|
|
581
|
+
audit?.({ kind: "analysis-scratch", rule: cfg.ruleId, name: "分析通道放行", tool: name, reason: `分析类操作放行(12A 判据同源):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (isLowRiskWorkspaceNew(name, args)) {
|
|
586
|
+
audit?.({ kind: "allow", rule: cfg.ruleId, name: "低风险新建豁免(12A 判据同源)", tool: name, reason: `工作区内低风险新建(12A 判据同源):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
502
589
|
// 规则 19:dsh-usage-manual/SKILL.md 正文更新免逐次确认(仅手册本身)
|
|
503
590
|
if (p && MANUAL_PATH_RE.test(p)) return null;
|
|
504
591
|
// /guard unlock 本身即用户对受保护配置的授权
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// hotwords.js - 会话热词学习(B1,2026-08-29 方案甲由用户拍板)。
|
|
2
|
+
// 背景:词表是主裁决器(快/免费/可复现),但人工枚举永远补不完(v4.74/82 连补 4 轮仍漏——
|
|
3
|
+
// "落/认可/清理/移除/清空"逐个补);0.5.12 A2 已有 LLM 兜底:词表未命中但 LLM 判为执行分点 → 放行。
|
|
4
|
+
// 本模块把"LLM 已确认过"的动作词沉淀为热词:下次同词直接词表命中,不再依赖 LLM(省成本 + 更快 + 可复现)。
|
|
5
|
+
//
|
|
6
|
+
// 原则:
|
|
7
|
+
// 1. 热词只服务"意图判定"(hasAction → execute 分点),绝不 bypass deny/授权——其他拦截链不受影响;
|
|
8
|
+
// 2. 学习只在 LLM 兜底明确判 hasExecute=true 且词表未命中时发生(非对称:词表不放行时不学);
|
|
9
|
+
// 3. 持久化 ~/.dsh/rule-engine-hotwords.json(原子写 + 上限 + 容错;写失败静默回退,不影响主流程);
|
|
10
|
+
// 4. 审计留痕 kind=hotword-learn(来源会话/词);
|
|
11
|
+
// 5. 手动补词(lexicon.js)仍保留且优先:热词是自动通道,不替代编译词表。
|
|
12
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { dshHome } from "./paths.js";
|
|
15
|
+
import { audit } from "./audit.js";
|
|
16
|
+
|
|
17
|
+
const MAX_WORDS = 500;
|
|
18
|
+
|
|
19
|
+
export function hotwordFilePath() {
|
|
20
|
+
return join(dshHome(), "rule-engine-hotwords.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function emptyStore() {
|
|
24
|
+
return { versions: 1, words: [], updatedAt: null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 读取热词文件(容错:缺失/损坏/非数组 → 空表) */
|
|
28
|
+
export function loadHotwords() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = readFileSync(hotwordFilePath(), "utf8");
|
|
31
|
+
const data = JSON.parse(raw);
|
|
32
|
+
const words = Array.isArray(data?.words) ? data.words : [];
|
|
33
|
+
// 元数据迁移容错:老格式 words 可能是字符串数组
|
|
34
|
+
return {
|
|
35
|
+
versions: Number(data?.versions) || 1,
|
|
36
|
+
words: words.filter((w) => typeof w === "string" && w.length >= 2 && w.length <= 12),
|
|
37
|
+
updatedAt: data?.updatedAt || null
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
return emptyStore();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 原子写(tmp + rename);失败静默(学习不影响主流程) */
|
|
45
|
+
export function saveHotwords(store) {
|
|
46
|
+
try {
|
|
47
|
+
const p = hotwordFilePath();
|
|
48
|
+
mkdirSync(dshHome(), { recursive: true });
|
|
49
|
+
const tmp = p + ".tmp";
|
|
50
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2), "utf8");
|
|
51
|
+
renameSync(tmp, p);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 文本是否命中热词(子串匹配;热词为空直接 false) */
|
|
59
|
+
export function hasHotwordAction(text) {
|
|
60
|
+
const t = String(text || "").toLowerCase();
|
|
61
|
+
const { words } = loadHotwords();
|
|
62
|
+
for (const w of words) {
|
|
63
|
+
if (w && t.includes(w.toLowerCase())) return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 学习一个动作词(LLM 兜底确认 execute 且词表未命中时调用)。
|
|
70
|
+
* 返回 true=已写入;false=校验失败/写失败。
|
|
71
|
+
* 校验:2-12 字符、去空白、不重复、上限 500(旧→新保留)。
|
|
72
|
+
*/
|
|
73
|
+
export function learnHotword(word) {
|
|
74
|
+
const clean = String(word || "").replace(/[\s"'“”‘’?,。;:!!??、]/g, "").trim();
|
|
75
|
+
if (clean.length < 2 || clean.length > 12) return false;
|
|
76
|
+
const store = loadHotwords();
|
|
77
|
+
if (store.words.includes(clean)) return true;
|
|
78
|
+
store.words.push(clean);
|
|
79
|
+
if (store.words.length > MAX_WORDS) store.words = store.words.slice(-MAX_WORDS);
|
|
80
|
+
store.updatedAt = new Date().toISOString();
|
|
81
|
+
const ok = saveHotwords(store);
|
|
82
|
+
if (ok) {
|
|
83
|
+
audit({ kind: "hotword-learn", rule: "22", name: "热词学习", reason: `LLM 确认动作词入热词表:${clean}`, session: "" });
|
|
84
|
+
}
|
|
85
|
+
return ok;
|
|
86
|
+
}
|