dsh-rule-engine 0.5.3 → 0.5.5

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,52 @@ 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
- });
179
+ const timer = setTimeout(() => {
180
+ pendingInjectTimers.delete(timer);
181
+ try {
182
+ const agent = ctx.agents.get(sessionId);
183
+ if (agent && typeof agent.inject === "function") {
184
+ // 2026-08-24 批次 6 收尾(O1 实弹观测):注入消息必须具备官方身份(id+role)——
185
+ // dsh-llm 官方 createUserMessage 生成 { id: randomUUID(), role: "user", content, source };
186
+ // id 时 Inbox.validate undefined 作身份,同批第二条起抛
187
+ // `message "undefined" is already pending`(实弹 17:56:37 八连发观测)。
188
+ agent.inject({
189
+ id: `re-${randomUUID()}`,
190
+ role: "user",
191
+ content: [
192
+ {
193
+ type: "text",
194
+ text: `[规则引擎] ${violation.reason}(规则 ${violation.ruleId},已记入 /guard log;下次回复请自证/纠正)`
195
+ }
196
+ ],
197
+ source: { kind: "plugin", plugin: name }
198
+ });
199
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入已投递(agent=${sessionId}):${String(violation.reason || "").slice(0, 120)}`, session: sessionId });
200
+ } else {
201
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入未投递:agents.get(${sessionId}) 无可用 agent.inject`, session: sessionId });
202
+ }
203
+ } catch (error) {
204
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入异常:${error instanceof Error ? error.message : String(error)}`, session: sessionId });
154
205
  }
155
- } catch {
156
- // 注入失败不影响审计
157
- }
206
+ }, 0);
207
+ pendingInjectTimers.add(timer);
158
208
  }
159
209
 
160
210
  async function refreshSkills(ctx) {
@@ -166,7 +216,7 @@ async function refreshSkills(ctx) {
166
216
  }
167
217
  }
168
218
 
169
- function handleSessionEvent(ctx, session, event) {
219
+ export function handleSessionEvent(ctx, session, event) {
170
220
  maybeReloadIfChanged(state);
171
221
  const sid = session?.id || "global";
172
222
  const s = getSessionState(state, sid);
@@ -175,11 +225,87 @@ function handleSessionEvent(ctx, session, event) {
175
225
  resetTurn(state, sid, d.turn);
176
226
  return;
177
227
  }
228
+ if (event.type === "turn/end") {
229
+ // M8 双通道机制:本回合用 dsh-manual-write 落盘手册/AGENTS 后,必须同轮 engram_store 沉淀记忆;
230
+ // 缺失 → 审计 + 注入纠正(用户明确要求机制化,2026-08-24)
231
+ if (s.turn.manualWriteSeen && !s.turn.engramStoreSeen) {
232
+ audit({
233
+ kind: "engram-gap",
234
+ rule: "__engram-gap",
235
+ name: "双通道记忆缺失",
236
+ event: "turn/end",
237
+ reason: "本回合 dsh-manual-write 落盘了手册/AGENTS,但未同轮调用 engram_store;按规则 19/77/M8 应在同一回合完成记忆沉淀",
238
+ session: sid
239
+ });
240
+ maybeInject(ctx, sid, {
241
+ ruleId: "__engram-gap",
242
+ reason: "规则 19/M8:手册/AGENTS 落盘后必须在同一回合补 engram_store,否则记忆机制断链——请立即补写并说明"
243
+ });
244
+ }
245
+ return;
246
+ }
178
247
  if (event.type === "user/message") {
179
- const text = extractUserText(d.message);
248
+ // 机制 A(2026-08-24):source.kind 主判——非 user 来源(官方 agent-instructions/skill-catalog、
249
+ // 插件自定义 kind)一律不进授权池、不覆盖回合状态;审计留痕可对账(source-skip)。
250
+ const srcKind = d?.source?.kind;
251
+ if (typeof srcKind === "string" && srcKind !== "user") {
252
+ const preview = extractUserText(d).slice(0, 80);
253
+ audit({
254
+ kind: "source-skip",
255
+ rule: "__source-skip",
256
+ name: "非用户来源消息跳过",
257
+ event: "user/message",
258
+ reason: `source.kind=${srcKind}:${preview}`,
259
+ session: sid
260
+ });
261
+ return;
262
+ }
263
+ // 系统注入剥离(2026-08-24 v2):注入与真实用户消息合并为同一事件
264
+ //(官方注入机制将 <system-reminder> 烤进 content;剥离后剩余文本 = 用户消息)。
265
+ // 2026-08-24 根因修复:event.data 即消息对象(官方 surface 结构),不再取 d.message。
266
+ const text = stripInjectedContext(extractUserText(d));
267
+ if (!text) return;
268
+ // 引擎自身注入的 [规则引擎] 提示不是用户消息,跳过——否则其中的“请直接执行”会被记录成授权(自我续授权漏洞)
269
+ if (isEngineInjectedMessage(text)) return;
270
+ // 模板兜底(对 kind=user 同样生效,防"标 user 的注入"——super-injector createUserMessage 同类风险):
271
+ // runtime context 快照 / 子代理完成通知 / vision-router 挂载提醒等已知注入模板 → 整体跳过
272
+ if (isKnownSystemInjection(text)) {
273
+ audit({
274
+ kind: "source-skip",
275
+ rule: "__source-skip",
276
+ name: "已知系统注入模板跳过",
277
+ event: "user/message",
278
+ reason: `文本模板命中:${text.slice(0, 80)}`,
279
+ session: sid
280
+ });
281
+ return;
282
+ }
283
+ // 批次 3(2026-08-24):用户明确"允许使用 X"→ 未归类工具会话白名(unknown→deny 的唯一批准路径)
284
+ if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
285
+ for (const hit of text.matchAll(/(?:允许|批准|同意)\s*(?:使用|调用|用|启用|放行)\s*([A-Za-z_][A-Za-z0-9_:.-]*)/g)) {
286
+ const toolName = hit[1];
287
+ if (toolClass(toolName, {}) !== "unknown") continue; // 只对未归类工具有意义(已知工具不走 unknown 分支)
288
+ const isNew = !state.unknownToolApproved.has(toolName);
289
+ state.unknownToolApproved.add(toolName);
290
+ audit({
291
+ kind: "unknown-tool-whitelist",
292
+ rule: "__unknown-tool",
293
+ name: "用户批准未归类工具",
294
+ event: "user/message",
295
+ reason: `用户明确允许使用 ${toolName}${isNew ? "" : "(重复,忽略)"} → 会话白名`,
296
+ session: sid
297
+ });
298
+ }
180
299
  s.lastUserText = text;
181
300
  s.turn.userText = text;
182
301
  s.turn.questionOnly = isQuestionMessage(text);
302
+ s.turn.intents = parseUserIntents(text);
303
+ // 规则 22 粒度升级(2026-08-24):本回合授权范围 = 各 execute 子句推导出的 type+path 范围
304
+ s.turn.scopes = scopesFromIntents(s.turn.intents);
305
+ // LLM 意图兜底(方案 A):低置信/歧义消息异步预取,不阻塞事件流;失败自动降级词表
306
+ if (pluginConfig.llmIntent?.enabled) {
307
+ void enrichIntentWithLlm(ctx, state, sid, text, pluginConfig.llmIntent);
308
+ }
183
309
  if (state.taskContract?.taskContractEnabled) {
184
310
  const patch = naturalMode(text, s.contract);
185
311
  if (patch) {
@@ -197,12 +323,29 @@ function handleSessionEvent(ctx, session, event) {
197
323
  }
198
324
  }
199
325
  }
200
- if (isAuthMessage(text) || isDirectiveMessage(text)) {
326
+ const intents = s.turn.intents;
327
+ let recordedAuth = false;
328
+ if (intents?.clauses?.some((c) => c.type === "execute")) {
329
+ // 与规则 22 粒度保持一致:按 scopesFromIntents 的多 type×多 path 逐条记录 12A 授权,
330
+ // 避免“规则 22 放行、12A 却因只记了单 type/单路径而拦截”的不一致(2026-08-24 第二轮修复)
331
+ const execScopes = s.turn.scopes && s.turn.scopes.length > 0 ? s.turn.scopes : scopesFromIntents(intents);
332
+ for (const scope of execScopes) {
333
+ recordAuthorization(state, sid, {
334
+ type: scope.type,
335
+ pathPrefix: scope.pathPrefix,
336
+ source: "user-message-clause",
337
+ clauseId: scope.clauseId
338
+ });
339
+ }
340
+ recordedAuth = true;
341
+ audit({ kind: "auth", rule: "12D", name: "用户消息执行子句授权", event: "user/message", reason: `记录执行分点授权:${text.slice(0, 120)}`, session: sid });
342
+ } else if (isAuthMessage(text) || isDirectiveMessage(text)) {
201
343
  recordAuthorization(state, sid, {
202
344
  type: inferTypeFromText(text),
203
345
  pathPrefix: inferPathPrefixFromText(text),
204
346
  source: "user-message"
205
347
  });
348
+ recordedAuth = true;
206
349
  audit({ kind: "auth", rule: "12D", name: "用户消息授权", event: "user/message", reason: `记录用户消息授权:${text.slice(0, 120)}`, session: sid });
207
350
  }
208
351
  const active = activateForUserMessage(state.configs, text);
@@ -231,6 +374,18 @@ function handleSessionEvent(ctx, session, event) {
231
374
  if (toolName === "pwsh" || toolName === "bash") {
232
375
  const cmd = args?.command || args?.code || "";
233
376
  if (isGetDateCommand(cmd)) s.turn.getDateSeen = true;
377
+ // workdir 校验提示(机制批 M6):命令含显式相对路径(./ 或 ../)但未声明 workdir → 审计提示(不拦)
378
+ if (cmd && /(?:^|\s)(?:\.{1,2}[\\/])/.test(cmd) && !args?.workdir) {
379
+ audit({
380
+ kind: "workdir-hint",
381
+ rule: "__workdir-hint",
382
+ name: "workdir 提示",
383
+ event: "tool/call",
384
+ tool: toolName,
385
+ reason: "命令含相对路径但未显式声明 workdir——易与当前目录错配(机制批 M6),pws 请显式 workdir",
386
+ session: sid
387
+ });
388
+ }
234
389
  const bp = backupPathsFromTool(toolName, args);
235
390
  if (bp) {
236
391
  if (!pendingCall.backupPaths) pendingCall.backupPaths = [];
@@ -283,7 +438,12 @@ function handleSessionEvent(ctx, session, event) {
283
438
  if (pendingCall) {
284
439
  const key = `${pendingCall.name}:${JSON.stringify(pendingCall.args || {})}`;
285
440
  if (isError) {
286
- state.retryCounts.set(key, (state.retryCounts.get(key) || 0) + 1);
441
+ // 规则 1 计数解耦(P1-7,2026-08-24):引擎 deny 来源的失败不计入“同工具连续失败”
442
+ if (!((state.deniedKeys || new Set()).has(key))) {
443
+ state.retryCounts.set(key, (state.retryCounts.get(key) || 0) + 1);
444
+ } else {
445
+ state.retryCounts.delete(key);
446
+ }
287
447
  } else {
288
448
  state.retryCounts.delete(key);
289
449
  }
@@ -297,23 +457,20 @@ function handleSessionEvent(ctx, session, event) {
297
457
  }
298
458
  }
299
459
 
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
- });
460
+ // M8 双通道机制(2026-08-24):dsh-manual-write 落盘成功 → 标记;同轮 engram_store 成功 → 标记
461
+ if (!isError && pendingCall) {
462
+ const cmd = pendingCall.args?.command || pendingCall.args?.code || "";
463
+ if ((pendingCall.name === "pwsh" || pendingCall.name === "bash") && /\bdsh-manual-write\.mjs/.test(cmd)) {
464
+ s.turn.manualWriteSeen = true;
465
+ }
466
+ if (pendingCall.name === "engram_store") {
467
+ s.turn.engramStoreSeen = true;
314
468
  }
315
469
  }
316
470
 
471
+ // 任务契约:委托预算扣减已移至 pre-execute 启动即计(批次 5.5;原 tool/result 路径因
472
+ // subagent 无 pendingCall 从未执行——本块删除防双重扣减)
473
+
317
474
  // 规则 27:装配变更成功后全局 revision +1;审计命令输出通过/失败后更新本会话审计 revision
318
475
  if (pendingCall) {
319
476
  if (!isError && isAssemblyMutationTool(pendingCall.name, pendingCall.args)) {
@@ -397,6 +554,19 @@ function handleSessionEvent(ctx, session, event) {
397
554
  }
398
555
  }
399
556
 
557
+ // 交付闸门采集(机制批 M3):测试/冷加载探针通过输出 → verify-pass 记录(供规则 23④ 声明核对)
558
+ // 2026-08-25 鲁棒化(实弹观测):旧条件依赖 pendingCall(tool/call→result 的 callId 匹配),
559
+ // RC.2 下疑似失配导致采集从未执行——23④ 每次声明都 verify-gap;改为只看输出文本,不依赖
560
+ // pendingToolCalls 状态(what 降级记录),verify-pass 语义不变(输出即证据)。
561
+ if (!isError) {
562
+ const output = extractToolOutput(d);
563
+ if (/\bALL TESTS PASSED\b|RESULT:\s*PASS/.test(output)) {
564
+ if (!state.verifyPass) state.verifyPass = [];
565
+ state.verifyPass.push({ at: Date.now(), sessionId: sid, what: pendingCall?.name || "tool-result" });
566
+ if (state.verifyPass.length > 100) state.verifyPass.splice(0, state.verifyPass.length - 100);
567
+ }
568
+ }
569
+
400
570
  // 命令输出静默错误检测(不阻断,只审计 + 注入提醒)
401
571
  if (!isError && pendingCall && (pendingCall.name === "pwsh" || pendingCall.name === "bash")) {
402
572
  const output = extractToolOutput(d);
@@ -421,7 +591,11 @@ function handleSessionEvent(ctx, session, event) {
421
591
  const result = d.result ?? d.value ?? d;
422
592
  if (askResultApproved(result)) {
423
593
  const qText = askQuestionText(pending.questions);
424
- const pathPrefix = inferPathPrefixFromText(qText);
594
+ const selectedText = askResultSelectedText(result);
595
+ // 授权范围只取“问题核心 + 用户实际选择”,不再把全部选项描述纳入;
596
+ // 避免“仅会话内说明/不跨会话保留”等选项说明把一次小授权放大成全局 12h(2026-08-24 修复)
597
+ const scopeText = `${askQuestionCoreText(pending.questions)} ${selectedText}`.trim();
598
+ const pathPrefix = inferPathPrefixFromText(scopeText || qText);
425
599
  // ask 问题文本措辞不可靠,授权记录为宽泛类型 any + 路径前缀,避免类型错位
426
600
  // 无路径的全局 any 授权缩短 TTL,降低安全边界风险
427
601
  const authRecord = {
@@ -429,10 +603,31 @@ function handleSessionEvent(ctx, session, event) {
429
603
  pathPrefix,
430
604
  source: "ask"
431
605
  };
432
- const sessionWide = /本会话|剩余|全部|会话内/i.test(qText);
606
+ const sessionWide = isSessionWideAskText(scopeText) || isSessionWideAskText(selectedText);
433
607
  if (sessionWide) authRecord.expiresAt = Date.now() + 12 * 60 * 60 * 1000;
434
608
  else if (!pathPrefix) authRecord.expiresAt = Date.now() + 2 * 60 * 1000;
435
609
  recordAuthorization(state, sid, authRecord);
610
+ // 2026-08-25 修复(用户授权):ask 答复也是用户明确允许——若答复文本含"允许使用 X"模式,
611
+ // 同步写入 unknown 工具会话白名单(与 user/message 分支同一判定;此前只有用户消息文本可写白名,
612
+ // ask 弹窗授权后 web_fetch 等仍被 unknown deny 拦——机制缺口,本修复关闭)
613
+ const whtText = `${qText} ${selectedText}`;
614
+ for (const hit of whtText.matchAll(/(?:允许|批准|同意)\s*(?:使用|调用|用|启用|放行)\s*([A-Za-z_][A-Za-z0-9_:.-]*)/g)) {
615
+ const wname = hit[1];
616
+ if (toolClass(wname, {}) !== "unknown") continue;
617
+ if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
618
+ const isNew = !state.unknownToolApproved.has(wname);
619
+ state.unknownToolApproved.add(wname);
620
+ audit({
621
+ kind: "unknown-tool-whitelist",
622
+ rule: "__unknown-tool",
623
+ name: "用户批准未归类工具(ask 答复)",
624
+ event: "tool/result",
625
+ reason: `ask 答复含"允许使用 ${wname}" → 会话白名${isNew ? "" : "(重复,忽略)"}(来源=ask 选择文本)`,
626
+ session: sid
627
+ });
628
+ }
629
+ if (!s.turn.scopes) s.turn.scopes = [];
630
+ s.turn.scopes.push(authRecord);
436
631
  audit({
437
632
  kind: "auth",
438
633
  rule: "12D",
@@ -442,6 +637,11 @@ function handleSessionEvent(ctx, session, event) {
442
637
  session: sid
443
638
  });
444
639
  } else {
640
+ // 弹窗消减(2026-08-24):本回合 ask 被拒 → 标记 + 全局记录(5 分钟内再 ask 会被 __ask-throttle 拦)
641
+ s.turn.askRejected = true;
642
+ if (!state.askRejections) state.askRejections = [];
643
+ state.askRejections.push({ sessionId: sid, at: Date.now() });
644
+ if (state.askRejections.length > 200) state.askRejections.splice(0, state.askRejections.length - 200);
445
645
  audit({
446
646
  kind: "auth-reject",
447
647
  rule: "12D",
@@ -464,6 +664,52 @@ function handleSessionEvent(ctx, session, event) {
464
664
  }
465
665
  if (event.type === "assistant/message") {
466
666
  const text = extractAssistantText(d.message);
667
+ // 交付声明机器闸门(机制批 M3,规则 23④):完成类声明 → 核对同会话 30 分钟内 verify-pass 记录,缺失注入纠正
668
+ if (/完成|已通过|已修复|搞定|验证通过|全部通过|修复完成|落盘完成/.test(text)) {
669
+ const windowStart = Date.now() - 30 * 60 * 1000;
670
+ const hasPass = (state.verifyPass || []).some((v) => v.sessionId === sid && v.at > windowStart);
671
+ if (!hasPass) {
672
+ audit({
673
+ kind: "verify-gap",
674
+ rule: "23",
675
+ name: "交付声明缺验证闸门记录",
676
+ event: "assistant/message",
677
+ reason: "交付/完成类声明缺少同会话近期 verify-pass(ALL TESTS PASSED / check-plugin-load RESULT: PASS)记录",
678
+ session: sid
679
+ });
680
+ maybeInject(ctx, sid, { ruleId: "23", reason: "规则 23④:完成/通过类声明需同会话近期验证记录(测试全绿或冷加载 PASS),缺失已记审计——请先运行验证并附证据再宣布完成" });
681
+ }
682
+ }
683
+ // 需求基线覆盖核查(RB-09,2026-08-24):方案/重构/整体完成类声明 → 基线仍存在 planned 需求 → 审计纠正(fail-closed)
684
+ if (BASELINE_COMPLETE_RE.test(text)) {
685
+ const bl = loadBaselineFile();
686
+ if (bl.ok) {
687
+ const gap = computeBaselineGap(text, bl.items);
688
+ if (gap) {
689
+ audit({
690
+ kind: "baseline-gap",
691
+ rule: "__baseline-gap",
692
+ name: "需求基线未覆盖",
693
+ event: "assistant/message",
694
+ reason: `完成类声明但需求基线仍有 ${gap.count} 条未落地(${gap.pending.join("、")})——先完成或更新基线再宣布完成`,
695
+ session: sid
696
+ });
697
+ maybeInject(ctx, sid, {
698
+ ruleId: "__baseline-gap",
699
+ reason: `需求基线仍有 ${gap.count} 条未落地(${gap.pending.join("、")}),请先完成或更新基线再宣布整体完成`
700
+ });
701
+ }
702
+ } else {
703
+ audit({
704
+ kind: "baseline-unreadable",
705
+ rule: "__baseline-gap",
706
+ name: "需求基线不可读",
707
+ event: "assistant/message",
708
+ reason: `完成类声明但需求基线文件无法读取(${bl.error || "未知"})——fail-closed,无法核查覆盖`,
709
+ session: sid
710
+ });
711
+ }
712
+ }
467
713
  const violations = detectViolations({ configs: state.configs, session: s, text, reasoningText: s.turn.reasoningText, mountRevision: state.mountRevision });
468
714
  for (const v of violations) {
469
715
  audit({
@@ -564,6 +810,7 @@ async function executeGuard(ctx, invocation) {
564
810
  ` 放行剩余:${fmtRemain(remainMs(state.bypassUntil))}(全部守卫暂停)`,
565
811
  ` 授权存储:内存态(重启失效)`,
566
812
  ` 审计日志:${auditFilePath()}`,
813
+ ` 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
814
  ` 最近激活:${state.lastActive.length ? state.lastActive.map((a) => a.ruleId).join("、") : "无"}`,
568
815
  ` 提示:审批策略 never 只关系统审批弹窗,不影响对话内 ask_user_question 授权;规则 12A 仍会硬拦需要授权的操作。`
569
816
  ];
