dsh-rule-engine 0.5.11 → 0.5.13

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
@@ -1,1663 +1,1788 @@
1
- // dsh-rule-engine —— DSH 规则执行引擎 v3(host 插件,纯 Node)
2
- // 容器:解析 AGENTS.md → 理解器 → 匹配机 → 执行框架。
3
- // 执行框架:ctx.tools.guard() 硬拦 + session/event 文本纠察 + 审计台账 + /guard 命令。
4
- import { readFileSync, watch, writeFileSync } from "node:fs";
5
- import { randomUUID } from "node:crypto";
6
- import { audit, readAuditLog } from "./core/audit.js";
7
- import { loadPluginConfig } from "./core/config.js";
8
- import { guardDecision } from "./core/guard-core.js";
9
- import { computeBaselineGap, loadBaselineFile, BASELINE_COMPLETE_RE } from "./core/baseline.js";
10
- import {
11
- activateForAssistant,
12
- activateForToolCall,
13
- activateForUserMessage
14
- } from "./core/matcher.js";
15
- import {
16
- auditOutputFailed,
17
- auditOutputPassed,
18
- backupPathsFromTool,
19
- isAssemblyMutationTool,
20
- isAuditCommand,
21
- isBackupCommand,
22
- isBackupTool,
23
- isGetDateCommand,
24
- isManualReadTool,
25
- isReadOnlyTool,
26
- matchKnownPitfall,
27
- needsApprovalReminder,
28
- PRIVATE_NETWORK_RE,
29
- setWorkspaceRoot,
30
- setWorkspaceRoots,
31
- setSessionWorkspaceRoot
32
- } from "./core/patterns.js";
33
- import {
34
- askQuestionCoreText,
35
- askQuestionText,
36
- askResultApproved,
37
- askResultRejected,
38
- askResultSelectedText,
39
- classifyAskScopeType,
40
- inferPathPrefixFromText,
41
- inferTypeFromText,
42
- isAuthMessage,
43
- isDirectiveMessage,
44
- isQuestionMessage,
45
- isSessionWideAskText,
46
- scopesFromIntents
47
- } from "./core/authorization.js";
48
- import { isEngineInjectedMessage, isInjectedContextMessage, isKnownSystemInjection, parseUserIntents, stripInjectedContext } from "./core/intent.js";
49
- import { enrichIntentWithLlm } from "./core/llm-intent.js";
50
- import {
51
- applyTaskContractConfig,
52
- clearAuthorizations,
53
- getSessionState,
54
- maybeReloadIfChanged,
55
- recordAuthorization,
56
- recordBackup,
57
- reloadRules,
58
- resetTurn
59
- } from "./core/state.js";
60
- import { toolClass } from "./core/tool-catalog.js";
61
- import { parseWhitelist, mergeWhitelist, serializeWhitelist } from "./core/whitelist.js";
62
- import { state } from "./core/runtime.js";
63
- import { DELIVERY_RE, detectViolations, extractAssistantText } from "./core/text-detect.js";
64
- import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
65
- import { judgeViolation, judgeViolationsBatch } from "./core/judge.js";
66
- import {
67
- applyContract,
68
- contractSummary,
69
- decideContractAction,
70
- consumeDelegateBudget,
71
- defaultContract,
72
- isArmed,
73
- isObserving,
74
- naturalMode,
75
- parseBudgetCommand,
76
- parseModeCommand
77
- } from "./core/contract.js";
78
- import {
79
- classifyAction,
80
- detectOverengineeringText,
81
- isRepeatedTaskAction,
82
- recordAction
83
- } from "./core/overengineering.js";
84
- import { writeUnderstanding } from "./core/understanding-store.js";
85
- import { agentsFilePath, auditFilePath, verifyFilePath, toolsWhitelistFilePath } from "./core/paths.js";
86
- import { isVersionedFile, validateEditedFile } from "./core/version-guard.js";
87
- import { computeMountSignature, profileNameFromArgs } from "./core/mount-signature.js";
88
- import { detectSilentError, extractToolOutput } from "./core/silent-error.js";
89
- import { enrichRulesWithLlm } from "./core/llm-understander.js";
90
- import { analyzeCoverage } from "./core/understander.js";
91
-
92
- export const name = "dsh-rule-engine";
93
- export const inject = ["tools", "commands", "agents", "workspaceRegistry", "skills", "llm"];
94
-
95
- const pluginConfig = loadPluginConfig();
96
- state.enabled = pluginConfig.enabled;
97
- applyTaskContractConfig(state, pluginConfig);
98
- // reloadRules 内部已统一刷新理解产物(P0-3),此处不再重复写
99
- reloadRules(state);
100
-
101
- // ── 工具函数 ────────────────────────────────────────────────────────────────
102
-
103
- /** 统计 substring 出现次数(0.5.11 唯一性前提;与 guard-core countOccurrences 同语义) */
104
- function countOccurrencesStr(text, sub) {
105
- if (typeof text !== "string" || typeof sub !== "string" || sub.length === 0) return 0;
106
- let count = 0;
107
- let idx = text.indexOf(sub);
108
- while (idx !== -1) {
109
- count++;
110
- idx = text.indexOf(sub, idx + sub.length);
111
- }
112
- return count;
113
- }
114
-
115
- function summarizeArgs(args) {
116
- try {
117
- const s = JSON.stringify(args ?? {});
118
- return s.length > 300 ? s.slice(0, 300) + "..." : s;
119
- } catch {
120
- return String(args);
121
- }
122
- }
123
-
124
- function remainMs(until) {
125
- return Math.max(0, until - Date.now());
126
- }
127
-
128
- function fmtRemain(ms) {
129
- if (ms <= 0) return "";
130
- const min = Math.floor(ms / 60000);
131
- const sec = Math.floor((ms % 60000) / 1000);
132
- return min > 0 ? `${min} 分 ${sec} 秒` : `${sec} 秒`;
133
- }
134
-
135
- function parseArgs(raw) {
136
- if (typeof raw === "string") {
137
- try {
138
- return JSON.parse(raw);
139
- } catch {
140
- return { raw };
141
- }
142
- }
143
- return raw || {};
144
- }
145
-
146
- function sessionIdOfExec(exec) {
147
- const agent = exec?.agent;
148
- if (!agent) return "global";
149
- if (typeof agent.session === "object" && agent.session?.id) return agent.session.id;
150
- if (typeof agent.session === "string") return agent.session;
151
- return "global";
152
- }
153
-
154
- /**
155
- * 从 user/message 事件提取用户文本。
156
- * 2026-08-24 根因修复:官方结构(@deepseek-ai/dsh-session/surface)中
157
- * event.data 就是消息对象本身(deriveEventMessage case 'user/message' 返回 event.data),
158
- * 并非 data.message——旧代码取 d.message 导致永远提取空文本:
159
- * 旧行为 空文本 parseUserIntents("") 非空对象 → 规则 22 无故全拦;
160
- * 中间版 if (!text) return 跳过 → 全放行(漏拦);
161
- * 正解 = 取对字段;兼容 data 与 data.message 两种历史形态。
162
- */
163
- export function extractUserText(message) {
164
- if (!message) return "";
165
- if (typeof message === "string") return message;
166
- const obj = typeof message === "object" && !Array.isArray(message)
167
- ? (message.content !== undefined ? message : message.message)
168
- : message;
169
- const content = obj?.content;
170
- if (typeof content === "string") return content;
171
- if (Array.isArray(content)) {
172
- return content
173
- .map((b) => (b && typeof b === "object" && b.type === "text" ? b.text : ""))
174
- .join("\n");
175
- }
176
- return "";
177
- }
178
-
179
- // ── session/event 处理 ──────────────────────────────────────────────────────
180
-
181
- // O1 注入通道修复(2026-08-24,批次 6):
182
- // 根因——maybeInject 在 session/event 观察回调内同步调用 agent.inject,而 inject 底层是
183
- // 同步 session.append("agent/inbox/spliced");dsh-session 对同一 session 的 append 有
184
- // 同步重入保护(entry.appending "session append cannot reenter while another append
185
- // is being published"),观察回调正处在 append 发布边界内 必然抛错(README 局限 6 记录:
186
- // 注入从未到达模型/界面,仅审计留痕)。
187
- // 修复——投递延迟到当前 append 发布边界之后(宏任务 setTimeout 0),语义不变
188
- // (inject 官方语义本就是 "queue context for the next pre-step, without waking");
189
- // 插件卸载/热重载时统一清理 pending 定时器。
190
- const pendingInjectTimers = new Set();
191
-
192
- function maybeInject(ctx, sessionId, violation) {
193
- if (!pluginConfig.correctInject) return;
194
- const key = `${sessionId}:${violation.ruleId}`;
195
- // D1(2026-08-28 阶段三):注入文案命令词静态检查——[规则引擎] 注入若含"请直接执行/不要再…
196
- // 请立即…"类命令式短语,会驱动模型越过用户直接行动(实弹:节流注入后模型跳过用户回复);
197
- // 注入只许陈述事实。命中 = 拒绝投递 + 审计留痕(防本次修复被下次改文案时回潮)。
198
- const INJECT_COMMAND_RE = /请直接执行|请立即|不要再|勿再|请马上|现在就做|立刻执行|直接执行/;
199
- if (INJECT_COMMAND_RE.test(String(violation?.reason || ""))) {
200
- audit({
201
- kind: "inject-command-gated",
202
- rule: violation.ruleId,
203
- name: "注入文案命令词拦截(D1)",
204
- event: "inject",
205
- reason: `注入文案含命令式短语,已拒绝投递(只许陈述事实):${String(violation.reason || "").slice(0, 100)}`,
206
- session: sessionId
207
- });
208
- return;
209
- }
210
- // v0.5.7 投递资格闸(用户拍板"提醒一次并记住 + 会话每小时 3 条预算",2026-08-26):
211
- // 同规则同会话仅投递一次(__self-cert 聚合除外),会话每小时至多 3 条。
212
- // 被拦的违规仍写审计(inject-skip)——只有"弹进对话"被省掉,审计完整性不降级。
213
- const gate = shouldDeliver({
214
- now: Date.now(),
215
- ruleId: violation.ruleId,
216
- firstAt: state.injectAt.get(key) ?? null,
217
- budgetTimes: state.injectBudget.get(sessionId) || []
218
- });
219
- if (!gate.ok) {
220
- audit({
221
- kind: "inject-skip",
222
- rule: violation.ruleId,
223
- name: "投递资格拦截",
224
- event: "inject",
225
- reason: `未投递(${gate.reason}):${String(violation.reason || "").slice(0, 80)}`,
226
- session: sessionId
227
- });
228
- return;
229
- }
230
- state.injectAt.set(key, Date.now());
231
- const budgetArr = state.injectBudget.get(sessionId) || [];
232
- budgetArr.push(Date.now());
233
- state.injectBudget.set(sessionId, budgetArr.slice(-20));
234
- const count = state.injectCounts.get(key) || 0;
235
- state.injectCounts.set(key, count + 1);
236
- // C2 统计:注入(injected)计数(实际投递尝试,上限内)
237
- {
238
- const sStat = getSessionState(state, sessionId);
239
- const st = (sStat.ruleStats ||= {});
240
- const k = (st[violation.ruleId] ||= { detected: 0, suppressed: 0, injected: 0 });
241
- k.injected++;
242
- }
243
- const timer = setTimeout(() => {
244
- pendingInjectTimers.delete(timer);
245
- try {
246
- const agent = ctx.agents.get(sessionId);
247
- if (agent && typeof agent.inject === "function") {
248
- // 2026-08-24 批次 6 收尾(O1 实弹观测):注入消息必须具备官方身份(id+role)——
249
- // dsh-llm 官方 createUserMessage 生成 { id: randomUUID(), role: "user", content, source };
250
- // 无 id 时 Inbox.validate 用 undefined 作身份,同批第二条起抛
251
- // `message "undefined" is already pending`(实弹 17:56:37 八连发观测)。
252
- agent.inject({
253
- id: `re-${randomUUID()}`,
254
- role: "user",
255
- content: [
256
- {
257
- type: "text",
258
- text: `[规则引擎] ${violation.reason}(规则 ${violation.ruleId},已记入 /guard log;下次回复请自证/纠正)`
259
- }
260
- ],
261
- source: { kind: "plugin", plugin: name }
262
- });
263
- audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入已投递(agent=${sessionId}):${String(violation.reason || "").slice(0, 120)}`, session: sessionId });
264
- } else {
265
- // D2(2026-08-28 阶段三):不可投递 agent(子代理/已销毁会话)——仅审计降噪留痕,
266
- // reason 注明"预期降噪"便于 /guard log 区分真实失败;不再产生可见投递噪音。
267
- audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入未投递(D2 预期降噪):agents.get(${sessionId}) 无可用 agent.inject——仅审计留痕`, session: sessionId });
268
- }
269
- } catch (error) {
270
- audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入异常:${error instanceof Error ? error.message : String(error)}`, session: sessionId });
271
- }
272
- }, 0);
273
- pendingInjectTimers.add(timer);
274
- }
275
-
276
- /**
277
- * v0.5.7:D 级嫌疑异步裁决并投递(用户拍板"只有错误的行为才值得被提醒")。
278
- * 裁决函数可注入(state.judgeFn,测试用 stub;生产 = judge.js LLM 实现)。
279
- * 结果审计:judge-pass(确认违规→投递)/ judge-false(合规声明/引述→不投)/
280
- * judge-unavailable(失败/超时/预算满→fail-closed 不投)——三类全部留 /guard log 可对质。
281
- */
282
- async function deliverSuspects(ctx, sessionId, suspects, text) {
283
- // 0.5.11(用户定稿,预算打穿修复):一次回复的全部嫌疑 = 一次批量裁决(一次 LLM/一条预算)
284
- // 此前逐条 judgeViolation(每条一次调用/一条预算)导致 50/日早上耗尽(unavailable 89 条实证)。
285
- const judgeFn = state.judgeFn || judgeViolation;
286
- // 注入版/单条路径保持兼容:state.judgeFn(测试 stub)或仅 1 条嫌疑 → 单项路径
287
- if (suspects.length <= 1 || state.judgeFn) {
288
- const results = await Promise.all(suspects.map(async (v) => {
289
- try {
290
- const verdict = await judgeFn(ctx, state, sessionId, v, text);
291
- const kind = verdict.action === "deliver" ? "judge-pass"
292
- : verdict.action === "suppress" ? "judge-false"
293
- : "judge-unavailable";
294
- audit({
295
- kind,
296
- rule: v.ruleId,
297
- name: "违规裁决",
298
- event: "assistant/message",
299
- reason: `${verdict.action}:${verdict.note || ""}${verdict.model ? `(model=${verdict.model})` : ""}`,
300
- session: sessionId
301
- });
302
- return verdict.action === "deliver" ? v : null;
303
- } catch (error) {
304
- audit({
305
- kind: "judge-unavailable",
306
- rule: v.ruleId,
307
- name: "裁决异常",
308
- event: "assistant/message",
309
- reason: `未投递:${error instanceof Error ? error.message : String(error)}`,
310
- session: sessionId
311
- });
312
- return null;
313
- }
314
- }));
315
- const deliver = results.filter(Boolean);
316
- if (deliver.length > 1) {
317
- const briefs = deliver.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`).join(";");
318
- maybeInject(ctx, sessionId, {
319
- ruleId: "__self-cert",
320
- reason: `裁决通过 ${deliver.length} 项:${briefs}——请按 /guard log 明细应对`
321
- });
322
- } else if (deliver.length === 1) {
323
- maybeInject(ctx, sessionId, deliver[0]);
324
- }
325
- return;
326
- }
327
- // 批量路径:一次 judgeViolationsBatch(一次调用/一次预算/逐条判定)
328
- const batch = await judgeViolationsBatch(ctx, state, sessionId, suspects, text);
329
- const deliver = [];
330
- for (const { violation: v, verdict } of batch) {
331
- const kind = verdict.action === "deliver" ? "judge-pass"
332
- : verdict.action === "suppress" ? "judge-false"
333
- : "judge-unavailable";
334
- audit({
335
- kind,
336
- rule: v.ruleId,
337
- name: "违规裁决",
338
- event: "assistant/message",
339
- reason: `${verdict.action}:${verdict.note || ""}${verdict.model ? `(model=${verdict.model})` : ""}`,
340
- session: sessionId
341
- });
342
- if (verdict.action === "deliver") deliver.push(v);
343
- }
344
- if (deliver.length > 1) {
345
- const briefs = deliver.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`).join(";");
346
- maybeInject(ctx, sessionId, {
347
- ruleId: "__self-cert",
348
- reason: `裁决通过 ${deliver.length} 项:${briefs}——请按 /guard log 明细应对`
349
- });
350
- } else if (deliver.length === 1) {
351
- maybeInject(ctx, sessionId, deliver[0]);
352
- }
353
- }
354
-
355
- async function refreshSkills(ctx) {
356
- try {
357
- const list = await ctx.skills.list();
358
- state.skillNames = new Set((list || []).map((s) => s && s.name).filter(Boolean));
359
- } catch {
360
- // 技能目录不可用时保留旧缓存,不阻断
361
- }
362
- }
363
-
364
- /** v0.5.7 P0-4:验证通过记录持久化(文件损坏/写失败不阻断运行时) */
365
- function persistVerifyPass(state) {
366
- try {
367
- writeFileSync(verifyFilePath(), JSON.stringify((state.verifyPass || []).slice(-100)), "utf8");
368
- } catch {
369
- // 持久化失败仅降级(内存态仍有效),不阻断
370
- }
371
- }
372
-
373
- /** v0.5.7 后续(用户批准):unknown 工具白名单持久化(~/.dsh/rule-engine-tools.json——"允许使用 X"重启/热重载不丢)
374
- * 0.5.9 升级:存储格式 v2 带元数据 [{name,time,session}]——可视化"谁、何时、哪个会话放行";
375
- * 旧格式(字符串数组)兼容读取;持久化时合并既有 meta(历史条目不丢时间/来源)。 */
376
- function persistToolWhitelist(state, sid) {
377
- try {
378
- const names = [...(state.unknownToolApproved || [])];
379
- const prev = readWhitelistMeta();
380
- const rows = mergeWhitelist([...prev.values()], names, sid);
381
- writeFileSync(toolsWhitelistFilePath(), serializeWhitelist(rows), "utf8");
382
- } catch {
383
- // 持久化失败仅降级(内存态仍有效),不阻断
384
- }
385
- }
386
-
387
- /** 读取白名单文件并按元数据归 Map(v1 字符串数组 → {name,time:null,session:null};v2 对象数组原样) */
388
- function readWhitelistMeta() {
389
- const m = new Map();
390
- let raw = null;
391
- try { raw = readFileSync(toolsWhitelistFilePath(), "utf8"); } catch {}
392
- for (const row of parseWhitelist(raw || "")) m.set(row.name, row);
393
- return m;
394
- }
395
-
396
- export function handleSessionEvent(ctx, session, event) {
397
- maybeReloadIfChanged(state);
398
- const sid = session?.id || "global";
399
- const s = getSessionState(state, sid);
400
- const d = event.data || {};
401
- if (event.type === "turn/start") {
402
- resetTurn(state, sid, d.turn);
403
- return;
404
- }
405
- if (event.type === "turn/end") {
406
- // M8 双通道机制:本回合用 dsh-manual-write 落盘手册/AGENTS 后,必须同轮 engram_store 沉淀记忆;
407
- // 缺失 审计 + 注入纠正(用户明确要求机制化,2026-08-24)
408
- if (s.turn.manualWriteSeen && !s.turn.engramStoreSeen) {
409
- audit({
410
- kind: "engram-gap",
411
- rule: "__engram-gap",
412
- name: "双通道记忆缺失",
413
- event: "turn/end",
414
- reason: "本回合 dsh-manual-write 落盘了手册/AGENTS,但未同轮调用 engram_store;按规则 19/77/M8 应在同一回合完成记忆沉淀",
415
- session: sid
416
- });
417
- maybeInject(ctx, sid, {
418
- ruleId: "__engram-gap",
419
- reason: "规则 19/M8:手册/AGENTS 落盘后应在同一回合补 engram_store,否则记忆机制断链(已记审计)"
420
- });
421
- }
422
- // F1(2026-08-28 阶段三):规则 2 时序竞态修复——assistant/message 检测到时间词违规时
423
- // 不立即投递(Get-Date 工具常在本回合后续步骤才执行,事故实弹:correct 早于 Get-Date 放行),
424
- // 标记 pendingRule2,turn/end 复核:getDateSeen 已定案——若本回合最终调用过 Get-Date,撤销(不投递)。
425
- const p2 = s.turn.pendingRule2;
426
- if (p2) {
427
- if (s.turn.getDateSeen) {
428
- audit({
429
- kind: "rule2-resolved",
430
- rule: "2",
431
- name: "规则2回合末复核(已核对)",
432
- event: "turn/end",
433
- reason: "回合内最终已调用 Get-Date(getDateSeen=true)——assistant/message 时误报撤销,不投递",
434
- session: sid
435
- });
436
- } else {
437
- audit({
438
- kind: "correct",
439
- rule: "2",
440
- name: "时间信息须真实(执行等级:B + D)",
441
- event: "turn/end",
442
- reason: p2
443
- });
444
- maybeInject(ctx, sid, { ruleId: "2", reason: p2 });
445
- }
446
- s.turn.pendingRule2 = null;
447
- }
448
- return;
449
- }
450
- if (event.type === "user/message") {
451
- // 机制 A(2026-08-24):source.kind 主判——非 user 来源(官方 agent-instructions/skill-catalog、
452
- // 插件自定义 kind)一律不进授权池、不覆盖回合状态;审计留痕可对账(source-skip)。
453
- const srcKind = d?.source?.kind;
454
- if (typeof srcKind === "string" && srcKind !== "user") {
455
- const preview = extractUserText(d).slice(0, 80);
456
- audit({
457
- kind: "source-skip",
458
- rule: "__source-skip",
459
- name: "非用户来源消息跳过",
460
- event: "user/message",
461
- reason: `source.kind=${srcKind}:${preview}`,
462
- session: sid
463
- });
464
- return;
465
- }
466
- // 系统注入剥离(2026-08-24 v2):注入与真实用户消息合并为同一事件
467
- //(官方注入机制将 <system-reminder> 烤进 content;剥离后剩余文本 = 用户消息)。
468
- // 2026-08-24 根因修复:event.data 即消息对象(官方 surface 结构),不再取 d.message。
469
- const text = stripInjectedContext(extractUserText(d));
470
- if (!text) return;
471
- // 引擎自身注入的 [规则引擎] 提示不是用户消息,跳过——否则其中的“请直接执行”会被记录成授权(自我续授权漏洞)
472
- if (isEngineInjectedMessage(text)) return;
473
- // 模板兜底(对 kind=user 同样生效,防"标 user 的注入"——super-injector createUserMessage 同类风险):
474
- // runtime context 快照 / 子代理完成通知 / vision-router 挂载提醒等已知注入模板 → 整体跳过
475
- if (isKnownSystemInjection(text)) {
476
- audit({
477
- kind: "source-skip",
478
- rule: "__source-skip",
479
- name: "已知系统注入模板跳过",
480
- event: "user/message",
481
- reason: `文本模板命中:${text.slice(0, 80)}`,
482
- session: sid
483
- });
484
- return;
485
- }
486
- // 批次 3(2026-08-24):用户明确"允许使用 X"→ 未归类工具会话白名(unknown→deny 的唯一批准路径)
487
- if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
488
- for (const hit of text.matchAll(/(?:允许|批准|同意)\s*(?:使用|调用|用|启用|放行)\s*([A-Za-z_][A-Za-z0-9_:.-]*)/g)) {
489
- const toolName = hit[1];
490
- if (toolClass(toolName, {}) !== "unknown") continue; // 只对未归类工具有意义(已知工具不走 unknown 分支)
491
- const isNew = !state.unknownToolApproved.has(toolName);
492
- state.unknownToolApproved.add(toolName);
493
- if (isNew) {
494
- if (!state.unknownToolSessionAdded) state.unknownToolSessionAdded = new Set();
495
- state.unknownToolSessionAdded.add(toolName); // /guard tools 区分"本会话新增"
496
- }
497
- persistToolWhitelist(state, sid); // v0.5.7 后续:白名单持久化(重启/热重载不丢——用户批准"这句不用每次发")
498
- audit({
499
- kind: "unknown-tool-whitelist",
500
- rule: "__unknown-tool",
501
- name: "用户批准未归类工具",
502
- event: "user/message",
503
- reason: `用户明确允许使用 ${toolName}${isNew ? "" : "(重复,忽略)"} → 会话白名`,
504
- session: sid
505
- });
506
- }
507
- s.lastUserText = text;
508
- s.turn.userText = text;
509
- s.turn.realUserSeen = true; // v0.5.7:真实用户在场(注入轮不检测的依据——乒乓根治)
510
- s.turn.questionOnly = isQuestionMessage(text);
511
- s.turn.intents = parseUserIntents(text);
512
- // 规则 22 粒度升级(2026-08-24):本回合授权范围 = 各 execute 子句推导出的 type+path 范围
513
- s.turn.scopes = scopesFromIntents(s.turn.intents);
514
- // LLM 意图兜底(方案 A):低置信/歧义消息异步预取,不阻塞事件流;失败自动降级词表
515
- // A2(阶段一):promise 记录到 turn——pre-execute 拦截点可同步等待,防"预取未回就按词表拦"竞态
516
- if (pluginConfig.llmIntent?.enabled) {
517
- const p = enrichIntentWithLlm(ctx, state, sid, text, pluginConfig.llmIntent);
518
- s.turn.llmIntentPromise = p;
519
- }
520
- if (state.taskContract?.taskContractEnabled) {
521
- const patch = naturalMode(text, s.contract);
522
- if (patch) {
523
- const res = applyContract(s.contract, patch);
524
- if (res.changed) {
525
- s.contract = res.contract;
526
- audit({
527
- kind: "task-contract",
528
- rule: "__task-contract",
529
- name: "任务契约更新",
530
- event: "user/message",
531
- reason: `${patch.source}: ${contractSummary(s.contract)}`,
532
- session: sid
533
- });
534
- }
535
- }
536
- }
537
- const intents = s.turn.intents;
538
- let recordedAuth = false;
539
- if (intents?.clauses?.some((c) => c.type === "execute")) {
540
- // 与规则 22 粒度保持一致:按 scopesFromIntents 的多 type×多 path 逐条记录 12A 授权,
541
- // 避免“规则 22 放行、12A 却因只记了单 type/单路径而拦截”的不一致(2026-08-24 第二轮修复)
542
- const execScopes = s.turn.scopes && s.turn.scopes.length > 0 ? s.turn.scopes : scopesFromIntents(intents);
543
- for (const scope of execScopes) {
544
- recordAuthorization(state, sid, {
545
- type: scope.type,
546
- pathPrefix: scope.pathPrefix,
547
- source: "user-message-clause",
548
- clauseId: scope.clauseId
549
- });
550
- }
551
- recordedAuth = true;
552
- audit({ kind: "auth", rule: "12D", name: "用户消息执行子句授权", event: "user/message", reason: `记录执行分点授权:${text.slice(0, 120)}`, session: sid });
553
- } else if (isAuthMessage(text) || isDirectiveMessage(text)) {
554
- recordAuthorization(state, sid, {
555
- type: inferTypeFromText(text),
556
- pathPrefix: inferPathPrefixFromText(text),
557
- source: "user-message"
558
- });
559
- recordedAuth = true;
560
- audit({ kind: "auth", rule: "12D", name: "用户消息授权", event: "user/message", reason: `记录用户消息授权:${text.slice(0, 120)}`, session: sid });
561
- }
562
- const active = activateForUserMessage(state.configs, text);
563
- if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: "用户消息命中触发词" }));
564
- return;
565
- }
566
- if (event.type === "tool/call") {
567
- const toolName = String(d.name || "");
568
- const args = parseArgs(d.arguments);
569
- s.turn.toolCount++;
570
- if (s.turn.toolCount === 1) s.turn.firstToolName = toolName;
571
- if (toolName === "ask_user_question") {
572
- s.turn.askSeen = true;
573
- s.turn.pendingAsk = { callId: d.callId, questions: args?.questions || [] };
574
- }
575
- const pendingCall = { name: toolName, args };
576
- const targetPath = args?.file_path ?? args?.path;
577
- if ((toolName === "edit" || toolName === "write" || (toolName === "str_replace_editor" && args?.command !== "view")) && targetPath && isVersionedFile(targetPath)) {
578
- try {
579
- pendingCall.originalContent = readFileSync(targetPath, "utf8");
580
- } catch {
581
- pendingCall.originalContent = null;
582
- }
583
- }
584
- s.turn.pendingToolCalls.set(d.callId, pendingCall);
585
- if (toolName === "pwsh" || toolName === "bash") {
586
- const cmd = args?.command || args?.code || "";
587
- if (isGetDateCommand(cmd)) s.turn.getDateSeen = true;
588
- // workdir 校验提示(机制批 M6):命令含显式相对路径(./ ../)但未声明 workdir 审计提示(不拦)
589
- if (cmd && /(?:^|\s)(?:\.{1,2}[\\/])/.test(cmd) && !args?.workdir) {
590
- audit({
591
- kind: "workdir-hint",
592
- rule: "__workdir-hint",
593
- name: "workdir 提示",
594
- event: "tool/call",
595
- tool: toolName,
596
- reason: "命令含相对路径但未显式声明 workdir——易与当前目录错配(机制批 M6),pws 请显式 workdir",
597
- session: sid
598
- });
599
- }
600
- const bp = backupPathsFromTool(toolName, args);
601
- if (bp) {
602
- if (!pendingCall.backupPaths) pendingCall.backupPaths = [];
603
- pendingCall.backupPaths.push(bp);
604
- }
605
- }
606
- if (toolName !== "pwsh" && toolName !== "bash") {
607
- const bpTool = backupPathsFromTool(toolName, args);
608
- if (bpTool) {
609
- if (!pendingCall.backupPaths) pendingCall.backupPaths = [];
610
- pendingCall.backupPaths.push(bpTool);
611
- }
612
- }
613
- if (isManualReadTool(toolName, args)) s.manualReadSeen = true;
614
- if (toolName === "skill" && args?.name) s.turn.skillNames.push(args.name);
615
- s.turn.toolNames.push(toolName);
616
- if (state.taskContract?.taskContractEnabled) {
617
- recordAction(s, toolName, args);
618
- if (isObserving(s.contract, state.taskContract)) {
619
- const action = classifyAction(toolName, args);
620
- const problems = [];
621
- if (action.hashIntent && s.contract.hashPolicy !== "allow") problems.push("hash 未授权");
622
- if (action.dependencyIntent && s.contract.dependencyPolicy !== "allow") problems.push("依赖未授权");
623
- if (isRepeatedTaskAction(s, toolName, args)) problems.push("重复动作打转");
624
- if (problems.length) {
625
- audit({
626
- kind: "task-observe",
627
- rule: "__task-contract",
628
- name: "任务契约观察",
629
- event: "tool/call",
630
- tool: toolName,
631
- args: summarizeArgs(args),
632
- reason: `[观察] ${problems.join(";")}`,
633
- session: sid
634
- });
635
- }
636
- }
637
- }
638
- const active = activateForToolCall(state.configs, toolName, args);
639
- if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: `工具 ${toolName} 命中` }));
640
- return;
641
- }
642
- if (event.type === "tool/result") {
643
- const resultBlock = Array.isArray(d.message?.content)
644
- ? d.message.content.find((b) => b && b.type === "tool-result")
645
- : null;
646
- const callId = resultBlock?.toolCallId ?? d.callId;
647
- const isError = Boolean(d.error) || resultBlock?.isError === true;
648
- const pendingCall = s.turn.pendingToolCalls.get(callId);
649
- if (pendingCall) {
650
- const key = `${pendingCall.name}:${JSON.stringify(pendingCall.args || {})}`;
651
- if (isError) {
652
- // 规则 1 计数解耦(P1-7,2026-08-24):引擎 deny 来源的失败不计入“同工具连续失败”
653
- if (!((state.deniedKeys || new Set()).has(key))) {
654
- state.retryCounts.set(key, (state.retryCounts.get(key) || 0) + 1);
655
- } else {
656
- state.retryCounts.delete(key);
657
- }
658
- } else {
659
- state.retryCounts.delete(key);
660
- }
661
- s.turn.pendingToolCalls.delete(callId);
662
- }
663
-
664
- // 备份证据只在工具调用成功后才记录;被拦截/失败的调用不产生备份记录
665
- if (!isError && pendingCall?.backupPaths?.length) {
666
- for (const bp of pendingCall.backupPaths) {
667
- recordBackup(state, sid, bp.targetPath, bp.backupPath);
668
- }
669
- }
670
-
671
- // 0.5.10 建议5(已知坑错误码召回):错误结果文本命中特征表 → 审计 + 注入指向知识库的提示
672
- //(本轮去重;仍走 maybeInject 资格链——真实用户在场/预算内才投递,不违反注入噪音治理)
673
- if (isError) {
674
- const errText = String(
675
- resultBlock?.content?.map?.((c) => c?.text || "").join(" ") ||
676
- d?.message?.content?.map?.((c) => c?.text || "").join(" ") ||
677
- ""
678
- );
679
- const pit = matchKnownPitfall(errText);
680
- if (pit) {
681
- if (!s.turn.errorHints) s.turn.errorHints = new Set();
682
- if (!s.turn.errorHints.has(pit.key)) {
683
- s.turn.errorHints.add(pit.key);
684
- audit({
685
- kind: "error-hint",
686
- rule: "__error-hint",
687
- name: "已知坑召回",
688
- event: "tool/result",
689
- tool: pendingCall?.name || undefined,
690
- reason: `${pit.key} 命中特征表(建议5:错误码→知识库)`,
691
- session: sid
692
- });
693
- maybeInject(ctx, sid, { ruleId: "__error-hint", reason: pit.hint });
694
- }
695
- }
696
- }
697
-
698
- // M8 双通道机制(2026-08-24):dsh-manual-write 落盘成功 → 标记;同轮 engram_store 成功 → 标记
699
- if (!isError && pendingCall) {
700
- const cmd = pendingCall.args?.command || pendingCall.args?.code || "";
701
- if ((pendingCall.name === "pwsh" || pendingCall.name === "bash") && /\bdsh-manual-write\.mjs/.test(cmd)) {
702
- s.turn.manualWriteSeen = true;
703
- }
704
- if (pendingCall.name === "engram_store") {
705
- s.turn.engramStoreSeen = true;
706
- }
707
- }
708
-
709
- // 任务契约:委托预算扣减已移至 pre-execute 启动即计(批次 5.5;原 tool/result 路径因
710
- // subagent 无 pendingCall 从未执行——本块删除防双重扣减)
711
-
712
- // 规则 27:装配变更成功后全局 revision +1;审计命令输出通过/失败后更新本会话审计 revision
713
- if (pendingCall) {
714
- if (!isError && isAssemblyMutationTool(pendingCall.name, pendingCall.args)) {
715
- state.mountRevision += 1;
716
- state.mountSignature = computeMountSignature(profileNameFromArgs(pendingCall.args));
717
- audit({
718
- kind: "mount-dirty",
719
- rule: "27",
720
- name: "插件装配变更",
721
- event: "tool/result",
722
- reason: `装配已变更(mountRevision=${state.mountRevision},哈希=${state.mountSignature.slice(0, 8)}),继续装配/重启前需先跑全量审计`,
723
- session: sid,
724
- tool: pendingCall.name,
725
- args: summarizeArgs(pendingCall.args)
726
- });
727
- }
728
- const auditCmd = pendingCall.args?.command || pendingCall.args?.code || "";
729
- if (isAuditCommand(auditCmd)) {
730
- const output = extractToolOutput(d);
731
- if (auditOutputFailed(output)) {
732
- audit({
733
- kind: "mount-audit-fail",
734
- rule: "27",
735
- name: "全量审计未通过",
736
- event: "tool/result",
737
- reason: "审计脚本发现 DUPLICATES FOUND",
738
- session: sid,
739
- tool: pendingCall.name
740
- });
741
- maybeInject(ctx, sid, { ruleId: "27", reason: "规则 27:全量审计未通过,先移除多余挂载再重跑审计" });
742
- } else if (isError) {
743
- // 2026-08-29 A1:审计命令未执行成功(被规则 22 拦截/工具错误,无审计输出)≠ 审计发现 DUPLICATES——
744
- // 不注入"先移除多余挂载"(误导:审计根本没执行;历史误报曾引导模型误移除正确挂载)。
745
- // 保持 dirty(审计未跑成,不知道结果);留痕 mount-audit-error 供 /guard log 排查。
746
- audit({
747
- kind: "mount-audit-error",
748
- rule: "27",
749
- name: "审计命令未执行成功",
750
- event: "tool/result",
751
- reason: "审计脚本被拦截或执行失败(无审计输出);请先处理拦截原因后重跑审计",
752
- session: sid,
753
- tool: pendingCall.name
754
- });
755
- } else if (auditOutputPassed(output)) {
756
- s.mountAuditRevision = state.mountRevision;
757
- s.mountAuditSignature = computeMountSignature(profileNameFromArgs(pendingCall.args));
758
- audit({
759
- kind: "mount-audit-pass",
760
- rule: "27",
761
- name: "全量审计通过",
762
- event: "tool/result",
763
- reason: `audit-mount-consistency 输出 MOUNT CONSISTENT(mountRevision=${state.mountRevision},装配哈希=${s.mountAuditSignature.slice(0, 8)})`,
764
- session: sid,
765
- tool: pendingCall.name
766
- });
767
- }
768
- }
769
- }
770
-
771
- // 版本文件写后自检:失败自动回滚 + 审计
772
- const versionTarget = pendingCall?.args?.file_path ?? pendingCall?.args?.path;
773
- if (!isError && pendingCall && pendingCall.originalContent != null && versionTarget && isVersionedFile(versionTarget)) {
774
- try {
775
- const current = readFileSync(versionTarget, "utf8");
776
- // 0.5.11:唯一性透传(与 guard-core 同口径——old 唯一匹配才算"单行重写"放行前提)
777
- const oldStr = pendingCall.args.old_string || pendingCall.args.old_str || "";
778
- const uniqueMatch = oldStr.length > 0 ? countOccurrencesStr(pendingCall.originalContent, oldStr) === 1 : false;
779
- const check = validateEditedFile(
780
- pendingCall.originalContent,
781
- current,
782
- oldStr,
783
- pendingCall.args.new_string || pendingCall.args.new_str || "",
784
- uniqueMatch
785
- );
786
- if (!check.ok) {
787
- writeFileSync(versionTarget, pendingCall.originalContent, "utf8");
788
- const reason = `edit 工具显示成功,但内容已被 version-guard 回滚:${check.errors.join("")}`;
789
- audit({
790
- kind: "rollback",
791
- rule: "__version-guard",
792
- name: "版本文件写后自检",
793
- event: "tool/result",
794
- reason,
795
- session: sid,
796
- file: versionTarget
797
- });
798
- maybeInject(ctx, sid, { ruleId: "__version-guard", reason });
799
- }
800
- } catch (error) {
801
- audit({
802
- kind: "rollback-error",
803
- rule: "__version-guard",
804
- name: "版本文件写后自检",
805
- event: "tool/result",
806
- reason: error instanceof Error ? error.message : String(error),
807
- session: sid
808
- });
809
- }
810
- }
811
-
812
- // 交付闸门采集(机制批 M3):测试/冷加载探针通过输出 → verify-pass 记录(供规则 23④ 声明核对)
813
- // 2026-08-25 鲁棒化(实弹观测):旧条件依赖 pendingCall(tool/call→result 的 callId 匹配),
814
- // RC.2 下疑似失配导致采集从未执行——23④ 每次声明都 verify-gap;改为只看输出文本,不依赖
815
- // pendingToolCalls 状态(what 降级记录),verify-pass 语义不变(输出即证据)。
816
- if (!isError) {
817
- const output = extractToolOutput(d);
818
- if (/\bALL TESTS PASSED\b|RESULT:\s*PASS|\bVERIFY ALL OK\b/.test(output)) {
819
- if (!state.verifyPass) state.verifyPass = [];
820
- state.verifyPass.push({ at: Date.now(), sessionId: sid, what: pendingCall?.name || "tool-result" });
821
- if (state.verifyPass.length > 100) state.verifyPass.splice(0, state.verifyPass.length - 100);
822
- persistVerifyPass(state); // v0.5.7 P0-4:持久化(热重载/重启不丢)
823
- }
824
- }
825
-
826
- // 命令输出静默错误检测(不阻断,只审计 + 注入提醒)
827
- if (!isError && pendingCall && (pendingCall.name === "pwsh" || pendingCall.name === "bash")) {
828
- const output = extractToolOutput(d);
829
- const det = detectSilentError(output, s.lastCommandOutput);
830
- if (det.suspicious) {
831
- audit({
832
- kind: "silent-error",
833
- rule: "__silent-error",
834
- name: "命令输出静默错误",
835
- event: "tool/result",
836
- reason: det.reason,
837
- session: sid,
838
- tool: pendingCall.name
839
- });
840
- maybeInject(ctx, sid, { ruleId: "__silent-error", reason: det.reason });
841
- }
842
- if (output) s.lastCommandOutput = output;
843
- }
844
-
845
- const pending = s.turn.pendingAsk;
846
- if (pending) {
847
- const result = d.result ?? d.value ?? d;
848
- if (askResultApproved(result)) {
849
- s.turn.realUserSeen = true; // v0.5.7:ask 弹窗答复也算"真实用户在场"
850
- const qText = askQuestionText(pending.questions);
851
- const selectedText = askResultSelectedText(result);
852
- // 授权范围只取“问题核心 + 用户实际选择”,不再把全部选项描述纳入;
853
- // 避免“仅会话内说明/不跨会话保留”等选项说明把一次小授权放大成全局 12h(2026-08-24 修复)
854
- const scopeText = `${askQuestionCoreText(pending.questions)} ${selectedText}`.trim();
855
- const pathPrefix = inferPathPrefixFromText(scopeText || qText);
856
- // 0.5.10 建议1①:ask 答复结构化——操作类型从答复文本推断(write/command/any),不再一律 any
857
- // (修复"弹窗答复接不住真想操作"的粒度问题——V476AL 同族)
858
- const authRecord = {
859
- type: classifyAskScopeType(scopeText || qText),
860
- pathPrefix,
861
- source: "ask"
862
- };
863
- const sessionWide = isSessionWideAskText(scopeText) || isSessionWideAskText(selectedText);
864
- if (sessionWide) authRecord.expiresAt = Date.now() + 12 * 60 * 60 * 1000;
865
- else if (!pathPrefix) authRecord.expiresAt = Date.now() + 2 * 60 * 1000;
866
- recordAuthorization(state, sid, authRecord);
867
- // 2026-08-25 修复(用户授权):ask 答复也是用户明确允许——若答复文本含"允许使用 X"模式,
868
- // 同步写入 unknown 工具会话白名单(与 user/message 分支同一判定;此前只有用户消息文本可写白名,
869
- // ask 弹窗授权后 web_fetch 等仍被 unknown deny 拦——机制缺口,本修复关闭)
870
- const whtText = `${qText} ${selectedText}`;
871
- for (const hit of whtText.matchAll(/(?:允许|批准|同意)\s*(?:使用|调用|用|启用|放行)\s*([A-Za-z_][A-Za-z0-9_:.-]*)/g)) {
872
- const wname = hit[1];
873
- if (toolClass(wname, {}) !== "unknown") continue;
874
- if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
875
- const isNew = !state.unknownToolApproved.has(wname);
876
- state.unknownToolApproved.add(wname);
877
- if (isNew) {
878
- if (!state.unknownToolSessionAdded) state.unknownToolSessionAdded = new Set();
879
- state.unknownToolSessionAdded.add(wname);
880
- }
881
- persistToolWhitelist(state, sid); // v0.5.7 后续:白名单持久化(见上)
882
- audit({
883
- kind: "unknown-tool-whitelist",
884
- rule: "__unknown-tool",
885
- name: "用户批准未归类工具(ask 答复)",
886
- event: "tool/result",
887
- reason: `ask 答复含"允许使用 ${wname}" → 会话白名${isNew ? "" : "(重复,忽略)"}(来源=ask 选择文本)`,
888
- session: sid
889
- });
890
- }
891
- if (!s.turn.scopes) s.turn.scopes = [];
892
- s.turn.scopes.push(authRecord);
893
- audit({
894
- kind: "auth",
895
- rule: "12D",
896
- name: "ask_user_question 授权",
897
- event: "tool/result",
898
- reason: `记录授权范围:${qText.slice(0, 120)}`,
899
- session: sid
900
- });
901
- } else if (askResultRejected(result)) {
902
- // 弹窗消减(2026-08-24):本回合 ask 被【明确拒绝】(用户点了拒绝/否定词)→ 标记 + 全局记录
903
- // C3(2026-08-28 阶段二):未响应/超时/无选择不再记为拒绝——"用户没时间回复"不是拒绝,
904
- // 旧逻辑把任何未批准都入 askRejections 池 → 5 分钟内再 ask 被节流,用户在忙时反而被烧掉
905
- // 授权通道(实弹:用户"你也没给我时间回复啊")。敏感操作仍由 12A 授权证据把关(不降级)。
906
- s.turn.askRejected = true;
907
- if (!state.askRejections) state.askRejections = [];
908
- state.askRejections.push({ sessionId: sid, at: Date.now() });
909
- if (state.askRejections.length > 200) state.askRejections.splice(0, state.askRejections.length - 200);
910
- audit({
911
- kind: "auth-reject",
912
- rule: "12D",
913
- name: "ask_user_question 未授权",
914
- event: "tool/result",
915
- reason: "用户明确拒绝该授权请求(拒绝词命中,计入节流池)",
916
- session: sid
917
- });
918
- } else {
919
- // C3:未响应/超时/无明确选择 不置 askRejected、不入节流池(仅审计留痕)
920
- audit({
921
- kind: "auth-noanswer",
922
- rule: "12D",
923
- name: "ask_user_question 未响应",
924
- event: "tool/result",
925
- reason: "ask 无结果/未响应(用户可能未及回复)——不计入拒绝节流池;后续可再次询问",
926
- session: sid
927
- });
928
- }
929
- s.turn.pendingAsk = null;
930
- }
931
- return;
932
- }
933
- if (event.type === "assistant/chunk") {
934
- const chunk = d.chunk;
935
- if (chunk && chunk.type === "reasoning-delta" && typeof chunk.text === "string") {
936
- s.turn.reasoningText += chunk.text;
937
- }
938
- return;
939
- }
940
- if (event.type === "assistant/message") {
941
- const text = extractAssistantText(d.message);
942
- // v0.5.7 第 0 站:注入轮(无真实用户消息)不检测不投递——这是乒乓循环的根治:
943
- // 引擎自己的注入 → 我的回复 → 再检测 的燃料被掐断(实测 2026-08-26 12:37 六圈即此循环)。
944
- if (!shouldDetectTurn({ realUserSeen: s.turn.realUserSeen })) return;
945
- // 交付声明机器闸门(机制批 M3,规则 23④):完成类声明 → 核对同会话 30 分钟内 verify-pass 记录,缺失注入纠正
946
- // F2(2026-08-28 阶段三):词面收紧——"完成"裸词太宽("完成社区检索/尚未完成/正在完成"均误触),
947
- // 改为强完成声明模式且排除否定/进行态;仍由 LLM 裁决层兜底(deliverSuspects)。
948
- if (DELIVERY_RE.test(text)) {
949
- const windowStart = Date.now() - 30 * 60 * 1000;
950
- const hasPass = (state.verifyPass || []).some((v) => v.sessionId === sid && v.at > windowStart);
951
- if (!hasPass) {
952
- audit({
953
- kind: "verify-gap",
954
- rule: "23",
955
- name: "交付声明缺验证闸门记录",
956
- event: "assistant/message",
957
- reason: "交付/完成类声明缺少同会话近期 verify-passALL TESTS PASSED / check-plugin-load RESULT: PASS)记录",
958
- session: sid
959
- });
960
- // v0.5.7 后续(用户拍板"暗示型统一裁决"):23④ 词面命中只是嫌疑——"完成"≠交付声明
961
- //("完成社区检索"类误触的修复):送 LLM 裁决,确认违规才投递。
962
- void deliverSuspects(ctx, sid, [{
963
- ruleId: "23",
964
- title: "变更与交付验证(执行等级:D 强)",
965
- kind: "self-certify",
966
- awaitingJudge: true,
967
- reason: "规则 23④:完成/通过类声明需同会话近期验证记录(测试全绿或冷加载 PASS),缺失已记审计"
968
- }], text);
969
- }
970
- }
971
- // 需求基线覆盖核查(RB-09,2026-08-24):方案/重构/整体完成类声明 → 基线仍存在 planned 需求 → 审计纠正(fail-closed)
972
- if (BASELINE_COMPLETE_RE.test(text)) {
973
- const bl = loadBaselineFile();
974
- if (bl.ok) {
975
- const gap = computeBaselineGap(text, bl.items);
976
- if (gap) {
977
- audit({
978
- kind: "baseline-gap",
979
- rule: "__baseline-gap",
980
- name: "需求基线未覆盖",
981
- event: "assistant/message",
982
- reason: `完成类声明但需求基线仍有 ${gap.count} 条未落地(${gap.pending.join("、")})——先完成或更新基线再宣布完成`,
983
- session: sid
984
- });
985
- maybeInject(ctx, sid, {
986
- ruleId: "__baseline-gap",
987
- reason: `需求基线仍有 ${gap.count} 条未落地(${gap.pending.join("")}),请先完成或更新基线再宣布整体完成`
988
- });
989
- }
990
- } else {
991
- audit({
992
- kind: "baseline-unreadable",
993
- rule: "__baseline-gap",
994
- name: "需求基线不可读",
995
- event: "assistant/message",
996
- reason: `完成类声明但需求基线文件无法读取(${bl.error || "未知"})——fail-closed,无法核查覆盖`,
997
- session: sid
998
- });
999
- }
1000
- }
1001
- // F1(2026-08-28 阶段三):规则 2 违规不在此时投递——标记 pendingRule2,turn/end 复核(Get-Date 定案)①
1002
- let violations = detectViolations({ configs: state.configs, session: s, text, reasoningText: s.turn.reasoningText, mountRevision: state.mountRevision });
1003
- const rule2s = violations.filter((v) => v.ruleId === "2");
1004
- for (const v of rule2s) {
1005
- if (!s.turn.pendingRule2) s.turn.pendingRule2 = v.reason;
1006
- violations = violations.filter((x) => x !== v);
1007
- }
1008
- for (const v of violations) {
1009
- audit({
1010
- kind: v.kind,
1011
- rule: v.ruleId,
1012
- name: v.title,
1013
- event: "assistant/message",
1014
- reason: v.reason,
1015
- session: sid
1016
- });
1017
- }
1018
- // v0.5.7 分流水线:B 级机器型(correct,词表=确定证据)立即投递;
1019
- // D 级语义型命中 = 嫌疑(awaitingJudge)→ 先裁决,只有裁决=违规的才投递
1020
- // (用户拍板:"只有错误的行为才值得被提醒"——词表只产嫌疑,不再定罪)。
1021
- const immediate = violations.filter((v) => v.kind !== "self-certify");
1022
- const suspects = violations.filter((v) => v.awaitingJudge === true);
1023
- if (immediate.length > 1) {
1024
- const briefs = immediate
1025
- .map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`)
1026
- .join(";");
1027
- maybeInject(ctx, sid, {
1028
- ruleId: "__self-cert",
1029
- reason: `本轮检出 ${immediate.length} 项:${briefs}——请按 /guard log 明细应对`
1030
- });
1031
- } else if (immediate.length === 1) {
1032
- maybeInject(ctx, sid, immediate[0]);
1033
- }
1034
- if (suspects.length > 0) {
1035
- void deliverSuspects(ctx, sid, suspects, text);
1036
- }
1037
- if (violations.length) {
1038
- state.lastActive = violations.map((v) => ({ ruleId: v.ruleId, title: v.title, reason: v.reason }));
1039
- } else {
1040
- const active = activateForAssistant(state.configs, text);
1041
- if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: "assistant 文本进入 B/D 检测" }));
1042
- }
1043
- return;
1044
- }
1045
- }
1046
-
1047
- // ── /guard 命令 ─────────────────────────────────────────────────────────────
1048
-
1049
- const USAGE = [
1050
- "用法:",
1051
- " /guard status 引擎状态",
1052
- " /guard rules 规则清单 + 理解产物",
1053
- " /guard active 最近激活的规则",
1054
- " /guard log [N] 最近 N 条审计(默认 10)",
1055
- " /guard unlock [N] 解锁配置写保护 N 分钟(默认 10,仅用户)",
1056
- " /guard bypass [N] 临时整体放行 N 分钟(默认 5,仅用户)",
1057
- " /guard lock 立即恢复全部守卫(取消解锁/放行)",
1058
- " /guard revoke 撤销全部授权记录",
1059
- " /guard tools 工具放行白名单(永久+本会话,含时间/来源会话)",
1060
- " /guard tools revoke <工具名> 撤销白名单条目(持久化+会话集)",
1061
- " /guard reload 强制重解析 AGENTS.md",
1062
- " /guard mode <模式> 设置任务契约模式(review/answer/change/monitor/watch/off)",
1063
- " /guard budget ... 设置预算(agents=N files=... deps=allow hash=allow)",
1064
- " /guard contract 查看当前任务契约",
1065
- " /guard label <id> <label> 给审计记录打标(correct/incorrect/inconclusive)",
1066
- "",
1067
- "说明:",
1068
- " - 守卫 = 硬拦截:违反规则的工具调用直接拒绝,模型无法自行绕过;",
1069
- " - 解锁/放行只能由你(用户)在对话框输入命令执行,助手无法代替;",
1070
- " - 每次拦截/纠察都会记录到 " + auditFilePath() + "(/guard log 可查)。"
1071
- ].join("\n");
1072
-
1073
- function parseCommand(rawInput) {
1074
- const text = (rawInput || "").trim();
1075
- if (!text || /^(status|state)$/i.test(text)) return { kind: "status" };
1076
- if (/^(rules|list|ls)$/i.test(text)) return { kind: "rules" };
1077
- if (/^(active)$/i.test(text)) return { kind: "active" };
1078
- if (/^(help|\?)$/i.test(text)) return { kind: "help" };
1079
- let m = text.match(/^unlock\s*(\d+)?$/i);
1080
- if (m) return { kind: "unlock", minutes: m[1] ? Number(m[1]) : 10 };
1081
- m = text.match(/^bypass\s*(\d+)?$/i);
1082
- if (m) return { kind: "bypass", minutes: m[1] ? Number(m[1]) : 5 };
1083
- if (/^lock$/i.test(text)) return { kind: "lock" };
1084
- if (/^revoke$/i.test(text)) return { kind: "revoke" };
1085
- m = text.match(/^log\s*(\d+)?$/i);
1086
- if (m) return { kind: "log", n: m[1] ? Number(m[1]) : 10 };
1087
- if (/^reload$/i.test(text)) return { kind: "reload" };
1088
- if (/^tools$/i.test(text)) return { kind: "tools", action: "list" };
1089
- m = text.match(/^tools revoke\s+([A-Za-z_][A-Za-z0-9_:.-]*)$/i);
1090
- if (m) return { kind: "tools", action: "revoke", target: m[1] };
1091
- if (/^mode\b/i.test(text)) {
1092
- const parsed = parseModeCommand(text);
1093
- if (parsed) return { kind: "mode", ...parsed };
1094
- }
1095
- if (/^budget\b/i.test(text)) {
1096
- const parsed = parseBudgetCommand(text);
1097
- if (parsed) return { kind: "budget", patch: parsed };
1098
- }
1099
- if (/^contract$/i.test(text)) return { kind: "contract" };
1100
- m = text.match(/^label\s+(\S+)\s+(correct|incorrect|inconclusive)$/i);
1101
- if (m) return { kind: "label", eventId: m[1], label: m[2].toLowerCase() };
1102
- return { kind: "invalid" };
1103
- }
1104
-
1105
- async function executeGuard(ctx, invocation) {
1106
- const command = parseCommand(invocation.rawInput);
1107
- switch (command.kind) {
1108
- case "help":
1109
- return { kind: "success", text: USAGE };
1110
- case "status": {
1111
- const conf = loadPluginConfig();
1112
- state.enabled = conf.enabled;
1113
- const high = state.configs.filter((c) => c.confidence === "high").length;
1114
- const medium = state.configs.filter((c) => c.confidence === "medium").length;
1115
- const low = state.configs.filter((c) => c.confidence === "low").length;
1116
- // 覆盖自省(一致性预防):dead = 映射有但规则无;uncovered = 硬等级规则但无 handler
1117
- const { dead, uncovered } = analyzeCoverage(state.configs);
1118
- const parts = [
1119
- "【规则引擎状态】",
1120
- ` 总开关:${state.enabled ? "开启" : "已关闭"}`,
1121
- ` 任务契约:${state.taskContract?.taskContractEnabled ? `开启(模式 ${state.taskContract.taskContractMode}|ask ${state.taskContract.askEnabled ? "" : ""})` : "关闭"}`,
1122
- ` 规则容器:${state.configOk ? `正常(${state.configs.length} 条规则)` : "⚠ " + state.configError}`,
1123
- ` 理解置信度:high ${high} / medium ${medium} / low ${low}`,
1124
- ` 覆盖自省:${dead.size === 0 && uncovered.length === 0 ? "无缺口" : (dead.size > 0 ? `死映射 ${[...dead.keys()].join("、")}(引擎映射存在但规则已删除,建议清理)` : "") + (uncovered.length > 0 ? `;未覆盖 ${uncovered.map((u) => u.ruleId).join("、")}(${uncovered.map((u) => u.actions.join("+")).join(";")} 但无 handler,规则声明强制但引擎不执行,建议降 D 级或补实现)` : "")}`,
1125
- ` 配置加载:${conf.ok ? "正常" : "⚠ " + conf.error}`,
1126
- ` 解锁剩余:${fmtRemain(remainMs(state.unlockUntil))}(配置写保护豁免)`,
1127
- ` 放行剩余:${fmtRemain(remainMs(state.bypassUntil))}(全部守卫暂停)`,
1128
- ` 授权存储:内存态(重启失效)`,
1129
- ` 审计日志:${auditFilePath()}`,
1130
- ` 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})` : "【降级词表】"}` : ""})` : "关闭"}`,
1131
- ` 最近激活:${state.lastActive.length ? state.lastActive.map((a) => a.ruleId).join("、") : "无"}`,
1132
- ` 提示:审批策略 never 只关系统审批弹窗,不影响对话内 ask_user_question 授权;规则 12A 仍会硬拦需要授权的操作。`
1133
- ];
1134
- return { kind: "success", text: parts.join("\n") };
1135
- }
1136
- case "rules": {
1137
- if (state.configs.length === 0) return { kind: "success", text: "当前没有可执行规则(AGENTS.md 为空或缺失)。" };
1138
- const parts = [`共 ${state.configs.length} 条规则:`, ""];
1139
- for (const c of state.configs) {
1140
- const flag = c.disabled ? "(已禁用)" : "";
1141
- const actions = (c.actions || []).join("/");
1142
- parts.push(` [${c.ruleId}] ${c.title} ${flag}`);
1143
- parts.push(` 等级 ${c.level || "?"}|动作 ${actions}|置信 ${c.confidence}|handler ${c.handler || "(无——规则声明强制但引擎不执行,建议降 D 级或补实现)"}`);
1144
- }
1145
- return { kind: "success", text: parts.join("\n") };
1146
- }
1147
- case "active": {
1148
- if (state.lastActive.length === 0) return { kind: "success", text: "暂无激活规则。" };
1149
- const parts = ["最近激活规则:", ""];
1150
- for (const a of state.lastActive) {
1151
- parts.push(` [${a.ruleId}] ${a.title}`);
1152
- if (a.reason) parts.push(` 原因:${a.reason}`);
1153
- }
1154
- return { kind: "success", text: parts.join("\n") };
1155
- }
1156
- case "unlock": {
1157
- const minutes = Math.min(Math.max(1, command.minutes), 60);
1158
- state.unlockUntil = Date.now() + minutes * 60000;
1159
- // 逃生门请求审计(机制批 M1):用途留痕,供规则 1⑤ 事前说明核对
1160
- audit({ kind: "guard-command", rule: "__escape-gate", name: "逃生门请求", event: "command", reason: `/guard unlock ${minutes} 分钟(配置写保护)`, session: invocation?.session?.id || "global" });
1161
- return {
1162
- kind: "success",
1163
- text: `已解锁「配置写保护」${minutes} 分钟。现在可以让助手修改 rule-engine.json / rule-understanding.json / AGENTS.md;改完请执行 /guard lock 或等待自动恢复。`
1164
- };
1165
- }
1166
- case "bypass": {
1167
- const minutes = Math.min(Math.max(1, command.minutes), 60);
1168
- state.bypassUntil = Date.now() + minutes * 60000;
1169
- // 逃生门请求审计(机制批 M1)
1170
- audit({ kind: "guard-command", rule: "__escape-gate", name: "逃生门请求", event: "command", reason: `/guard bypass ${minutes} 分钟(全部守卫)`, session: invocation?.session?.id || "global" });
1171
- return {
1172
- kind: "success",
1173
- text: `已临时放行全部守卫 ${minutes} 分钟。到期自动恢复,也可 /guard reload 后立即恢复。`
1174
- };
1175
- }
1176
- case "lock": {
1177
- state.unlockUntil = 0;
1178
- state.bypassUntil = 0;
1179
- return { kind: "success", text: "守卫已全部恢复:解锁与放行均已取消。" };
1180
- }
1181
- case "revoke": {
1182
- // R1.3(2026-08-24):全清——session.authorizations + turn.scopes + globalAuthorizations + askRejections
1183
- const count = clearAuthorizations(state);
1184
- // 可观测性(阶段 0 + 机制 C,2026-08-24):revoke 事件必须留痕,否则撤销无法复核(B4 实证)
1185
- audit({
1186
- kind: "guard-command",
1187
- rule: "__revoke",
1188
- name: "撤销全部授权",
1189
- event: "command",
1190
- reason: `撤销 ${count} 条授权/范围(session + turn.scopes + global + askRejections 全清)`,
1191
- session: invocation?.session?.id || "global"
1192
- });
1193
- return { kind: "success", text: `已撤销全部授权与范围(共 ${count} 条,含全局池与 ask 拒绝记录;不再有残留)。` };
1194
- }
1195
- case "log": {
1196
- const n = Math.min(Math.max(1, command.n), 200);
1197
- const entries = readAuditLog(n);
1198
- if (entries.length === 0) return { kind: "success", text: "暂无审计记录。" };
1199
- const parts = [`最近 ${entries.length} 条审计:`, ""];
1200
- for (const e of entries) {
1201
- if (e.raw) {
1202
- parts.push(` ${e.raw}`);
1203
- continue;
1204
- }
1205
- const t = (e.ts || "").replace("T", " ").slice(0, 19);
1206
- parts.push(` [${t}] ${e.kind || "?"}|规则 ${e.rule || "?"}|${e.name || ""}${e.errId ? `(ERR-${e.errId})` : ""}${e.eventId ? `(${e.eventId})` : ""}`);
1207
- if (e.reason) parts.push(` 原因:${e.reason}`);
1208
- if (e.tool) parts.push(` 工具:${e.tool}|参数:${e.args || ""}`);
1209
- }
1210
- return { kind: "success", text: parts.join("\n") };
1211
- }
1212
- case "reload": {
1213
- reloadRules(state);
1214
- const saved = writeUnderstanding(state.configs);
1215
- return {
1216
- kind: "success",
1217
- text: `已重解析 AGENTS.md:${state.configOk ? `正常(${state.configs.length} 条规则)` : "⚠ " + state.configError}` +
1218
- (saved.ok ? `\n理解产物已写入:${saved.path}` : `\n理解产物写入失败:${saved.error}`)
1219
- };
1220
- }
1221
- case "tools": {
1222
- // v0.5.9:白名单可视化——永久(元数据)+ 本会话新增;revoke = 从持久化文件与会话集移除
1223
- if (command.action === "revoke") {
1224
- const target = command.target;
1225
- const prev = readWhitelistMeta();
1226
- const existed = prev.delete(target);
1227
- writeFileSync(toolsWhitelistFilePath(), JSON.stringify([...prev.values()]), "utf8");
1228
- state.unknownToolApproved?.delete(target);
1229
- state.unknownToolSessionAdded?.delete(target);
1230
- audit({
1231
- kind: "guard-command",
1232
- rule: "__whitelist",
1233
- name: "白名单撤销",
1234
- event: "command",
1235
- reason: `/guard tools revoke ${target}(持久化+会话集同步移除)`,
1236
- session: invocation?.session?.id || "global"
1237
- });
1238
- return { kind: "success", text: `已撤销 ${target}${existed ? "" : "(原不在白名单)"}。` };
1239
- }
1240
- const meta = state.unknownToolApprovedMeta || new Map();
1241
- const sessionAdded = state.unknownToolSessionAdded || new Set();
1242
- const rows = [...(state.unknownToolApproved || [])].map((n) => {
1243
- const m = meta.get(n) || {};
1244
- let when = "未知";
1245
- if (typeof m.time === "number") {
1246
- try { when = new Date(m.time).toISOString(); } catch { when = "非法时间"; }
1247
- }
1248
- return `- ${n}${sessionAdded.has(n) ? "(本会话新增)" : ""} 时间=${when} 来源会话=${m.session || "未知"}`;
1249
- });
1250
- const text = rows.length
1251
- ? `工具放行白名单(/guard tools):\n${rows.join("\n")}\n\n撤销:/guard tools revoke <工具名>`
1252
- : "工具放行白名单为空。";
1253
- return { kind: "success", text };
1254
- }
1255
- case "mode": {
1256
- if (!state.taskContract?.taskContractEnabled) return { kind: "error", text: "任务契约未启用:请先在规则引擎设置页开启总开关。" };
1257
- const sid = invocation?.session?.id || "global";
1258
- const s = getSessionState(state, sid);
1259
- const res = applyContract(s.contract, { mode: command.mode, level: command.level, source: "guard-command" });
1260
- if (res.changed) {
1261
- s.contract = res.contract;
1262
- audit({ kind: "task-contract", rule: "__task-contract", name: "任务契约更新", event: "command", reason: `/guard mode: ${contractSummary(s.contract)}`, session: sid });
1263
- }
1264
- return { kind: "success", text: `任务契约:${contractSummary(s.contract)}` };
1265
- }
1266
- case "budget": {
1267
- if (!state.taskContract?.taskContractEnabled) return { kind: "error", text: "任务契约未启用:请先在规则引擎设置页开启总开关。" };
1268
- const sid = invocation?.session?.id || "global";
1269
- const s = getSessionState(state, sid);
1270
- const res = applyContract(s.contract, { ...command.patch, source: "guard-command" });
1271
- if (res.changed) {
1272
- s.contract = res.contract;
1273
- audit({ kind: "task-contract", rule: "__task-contract", name: "任务预算更新", event: "command", reason: `/guard budget: ${contractSummary(s.contract)}`, session: sid });
1274
- }
1275
- return { kind: "success", text: `任务契约:${contractSummary(s.contract)}` };
1276
- }
1277
- case "contract": {
1278
- const sid = invocation?.session?.id || "global";
1279
- const s = getSessionState(state, sid);
1280
- const enabled = state.taskContract?.taskContractEnabled ? "开启" : "关闭(总开关未开启,命令不生效)";
1281
- return { kind: "success", text: `任务契约(总开关:${enabled})\n${contractSummary(s.contract)}` };
1282
- }
1283
- case "label": {
1284
- const entries = readAuditLog(500);
1285
- // E1(2026-08-28 阶段一):支持 ERR-xxxxxx 短码定位(此前只认 eventId UUID——
1286
- // 用户拿拦截提示里的 ERR 码无法打标,链路断在"两套标识符无映射"
1287
- const want = String(command.eventId || "").trim();
1288
- const normalized = want.toUpperCase().startsWith("ERR-") ? want.slice(4).toUpperCase() : want;
1289
- const found = entries.find(
1290
- (e) => e.eventId === want || e.errId === normalized || e.errId === want || `ERR-${e.errId}` === want.toUpperCase()
1291
- );
1292
- if (!found) return { kind: "error", text: `未找到审计事件:${command.eventId}(提示:/guard log 可查最近记录;若为旧日志则拦截时未记录 ERR 码)` };
1293
- state.labels.set(found.eventId, command.label);
1294
- audit({ kind: "task-label", rule: "__task-contract", name: "审计人工标注", event: "command", reason: `${found.eventId} = ${command.label}(来源 ERR-${found.errId || "?"})`, session: invocation?.session?.id || "global" });
1295
- return { kind: "success", text: `已标注 ${found.eventId} = ${command.label}` };
1296
- }
1297
- default:
1298
- return { kind: "error", text: USAGE };
1299
- }
1300
- }
1301
-
1302
- // ── 插件主体 ────────────────────────────────────────────────────────────────
1303
-
1304
- export function apply(ctx) {
1305
- // 0. v0.5.7 P0-3:注册全部工作区根(多工作区环境;精确层=会话级,在 guard 回调按会话同步)
1306
- try {
1307
- const list = ctx.workspaceRegistry?.list?.() || [];
1308
- setWorkspaceRoots(list.map((e) => e && (e.path || e.record?.path)).filter(Boolean));
1309
- } catch {
1310
- setWorkspaceRoots([]);
1311
- }
1312
-
1313
- // v0.5.7 P0-4:加载持久化验证记录(热重载/重启后规则 23④ 证据链不丢;仅保留 30 分钟内)
1314
- try {
1315
- const raw = readFileSync(verifyFilePath(), "utf8");
1316
- const arr = JSON.parse(raw);
1317
- if (Array.isArray(arr)) {
1318
- state.verifyPass = arr
1319
- .filter((v) => v && v.at && Date.now() - Number(v.at) < 30 * 60 * 1000)
1320
- .slice(-100);
1321
- }
1322
- } catch {
1323
- // 首次运行/文件缺失:无历史记录,正常
1324
- }
1325
-
1326
- // v0.5.7 后续(用户批准):加载白名单持久化("允许使用 X"重启/热重载不丢)
1327
- // 0.5.9:v2 对象数组(带 meta)与 v1 字符串数组兼容;meta 供 /guard tools 可视化
1328
- try {
1329
- const wlRaw = readFileSync(toolsWhitelistFilePath(), "utf8");
1330
- const rows = parseWhitelist(wlRaw);
1331
- state.unknownToolApproved = new Set(rows.map((r) => r.name));
1332
- state.unknownToolApprovedMeta = new Map(rows.map((r) => [r.name, r]));
1333
- } catch {
1334
- // 首次运行/无文件:空白名单
1335
- }
1336
-
1337
- // 0.0.1 LLM 意图兜底配置(guardDecision 为纯函数,经 state 传入)
1338
- state.llmIntentCfg = pluginConfig.llmIntent || {};
1339
-
1340
- // 0.1 技能目录实时联动
1341
- refreshSkills(ctx);
1342
- ctx.on("skills/change", () => {
1343
- refreshSkills(ctx);
1344
- });
1345
-
1346
- // 0.2 LLM 增量理解(非 high 置信规则,失败自动回退模式库)
1347
- enrichRulesWithLlm(ctx, state);
1348
-
1349
- // 0.3 AGENTS.md 文件监听(fs.watch 即时触发,stat 轮询保留为兜底)
1350
- ctx.effect(
1351
- function* () {
1352
- let timer = null;
1353
- let watcher = null;
1354
- try {
1355
- watcher = watch(agentsFilePath(), { persistent: false }, () => {
1356
- clearTimeout(timer);
1357
- timer = setTimeout(() => {
1358
- reloadRules(state);
1359
- // 规则变化后补一次 LLM 增量理解(有 ruleId@mtime 去重,不会重复烧 token;P1-6)
1360
- void enrichRulesWithLlm(ctx, state);
1361
- }, 200);
1362
- });
1363
- } catch {
1364
- // 文件暂不可监听时由 stat 轮询兜底
1365
- }
1366
- yield () => {
1367
- if (timer) clearTimeout(timer);
1368
- if (watcher) watcher.close();
1369
- };
1370
- },
1371
- "dsh-rule-engine watch"
1372
- );
1373
-
1374
- // 0.3.1 注入延迟投递定时器清理(O1 批次 6):卸载/热重载时取消全部 pending inject
1375
- ctx.effect(
1376
- function* () {
1377
- yield () => {
1378
- for (const t of pendingInjectTimers) clearTimeout(t);
1379
- pendingInjectTimers.clear();
1380
- };
1381
- },
1382
- "dsh-rule-engine inject timers"
1383
- );
1384
-
1385
- // 1. 单调守卫:工具调用先裁决,命中即物理拒绝
1386
- ctx.effect(
1387
- function* () {
1388
- yield ctx.tools.guard((exec) => {
1389
- // v0.5.7 P0-3:多工作区正确模型——根随会话走。每个工具调用前按当前会话同步其工作区根
1390
- //(官方 workspaceRegistry.sessionPath 提供会话→工作区映射;新工作区零维护自动识别)。
1391
- try {
1392
- const gsid = sessionIdOfExec(exec);
1393
- if (gsid && gsid !== "global" && ctx.workspaceRegistry?.sessionPath) {
1394
- const root = ctx.workspaceRegistry.sessionPath(gsid);
1395
- if (root) setSessionWorkspaceRoot(gsid, root);
1396
- }
1397
- } catch {
1398
- // 会话未映射时安静走全局兜底,不阻塞裁决
1399
- }
1400
- // 落盘授权粒度提醒(机制批 M7,2026-08-24):受保护文件统一入口调用 + 本回合用户消息为方案性指令
1401
- //(调整/补充/评估/建议且无落盘词)→ approval-gap 审计 + 注入提醒(规则 22 自证③:方案性指令 ≠ 落盘授权)
1402
- {
1403
- const cmd = exec?.arguments?.command || exec?.arguments?.code || "";
1404
- if ((exec?.name === "pwsh" || exec?.name === "bash") && /\bdsh-manual-write\.mjs/.test(cmd)) {
1405
- const s7 = getSessionState(state, sessionIdOfExec(exec));
1406
- if (needsApprovalReminder(s7.turn.userText || "")) {
1407
- audit({
1408
- kind: "approval-gap",
1409
- rule: "__approval-gap",
1410
- name: "落盘授权粒度提醒",
1411
- event: "tool/guard",
1412
- tool: exec?.name,
1413
- args: summarizeArgs(exec?.arguments),
1414
- reason: "本回合用户消息为方案性指令(调整/补充/评估/建议等),不构成落盘授权(规则 22 自证③)——落盘前应先展示→用户确认",
1415
- session: sessionIdOfExec(exec)
1416
- });
1417
- maybeInject(ctx, sessionIdOfExec(exec), {
1418
- ruleId: "__approval-gap",
1419
- reason: "规则 22 自证③:方案性指令不构成落盘授权——受保护文件落盘需先展示改动→用户明确确认→再执行(机制批 M7)"
1420
- });
1421
- }
1422
- }
1423
- }
1424
- // 逃生门窗口打标(机制批 M2):bypass 生效期间一切变更类调用记 bypass-action 审计(用途留痕,供规则 1⑤ 核对)
1425
- if (state.bypassUntil > Date.now() && !isReadOnlyTool(exec?.name, exec?.arguments)) {
1426
- audit({
1427
- kind: "bypass-action",
1428
- rule: "__escape-gate",
1429
- name: "逃生门窗口变更",
1430
- event: "tool/guard",
1431
- tool: exec?.name,
1432
- args: summarizeArgs(exec?.arguments),
1433
- reason: "窗口内变更(逃生门用途:引擎/规则/手册修复,规则 1⑤ 范围核对)",
1434
- session: sessionIdOfExec(exec)
1435
- });
1436
- }
1437
- const hit = guardDecision(state, exec, Date.now(), { audit });
1438
- if (hit) {
1439
- audit({
1440
- kind: "deny",
1441
- rule: hit.ruleId,
1442
- name: hit.title,
1443
- event: "tool/guard",
1444
- tool: exec?.name,
1445
- args: summarizeArgs(exec?.arguments),
1446
- reason: hit.reason,
1447
- errId: hit.errId,
1448
- session: sessionIdOfExec(exec) // P0-4:deny 审计补 session(2026-08-24 事故复盘:归属会话靠猜)
1449
- });
1450
- state.lastActive = [{ ruleId: hit.ruleId, title: hit.title, reason: hit.reason }];
1451
- // 规则 1 计数解耦(2026-08-24,P1-7):引擎 deny 不计入“同工具连续失败”
1452
- const dkey = `${exec?.name}:${JSON.stringify(exec?.arguments || {})}`;
1453
- if (!state.deniedKeys) state.deniedKeys = new Set();
1454
- state.deniedKeys.add(dkey);
1455
- if (state.deniedKeys.size > 500) {
1456
- const oldest = state.deniedKeys.keys().next().value;
1457
- if (oldest !== undefined) state.deniedKeys.delete(oldest);
1458
- }
1459
- // 弹窗消减(2026-08-24):E3/ask 节流命中后注入纠正,提示改用普通文本,勿连环弹窗
1460
- // D1(2026-08-28 阶段三):改**陈述式**——旧文案"请直接执行"是命令,模型照做会越过
1461
- // 用户(实弹:注入后模型跳过用户回复直接执行);只陈述事实,决策权留给用户。
1462
- if (hit.ruleId === "__already-authorized" || hit.ruleId === "__ask-rejected" || hit.ruleId === "__ask-throttle") {
1463
- maybeInject(ctx, sessionIdOfExec(exec), {
1464
- ruleId: hit.ruleId,
1465
- reason: `${hit.title}(规则 ${hit.ruleId}):本会话已存在相近授权记录或被拒记录;再次弹窗询问可能无法送达用户。可用普通文本说明。`
1466
- });
1467
- }
1468
- return hit.reason;
1469
- }
1470
- return undefined;
1471
- });
1472
- },
1473
- "dsh-rule-engine guard"
1474
- );
1475
-
1476
- // 1.0 规则 12C B 级留痕:命令文本含回环/内网地址时记录审计,不拦截
1477
- // A2(阶段一,2026-08-28):LLM 意图兜底同步等待——词表判"拦"(denyMutation)时,若 LLM
1478
- // 预取仍在进行(llm-pending),在工具执行前同步等待结果(上限 300ms,超时按词表保守裁决)。
1479
- // 根因:enrichIntentWithLlm 是异步预取,模型先发工具调用往往先于 LLM 返回 → 词表盲区
1480
- // (转化/读取/展示 等未收录词)在拦截点直接判拦,LLM 兜底形同虚设(2026-08-28 实弹)。
1481
- ctx.on("tools/pre-execute", async (exec, next) => {
1482
- try {
1483
- const sid2 = sessionIdOfExec(exec);
1484
- const s2 = getSessionState(state, sid2);
1485
- if (s2?.turn?.intentState === "llm-pending" && typeof s2.turn?.llmIntentPromise?.then === "function") {
1486
- await Promise.race([
1487
- s2.turn.llmIntentPromise,
1488
- new Promise((resolve) => setTimeout(resolve, 300))
1489
- ]);
1490
- }
1491
- } catch {
1492
- // 等待失败不阻断:词表裁决兜底
1493
- }
1494
- return next();
1495
- });
1496
- ctx.on("tools/pre-execute", async (exec, next) => {
1497
- try {
1498
- const cmd = exec?.arguments?.command || exec?.arguments?.code || "";
1499
- if (typeof cmd === "string" && cmd.length > 0 && (exec?.name === "pwsh" || exec?.name === "bash") && PRIVATE_NETWORK_RE.test(cmd)) {
1500
- audit({
1501
- kind: "network-flag",
1502
- rule: "12C",
1503
- name: "内网/回环地址访问留痕",
1504
- event: "tools/pre-execute",
1505
- tool: exec?.name,
1506
- reason: `命令文本含回环/内网地址模式:${cmd.slice(0, 160)}`,
1507
- session: sessionIdOfExec(exec)
1508
- });
1509
- }
1510
- } catch {
1511
- // 留痕失败不阻断
1512
- }
1513
- return next();
1514
- });
1515
-
1516
- // 0.9 机制 B(2026-08-24;0.5.9 修订——K-02/K-03 依据):未归类工具首调按 unknownPolicy 处置:
1517
- // "ask" = 官方 pre-execute ask 语义(approval 弹窗,allowed-once 才续行;无审批通道 → 官方自动 deny,
1518
- // fail-closed 由官方保证);"deny" = 物理拒绝 + 提示("允许使用 X" 会话白名路径恒可用)。
1519
- // 历史教训(08-24 实测 ask 被自动放行 → 当时改 deny):0.5.9 发布前必须实弹验证 ask 弹窗真实出现
1520
- // (K-02 门禁),验证不过则发布时默认保持 deny。
1521
- ctx.on("tools/pre-execute", async (exec, next) => {
1522
- try {
1523
- if ((state.unknownToolApproved || new Set()).has(exec?.name)) return next(); // 会话内用户已批准
1524
- if (toolClass(exec?.name, exec?.arguments || {}) !== "unknown") return next();
1525
- // 0.5.9:unknownPolicy 默认 "deny"(K-02 门禁:ask 需实弹验证弹窗真实出现后才可默认;
1526
- // 当前无真实 unknown 工具可触发实弹——分类表已全覆盖,等待未来真实场景验证后经配置切换)
1527
- const policy = pluginConfig.unknownPolicy === "ask" ? "ask" : "deny";
1528
- audit({
1529
- kind: "unknown-tool",
1530
- rule: "__unknown-tool",
1531
- name: "未归类工具首调处置",
1532
- event: "tools/pre-execute",
1533
- tool: exec?.name,
1534
- args: summarizeArgs(exec?.arguments),
1535
- reason: `工具 ${exec.name} 不在规则引擎分类表:按 unknownPolicy=${policy} 首调处置(批准路径:官方弹窗批准 / 用户明确"允许使用 ${exec.name}"写入会话白名单;长期规则请补充工具分类表)`,
1536
- session: sessionIdOfExec(exec)
1537
- });
1538
- if (policy === "ask") {
1539
- return {
1540
- kind: "ask",
1541
- reason: `工具 ${exec.name} 未登记在规则引擎分类表:批准本次后放行;如需本会话持续放行请说"允许使用 ${exec.name}"。`
1542
- };
1543
- }
1544
- return {
1545
- kind: "deny",
1546
- reason: `工具 ${exec.name} 尚未归类(新装插件?):已拦截。如需使用请向用户说明用途并获取其"允许使用 ${exec.name}"的明确答复后重试(批准后本会话内放行)。`
1547
- };
1548
- } catch (error) {
1549
- ctx.logger?.warn?.("[dsh-rule-engine] unknown-tool handling error", error);
1550
- return next();
1551
- }
1552
- });
1553
-
1554
- // 1.1 任务契约裁决钩子:deny 物理拒绝 + ask 通道 + 委托预算启动即计。
1555
- // 2026-08-25 修复(预算语义偏差,实弹:第 2 个子代理被误拒——允许 N 实际 N-1):
1556
- // 根因 = 扣减(本钩子)先于 guard 层的 decideContractAction(L1138 用已扣 contract 重判 → 2+1>2 → 误拒)。
1557
- // 修复 = 本钩子成为契约唯一裁决点(deny 在扣减**前**判定并物理拒绝),guard 层跳过预算类 deny(防双判)。
1558
- ctx.on("tools/pre-execute", async (exec, next) => {
1559
- try {
1560
- if (!state.taskContract?.taskContractEnabled) return next();
1561
- const sid = sessionIdOfExec(exec);
1562
- const s = getSessionState(state, sid);
1563
- if (!isArmed(s.contract, state.taskContract)) return next();
1564
- const action = classifyAction(exec?.name, exec?.arguments);
1565
- const dec = decideContractAction({ contract: s.contract, action, config: state.taskContract });
1566
- // deny(在扣减前判定,语义=本次调用发生时点)
1567
- if (dec.outcome === "deny") {
1568
- audit({
1569
- kind: "task-contract-deny",
1570
- rule: "__task-contract",
1571
- name: `任务契约拒绝:${dec.reasonCode}`,
1572
- event: "tools/pre-execute",
1573
- tool: exec?.name,
1574
- args: summarizeArgs(exec?.arguments),
1575
- reason: dec.reason,
1576
- session: sid
1577
- });
1578
- return { kind: "deny", reason: `【硬拦截】${dec.reason}(${dec.reasonCode}|任务契约|放行:${dec.nextStep || "请调整契约"})` };
1579
- }
1580
- // 批次 5.5(2026-08-24):委托启动即计预算——原 tool/result 扣减因 subagent 无 pendingCall 从未执行(agents 恒 0)
1581
- if (dec.outcome === "allow" && action.mutability === "delegate" && action.delegationCount > 0) {
1582
- s.contract = consumeDelegateBudget(s.contract, action.delegationCount);
1583
- audit({
1584
- kind: "task-budget",
1585
- rule: "__task-contract",
1586
- name: "子代理预算扣减",
1587
- event: "tools/pre-execute",
1588
- tool: exec?.name,
1589
- reason: `已用 ${s.contract.agentsUsed}/${s.contract.agentBudget}`,
1590
- session: sid
1591
- });
1592
- }
1593
- if (dec.outcome === "ask") {
1594
- audit({
1595
- kind: "task-ask",
1596
- rule: "__task-contract",
1597
- name: "任务契约询问",
1598
- event: "tools/pre-execute",
1599
- tool: exec?.name,
1600
- args: summarizeArgs(exec?.arguments),
1601
- reason: dec.reason,
1602
- session: sid
1603
- });
1604
- return { kind: "ask", reason: `${dec.reason}(任务契约|${dec.nextStep || "请确认或使用放行词"})` };
1605
- }
1606
- // 可观测性(阶段 0,2026-08-24):armed 下 deny/allow 裁决也留痕,CI 澄清"契约 deny 有无真实拦截路径"
1607
- if (dec.outcome === "deny" || dec.outcome === "allow") {
1608
- audit({
1609
- kind: "contract-decision",
1610
- rule: "__task-contract",
1611
- name: "任务契约裁决",
1612
- event: "tools/pre-execute",
1613
- tool: exec?.name,
1614
- args: summarizeArgs(exec?.arguments),
1615
- reason: `裁决 ${dec.outcome}|${dec.reasonCode}|${dec.reason}`,
1616
- session: sid
1617
- });
1618
- }
1619
- // 批次 4(2026-08-24):契约 deny 真实物理拦截(原仅审计 + next()——armed 下出界写/
1620
- // 无界委托/预算超/模式禁止变更均无实际效力)
1621
- if (dec.outcome === "deny") {
1622
- return { kind: "deny", reason: `${dec.reason}(任务契约|${dec.nextStep || "请调整契约或停止该操作"})` };
1623
- }
1624
- return next();
1625
- } catch (error) {
1626
- ctx.logger?.warn?.("[dsh-rule-engine] tools/pre-execute error", error);
1627
- return next();
1628
- }
1629
- });
1630
-
1631
- // 2. session/event 监听:文本纠察 + 时序状态
1632
- ctx.on("session/event", (session, event) => {
1633
- try {
1634
- handleSessionEvent(ctx, session, event);
1635
- } catch (error) {
1636
- ctx.logger?.warn?.("[dsh-rule-engine] session/event handler error", error);
1637
- }
1638
- });
1639
-
1640
- // 3. /guard 命令(仅用户可执行;模型无命令工具)
1641
- ctx.effect(
1642
- function* () {
1643
- yield ctx.commands.register({
1644
- name: "guard",
1645
- description: "规则执行引擎:查看状态/规则/激活/审计,解锁配置修改,临时放行,强制重载",
1646
- input: { hint: "[status|rules|active|log <N>|unlock <分钟>|bypass <分钟>|reload]" },
1647
- handler: async (invocation) => {
1648
- try {
1649
- return await executeGuard(ctx, invocation);
1650
- } catch (error) {
1651
- return {
1652
- kind: "error",
1653
- text: `执行出错:${error instanceof Error ? error.message : String(error)}`
1654
- };
1655
- }
1656
- }
1657
- });
1658
- },
1659
- "dsh-rule-engine commands"
1660
- );
1661
-
1662
- ctx.logger?.info?.("[dsh-rule-engine] 已加载,AGENTS.md 规则数:" + state.configs.length);
1663
- }
1
+ // dsh-rule-engine —— DSH 规则执行引擎 v3(host 插件,纯 Node)
2
+ // 容器:解析 AGENTS.md → 理解器 → 匹配机 → 执行框架。
3
+ // 执行框架:ctx.tools.guard() 硬拦 + session/event 文本纠察 + 审计台账 + /guard 命令。
4
+ import { readFileSync, watch, writeFileSync } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
6
+ import { audit, readAuditLog } from "./core/audit.js";
7
+ import { loadPluginConfig } from "./core/config.js";
8
+ import { guardDecision } from "./core/guard-core.js";
9
+ import { computeBaselineGap, loadBaselineFile, BASELINE_COMPLETE_RE } from "./core/baseline.js";
10
+ import {
11
+ activateForAssistant,
12
+ activateForToolCall,
13
+ activateForUserMessage
14
+ } from "./core/matcher.js";
15
+ import {
16
+ auditOutputFailed,
17
+ auditOutputPassed,
18
+ backupPathsFromTool,
19
+ isAssemblyMutationTool,
20
+ isAuditCommand,
21
+ isBackupCommand,
22
+ isBackupTool,
23
+ isGetDateCommand,
24
+ isManualReadTool,
25
+ isReadOnlyTool,
26
+ matchKnownPitfall,
27
+ needsApprovalReminder,
28
+ PRIVATE_NETWORK_RE,
29
+ setWorkspaceRoot,
30
+ setWorkspaceRoots,
31
+ setSessionWorkspaceRoot
32
+ } from "./core/patterns.js";
33
+ import {
34
+ askQuestionCoreText,
35
+ askQuestionText,
36
+ askResultApproved,
37
+ askResultRejected,
38
+ askResultSelectedText,
39
+ classifyAskScopeType,
40
+ inferPathPrefixFromText,
41
+ inferTypeFromText,
42
+ isAuthMessage,
43
+ isDirectiveMessage,
44
+ isQuestionMessage,
45
+ isSessionWideAskText,
46
+ scopesFromIntents
47
+ } from "./core/authorization.js";
48
+ import { isEngineInjectedMessage, isInjectedContextMessage, isKnownSystemInjection, parseUserIntents, stripInjectedContext } from "./core/intent.js";
49
+ import { enrichIntentWithLlm } from "./core/llm-intent.js";
50
+ import {
51
+ applyTaskContractConfig,
52
+ clearAuthorizations,
53
+ getSessionState,
54
+ maybeReloadIfChanged,
55
+ recordAuthorization,
56
+ recordBackup,
57
+ reloadRules,
58
+ resetTurn
59
+ } from "./core/state.js";
60
+ import { toolClass } from "./core/tool-catalog.js";
61
+ import { parseWhitelist, mergeWhitelist, serializeWhitelist } from "./core/whitelist.js";
62
+ import { state } from "./core/runtime.js";
63
+ import { DELIVERY_RE, detectViolations, extractAssistantText } from "./core/text-detect.js";
64
+ import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
65
+ import { judgeViolation, judgeViolationsBatch } from "./core/judge.js";
66
+ import {
67
+ applyContract,
68
+ contractSummary,
69
+ decideContractAction,
70
+ consumeDelegateBudget,
71
+ defaultContract,
72
+ isArmed,
73
+ isObserving,
74
+ naturalMode,
75
+ parseBudgetCommand,
76
+ parseModeCommand,
77
+ categoryOfCommand
78
+ } from "./core/contract.js";
79
+ // 0.5.12(F5 批次 3):契约完成信号判定——install/build 类成功即使命达成
80
+ function isNonDestructiveAndComplete(cat) {
81
+ return cat === "install" || cat === "build";
82
+ }
83
+ import {
84
+ classifyAction,
85
+ detectOverengineeringText,
86
+ isRepeatedTaskAction,
87
+ recordAction
88
+ } from "./core/overengineering.js";
89
+ import { writeUnderstanding } from "./core/understanding-store.js";
90
+ import { labelFingerprint, labelEntry, labelsFilePath, saveLabelsToDisk, upsertLabel } from "./core/label-fingerprint.js";
91
+ import { agentsFilePath, auditFilePath, verifyFilePath, toolsWhitelistFilePath, dshHome } from "./core/paths.js";
92
+ import { isVersionedFile, validateEditedFile } from "./core/version-guard.js";
93
+ import { computeMountSignature, profileNameFromArgs } from "./core/mount-signature.js";
94
+ import { detectSilentError, extractToolOutput } from "./core/silent-error.js";
95
+ import { enrichRulesWithLlm } from "./core/llm-understander.js";
96
+ import { analyzeCoverage } from "./core/understander.js";
97
+
98
+ export const name = "dsh-rule-engine";
99
+ export const inject = ["tools", "commands", "agents", "workspaceRegistry", "skills", "llm"];
100
+
101
+ const pluginConfig = loadPluginConfig();
102
+ state.enabled = pluginConfig.enabled;
103
+ applyTaskContractConfig(state, pluginConfig);
104
+ // T3 通用化(2026-08-31):声明式绑定覆盖表注入(rule-engine.json 可选键 handlerOverrides;缺省 {})
105
+ state.handlerOverrides = pluginConfig?.handlerOverrides || {};
106
+ // 残余1 剥离(2026-08-31):本机默认偏好表注入(rule-engine.json 可选键 handlerDefaultMap;
107
+ // 通用部署=空表——代码零本机编号,本机偏好不随包走、升级不丢)
108
+ state.handlerDefaultMap = pluginConfig?.handlerDefaultMap || {};
109
+ // reloadRules 内部已统一刷新理解产物(P0-3),此处不再重复写
110
+ reloadRules(state);
111
+
112
+ // ── 工具函数 ────────────────────────────────────────────────────────────────
113
+
114
+ /** 统计 substring 出现次数(0.5.11 唯一性前提;与 guard-core countOccurrences 同语义) */
115
+ function countOccurrencesStr(text, sub) {
116
+ if (typeof text !== "string" || typeof sub !== "string" || sub.length === 0) return 0;
117
+ let count = 0;
118
+ let idx = text.indexOf(sub);
119
+ while (idx !== -1) {
120
+ count++;
121
+ idx = text.indexOf(sub, idx + sub.length);
122
+ }
123
+ return count;
124
+ }
125
+
126
+ function summarizeArgs(args) {
127
+ try {
128
+ const s = JSON.stringify(args ?? {});
129
+ return s.length > 300 ? s.slice(0, 300) + "..." : s;
130
+ } catch {
131
+ return String(args);
132
+ }
133
+ }
134
+
135
+ function remainMs(until) {
136
+ return Math.max(0, until - Date.now());
137
+ }
138
+
139
+ function fmtRemain(ms) {
140
+ if (ms <= 0) return "无";
141
+ const min = Math.floor(ms / 60000);
142
+ const sec = Math.floor((ms % 60000) / 1000);
143
+ return min > 0 ? `${min} 分 ${sec} 秒` : `${sec} 秒`;
144
+ }
145
+
146
+ function parseArgs(raw) {
147
+ if (typeof raw === "string") {
148
+ try {
149
+ return JSON.parse(raw);
150
+ } catch {
151
+ return { raw };
152
+ }
153
+ }
154
+ return raw || {};
155
+ }
156
+
157
+ function sessionIdOfExec(exec) {
158
+ const agent = exec?.agent;
159
+ if (!agent) return "global";
160
+ if (typeof agent.session === "object" && agent.session?.id) return agent.session.id;
161
+ if (typeof agent.session === "string") return agent.session;
162
+ return "global";
163
+ }
164
+
165
+ /**
166
+ * /guard 命令 invocation 取会话 id(2026-08-31 会话寻址修复)。
167
+ * 官方 CommandInvocation session 字段(只有 commandId/agent/rawInput/attachments/signal),
168
+ * 旧代码 invocation?.session?.id 恒为 undefined → 永远落到 "global"(仅模板),而当前会话的
169
+ * contract 在创建时已快照、不随 global 变化 —— 这是"/guard mode change 放行无效"的根因。
170
+ * 官方类型:Agent.id session.id 是同一身份("The single identity shared with session"),
171
+ * 收到命令的代理 = 命令发起的会话;裁决侧 sessionIdOfExec 取 exec.agent.session.id —— 两者对齐。
172
+ */
173
+ export function sessionIdOfInvocation(invocation) {
174
+ const agent = invocation?.agent;
175
+ if (!agent) return "global";
176
+ if (typeof agent.id === "string" && agent.id) return agent.id;
177
+ if (typeof agent.session === "object" && agent.session?.id) return agent.session.id;
178
+ if (typeof agent.session === "string") return agent.session;
179
+ return "global";
180
+ }
181
+
182
+ /**
183
+ * 从 user/message 事件提取用户文本。
184
+ * 2026-08-24 根因修复:官方结构(@deepseek-ai/dsh-session/surface)中
185
+ * event.data 就是消息对象本身(deriveEventMessage case 'user/message' 返回 event.data),
186
+ * 并非 data.message——旧代码取 d.message 导致永远提取空文本:
187
+ * 旧行为 空文本 parseUserIntents("") 非空对象 → 规则 22 无故全拦;
188
+ * 中间版 if (!text) return 跳过 全放行(漏拦);
189
+ * 正解 = 取对字段;兼容 data 与 data.message 两种历史形态。
190
+ */
191
+ export function extractUserText(message) {
192
+ if (!message) return "";
193
+ if (typeof message === "string") return message;
194
+ const obj = typeof message === "object" && !Array.isArray(message)
195
+ ? (message.content !== undefined ? message : message.message)
196
+ : message;
197
+ const content = obj?.content;
198
+ if (typeof content === "string") return content;
199
+ if (Array.isArray(content)) {
200
+ return content
201
+ .map((b) => (b && typeof b === "object" && b.type === "text" ? b.text : ""))
202
+ .join("\n");
203
+ }
204
+ return "";
205
+ }
206
+
207
+ // ── session/event 处理 ──────────────────────────────────────────────────────
208
+
209
+ // O1 注入通道修复(2026-08-24,批次 6):
210
+ // 根因——maybeInject session/event 观察回调内同步调用 agent.inject,而 inject 底层是
211
+ // 同步 session.append("agent/inbox/spliced");dsh-session 对同一 session 的 append 有
212
+ // 同步重入保护(entry.appending → "session append cannot reenter while another append
213
+ // is being published"),观察回调正处在 append 发布边界内 → 必然抛错(README 局限 6 记录:
214
+ // 注入从未到达模型/界面,仅审计留痕)。
215
+ // 修复——投递延迟到当前 append 发布边界之后(宏任务 setTimeout 0),语义不变
216
+ // (inject 官方语义本就是 "queue context for the next pre-step, without waking");
217
+ // 插件卸载/热重载时统一清理 pending 定时器。
218
+ const pendingInjectTimers = new Set();
219
+
220
+ function maybeInject(ctx, sessionId, violation) {
221
+ if (!pluginConfig.correctInject) return;
222
+ const key = `${sessionId}:${violation.ruleId}`;
223
+ // D1(2026-08-28 阶段三):注入文案命令词静态检查——[规则引擎] 注入若含"请直接执行/不要再…
224
+ // 请立即…"类命令式短语,会驱动模型越过用户直接行动(实弹:节流注入后模型跳过用户回复);
225
+ // 注入只许陈述事实。命中 = 拒绝投递 + 审计留痕(防本次修复被下次改文案时回潮)。
226
+ const INJECT_COMMAND_RE = /请直接执行|请立即|不要再|勿再|请马上|现在就做|立刻执行|直接执行/;
227
+ if (INJECT_COMMAND_RE.test(String(violation?.reason || ""))) {
228
+ audit({
229
+ kind: "inject-command-gated",
230
+ rule: violation.ruleId,
231
+ name: "注入文案命令词拦截(D1)",
232
+ event: "inject",
233
+ reason: `注入文案含命令式短语,已拒绝投递(只许陈述事实):${String(violation.reason || "").slice(0, 100)}`,
234
+ session: sessionId
235
+ });
236
+ return;
237
+ }
238
+ // v0.5.7 投递资格闸(用户拍板"提醒一次并记住 + 会话每小时 3 条预算",2026-08-26):
239
+ // 同规则同会话仅投递一次(__self-cert 聚合除外),会话每小时至多 3 条。
240
+ // 被拦的违规仍写审计(inject-skip)——只有"弹进对话"被省掉,审计完整性不降级。
241
+ const gate = shouldDeliver({
242
+ now: Date.now(),
243
+ ruleId: violation.ruleId,
244
+ firstAt: state.injectAt.get(key) ?? null,
245
+ budgetTimes: state.injectBudget.get(sessionId) || []
246
+ });
247
+ if (!gate.ok) {
248
+ audit({
249
+ kind: "inject-skip",
250
+ rule: violation.ruleId,
251
+ name: "投递资格拦截",
252
+ event: "inject",
253
+ reason: `未投递(${gate.reason}):${String(violation.reason || "").slice(0, 80)}`,
254
+ session: sessionId
255
+ });
256
+ return;
257
+ }
258
+ state.injectAt.set(key, Date.now());
259
+ const budgetArr = state.injectBudget.get(sessionId) || [];
260
+ budgetArr.push(Date.now());
261
+ state.injectBudget.set(sessionId, budgetArr.slice(-20));
262
+ const count = state.injectCounts.get(key) || 0;
263
+ state.injectCounts.set(key, count + 1);
264
+ // C2 统计:注入(injected)计数(实际投递尝试,上限内)
265
+ {
266
+ const sStat = getSessionState(state, sessionId);
267
+ const st = (sStat.ruleStats ||= {});
268
+ const k = (st[violation.ruleId] ||= { detected: 0, suppressed: 0, injected: 0 });
269
+ k.injected++;
270
+ }
271
+ const timer = setTimeout(() => {
272
+ pendingInjectTimers.delete(timer);
273
+ try {
274
+ const agent = ctx.agents.get(sessionId);
275
+ if (agent && typeof agent.inject === "function") {
276
+ // 2026-08-24 批次 6 收尾(O1 实弹观测):注入消息必须具备官方身份(id+role)——
277
+ // dsh-llm 官方 createUserMessage 生成 { id: randomUUID(), role: "user", content, source };
278
+ // id Inbox.validate undefined 作身份,同批第二条起抛
279
+ // `message "undefined" is already pending`(实弹 17:56:37 八连发观测)。
280
+ agent.inject({
281
+ id: `re-${randomUUID()}`,
282
+ role: "user",
283
+ content: [
284
+ {
285
+ type: "text",
286
+ text: `[规则引擎] ${violation.reason}(规则 ${violation.ruleId},已记入 /guard log;下次回复请自证/纠正)`
287
+ }
288
+ ],
289
+ source: { kind: "plugin", plugin: name }
290
+ });
291
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入已投递(agent=${sessionId}):${String(violation.reason || "").slice(0, 120)}`, session: sessionId });
292
+ } else {
293
+ // D2(2026-08-28 阶段三):不可投递 agent(子代理/已销毁会话)——仅审计降噪留痕,
294
+ // reason 注明"预期降噪"便于 /guard log 区分真实失败;不再产生可见投递噪音。
295
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入未投递(D2 预期降噪):agents.get(${sessionId}) 无可用 agent.inject——仅审计留痕`, session: sessionId });
296
+ }
297
+ } catch (error) {
298
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入异常:${error instanceof Error ? error.message : String(error)}`, session: sessionId });
299
+ }
300
+ }, 0);
301
+ pendingInjectTimers.add(timer);
302
+ }
303
+
304
+ /**
305
+ * v0.5.7:D 级嫌疑异步裁决并投递(用户拍板"只有错误的行为才值得被提醒")。
306
+ * 裁决函数可注入(state.judgeFn,测试用 stub;生产 = judge.js 的 LLM 实现)。
307
+ * 结果审计:judge-pass(确认违规→投递)/ judge-false(合规声明/引述→不投)/
308
+ * judge-unavailable(失败/超时/预算满→fail-closed 不投)——三类全部留 /guard log 可对质。
309
+ */
310
+ async function deliverSuspects(ctx, sessionId, suspects, text) {
311
+ // 0.5.11(用户定稿,预算打穿修复):一次回复的全部嫌疑 = 一次批量裁决(一次 LLM/一条预算)
312
+ // 此前逐条 judgeViolation(每条一次调用/一条预算)导致 50/日早上耗尽(unavailable 89 条实证)。
313
+ const judgeFn = state.judgeFn || judgeViolation;
314
+ // 注入版/单条路径保持兼容:state.judgeFn(测试 stub)或仅 1 条嫌疑 → 单项路径
315
+ if (suspects.length <= 1 || state.judgeFn) {
316
+ const results = await Promise.all(suspects.map(async (v) => {
317
+ try {
318
+ const verdict = await judgeFn(ctx, state, sessionId, v, text);
319
+ const kind = verdict.action === "deliver" ? "judge-pass"
320
+ : verdict.action === "suppress" ? "judge-false"
321
+ : "judge-unavailable";
322
+ audit({
323
+ kind,
324
+ rule: v.ruleId,
325
+ name: "违规裁决",
326
+ event: "assistant/message",
327
+ reason: `${verdict.action}:${verdict.note || ""}${verdict.model ? `(model=${verdict.model})` : ""}`,
328
+ session: sessionId
329
+ });
330
+ return verdict.action === "deliver" ? v : null;
331
+ } catch (error) {
332
+ audit({
333
+ kind: "judge-unavailable",
334
+ rule: v.ruleId,
335
+ name: "裁决异常",
336
+ event: "assistant/message",
337
+ reason: `未投递:${error instanceof Error ? error.message : String(error)}`,
338
+ session: sessionId
339
+ });
340
+ return null;
341
+ }
342
+ }));
343
+ const deliver = results.filter(Boolean);
344
+ if (deliver.length > 1) {
345
+ const briefs = deliver.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`).join(";");
346
+ maybeInject(ctx, sessionId, {
347
+ ruleId: "__self-cert",
348
+ reason: `裁决通过 ${deliver.length} 项:${briefs}——请按 /guard log 明细应对`
349
+ });
350
+ } else if (deliver.length === 1) {
351
+ maybeInject(ctx, sessionId, deliver[0]);
352
+ }
353
+ return;
354
+ }
355
+ // 批量路径:一次 judgeViolationsBatch(一次调用/一次预算/逐条判定)
356
+ const batch = await judgeViolationsBatch(ctx, state, sessionId, suspects, text);
357
+ const deliver = [];
358
+ for (const { violation: v, verdict } of batch) {
359
+ const kind = verdict.action === "deliver" ? "judge-pass"
360
+ : verdict.action === "suppress" ? "judge-false"
361
+ : "judge-unavailable";
362
+ audit({
363
+ kind,
364
+ rule: v.ruleId,
365
+ name: "违规裁决",
366
+ event: "assistant/message",
367
+ reason: `${verdict.action}:${verdict.note || ""}${verdict.model ? `(model=${verdict.model})` : ""}`,
368
+ session: sessionId
369
+ });
370
+ if (verdict.action === "deliver") deliver.push(v);
371
+ }
372
+ if (deliver.length > 1) {
373
+ const briefs = deliver.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`).join(";");
374
+ maybeInject(ctx, sessionId, {
375
+ ruleId: "__self-cert",
376
+ reason: `裁决通过 ${deliver.length} 项:${briefs}——请按 /guard log 明细应对`
377
+ });
378
+ } else if (deliver.length === 1) {
379
+ maybeInject(ctx, sessionId, deliver[0]);
380
+ }
381
+ }
382
+
383
+ async function refreshSkills(ctx) {
384
+ try {
385
+ const list = await ctx.skills.list();
386
+ state.skillNames = new Set((list || []).map((s) => s && s.name).filter(Boolean));
387
+ } catch {
388
+ // 技能目录不可用时保留旧缓存,不阻断
389
+ }
390
+ }
391
+
392
+ /** v0.5.7 P0-4:验证通过记录持久化(文件损坏/写失败不阻断运行时) */
393
+ function persistVerifyPass(state) {
394
+ try {
395
+ writeFileSync(verifyFilePath(), JSON.stringify((state.verifyPass || []).slice(-100)), "utf8");
396
+ } catch {
397
+ // 持久化失败仅降级(内存态仍有效),不阻断
398
+ }
399
+ }
400
+
401
+ /** v0.5.7 后续(用户批准):unknown 工具白名单持久化(~/.dsh/rule-engine-tools.json——"允许使用 X"重启/热重载不丢)
402
+ * 0.5.9 升级:存储格式 v2 带元数据 [{name,time,session}]——可视化"谁、何时、哪个会话放行";
403
+ * 旧格式(字符串数组)兼容读取;持久化时合并既有 meta(历史条目不丢时间/来源)。 */
404
+ function persistToolWhitelist(state, sid) {
405
+ try {
406
+ const names = [...(state.unknownToolApproved || [])];
407
+ const prev = readWhitelistMeta();
408
+ const rows = mergeWhitelist([...prev.values()], names, sid);
409
+ writeFileSync(toolsWhitelistFilePath(), serializeWhitelist(rows), "utf8");
410
+ } catch {
411
+ // 持久化失败仅降级(内存态仍有效),不阻断
412
+ }
413
+ }
414
+
415
+ /** 读取白名单文件并按元数据归 Map(v1 字符串数组 → {name,time:null,session:null};v2 对象数组原样) */
416
+ function readWhitelistMeta() {
417
+ const m = new Map();
418
+ let raw = null;
419
+ try { raw = readFileSync(toolsWhitelistFilePath(), "utf8"); } catch {}
420
+ for (const row of parseWhitelist(raw || "")) m.set(row.name, row);
421
+ return m;
422
+ }
423
+
424
+ export function handleSessionEvent(ctx, session, event) {
425
+ maybeReloadIfChanged(state);
426
+ const sid = session?.id || "global";
427
+ const s = getSessionState(state, sid);
428
+ const d = event.data || {};
429
+ if (event.type === "turn/start") {
430
+ resetTurn(state, sid, d.turn);
431
+ return;
432
+ }
433
+ if (event.type === "turn/end") {
434
+ // M8 双通道机制:本回合用 dsh-manual-write 落盘手册/AGENTS 后,必须同轮 engram_store 沉淀记忆;
435
+ // 缺失 → 审计 + 注入纠正(用户明确要求机制化,2026-08-24)
436
+ if (s.turn.manualWriteSeen && !s.turn.engramStoreSeen) {
437
+ audit({
438
+ kind: "engram-gap",
439
+ rule: "__engram-gap",
440
+ name: "双通道记忆缺失",
441
+ event: "turn/end",
442
+ reason: "本回合 dsh-manual-write 落盘了手册/AGENTS,但未同轮调用 engram_store;按规则 19/77/M8 应在同一回合完成记忆沉淀",
443
+ session: sid
444
+ });
445
+ maybeInject(ctx, sid, {
446
+ ruleId: "__engram-gap",
447
+ reason: "规则 19/M8:手册/AGENTS 落盘后应在同一回合补 engram_store,否则记忆机制断链(已记审计)"
448
+ });
449
+ }
450
+ // F1(2026-08-28 阶段三):规则 2 时序竞态修复——assistant/message 检测到时间词违规时
451
+ // 不立即投递(Get-Date 工具常在本回合后续步骤才执行,事故实弹:correct 早于 Get-Date 放行),
452
+ // 标记 pendingRule2,turn/end 复核:getDateSeen 已定案——若本回合最终调用过 Get-Date,撤销(不投递)。
453
+ const p2 = s.turn.pendingRule2;
454
+ if (p2) {
455
+ if (s.turn.getDateSeen) {
456
+ audit({
457
+ kind: "rule2-resolved",
458
+ rule: "2",
459
+ name: "规则2回合末复核(已核对)",
460
+ event: "turn/end",
461
+ reason: "回合内最终已调用 Get-Date(getDateSeen=true)——assistant/message 时误报撤销,不投递",
462
+ session: sid
463
+ });
464
+ } else {
465
+ audit({
466
+ kind: "correct",
467
+ rule: "2",
468
+ name: "时间信息须真实(执行等级:B + D)",
469
+ event: "turn/end",
470
+ reason: p2
471
+ });
472
+ maybeInject(ctx, sid, { ruleId: "2", reason: p2 });
473
+ }
474
+ s.turn.pendingRule2 = null;
475
+ }
476
+ return;
477
+ }
478
+ if (event.type === "user/message") {
479
+ // 机制 A(2026-08-24):source.kind 主判——非 user 来源(官方 agent-instructions/skill-catalog、
480
+ // 插件自定义 kind)一律不进授权池、不覆盖回合状态;审计留痕可对账(source-skip)。
481
+ const srcKind = d?.source?.kind;
482
+ if (typeof srcKind === "string" && srcKind !== "user") {
483
+ const preview = extractUserText(d).slice(0, 80);
484
+ audit({
485
+ kind: "source-skip",
486
+ rule: "__source-skip",
487
+ name: "非用户来源消息跳过",
488
+ event: "user/message",
489
+ reason: `source.kind=${srcKind}:${preview}`,
490
+ session: sid
491
+ });
492
+ return;
493
+ }
494
+ // 系统注入剥离(2026-08-24 v2):注入与真实用户消息合并为同一事件
495
+ //(官方注入机制将 <system-reminder> 烤进 content;剥离后剩余文本 = 用户消息)。
496
+ // 2026-08-24 根因修复:event.data 即消息对象(官方 surface 结构),不再取 d.message。
497
+ const text = stripInjectedContext(extractUserText(d));
498
+ if (!text) return;
499
+ // 引擎自身注入的 [规则引擎] 提示不是用户消息,跳过——否则其中的“请直接执行”会被记录成授权(自我续授权漏洞)
500
+ if (isEngineInjectedMessage(text)) return;
501
+ // 模板兜底(对 kind=user 同样生效,防"标 user 的注入"——super-injector createUserMessage 同类风险):
502
+ // runtime context 快照 / 子代理完成通知 / vision-router 挂载提醒等已知注入模板 → 整体跳过
503
+ if (isKnownSystemInjection(text)) {
504
+ audit({
505
+ kind: "source-skip",
506
+ rule: "__source-skip",
507
+ name: "已知系统注入模板跳过",
508
+ event: "user/message",
509
+ reason: `文本模板命中:${text.slice(0, 80)}`,
510
+ session: sid
511
+ });
512
+ return;
513
+ }
514
+ // 批次 3(2026-08-24):用户明确"允许使用 X"→ 未归类工具会话白名(unknown→deny 的唯一批准路径)
515
+ if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
516
+ for (const hit of text.matchAll(/(?:允许|批准|同意)\s*(?:使用|调用|用|启用|放行)\s*([A-Za-z_][A-Za-z0-9_:.-]*)/g)) {
517
+ const toolName = hit[1];
518
+ if (toolClass(toolName, {}) !== "unknown") continue; // 只对未归类工具有意义(已知工具不走 unknown 分支)
519
+ const isNew = !state.unknownToolApproved.has(toolName);
520
+ state.unknownToolApproved.add(toolName);
521
+ if (isNew) {
522
+ if (!state.unknownToolSessionAdded) state.unknownToolSessionAdded = new Set();
523
+ state.unknownToolSessionAdded.add(toolName); // /guard tools 区分"本会话新增"
524
+ }
525
+ persistToolWhitelist(state, sid); // v0.5.7 后续:白名单持久化(重启/热重载不丢——用户批准"这句不用每次发")
526
+ audit({
527
+ kind: "unknown-tool-whitelist",
528
+ rule: "__unknown-tool",
529
+ name: "用户批准未归类工具",
530
+ event: "user/message",
531
+ reason: `用户明确允许使用 ${toolName}${isNew ? "" : "(重复,忽略)"} → 会话白名`,
532
+ session: sid
533
+ });
534
+ }
535
+ s.lastUserText = text;
536
+ s.turn.userText = text;
537
+ s.turn.realUserSeen = true; // v0.5.7:真实用户在场(注入轮不检测的依据——乒乓根治)
538
+ s.turn.questionOnly = isQuestionMessage(text);
539
+ s.turn.intents = parseUserIntents(text);
540
+ // 规则 22 粒度升级(2026-08-24):本回合授权范围 = execute 子句推导出的 type+path 范围
541
+ s.turn.scopes = scopesFromIntents(s.turn.intents);
542
+ // LLM 意图兜底(方案 A):低置信/歧义消息异步预取,不阻塞事件流;失败自动降级词表
543
+ // A2(阶段一):promise 记录到 turn——pre-execute 拦截点可同步等待,防"预取未回就按词表拦"竞态
544
+ if (pluginConfig.llmIntent?.enabled) {
545
+ const p = enrichIntentWithLlm(ctx, state, sid, text, pluginConfig.llmIntent);
546
+ s.turn.llmIntentPromise = p;
547
+ }
548
+ if (state.taskContract?.taskContractEnabled) {
549
+ const patch = naturalMode(text, s.contract);
550
+ if (patch) {
551
+ const res = applyContract(s.contract, patch);
552
+ if (res.changed) {
553
+ s.contract = res.contract;
554
+ audit({
555
+ kind: "task-contract",
556
+ rule: "__task-contract",
557
+ name: "任务契约更新",
558
+ event: "user/message",
559
+ reason: `${patch.source}: ${contractSummary(s.contract)}`,
560
+ session: sid
561
+ });
562
+ }
563
+ }
564
+ }
565
+ const intents = s.turn.intents;
566
+ let recordedAuth = false;
567
+ if (intents?.clauses?.some((c) => c.type === "execute")) {
568
+ // 与规则 22 粒度保持一致:按 scopesFromIntents 的多 type×多 path 逐条记录 12A 授权,
569
+ // 避免“规则 22 放行、12A 却因只记了单 type/单路径而拦截”的不一致(2026-08-24 第二轮修复)
570
+ const execScopes = s.turn.scopes && s.turn.scopes.length > 0 ? s.turn.scopes : scopesFromIntents(intents);
571
+ for (const scope of execScopes) {
572
+ recordAuthorization(state, sid, {
573
+ type: scope.type,
574
+ pathPrefix: scope.pathPrefix,
575
+ source: "user-message-clause",
576
+ clauseId: scope.clauseId
577
+ });
578
+ }
579
+ recordedAuth = true;
580
+ audit({ kind: "auth", rule: "12D", name: "用户消息执行子句授权", event: "user/message", reason: `记录执行分点授权:${text.slice(0, 120)}`, session: sid });
581
+ } else if (isAuthMessage(text) || isDirectiveMessage(text)) {
582
+ recordAuthorization(state, sid, {
583
+ type: inferTypeFromText(text),
584
+ pathPrefix: inferPathPrefixFromText(text),
585
+ source: "user-message"
586
+ });
587
+ recordedAuth = true;
588
+ audit({ kind: "auth", rule: "12D", name: "用户消息授权", event: "user/message", reason: `记录用户消息授权:${text.slice(0, 120)}`, session: sid });
589
+ }
590
+ const active = activateForUserMessage(state.configs, text);
591
+ if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: "用户消息命中触发词" }));
592
+ return;
593
+ }
594
+ if (event.type === "tool/call") {
595
+ const toolName = String(d.name || "");
596
+ const args = parseArgs(d.arguments);
597
+ s.turn.toolCount++;
598
+ if (s.turn.toolCount === 1) s.turn.firstToolName = toolName;
599
+ if (toolName === "ask_user_question") {
600
+ s.turn.askSeen = true;
601
+ s.turn.pendingAsk = { callId: d.callId, questions: args?.questions || [] };
602
+ }
603
+ const pendingCall = { name: toolName, args };
604
+ const targetPath = args?.file_path ?? args?.path;
605
+ if ((toolName === "edit" || toolName === "write" || (toolName === "str_replace_editor" && args?.command !== "view")) && targetPath && isVersionedFile(targetPath)) {
606
+ try {
607
+ pendingCall.originalContent = readFileSync(targetPath, "utf8");
608
+ } catch {
609
+ pendingCall.originalContent = null;
610
+ }
611
+ }
612
+ s.turn.pendingToolCalls.set(d.callId, pendingCall);
613
+ if (toolName === "pwsh" || toolName === "bash") {
614
+ const cmd = args?.command || args?.code || "";
615
+ if (isGetDateCommand(cmd)) s.turn.getDateSeen = true;
616
+ // workdir 校验提示(机制批 M6):命令含显式相对路径(./ 或 ../)但未声明 workdir → 审计提示(不拦)
617
+ if (cmd && /(?:^|\s)(?:\.{1,2}[\\/])/.test(cmd) && !args?.workdir) {
618
+ audit({
619
+ kind: "workdir-hint",
620
+ rule: "__workdir-hint",
621
+ name: "workdir 提示",
622
+ event: "tool/call",
623
+ tool: toolName,
624
+ reason: "命令含相对路径但未显式声明 workdir——易与当前目录错配(机制批 M6),pws 请显式 workdir",
625
+ session: sid
626
+ });
627
+ }
628
+ const bp = backupPathsFromTool(toolName, args);
629
+ if (bp) {
630
+ if (!pendingCall.backupPaths) pendingCall.backupPaths = [];
631
+ pendingCall.backupPaths.push(bp);
632
+ }
633
+ }
634
+ if (toolName !== "pwsh" && toolName !== "bash") {
635
+ const bpTool = backupPathsFromTool(toolName, args);
636
+ if (bpTool) {
637
+ if (!pendingCall.backupPaths) pendingCall.backupPaths = [];
638
+ pendingCall.backupPaths.push(bpTool);
639
+ }
640
+ }
641
+ if (isManualReadTool(toolName, args)) s.manualReadSeen = true;
642
+ if (toolName === "skill" && args?.name) s.turn.skillNames.push(args.name);
643
+ s.turn.toolNames.push(toolName);
644
+ if (state.taskContract?.taskContractEnabled) {
645
+ recordAction(s, toolName, args);
646
+ if (isObserving(s.contract, state.taskContract)) {
647
+ const action = classifyAction(toolName, args);
648
+ const problems = [];
649
+ if (action.hashIntent && s.contract.hashPolicy !== "allow") problems.push("hash 未授权");
650
+ if (action.dependencyIntent && s.contract.dependencyPolicy !== "allow") problems.push("依赖未授权");
651
+ if (isRepeatedTaskAction(s, toolName, args)) problems.push("重复动作打转");
652
+ if (problems.length) {
653
+ audit({
654
+ kind: "task-observe",
655
+ rule: "__task-contract",
656
+ name: "任务契约观察",
657
+ event: "tool/call",
658
+ tool: toolName,
659
+ args: summarizeArgs(args),
660
+ reason: `[观察] ${problems.join(";")}`,
661
+ session: sid
662
+ });
663
+ }
664
+ }
665
+ }
666
+ const active = activateForToolCall(state.configs, toolName, args);
667
+ if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: `工具 ${toolName} 命中` }));
668
+ return;
669
+ }
670
+ if (event.type === "tool/result") {
671
+ const resultBlock = Array.isArray(d.message?.content)
672
+ ? d.message.content.find((b) => b && b.type === "tool-result")
673
+ : null;
674
+ const callId = resultBlock?.toolCallId ?? d.callId;
675
+ const isError = Boolean(d.error) || resultBlock?.isError === true;
676
+ const pendingCall = s.turn.pendingToolCalls.get(callId);
677
+ if (pendingCall) {
678
+ const key = `${pendingCall.name}:${JSON.stringify(pendingCall.args || {})}`;
679
+ if (isError) {
680
+ // 规则 1 计数解耦(P1-7,2026-08-24):引擎 deny 来源的失败不计入“同工具连续失败”
681
+ if (!((state.deniedKeys || new Set()).has(key))) {
682
+ state.retryCounts.set(key, (state.retryCounts.get(key) || 0) + 1);
683
+ } else {
684
+ state.retryCounts.delete(key);
685
+ }
686
+ } else {
687
+ state.retryCounts.delete(key);
688
+ }
689
+ s.turn.pendingToolCalls.delete(callId);
690
+ }
691
+
692
+ // 备份证据只在工具调用成功后才记录;被拦截/失败的调用不产生备份记录
693
+ if (!isError && pendingCall?.backupPaths?.length) {
694
+ for (const bp of pendingCall.backupPaths) {
695
+ recordBackup(state, sid, bp.targetPath, bp.backupPath);
696
+ }
697
+ }
698
+
699
+ // 0.5.12(F5 批次 3):任务契约完成自动降级——契约内命令成功(exit 0)且为
700
+ // install/build 完成类 契约自动降为 watch(普通判定,不再按契约放行;TTL 不再续期)。
701
+ // "契约开着、任务已完、命令还能放行"窗口(最坏情况缓解)。
702
+ try {
703
+ if (state.taskContract?.taskContractEnabled && s.contract && s.contract.mode !== "unconfirmed" && !isError && pendingCall?.name === "pwsh") {
704
+ const cmdText = String(pendingCall.args?.command || "");
705
+ const cat = categoryOfCommand(cmdText);
706
+ if (isNonDestructiveAndComplete(cat)) {
707
+ if (s.contract.mode !== "watch") {
708
+ const prevMode = s.contract.mode;
709
+ s.contract = applyContract(s.contract, { mode: "watch", level: "guard", source: "auto-complete" }).contract;
710
+ audit({
711
+ kind: "task-contract",
712
+ rule: "__task-contract",
713
+ name: "任务契约完成自动降级",
714
+ event: "tool/result",
715
+ reason: `契约内 ${cat} 命令成功(exit 0)→ 自动降级 ${prevMode}→watch(任务完成,契约不再续期)`,
716
+ session: sid
717
+ });
718
+ }
719
+ }
720
+ }
721
+ } catch {
722
+ // 降级失败不阻断主流程(保守:契约维持,TTL 兜底)
723
+ }
724
+
725
+ // 0.5.10 建议5(已知坑错误码召回):错误结果文本命中特征表 → 审计 + 注入指向知识库的提示
726
+ //(本轮去重;仍走 maybeInject 资格链——真实用户在场/预算内才投递,不违反注入噪音治理)
727
+ if (isError) {
728
+ const errText = String(
729
+ resultBlock?.content?.map?.((c) => c?.text || "").join(" ") ||
730
+ d?.message?.content?.map?.((c) => c?.text || "").join(" ") ||
731
+ ""
732
+ );
733
+ const pit = matchKnownPitfall(errText);
734
+ if (pit) {
735
+ if (!s.turn.errorHints) s.turn.errorHints = new Set();
736
+ if (!s.turn.errorHints.has(pit.key)) {
737
+ s.turn.errorHints.add(pit.key);
738
+ audit({
739
+ kind: "error-hint",
740
+ rule: "__error-hint",
741
+ name: "已知坑召回",
742
+ event: "tool/result",
743
+ tool: pendingCall?.name || undefined,
744
+ reason: `${pit.key} 命中特征表(建议5:错误码→知识库)`,
745
+ session: sid
746
+ });
747
+ maybeInject(ctx, sid, { ruleId: "__error-hint", reason: pit.hint });
748
+ }
749
+ }
750
+ }
751
+
752
+ // M8 双通道机制(2026-08-24):dsh-manual-write 落盘成功 → 标记;同轮 engram_store 成功 → 标记
753
+ if (!isError && pendingCall) {
754
+ const cmd = pendingCall.args?.command || pendingCall.args?.code || "";
755
+ if ((pendingCall.name === "pwsh" || pendingCall.name === "bash") && /\bdsh-manual-write\.mjs/.test(cmd)) {
756
+ s.turn.manualWriteSeen = true;
757
+ }
758
+ if (pendingCall.name === "engram_store") {
759
+ s.turn.engramStoreSeen = true;
760
+ }
761
+ }
762
+
763
+ // 任务契约:委托预算扣减已移至 pre-execute 启动即计(批次 5.5;原 tool/result 路径因
764
+ // subagent 无 pendingCall 从未执行——本块删除防双重扣减)
765
+
766
+ // 规则 27:装配变更成功后全局 revision +1;审计命令输出通过/失败后更新本会话审计 revision
767
+ if (pendingCall) {
768
+ if (!isError && isAssemblyMutationTool(pendingCall.name, pendingCall.args)) {
769
+ state.mountRevision += 1;
770
+ state.mountSignature = computeMountSignature(profileNameFromArgs(pendingCall.args));
771
+ audit({
772
+ kind: "mount-dirty",
773
+ rule: "27",
774
+ name: "插件装配变更",
775
+ event: "tool/result",
776
+ reason: `装配已变更(mountRevision=${state.mountRevision},哈希=${state.mountSignature.slice(0, 8)}),继续装配/重启前需先跑全量审计`,
777
+ session: sid,
778
+ tool: pendingCall.name,
779
+ args: summarizeArgs(pendingCall.args)
780
+ });
781
+ }
782
+ const auditCmd = pendingCall.args?.command || pendingCall.args?.code || "";
783
+ if (isAuditCommand(auditCmd)) {
784
+ const output = extractToolOutput(d);
785
+ if (auditOutputFailed(output)) {
786
+ audit({
787
+ kind: "mount-audit-fail",
788
+ rule: "27",
789
+ name: "全量审计未通过",
790
+ event: "tool/result",
791
+ reason: "审计脚本发现 DUPLICATES FOUND",
792
+ session: sid,
793
+ tool: pendingCall.name
794
+ });
795
+ maybeInject(ctx, sid, { ruleId: "27", reason: "规则 27:全量审计未通过,先移除多余挂载再重跑审计" });
796
+ } else if (isError) {
797
+ // 2026-08-29 A1:审计命令未执行成功(被规则 22 拦截/工具错误,无审计输出)≠ 审计发现 DUPLICATES——
798
+ // 不注入"先移除多余挂载"(误导:审计根本没执行;历史误报曾引导模型误移除正确挂载)。
799
+ // 保持 dirty(审计未跑成,不知道结果);留痕 mount-audit-error 供 /guard log 排查。
800
+ audit({
801
+ kind: "mount-audit-error",
802
+ rule: "27",
803
+ name: "审计命令未执行成功",
804
+ event: "tool/result",
805
+ reason: "审计脚本被拦截或执行失败(无审计输出);请先处理拦截原因后重跑审计",
806
+ session: sid,
807
+ tool: pendingCall.name
808
+ });
809
+ } else if (auditOutputPassed(output)) {
810
+ s.mountAuditRevision = state.mountRevision;
811
+ s.mountAuditSignature = computeMountSignature(profileNameFromArgs(pendingCall.args));
812
+ audit({
813
+ kind: "mount-audit-pass",
814
+ rule: "27",
815
+ name: "全量审计通过",
816
+ event: "tool/result",
817
+ reason: `audit-mount-consistency 输出 MOUNT CONSISTENT(mountRevision=${state.mountRevision},装配哈希=${s.mountAuditSignature.slice(0, 8)})`,
818
+ session: sid,
819
+ tool: pendingCall.name
820
+ });
821
+ }
822
+ }
823
+ }
824
+
825
+ // 版本文件写后自检:失败自动回滚 + 审计
826
+ const versionTarget = pendingCall?.args?.file_path ?? pendingCall?.args?.path;
827
+ if (!isError && pendingCall && pendingCall.originalContent != null && versionTarget && isVersionedFile(versionTarget)) {
828
+ try {
829
+ const current = readFileSync(versionTarget, "utf8");
830
+ // 0.5.11:唯一性透传(与 guard-core 同口径——old 唯一匹配才算"单行重写"放行前提)
831
+ const oldStr = pendingCall.args.old_string || pendingCall.args.old_str || "";
832
+ const uniqueMatch = oldStr.length > 0 ? countOccurrencesStr(pendingCall.originalContent, oldStr) === 1 : false;
833
+ const check = validateEditedFile(
834
+ pendingCall.originalContent,
835
+ current,
836
+ oldStr,
837
+ pendingCall.args.new_string || pendingCall.args.new_str || "",
838
+ uniqueMatch
839
+ );
840
+ if (!check.ok) {
841
+ writeFileSync(versionTarget, pendingCall.originalContent, "utf8");
842
+ const reason = `edit 工具显示成功,但内容已被 version-guard 回滚:${check.errors.join(";")}`;
843
+ audit({
844
+ kind: "rollback",
845
+ rule: "__version-guard",
846
+ name: "版本文件写后自检",
847
+ event: "tool/result",
848
+ reason,
849
+ session: sid,
850
+ file: versionTarget
851
+ });
852
+ maybeInject(ctx, sid, { ruleId: "__version-guard", reason });
853
+ }
854
+ } catch (error) {
855
+ audit({
856
+ kind: "rollback-error",
857
+ rule: "__version-guard",
858
+ name: "版本文件写后自检",
859
+ event: "tool/result",
860
+ reason: error instanceof Error ? error.message : String(error),
861
+ session: sid
862
+ });
863
+ }
864
+ }
865
+
866
+ // 交付闸门采集(机制批 M3):测试/冷加载探针通过输出 → verify-pass 记录(供规则 23④ 声明核对)
867
+ // 2026-08-25 鲁棒化(实弹观测):旧条件依赖 pendingCall(tool/call→result 的 callId 匹配),
868
+ // RC.2 下疑似失配导致采集从未执行——23④ 每次声明都 verify-gap;改为只看输出文本,不依赖
869
+ // pendingToolCalls 状态(what 降级记录),verify-pass 语义不变(输出即证据)。
870
+ if (!isError) {
871
+ const output = extractToolOutput(d);
872
+ if (/\bALL TESTS PASSED\b|RESULT:\s*PASS|\bVERIFY ALL OK\b/.test(output)) {
873
+ if (!state.verifyPass) state.verifyPass = [];
874
+ state.verifyPass.push({ at: Date.now(), sessionId: sid, what: pendingCall?.name || "tool-result" });
875
+ if (state.verifyPass.length > 100) state.verifyPass.splice(0, state.verifyPass.length - 100);
876
+ persistVerifyPass(state); // v0.5.7 P0-4:持久化(热重载/重启不丢)
877
+ }
878
+ }
879
+
880
+ // 命令输出静默错误检测(不阻断,只审计 + 注入提醒)
881
+ if (!isError && pendingCall && (pendingCall.name === "pwsh" || pendingCall.name === "bash")) {
882
+ const output = extractToolOutput(d);
883
+ const det = detectSilentError(output, s.lastCommandOutput);
884
+ if (det.suspicious) {
885
+ audit({
886
+ kind: "silent-error",
887
+ rule: "__silent-error",
888
+ name: "命令输出静默错误",
889
+ event: "tool/result",
890
+ reason: det.reason,
891
+ session: sid,
892
+ tool: pendingCall.name
893
+ });
894
+ maybeInject(ctx, sid, { ruleId: "__silent-error", reason: det.reason });
895
+ }
896
+ if (output) s.lastCommandOutput = output;
897
+ }
898
+
899
+ const pending = s.turn.pendingAsk;
900
+ if (pending) {
901
+ const result = d.result ?? d.value ?? d;
902
+ if (askResultApproved(result)) {
903
+ s.turn.realUserSeen = true; // v0.5.7:ask 弹窗答复也算"真实用户在场"
904
+ const qText = askQuestionText(pending.questions);
905
+ const selectedText = askResultSelectedText(result);
906
+ // 授权范围只取“问题核心 + 用户实际选择”,不再把全部选项描述纳入;
907
+ // 避免“仅会话内说明/不跨会话保留”等选项说明把一次小授权放大成全局 12h(2026-08-24 修复)
908
+ const scopeText = `${askQuestionCoreText(pending.questions)} ${selectedText}`.trim();
909
+ const pathPrefix = inferPathPrefixFromText(scopeText || qText);
910
+ // 0.5.10 建议1①:ask 答复结构化——操作类型从答复文本推断(write/command/any),不再一律 any
911
+ // (修复"弹窗答复接不住真想操作"的粒度问题——V476AL 同族)
912
+ const authRecord = {
913
+ type: classifyAskScopeType(scopeText || qText),
914
+ pathPrefix,
915
+ source: "ask"
916
+ };
917
+ const sessionWide = isSessionWideAskText(scopeText) || isSessionWideAskText(selectedText);
918
+ if (sessionWide) authRecord.expiresAt = Date.now() + 12 * 60 * 60 * 1000;
919
+ else if (!pathPrefix) authRecord.expiresAt = Date.now() + 2 * 60 * 1000;
920
+ recordAuthorization(state, sid, authRecord);
921
+ // 2026-08-25 修复(用户授权):ask 答复也是用户明确允许——若答复文本含"允许使用 X"模式,
922
+ // 同步写入 unknown 工具会话白名单(与 user/message 分支同一判定;此前只有用户消息文本可写白名,
923
+ // ask 弹窗授权后 web_fetch 等仍被 unknown deny 拦——机制缺口,本修复关闭)
924
+ const whtText = `${qText} ${selectedText}`;
925
+ for (const hit of whtText.matchAll(/(?:允许|批准|同意)\s*(?:使用|调用|用|启用|放行)\s*([A-Za-z_][A-Za-z0-9_:.-]*)/g)) {
926
+ const wname = hit[1];
927
+ if (toolClass(wname, {}) !== "unknown") continue;
928
+ if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
929
+ const isNew = !state.unknownToolApproved.has(wname);
930
+ state.unknownToolApproved.add(wname);
931
+ if (isNew) {
932
+ if (!state.unknownToolSessionAdded) state.unknownToolSessionAdded = new Set();
933
+ state.unknownToolSessionAdded.add(wname);
934
+ }
935
+ persistToolWhitelist(state, sid); // v0.5.7 后续:白名单持久化(见上)
936
+ audit({
937
+ kind: "unknown-tool-whitelist",
938
+ rule: "__unknown-tool",
939
+ name: "用户批准未归类工具(ask 答复)",
940
+ event: "tool/result",
941
+ reason: `ask 答复含"允许使用 ${wname}" → 会话白名${isNew ? "" : "(重复,忽略)"}(来源=ask 选择文本)`,
942
+ session: sid
943
+ });
944
+ }
945
+ if (!s.turn.scopes) s.turn.scopes = [];
946
+ s.turn.scopes.push(authRecord);
947
+ audit({
948
+ kind: "auth",
949
+ rule: "12D",
950
+ name: "ask_user_question 授权",
951
+ event: "tool/result",
952
+ reason: `记录授权范围:${qText.slice(0, 120)}`,
953
+ session: sid
954
+ });
955
+ } else if (askResultRejected(result)) {
956
+ // 弹窗消减(2026-08-24):本回合 ask 被【明确拒绝】(用户点了拒绝/否定词)→ 标记 + 全局记录
957
+ // C32026-08-28 阶段二):未响应/超时/无选择不再记为拒绝——"用户没时间回复"不是拒绝,
958
+ // 旧逻辑把任何未批准都入 askRejections 池 → 5 分钟内再 ask 被节流,用户在忙时反而被烧掉
959
+ // 授权通道(实弹:用户"你也没给我时间回复啊")。敏感操作仍由 12A 授权证据把关(不降级)。
960
+ s.turn.askRejected = true;
961
+ if (!state.askRejections) state.askRejections = [];
962
+ state.askRejections.push({ sessionId: sid, at: Date.now() });
963
+ if (state.askRejections.length > 200) state.askRejections.splice(0, state.askRejections.length - 200);
964
+ audit({
965
+ kind: "auth-reject",
966
+ rule: "12D",
967
+ name: "ask_user_question 未授权",
968
+ event: "tool/result",
969
+ reason: "用户明确拒绝该授权请求(拒绝词命中,计入节流池)",
970
+ session: sid
971
+ });
972
+ } else {
973
+ // C3:未响应/超时/无明确选择 不置 askRejected、不入节流池(仅审计留痕)
974
+ audit({
975
+ kind: "auth-noanswer",
976
+ rule: "12D",
977
+ name: "ask_user_question 未响应",
978
+ event: "tool/result",
979
+ reason: "ask 无结果/未响应(用户可能未及回复)——不计入拒绝节流池;后续可再次询问",
980
+ session: sid
981
+ });
982
+ }
983
+ s.turn.pendingAsk = null;
984
+ }
985
+ return;
986
+ }
987
+ if (event.type === "assistant/chunk") {
988
+ const chunk = d.chunk;
989
+ if (chunk && chunk.type === "reasoning-delta" && typeof chunk.text === "string") {
990
+ s.turn.reasoningText += chunk.text;
991
+ }
992
+ return;
993
+ }
994
+ if (event.type === "assistant/message") {
995
+ const text = extractAssistantText(d.message);
996
+ // v0.5.7 0 站:注入轮(无真实用户消息)不检测不投递——这是乒乓循环的根治:
997
+ // 引擎自己的注入 → 我的回复 → 再检测 的燃料被掐断(实测 2026-08-26 12:37 六圈即此循环)。
998
+ if (!shouldDetectTurn({ realUserSeen: s.turn.realUserSeen })) return;
999
+ // 交付声明机器闸门(机制批 M3,规则 23④):完成类声明 → 核对同会话 30 分钟内 verify-pass 记录,缺失注入纠正
1000
+ // F2(2026-08-28 阶段三):词面收紧——"完成"裸词太宽("完成社区检索/尚未完成/正在完成"均误触),
1001
+ // 改为强完成声明模式且排除否定/进行态;仍由 LLM 裁决层兜底(deliverSuspects)。
1002
+ if (DELIVERY_RE.test(text)) {
1003
+ const windowStart = Date.now() - 30 * 60 * 1000;
1004
+ const hasPass = (state.verifyPass || []).some((v) => v.sessionId === sid && v.at > windowStart);
1005
+ if (!hasPass) {
1006
+ audit({
1007
+ kind: "verify-gap",
1008
+ rule: "23",
1009
+ name: "交付声明缺验证闸门记录",
1010
+ event: "assistant/message",
1011
+ reason: "交付/完成类声明缺少同会话近期 verify-pass(ALL TESTS PASSED / check-plugin-load RESULT: PASS)记录",
1012
+ session: sid
1013
+ });
1014
+ // v0.5.7 后续(用户拍板"暗示型统一裁决"):23④ 词面命中只是嫌疑——"完成"≠交付声明
1015
+ //("完成社区检索"类误触的修复):送 LLM 裁决,确认违规才投递。
1016
+ void deliverSuspects(ctx, sid, [{
1017
+ ruleId: "23",
1018
+ title: "变更与交付验证(执行等级:D 强)",
1019
+ kind: "self-certify",
1020
+ awaitingJudge: true,
1021
+ reason: "规则 23④:完成/通过类声明需同会话近期验证记录(测试全绿或冷加载 PASS),缺失已记审计"
1022
+ }], text);
1023
+ }
1024
+ }
1025
+ // 需求基线覆盖核查(RB-09,2026-08-24):方案/重构/整体完成类声明 基线仍存在 planned 需求 → 审计纠正(fail-closed)
1026
+ if (BASELINE_COMPLETE_RE.test(text)) {
1027
+ const bl = loadBaselineFile();
1028
+ if (bl.ok) {
1029
+ const gap = computeBaselineGap(text, bl.items);
1030
+ if (gap) {
1031
+ audit({
1032
+ kind: "baseline-gap",
1033
+ rule: "__baseline-gap",
1034
+ name: "需求基线未覆盖",
1035
+ event: "assistant/message",
1036
+ reason: `完成类声明但需求基线仍有 ${gap.count} 条未落地(${gap.pending.join("、")})——先完成或更新基线再宣布完成`,
1037
+ session: sid
1038
+ });
1039
+ maybeInject(ctx, sid, {
1040
+ ruleId: "__baseline-gap",
1041
+ reason: `需求基线仍有 ${gap.count} 条未落地(${gap.pending.join("")}),请先完成或更新基线再宣布整体完成`
1042
+ });
1043
+ }
1044
+ } else {
1045
+ audit({
1046
+ kind: "baseline-unreadable",
1047
+ rule: "__baseline-gap",
1048
+ name: "需求基线不可读",
1049
+ event: "assistant/message",
1050
+ reason: `完成类声明但需求基线文件无法读取(${bl.error || "未知"})——fail-closed,无法核查覆盖`,
1051
+ session: sid
1052
+ });
1053
+ }
1054
+ }
1055
+ // F1(2026-08-28 阶段三):规则 2 违规不在此时投递——标记 pendingRule2,turn/end 复核(Get-Date 定案)①
1056
+ let violations = detectViolations({ configs: state.configs, session: s, text, reasoningText: s.turn.reasoningText, mountRevision: state.mountRevision });
1057
+ const rule2s = violations.filter((v) => v.ruleId === "2");
1058
+ for (const v of rule2s) {
1059
+ if (!s.turn.pendingRule2) s.turn.pendingRule2 = v.reason;
1060
+ violations = violations.filter((x) => x !== v);
1061
+ }
1062
+ for (const v of violations) {
1063
+ audit({
1064
+ kind: v.kind,
1065
+ rule: v.ruleId,
1066
+ name: v.title,
1067
+ event: "assistant/message",
1068
+ reason: v.reason,
1069
+ session: sid
1070
+ });
1071
+ }
1072
+ // v0.5.7 分流水线:B 级机器型(correct,词表=确定证据)立即投递;
1073
+ // D 级语义型命中 = 嫌疑(awaitingJudge)→ 先裁决,只有裁决=违规的才投递
1074
+ // (用户拍板:"只有错误的行为才值得被提醒"——词表只产嫌疑,不再定罪)。
1075
+ const immediate = violations.filter((v) => v.kind !== "self-certify");
1076
+ const suspects = violations.filter((v) => v.awaitingJudge === true);
1077
+ if (immediate.length > 1) {
1078
+ const briefs = immediate
1079
+ .map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`)
1080
+ .join("");
1081
+ maybeInject(ctx, sid, {
1082
+ ruleId: "__self-cert",
1083
+ reason: `本轮检出 ${immediate.length} 项:${briefs}——请按 /guard log 明细应对`
1084
+ });
1085
+ } else if (immediate.length === 1) {
1086
+ maybeInject(ctx, sid, immediate[0]);
1087
+ }
1088
+ if (suspects.length > 0) {
1089
+ void deliverSuspects(ctx, sid, suspects, text);
1090
+ }
1091
+ if (violations.length) {
1092
+ state.lastActive = violations.map((v) => ({ ruleId: v.ruleId, title: v.title, reason: v.reason }));
1093
+ } else {
1094
+ const active = activateForAssistant(state.configs, text);
1095
+ if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: "assistant 文本进入 B/D 检测" }));
1096
+ }
1097
+ return;
1098
+ }
1099
+ }
1100
+
1101
+ // ── /guard 命令 ─────────────────────────────────────────────────────────────
1102
+
1103
+ // ── /guard 子命令单一真源(0.5.13:hint 手写漏 tools 修复)──
1104
+ // hint(输入框提示)与 USAGE(完整帮助)均由此派生:未来新增子命令只改这一个数组。
1105
+ const COMMAND_SPECS = [
1106
+ { name: "status", args: "", desc: "引擎状态" },
1107
+ { name: "rules", args: "", desc: "规则清单 + 理解产物" },
1108
+ { name: "active", args: "", desc: "最近激活的规则" },
1109
+ { name: "log", args: "[N]", desc: "最近 N 条审计(默认 10)" },
1110
+ { name: "unlock", args: "[N]", desc: "解锁配置写保护 N 分钟(默认 10,仅用户)" },
1111
+ { name: "bypass", args: "[N]", desc: "临时整体放行 N 分钟(默认 5,仅用户)" },
1112
+ { name: "lock", args: "", desc: "立即恢复全部守卫(取消解锁/放行)" },
1113
+ { name: "revoke", args: "", desc: "撤销全部授权记录" },
1114
+ { name: "tools", args: "", desc: "工具放行白名单(永久+本会话,含时间/来源会话)" },
1115
+ { name: "tools revoke", args: "<工具名>", desc: "撤销白名单条目(持久化+会话集)" },
1116
+ { name: "reload", args: "", desc: "强制重解析 AGENTS.md" },
1117
+ { name: "mode", args: "<模式>", desc: "设置任务契约模式(review/answer/change/monitor/watch/off)" },
1118
+ { name: "budget", args: "...", desc: "设置预算(agents=N files=... deps=allow hash=allow)" },
1119
+ { name: "contract", args: "", desc: "查看当前任务契约" },
1120
+ { name: "contract categories", args: "...", desc: "设定契约类别白名单(0.5.12)" },
1121
+ { name: "label", args: "<id> <label>", desc: "给审计记录打标(correct/incorrect/inconclusive)" }
1122
+ ];
1123
+
1124
+ const USAGE = [
1125
+ "用法:",
1126
+ ...COMMAND_SPECS.map((s) => ` /guard ${s.name}${s.args ? " " + s.args : ""} ${s.desc}`),
1127
+ "",
1128
+ "说明:",
1129
+ " - 守卫 = 硬拦截:违反规则的工具调用直接拒绝,模型无法自行绕过;",
1130
+ " - 解锁/放行只能由你(用户)在对话框输入命令执行,助手无法代替;",
1131
+ " - 每次拦截/纠察都会记录到 " + auditFilePath() + "(/guard log 可查)。"
1132
+ ].join("\n");
1133
+
1134
+ // 输入框提示:由子命令清单自动派生(顶层命令名去重,新子命令/别名自动同步)
1135
+ // eslint-disable-next-line no-unused-vars
1136
+ const GUARD_HINT = "[" + [...new Set(COMMAND_SPECS.map((s) => s.name.split(" ")[0]))].join("|") + "]";
1137
+
1138
+ function parseCommand(rawInput) {
1139
+ const text = (rawInput || "").trim();
1140
+ if (!text || /^(status|state)$/i.test(text)) return { kind: "status" };
1141
+ if (/^(rules|list|ls)$/i.test(text)) return { kind: "rules" };
1142
+ if (/^(active)$/i.test(text)) return { kind: "active" };
1143
+ if (/^(help|\?)$/i.test(text)) return { kind: "help" };
1144
+ let m = text.match(/^unlock\s*(\d+)?$/i);
1145
+ if (m) return { kind: "unlock", minutes: m[1] ? Number(m[1]) : 10 };
1146
+ m = text.match(/^bypass\s*(\d+)?$/i);
1147
+ if (m) return { kind: "bypass", minutes: m[1] ? Number(m[1]) : 5 };
1148
+ if (/^lock$/i.test(text)) return { kind: "lock" };
1149
+ if (/^revoke$/i.test(text)) return { kind: "revoke" };
1150
+ m = text.match(/^log\s*(\d+)?$/i);
1151
+ if (m) return { kind: "log", n: m[1] ? Number(m[1]) : 10 };
1152
+ if (/^reload$/i.test(text)) return { kind: "reload" };
1153
+ if (/^tools$/i.test(text)) return { kind: "tools", action: "list" };
1154
+ m = text.match(/^tools revoke\s+([A-Za-z_][A-Za-z0-9_:.-]*)$/i);
1155
+ if (m) return { kind: "tools", action: "revoke", target: m[1] };
1156
+ if (/^mode\b/i.test(text)) {
1157
+ const parsed = parseModeCommand(text);
1158
+ if (parsed) return { kind: "mode", ...parsed };
1159
+ }
1160
+ if (/^budget\b/i.test(text)) {
1161
+ const parsed = parseBudgetCommand(text);
1162
+ if (parsed) return { kind: "budget", patch: parsed };
1163
+ }
1164
+ if (/^contract$/i.test(text)) return { kind: "contract" };
1165
+ // 0.5.12(F5 批次 3):/guard contract categories <build,test,install,...> 设定类别白名单
1166
+ m = text.match(/^contract\s+categories\s+(.+)$/i);
1167
+ if (m) {
1168
+ const cats = m[1].split(/[,,\s]+/).map((c) => c.trim().toLowerCase()).filter(Boolean);
1169
+ return { kind: "contract-categories", categories: cats };
1170
+ }
1171
+ m = text.match(/^contract\s+categories\s*$/i);
1172
+ if (m) return { kind: "contract-categories", categories: [] };
1173
+ m = text.match(/^label\s+(\S+)\s+(correct|incorrect|inconclusive)$/i);
1174
+ if (m) return { kind: "label", eventId: m[1], label: m[2].toLowerCase() };
1175
+ m = text.match(/^label\s+clear\s+(.+)$/i);
1176
+ if (m) return { kind: "label-clear", fingerprint: m[1].trim() };
1177
+ return { kind: "invalid" };
1178
+ }
1179
+
1180
+ async function executeGuard(ctx, invocation) {
1181
+ const command = parseCommand(invocation.rawInput);
1182
+ switch (command.kind) {
1183
+ case "help":
1184
+ return { kind: "success", text: USAGE };
1185
+ case "status": {
1186
+ const conf = loadPluginConfig();
1187
+ state.enabled = conf.enabled;
1188
+ const high = state.configs.filter((c) => c.confidence === "high").length;
1189
+ const medium = state.configs.filter((c) => c.confidence === "medium").length;
1190
+ const low = state.configs.filter((c) => c.confidence === "low").length;
1191
+ // 覆盖自省(一致性预防):dead = 映射有但规则无;uncovered = 硬等级规则但无 handler
1192
+ const { dead, uncovered } = analyzeCoverage(state.configs, state.handlerDefaultMap || {});
1193
+ const parts = [
1194
+ "【规则引擎状态】",
1195
+ ` 总开关:${state.enabled ? "开启" : "已关闭"}`,
1196
+ ` 任务契约:${state.taskContract?.taskContractEnabled ? `开启(模式 ${state.taskContract.taskContractMode}|ask ${state.taskContract.askEnabled ? "开" : "关"})` : "关闭"}`,
1197
+ // 2026-08-31:本会话真实契约(mode/level/预算)——档位(面板)≠ 本会话模式,两者分开展示
1198
+ // (旧版只显示档位:用户 "armed" 误以为已放行,实际裁决按会话 mode 走)
1199
+ ` 本会话契约:${state.taskContract?.taskContractEnabled ? `${contractSummary(getSessionState(state, sessionIdOfInvocation(invocation)).contract)}(会话 ${sessionIdOfInvocation(invocation)})` : "关闭(总开关未开,命令不生效)"}`,
1200
+ ` 规则容器:${state.configOk ? `正常(${state.configs.length} 条规则)` : "⚠ " + state.configError}`,
1201
+ ` 理解置信度:high ${high} / medium ${medium} / low ${low}`,
1202
+ ` 覆盖自省:${dead.size === 0 && uncovered.length === 0 ? "无缺口" : (dead.size > 0 ? `死映射 ${[...dead.keys()].join("、")}(引擎映射存在但规则已删除,建议清理)` : "") + (uncovered.length > 0 ? `;未覆盖 ${uncovered.map((u) => u.ruleId).join("、")}(${uncovered.map((u) => u.actions.join("+")).join(";")} 但无 handler,规则声明强制但引擎不执行,建议降 D 级或补实现)` : "")}`,
1203
+ ` 配置加载:${conf.ok ? "正常" : "⚠ " + conf.error}`,
1204
+ ` 解锁剩余:${fmtRemain(remainMs(state.unlockUntil))}(配置写保护豁免)`,
1205
+ ` 放行剩余:${fmtRemain(remainMs(state.bypassUntil))}(全部守卫暂停)`,
1206
+ ` 授权存储:内存态(重启失效)`,
1207
+ ` 审计日志:${auditFilePath()}`,
1208
+ ` 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})` : "【降级词表】"}` : ""})` : "关闭"}`,
1209
+ ` 最近激活:${state.lastActive.length ? state.lastActive.map((a) => a.ruleId).join("、") : "无"}`,
1210
+ ` 提示:审批策略 never 只关系统审批弹窗,不影响对话内 ask_user_question 授权;规则 12A 仍会硬拦需要授权的操作。`
1211
+ ];
1212
+ return { kind: "success", text: parts.join("\n") };
1213
+ }
1214
+ case "rules": {
1215
+ if (state.configs.length === 0) return { kind: "success", text: "当前没有可执行规则(AGENTS.md 为空或缺失)。" };
1216
+ const parts = [`共 ${state.configs.length} 条规则:`, ""];
1217
+ for (const c of state.configs) {
1218
+ const flag = c.disabled ? "(已禁用)" : "";
1219
+ const actions = (c.actions || []).join("/");
1220
+ parts.push(` [${c.ruleId}] ${c.title} ${flag}`);
1221
+ parts.push(` 等级 ${c.level || "?"}|动作 ${actions}|置信 ${c.confidence}|handler ${c.handler || "(无——规则声明强制但引擎不执行,建议降 D 级或补实现)"}`);
1222
+ }
1223
+ return { kind: "success", text: parts.join("\n") };
1224
+ }
1225
+ case "active": {
1226
+ if (state.lastActive.length === 0) return { kind: "success", text: "暂无激活规则。" };
1227
+ const parts = ["最近激活规则:", ""];
1228
+ for (const a of state.lastActive) {
1229
+ parts.push(` [${a.ruleId}] ${a.title}`);
1230
+ if (a.reason) parts.push(` 原因:${a.reason}`);
1231
+ }
1232
+ return { kind: "success", text: parts.join("\n") };
1233
+ }
1234
+ case "unlock": {
1235
+ const minutes = Math.min(Math.max(1, command.minutes), 60);
1236
+ state.unlockUntil = Date.now() + minutes * 60000;
1237
+ // 逃生门请求审计(机制批 M1):用途留痕,供规则 1⑤ 事前说明核对
1238
+ audit({ kind: "guard-command", rule: "__escape-gate", name: "逃生门请求", event: "command", reason: `/guard unlock ${minutes} 分钟(配置写保护)`, session: sessionIdOfInvocation(invocation) });
1239
+ return {
1240
+ kind: "success",
1241
+ text: `已解锁「配置写保护」${minutes} 分钟。现在可以让助手修改 rule-engine.json / rule-understanding.json / AGENTS.md;改完请执行 /guard lock 或等待自动恢复。`
1242
+ };
1243
+ }
1244
+ case "bypass": {
1245
+ const minutes = Math.min(Math.max(1, command.minutes), 60);
1246
+ state.bypassUntil = Date.now() + minutes * 60000;
1247
+ // 逃生门请求审计(机制批 M1)
1248
+ audit({ kind: "guard-command", rule: "__escape-gate", name: "逃生门请求", event: "command", reason: `/guard bypass ${minutes} 分钟(全部守卫)`, session: sessionIdOfInvocation(invocation) });
1249
+ return {
1250
+ kind: "success",
1251
+ text: `已临时放行全部守卫 ${minutes} 分钟。到期自动恢复,也可 /guard reload 后立即恢复。`
1252
+ };
1253
+ }
1254
+ case "lock": {
1255
+ state.unlockUntil = 0;
1256
+ state.bypassUntil = 0;
1257
+ return { kind: "success", text: "守卫已全部恢复:解锁与放行均已取消。" };
1258
+ }
1259
+ case "revoke": {
1260
+ // R1.3(2026-08-24):全清——session.authorizations + turn.scopes + globalAuthorizations + askRejections
1261
+ const count = clearAuthorizations(state);
1262
+ // 可观测性(阶段 0 + 机制 C,2026-08-24):revoke 事件必须留痕,否则撤销无法复核(B4 实证)
1263
+ audit({
1264
+ kind: "guard-command",
1265
+ rule: "__revoke",
1266
+ name: "撤销全部授权",
1267
+ event: "command",
1268
+ reason: `撤销 ${count} 条授权/范围(session + turn.scopes + global + askRejections 全清)`,
1269
+ session: sessionIdOfInvocation(invocation)
1270
+ });
1271
+ return { kind: "success", text: `已撤销全部授权与范围(共 ${count} 条,含全局池与 ask 拒绝记录;不再有残留)。` };
1272
+ }
1273
+ case "log": {
1274
+ const n = Math.min(Math.max(1, command.n), 200);
1275
+ const entries = readAuditLog(n);
1276
+ if (entries.length === 0) return { kind: "success", text: "暂无审计记录。" };
1277
+ const parts = [`最近 ${entries.length} 条审计:`, ""];
1278
+ for (const e of entries) {
1279
+ if (e.raw) {
1280
+ parts.push(` ${e.raw}`);
1281
+ continue;
1282
+ }
1283
+ const t = (e.ts || "").replace("T", " ").slice(0, 19);
1284
+ parts.push(` [${t}] ${e.kind || "?"}|规则 ${e.rule || "?"}|${e.name || ""}${e.errId ? `(ERR-${e.errId})` : ""}${e.eventId ? `(${e.eventId})` : ""}`);
1285
+ if (e.reason) parts.push(` 原因:${e.reason}`);
1286
+ if (e.tool) parts.push(` 工具:${e.tool}|参数:${e.args || ""}`);
1287
+ }
1288
+ return { kind: "success", text: parts.join("\n") };
1289
+ }
1290
+ case "reload": {
1291
+ reloadRules(state);
1292
+ const saved = writeUnderstanding(state.configs);
1293
+ return {
1294
+ kind: "success",
1295
+ text: `已重解析 AGENTS.md:${state.configOk ? `正常(${state.configs.length} 条规则)` : "⚠ " + state.configError}` +
1296
+ (saved.ok ? `\n理解产物已写入:${saved.path}` : `\n理解产物写入失败:${saved.error}`)
1297
+ };
1298
+ }
1299
+ case "tools": {
1300
+ // v0.5.9:白名单可视化——永久(元数据)+ 本会话新增;revoke = 从持久化文件与会话集移除
1301
+ if (command.action === "revoke") {
1302
+ const target = command.target;
1303
+ const prev = readWhitelistMeta();
1304
+ const existed = prev.delete(target);
1305
+ writeFileSync(toolsWhitelistFilePath(), JSON.stringify([...prev.values()]), "utf8");
1306
+ state.unknownToolApproved?.delete(target);
1307
+ state.unknownToolSessionAdded?.delete(target);
1308
+ audit({
1309
+ kind: "guard-command",
1310
+ rule: "__whitelist",
1311
+ name: "白名单撤销",
1312
+ event: "command",
1313
+ reason: `/guard tools revoke ${target}(持久化+会话集同步移除)`,
1314
+ session: sessionIdOfInvocation(invocation)
1315
+ });
1316
+ return { kind: "success", text: `已撤销 ${target}${existed ? "" : "(原不在白名单)"}。` };
1317
+ }
1318
+ const meta = state.unknownToolApprovedMeta || new Map();
1319
+ const sessionAdded = state.unknownToolSessionAdded || new Set();
1320
+ const rows = [...(state.unknownToolApproved || [])].map((n) => {
1321
+ const m = meta.get(n) || {};
1322
+ let when = "未知";
1323
+ if (typeof m.time === "number") {
1324
+ try { when = new Date(m.time).toISOString(); } catch { when = "非法时间"; }
1325
+ }
1326
+ return `- ${n}${sessionAdded.has(n) ? "(本会话新增)" : ""} 时间=${when} 来源会话=${m.session || "未知"}`;
1327
+ });
1328
+ const text = rows.length
1329
+ ? `工具放行白名单(/guard tools):\n${rows.join("\n")}\n\n撤销:/guard tools revoke <工具名>`
1330
+ : "工具放行白名单为空。";
1331
+ return { kind: "success", text };
1332
+ }
1333
+ case "mode": {
1334
+ if (!state.taskContract?.taskContractEnabled) return { kind: "error", text: "任务契约未启用:请先在规则引擎设置页开启总开关。" };
1335
+ const sid = sessionIdOfInvocation(invocation);
1336
+ const s = getSessionState(state, sid);
1337
+ const res = applyContract(s.contract, { mode: command.mode, level: command.level, source: "guard-command" });
1338
+ if (res.changed) {
1339
+ s.contract = res.contract;
1340
+ audit({ kind: "task-contract", rule: "__task-contract", name: "任务契约更新", event: "command", reason: `/guard mode: ${contractSummary(s.contract)}`, session: sid });
1341
+ }
1342
+ return { kind: "success", text: `任务契约:${contractSummary(s.contract)}` };
1343
+ }
1344
+ case "budget": {
1345
+ if (!state.taskContract?.taskContractEnabled) return { kind: "error", text: "任务契约未启用:请先在规则引擎设置页开启总开关。" };
1346
+ const sid = sessionIdOfInvocation(invocation);
1347
+ const s = getSessionState(state, sid);
1348
+ const res = applyContract(s.contract, { ...command.patch, source: "guard-command" });
1349
+ if (res.changed) {
1350
+ s.contract = res.contract;
1351
+ audit({ kind: "task-contract", rule: "__task-contract", name: "任务预算更新", event: "command", reason: `/guard budget: ${contractSummary(s.contract)}`, session: sid });
1352
+ }
1353
+ return { kind: "success", text: `任务契约:${contractSummary(s.contract)}` };
1354
+ }
1355
+ case "contract": {
1356
+ const sid = sessionIdOfInvocation(invocation);
1357
+ const s = getSessionState(state, sid);
1358
+ const enabled = state.taskContract?.taskContractEnabled ? "开启" : "关闭(总开关未开启,命令不生效)";
1359
+ return { kind: "success", text: `任务契约(总开关:${enabled})\n${contractSummary(s.contract)}` };
1360
+ }
1361
+ case "contract-categories": {
1362
+ if (!state.taskContract?.taskContractEnabled) return { kind: "error", text: "任务契约未启用:请先在规则引擎设置页开启总开关。" };
1363
+ const sid = sessionIdOfInvocation(invocation);
1364
+ const s = getSessionState(state, sid);
1365
+ const cats = command.categories;
1366
+ if (cats.length === 0) {
1367
+ s.contract = applyContract(s.contract, { source: "guard-command" }).contract;
1368
+ s.contract.categories = null;
1369
+ audit({ kind: "task-contract", rule: "__task-contract", name: "任务契约类别白名单清空", event: "command", reason: `/guard contract categories 清空(恢复不限制类别)`, session: sid });
1370
+ return { kind: "success", text: `任务契约:类别白名单已清空(${contractSummary(s.contract)})` };
1371
+ }
1372
+ // 破坏类结构性排除:用户设置含 delete/move 等 → 拒绝(不因用户配置而放宽)
1373
+ const DESTRUCTIVE = ["delete", "move", "replace", "purge"];
1374
+ const bad = cats.filter((c) => DESTRUCTIVE.includes(c));
1375
+ if (bad.length) {
1376
+ return { kind: "error", text: `破坏类类别(${bad.join(",")})永不可入契约白名单——任务契约只放行非破坏操作` };
1377
+ }
1378
+ s.contract = applyContract(s.contract, { source: "guard-command" }).contract;
1379
+ s.contract.categories = cats;
1380
+ audit({ kind: "task-contract", rule: "__task-contract", name: "任务契约类别白名单设定", event: "command", reason: `/guard contract categories ${cats.join(",")}`, session: sid });
1381
+ return { kind: "success", text: `任务契约:类别白名单 = [${cats.join(",")}](${contractSummary(s.contract)})` };
1382
+ }
1383
+ case "label": {
1384
+ const entries = readAuditLog(500);
1385
+ // E1(2026-08-28 阶段一):支持 ERR-xxxxxx 短码定位(此前只认 eventId UUID——
1386
+ // 用户拿拦截提示里的 ERR 码无法打标,链路断在"两套标识符无映射")
1387
+ const want = String(command.eventId || "").trim();
1388
+ const normalized = want.toUpperCase().startsWith("ERR-") ? want.slice(4).toUpperCase() : want;
1389
+ const found = entries.find(
1390
+ (e) => e.eventId === want || e.errId === normalized || e.errId === want || `ERR-${e.errId}` === want.toUpperCase()
1391
+ );
1392
+ if (!found) return { kind: "error", text: `未找到审计事件:${command.eventId}(提示:/guard log 可查最近记录;若为旧日志则拦截时未记录 ERR 码)` };
1393
+ state.labels.set(found.eventId, command.label);
1394
+ // 0.5.12(F2):incorrect 打标同时写入命令指纹台账(持久化)——同指纹命令后续直接放行
1395
+ if (command.label === "incorrect" && (found.tool === "pwsh" || found.tool === "bash")) {
1396
+ try {
1397
+ const fp = labelFingerprint(JSON.parse(found.args || "{}").command || "");
1398
+ if (fp) {
1399
+ const sid = sessionIdOfInvocation(invocation);
1400
+ state.labelRows = upsertLabel(state.labelRows || [], labelEntry(fp, "incorrect", sid));
1401
+ saveLabelsToDisk(labelsFilePath(dshHome()), state.labelRows);
1402
+ audit({ kind: "task-label", rule: "__task-contract", name: "审计人工标注", event: "command", reason: `${found.eventId} = ${command.label}(来源 ERR-${found.errId || "?"})+ 命令指纹放行已记录:${fp.slice(0, 100)}`, session: sid });
1403
+ return { kind: "success", text: `已标注 ${found.eventId} = ${command.label};并记录命令指纹 ${fp.slice(0, 80)}(同指纹命令将放行:/guard label clear <指纹> 撤销)` };
1404
+ }
1405
+ } catch {
1406
+ // 指纹解析失败不阻断打标(保守:只记单条,不记指纹)
1407
+ }
1408
+ }
1409
+ audit({ kind: "task-label", rule: "__task-contract", name: "审计人工标注", event: "command", reason: `${found.eventId} = ${command.label}(来源 ERR-${found.errId || "?"})`, session: sessionIdOfInvocation(invocation) });
1410
+ return { kind: "success", text: `已标注 ${found.eventId} = ${command.label}` };
1411
+ }
1412
+ case "label-clear": {
1413
+ const fp = command.fingerprint;
1414
+ const before = (state.labelRows || []).length;
1415
+ state.labelRows = (state.labelRows || []).filter((r) => r.fingerprint !== fp);
1416
+ saveLabelsToDisk(labelsFilePath(dshHome()), state.labelRows);
1417
+ audit({ kind: "task-label", rule: "__task-contract", name: "打标指纹撤销", event: "command", reason: `撤销命令指纹:${fp}(${before} → ${state.labelRows.length} 条)`, session: sessionIdOfInvocation(invocation) });
1418
+ return { kind: "success", text: `已撤销命令指纹 ${fp}(剩余 ${state.labelRows.length} 条)` };
1419
+ }
1420
+ default:
1421
+ return { kind: "error", text: USAGE };
1422
+ }
1423
+ }
1424
+
1425
+ // ── 插件主体 ────────────────────────────────────────────────────────────────
1426
+
1427
+ export function apply(ctx) {
1428
+ // 0. v0.5.7 P0-3:注册全部工作区根(多工作区环境;精确层=会话级,在 guard 回调按会话同步)
1429
+ try {
1430
+ const list = ctx.workspaceRegistry?.list?.() || [];
1431
+ setWorkspaceRoots(list.map((e) => e && (e.path || e.record?.path)).filter(Boolean));
1432
+ } catch {
1433
+ setWorkspaceRoots([]);
1434
+ }
1435
+
1436
+ // v0.5.7 P0-4:加载持久化验证记录(热重载/重启后规则 23④ 证据链不丢;仅保留 30 分钟内)
1437
+ try {
1438
+ const raw = readFileSync(verifyFilePath(), "utf8");
1439
+ const arr = JSON.parse(raw);
1440
+ if (Array.isArray(arr)) {
1441
+ state.verifyPass = arr
1442
+ .filter((v) => v && v.at && Date.now() - Number(v.at) < 30 * 60 * 1000)
1443
+ .slice(-100);
1444
+ }
1445
+ } catch {
1446
+ // 首次运行/文件缺失:无历史记录,正常
1447
+ }
1448
+
1449
+ // v0.5.7 后续(用户批准):加载白名单持久化("允许使用 X"重启/热重载不丢)
1450
+ // 0.5.9:v2 对象数组(带 meta)与 v1 字符串数组兼容;meta /guard tools 可视化
1451
+ try {
1452
+ const wlRaw = readFileSync(toolsWhitelistFilePath(), "utf8");
1453
+ const rows = parseWhitelist(wlRaw);
1454
+ state.unknownToolApproved = new Set(rows.map((r) => r.name));
1455
+ state.unknownToolApprovedMeta = new Map(rows.map((r) => [r.name, r]));
1456
+ } catch {
1457
+ // 首次运行/无文件:空白名单
1458
+ }
1459
+
1460
+ // 0.0.1 LLM 意图兜底配置(guardDecision 为纯函数,经 state 传入)
1461
+ state.llmIntentCfg = pluginConfig.llmIntent || {};
1462
+
1463
+ // 0.1 技能目录实时联动
1464
+ refreshSkills(ctx);
1465
+ ctx.on("skills/change", () => {
1466
+ refreshSkills(ctx);
1467
+ });
1468
+
1469
+ // 0.2 LLM 增量理解(非 high 置信规则,失败自动回退模式库)
1470
+ enrichRulesWithLlm(ctx, state);
1471
+
1472
+ // 0.3 AGENTS.md 文件监听(fs.watch 即时触发,stat 轮询保留为兜底)
1473
+ ctx.effect(
1474
+ function* () {
1475
+ let timer = null;
1476
+ let watcher = null;
1477
+ try {
1478
+ watcher = watch(agentsFilePath(), { persistent: false }, () => {
1479
+ clearTimeout(timer);
1480
+ timer = setTimeout(() => {
1481
+ reloadRules(state);
1482
+ // 规则变化后补一次 LLM 增量理解(有 ruleId@mtime 去重,不会重复烧 token;P1-6)
1483
+ void enrichRulesWithLlm(ctx, state);
1484
+ }, 200);
1485
+ });
1486
+ } catch {
1487
+ // 文件暂不可监听时由 stat 轮询兜底
1488
+ }
1489
+ yield () => {
1490
+ if (timer) clearTimeout(timer);
1491
+ if (watcher) watcher.close();
1492
+ };
1493
+ },
1494
+ "dsh-rule-engine watch"
1495
+ );
1496
+
1497
+ // 0.3.1 注入延迟投递定时器清理(O1 批次 6):卸载/热重载时取消全部 pending inject
1498
+ ctx.effect(
1499
+ function* () {
1500
+ yield () => {
1501
+ for (const t of pendingInjectTimers) clearTimeout(t);
1502
+ pendingInjectTimers.clear();
1503
+ };
1504
+ },
1505
+ "dsh-rule-engine inject timers"
1506
+ );
1507
+
1508
+ // 1. 单调守卫:工具调用先裁决,命中即物理拒绝
1509
+ ctx.effect(
1510
+ function* () {
1511
+ yield ctx.tools.guard((exec) => {
1512
+ // v0.5.7 P0-3:多工作区正确模型——根随会话走。每个工具调用前按当前会话同步其工作区根
1513
+ //(官方 workspaceRegistry.sessionPath 提供会话→工作区映射;新工作区零维护自动识别)。
1514
+ try {
1515
+ const gsid = sessionIdOfExec(exec);
1516
+ if (gsid && gsid !== "global" && ctx.workspaceRegistry?.sessionPath) {
1517
+ const root = ctx.workspaceRegistry.sessionPath(gsid);
1518
+ if (root) setSessionWorkspaceRoot(gsid, root);
1519
+ }
1520
+ } catch {
1521
+ // 会话未映射时安静走全局兜底,不阻塞裁决
1522
+ }
1523
+ // 落盘授权粒度提醒(机制批 M7,2026-08-24):受保护文件统一入口调用 + 本回合用户消息为方案性指令
1524
+ //(调整/补充/评估/建议且无落盘词)→ approval-gap 审计 + 注入提醒(规则 22 自证③:方案性指令 落盘授权)
1525
+ {
1526
+ const cmd = exec?.arguments?.command || exec?.arguments?.code || "";
1527
+ if ((exec?.name === "pwsh" || exec?.name === "bash") && /\bdsh-manual-write\.mjs/.test(cmd)) {
1528
+ const s7 = getSessionState(state, sessionIdOfExec(exec));
1529
+ if (needsApprovalReminder(s7.turn.userText || "")) {
1530
+ audit({
1531
+ kind: "approval-gap",
1532
+ rule: "__approval-gap",
1533
+ name: "落盘授权粒度提醒",
1534
+ event: "tool/guard",
1535
+ tool: exec?.name,
1536
+ args: summarizeArgs(exec?.arguments),
1537
+ reason: "本回合用户消息为方案性指令(调整/补充/评估/建议等),不构成落盘授权(规则 22 自证③)——落盘前应先展示→用户确认",
1538
+ session: sessionIdOfExec(exec)
1539
+ });
1540
+ maybeInject(ctx, sessionIdOfExec(exec), {
1541
+ ruleId: "__approval-gap",
1542
+ reason: "规则 22 自证③:方案性指令不构成落盘授权——受保护文件落盘需先展示改动→用户明确确认→再执行(机制批 M7)"
1543
+ });
1544
+ }
1545
+ }
1546
+ }
1547
+ // 逃生门窗口打标(机制批 M2):bypass 生效期间一切变更类调用记 bypass-action 审计(用途留痕,供规则 1⑤ 核对)
1548
+ if (state.bypassUntil > Date.now() && !isReadOnlyTool(exec?.name, exec?.arguments)) {
1549
+ audit({
1550
+ kind: "bypass-action",
1551
+ rule: "__escape-gate",
1552
+ name: "逃生门窗口变更",
1553
+ event: "tool/guard",
1554
+ tool: exec?.name,
1555
+ args: summarizeArgs(exec?.arguments),
1556
+ reason: "窗口内变更(逃生门用途:引擎/规则/手册修复,规则 1 范围核对)",
1557
+ session: sessionIdOfExec(exec)
1558
+ });
1559
+ }
1560
+ const hit = guardDecision(state, exec, Date.now(), { audit });
1561
+ if (hit) {
1562
+ audit({
1563
+ kind: "deny",
1564
+ rule: hit.ruleId,
1565
+ name: hit.title,
1566
+ event: "tool/guard",
1567
+ tool: exec?.name,
1568
+ args: summarizeArgs(exec?.arguments),
1569
+ reason: hit.reason,
1570
+ errId: hit.errId,
1571
+ session: sessionIdOfExec(exec) // P0-4:deny 审计补 session(2026-08-24 事故复盘:归属会话靠猜)
1572
+ });
1573
+ state.lastActive = [{ ruleId: hit.ruleId, title: hit.title, reason: hit.reason }];
1574
+ // 规则 1 计数解耦(2026-08-24,P1-7):引擎 deny 不计入“同工具连续失败”
1575
+ const dkey = `${exec?.name}:${JSON.stringify(exec?.arguments || {})}`;
1576
+ if (!state.deniedKeys) state.deniedKeys = new Set();
1577
+ state.deniedKeys.add(dkey);
1578
+ if (state.deniedKeys.size > 500) {
1579
+ const oldest = state.deniedKeys.keys().next().value;
1580
+ if (oldest !== undefined) state.deniedKeys.delete(oldest);
1581
+ }
1582
+ // 弹窗消减(2026-08-24):E3/ask 节流命中后注入纠正,提示改用普通文本,勿连环弹窗
1583
+ // D1(2026-08-28 阶段三):改**陈述式**——旧文案"请直接执行"是命令,模型照做会越过
1584
+ // 用户(实弹:注入后模型跳过用户回复直接执行);只陈述事实,决策权留给用户。
1585
+ if (hit.ruleId === "__already-authorized" || hit.ruleId === "__ask-rejected" || hit.ruleId === "__ask-throttle") {
1586
+ maybeInject(ctx, sessionIdOfExec(exec), {
1587
+ ruleId: hit.ruleId,
1588
+ reason: `${hit.title}(规则 ${hit.ruleId}):本会话已存在相近授权记录或被拒记录;再次弹窗询问可能无法送达用户。可用普通文本说明。`
1589
+ });
1590
+ }
1591
+ return hit.reason;
1592
+ }
1593
+ return undefined;
1594
+ });
1595
+ },
1596
+ "dsh-rule-engine guard"
1597
+ );
1598
+
1599
+ // 1.0 规则 12C B 级留痕:命令文本含回环/内网地址时记录审计,不拦截
1600
+ // A2(阶段一,2026-08-28):LLM 意图兜底同步等待——词表判"拦"(denyMutation)时,若 LLM
1601
+ // 预取仍在进行(llm-pending),在工具执行前同步等待结果(上限 300ms,超时按词表保守裁决)。
1602
+ // 根因:enrichIntentWithLlm 是异步预取,模型先发工具调用往往先于 LLM 返回 → 词表盲区
1603
+ // (转化/读取/展示 等未收录词)在拦截点直接判拦,LLM 兜底形同虚设(2026-08-28 实弹)。
1604
+ ctx.on("tools/pre-execute", async (exec, next) => {
1605
+ try {
1606
+ const sid2 = sessionIdOfExec(exec);
1607
+ const s2 = getSessionState(state, sid2);
1608
+ if (s2?.turn?.intentState === "llm-pending" && typeof s2.turn?.llmIntentPromise?.then === "function") {
1609
+ await Promise.race([
1610
+ s2.turn.llmIntentPromise,
1611
+ new Promise((resolve) => setTimeout(resolve, 300))
1612
+ ]);
1613
+ }
1614
+ } catch {
1615
+ // 等待失败不阻断:词表裁决兜底
1616
+ }
1617
+ return next();
1618
+ });
1619
+ ctx.on("tools/pre-execute", async (exec, next) => {
1620
+ try {
1621
+ const cmd = exec?.arguments?.command || exec?.arguments?.code || "";
1622
+ if (typeof cmd === "string" && cmd.length > 0 && (exec?.name === "pwsh" || exec?.name === "bash") && PRIVATE_NETWORK_RE.test(cmd)) {
1623
+ audit({
1624
+ kind: "network-flag",
1625
+ rule: "12C",
1626
+ name: "内网/回环地址访问留痕",
1627
+ event: "tools/pre-execute",
1628
+ tool: exec?.name,
1629
+ reason: `命令文本含回环/内网地址模式:${cmd.slice(0, 160)}`,
1630
+ session: sessionIdOfExec(exec)
1631
+ });
1632
+ }
1633
+ } catch {
1634
+ // 留痕失败不阻断
1635
+ }
1636
+ return next();
1637
+ });
1638
+
1639
+ // 0.9 机制 B(2026-08-24;0.5.9 修订——K-02/K-03 依据):未归类工具首调按 unknownPolicy 处置:
1640
+ // "ask" = 官方 pre-execute ask 语义(approval 弹窗,allowed-once 才续行;无审批通道 → 官方自动 deny,
1641
+ // fail-closed 由官方保证);"deny" = 物理拒绝 + 提示("允许使用 X" 会话白名路径恒可用)。
1642
+ // 历史教训(08-24 实测 ask 被自动放行 → 当时改 deny):0.5.9 发布前必须实弹验证 ask 弹窗真实出现
1643
+ // (K-02 门禁),验证不过则发布时默认保持 deny。
1644
+ ctx.on("tools/pre-execute", async (exec, next) => {
1645
+ try {
1646
+ if ((state.unknownToolApproved || new Set()).has(exec?.name)) return next(); // 会话内用户已批准
1647
+ if (toolClass(exec?.name, exec?.arguments || {}) !== "unknown") return next();
1648
+ // 0.5.9:unknownPolicy 默认 "deny"(K-02 门禁:ask 需实弹验证弹窗真实出现后才可默认;
1649
+ // 当前无真实 unknown 工具可触发实弹——分类表已全覆盖,等待未来真实场景验证后经配置切换)
1650
+ const policy = pluginConfig.unknownPolicy === "ask" ? "ask" : "deny";
1651
+ audit({
1652
+ kind: "unknown-tool",
1653
+ rule: "__unknown-tool",
1654
+ name: "未归类工具首调处置",
1655
+ event: "tools/pre-execute",
1656
+ tool: exec?.name,
1657
+ args: summarizeArgs(exec?.arguments),
1658
+ reason: `工具 ${exec.name} 不在规则引擎分类表:按 unknownPolicy=${policy} 首调处置(批准路径:官方弹窗批准 / 用户明确"允许使用 ${exec.name}"写入会话白名单;长期规则请补充工具分类表)`,
1659
+ session: sessionIdOfExec(exec)
1660
+ });
1661
+ if (policy === "ask") {
1662
+ return {
1663
+ kind: "ask",
1664
+ reason: `工具 ${exec.name} 未登记在规则引擎分类表:批准本次后放行;如需本会话持续放行请说"允许使用 ${exec.name}"。`
1665
+ };
1666
+ }
1667
+ return {
1668
+ kind: "deny",
1669
+ reason: `工具 ${exec.name} 尚未归类(新装插件?):已拦截。如需使用请向用户说明用途并获取其"允许使用 ${exec.name}"的明确答复后重试(批准后本会话内放行)。`
1670
+ };
1671
+ } catch (error) {
1672
+ ctx.logger?.warn?.("[dsh-rule-engine] unknown-tool handling error", error);
1673
+ return next();
1674
+ }
1675
+ });
1676
+
1677
+ // 1.1 任务契约裁决钩子:deny 物理拒绝 + ask 通道 + 委托预算启动即计。
1678
+ // 2026-08-25 修复(预算语义偏差,实弹:第 2 个子代理被误拒——允许 N 实际 N-1):
1679
+ // 根因 = 扣减(本钩子)先于 guard 层的 decideContractAction(L1138 用已扣 contract 重判 → 2+1>2 → 误拒)。
1680
+ // 修复 = 本钩子成为契约唯一裁决点(deny 在扣减**前**判定并物理拒绝),guard 层跳过预算类 deny(防双判)。
1681
+ ctx.on("tools/pre-execute", async (exec, next) => {
1682
+ try {
1683
+ if (!state.taskContract?.taskContractEnabled) return next();
1684
+ const sid = sessionIdOfExec(exec);
1685
+ const s = getSessionState(state, sid);
1686
+ if (!isArmed(s.contract, state.taskContract)) return next();
1687
+ const action = classifyAction(exec?.name, exec?.arguments);
1688
+ // 0.5.12(F5 批次 3):补命令文本供类别白名单判定(categoryOfCommand 需要)
1689
+ action.commandText = exec?.arguments?.command || exec?.arguments?.code || "";
1690
+ const dec = decideContractAction({ contract: s.contract, action, config: state.taskContract });
1691
+ // deny(在扣减前判定,语义=本次调用发生时点)
1692
+ if (dec.outcome === "deny") {
1693
+ audit({
1694
+ kind: "task-contract-deny",
1695
+ rule: "__task-contract",
1696
+ name: `任务契约拒绝:${dec.reasonCode}`,
1697
+ event: "tools/pre-execute",
1698
+ tool: exec?.name,
1699
+ args: summarizeArgs(exec?.arguments),
1700
+ reason: dec.reason,
1701
+ session: sid
1702
+ });
1703
+ return { kind: "deny", reason: `[guardian:contract] 【硬拦截】${dec.reason}(${dec.reasonCode}|任务契约|放行:${dec.nextStep || "请调整契约"})` };
1704
+ }
1705
+ // 批次 5.5(2026-08-24):委托启动即计预算——原 tool/result 扣减因 subagent 无 pendingCall 从未执行(agents 恒 0)
1706
+ if (dec.outcome === "allow" && action.mutability === "delegate" && action.delegationCount > 0) {
1707
+ s.contract = consumeDelegateBudget(s.contract, action.delegationCount);
1708
+ audit({
1709
+ kind: "task-budget",
1710
+ rule: "__task-contract",
1711
+ name: "子代理预算扣减",
1712
+ event: "tools/pre-execute",
1713
+ tool: exec?.name,
1714
+ reason: `已用 ${s.contract.agentsUsed}/${s.contract.agentBudget}`,
1715
+ session: sid
1716
+ });
1717
+ }
1718
+ if (dec.outcome === "ask") {
1719
+ audit({
1720
+ kind: "task-ask",
1721
+ rule: "__task-contract",
1722
+ name: "任务契约询问",
1723
+ event: "tools/pre-execute",
1724
+ tool: exec?.name,
1725
+ args: summarizeArgs(exec?.arguments),
1726
+ reason: dec.reason,
1727
+ session: sid
1728
+ });
1729
+ return { kind: "ask", reason: `${dec.reason}(任务契约|${dec.nextStep || "请确认或使用放行词"})` };
1730
+ }
1731
+ // 可观测性(阶段 0,2026-08-24):armed 下 deny/allow 裁决也留痕,CI 澄清"契约 deny 有无真实拦截路径"
1732
+ if (dec.outcome === "deny" || dec.outcome === "allow") {
1733
+ audit({
1734
+ kind: "contract-decision",
1735
+ rule: "__task-contract",
1736
+ name: "任务契约裁决",
1737
+ event: "tools/pre-execute",
1738
+ tool: exec?.name,
1739
+ args: summarizeArgs(exec?.arguments),
1740
+ reason: `裁决 ${dec.outcome}|${dec.reasonCode}|${dec.reason}`,
1741
+ session: sid
1742
+ });
1743
+ }
1744
+ // 批次 4(2026-08-24):契约 deny 真实物理拦截(原仅审计 + next()——armed 下出界写/
1745
+ // 无界委托/预算超/模式禁止变更均无实际效力)
1746
+ if (dec.outcome === "deny") {
1747
+ return { kind: "deny", reason: `[guardian:contract] ${dec.reason}(任务契约|${dec.nextStep || "请调整契约或停止该操作"})` };
1748
+ }
1749
+ return next();
1750
+ } catch (error) {
1751
+ ctx.logger?.warn?.("[dsh-rule-engine] tools/pre-execute error", error);
1752
+ return next();
1753
+ }
1754
+ });
1755
+
1756
+ // 2. session/event 监听:文本纠察 + 时序状态
1757
+ ctx.on("session/event", (session, event) => {
1758
+ try {
1759
+ handleSessionEvent(ctx, session, event);
1760
+ } catch (error) {
1761
+ ctx.logger?.warn?.("[dsh-rule-engine] session/event handler error", error);
1762
+ }
1763
+ });
1764
+
1765
+ // 3. /guard 命令(仅用户可执行;模型无命令工具)
1766
+ ctx.effect(
1767
+ function* () {
1768
+ yield ctx.commands.register({
1769
+ name: "guard",
1770
+ description: "规则执行引擎:查看状态/规则/激活/审计,解锁配置修改,临时放行,强制重载",
1771
+ input: { hint: GUARD_HINT },
1772
+ handler: async (invocation) => {
1773
+ try {
1774
+ return await executeGuard(ctx, invocation);
1775
+ } catch (error) {
1776
+ return {
1777
+ kind: "error",
1778
+ text: `执行出错:${error instanceof Error ? error.message : String(error)}`
1779
+ };
1780
+ }
1781
+ }
1782
+ });
1783
+ },
1784
+ "dsh-rule-engine commands"
1785
+ );
1786
+
1787
+ ctx.logger?.info?.("[dsh-rule-engine] 已加载,AGENTS.md 规则数:" + state.configs.length);
1788
+ }