dsh-rule-engine 0.5.4 → 0.5.6

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.
@@ -0,0 +1,148 @@
1
+ // llm-intent.js - LLM 用户消息意图兜底(方案 A,v0.5.5 规划,2026-08-24)。
2
+ // 原则:
3
+ // - 词表是主裁决器(快/免费/可测试/可复现);LLM 只补低置信/歧义/未命中边缘;
4
+ // - 非对称:LLM 只在词表判“拦”时参与解救(hasExecute → 放行);词表判“放行”时永不收紧(防软化);
5
+ // - 失败/未就绪/超时/限额一律保守:维持词表裁决(拦截方向不变);
6
+ // - 全程审计:谁判的(词表/LLM)、置信、延迟。
7
+ // 纯依赖注入 ctx.llm;复用 llm-understander.js 的 resolveRoute/callLlm/parseJsonOutput。
8
+ import { createHash } from "node:crypto";
9
+ import { parseUserIntents, shouldDenyMutation } from "./intent.js";
10
+ import { resolveRoute, callLlm } from "./llm-understander.js";
11
+ import { audit } from "./audit.js";
12
+
13
+ const LLM_PROMPT = [
14
+ "你是 DSH 规则引擎的意图判定器。输出严格 JSON,不要输出其他内容:",
15
+ '{"hasExecute":bool,"hasPlan":bool,"hasQuestion":bool,"ambiguous":bool,"reason":"一句话","confidence":0-1}',
16
+ "规则:hasExecute=消息含明确执行指令(动作词+非疑问句);hasQuestion=含疑问/征询;hasPlan=要方案/评估/设计;ambiguous=同一消息内标签或语气冲突(如【执行】标签+疑问正文);confidence=你的把握。",
17
+ "待判定用户消息:{{TEXT}}"
18
+ ].join("\n");
19
+
20
+ const cacheKey = (text) => "intent:" + createHash("sha256").update(String(text), "utf8").digest("hex");
21
+
22
+ /** 是否需要 LLM 兜底(仅词表低置信且非确定性分类的消息) */
23
+ export function needsLlmEnrich(intents) {
24
+ if (!intents) return false;
25
+ if (intents.confidence === "high") return false;
26
+ // status/execute/plan 均为确定性分类,不走 LLM:
27
+ // plan(要方案)绝不因 LLM 误判 execute 而变成执行授权(安全方向,防误放行)
28
+ if (intents.hasStatus || intents.hasExecute || intents.hasPlan) return false;
29
+ return true;
30
+ }
31
+
32
+ /**
33
+ * 同步三级裁决(守卫路径调用,纯函数):
34
+ * - 词表放行 → 维持放行(LLM 不收紧,非对称)
35
+ * - 词表判拦 + turn.llmIntent.raw 匹配当前 userText 且 verdict.hasExecute 且 !ambiguous:
36
+ * confidence >= high → LLM 放行(来源 llm-high)
37
+ * confidence >= low → LLM 放行(来源 llm-low,reason 标注低置信)
38
+ * 否则 → 词表拦截
39
+ * - 无 LLM 结果/raw 不匹配 → 词表拦截(保守)
40
+ */
41
+ export function verdictForDeny(turn, cfg = {}, lexiconDenied = true) {
42
+ if (!lexiconDenied) return { mutationDenied: false, source: "lexicon", reason: "词表放行" };
43
+ const high = cfg.thresholdHigh ?? 0.8;
44
+ const low = cfg.thresholdLow ?? 0.5;
45
+ const llm = turn?.llmIntent;
46
+ if (llm && llm.raw === turn?.userText && llm.verdict?.hasExecute && !llm.verdict.ambiguous) {
47
+ const conf = Number(llm.verdict.confidence) || 0;
48
+ if (conf >= high) return { mutationDenied: false, source: "llm-high", reason: `LLM 高置信(${conf})` };
49
+ if (conf >= low) return { mutationDenied: false, source: "llm-low", reason: `LLM 低置信(${conf})` };
50
+ }
51
+ return { mutationDenied: true, source: "lexicon", reason: "" };
52
+ }
53
+
54
+ /**
55
+ * 异步预取:对低置信消息调用一次 LLM,结果写入 turn.llmIntent。
56
+ * 不进同步守卫路径;失败/超时/限额仅审计并保留词表裁决。
57
+ * @param {object} ctx Cordis ctx(需 llm 服务)
58
+ * @param {object} state 引擎运行时状态
59
+ * @param {string} sid 会话 id
60
+ * @param {string} text 用户消息文本
61
+ * @param {object} cfg llmIntent 配置段(config.js DEFAULT_CONFIG.llmIntent)
62
+ */
63
+ export async function enrichIntentWithLlm(ctx, state, sid, text, cfg) {
64
+ if (!ctx?.llm || !cfg?.enabled) return;
65
+ const intents = parseUserIntents(text);
66
+ if (!needsLlmEnrich(intents)) return;
67
+ const session = state.sessions.get(sid);
68
+ if (!session) return;
69
+ session.turn.intentState = "llm-pending";
70
+ const key = cacheKey(text);
71
+ if (!state.llmIntentCache) state.llmIntentCache = new Map();
72
+ const cached = state.llmIntentCache.get(key);
73
+ if (cached) {
74
+ session.turn.llmIntent = { raw: text, verdict: cached, at: Date.now(), ms: 0 };
75
+ session.turn.intentState = "llm-ready";
76
+ state.llmIntentHits = (state.llmIntentHits || 0) + 1;
77
+ return;
78
+ }
79
+ if (!state.llmIntentBudget) state.llmIntentBudget = new Map();
80
+ const used = state.llmIntentBudget.get(sid) || 0;
81
+ if (used >= (cfg.dailyLimitPerSession ?? 50)) {
82
+ session.turn.intentState = "lexicon";
83
+ return; // 超限降级词表
84
+ }
85
+ state.llmIntentBudget.set(sid, used + 1);
86
+ const started = Date.now();
87
+ try {
88
+ const route = await resolveRoute(ctx);
89
+ if (!route) throw new Error("no llm route");
90
+ const userText = String(text).slice(0, 2000);
91
+ const messages = [
92
+ { role: "user", content: [{ type: "text", text: LLM_PROMPT.replace("{{TEXT}}", userText) }] }
93
+ ];
94
+ const out = await callLlm(ctx, route, { ruleId: "__intent", title: "LLM 意图判定", body: messages[0].content[0].text });
95
+ if (!out) throw new Error("llm parse fail");
96
+ const verdict = {
97
+ hasExecute: Boolean(out.hasExecute),
98
+ hasPlan: Boolean(out.hasPlan),
99
+ hasQuestion: Boolean(out.hasQuestion),
100
+ ambiguous: Boolean(out.ambiguous),
101
+ reason: String(out.reason || "").slice(0, 120),
102
+ confidence: Number(out.confidence) || 0
103
+ };
104
+ const ms = Date.now() - started;
105
+ session.turn.llmIntent = { raw: text, verdict, at: Date.now(), ms };
106
+ session.turn.intentState = "llm-ready";
107
+ state.llmIntentCache.set(key, verdict);
108
+ const maxCache = cfg.cacheSize ?? 500;
109
+ while (state.llmIntentCache.size > maxCache) {
110
+ const firstKey = state.llmIntentCache.keys().next().value;
111
+ if (firstKey === undefined) break;
112
+ state.llmIntentCache.delete(firstKey);
113
+ }
114
+ auditIntent(state, sid, text, verdict, ms, false);
115
+ } catch {
116
+ session.turn.intentState = "lexicon";
117
+ auditIntent(state, sid, text, null, Date.now() - started, false);
118
+ }
119
+ }
120
+
121
+ function auditIntent(state, sid, text, verdict, ms, limitHit) {
122
+ try {
123
+ state.llmIntentLast = {
124
+ text: String(text).slice(0, 60),
125
+ verdict: verdict ? { ...verdict, confidence: Number(verdict.confidence) } : null,
126
+ source: verdict ? "llm" : "fail",
127
+ at: Date.now()
128
+ };
129
+ audit({
130
+ kind: "intent-llm",
131
+ rule: "__intent-llm",
132
+ name: "LLM 意图判定",
133
+ event: "user/message",
134
+ reason: verdict
135
+ ? `词表低置信 → LLM:${JSON.stringify(verdict)}(延迟 ${ms}ms${limitHit ? " 限额" : ""})`
136
+ : `词表低置信 → LLM 失败降级(延迟 ${ms}ms)`,
137
+ session: sid,
138
+ text: String(text).slice(0, 120)
139
+ });
140
+ } catch {
141
+ // 审计失败不阻断预取
142
+ }
143
+ }
144
+
145
+ /** 供测试:needsLlmEnrich 与 verdictForDeny 的纯函数路径(不依赖 ctx) */
146
+ export function shouldDenyWithLlm(turn, cfg, lexiconDenied) {
147
+ return verdictForDeny(turn, cfg, lexiconDenied);
148
+ }
@@ -2,7 +2,7 @@
2
2
  // 对非 high 置信规则调用 ctx.llm 做结构化理解;失败/不可用时回退模式库。