@@ -592,6 +839,8 @@ async function executeGuard(ctx, invocation) {
592
839
  case "unlock": {
593
840
  const minutes = Math.min(Math.max(1, command.minutes), 60);
594
841
  state.unlockUntil = Date.now() + minutes * 60000;
842
+ // 逃生门请求审计(机制批 M1):用途留痕,供规则 1⑤ 事前说明核对
843
+ audit({ kind: "guard-command", rule: "__escape-gate", name: "逃生门请求", event: "command", reason: `/guard unlock ${minutes} 分钟(配置写保护)`, session: invocation?.session?.id || "global" });
595
844
  return {
596
845
  kind: "success",
597
846
  text: `已解锁「配置写保护」${minutes} 分钟。现在可以让助手修改 rule-engine.json / rule-understanding.json / AGENTS.md;改完请执行 /guard lock 或等待自动恢复。`
@@ -600,6 +849,8 @@ async function executeGuard(ctx, invocation) {
600
849
  case "bypass": {
601
850
  const minutes = Math.min(Math.max(1, command.minutes), 60);
602
851
  state.bypassUntil = Date.now() + minutes * 60000;
852
+ // 逃生门请求审计(机制批 M1)
853
+ audit({ kind: "guard-command", rule: "__escape-gate", name: "逃生门请求", event: "command", reason: `/guard bypass ${minutes} 分钟(全部守卫)`, session: invocation?.session?.id || "global" });
603
854
  return {
604
855
  kind: "success",
605
856
  text: `已临时放行全部守卫 ${minutes} 分钟。到期自动恢复,也可 /guard reload 后立即恢复。`
@@ -611,12 +862,18 @@ async function executeGuard(ctx, invocation) {
611
862
  return { kind: "success", text: "守卫已全部恢复:解锁与放行均已取消。" };
612
863
  }
613
864
  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} 条)。` };
865
+ // R1.3(2026-08-24):全清——session.authorizations + turn.scopes + globalAuthorizations + askRejections
866
+ const count = clearAuthorizations(state);
867
+ // 可观测性(阶段 0 + 机制 C,2026-08-24):revoke 事件必须留痕,否则撤销无法复核(B4 实证)
868
+ audit({
869
+ kind: "guard-command",
870
+ rule: "__revoke",
871
+ name: "撤销全部授权",
872
+ event: "command",
873
+ reason: `撤销 ${count} 条授权/范围(session + turn.scopes + global + askRejections 全清)`,
874
+ session: invocation?.session?.id || "global"
875
+ });
876
+ return { kind: "success", text: `已撤销全部授权与范围(共 ${count} 条,含全局池与 ask 拒绝记录;不再有残留)。` };
620
877
  }
621
878
  case "log": {
622
879
  const n = Math.min(Math.max(1, command.n), 200);
@@ -696,6 +953,9 @@ export function apply(ctx) {
696
953
  setWorkspaceRoot(process.env.DSH_WORKSPACE || "");
697
954
  }
698
955
 
956
+ // 0.0.1 LLM 意图兜底配置(guardDecision 为纯函数,经 state 传入)
957
+ state.llmIntentCfg = pluginConfig.llmIntent || {};
958
+
699
959
  // 0.1 技能目录实时联动
700
960
  refreshSkills(ctx);
701
961
  ctx.on("skills/change", () => {
@@ -730,11 +990,59 @@ export function apply(ctx) {
730
990
  "dsh-rule-engine watch"
731
991
  );
732
992
 
993
+ // 0.3.1 注入延迟投递定时器清理(O1 批次 6):卸载/热重载时取消全部 pending inject
994
+ ctx.effect(
995
+ function* () {
996
+ yield () => {
997
+ for (const t of pendingInjectTimers) clearTimeout(t);
998
+ pendingInjectTimers.clear();
999
+ };
1000
+ },
1001
+ "dsh-rule-engine inject timers"
1002
+ );
1003
+
733
1004
  // 1. 单调守卫:工具调用先裁决,命中即物理拒绝
734
1005
  ctx.effect(
735
1006
  function* () {
736
1007
  yield ctx.tools.guard((exec) => {
737
- const hit = guardDecision(state, exec, Date.now());
1008
+ // 落盘授权粒度提醒(机制批 M7,2026-08-24):受保护文件统一入口调用 + 本回合用户消息为方案性指令
1009
+ //(调整/补充/评估/建议且无落盘词)→ approval-gap 审计 + 注入提醒(规则 22 自证③:方案性指令 ≠ 落盘授权)
1010
+ {
1011
+ const cmd = exec?.arguments?.command || exec?.arguments?.code || "";
1012
+ if ((exec?.name === "pwsh" || exec?.name === "bash") && /\bdsh-manual-write\.mjs/.test(cmd)) {
1013
+ const s7 = getSessionState(state, sessionIdOfExec(exec));
1014
+ if (needsApprovalReminder(s7.turn.userText || "")) {
1015
+ audit({
1016
+ kind: "approval-gap",
1017
+ rule: "__approval-gap",
1018
+ name: "落盘授权粒度提醒",
1019
+ event: "tool/guard",
1020
+ tool: exec?.name,
1021
+ args: summarizeArgs(exec?.arguments),
1022
+ reason: "本回合用户消息为方案性指令(调整/补充/评估/建议等),不构成落盘授权(规则 22 自证③)——落盘前应先展示→用户确认",
1023
+ session: sessionIdOfExec(exec)
1024
+ });
1025
+ maybeInject(ctx, sessionIdOfExec(exec), {
1026
+ ruleId: "__approval-gap",
1027
+ reason: "规则 22 自证③:方案性指令不构成落盘授权——受保护文件落盘需先展示改动→用户明确确认→再执行(机制批 M7)"
1028
+ });
1029
+ }
1030
+ }
1031
+ }
1032
+ // 逃生门窗口打标(机制批 M2):bypass 生效期间一切变更类调用记 bypass-action 审计(用途留痕,供规则 1⑤ 核对)
1033
+ if (state.bypassUntil > Date.now() && !isReadOnlyTool(exec?.name, exec?.arguments)) {
1034
+ audit({
1035
+ kind: "bypass-action",
1036
+ rule: "__escape-gate",
1037
+ name: "逃生门窗口变更",
1038
+ event: "tool/guard",
1039
+ tool: exec?.name,
1040
+ args: summarizeArgs(exec?.arguments),
1041
+ reason: "窗口内变更(逃生门用途:引擎/规则/手册修复,规则 1⑤ 范围核对)",
1042
+ session: sessionIdOfExec(exec)
1043
+ });
1044
+ }
1045
+ const hit = guardDecision(state, exec, Date.now(), { audit });
738
1046
  if (hit) {
739
1047
  audit({
740
1048
  kind: "deny",
@@ -743,9 +1051,25 @@ export function apply(ctx) {
743
1051
  event: "tool/guard",
744
1052
  tool: exec?.name,
745
1053
  args: summarizeArgs(exec?.arguments),
746
- reason: hit.reason
1054
+ reason: hit.reason,
1055
+ session: sessionIdOfExec(exec) // P0-4:deny 审计补 session(2026-08-24 事故复盘:归属会话靠猜)
747
1056
  });
748
1057
  state.lastActive = [{ ruleId: hit.ruleId, title: hit.title, reason: hit.reason }];
1058
+ // 规则 1 计数解耦(2026-08-24,P1-7):引擎 deny 不计入“同工具连续失败”
1059
+ const dkey = `${exec?.name}:${JSON.stringify(exec?.arguments || {})}`;
1060
+ if (!state.deniedKeys) state.deniedKeys = new Set();
1061
+ state.deniedKeys.add(dkey);
1062
+ if (state.deniedKeys.size > 500) {
1063
+ const oldest = state.deniedKeys.keys().next().value;
1064
+ if (oldest !== undefined) state.deniedKeys.delete(oldest);
1065
+ }
1066
+ // 弹窗消减(2026-08-24):E3/ask 节流命中后注入纠正,提示改用普通文本,勿连环弹窗
1067
+ if (hit.ruleId === "__already-authorized" || hit.ruleId === "__ask-rejected" || hit.ruleId === "__ask-throttle") {
1068
+ maybeInject(ctx, sessionIdOfExec(exec), {
1069
+ ruleId: hit.ruleId,
1070
+ reason: `${hit.title}:已有授权或询问被拒时请直接执行、或用普通文本说明,不要再弹窗 ask。`
1071
+ });
1072
+ }
749
1073
  return hit.reason;
750
1074
  }
751
1075
  return undefined;
@@ -775,6 +1099,34 @@ export function apply(ctx) {
775
1099
  return next();
776
1100
  });
777
1101
 
1102
+ // 0.9 机制 B(2026-08-24):未归类工具(新插件/新工具)首调 deny——fail-closed,防"approval 层自动放行 ask"的洞。
1103
+ // 批次 3(2026-08-24 用户拍板分支 A):实测未知工具首调 ask 被 DSH approval 层自动放行(无弹窗直接执行),
1104
+ // 因此 unknown → 物理 deny;批准路径 = 用户明确"允许使用 X"(handleSessionEvent 写入会话白名),本会话内放行。
1105
+ ctx.on("tools/pre-execute", async (exec, next) => {
1106
+ try {
1107
+ if (SAFE_UNCOVERED_TOOLS.has(exec?.name)) return next(); // 控制类不进 unknown 判定(避免 ask 自身被 ask)
1108
+ if (toolClass(exec?.name, exec?.arguments || {}) !== "unknown") return next();
1109
+ if ((state.unknownToolApproved || new Set()).has(exec.name)) return next(); // 会话内用户已批准
1110
+ audit({
1111
+ kind: "unknown-tool",
1112
+ rule: "__unknown-tool",
1113
+ name: "未归类工具拦截",
1114
+ event: "tools/pre-execute",
1115
+ tool: exec?.name,
1116
+ args: summarizeArgs(exec?.arguments),
1117
+ reason: `工具 ${exec.name} 不在规则引擎分类表(可能是新装的插件):已拦截(unknown→deny)。批准路径:用户明确"允许使用 ${exec.name}"后本会话放行;长期规则请补充工具分类表`,
1118
+ session: sessionIdOfExec(exec)
1119
+ });
1120
+ return {
1121
+ kind: "deny",
1122
+ reason: `工具 ${exec.name} 尚未归类(新装插件?):已拦截。如需使用请向用户说明用途并获取其"允许使用 ${exec.name}"的明确答复后重试(批准后本会话内放行)。`
1123
+ };
1124
+ } catch (error) {
1125
+ ctx.logger?.warn?.("[dsh-rule-engine] unknown-tool deny error", error);
1126
+ return next();
1127
+ }
1128
+ });
1129
+
778
1130
  // 1.1 任务契约 ask 通道:仅在总开关 + askEnabled 时对“应询问”动作返回 ask
779
1131
  ctx.on("tools/pre-execute", async (exec, next) => {
780
1132
  try {
@@ -784,6 +1136,19 @@ export function apply(ctx) {
784
1136
  if (!isArmed(s.contract, state.taskContract)) return next();
785
1137
  const action = classifyAction(exec?.name, exec?.arguments);
786
1138
  const dec = decideContractAction({ contract: s.contract, action, config: state.taskContract });
1139
+ // 批次 5.5(2026-08-24):委托启动即计预算——原 tool/result 扣减因 subagent 无 pendingCall 从未执行(agents 恒 0)
1140
+ if (dec.outcome === "allow" && action.mutability === "delegate" && action.delegationCount > 0) {
1141
+ s.contract = consumeDelegateBudget(s.contract, action.delegationCount);
1142
+ audit({
1143
+ kind: "task-budget",
1144
+ rule: "__task-contract",
1145
+ name: "子代理预算扣减",
1146
+ event: "tools/pre-execute",
1147
+ tool: exec?.name,
1148
+ reason: `已用 ${s.contract.agentsUsed}/${s.contract.agentBudget}`,
1149
+ session: sid
1150
+ });
1151
+ }
787
1152
  if (dec.outcome === "ask") {
788
1153
  audit({
789
1154
  kind: "task-ask",
@@ -797,6 +1162,24 @@ export function apply(ctx) {
797
1162
  });
798
1163
  return { kind: "ask", reason: `${dec.reason}(任务契约|${dec.nextStep || "请确认或使用放行词"})` };
799
1164
  }
1165
+ // 可观测性(阶段 0,2026-08-24):armed 下 deny/allow 裁决也留痕,CI 澄清"契约 deny 有无真实拦截路径"
1166
+ if (dec.outcome === "deny" || dec.outcome === "allow") {
1167
+ audit({
1168
+ kind: "contract-decision",
1169
+ rule: "__task-contract",
1170
+ name: "任务契约裁决",
1171
+ event: "tools/pre-execute",
1172
+ tool: exec?.name,
1173
+ args: summarizeArgs(exec?.arguments),
1174
+ reason: `裁决 ${dec.outcome}|${dec.reasonCode}|${dec.reason}`,
1175
+ session: sid
1176
+ });
1177
+ }
1178
+ // 批次 4(2026-08-24):契约 deny 真实物理拦截(原仅审计 + next()——armed 下出界写/
1179
+ // 无界委托/预算超/模式禁止变更均无实际效力)
1180
+ if (dec.outcome === "deny") {
1181
+ return { kind: "deny", reason: `${dec.reason}(任务契约|${dec.nextStep || "请调整契约或停止该操作"})` };
1182
+ }
800
1183
  return next();
801
1184
  } catch (error) {
802
1185
  ctx.logger?.warn?.("[dsh-rule-engine] tools/pre-execute error", error);