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.
package/lib/index.js CHANGED
@@ -2,9 +2,11 @@
2
2
  // 容器:解析 AGENTS.md → 理解器 → 匹配机 → 执行框架。
3
3
  // 执行框架:ctx.tools.guard() 硬拦 + session/event 文本纠察 + 审计台账 + /guard 命令。
4
4
  import { readFileSync, watch, writeFileSync } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
5
6
  import { audit, readAuditLog } from "./core/audit.js";
6
7
  import { loadPluginConfig } from "./core/config.js";
7
8
  import { guardDecision } from "./core/guard-core.js";
9
+ import { computeBaselineGap, loadBaselineFile, BASELINE_COMPLETE_RE } from "./core/baseline.js";
8
10
  import {
9
11
  activateForAssistant,
10
12
  activateForToolCall,
@@ -20,20 +22,29 @@ import {
20
22
  isBackupTool,
21
23
  isGetDateCommand,
22
24
  isManualReadTool,
25
+ isReadOnlyTool,
26
+ needsApprovalReminder,
23
27
  PRIVATE_NETWORK_RE,
24
28
  setWorkspaceRoot
25
29
  } from "./core/patterns.js";
26
30
  import {
31
+ askQuestionCoreText,
27
32
  askQuestionText,
28
33
  askResultApproved,
34
+ askResultSelectedText,
29
35
  inferPathPrefixFromText,
30
36
  inferTypeFromText,
31
37
  isAuthMessage,
32
38
  isDirectiveMessage,
33
- isQuestionMessage
39
+ isQuestionMessage,
40
+ isSessionWideAskText,
41
+ scopesFromIntents
34
42
  } from "./core/authorization.js";
43
+ import { isEngineInjectedMessage, isInjectedContextMessage, isKnownSystemInjection, parseUserIntents, stripInjectedContext } from "./core/intent.js";
44
+ import { enrichIntentWithLlm } from "./core/llm-intent.js";
35
45
  import {
36
46
  applyTaskContractConfig,
47
+ clearAuthorizations,
37
48
  getSessionState,
38
49
  maybeReloadIfChanged,
39
50
  recordAuthorization,
@@ -41,12 +52,15 @@ import {
41
52
  reloadRules,
42
53
  resetTurn
43
54
  } from "./core/state.js";
55
+ import { SAFE_UNCOVERED_TOOLS } from "./core/guard-core.js";
56
+ import { toolClass } from "./core/tool-catalog.js";
44
57
  import { state } from "./core/runtime.js";
45
58
  import { detectViolations, extractAssistantText } from "./core/text-detect.js";
46
59
  import {
47
60
  applyContract,
48
61
  contractSummary,
49
62
  decideContractAction,
63
+ consumeDelegateBudget,
50
64
  defaultContract,
51
65
  isArmed,
52
66
  isObserving,
@@ -118,10 +132,22 @@ function sessionIdOfExec(exec) {
118
132
  return "global";
119
133
  }
120
134
 
121
- function extractUserText(message) {
135
+ /**
136
+ * 从 user/message 事件提取用户文本。
137
+ * 2026-08-24 根因修复:官方结构(@deepseek-ai/dsh-session/surface)中
138
+ * event.data 就是消息对象本身(deriveEventMessage case 'user/message' 返回 event.data),
139
+ * 并非 data.message——旧代码取 d.message 导致永远提取空文本:
140
+ * 旧行为 → 空文本 parseUserIntents("") 非空对象 → 规则 22 无故全拦;
141
+ * 中间版 → if (!text) return 跳过 → 全放行(漏拦);
142
+ * 正解 = 取对字段;兼容 data 与 data.message 两种历史形态。
143
+ */
144
+ export function extractUserText(message) {
122
145
  if (!message) return "";
123
146
  if (typeof message === "string") return message;
124
- const content = message.content;
147
+ const obj = typeof message === "object" && !Array.isArray(message)
148
+ ? (message.content !== undefined ? message : message.message)
149
+ : message;
150
+ const content = obj?.content;
125
151
  if (typeof content === "string") return content;
126
152
  if (Array.isArray(content)) {
127
153
  return content
@@ -133,28 +159,59 @@ function extractUserText(message) {
133
159
 
134
160
  // ── session/event 处理 ──────────────────────────────────────────────────────
135
161
 
162
+ // O1 注入通道修复(2026-08-24,批次 6):
163
+ // 根因——maybeInject 在 session/event 观察回调内同步调用 agent.inject,而 inject 底层是
164
+ // 同步 session.append("agent/inbox/spliced");dsh-session 对同一 session 的 append 有
165
+ // 同步重入保护(entry.appending → "session append cannot reenter while another append
166
+ // is being published"),观察回调正处在 append 发布边界内 → 必然抛错(README 局限 6 记录:
167
+ // 注入从未到达模型/界面,仅审计留痕)。
168
+ // 修复——投递延迟到当前 append 发布边界之后(宏任务 setTimeout 0),语义不变
169
+ // (inject 官方语义本就是 "queue context for the next pre-step, without waking");
170
+ // 插件卸载/热重载时统一清理 pending 定时器。
171
+ const pendingInjectTimers = new Set();
172
+
136
173
  function maybeInject(ctx, sessionId, violation) {
137
174
  if (!pluginConfig.correctInject) return;
138
175
  const key = `${sessionId}:${violation.ruleId}`;
139
176
  const count = state.injectCounts.get(key) || 0;
140
177
  if (count >= pluginConfig.injectLimitPerRulePerSession) return;
141
178
  state.injectCounts.set(key, count + 1);
142
- try {
143
- const agent = ctx.agents.get(sessionId);
144
- if (agent && typeof agent.inject === "function") {
145
- agent.inject({
146
- content: [
147
- {
148
- type: "text",
149
- text: `[规则引擎] ${violation.reason}(规则 ${violation.ruleId},已记入 /guard log;下次回复请自证/纠正)`
150
- }
151
- ],
152
- source: { kind: "plugin", plugin: name }
153
- });
154
- }
155
- } catch {
156
- // 注入失败不影响审计
179
+ // C2 统计:注入(injected)计数(实际投递尝试,上限内)
180
+ {
181
+ const sStat = getSessionState(state, sessionId);
182
+ const st = (sStat.ruleStats ||= {});
183
+ const k = (st[violation.ruleId] ||= { detected: 0, suppressed: 0, injected: 0 });
184
+ k.injected++;
157
185
  }
186
+ const timer = setTimeout(() => {
187
+ pendingInjectTimers.delete(timer);
188
+ try {
189
+ const agent = ctx.agents.get(sessionId);
190
+ if (agent && typeof agent.inject === "function") {
191
+ // 2026-08-24 批次 6 收尾(O1 实弹观测):注入消息必须具备官方身份(id+role)——
192
+ // dsh-llm 官方 createUserMessage 生成 { id: randomUUID(), role: "user", content, source };
193
+ // 无 id 时 Inbox.validate 用 undefined 作身份,同批第二条起抛
194
+ // `message "undefined" is already pending`(实弹 17:56:37 八连发观测)。
195
+ agent.inject({
196
+ id: `re-${randomUUID()}`,
197
+ role: "user",
198
+ content: [
199
+ {
200
+ type: "text",
201
+ text: `[规则引擎] ${violation.reason}(规则 ${violation.ruleId},已记入 /guard log;下次回复请自证/纠正)`
202
+ }
203
+ ],
204
+ source: { kind: "plugin", plugin: name }
205
+ });
206
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入已投递(agent=${sessionId}):${String(violation.reason || "").slice(0, 120)}`, session: sessionId });
207
+ } else {
208
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入未投递:agents.get(${sessionId}) 无可用 agent.inject`, session: sessionId });
209
+ }
210
+ } catch (error) {
211
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入异常:${error instanceof Error ? error.message : String(error)}`, session: sessionId });
212
+ }
213
+ }, 0);
214
+ pendingInjectTimers.add(timer);
158
215
  }
159
216
 
160
217
  async function refreshSkills(ctx) {
@@ -166,7 +223,7 @@ async function refreshSkills(ctx) {
166
223
  }
167
224
  }
168
225
 
169
- function handleSessionEvent(ctx, session, event) {
226
+ export function handleSessionEvent(ctx, session, event) {
170
227
  maybeReloadIfChanged(state);
171
228
  const sid = session?.id || "global";
172
229
  const s = getSessionState(state, sid);
@@ -175,11 +232,87 @@ function handleSessionEvent(ctx, session, event) {
175
232
  resetTurn(state, sid, d.turn);
176
233
  return;
177
234
  }
235
+ if (event.type === "turn/end") {
236
+ // M8 双通道机制:本回合用 dsh-manual-write 落盘手册/AGENTS 后,必须同轮 engram_store 沉淀记忆;
237
+ // 缺失 → 审计 + 注入纠正(用户明确要求机制化,2026-08-24)
238
+ if (s.turn.manualWriteSeen && !s.turn.engramStoreSeen) {
239
+ audit({
240
+ kind: "engram-gap",
241
+ rule: "__engram-gap",
242
+ name: "双通道记忆缺失",
243
+ event: "turn/end",
244
+ reason: "本回合 dsh-manual-write 落盘了手册/AGENTS,但未同轮调用 engram_store;按规则 19/77/M8 应在同一回合完成记忆沉淀",
245
+ session: sid
246
+ });
247
+ maybeInject(ctx, sid, {
248
+ ruleId: "__engram-gap",
249
+ reason: "规则 19/M8:手册/AGENTS 落盘后必须在同一回合补 engram_store,否则记忆机制断链——请立即补写并说明"
250
+ });
251
+ }
252
+ return;
253
+ }
178
254
  if (event.type === "user/message") {
179
- const text = extractUserText(d.message);
255
+ // 机制 A(2026-08-24):source.kind 主判——非 user 来源(官方 agent-instructions/skill-catalog、
256
+ // 插件自定义 kind)一律不进授权池、不覆盖回合状态;审计留痕可对账(source-skip)。
257
+ const srcKind = d?.source?.kind;
258
+ if (typeof srcKind === "string" && srcKind !== "user") {
259
+ const preview = extractUserText(d).slice(0, 80);
260
+ audit({
261
+ kind: "source-skip",
262
+ rule: "__source-skip",
263
+ name: "非用户来源消息跳过",
264
+ event: "user/message",
265
+ reason: `source.kind=${srcKind}:${preview}`,
266
+ session: sid
267
+ });
268
+ return;
269
+ }
270
+ // 系统注入剥离(2026-08-24 v2):注入与真实用户消息合并为同一事件
271
+ //(官方注入机制将 <system-reminder> 烤进 content;剥离后剩余文本 = 用户消息)。
272
+ // 2026-08-24 根因修复:event.data 即消息对象(官方 surface 结构),不再取 d.message。
273
+ const text = stripInjectedContext(extractUserText(d));
274
+ if (!text) return;
275
+ // 引擎自身注入的 [规则引擎] 提示不是用户消息,跳过——否则其中的“请直接执行”会被记录成授权(自我续授权漏洞)
276
+ if (isEngineInjectedMessage(text)) return;
277
+ // 模板兜底(对 kind=user 同样生效,防"标 user 的注入"——super-injector createUserMessage 同类风险):
278
+ // runtime context 快照 / 子代理完成通知 / vision-router 挂载提醒等已知注入模板 → 整体跳过
279
+ if (isKnownSystemInjection(text)) {
280
+ audit({
281
+ kind: "source-skip",
282
+ rule: "__source-skip",
283
+ name: "已知系统注入模板跳过",
284
+ event: "user/message",
285
+ reason: `文本模板命中:${text.slice(0, 80)}`,
286
+ session: sid
287
+ });
288
+ return;
289
+ }
290
+ // 批次 3(2026-08-24):用户明确"允许使用 X"→ 未归类工具会话白名(unknown→deny 的唯一批准路径)
291
+ if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
292
+ for (const hit of text.matchAll(/(?:允许|批准|同意)\s*(?:使用|调用|用|启用|放行)\s*([A-Za-z_][A-Za-z0-9_:.-]*)/g)) {
293
+ const toolName = hit[1];
294
+ if (toolClass(toolName, {}) !== "unknown") continue; // 只对未归类工具有意义(已知工具不走 unknown 分支)
295
+ const isNew = !state.unknownToolApproved.has(toolName);
296
+ state.unknownToolApproved.add(toolName);
297
+ audit({
298
+ kind: "unknown-tool-whitelist",
299
+ rule: "__unknown-tool",
300
+ name: "用户批准未归类工具",
301
+ event: "user/message",
302
+ reason: `用户明确允许使用 ${toolName}${isNew ? "" : "(重复,忽略)"} → 会话白名`,
303
+ session: sid
304
+ });
305
+ }
180
306
  s.lastUserText = text;
181
307
  s.turn.userText = text;
182
308
  s.turn.questionOnly = isQuestionMessage(text);
309
+ s.turn.intents = parseUserIntents(text);
310
+ // 规则 22 粒度升级(2026-08-24):本回合授权范围 = 各 execute 子句推导出的 type+path 范围
311
+ s.turn.scopes = scopesFromIntents(s.turn.intents);
312
+ // LLM 意图兜底(方案 A):低置信/歧义消息异步预取,不阻塞事件流;失败自动降级词表
313
+ if (pluginConfig.llmIntent?.enabled) {
314
+ void enrichIntentWithLlm(ctx, state, sid, text, pluginConfig.llmIntent);
315
+ }
183
316
  if (state.taskContract?.taskContractEnabled) {
184
317
  const patch = naturalMode(text, s.contract);
185
318
  if (patch) {
@@ -197,12 +330,29 @@ function handleSessionEvent(ctx, session, event) {
197
330
  }
198
331
  }
199
332
  }
200
- if (isAuthMessage(text) || isDirectiveMessage(text)) {
333
+ const intents = s.turn.intents;
334
+ let recordedAuth = false;
335
+ if (intents?.clauses?.some((c) => c.type === "execute")) {
336
+ // 与规则 22 粒度保持一致:按 scopesFromIntents 的多 type×多 path 逐条记录 12A 授权,
337
+ // 避免“规则 22 放行、12A 却因只记了单 type/单路径而拦截”的不一致(2026-08-24 第二轮修复)
338
+ const execScopes = s.turn.scopes && s.turn.scopes.length > 0 ? s.turn.scopes : scopesFromIntents(intents);
339
+ for (const scope of execScopes) {
340
+ recordAuthorization(state, sid, {
341
+ type: scope.type,
342
+ pathPrefix: scope.pathPrefix,
343
+ source: "user-message-clause",
344
+ clauseId: scope.clauseId
345
+ });
346
+ }
347
+ recordedAuth = true;
348
+ audit({ kind: "auth", rule: "12D", name: "用户消息执行子句授权", event: "user/message", reason: `记录执行分点授权:${text.slice(0, 120)}`, session: sid });
349
+ } else if (isAuthMessage(text) || isDirectiveMessage(text)) {
201
350
  recordAuthorization(state, sid, {
202
351
  type: inferTypeFromText(text),
203
352
  pathPrefix: inferPathPrefixFromText(text),
204
353
  source: "user-message"
205
354
  });
355
+ recordedAuth = true;
206
356
  audit({ kind: "auth", rule: "12D", name: "用户消息授权", event: "user/message", reason: `记录用户消息授权:${text.slice(0, 120)}`, session: sid });
207
357
  }
208
358
  const active = activateForUserMessage(state.configs, text);
@@ -231,6 +381,18 @@ function handleSessionEvent(ctx, session, event) {
231
381
  if (toolName === "pwsh" || toolName === "bash") {
232
382
  const cmd = args?.command || args?.code || "";
233
383
  if (isGetDateCommand(cmd)) s.turn.getDateSeen = true;
384
+ // workdir 校验提示(机制批 M6):命令含显式相对路径(./ 或 ../)但未声明 workdir → 审计提示(不拦)
385
+ if (cmd && /(?:^|\s)(?:\.{1,2}[\\/])/.test(cmd) && !args?.workdir) {
386
+ audit({
387
+ kind: "workdir-hint",
388
+ rule: "__workdir-hint",
389
+ name: "workdir 提示",
390
+ event: "tool/call",
391
+ tool: toolName,
392
+ reason: "命令含相对路径但未显式声明 workdir——易与当前目录错配(机制批 M6),pws 请显式 workdir",
393
+ session: sid
394
+ });
395
+ }
234
396
  const bp = backupPathsFromTool(toolName, args);
235
397
  if (bp) {
236
398
  if (!pendingCall.backupPaths) pendingCall.backupPaths = [];
@@ -283,7 +445,12 @@ function handleSessionEvent(ctx, session, event) {
283
445
  if (pendingCall) {
284
446
  const key = `${pendingCall.name}:${JSON.stringify(pendingCall.args || {})}`;
285
447
  if (isError) {
286
- state.retryCounts.set(key, (state.retryCounts.get(key) || 0) + 1);
448
+ // 规则 1 计数解耦(P1-7,2026-08-24):引擎 deny 来源的失败不计入“同工具连续失败”
449
+ if (!((state.deniedKeys || new Set()).has(key))) {
450
+ state.retryCounts.set(key, (state.retryCounts.get(key) || 0) + 1);
451
+ } else {
452
+ state.retryCounts.delete(key);
453
+ }
287
454
  } else {
288
455
  state.retryCounts.delete(key);
289
456
  }
@@ -297,23 +464,20 @@ function handleSessionEvent(ctx, session, event) {
297
464
  }
298
465
  }
299
466
 
300
- // 任务契约:子代理成功执行后扣减预算(仅 armed 生效)
301
- if (!isError && pendingCall && state.taskContract?.taskContractEnabled && isArmed(s.contract, state.taskContract)) {
302
- const action = classifyAction(pendingCall.name, pendingCall.args);
303
- if (action.mutability === "delegate" && action.delegationCount > 0) {
304
- s.contract.agentsUsed += action.delegationCount;
305
- audit({
306
- kind: "task-budget",
307
- rule: "__task-contract",
308
- name: "子代理预算扣减",
309
- event: "tool/result",
310
- reason: `已用 ${s.contract.agentsUsed}/${s.contract.agentBudget}`,
311
- session: sid,
312
- tool: pendingCall.name
313
- });
467
+ // M8 双通道机制(2026-08-24):dsh-manual-write 落盘成功 → 标记;同轮 engram_store 成功 → 标记
468
+ if (!isError && pendingCall) {
469
+ const cmd = pendingCall.args?.command || pendingCall.args?.code || "";
470
+ if ((pendingCall.name === "pwsh" || pendingCall.name === "bash") && /\bdsh-manual-write\.mjs/.test(cmd)) {
471
+ s.turn.manualWriteSeen = true;
472
+ }
473
+ if (pendingCall.name === "engram_store") {
474
+ s.turn.engramStoreSeen = true;
314
475
  }
315
476
  }
316
477
 
478
+ // 任务契约:委托预算扣减已移至 pre-execute 启动即计(批次 5.5;原 tool/result 路径因
479
+ // subagent 无 pendingCall 从未执行——本块删除防双重扣减)
480
+
317
481
  // 规则 27:装配变更成功后全局 revision +1;审计命令输出通过/失败后更新本会话审计 revision
318
482
  if (pendingCall) {
319
483
  if (!isError && isAssemblyMutationTool(pendingCall.name, pendingCall.args)) {
@@ -397,6 +561,19 @@ function handleSessionEvent(ctx, session, event) {
397
561
  }
398
562
  }
399
563
 
564
+ // 交付闸门采集(机制批 M3):测试/冷加载探针通过输出 → verify-pass 记录(供规则 23④ 声明核对)
565
+ // 2026-08-25 鲁棒化(实弹观测):旧条件依赖 pendingCall(tool/call→result 的 callId 匹配),
566
+ // RC.2 下疑似失配导致采集从未执行——23④ 每次声明都 verify-gap;改为只看输出文本,不依赖
567
+ // pendingToolCalls 状态(what 降级记录),verify-pass 语义不变(输出即证据)。
568
+ if (!isError) {
569
+ const output = extractToolOutput(d);
570
+ if (/\bALL TESTS PASSED\b|RESULT:\s*PASS/.test(output)) {
571
+ if (!state.verifyPass) state.verifyPass = [];
572
+ state.verifyPass.push({ at: Date.now(), sessionId: sid, what: pendingCall?.name || "tool-result" });
573
+ if (state.verifyPass.length > 100) state.verifyPass.splice(0, state.verifyPass.length - 100);
574
+ }
575
+ }
576
+
400
577
  // 命令输出静默错误检测(不阻断,只审计 + 注入提醒)
401
578
  if (!isError && pendingCall && (pendingCall.name === "pwsh" || pendingCall.name === "bash")) {
402
579
  const output = extractToolOutput(d);
@@ -421,7 +598,11 @@ function handleSessionEvent(ctx, session, event) {
421
598
  const result = d.result ?? d.value ?? d;
422
599
  if (askResultApproved(result)) {
423
600
  const qText = askQuestionText(pending.questions);
424
- const pathPrefix = inferPathPrefixFromText(qText);
601
+ const selectedText = askResultSelectedText(result);
602
+ // 授权范围只取“问题核心 + 用户实际选择”,不再把全部选项描述纳入;
603
+ // 避免“仅会话内说明/不跨会话保留”等选项说明把一次小授权放大成全局 12h(2026-08-24 修复)
604
+ const scopeText = `${askQuestionCoreText(pending.questions)} ${selectedText}`.trim();
605
+ const pathPrefix = inferPathPrefixFromText(scopeText || qText);
425
606
  // ask 问题文本措辞不可靠,授权记录为宽泛类型 any + 路径前缀,避免类型错位
426
607
  // 无路径的全局 any 授权缩短 TTL,降低安全边界风险
427
608
  const authRecord = {
@@ -429,10 +610,31 @@ function handleSessionEvent(ctx, session, event) {
429
610
  pathPrefix,
430
611
  source: "ask"
431
612
  };
432
- const sessionWide = /本会话|剩余|全部|会话内/i.test(qText);
613
+ const sessionWide = isSessionWideAskText(scopeText) || isSessionWideAskText(selectedText);
433
614
  if (sessionWide) authRecord.expiresAt = Date.now() + 12 * 60 * 60 * 1000;
434
615
  else if (!pathPrefix) authRecord.expiresAt = Date.now() + 2 * 60 * 1000;
435
616
  recordAuthorization(state, sid, authRecord);
617
+ // 2026-08-25 修复(用户授权):ask 答复也是用户明确允许——若答复文本含"允许使用 X"模式,
618
+ // 同步写入 unknown 工具会话白名单(与 user/message 分支同一判定;此前只有用户消息文本可写白名,
619
+ // ask 弹窗授权后 web_fetch 等仍被 unknown deny 拦——机制缺口,本修复关闭)
620
+ const whtText = `${qText} ${selectedText}`;
621
+ for (const hit of whtText.matchAll(/(?:允许|批准|同意)\s*(?:使用|调用|用|启用|放行)\s*([A-Za-z_][A-Za-z0-9_:.-]*)/g)) {
622
+ const wname = hit[1];
623
+ if (toolClass(wname, {}) !== "unknown") continue;
624
+ if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
625
+ const isNew = !state.unknownToolApproved.has(wname);
626
+ state.unknownToolApproved.add(wname);
627
+ audit({
628
+ kind: "unknown-tool-whitelist",
629
+ rule: "__unknown-tool",
630
+ name: "用户批准未归类工具(ask 答复)",
631
+ event: "tool/result",
632
+ reason: `ask 答复含"允许使用 ${wname}" → 会话白名${isNew ? "" : "(重复,忽略)"}(来源=ask 选择文本)`,
633
+ session: sid
634
+ });
635
+ }
636
+ if (!s.turn.scopes) s.turn.scopes = [];
637
+ s.turn.scopes.push(authRecord);
436
638
  audit({
437
639
  kind: "auth",
438
640
  rule: "12D",
@@ -442,6 +644,11 @@ function handleSessionEvent(ctx, session, event) {
442
644
  session: sid
443
645
  });
444
646
  } else {
647
+ // 弹窗消减(2026-08-24):本回合 ask 被拒 → 标记 + 全局记录(5 分钟内再 ask 会被 __ask-throttle 拦)
648
+ s.turn.askRejected = true;
649
+ if (!state.askRejections) state.askRejections = [];
650
+ state.askRejections.push({ sessionId: sid, at: Date.now() });
651
+ if (state.askRejections.length > 200) state.askRejections.splice(0, state.askRejections.length - 200);
445
652
  audit({
446
653
  kind: "auth-reject",
447
654
  rule: "12D",
@@ -464,6 +671,52 @@ function handleSessionEvent(ctx, session, event) {
464
671
  }
465
672
  if (event.type === "assistant/message") {
466
673
  const text = extractAssistantText(d.message);
674
+ // 交付声明机器闸门(机制批 M3,规则 23④):完成类声明 → 核对同会话 30 分钟内 verify-pass 记录,缺失注入纠正
675
+ if (/完成|已通过|已修复|搞定|验证通过|全部通过|修复完成|落盘完成/.test(text)) {
676
+ const windowStart = Date.now() - 30 * 60 * 1000;
677
+ const hasPass = (state.verifyPass || []).some((v) => v.sessionId === sid && v.at > windowStart);
678
+ if (!hasPass) {
679
+ audit({
680
+ kind: "verify-gap",
681
+ rule: "23",
682
+ name: "交付声明缺验证闸门记录",
683
+ event: "assistant/message",
684
+ reason: "交付/完成类声明缺少同会话近期 verify-pass(ALL TESTS PASSED / check-plugin-load RESULT: PASS)记录",
685
+ session: sid
686
+ });
687
+ maybeInject(ctx, sid, { ruleId: "23", reason: "规则 23④:完成/通过类声明需同会话近期验证记录(测试全绿或冷加载 PASS),缺失已记审计——请先运行验证并附证据再宣布完成" });
688
+ }
689
+ }
690
+ // 需求基线覆盖核查(RB-09,2026-08-24):方案/重构/整体完成类声明 → 基线仍存在 planned 需求 → 审计纠正(fail-closed)
691
+ if (BASELINE_COMPLETE_RE.test(text)) {
692
+ const bl = loadBaselineFile();
693
+ if (bl.ok) {
694
+ const gap = computeBaselineGap(text, bl.items);
695
+ if (gap) {
696
+ audit({
697
+ kind: "baseline-gap",
698
+ rule: "__baseline-gap",
699
+ name: "需求基线未覆盖",
700
+ event: "assistant/message",
701
+ reason: `完成类声明但需求基线仍有 ${gap.count} 条未落地(${gap.pending.join("、")})——先完成或更新基线再宣布完成`,
702
+ session: sid
703
+ });
704
+ maybeInject(ctx, sid, {
705
+ ruleId: "__baseline-gap",
706
+ reason: `需求基线仍有 ${gap.count} 条未落地(${gap.pending.join("、")}),请先完成或更新基线再宣布整体完成`
707
+ });
708
+ }
709
+ } else {
710
+ audit({
711
+ kind: "baseline-unreadable",
712
+ rule: "__baseline-gap",
713
+ name: "需求基线不可读",
714
+ event: "assistant/message",
715
+ reason: `完成类声明但需求基线文件无法读取(${bl.error || "未知"})——fail-closed,无法核查覆盖`,
716
+ session: sid
717
+ });
718
+ }
719
+ }
467
720
  const violations = detectViolations({ configs: state.configs, session: s, text, reasoningText: s.turn.reasoningText, mountRevision: state.mountRevision });
468
721
  for (const v of violations) {
469
722
  audit({
@@ -474,7 +727,19 @@ function handleSessionEvent(ctx, session, event) {
474
727
  reason: v.reason,
475
728
  session: sid
476
729
  });
477
- maybeInject(ctx, sid, v);
730
+ }
731
+ // 注入噪音治理 v0.5.6(建议1/B1 聚合注入):同回合多条违规 → 聚合为一条注入(明细全在 /guard log);
732
+ // 单条仍按逐规则投递(保留完整提示文本与逐规则计数上限);聚合注入用伪规则 __self-cert 计数。
733
+ if (violations.length > 1) {
734
+ const briefs = violations
735
+ .map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`)
736
+ .join(";");
737
+ maybeInject(ctx, sid, {
738
+ ruleId: "__self-cert",
739
+ reason: `本轮检出 ${violations.length} 项待自证:${briefs}——请按 /guard log 明细一次性自证`
740
+ });
741
+ } else if (violations.length === 1) {
742
+ maybeInject(ctx, sid, violations[0]);
478
743
  }
479
744
  if (violations.length) {
480
745
  state.lastActive = violations.map((v) => ({ ruleId: v.ruleId, title: v.title, reason: v.reason }));
@@ -564,6 +829,7 @@ async function executeGuard(ctx, invocation) {
564
829
  ` 放行剩余:${fmtRemain(remainMs(state.bypassUntil))}(全部守卫暂停)`,
565
830
  ` 授权存储:内存态(重启失效)`,
566
831
  ` 审计日志:${auditFilePath()}`,
832
+ ` LLM 意图判定:${state.llmIntentCfg?.enabled ? `开启(今日 ${[...(state.llmIntentBudget?.values() || [])].reduce((a, b) => a + b, 0)} 次/${pluginConfig.llmIntent?.dailyLimitPerSession ?? 50},缓存命中 ${state.llmIntentHits || 0},缓存 ${state.llmIntentCache?.size || 0} 条${state.llmIntentLast ? `;最近:${state.llmIntentLast.text} → ${state.llmIntentLast.source}${state.llmIntentLast.verdict ? `(conf=${state.llmIntentLast.verdict.confidence})` : "【降级词表】"}` : ""})` : "关闭"}`,
567
833
  ` 最近激活:${state.lastActive.length ? state.lastActive.map((a) => a.ruleId).join("、") : "无"}`,
568
834
  ` 提示:审批策略 never 只关系统审批弹窗,不影响对话内 ask_user_question 授权;规则 12A 仍会硬拦需要授权的操作。`
569
835
  ];
@@ -592,6 +858,8 @@ async function executeGuard(ctx, invocation) {
592
858
  case "unlock": {
593
859
  const minutes = Math.min(Math.max(1, command.minutes), 60);
594
860
  state.unlockUntil = Date.now() + minutes * 60000;
861
+ // 逃生门请求审计(机制批 M1):用途留痕,供规则 1⑤ 事前说明核对
862
+ audit({ kind: "guard-command", rule: "__escape-gate", name: "逃生门请求", event: "command", reason: `/guard unlock ${minutes} 分钟(配置写保护)`, session: invocation?.session?.id || "global" });
595
863
  return {
596
864
  kind: "success",
597
865
  text: `已解锁「配置写保护」${minutes} 分钟。现在可以让助手修改 rule-engine.json / rule-understanding.json / AGENTS.md;改完请执行 /guard lock 或等待自动恢复。`
@@ -600,6 +868,8 @@ async function executeGuard(ctx, invocation) {
600
868
  case "bypass": {
601
869
  const minutes = Math.min(Math.max(1, command.minutes), 60);
602
870
  state.bypassUntil = Date.now() + minutes * 60000;
871
+ // 逃生门请求审计(机制批 M1)
872
+ audit({ kind: "guard-command", rule: "__escape-gate", name: "逃生门请求", event: "command", reason: `/guard bypass ${minutes} 分钟(全部守卫)`, session: invocation?.session?.id || "global" });
603
873
  return {
604
874
  kind: "success",
605
875
  text: `已临时放行全部守卫 ${minutes} 分钟。到期自动恢复,也可 /guard reload 后立即恢复。`
@@ -611,12 +881,18 @@ async function executeGuard(ctx, invocation) {
611
881
  return { kind: "success", text: "守卫已全部恢复:解锁与放行均已取消。" };
612
882
  }
613
883
  case "revoke": {
614
- let count = 0;
615
- for (const s of state.sessions.values()) {
616
- count += s.authorizations.length;
617
- s.authorizations = [];
618
- }
619
- return { kind: "success", text: `已撤销全部授权记录(共 ${count} 条)。` };
884
+ // R1.3(2026-08-24):全清——session.authorizations + turn.scopes + globalAuthorizations + askRejections
885
+ const count = clearAuthorizations(state);
886
+ // 可观测性(阶段 0 + 机制 C,2026-08-24):revoke 事件必须留痕,否则撤销无法复核(B4 实证)
887
+ audit({
888
+ kind: "guard-command",
889
+ rule: "__revoke",
890
+ name: "撤销全部授权",
891
+ event: "command",
892
+ reason: `撤销 ${count} 条授权/范围(session + turn.scopes + global + askRejections 全清)`,
893
+ session: invocation?.session?.id || "global"
894
+ });
895
+ return { kind: "success", text: `已撤销全部授权与范围(共 ${count} 条,含全局池与 ask 拒绝记录;不再有残留)。` };
620
896
  }
621
897
  case "log": {
622
898
  const n = Math.min(Math.max(1, command.n), 200);
@@ -696,6 +972,9 @@ export function apply(ctx) {
696
972
  setWorkspaceRoot(process.env.DSH_WORKSPACE || "");
697
973
  }
698
974
 
975
+ // 0.0.1 LLM 意图兜底配置(guardDecision 为纯函数,经 state 传入)
976
+ state.llmIntentCfg = pluginConfig.llmIntent || {};
977
+
699
978
  // 0.1 技能目录实时联动
700
979
  refreshSkills(ctx);
701
980
  ctx.on("skills/change", () => {
@@ -730,11 +1009,59 @@ export function apply(ctx) {
730
1009
  "dsh-rule-engine watch"
731
1010
  );
732
1011
 
1012
+ // 0.3.1 注入延迟投递定时器清理(O1 批次 6):卸载/热重载时取消全部 pending inject
1013
+ ctx.effect(
1014
+ function* () {
1015
+ yield () => {
1016
+ for (const t of pendingInjectTimers) clearTimeout(t);
1017
+ pendingInjectTimers.clear();
1018
+ };
1019
+ },
1020
+ "dsh-rule-engine inject timers"
1021
+ );
1022
+
733
1023
  // 1. 单调守卫:工具调用先裁决,命中即物理拒绝
734
1024
  ctx.effect(
735
1025
  function* () {
736
1026
  yield ctx.tools.guard((exec) => {
737
- const hit = guardDecision(state, exec, Date.now());
1027
+ // 落盘授权粒度提醒(机制批 M7,2026-08-24):受保护文件统一入口调用 + 本回合用户消息为方案性指令
1028
+ //(调整/补充/评估/建议且无落盘词)→ approval-gap 审计 + 注入提醒(规则 22 自证③:方案性指令 ≠ 落盘授权)
1029
+ {
1030
+ const cmd = exec?.arguments?.command || exec?.arguments?.code || "";
1031
+ if ((exec?.name === "pwsh" || exec?.name === "bash") && /\bdsh-manual-write\.mjs/.test(cmd)) {
1032
+ const s7 = getSessionState(state, sessionIdOfExec(exec));
1033
+ if (needsApprovalReminder(s7.turn.userText || "")) {
1034
+ audit({
1035
+ kind: "approval-gap",
1036
+ rule: "__approval-gap",
1037
+ name: "落盘授权粒度提醒",
1038
+ event: "tool/guard",
1039
+ tool: exec?.name,
1040
+ args: summarizeArgs(exec?.arguments),
1041
+ reason: "本回合用户消息为方案性指令(调整/补充/评估/建议等),不构成落盘授权(规则 22 自证③)——落盘前应先展示→用户确认",
1042
+ session: sessionIdOfExec(exec)
1043
+ });
1044
+ maybeInject(ctx, sessionIdOfExec(exec), {
1045
+ ruleId: "__approval-gap",
1046
+ reason: "规则 22 自证③:方案性指令不构成落盘授权——受保护文件落盘需先展示改动→用户明确确认→再执行(机制批 M7)"
1047
+ });
1048
+ }
1049
+ }
1050
+ }
1051
+ // 逃生门窗口打标(机制批 M2):bypass 生效期间一切变更类调用记 bypass-action 审计(用途留痕,供规则 1⑤ 核对)
1052
+ if (state.bypassUntil > Date.now() && !isReadOnlyTool(exec?.name, exec?.arguments)) {
1053
+ audit({
1054
+ kind: "bypass-action",
1055
+ rule: "__escape-gate",
1056
+ name: "逃生门窗口变更",
1057
+ event: "tool/guard",
1058
+ tool: exec?.name,
1059
+ args: summarizeArgs(exec?.arguments),
1060
+ reason: "窗口内变更(逃生门用途:引擎/规则/手册修复,规则 1⑤ 范围核对)",
1061
+ session: sessionIdOfExec(exec)
1062
+ });
1063
+ }
1064
+ const hit = guardDecision(state, exec, Date.now(), { audit });
738
1065
  if (hit) {
739
1066
  audit({
740
1067
  kind: "deny",
@@ -743,9 +1070,25 @@ export function apply(ctx) {
743
1070
  event: "tool/guard",
744
1071
  tool: exec?.name,
745
1072
  args: summarizeArgs(exec?.arguments),
746
- reason: hit.reason
1073
+ reason: hit.reason,
1074
+ session: sessionIdOfExec(exec) // P0-4:deny 审计补 session(2026-08-24 事故复盘:归属会话靠猜)
747
1075
  });
748
1076
  state.lastActive = [{ ruleId: hit.ruleId, title: hit.title, reason: hit.reason }];
1077
+ // 规则 1 计数解耦(2026-08-24,P1-7):引擎 deny 不计入“同工具连续失败”
1078
+ const dkey = `${exec?.name}:${JSON.stringify(exec?.arguments || {})}`;
1079
+ if (!state.deniedKeys) state.deniedKeys = new Set();
1080
+ state.deniedKeys.add(dkey);
1081
+ if (state.deniedKeys.size > 500) {
1082
+ const oldest = state.deniedKeys.keys().next().value;
1083
+ if (oldest !== undefined) state.deniedKeys.delete(oldest);
1084
+ }
1085
+ // 弹窗消减(2026-08-24):E3/ask 节流命中后注入纠正,提示改用普通文本,勿连环弹窗
1086
+ if (hit.ruleId === "__already-authorized" || hit.ruleId === "__ask-rejected" || hit.ruleId === "__ask-throttle") {
1087
+ maybeInject(ctx, sessionIdOfExec(exec), {
1088
+ ruleId: hit.ruleId,
1089
+ reason: `${hit.title}:已有授权或询问被拒时请直接执行、或用普通文本说明,不要再弹窗 ask。`
1090
+ });
1091
+ }
749
1092
  return hit.reason;
750
1093
  }
751
1094
  return undefined;
@@ -775,15 +1118,73 @@ export function apply(ctx) {
775
1118
  return next();
776
1119
  });
777
1120
 
778
- // 1.1 任务契约 ask 通道:仅在总开关 + askEnabled 时对“应询问”动作返回 ask
1121
+ // 0.9 机制 B(2026-08-24):未归类工具(新插件/新工具)首调 deny——fail-closed,防"approval 层自动放行 ask"的洞。
1122
+ // 批次 3(2026-08-24 用户拍板分支 A):实测未知工具首调 ask 被 DSH approval 层自动放行(无弹窗直接执行),
1123
+ // 因此 unknown → 物理 deny;批准路径 = 用户明确"允许使用 X"(handleSessionEvent 写入会话白名),本会话内放行。
1124
+ ctx.on("tools/pre-execute", async (exec, next) => {
1125
+ try {
1126
+ if (SAFE_UNCOVERED_TOOLS.has(exec?.name)) return next(); // 控制类不进 unknown 判定(避免 ask 自身被 ask)
1127
+ if (toolClass(exec?.name, exec?.arguments || {}) !== "unknown") return next();
1128
+ if ((state.unknownToolApproved || new Set()).has(exec.name)) return next(); // 会话内用户已批准
1129
+ audit({
1130
+ kind: "unknown-tool",
1131
+ rule: "__unknown-tool",
1132
+ name: "未归类工具拦截",
1133
+ event: "tools/pre-execute",
1134
+ tool: exec?.name,
1135
+ args: summarizeArgs(exec?.arguments),
1136
+ reason: `工具 ${exec.name} 不在规则引擎分类表(可能是新装的插件):已拦截(unknown→deny)。批准路径:用户明确"允许使用 ${exec.name}"后本会话放行;长期规则请补充工具分类表`,
1137
+ session: sessionIdOfExec(exec)
1138
+ });
1139
+ return {
1140
+ kind: "deny",
1141
+ reason: `工具 ${exec.name} 尚未归类(新装插件?):已拦截。如需使用请向用户说明用途并获取其"允许使用 ${exec.name}"的明确答复后重试(批准后本会话内放行)。`
1142
+ };
1143
+ } catch (error) {
1144
+ ctx.logger?.warn?.("[dsh-rule-engine] unknown-tool deny error", error);
1145
+ return next();
1146
+ }
1147
+ });
1148
+
1149
+ // 1.1 任务契约裁决钩子:deny 物理拒绝 + ask 通道 + 委托预算启动即计。
1150
+ // 2026-08-25 修复(预算语义偏差,实弹:第 2 个子代理被误拒——允许 N 实际 N-1):
1151
+ // 根因 = 扣减(本钩子)先于 guard 层的 decideContractAction(L1138 用已扣 contract 重判 → 2+1>2 → 误拒)。
1152
+ // 修复 = 本钩子成为契约唯一裁决点(deny 在扣减**前**判定并物理拒绝),guard 层跳过预算类 deny(防双判)。
779
1153
  ctx.on("tools/pre-execute", async (exec, next) => {
780
1154
  try {
781
- if (!state.taskContract?.taskContractEnabled || !state.taskContract?.askEnabled) return next();
1155
+ if (!state.taskContract?.taskContractEnabled) return next();
782
1156
  const sid = sessionIdOfExec(exec);
783
1157
  const s = getSessionState(state, sid);
784
1158
  if (!isArmed(s.contract, state.taskContract)) return next();
785
1159
  const action = classifyAction(exec?.name, exec?.arguments);
786
1160
  const dec = decideContractAction({ contract: s.contract, action, config: state.taskContract });
1161
+ // deny(在扣减前判定,语义=本次调用发生时点)
1162
+ if (dec.outcome === "deny") {
1163
+ audit({
1164
+ kind: "task-contract-deny",
1165
+ rule: "__task-contract",
1166
+ name: `任务契约拒绝:${dec.reasonCode}`,
1167
+ event: "tools/pre-execute",
1168
+ tool: exec?.name,
1169
+ args: summarizeArgs(exec?.arguments),
1170
+ reason: dec.reason,
1171
+ session: sid
1172
+ });
1173
+ return { kind: "deny", reason: `【硬拦截】${dec.reason}(${dec.reasonCode}|任务契约|放行:${dec.nextStep || "请调整契约"})` };
1174
+ }
1175
+ // 批次 5.5(2026-08-24):委托启动即计预算——原 tool/result 扣减因 subagent 无 pendingCall 从未执行(agents 恒 0)
1176
+ if (dec.outcome === "allow" && action.mutability === "delegate" && action.delegationCount > 0) {
1177
+ s.contract = consumeDelegateBudget(s.contract, action.delegationCount);
1178
+ audit({
1179
+ kind: "task-budget",
1180
+ rule: "__task-contract",
1181
+ name: "子代理预算扣减",
1182
+ event: "tools/pre-execute",
1183
+ tool: exec?.name,
1184
+ reason: `已用 ${s.contract.agentsUsed}/${s.contract.agentBudget}`,
1185
+ session: sid
1186
+ });
1187
+ }
787
1188
  if (dec.outcome === "ask") {
788
1189
  audit({
789
1190
  kind: "task-ask",
@@ -797,6 +1198,24 @@ export function apply(ctx) {
797
1198
  });
798
1199
  return { kind: "ask", reason: `${dec.reason}(任务契约|${dec.nextStep || "请确认或使用放行词"})` };
799
1200
  }
1201
+ // 可观测性(阶段 0,2026-08-24):armed 下 deny/allow 裁决也留痕,CI 澄清"契约 deny 有无真实拦截路径"
1202
+ if (dec.outcome === "deny" || dec.outcome === "allow") {
1203
+ audit({
1204
+ kind: "contract-decision",
1205
+ rule: "__task-contract",
1206
+ name: "任务契约裁决",
1207
+ event: "tools/pre-execute",
1208
+ tool: exec?.name,
1209
+ args: summarizeArgs(exec?.arguments),
1210
+ reason: `裁决 ${dec.outcome}|${dec.reasonCode}|${dec.reason}`,
1211
+ session: sid
1212
+ });
1213
+ }
1214
+ // 批次 4(2026-08-24):契约 deny 真实物理拦截(原仅审计 + next()——armed 下出界写/
1215
+ // 无界委托/预算超/模式禁止变更均无实际效力)
1216
+ if (dec.outcome === "deny") {
1217
+ return { kind: "deny", reason: `${dec.reason}(任务契约|${dec.nextStep || "请调整契约或停止该操作"})` };
1218
+ }
800
1219
  return next();
801
1220
  } catch (error) {
802
1221
  ctx.logger?.warn?.("[dsh-rule-engine] tools/pre-execute error", error);