3
3
  // 纯依赖注入 ctx.llm,不 import 官方包。
4
4
 
5
- async function resolveRoute(ctx) {
5
+ export async function resolveRoute(ctx) {
6
6
  if (!ctx?.llm) return null;
7
7
  try {
8
8
  const providers = ctx.llm.listProviders?.() || [];
@@ -46,7 +46,7 @@ function parseJsonOutput(text) {
46
46
  }
47
47
  }
48
48
 
49
- async function callLlm(ctx, route, rule) {
49
+ export async function callLlm(ctx, route, rule) {
50
50
  const messages = [
51
51
  {
52
52
  role: "user",
@@ -18,8 +18,9 @@ export function detectHashIntent(toolName, args) {
18
18
  const name = String(toolName || "");
19
19
  const cmd = commandText(args) || "";
20
20
  if (name === "pwsh" || name === "bash") return HASH_RE.test(cmd);
21
- const text = JSON.stringify(args || {});
22
- return HASH_RE.test(text);
21
+ // 批次 4:只对命令类字段判定——写入类工具内容含命令字样 相关操作(参数全文匹配导致 hash=deny 误拦一切含字样的写入)
22
+ const cmdish = ["script", "query", "sql", "code"].map((k) => (typeof args?.[k] === "string" ? args[k] : "")).join("\n");
23
+ return HASH_RE.test(cmdish);
23
24
  }
24
25
 
25
26
  /** 是否命中“添加依赖”类动作 */
@@ -27,8 +28,9 @@ export function detectDependencyIntent(toolName, args) {
27
28
  const name = String(toolName || "");
28
29
  const cmd = commandText(args) || "";
29
30
  if (name === "pwsh" || name === "bash") return DEPENDENCY_RE.test(cmd);
30
- const text = JSON.stringify(args || {});
31
- return DEPENDENCY_RE.test(text);
31
+ // 批次 4:只对命令类字段判定(同上:写入内容含依赖安装字样 添加依赖动作)
32
+ const cmdish = ["script", "query", "sql", "code"].map((k) => (typeof args?.[k] === "string" ? args[k] : "")).join("\n");
33
+ return DEPENDENCY_RE.test(cmdish);
32
34
  }
33
35
 
34
36
  /** 粗分类一次工具调用的可变更性 */
package/lib/core/paths.js CHANGED
@@ -28,3 +28,8 @@ export function understandingFilePath() {
28
28
  export function disabledRulesFilePath() {
29
29
  return join(dshHome(), "disabled-rules.json");
30
30
  }
31
+
32
+ /** 需求基线文件(需求覆盖校验机制,RB-09,2026-08-24) */
33
+ export function baselineFilePath() {
34
+ return join(dshHome(), "rule-engine-baseline.json");
35
+ }
@@ -237,15 +237,29 @@ 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)\b/i;
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)\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|$))|(?:\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
- /** 判断命令文本是否只读(读文件/查询类,无写入/删除/提交副作用) */
244
+ /** 只读命令链按分隔符折段(机制 B,2026-08-24):`; 换行 && || |`(管道前后是命令链;`2>&1`/`2>$null` 的 stderr 重定向不拆分) */
245
+ function splitCommandSegments(command) {
246
+ return String(command)
247
+ .split(/\r?\n|;|&&|\|\||(?<!\d)\|/)
248
+ .map((s) => s.trim())
249
+ .filter(Boolean);
250
+ }
251
+
252
+ /** 判断命令文本是否只读(读文件/查询类,无写入/删除/提交副作用)。
253
+ * 2026-08-24 分段化:整条命令按 ; | & 换行拆段,每段都必须只读——
254
+ * 修复 "git -C <dir> status" 变体不匹配、"git status; echo ok" 链式被误判为写 的实测缺陷。 */
245
255
  export function isReadOnlyCommand(command) {
246
256
  if (typeof command !== "string") return false;
247
- if (MUTATING_CMD_RE.test(command)) return false;
248
- return READONLY_CMD_RE.test(command);
257
+ const segments = splitCommandSegments(command);
258
+ if (segments.length === 0) return false;
259
+ return segments.every((seg) => {
260
+ if (MUTATING_CMD_RE.test(seg)) return false;
261
+ return READONLY_CMD_RE.test(seg);
262
+ });
249
263
  }
250
264
 
251
265
  /** 判断一次工具调用是否只读(必须无条件放行) */
@@ -259,6 +273,31 @@ export function isReadOnlyTool(toolName, args) {
259
273
  return false;
260
274
  }
261
275
 
276
+ /**
277
+ * node -e 内联代码的危险特征判定(机制批 M4:diag-run 受控诊断工具使用)。
278
+ * 黑名单命中任一 = 判定有写/网络/进程副作用(保守:宁可拒)。规则 9 正文不动(A2=B 方案)。
279
+ */
280
+ const DANGEROUS_INLINE_NODE_RE =
281
+ /(?:fs\.|\bwriteFile(?:Sync)?\b|\bappendFile(?:Sync)?\b|\bcreateWriteStream\b|\bunlink(?:Sync)?\b|\brm(?:Sync)?\b|\bcopyFile(?:Sync)?\b|\brename(?:Sync)?\b|\bmkdir(?:Sync)?\b|\brmdir(?:Sync)?\b|\bchild_process\b|\bexec(?:Sync)?\b|\bspawn\b|\bfetch\s*\(|\bhttps?\.|\bnet\.|\bcreateServer\b|\brequire\s*\(\s*["']fs|from\s+["']node:fs|from\s+["']node:child_process|process\.env(?:\.[A-Za-z_][A-Za-z0-9_]*)?\s*=)/i;
282
+ /** 供 diag-run 及其测试使用:true = 判定有副作用(应拒绝执行) */
283
+ export function isDangerousInlineNode(code) {
284
+ return DANGEROUS_INLINE_NODE_RE.test(String(code || ""));
285
+ }
286
+
287
+ /**
288
+ * 落盘授权粒度提醒(机制批 M7,2026-08-24,用户确认):
289
+ * 方案性指令(调整/补充/评估/建议/草案……)不构成"落盘/发布"授权(规则 22 自证③)。
290
+ * true = 用户消息更像"要方案"而非"授权落盘"——此时对受保护文件统一入口调用应记 approval-gap 提醒。
291
+ * 保守:含落盘性词(落盘/写入/发布/改为……)→ false(不提醒)。
292
+ */
293
+ const PLAN_INSTRUCTION_RE = /方案|调整|补充|建议|评估|草案|完善|优化|改进|提炼|重构|梳理/;
294
+ const WRITE_INSTRUCTION_RE = /落盘|写入|发布|正式写入|正式落盘|改为|改成|保存到手册|写进手册|确定为|确认后(?:落盘|写入|发布)/;
295
+ export function needsApprovalReminder(userText) {
296
+ const t = String(userText || "");
297
+ if (!t) return false;
298
+ return PLAN_INSTRUCTION_RE.test(t) && !WRITE_INSTRUCTION_RE.test(t);
299
+ }
300
+
262
301
  let workspaceRootOverride = "";
263
302
 
264
303
  /** 由插件 apply 阶段从 workspaceRegistry 设置工作区根目录 */
@@ -395,3 +434,16 @@ export function isSensitiveToolCall(toolName, args) {
395
434
  if (GENERIC_EXEC_TOOLS.has(name)) return true;
396
435
  return false;
397
436
  }
437
+
438
+ // ── 批次 5 文本健康检测词表(2026-08-24) ──────────────────────────────
439
+ /** 技术术语(规则 11③ 零基础表达:中文提问 + 术语 + 无解释 → 自证)。英文词带 \b,中文词组按字面。 */
440
+ export const TECH_TERM_RE =
441
+ /\b(?:API|JSON|REST|WebSocket|OAuth|JWT|ORM|Schema|SSR|CSR|DI|Docker|Kubernetes|K8s|npm|pnpm|Node\.js|TypeScript|Git)\b|正则(?:表达|表达式)?|异步|回调|闭包|哈希|令牌|中间件|依赖注入|虚拟DOM|虚拟 DOM|数据库|SQL|序列化|反序列化|面向对象|函数式|类型推断|泛型/i;
442
+
443
+ /** 术语解释伴随词(出现任一 → 视为已解释,不触发密度自证) */
444
+ export const TERM_EXPLANATION_RE =
445
+ /例如|比如|即\b|就是|简单说|换言之|类比|也就是说|通俗|直白|理解为|打个比方|举个例子/;
446
+
447
+ /** 建议类表述(规则 16 重复推销:会话内同类建议 ≥2 → 自证) */
448
+ export const SUGGEST_RE =
449
+ /我建议|建议用|推荐|更优方案|建议(?:是|考虑|换成|用|来)|档位建议|不如换|优化建议/;
package/lib/core/state.js CHANGED
@@ -6,7 +6,7 @@ import { agentsFilePath, disabledRulesFilePath } from "./paths.js";
6
6
  import { understandAll } from "./understander.js";
7
7
  import { writeUnderstanding } from "./understanding-store.js";
8
8
  import { AUTH_TTL_MS } from "./authorization.js";
9
- import { defaultContract } from "./contract.js";
9
+ import { initSessionContract } from "./contract.js";
10
10
 
11
11
  const MAX_SESSIONS = 200;
12
12
  const MAX_RETRY_KEYS = 500;
@@ -41,7 +41,15 @@ export function createState() {
41
41
  askEnabled: false,
42
42
  taskContractMode: "observe"
43
43
  },
44
- labels: new Map()
44
+ labels: new Map(),
45
+ askRejections: [], // { sessionId, at }(ask 被拒记录,弹窗消减/节流)
46
+ llmIntentCache: new Map(), // sha256(text) → verdict(LRU 上限 cfg.cacheSize)
47
+ llmIntentBudget: new Map(), // sessionId → 当日调用数(上限 cfg.dailyLimitPerSession)
48
+ llmIntentHits: 0, // LLM 缓存命中计数(/guard status 观测)
49
+ llmIntentLast: null, // { text, verdict, at }(最近一次 LLM 采用/失败,/guard status 观测)
50
+ verifyPass: [], // { at, sessionId, what }(测试通过/冷加载探针输出采集,交付闸门 M3 用)
51
+ deniedKeys: new Set(), // 引擎 deny 的调用 key(规则 1 计数解耦)
52
+ unknownToolApproved: new Set() // 机制 B:已 ask 过的未归类工具(本会话内批准后不再重复询问)
45
53
  };
46
54
  }
47
55
 
@@ -104,6 +112,7 @@ export function getSessionState(state, sessionId) {
104
112
  if (!s) {
105
113
  s = {
106
114
  id: key,
115
+ ruleStats: {}, // C2:每规则 detected/suppressed/injected 统计(会话级,跨回合累计)
107
116
  manualReadSeen: false,
108
117
  lastUserText: "",
109
118
  selfCertCount: new Map(),
@@ -114,7 +123,10 @@ export function getSessionState(state, sessionId) {
114
123
  lastCommandOutput: "",
115
124
  lastSeen: Date.now(),
116
125
  turn: freshTurn(),
117
- contract: { ...defaultContract(), ...(state.taskContract?.defaults || {}) },
126
+ // 批次 4(阶段 3 默认武装):global 会话自身不继承(防循环);其他会话继承 global 模板(/guard 命令写入)
127
+ contract: key === "global"
128
+ ? initSessionContract(state.taskContract, null)
129
+ : initSessionContract(state.taskContract, getSessionState(state, "global").contract),
118
130
  recentActions: []
119
131
  };
120
132
  state.sessions.set(key, s);
@@ -123,7 +135,11 @@ export function getSessionState(state, sessionId) {
123
135
  return s;
124
136
  }
125
137
 
126
- /** 记录一条授权证据(结构化:type/pathPrefix/at/source/expiresAt)。同时写入全局池,便于子代理/跨会话工具调用复用。 */
138
+ /**
139
+ * 记录一条授权证据(结构化:type/pathPrefix/at/source/expiresAt)。
140
+ * 2026-08-24(R1 统一授权存储):自动来源(执行分点/用户消息/ask)**绝不同步进 globalAuthorizations**——
141
+ * 全局池仅显式白名单可写(recordGlobalAuth),杜绝"一个会话的授权被其它会话复用"与 revoke 残留。
142
+ */
127
143
  export function recordAuthorization(state, sessionId, auth) {
128
144
  const record = {
129
145
  at: Date.now(),
@@ -133,10 +149,37 @@ export function recordAuthorization(state, sessionId, auth) {
133
149
  };
134
150
  const s = getSessionState(state, sessionId);
135
151
  s.authorizations.push(record);
136
- state.globalAuthorizations.push(record);
137
152
  return s.authorizations;
138
153
  }
139
154
 
155
+ /**
156
+ * 显式白名单授权(用户明确选择"记住此授权"时写入;当前无 UI 入口,结构预留)。
157
+ * 这是 globalAuthorizations 的唯一写入通道。
158
+ */
159
+ export function recordGlobalAuth(state, sessionId, auth) {
160
+ const list = recordAuthorization(state, sessionId, auth);
161
+ const record = list[list.length - 1];
162
+ state.globalAuthorizations.push(record);
163
+ return list;
164
+ }
165
+
166
+ /**
167
+ * revoke 全清(R1.3,2026-08-24):session.authorizations + turn.scopes + globalAuthorizations + askRejections。
168
+ * @returns {number} 清理的授权/范围条目数(供审计描述)
169
+ */
170
+ export function clearAuthorizations(state) {
171
+ let count = 0;
172
+ for (const s of state.sessions.values()) {
173
+ count += (s.authorizations || []).length + ((s.turn && s.turn.scopes) ? s.turn.scopes.length : 0);
174
+ s.authorizations = [];
175
+ if (s.turn) s.turn.scopes = [];
176
+ }
177
+ count += (state.globalAuthorizations || []).length;
178
+ state.globalAuthorizations = [];
179
+ if (Array.isArray(state.askRejections)) state.askRejections = [];
180
+ return count;
181
+ }
182
+
140
183
  /** 记录一条备份证据(目标路径 → 备份路径) */
141
184
  export function recordBackup(state, sessionId, targetPath, backupPath) {
142
185
  const s = getSessionState(state, sessionId);
@@ -164,7 +207,11 @@ export function freshTurn() {
164
207
  number: 0,
165
208
  userText: "",
166
209
  askSeen: false,
210
+ askRejected: false, // 本回合 ask 已被拒(防连环 ask,弹窗消减)
167
211
  questionOnly: false,
212
+ intents: null,
213
+ intentState: "lexicon", // lexicon | llm-pending | llm-ready(LLM 意图兜底状态)
214
+ llmIntent: null, // { raw, verdict, reason, confidence, at }(LLM 裁决,非高置信时词表兜底)
168
215
  pendingAsk: null,
169
216
  pendingToolCalls: new Map(),
170
217
  getDateSeen: false,
@@ -174,7 +221,12 @@ export function freshTurn() {
174
221
  skillNames: [],
175
222
  toolNames: [],
176
223
  commands: [],
177
- reasoningText: ""
224
+ reasoningText: "",
225
+ // 规则 22 粒度升级(2026-08-24):本回合已获授权范围(execute 子句 + ask 授权)
226
+ scopes: [],
227
+ // M8 双通道机制(2026-08-24):dsh-manual-write 落盘后同轮 engram_store 校验
228
+ manualWriteSeen: false,
229
+ engramStoreSeen: false
178
230
  };
179
231
  }
180
232
 
@@ -182,7 +234,9 @@ export function resetTurn(state, sessionId, turnNumber) {
182
234
  const s = getSessionState(state, sessionId);
183
235
  s.turn = freshTurn();
184
236
  s.turn.number = typeof turnNumber === "number" ? turnNumber : 0;
185
- s.turn.userText = s.lastUserText || "";
237
+ // 2026-08-24(事故复盘 P0-3 根修):不再继承上一轮 lastUserText——
238
+ // 上一轮若为询问(含“为什么”等)会把旧意图带进新回合导致误拦;
239
+ // turn.userText 只由真实的 user/message 事件写入;无消息回合不做规则 22 判定(12A/13A 仍把关)。
186
240
  return s.turn;
187
241
  }
188
242
 
@@ -5,7 +5,10 @@ import {
5
5
  PROMISE_WORDS,
6
6
  SOURCE_MARK,
7
7
  TIME_WORDS,
8
- URL_RE
8
+ URL_RE,
9
+ TECH_TERM_RE,
10
+ TERM_EXPLANATION_RE,
11
+ SUGGEST_RE
9
12
  } from "./patterns.js";
10
13
  import { detectOverengineeringText } from "./overengineering.js";
11
14
 
@@ -27,7 +30,6 @@ const SELF_CERT_HINTS = {
27
30
  "14": { re: /总结|汇报|完成/, reason: "请按规则 14 一次性完整汇报,区分事实与推断" },
28
31
  "22": { re: /我记下了|记住了|收到,我记下了|放心,我记住了/i, reason: "检测到「我记下了」类空话;正确动作是落盘执行并汇报" },
29
32
  "23": { re: /完成|交付/, reason: "交付/完成声明未附运行时验证证据" },
30
- "6": { re: /(?:API|JSON|正则|异步|依赖注入|回调|schema|DI)/i, reason: "请按规则 6 用零基础用户能懂的语言解释" },
31
33
  "16": { re: /建议|优化|更优方案|推理档位|档位/, reason: "请按规则 16 用绑定检查格式提建议(或给档位建议并说明理由),避免频繁打断" },
32
34
  "12E": { re: /重启|GUI|登录|界面|窗口/, reason: "请按规则 12E 先确认界面真实存在并声明能力边界" },
33
35
  "26": { re: /(?=发布|Release|附件)(?!(?:Attach binaries|正式 Asset|release\.assets))/i, reason: "请按规则 26 确认正式 Release Asset(release.assets)并附 tgz/zip" },
@@ -49,6 +51,20 @@ function isMostlyEnglish(text) {
49
51
  return letters > 30 && cjk === 0;
50
52
  }
51
53
 
54
+ /**
55
+ * 注入噪音治理 v0.5.6(建议2/3 已自证抑制):同一条回复中已含"规则 X 已按/已自证/已核对…"标记时,
56
+ * 该规则本轮不再计为新违规——打断"自证回复复述触发词 → 再次注入"的自循环。
57
+ * 仅对 kind=self-certify 的 D 级自证命中生效;B 级纠察(kind=correct)不受影响(审计优先)。
58
+ */
59
+ export function isSelfCertified(text, ruleId) {
60
+ if (!text || !ruleId) return false;
61
+ const id = String(ruleId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62
+ const tail = "(?:已按|已按要求|已自证|已核对|已核实|核实通过|合规|已满足|已一次性说明|已合并提出|已回应|已实施|已落地)";
63
+ const suffix = "(?:[①②③④⑤⑥⑦⑧⑨⑩]|\\.\\d+|\\d+)?";
64
+ const re = new RegExp(`(?:规则\\s*${id}${suffix}\\s*${tail}|${tail}\\s*规则\\s*${id}${suffix})`);
65
+ return re.test(text);
66
+ }
67
+
52
68
  /**
53
69
  * 检测一次 assistant/message 的 B/D 级违规。
54
70
  * @param {object} options
@@ -105,6 +121,32 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
105
121
  });
106
122
  }
107
123
 
124
+ // 批次 5:术语密度(规则 11③——零基础表达:中文提问 + 术语 + 无解释伴随 → 自证)
125
+ const termCfg = byId.get("11");
126
+ if (termCfg && session.lastUserText && CJK_RE.test(session.lastUserText) && TECH_TERM_RE.test(text) && !TERM_EXPLANATION_RE.test(text)) {
127
+ hits.push({
128
+ ruleId: "11",
129
+ title: termCfg.title,
130
+ kind: "self-certify",
131
+ reason: "规则 11③:回答含技术术语且未用零基础语言解释(请用类比/日常语言说明,或标注术语含义)"
132
+ });
133
+ }
134
+
135
+ // 批次 5:重复推销(规则 16——会话内同类建议 ≥2 次 → 自证,防反复推销)
136
+ const suggestCfg = byId.get("16");
137
+ if (suggestCfg && SUGGEST_RE.test(text)) {
138
+ session.suggestionCounts = session.suggestionCounts || {};
139
+ session.suggestionCounts.general = (session.suggestionCounts.general || 0) + 1;
140
+ if (session.suggestionCounts.general >= 2) {
141
+ hits.push({
142
+ ruleId: "16",
143
+ title: suggestCfg.title,
144
+ kind: "self-certify",
145
+ reason: "规则 16:检测到重复建议(同类建议在会话内再次提出)——请确认必要性并避免反复推销;已并入同一框架请说明"
146
+ });
147
+ }
148
+ }
149
+
108
150
  const directCfg = byId.get("22");
109
151
  if (directCfg && /我记下了|记住了|收到,我记下了|放心,我记住了/i.test(text)) {
110
152
  hits.push({
@@ -195,5 +237,21 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
195
237
  });
196
238
  }
197
239
 
198
- return hits;
240
+ // C2 统计:命中(detected)计数——在抑制过滤前逐条累计
241
+ for (const h of hits) {
242
+ const st = (session.ruleStats ||= {});
243
+ const k = (st[h.ruleId] ||= { detected: 0, suppressed: 0, injected: 0 });
244
+ k.detected++;
245
+ }
246
+ // 注入噪音治理 v0.5.6(建议2/3):已自证标记("规则 X 已按/已自证/已核对…")→ 该规则本轮不计新违规
247
+ const kept = hits.filter((h) => h.kind !== "self-certify" || !isSelfCertified(text, h.ruleId));
248
+ // C2 统计:被抑制(suppressed)计数
249
+ for (const h of hits) {
250
+ if (!kept.includes(h)) {
251
+ const st = session.ruleStats || {};
252
+ const k = st[h.ruleId];
253
+ if (k) k.suppressed++;
254
+ }
255
+ }
256
+ return kept;
199
257
  }
@@ -0,0 +1,85 @@
1
+ // tool-catalog.js - 工具分类制(机制 B,2026-08-24)
2
+ // 任何插件(现有/未来)注册的工具都按此分类:
3
+ // analysis = 只读分析(无条件放行)
4
+ // artifact = 写产物(低风险:放行 + 审计留痕;受保护路径仍由 13A/__self-protect 把关)
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. 分类表随实测/新插件逐步扩充(机制允许快速扩展,无需改逻辑)。
13
+ import { isReadOnlyTool } from "./patterns.js";
14
+
15
+ // 只读分析类:无任何文件副作用(或仅有会话内状态),无条件放行
16
+ const ANALYSIS_TOOLS = new Set([
17
+ "read", "grep", "glob", "read_image",
18
+ // 视觉分析类(纯看图,无文件副作用)
19
+ "vision_describe", "vision_detect", "vision_ground", "vision_colors", "vision_ocr", "vision_bootstrap",
20
+ // 查询/状态类(工具自身状态,无持久副作用)
21
+ "job_list", "job_output", "list_agents", "get_goal", "schedule_list",
22
+ "dev_plugin_status", "dev_injected_list", "dev_stage_list", "dev_self_test",
23
+ "engram_recall", "engram_detail", "engram_model",
24
+ // 官方只读联网工具(@deepseek-ai/dsh-tool-web 家族,2026-08-25 补):只读抓取/搜索,无文件副作用;
25
+ // 公网访问由 12C 的 B 级留痕与 D⑤ 边界继续把关(回环/内网/SSRF 防护不因分类豁免)
26
+ "web_fetch", "web_search",
27
+ // 子代理交付工具(官方 dsh-tool-subagent-report,2026-08-24 实测发现):仅子代理环境注册,
28
+ // 参数仅 output 文本 → 直接交付父代理,无文件副作用 → analysis(避免子代理交付被 unknown deny 阻断)
29
+ "report"
30
+ ]);
31
+
32
+ // 产物类:工具自然行为会写文件(artifact/工作区/会话存储),低风险——放行 + 审计。
33
+ // 若写目标命中受保护路径/工作区外,仍由 __self-protect / 13A / 12A 独立把关(不因分类而豁免)。
34
+ const ARTIFACT_TOOLS = new Set([
35
+ // 视觉产物类(裁剪/截图/长图 OCR/矢量化/抠图/物化/展示 → 写 artifact)
36
+ "vision_crop", "vision_pixel_diff", "vision_trace", "vision_extract_foreground",
37
+ "vision_html_screenshot", "vision_long_screenshot_ocr", "vision_materialize", "vision_present",
38
+ // 记忆/日程(写引擎存储,低风险)
39
+ "engram_store", "schedule_create", "schedule_delete",
40
+ // 开发侧 staging(写 staging 区,dev_* 工具的自然行为)
41
+ "dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote"
42
+ ]);
43
+
44
+ // 变更类:维持现有严格授权(规则 22 粒度比对 / 12A 敏感授权 / 13A 备份)
45
+ const MUTATING_TOOLS = new Set([
46
+ "edit", "write", "str_replace_editor", "pwsh", "bash",
47
+ // 插件装配类(规则 24/27 管辖)
48
+ "dev_install_package", "dev_inject_plugin", "dev_uninject_plugin", "dev_reload_package",
49
+ "dev_fix_patch", "dev_heal_links",
50
+ // 提交/网络写类由命令文本与 12C 管辖,此处不重复枚举
51
+ "upload_file"
52
+ ]);
53
+
54
+ /**
55
+ * 工具分类判定(纯函数)。
56
+ * @param {string} name 工具名
57
+ * @param {object} args 工具参数(pwsh/bash 需要命令文本判读写)
58
+ * @returns {'analysis'|'artifact'|'mutating'|'unknown'}
59
+ */
60
+ export function toolClass(name, args = {}) {
61
+ const n = String(name || "");
62
+ if (n === "pwsh" || n === "bash") {
63
+ return isReadOnlyTool(n, args) ? "analysis" : "mutating";
64
+ }
65
+ if (ANALYSIS_TOOLS.has(n)) return "analysis";
66
+ if (ARTIFACT_TOOLS.has(n)) return "artifact";
67
+ if (MUTATING_TOOLS.has(n)) return "mutating";
68
+ return "unknown";
69
+ }
70
+
71
+ /**
72
+ * unknown 工具的处置决策(pre-execute 层调用)。
73
+ * 批次 3(2026-08-24,用户拍板分支 A):ask → deny——实测 DSH approval 层自动放行 ask
74
+ *(guardian ask 与 unknown 首调均无弹窗直接执行 = fail-open 洞),未知工具首调必须物理拦截;
75
+ * 批准路径 = 用户明确"允许使用 X"(handleSessionEvent 写入会话白名);白名后本会话放行。
76
+ * @param {string} name 工具名
77
+ * @param {object} args 工具参数
78
+ * @param {Set<string>|null} whitelist 会话内已批准工具集(可选)
79
+ * @returns {'deny'|null} unknown 且未白名 → 'deny';已知分类或已白名 → null
80
+ */
81
+ export function unknownToolDecision(name, args = {}, whitelist = null) {
82
+ if (toolClass(name, args) !== "unknown") return null;
83
+ if (whitelist && whitelist.has(name)) return null;
84
+ return "deny";
85
+ }
@@ -25,7 +25,7 @@ const HANDLER_BY_RULE = {
25
25
  "12B": "rule12b-skill",
26
26
  "12C": "rule12c-network",
27
27
  "13A": "rule13a-backup",
28
- "13B": "rule13b-session",
28
+ // 13B 已于 2026-08-24 外移至手册(规则正文删除,映射一并清理,防死映射)
29
29
  14: "rule14-report",
30
30
  18: "rule18-manual-first",
31
31
  21: "rule21-meta",