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