dsh-rule-engine 0.5.16 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,12 +51,63 @@ const TYPE_HINTS = [
51
51
  // 只读/审查类(置末位,2026-08-25 RB-05):不覆盖在前类别的变更语义(“修改并审查”仍按 write/delete),
52
52
  // 仅当无其它类型命中时给只读类型——配合 ACTION_RE 只读词(审查/阅读/验证等),使“审查文件”类执行分点
53
53
  // 授权类型为 analysis(只读),不再落入 any 宽泛授权。
54
- { re: /审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|评估|分析|研究|排查|核实|梳理|调查|诊断|读取|读(?:出|一下|一遍|完)?|展示|打开|提取/i, type: "analysis" }
54
+ { re: /审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|评估|分析|研究|排查|核实|梳理|调查|诊断|读取|读(?:出|一下|一遍|完)?|展示|打开|提取/i, type: "analysis" },
55
+ // C4(2026-09-04):archive 独立类型(解压/归档高频用例;物理确认最小范围)
56
+ { re: /解压|解包|解压缩|归档|unzip|expand-archive|extract-archive|tar\s+-x|7z\s+x/i, type: "archive" }
55
57
  ];
56
58
 
59
+ // ── C4(2026-09-04):类型提示配置化 + 物理确认类型枚举 ──
60
+ // typeHintsOverride = null → 内置表;非空 → 完全替换(clear 语义);空 → 还原内置。
61
+ let typeHintsOverride = null;
62
+
63
+ function isValidHint(h) {
64
+ if (!h || typeof h.type !== "string" || h.type.length === 0 || h.type === "any") return false;
65
+ if (typeof h.re !== "string") return false;
66
+ try {
67
+ new RegExp(h.re, "i");
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function normalizeHints(list) {
75
+ const out = [];
76
+ for (const h of list) {
77
+ if (!isValidHint(h)) continue;
78
+ if (!out.some((x) => x.type === h.type)) out.push({ type: h.type, re: new RegExp(h.re, "i") });
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** C4:配置层扩展/替换授权类型提示;空列表 = 还原内置;{ clear: true } = 完全替换(不含内置) */
84
+ export function setTypeHints(hints, opts = {}) {
85
+ const list = Array.isArray(hints) ? hints : [];
86
+ if (list.length === 0) {
87
+ typeHintsOverride = null;
88
+ return;
89
+ }
90
+ const normalized = normalizeHints(list);
91
+ typeHintsOverride = opts?.clear ? normalized : [...TYPE_HINTS, ...normalized];
92
+ }
93
+
94
+ /** C4:当前生效的类型枚举(内置 9 类 + 配置扩展;any=全局通配被禁止,不枚举) */
95
+ export function APPROVE_TYPES() {
96
+ const hints = typeHintsOverride ?? TYPE_HINTS;
97
+ const types = [];
98
+ for (const h of hints) {
99
+ if (h && typeof h.type === "string" && h.type !== "any" && !types.includes(h.type)) types.push(h.type);
100
+ }
101
+ return types;
102
+ }
103
+
104
+ function currentHints() {
105
+ return typeHintsOverride ?? TYPE_HINTS;
106
+ }
107
+
57
108
  // C2(2026-08-28 阶段二):`、`(中文顿号)从非路径字符黑名单移除——Windows 中文目录名
58
- // 常见顿号("D:\1、工作\..."——用户工作目录),原正则在此截断 → 授权记录成 "d:/1",
59
- // 真实操作路径匹配失败(ERR-LU50QQ:已授权范围 [write d:/1] 拦 d:/1、工作/...)。
109
+ // 常见顿号("D:\1、示例\..."——用户工作目录),原正则在此截断 → 授权记录成 "d:/1",
110
+ // 真实操作路径匹配失败(ERR-LU50QQ:已授权范围 [write d:/1] 拦 d:/1、示例/练习/...)。
60
111
  // 保守边界:顿号后跟空格+新词时可能合并(fail-closed——提取过长 → 授权匹配失败 → 拦而非放),
61
112
  // 不放大授权;引号包裹分支本就不受此影响。
62
113
  const PATH_TOKEN_RE = /["'][A-Za-z]:[\\/](?!\/)[^"']+["']|[A-Za-z]:[\\/](?!\/)[^\s'"`,。;:!?()【】《》]+|(?:^|[\s"'`])\/(?:[^\s"'`,。;:!?()【】《》、]+)/g;
@@ -69,7 +120,7 @@ export function normalizePath(p) {
69
120
  /** 从文本推断操作类型(取第一个命中) */
70
121
  export function inferTypeFromText(text) {
71
122
  const s = String(text || "");
72
- for (const hint of TYPE_HINTS) {
123
+ for (const hint of currentHints()) {
73
124
  if (hint.re.test(s)) return hint.type;
74
125
  }
75
126
  return "any";
@@ -92,7 +143,7 @@ export function classifyAskScopeType(text) {
92
143
  export function inferTypesFromText(text) {
93
144
  const s = String(text || "");
94
145
  const types = [];
95
- for (const hint of TYPE_HINTS) {
146
+ for (const hint of currentHints()) {
96
147
  if (hint.re.test(s)) types.push(hint.type);
97
148
  }
98
149
  return types.length ? [...new Set(types)] : ["any"];
@@ -101,8 +152,8 @@ export function inferTypesFromText(text) {
101
152
  /**
102
153
  * 从文本提取路径候选(批次 4 统一入口:pairActionScopes 与 inferPathPrefixesFromText 共用)。
103
154
  * 处理两类噪声:
104
- * 1. 前缀冗余:引号路径同时命中两分支 → 截断前缀(如 "d:/deepseek")被更长匹配覆盖 → 丢弃;
105
- * 2. 截断可疑:裸含空格路径(无引号)只能提取到空格前的半截("D:\workspace\docs\..." → "d:/workspace")
155
+ * 1. 前缀冗余:引号路径同时命中两分支 → 截断前缀(如 "d:/example")被更长匹配覆盖 → 丢弃;
156
+ * 2. 截断可疑:裸含空格路径(无引号)只能提取到空格前的半截("D:\\workspace\docs\..." → "d:/workspace")
106
157
  * ——匹配后紧跟空白且后随 token 是路径字符开头且非盘符/引号 → 判定为截断 → 丢弃(fail-closed:
107
158
  * 宁缺授权,拦截后请用户给完整/引号路径,也不把半截前缀当成授权)。
108
159
  * @returns {Array<{index:number,path:string}>} 去噪后的候选(保留原文索引供就近配对)
@@ -38,7 +38,7 @@ export function categoryOfCommand(command) {
38
38
  if (/remove-item|\brm(?:dir)?\b|del(?:ete)?\b|move-item|\bmv\b|rename-item|\bren\b|clear-content|clean|purge|rm\s+-/.test(s)) return "delete";
39
39
  if (/install|add\s+--save|pnpm\s+add|npm\s+install|yarn\s+add/.test(s)) return "install";
40
40
  if (/build|tsc|tsdown|vite\s+build|webpack|rollup|compile|bundle/.test(s)) return "build";
41
- if (/test|jest|vitest|playwright|npm\s+test|pnpm\s+test|check-plugin-load|verify/.test(s)) return "test";
41
+ if (/test|jest|vitest|playwright|npm\s+test|pnpm\s+test|verify/.test(s)) return "test";
42
42
  if (/audit|mount|consistency|dump-config|inspect/.test(s)) return "audit";
43
43
  if (/analyze|analysis|review|read|grep|search/.test(s)) return "analyze";
44
44
  return null;
@@ -8,9 +8,7 @@ import {
8
8
  DESTRUCTIVE_CMD,
9
9
  DSH_KEYWORDS_RE,
10
10
  INLINE_CMD,
11
- MANUAL_PATH_RE,
12
11
  PROTECTED_FILENAME_RE,
13
- SKILL_EXEMPT,
14
12
  commandText,
15
13
  isAssemblyMutationTool,
16
14
  isBackupTool,
@@ -27,6 +25,7 @@ import {
27
25
  isAnalysisOp,
28
26
  isAnalysisScratchPath,
29
27
  extractAnalysisScratchPaths,
28
+ matchManualPath,
30
29
  pathTarget
31
30
  } from "./patterns.js";
32
31
  import { authMatches, describeAuth, describeOp, describeScopes, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText, scopesFromIntents } from "./authorization.js";
@@ -75,11 +74,11 @@ const RULE_HINTS = {
75
74
  "9": "改用脚本文件或显式 UTF-8 BOM 流程",
76
75
  "12A": "先 ask_user_question 获取匹配授权",
77
76
  "13A": "先对目标路径执行备份(复制到 .bak/.backups/trash-)",
78
- "18": "先读取 ~/.dsh/skills/dsh-usage-manual/SKILL.md",
77
+ "18": "先读取本机配置的手册(路径经 localIntegrations 配置;未配置则该规则无对象)",
79
78
  "21": "按规则 21 分级确认后再落盘",
80
79
  "22": "先回答/展示方案,或补充明确执行分点(工具类别+路径范围);已授权变更必须落在本回合执行分点/ask 授权范围内",
81
80
  "24": "确认插件 dsh.bundle 类型或改用正确挂载",
82
- "27": "先运行 node scripts/audit-mount-consistency.mjs --profile web"
81
+ "27": "先运行 node scripts/audit-mount-consistency.mjs --profile <p>"
83
82
  };
84
83
 
85
84
  // 0.5.12(F3):拒绝来源前缀——机器可解析 token 化(/guard log 可按来源 grep 过滤)
@@ -128,9 +127,28 @@ export function isProfilePackageJson(p) {
128
127
  return typeof p === "string" && /profiles[\\/][^\\/]+[\\/]package\.json$/i.test(p);
129
128
  }
130
129
 
131
- /** 判定命令是否"整条命令仅调用统一入口 dsh-manual-write.mjs"(无 ; | & 链式/换行) */
132
- function isEntryChannelCommand(cmd) {
133
- return typeof cmd === "string" && /^[^\n;|&]*dsh-manual-write\.mjs[^\n;|&]*$/i.test(cmd);
130
+ /** 判定命令是否"整条命令仅调用统一入口脚本"(无 ; | & 链式/换行/重定向)
131
+ * N1(2026-09-04 P2):排除向文件的重定向(> / >> / &> / fd>file)——`入口 status x > AGENTS.md` 不再被豁免;
132
+ * 仅保留 fd→fd 复制(2>&1 / 1>&2)。
133
+ * G1(2026-09-04 P2):引号感知——引号内的换行/;|& 视为合法参数内容(上会话曾误拦合法多行参数)。
134
+ * entryMarker:统一入口脚本名(来自 localIntegrations.entryScript;无配置 = 不存在"统一入口"概念)。 */
135
+ function isEntryChannelCommand(cmd, entryMarker) {
136
+ if (typeof cmd !== "string") return false;
137
+ if (!entryMarker) return false; // 无配置 = 无对象,自然静默
138
+ const c = cmd.trim();
139
+ if (!c.includes(entryMarker)) return false;
140
+ // ① 链式分隔(引号感知):引号外的 ; | & 换行 → 非法
141
+ let inQ = null;
142
+ for (let i = 0; i < c.length; i++) {
143
+ const ch = c[i];
144
+ if (inQ) { if (ch === inQ) inQ = null; continue; }
145
+ if (ch === '"' || ch === "'") { inQ = ch; continue; }
146
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") return false;
147
+ }
148
+ // ② 重定向:摘除 fd 复制(N>&M / N<&M)后,任何 > / >> / &> 均非法(N1——含数字前置 2>/1> 盲区)
149
+ const noFd = c.replace(/\d*\s*[<>]&\s*\d+/g, "");
150
+ if (/>>|&>|(?:^|[^<])>/.test(noFd)) return false;
151
+ return true;
134
152
  }
135
153
 
136
154
  /** 计算 edit/write/str_replace 后的目标文件内容;无法可靠计算时返回 null */
@@ -312,17 +330,21 @@ export function guardDecision(state, exec, now = Date.now(), opts = {}) {
312
330
  }
313
331
 
314
332
  // 阶段 C:硬拦 pwsh/bash 绕过统一入口直接写受保护文件(规则 19⑧/21⑨ 的机器执行层)
315
- // 合法通道 = 整条命令仅调用 dsh-manual-write.mjs(无 ; | & 链式/换行,防注释文本伪造放行)
316
- // 0.5.10:写类判定改 isMutationCommand(单真源)——修复 `Write-Output` `write` 子串误杀(WGO654/ES3VCD 同类)
333
+ // 合法通道 = 整条命令仅调用统一入口脚本(无 ; | & 链式/换行,防注释文本伪造放行)
334
+ // A2-2(0.6.0):无 localIntegrations.entryScript 配置 = 守卫无对象(不激活);protectedFiles 为通用基线之上的本机追加清单
317
335
  if (!unlock && (name === "pwsh" || name === "bash")) {
318
336
  const cmd = commandText(args) || "";
319
- if (cmd && isMutationCommand(cmd) && PROTECTED_FILENAME_RE.test(cmd)) {
320
- if (!isEntryChannelCommand(cmd)) {
337
+ const entryMarker = state.localIntegrations?.entryScript;
338
+ if (entryMarker && cmd && isMutationCommand(cmd) && (PROTECTED_FILENAME_RE.test(cmd) || matchManualPath(cmd, state.localIntegrations?.protectedFiles || []))) {
339
+ if (!isEntryChannelCommand(cmd, entryMarker)) {
321
340
  return makeHit(
322
341
  { ruleId: "__self-protect", title: "受保护文件禁止绕过统一入口直写", action: "deny" },
323
- `【硬拦截】受保护文件禁止通过 pwsh/bash 绕过统一入口直写;请使用 scripts/dsh-manual-write.mjs(整个命令只能调用该脚本,不得链式拼接其他写命令;或 /guard unlock 临时放行)。`
342
+ `【硬拦截】受保护文件禁止通过 pwsh/bash 绕过统一入口直写;请使用 ${entryMarker}(整个命令只能调用该脚本,不得链式拼接其他写命令/重定向;或 /guard unlock 临时放行)。`
324
343
  );
325
344
  }
345
+ } else if (!entryMarker && cmd && isMutationCommand(cmd) && (PROTECTED_FILENAME_RE.test(cmd) || matchManualPath(cmd, state.localIntegrations?.protectedFiles || []))) {
346
+ // A2-2(0.6.0):无 entryScript 配置 = 守卫无对象——登记 skipped 审计(验证"默认无"的留痕;不拦截)
347
+ opts?.audit?.({ kind: "li-skipped", rule: "19", name: "本地集成未配置(守卫无对象)", event: "tool/guard", reason: "skipped: no localIntegrations.entryScript——该守卫在此环境不存在(0.6.0 默认无)", session: sessionIdOf(exec) });
326
348
  }
327
349
  }
328
350
 
@@ -514,17 +536,17 @@ function matchRule(cfg, ctx) {
514
536
  if (cfg.handler === "rule18-manual-first") {
515
537
  const userText = session.turn.userText || session.lastUserText || "";
516
538
  const firstTool = session.turn.toolCount === 0;
517
- if (firstTool && !session.manualReadSeen && DSH_KEYWORDS_RE.test(userText) && !isManualReadTool(name, args)) {
518
- return makeHit(cfg, "【硬拦截】任务涉及 DSH,首次工具调用前需先 grep/read 手册(~/.dsh/skills/dsh-usage-manual/SKILL.md)");
539
+ if (firstTool && !session.manualReadSeen && DSH_KEYWORDS_RE.test(userText) && !isManualReadTool(name, args, state.localIntegrations?.manualExempt?.paths || [])) {
540
+ return makeHit(cfg, "【硬拦截】任务涉及 DSH,首次工具调用前需先 grep/read 本机手册(路径经 localIntegrations 配置)");
519
541
  }
520
542
  return null;
521
543
  }
522
544
 
523
545
  // 规则 13A:删除/覆盖/高风险写前需有“目标路径对应备份”证据
524
546
  if (cfg.handler === "rule13a-backup") {
525
- // 统一入口命令豁免:dsh-manual-write.mjs 每次写入前自身执行备份(backup() 保留 5 份),
547
+ // 统一入口命令豁免:入口脚本每次写入前自身执行备份(backup() 保留 5 份),
526
548
  // 引擎静态扫描看不到脚本内部动作(已知盲区);入口命令也已被 __self-protect 限定为唯一写通道。
527
- if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd)) return null;
549
+ if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd, state.localIntegrations?.entryScript)) return null;
528
550
  const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args, sessionIdOf(exec)) && !/git\s+(push|commit)/i.test(cmd)));
529
551
  const highRiskWrite = (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p);
530
552
  if (highRiskWrite && unlock && !isHighRiskEntryFile(p)) return null;
@@ -567,7 +589,7 @@ function matchRule(cfg, ctx) {
567
589
  if (cfg.handler === "rule12b-skill") {
568
590
  if (name === "skill") {
569
591
  const skillName = typeof args?.name === "string" ? args.name : "";
570
- if (SKILL_EXEMPT.has(skillName)) return null;
592
+ if ((state.localIntegrations?.manualExempt?.skills || []).includes(skillName)) return null;
571
593
  // 技能目录实时联动:已加载目录且该技能不存在/被禁用时,规则不激活
572
594
  if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
573
595
  if (denyMutation) {
@@ -587,7 +609,7 @@ function matchRule(cfg, ctx) {
587
609
  if ((cfg.hints || []).includes("skill") && name === "skill") {
588
610
  // hints 兜底:理解器未分配 handler 但 hints 含 skill 的 cfg——仅 skill 工具时参与,不截胡其它规则
589
611
  const skillName = typeof args?.name === "string" ? args.name : "";
590
- if (SKILL_EXEMPT.has(skillName)) return null;
612
+ if ((state.localIntegrations?.manualExempt?.skills || []).includes(skillName)) return null;
591
613
  if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
592
614
  const op = { type: "skill", pathPrefix: "" };
593
615
  const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
@@ -616,8 +638,8 @@ function matchRule(cfg, ctx) {
616
638
  audit?.({ kind: "allow", rule: cfg.ruleId, name: "低风险新建豁免(12A 判据同源)", tool: name, reason: `工作区内低风险新建(12A 判据同源):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
617
639
  return null;
618
640
  }
619
- // 规则 19:dsh-usage-manual/SKILL.md 正文更新免逐次确认(仅手册本身)
620
- if (p && MANUAL_PATH_RE.test(p)) return null;
641
+ // 规则 19:本机手册正文更新免逐次确认(仅手册本身;路径经配置)
642
+ if (p && (matchManualPath(p, state.localIntegrations?.manualExempt?.paths))) return null;
621
643
  // /guard unlock 本身即用户对受保护配置的授权
622
644
  if (unlock && isProtectedConfigPath(p)) return null;
623
645
  const op = operationOf(name, args);
@@ -704,11 +726,11 @@ function matchRule(cfg, ctx) {
704
726
  if (hints.includes("bom-write") && (name === "pwsh" || name === "bash") && cmd && BOM_WRITE.test(cmd)) {
705
727
  return makeHit(cfg, `【硬拦截】${cfg.title}`);
706
728
  }
707
- if (hints.includes("manual") && session.turn.toolCount === 0 && !session.manualReadSeen && !isManualReadTool(name, args)) {
729
+ if (hints.includes("manual") && session.turn.toolCount === 0 && !session.manualReadSeen && !isManualReadTool(name, args, state.localIntegrations?.manualExempt?.paths || [])) {
708
730
  return makeHit(cfg, `【硬拦截】${cfg.title}`);
709
731
  }
710
732
  if (hints.includes("sensitive") && isSensitiveToolCall(name, args, sessionIdOf(exec))) {
711
- if (p && MANUAL_PATH_RE.test(p)) return null;
733
+ if (p && (matchManualPath(p, state.localIntegrations?.manualExempt?.paths))) return null;
712
734
  if (unlock && isProtectedConfigPath(p)) return null;
713
735
  const op = operationOf(name, args);
714
736
  if (denyMutation) {
@@ -143,6 +143,7 @@ function auditIntent(state, sid, text, verdict, ms, limitHit) {
143
143
  ? `词表低置信 → LLM:${JSON.stringify(verdict)}(延迟 ${ms}ms${limitHit ? " 限额" : ""})`
144
144
  : `词表低置信 → LLM 失败降级(延迟 ${ms}ms)`,
145
145
  session: sid,
146
+ verdictSource: verdict ? "llm-intent" : "lexicon",
146
147
  text: String(text).slice(0, 120)
147
148
  });
148
149
  } catch {
@@ -28,12 +28,18 @@ export const PROTECTED_FILENAME_RE =
28
28
  export const DATA_DIR_RE =
29
29
  /(?:^|[\\/])\.dsh[\\/](?:sessions|storages|\.backups)[\\/]/i;
30
30
 
31
+ /** 当下时间词(规则 2①:写当下相对时间前必须先 Get-Date 核对——A1 拆组,2026-09-03) */
31
32
  export const TIME_WORDS =
32
- /今天|昨天|前天|上周|本周|刚才|\d+\s*分钟前|\d{1,2}\s*月\s*\d{1,2}\s*日|\d{4}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日/;
33
+ /今天|昨天|前天|上周|本周|刚才|\d+\s*分钟前/;
33
34
 
34
- /** 事件时间证据标注(规则 2②:过去事件时间必须绑定事件自身证据;"之前/当时"等模糊词不触发检测、不采信) */
35
+ /** 历史日期/绝对日期(规则 2②:只要求事件证据锚,不要求 Get-Date——A1 拆组;含 ISO 格式,本机高频表述) */
36
+ export const HISTORIC_DATE_RE =
37
+ /\d{1,2}\s*月\s*\d{1,2}\s*日|\d{4}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日|20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/;
38
+
39
+ /** 事件时间证据标注(规则 2②:过去事件时间必须绑定事件自身证据;"之前/当时"等模糊词不触发检测、不采信)
40
+ * A1 增补证据锚(2026-09-03):commit hash / 版本号 / 踩坑 N / 版本记录 vX —— 历史日期带锚即视为已标注来源,免 Get-Date */
35
41
  export const EVIDENCE_MARK_RE =
36
- /日志\s*(?:ts|时间戳)|mtime|启动时间|进程\s*StartTime|文件\s*修改时间|来源[::]|ts\s*[:=]|Get-Date\s*输出|事件时间已核实|【时间未核实】/i;
42
+ /日志\s*(?:ts|时间戳)|mtime|启动时间|进程\s*StartTime|文件\s*修改时间|来源[::]|ts\s*[:=]|Get-Date\s*输出|事件时间已核实|【时间未核实】|\b[0-9a-f]{7,40}\b|\bv\d+(?:\.\d+){1,2}\b|踩坑\s*\d+|版本(?:记录)?\s*v\d+(?:\.\d+){1,2}/i;
37
43
 
38
44
  export const PROMISE_WORDS =
39
45
  /包在我身上|肯定能|绝对没问题|保证(?!不|无法)|一定可以|放心(?:,|,)?肯定|万无一失/;
@@ -48,34 +54,41 @@ export const SOURCE_MARK = /来源|出处|via|source|reference|引自|参考/i;
48
54
 
49
55
  export const CJK_RE = /[\u4e00-\u9fff]/;
50
56
 
51
- export const MANUAL_PATH_RE = /dsh-usage-manual[\\/]SKILL\.md/i;
52
-
53
57
  export const DSH_KEYWORDS_RE =
54
58
  /DSH|dsh|插件|技能|规则|配置|迁移|手册|会话|装配|profile|bundle/i;
55
59
 
56
- export const SKILL_EXEMPT = new Set(["dsh-usage-manual", "task-planner"]);
57
-
58
60
  export const SELF_PROTECT_PATHS = [
59
61
  "**/rule-engine.json",
60
62
  "**/rule-understanding.json",
61
63
  "**/rule-guard.json"
62
64
  ];
63
65
 
64
- /** 判断一条命令文本是否是「读取手册」类命令 */
65
- export function isManualReadCommand(command) {
66
+ /** 判断一条命令文本是否是「读取手册」类命令(manualPaths = 本机配置的手册路径列表;无配置 = 无对象,恒 false) */
67
+ export function isManualReadCommand(command, manualPaths = []) {
66
68
  if (typeof command !== "string") return false;
67
- return MANUAL_PATH_RE.test(command) && /get-content|cat|type|read|grep|findstr|str_replace_editor/i.test(command);
69
+ if (!Array.isArray(manualPaths)) return false;
70
+ const hit = manualPaths.some((pp) => command.toLowerCase().includes(String(pp).toLowerCase()));
71
+ return hit && /get-content|cat|type|read|grep|findstr|str_replace_editor/i.test(command);
72
+ }
73
+
74
+ /** 本机追加文件路径匹配(A2-2 算法):相对 DSH_HOME 归一化 + 大小写不敏感 + 包含比较。
75
+ * manualPaths 为空 = 无本机配置 = 恒 false(守卫无对象,自然静默)。 */
76
+ export function matchManualPath(p, manualPaths = []) {
77
+ if (typeof p !== "string" || !Array.isArray(manualPaths)) return false;
78
+ const norm = (s) => s.replace(/\\/g, "/").replace(/^["']+|["']+$/g, "").toLowerCase();
79
+ const n = norm(p);
80
+ return manualPaths.some((pp) => n.includes(norm(String(pp))));
68
81
  }
69
82
 
70
- /** 判断一次工具调用是否算作「已读手册」 */
71
- export function isManualReadTool(toolName, args) {
83
+ /** 判断一次工具调用是否算作「已读手册」(manualPaths = 本机配置的手册路径列表;无配置 = 无对象,恒 false) */
84
+ export function isManualReadTool(toolName, args, manualPaths = []) {
72
85
  const name = String(toolName || "");
73
86
  if (name === "read" || name === "grep" || name === "str_replace_editor") {
74
87
  const p = String(args?.file_path || args?.path || args?.pattern || "");
75
- if (MANUAL_PATH_RE.test(p)) return true;
88
+ if (isManualReadCommand(p, manualPaths)) return true;
76
89
  }
77
90
  if (name === "pwsh" || name === "bash") {
78
- return isManualReadCommand(args?.command || args?.code || "");
91
+ return isManualReadCommand(args?.command || args?.code || "", manualPaths);
79
92
  }
80
93
  return false;
81
94
  }
@@ -246,7 +259,7 @@ const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "read_image"]);
246
259
  const READONLY_CMD_RE =
247
260
  /(?:\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|Join-Path|Split-Path|Resolve-Path|New-Object|Add-Type|Get-Member|Get-ChildItemProperty|Get-CimInstance|Get-WmiObject)\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;
248
261
  const MUTATING_CMD_RE =
249
- /(?:Set-Content|Add-Content|Out-File|Tee-Object|Remove-Item|Move-Item|Copy-Item|Rename-Item|New-Item|Clear-Content|Start-Process|Invoke-Expression|Invoke-Item|Export-Csv|Export-Clixml|Export-ModuleMember|Compress-Archive|Expand-Archive|Add-Type\s+-Output(?:Assembly|Type)|git\s+(?:push|commit)|rm\s+-r|rmdir\s+\/s|del\s+\/s|(?:^|[^0-9])>>|(?:^|[^0-9])>|(?:curl|wget|iwr|Invoke-WebRequest|Invoke-RestMethod)\s+(?=[^\n;|]*\s(?:-o|--output|-OutFile|OutFile)\s))/i;
262
+ /(?:Set-Content|Add-Content|Out-File|Tee-Object|Remove-Item|Move-Item|Copy-Item|Rename-Item|New-Item|Clear-Content|Start-Process|Invoke-Expression|Invoke-Item|Export-Csv|Export-Clixml|Export-ModuleMember|Compress-Archive|Expand-Archive|Add-Type\s+-Output(?:Assembly|Type)|git\s+(?:push|commit)|rm\s+-r|rmdir\s+\/s|del\s+\/s|(?:^|[^0-9])(?:\d*>|\d*>>|>|>>)(?!\s*&\s*\d|\s*\$?(?:null|nul)\b)|(?:curl|wget|iwr|Invoke-WebRequest|Invoke-RestMethod)\s+(?=[^\n;|]*\s(?:-o|--output|-OutFile|OutFile)\s))/i;
250
263
  // 0.5.12(F4):PowerShell 别名展开表——写/删除/移动/执行别名 → 标准 cmdlet 名
251
264
  // 供 normalizeAliases 做命令规范化(MUTATING_CMD_RE 覆盖完整 cmdlet;别名先展开再判)
252
265
  const PS_ALIAS_MAP = [
@@ -587,7 +600,7 @@ export function isAuditCommand(command) {
587
600
  */
588
601
  export function isVerificationCommand(command) {
589
602
  if (typeof command !== "string") return false;
590
- if (/\b(?:node|npm|npx)\s+[^\n]*(?:run-all\.mjs|check-plugin-load\.mjs|audit-mount-consistency\.mjs|loader-smoke\.e2e\.mjs|verify-all\.mjs|health-audit\.mjs|check-tool-coverage\.mjs|publish-aptitude-check\.mjs|\.test\.mjs\b|--check|--test|--dry-run)\b/i.test(command)) return true;
603
+ if (/\b(?:node|npm|npx)\s+[^\n]*(?:run-all\.mjs|audit-mount-consistency\.mjs|loader-smoke\.e2e\.mjs|verify-all\.mjs|health-audit\.mjs|check-tool-coverage\.mjs|publish-aptitude-check\.mjs|\.test\.mjs\b|--check|--test|--dry-run)\b/i.test(command)) return true;
591
604
  if (/\b(?:npm|pnpm)\s+(?:test|run\s+test)\b/i.test(command)) return true;
592
605
  if (/\bgh\s+api\b/i.test(command)) {
593
606
  // 只读 GET 形态;写形态(--method/-X 显式方法、-F/--field 表单)不豁免(gg api 写=执行类)
package/lib/core/state.js CHANGED
@@ -265,7 +265,7 @@ export function freshTurn() {
265
265
  reasoningText: "",
266
266
  // 规则 22 粒度升级(2026-08-24):本回合已获授权范围(execute 子句 + ask 授权)
267
267
  scopes: [],
268
- // M8 双通道机制(2026-08-24):dsh-manual-write 落盘后同轮 engram_store 校验
268
+ // M8 双通道机制(2026-08-24):统一入口落盘后同轮 engram_store 校验(entryMarker 经配置)
269
269
  manualWriteSeen: false,
270
270
  engramStoreSeen: false
271
271
  };
@@ -5,6 +5,7 @@ import {
5
5
  PROMISE_WORDS,
6
6
  SOURCE_MARK,
7
7
  TIME_WORDS,
8
+ HISTORIC_DATE_RE,
8
9
  EVIDENCE_MARK_RE,
9
10
  URL_RE,
10
11
  TECH_TERM_RE,
@@ -107,8 +108,13 @@ export function isSelfCertified(text, ruleId) {
107
108
  * @returns {Array<{ruleId,title,kind,reason}>}
108
109
  */
109
110
  export function detectTimeRule(session, text, timeCfg) {
110
- if (!timeCfg || !TIME_WORDS.test(text) || isQuoteOrParaphraseContext(text, TIME_WORDS)) return [];
111
- if (!session?.turn?.getDateSeen) {
111
+ const hasNow = TIME_WORDS.test(text);
112
+ const hasHist = HISTORIC_DATE_RE.test(text);
113
+ if (!timeCfg || (!hasNow && !hasHist)) return [];
114
+ if (isQuoteOrParaphraseContext(text, TIME_WORDS) || isQuoteOrParaphraseContext(text, HISTORIC_DATE_RE)) return [];
115
+ // A1(2026-09-03 拆组):当下时间词才要求 Get-Date 核对(①);历史日期只走证据锚(②),
116
+ // 不再要求 Get-Date——消除"引用历史日期必判未核对"的误报(规则 2②:每时间点绑定自己证据)。
117
+ if (hasNow && !session?.turn?.getDateSeen) {
112
118
  return [{
113
119
  ruleId: "2",
114
120
  title: timeCfg.title,
@@ -116,12 +122,12 @@ export function detectTimeRule(session, text, timeCfg) {
116
122
  reason: "回答出现具体时间词/日期,但本回合未先调用 Get-Date 核对"
117
123
  }];
118
124
  }
119
- if (!EVIDENCE_MARK_RE.test(text)) {
125
+ if ((hasNow || hasHist) && !EVIDENCE_MARK_RE.test(text)) {
120
126
  return [{
121
127
  ruleId: "2",
122
128
  title: timeCfg.title,
123
129
  kind: "correct",
124
- reason: "回答含具体时间词但未附事件证据标注(日志 ts/文件 mtime/进程启动时间等)——Get-Date 当前时间不算过去事件证据(规则 2②)。正确动作:查该事件证据补(来源:…)或删时间词/标【时间未核实】,勿以当前时间替代"
130
+ reason: "回答含具体时间词/日期但未附事件证据标注(日志 ts/文件 mtime/进程启动时间/commit 锚/版本行/踩坑 N 等)——Get-Date 当前时间不算过去事件证据(规则 2②)。正确动作:查该事件证据补(来源:…)或删时间词/标【时间未核实】,勿以当前时间替代"
125
131
  }];
126
132
  }
127
133
  return [];
@@ -138,30 +144,31 @@ export function detectTimeRule(session, text, timeCfg) {
138
144
  * @param {string} options.text assistant 纯文本
139
145
  * @returns {Array<{ruleId:string,title:string,kind:string,reason:string}>}
140
146
  */
141
- export function detectViolations({ configs, session, text, reasoningText = "", mountRevision = 0 }) {
147
+ export function detectViolations({ configs, session, text, reasoningText = "", mountRevision = 0, rule5Window = 3 }) {
142
148
  const hits = [];
143
149
  const byId = new Map(configs.filter((c) => c.confidence !== "low").map((c) => [String(c.ruleId), c]));
144
150
 
145
151
  const timeCfg = byId.get("2");
146
152
  // v0.5.11(用户定稿):① 只命中具体时间词(TIME_WORDS 为具体词表——"之前/当时"等模糊词不命中,不新增);
147
153
  // ② 具体时间词 + 无事件证据标注(日志 ts/文件 mtime/进程启动时间等)→ 违规,Get-Date 当前时间不算事件证据。
154
+ // A1(2026-09-03):时间词拆组——当下词(TIME_WORDS)才要求 Get-Date①;历史日期(HISTORIC_DATE_RE)只查证据锚②。
148
155
  // F1(2026-08-28 阶段三):本检测仅作"判定",投递时机由调用方在 turn/end 复核(见 detectTimeRule 注释)。
149
156
  // B3(2026-08-29):引述/转述语境的时间词不触发("你昨天说…"是转述,不是我的时间表述);
150
157
  // 第一人称"我说昨天…"仍触发(转述不了自己)。
151
- if (timeCfg && TIME_WORDS.test(text) && !isQuoteOrParaphraseContext(text, TIME_WORDS)) {
152
- if (!session.turn.getDateSeen) {
158
+ if (timeCfg && (TIME_WORDS.test(text) || HISTORIC_DATE_RE.test(text)) && !isQuoteOrParaphraseContext(text, TIME_WORDS) && !isQuoteOrParaphraseContext(text, HISTORIC_DATE_RE)) {
159
+ if (!session.turn.getDateSeen && TIME_WORDS.test(text)) {
153
160
  hits.push({
154
161
  ruleId: "2",
155
162
  title: timeCfg.title,
156
163
  kind: "correct",
157
164
  reason: "回答出现具体时间词/日期,但本回合未先调用 Get-Date 核对"
158
165
  });
159
- } else if (TIME_WORDS.test(text) && !EVIDENCE_MARK_RE.test(text)) {
166
+ } else if (!EVIDENCE_MARK_RE.test(text)) {
160
167
  hits.push({
161
168
  ruleId: "2",
162
169
  title: timeCfg.title,
163
170
  kind: "correct",
164
- reason: "回答含具体时间词但未附事件证据标注(日志 ts/文件 mtime/进程启动时间等)——Get-Date 当前时间不算过去事件证据(规则 2②)"
171
+ reason: "回答含具体时间词/日期但未附事件证据标注(日志 ts/文件 mtime/进程启动时间/commit 锚/版本行/踩坑 N 等)——Get-Date 当前时间不算过去事件证据(规则 2②)"
165
172
  });
166
173
  }
167
174
  }
@@ -188,15 +195,16 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
188
195
  });
189
196
  } else if (INTERNAL_REF_RE.test(text) && !isQuoteOrParaphraseContext(text, INTERNAL_REF_RE)) {
190
197
  // 规则 5 扩展(2026-09-01 用户拍板):内部文档引用(手册/踩坑/条款/源码…)须有依据——
191
- // 近 3 回合无对应 read/grep 时提示(B 级:留痕+注入,不拦截)。
198
+ // 近 rule5Window 回合(默认 3;配置层 rule-engine.json `rule5SourceWindow` 可覆盖,2026-09-03
199
+ // 通用化修正:本机偏好走配置、通用默认保持 3)无对应 read/grep 时提示(B 级:留痕+注入,不拦截)。
192
200
  const curTurn = session.turn.number || 0;
193
201
  const lastQuery = session.lastQueryTurn ?? -1;
194
- if (lastQuery < 0 || curTurn - lastQuery > 3) {
202
+ if (lastQuery < 0 || curTurn - lastQuery > rule5Window) {
195
203
  hits.push({
196
204
  ruleId: "5",
197
205
  title: sourceCfg.title,
198
206
  kind: "correct",
199
- reason: "回答引用内部文档(手册/踩坑/条款/源码等)但近 3 回合无对应 read/grep——请标注手册位置或删去断言"
207
+ reason: `回答引用内部文档(手册/踩坑/条款/源码等)但近 ${rule5Window} 回合无对应 read/grep——请标注手册位置或删去断言`
200
208
  });
201
209
  }
202
210
  }