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/core/guard-core.js
CHANGED
|
@@ -22,7 +22,10 @@ import {
|
|
|
22
22
|
isVariablePath,
|
|
23
23
|
pathTarget
|
|
24
24
|
} from "./patterns.js";
|
|
25
|
-
import { describeAuth, describeOp, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText } from "./authorization.js";
|
|
25
|
+
import { authMatches, describeAuth, describeOp, describeScopes, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText, scopesFromIntents } from "./authorization.js";
|
|
26
|
+
import { parseUserIntents, shouldDenyMutation } from "./intent.js";
|
|
27
|
+
import { toolClass } from "./tool-catalog.js";
|
|
28
|
+
import { verdictForDeny } from "./llm-intent.js";
|
|
26
29
|
import { computeMountSignature, profileNameFromArgs } from "./mount-signature.js";
|
|
27
30
|
import { findBackupForPath, getSessionState, maybeReloadIfChanged } from "./state.js";
|
|
28
31
|
import { isVersionedFile, validateEditedFile } from "./version-guard.js";
|
|
@@ -44,7 +47,7 @@ const RULE_HINTS = {
|
|
|
44
47
|
"13A": "先对目标路径执行备份(复制到 .bak/.backups/trash-)",
|
|
45
48
|
"18": "先读取 ~/.dsh/skills/dsh-usage-manual/SKILL.md",
|
|
46
49
|
"21": "按规则 21 分级确认后再落盘",
|
|
47
|
-
"22": "
|
|
50
|
+
"22": "先回答/展示方案,或补充明确执行分点(工具类别+路径范围);已授权变更必须落在本回合执行分点/ask 授权范围内",
|
|
48
51
|
"24": "确认插件 dsh.bundle 类型或改用正确挂载",
|
|
49
52
|
"27": "先运行 node scripts/audit-mount-consistency.mjs --profile web"
|
|
50
53
|
};
|
|
@@ -60,11 +63,11 @@ function makeHit(cfg, reason) {
|
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
const COVERED_MUTATION_TOOLS = new Set([
|
|
66
|
+
export const COVERED_MUTATION_TOOLS = new Set([
|
|
64
67
|
"edit", "write", "str_replace_editor", "pwsh", "bash",
|
|
65
68
|
"dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote"
|
|
66
69
|
]);
|
|
67
|
-
const SAFE_UNCOVERED_TOOLS = new Set([
|
|
70
|
+
export const SAFE_UNCOVERED_TOOLS = new Set([
|
|
68
71
|
"ask_user_question", "todo_write", "subagent", "workflow", "visualize", "skill",
|
|
69
72
|
"read", "grep", "glob", "read_image", "job_list", "job_output", "list_agents",
|
|
70
73
|
"get_goal", "dev_plugin_status", "dev_reload_package", "dev_injected_list",
|
|
@@ -174,6 +177,9 @@ function taskContractGuardDecision(state, exec, session) {
|
|
|
174
177
|
if (!isArmed(contract, state.taskContract)) return null;
|
|
175
178
|
const action = classifyAction(exec?.name, exec?.arguments);
|
|
176
179
|
const dec = decideContractAction({ contract, action, config: state.taskContract });
|
|
180
|
+
// 2026-08-25 预算双判防护:AGENT_BUDGET_EXHAUSTED 唯一裁决点 = tools/pre-execute 钩子(扣减前判定);
|
|
181
|
+
// guard 层可能在 pre-execute 扣减之后运行(收到已扣 contract),若在此重判会误拒“预算内”调用(允许 N 实际 N-1)。
|
|
182
|
+
if (dec.reasonCode === "AGENT_BUDGET_EXHAUSTED") return null;
|
|
177
183
|
if (dec.outcome === "deny") {
|
|
178
184
|
return makeHit(
|
|
179
185
|
{ ruleId: "__task-contract", title: `任务契约:${dec.reasonCode}`, action: "deny" },
|
|
@@ -190,7 +196,7 @@ function taskContractGuardDecision(state, exec, session) {
|
|
|
190
196
|
* @param {number} now
|
|
191
197
|
* @returns {object|null}
|
|
192
198
|
*/
|
|
193
|
-
export function guardDecision(state, exec, now = Date.now()) {
|
|
199
|
+
export function guardDecision(state, exec, now = Date.now(), opts = {}) {
|
|
194
200
|
if (state.enabled === false) return null;
|
|
195
201
|
if (state.bypassUntil > now) return null;
|
|
196
202
|
maybeReloadIfChanged(state, now);
|
|
@@ -208,16 +214,24 @@ export function guardDecision(state, exec, now = Date.now()) {
|
|
|
208
214
|
const contractHit = taskContractGuardDecision(state, exec, session);
|
|
209
215
|
if (contractHit) return contractHit;
|
|
210
216
|
|
|
211
|
-
//
|
|
217
|
+
// ask_user_question 必须真正送达用户;已有授权不再吞弹窗(2026-08-24 修复:
|
|
218
|
+
// __already-authorized 之前会把 ask 拦截成内部“已有授权”,导致用户收不到任何弹窗)
|
|
212
219
|
if (name === "ask_user_question") {
|
|
213
220
|
const qText = askQuestionText(args?.questions);
|
|
214
221
|
if (qText) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (auth) {
|
|
222
|
+
// 弹窗消减(2026-08-24):本回合 ask 已被拒 → 再次 ask 直接拦(提示改用普通文本)
|
|
223
|
+
if (session.turn.askRejected) {
|
|
218
224
|
return makeHit(
|
|
219
|
-
{ ruleId: "
|
|
220
|
-
|
|
225
|
+
{ ruleId: "__ask-rejected", title: "本回合 ask 已被拒绝", action: "deny" },
|
|
226
|
+
`【提示】本回合的 ask 已遭拒绝,请勿再次弹窗询问;改用普通文本说明即可(规则 22 沟通直接性)。`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
// 5 分钟内已有被拒的 ask 记录 → 拦(防连环弹窗 → not-pending 诱因链)
|
|
230
|
+
const recentReject = (state.askRejections || []).some((r) => r.sessionId === session.id && now - r.at < 5 * 60 * 1000);
|
|
231
|
+
if (recentReject) {
|
|
232
|
+
return makeHit(
|
|
233
|
+
{ ruleId: "__ask-throttle", title: "ask 请求过频", action: "deny" },
|
|
234
|
+
`【提示】5 分钟内已发起过 ask 且遭拒绝,请改用普通文本说明,勿连环弹窗询问。`
|
|
221
235
|
);
|
|
222
236
|
}
|
|
223
237
|
}
|
|
@@ -266,29 +280,92 @@ export function guardDecision(state, exec, now = Date.now()) {
|
|
|
266
280
|
|
|
267
281
|
for (const cfg of state.configs) {
|
|
268
282
|
if (cfg.disabled) continue;
|
|
269
|
-
// 低置信规则不硬拦(保守不误拦,交给 /guard rules
|
|
270
|
-
if (cfg.confidence === "low")
|
|
283
|
+
// 低置信规则不硬拦(保守不误拦,交给 /guard rules 人工复核)——批次 3:跳过必须留痕(N2 防御可观测)
|
|
284
|
+
if (cfg.confidence === "low") {
|
|
285
|
+
opts?.audit?.({
|
|
286
|
+
kind: "n2-skip",
|
|
287
|
+
rule: cfg.ruleId,
|
|
288
|
+
name: "低置信规则跳过",
|
|
289
|
+
event: "tool/guard",
|
|
290
|
+
reason: `规则 ${cfg.ruleId} 理解低置信 → 跳过硬拦(保守不误拦;请在 /guard rules 对该规则复核)`,
|
|
291
|
+
session: sessionIdOf(exec)
|
|
292
|
+
});
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
271
295
|
// 分级执行:只有 A 硬拦 / C 时序 / M 元规则才进入工具守卫
|
|
272
296
|
const actions = cfg.actions || [];
|
|
273
297
|
if (!actions.some((a) => a === "deny" || a === "ask" || a === "meta")) continue;
|
|
274
|
-
const hit = matchRule(cfg, { name, args, p, cmd, session, unlock, state, now });
|
|
298
|
+
const hit = matchRule(cfg, { name, args, p, cmd, session, unlock, state, now, audit: opts?.audit, exec });
|
|
275
299
|
if (hit) return hit;
|
|
276
300
|
}
|
|
277
301
|
return null;
|
|
278
302
|
}
|
|
279
303
|
|
|
280
304
|
function matchRule(cfg, ctx) {
|
|
281
|
-
const { name, args, p, cmd, session, unlock, state, now } = ctx;
|
|
305
|
+
const { name, args, p, cmd, session, unlock, state, now, audit, exec } = ctx;
|
|
282
306
|
const id = String(cfg.ruleId);
|
|
307
|
+
// 防热重载/事件顺序导致意图状态落后:若当前 turn.userText 与已缓存 intents.raw 不一致,
|
|
308
|
+
// 或尚无 intents 但有用户文本,则以最新用户文本重新解析并回写,避免用旧状态误拦。
|
|
309
|
+
// P0-3 根修(2026-08-24):裁决基底只用“本回合”的真实用户文本(turn.userText),
|
|
310
|
+
// 不再回退 lastUserText——上一轮残留文本(如含“为什么”的询问)不得裁决本轮。
|
|
311
|
+
const currentText = session.turn.userText || "";
|
|
312
|
+
let turnIntents = session.turn.intents;
|
|
313
|
+
if (turnIntents && currentText && turnIntents.raw !== currentText) {
|
|
314
|
+
turnIntents = parseUserIntents(currentText);
|
|
315
|
+
session.turn.intents = turnIntents;
|
|
316
|
+
} else if (!turnIntents && currentText) {
|
|
317
|
+
turnIntents = parseUserIntents(currentText);
|
|
318
|
+
session.turn.intents = turnIntents;
|
|
319
|
+
}
|
|
320
|
+
// 无用户消息回合(委派/ask 答复后/状态信号)不做规则 22 判定;敏感操作由 12A/13A 独立把关
|
|
321
|
+
const denyMutation = turnIntents
|
|
322
|
+
? shouldDenyMutation(turnIntents, session.turn.askSeen && !session.turn.askRejected)
|
|
323
|
+
: false;
|
|
283
324
|
|
|
284
|
-
// 规则 22
|
|
325
|
+
// 规则 22 机器化:无执行分点/存在歧义 → 本回合禁止变更类工具调用
|
|
285
326
|
//("你的执行方案难道没问题吗?"虽含"执行"仍是疑问句——同形词不构成指令,不豁免)
|
|
286
327
|
if (cfg.handler === "rule22-7-direct") {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
328
|
+
// 机制 B(2026-08-24):工具分类制(纯函数,覆盖现有与未来插件注册的全部工具)——
|
|
329
|
+
// control(SAFE 无条件放行)/ analysis(只读分析放行)/ artifact(产物放行+审计)/
|
|
330
|
+
// unknown(物理不拦,由 pre-execute ask 层首调询问,防新插件绕过);mutating 维持原有严格逻辑。
|
|
331
|
+
if (SAFE_UNCOVERED_TOOLS.has(name)) return null; // 授权/委托/技能/查询展示类 → 无条件放行
|
|
332
|
+
const cls = toolClass(name, args);
|
|
333
|
+
if (cls === "analysis") return null;
|
|
334
|
+
if (cls === "artifact") {
|
|
335
|
+
audit?.({ kind: "allow", rule: cfg.ruleId, name: "产物类工具放行", tool: name, reason: `分类 artifact(低风险产物写入,留痕可对账):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
if (cls === "unknown") return null; // 物理不拦;pre-execute ask 层首调询问
|
|
339
|
+
if (denyMutation) {
|
|
340
|
+
// LLM 意图兜底(方案 A,2026-08-24):词表判拦 + LLM 高/低置信判 execute → 放行
|
|
341
|
+
//(非对称:LLM 只解救不收紧;审计在预取时已记 intent-llm)
|
|
342
|
+
const verdict = verdictForDeny(session.turn, state.llmIntentCfg || {});
|
|
343
|
+
if (!verdict.mutationDenied) return null;
|
|
344
|
+
if (isReadOnlyTool(name, args)) return null; // 只读 → 豁免
|
|
345
|
+
const llmNote = verdict.source === "llm-low" ? "(LLM 低置信未能挽救)" : "";
|
|
346
|
+
return makeHit(cfg, `【硬拦截】用户消息是询问/没有明确执行分点(规则 22)${llmNote}:请先回答/展示方案,待用户明确授权后再执行(放行:存在执行分点,或 ask_user_question 授权答复)`);
|
|
291
347
|
}
|
|
348
|
+
// 规则 22 粒度升级(2026-08-24,用户点名治本项):
|
|
349
|
+
// 存在执行分点时不再"同回合一词放行",而是逐项比对本次变更是否落在
|
|
350
|
+
// 本回合 execute 子句或 ask 授权的「工具类别 + 路径前缀」范围内;未覆盖 → 拦。
|
|
351
|
+
// 无用户消息/状态信号/ask 答复后回合仍不做规则 22 判定(由 12A/13A 把关)。
|
|
352
|
+
if (!turnIntents || !turnIntents.hasExecute) return null;
|
|
353
|
+
if (isReadOnlyTool(name, args)) return null;
|
|
354
|
+
if (!COVERED_MUTATION_TOOLS.has(name) && !looksLikeFileMutation(name, args)) return null;
|
|
355
|
+
const op = operationOf(name, args);
|
|
356
|
+
// 保护性备份豁免(2026-08-26,backup 口径修复):规则 12A 豁免③ / 13A ⑦ 均豁免
|
|
357
|
+
// 复制到 .backups/.bak 的保护性备份——与 13A 分支口径一致,backup 类型不再被 22 粒度误拦。
|
|
358
|
+
if (op.type === "backup") return null;
|
|
359
|
+
// 只使用本回合的 scopes(execute 子句 + 本回合 ask 授权已由 handleSessionEvent 写入 turn.scopes);
|
|
360
|
+
// 不再把 session.authorizations 里历史 ask 授权拉进来,避免上一轮授权放宽本轮。
|
|
361
|
+
const scopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
|
|
362
|
+
? session.turn.scopes
|
|
363
|
+
: scopesFromIntents(turnIntents);
|
|
364
|
+
if (!scopes.some((scope) => authMatches(scope, op))) {
|
|
365
|
+
return makeHit(cfg, `【硬拦截】本次变更操作不在本回合执行分点/授权范围内(规则 22):已授权范围 [${describeScopes(scopes)}];本次操作 [${describeOp(op)}]。请补充明确授权(可用 ask_user_question)后再执行`);
|
|
366
|
+
}
|
|
367
|
+
// 可观测性(阶段 0,2026-08-24):规则 22 粒度放行是最高频放行路径,必须留痕可查
|
|
368
|
+
audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(规则 22 粒度命中):${describeScopes(scopes)} 覆盖 ${describeOp(op)}`, session: sessionIdOf(exec) });
|
|
292
369
|
return null;
|
|
293
370
|
}
|
|
294
371
|
|
|
@@ -346,7 +423,11 @@ function matchRule(cfg, ctx) {
|
|
|
346
423
|
if (targetPath && isVariablePath(targetPath)) return null;
|
|
347
424
|
// 已获 12A/12D 授权的操作 = 用户已明确确认本次操作 → 跳过 13A 机械备份(P1-2,一次授权覆盖全规则)
|
|
348
425
|
// 但高风险运行入口文件除外:即使已授权也必须有备份证据或明确提示
|
|
349
|
-
|
|
426
|
+
const auth13 = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
|
|
427
|
+
if (auth13 && !isHighRiskEntryFile(targetPath)) {
|
|
428
|
+
audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(13A 跳过备份):${describeAuth(auth13)}`, session: sessionIdOf(exec) });
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
350
431
|
// 复制/新建到“尚不存在”的目标文件:属于创建新文件,不适用 13A 覆盖备份要求
|
|
351
432
|
const isCreateNewTarget = !highRiskWrite && cmd && /copy-item|new-item/i.test(cmd) && targetPath && !existsSync(targetPath);
|
|
352
433
|
if (!isCreateNewTarget) {
|
|
@@ -365,14 +446,18 @@ function matchRule(cfg, ctx) {
|
|
|
365
446
|
}
|
|
366
447
|
|
|
367
448
|
// 规则 12B:技能调用四步时序(关键词→授权→调用;豁免技能除外)
|
|
368
|
-
|
|
449
|
+
// 2026-08-24 修复:旧条件 `handler==='rule12b-skill' || hints.includes('skill')` 会把
|
|
450
|
+
// hints 含 "skill" 的其它规则 cfg(如 12A:hints=["ask","skill","sensitive",...])截胡——
|
|
451
|
+
// 非 skill 工具时 return null,导致 12A 敏感授权检查静默失效(C-2 实测 Move-Item 放行)。
|
|
452
|
+
// 现在:hints 兜底仅在「name === 'skill'」时进入;handler 明确为 rule12b-skill 时维持原语义。
|
|
453
|
+
if (cfg.handler === "rule12b-skill") {
|
|
369
454
|
if (name === "skill") {
|
|
370
455
|
const skillName = typeof args?.name === "string" ? args.name : "";
|
|
371
456
|
if (SKILL_EXEMPT.has(skillName)) return null;
|
|
372
457
|
// 技能目录实时联动:已加载目录且该技能不存在/被禁用时,规则不激活
|
|
373
458
|
if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
|
|
374
|
-
if (
|
|
375
|
-
return makeHit(cfg,
|
|
459
|
+
if (denyMutation) {
|
|
460
|
+
return makeHit(cfg, `【硬拦截】当前用户消息是询问/没有明确执行分点,技能 ${skillName} 未获授权`);
|
|
376
461
|
}
|
|
377
462
|
const op = { type: "skill", pathPrefix: "" };
|
|
378
463
|
const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
|
|
@@ -380,6 +465,21 @@ function matchRule(cfg, ctx) {
|
|
|
380
465
|
const existing = session.authorizations.map(describeAuth).join(";") || "无";
|
|
381
466
|
return makeHit(cfg, `【硬拦截】技能调用缺少匹配授权:${skillName}(已有授权:${existing};本次范围:${describeOp(op)})`);
|
|
382
467
|
}
|
|
468
|
+
audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(技能 ${skillName}):${describeAuth(auth)}`, session: sessionIdOf(exec) });
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
if ((cfg.hints || []).includes("skill") && name === "skill") {
|
|
474
|
+
// hints 兜底:理解器未分配 handler 但 hints 含 skill 的 cfg——仅 skill 工具时参与,不截胡其它规则
|
|
475
|
+
const skillName = typeof args?.name === "string" ? args.name : "";
|
|
476
|
+
if (SKILL_EXEMPT.has(skillName)) return null;
|
|
477
|
+
if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
|
|
478
|
+
const op = { type: "skill", pathPrefix: "" };
|
|
479
|
+
const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
|
|
480
|
+
if (!auth) {
|
|
481
|
+
const existing = session.authorizations.map(describeAuth).join(";") || "无";
|
|
482
|
+
return makeHit(cfg, `【硬拦截】技能调用缺少匹配授权:${skillName}(已有授权:${existing};本次范围:${describeOp(op)})`);
|
|
383
483
|
}
|
|
384
484
|
return null;
|
|
385
485
|
}
|
|
@@ -392,14 +492,18 @@ function matchRule(cfg, ctx) {
|
|
|
392
492
|
// /guard unlock 本身即用户对受保护配置的授权
|
|
393
493
|
if (unlock && isProtectedConfigPath(p)) return null;
|
|
394
494
|
const op = operationOf(name, args);
|
|
395
|
-
|
|
396
|
-
|
|
495
|
+
// 规则 12A 正文豁免:保护性备份(创建/复制/移动文件到 .backups/ 或 .backups/trash-<时间戳>/)免询问
|
|
496
|
+
if (op.type === "backup") return null;
|
|
497
|
+
if (denyMutation) {
|
|
498
|
+
return makeHit(cfg, `【硬拦截】当前用户消息是询问/没有明确执行分点,未构成授权证据(本次操作:${describeOp(op)})`);
|
|
397
499
|
}
|
|
398
500
|
const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
|
|
399
501
|
if (!auth) {
|
|
400
502
|
const existing = session.authorizations.map(describeAuth).join(";") || "无";
|
|
401
503
|
return makeHit(cfg, `【硬拦截】敏感操作缺少匹配授权:已有授权范围 [${existing}];本次操作范围 [${describeOp(op)}]`);
|
|
402
504
|
}
|
|
505
|
+
audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(12A 敏感操作):${describeAuth(auth)}`, session: sessionIdOf(exec) });
|
|
506
|
+
return null;
|
|
403
507
|
}
|
|
404
508
|
return null;
|
|
405
509
|
}
|
|
@@ -477,14 +581,15 @@ function matchRule(cfg, ctx) {
|
|
|
477
581
|
if (p && MANUAL_PATH_RE.test(p)) return null;
|
|
478
582
|
if (unlock && isProtectedConfigPath(p)) return null;
|
|
479
583
|
const op = operationOf(name, args);
|
|
480
|
-
if (
|
|
481
|
-
return makeHit(cfg,
|
|
584
|
+
if (denyMutation) {
|
|
585
|
+
return makeHit(cfg, `【硬拦截】当前用户消息是询问/没有明确执行分点(本次操作:${describeOp(op)})`);
|
|
482
586
|
}
|
|
483
587
|
const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
|
|
484
588
|
if (!auth) {
|
|
485
589
|
const existing = session.authorizations.map(describeAuth).join(";") || "无";
|
|
486
590
|
return makeHit(cfg, `【硬拦截】${cfg.title}:缺少匹配授权(已有:${existing};本次:${describeOp(op)})`);
|
|
487
591
|
}
|
|
592
|
+
audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(${cfg.title}):${describeAuth(auth)}`, session: sessionIdOf(exec) });
|
|
488
593
|
}
|
|
489
594
|
return null;
|
|
490
595
|
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// intent.js - 用户消息子句级意图解析(规则 22 分点语义的引擎侧实现)。
|
|
2
|
+
// 输入同一条用户消息,输出:
|
|
3
|
+
// - 按数字列表/标签/单条消息切分出的子句
|
|
4
|
+
// - 每个子句的意图类型:execute / plan / question / info / status / ambiguous
|
|
5
|
+
// - 聚合标志:hasExecute / hasPlan / hasQuestion / hasStatus / ambiguous
|
|
6
|
+
//
|
|
7
|
+
// 设计原则:
|
|
8
|
+
// - 数字列表是确定性切分结构;
|
|
9
|
+
// - 标签是高置信语义标注;
|
|
10
|
+
// - 无标签时用保守规则分类,宁可少授权、不多授权。
|
|
11
|
+
// - “可以执行吗?”是询问,不是执行授权;
|
|
12
|
+
// - “请给出执行方案”是方案请求,不是执行授权;
|
|
13
|
+
// - “我已重启/已输入”等状态信号(规则 22⑩)是对先前指令的就绪确认:
|
|
14
|
+
// 不构成执行授权、也不触发“无执行分点”拦截;变更类操作仍由 12A/13A 把关。
|
|
15
|
+
//
|
|
16
|
+
// 2026-08-24(v0.5.5 规划,事故复盘 P1-5/P0-3/规则 22⑩):
|
|
17
|
+
// - 词表单一来源:QUESTION_RE 同时供 authorization.isQuestionMessage 使用,
|
|
18
|
+
// 消灭“两套疑问词判定不一致”(如 authorization 的“影响吗”与 intent 的“怎么”)。
|
|
19
|
+
// - 新增 STATUS_SIGNAL_RE / isStatusSignal():状态信号分类(宽松版,危险词排除,
|
|
20
|
+
// 12A/13A 兜底),与“无用户消息回合”同等待遇。
|
|
21
|
+
|
|
22
|
+
export const TAG_RE = /【(执行|方案|问询|信息)】/;
|
|
23
|
+
|
|
24
|
+
// 统一疑问词表(合并原 intent 与 authorization 两套:怎么/为何 + 影响吗/可以吗/要不要/需不需要)
|
|
25
|
+
export const QUESTION_RE = /[??]|吗|为什么|是否|能不能|行不行|难道|是不是|能否|怎么|为何|是否要|影响吗|可以吗|要不要|需不需要/;
|
|
26
|
+
|
|
27
|
+
const DEFER_RE = /待确认|确认后再|等我确认|先方案|先别|不要执行|之后再说|确认后|再确认|先确认|不要直接/;
|
|
28
|
+
const ACTION_RE = /执行|落盘|开始|写入|写(?:下|好|完|成|作|文件)?|保存|另存为|删除|修改|改(?:为|成|动|一下|进|善)?|补(?:上|齐|全|充|写)?|修(?:改|复|一下)?|做(?:好|完|一下)?|创建|安装|发布|下载|提交|运行|修复|替换|重建|启动|重启|停止|卸载|开始改|开始写|改进|优化|增强|完善|升级|迁移|整理|调整|实现|实施|添加|增加|补充|重写|改造|调试|排查|处理|解决|继续|推进|更新|部署|核实|梳理|诊断|对齐|跟进|落实|设计并实现|调查|开工|动手|进行|完成|选择|采纳|选定|产出|编写|撰写|生成|审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|调研|研究/;
|
|
29
|
+
const PLAN_RE = /方案|建议|评估|分析|设计|规划|计划|给出|提供/;
|
|
30
|
+
const STRONG_EXEC_RE = /执行吧|开始执行|直接执行|立即执行|现在执行|马上执行|请落盘|开始写|开始改|执行以下|执行第|执行这个|执行它|落盘|开始蒸馏|开始迁移|开始修复|开始改进|开始优化|采纳|选定/;
|
|
31
|
+
const NUMBERED_ITEM_RE = /^\s*(?:\(?(\d+)[\)、..)]\s*)/;
|
|
32
|
+
|
|
33
|
+
// 状态信号:对先前指令的就绪/完成确认(“我已重启 / 已输入 / 重启完成 / 好了 / done”)。
|
|
34
|
+
// 语义 = 与“无用户消息回合”同等待遇:不触发规则 22 的“无执行分点”判定(规则 22⑩)。
|
|
35
|
+
export const STATUS_SIGNAL_RE =
|
|
36
|
+
/(?:我已|我已经|我(?:们)?(?:刚)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好))(?:了|完毕|完成|好)?|(?:^|\s)(?:已|已经)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好|unlock)(?:了|完毕|完成|好)?(?:\s|$)|(?:^|\s)已经(?:刚)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好|unlock)[^\s]*(?:\s|$)|(?:^|\s)(?:重启|输入|命令|操作)(?:完成|完毕|好了|已输入)(?:\s|$)|(?:^|\s)好了(?:\s|$)|^(?:done|restarted|finished|entered)$/i;
|
|
37
|
+
|
|
38
|
+
// 危险动作词:命中则不当状态信号(防“我已删除了某文件”被当成就绪确认)
|
|
39
|
+
const DANGEROUS_ACTION_RE = /删除|覆盖|移除|清空|卸载|remove-item|rm\s+-r|del\s+/i;
|
|
40
|
+
|
|
41
|
+
/** DSH 在会话上下文里注入的系统文本(工作区指令/技能目录等),不是用户的真实消息 */
|
|
42
|
+
export function isInjectedContextMessage(text) {
|
|
43
|
+
const t = String(text || "");
|
|
44
|
+
return t.includes("<system-reminder>") || t.includes("</system-reminder>");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 规则引擎自身注入的纠正/提示消息(maybeInject 发出,前缀 [规则引擎]),不是用户消息。
|
|
49
|
+
* 2026-08-24 修复:此前这类文本含“请直接执行/继续执行”,会被 handleSessionEvent 当成用户执行分点
|
|
50
|
+
* 并 recordAuthorization → 引擎自我续授权,导致 /guard revoke 后仍会被自己加回全局授权。
|
|
51
|
+
*/
|
|
52
|
+
export function isEngineInjectedMessage(text) {
|
|
53
|
+
const t = String(text || "").trim();
|
|
54
|
+
return t.startsWith("[规则引擎]");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 已知系统注入模板(机制 A 兜底层,2026-08-24):
|
|
58
|
+
// source.kind 主判之外,对"标记为 user 但实为注入"的消息做模板兜底——
|
|
59
|
+
// 命中即整体跳过:不覆盖回合状态、不产生 scopes/授权。
|
|
60
|
+
// 本机实证来源:runtime context 快照(无 system-reminder 标签)、子代理完成通知(B3)、
|
|
61
|
+
// vision-router 自动挂载提醒(index.js:3494-3518)、引擎自身注入([规则引擎])。
|
|
62
|
+
// super-injector 的近距离引导模板待实测后收窄补充。
|
|
63
|
+
const SYSTEM_INJECTION_PREFIXES = [
|
|
64
|
+
/^Current runtime context\./,
|
|
65
|
+
/^Background subagent\b/,
|
|
66
|
+
/^\[规则引擎\]/,
|
|
67
|
+
/^本轮消息包含图片,像素级视觉工具已自动挂载/
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** 整条文本是否为"已知系统注入"(不是用户想说的事) */
|
|
71
|
+
export function isKnownSystemInjection(text) {
|
|
72
|
+
const t = String(text || "").trim();
|
|
73
|
+
return SYSTEM_INJECTION_PREFIXES.some((re) => re.test(t));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 剥离 system-reminder 注入块,返回剩余用户文本。
|
|
78
|
+
* 2026-08-24 v2:注入可能与真实用户消息合并为同一事件(本会话实测模式:
|
|
79
|
+
* <system-reminder> 块 + 用户文本同在一个 content 数组,整条跳过会把真实消息也丢弃)。
|
|
80
|
+
* 无注入 → 原样返回;纯注入 → 空串(调用方按"跳过"处理)。
|
|
81
|
+
*/
|
|
82
|
+
export function stripInjectedContext(text) {
|
|
83
|
+
const t = String(text || "");
|
|
84
|
+
if (!t.includes("<system-reminder>")) return t;
|
|
85
|
+
return t.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 状态信号判定(规则 22⑩):对先前指令的就绪确认,且不含危险动作词 */
|
|
89
|
+
export function isStatusSignal(text) {
|
|
90
|
+
const t = String(text || "");
|
|
91
|
+
// 命令式排除(2026-08-25 RB-05):行首裸“完成/重启/执行/搞定等 + 空格 + 宾语”= 命令
|
|
92
|
+
//(“完成 A 和 C 两项”/“重启服务”),不是对先前指令的就绪确认;状态信号须带“已/我已完成”前缀
|
|
93
|
+
// 或“了/完成/完毕/好”后缀(根因:STATUS_SIGNAL_RE 的“裸词 + 可选后缀”分支把命令式误判为状态)。
|
|
94
|
+
if (/^(?:请|帮我)?\s*(?:先|接着|然后)?\s*(?:完成|重启|执行|搞定|做完|弄好)\s+\S/.test(t.trim())) return false;
|
|
95
|
+
return STATUS_SIGNAL_RE.test(t) && !DANGEROUS_ACTION_RE.test(t);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeTag(tag) {
|
|
99
|
+
if (!tag) return null;
|
|
100
|
+
const t = tag.trim();
|
|
101
|
+
if (t === "执行") return "execute";
|
|
102
|
+
if (t === "方案") return "plan";
|
|
103
|
+
if (t === "问询") return "question";
|
|
104
|
+
if (t === "信息") return "info";
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function extractTag(text) {
|
|
109
|
+
const m = String(text || "").match(TAG_RE);
|
|
110
|
+
return m ? m[1] : "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function splitClauses(text) {
|
|
114
|
+
const raw = String(text || "").trim();
|
|
115
|
+
if (!raw) return [];
|
|
116
|
+
|
|
117
|
+
// 同行内多个编号("1、A 2、B"):在编号前(前导空白处)插入换行,统一按行切分。
|
|
118
|
+
// 修复 2026-08-24 实测缺陷:同行编号列表被并入单子句,"方案"等词导致整条误判 plan。
|
|
119
|
+
// 保守:仅切分“编号前为空白”的项("执行第2、3条"中"2、"前是汉字 → 不切)。
|
|
120
|
+
const normalized = raw.replace(/(?:\s)(\d+[\)、..)])/g, "\n$1");
|
|
121
|
+
const lines = normalized.split(/\r?\n/);
|
|
122
|
+
const hasNumbered = lines.some((line) => NUMBERED_ITEM_RE.test(line));
|
|
123
|
+
const hasTagLines = lines.filter((line) => TAG_RE.test(line)).length >= 2;
|
|
124
|
+
|
|
125
|
+
if (!hasNumbered) {
|
|
126
|
+
if (hasTagLines) {
|
|
127
|
+
return lines
|
|
128
|
+
.map((line, i) => ({ id: String(i + 1), raw: line.trim() }))
|
|
129
|
+
.filter((c) => c.raw);
|
|
130
|
+
}
|
|
131
|
+
// 标题 + 正文 拆分:非标准【标签】的「【标题】正文」结构拆为两个子句,
|
|
132
|
+
// 使标题含动作词(如"改进/优化")即构成执行分点,正文的疑问/描述不再掩盖标题意图(规则 22 混合指令)
|
|
133
|
+
const tm = raw.match(/^【([^】]{2,})】\s*([\s\S]+)$/);
|
|
134
|
+
if (tm && !normalizeTag(tm[1])) {
|
|
135
|
+
return [
|
|
136
|
+
{ id: "t", raw: tm[1].trim() },
|
|
137
|
+
{ id: "b", raw: tm[2].trim() }
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
return [{ id: "1", raw }];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const clauses = [];
|
|
144
|
+
let current = null;
|
|
145
|
+
|
|
146
|
+
const pushCurrent = () => {
|
|
147
|
+
if (current && current.raw.trim()) clauses.push(current);
|
|
148
|
+
current = null;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
for (const line of lines) {
|
|
152
|
+
const m = line.match(NUMBERED_ITEM_RE);
|
|
153
|
+
if (m) {
|
|
154
|
+
pushCurrent();
|
|
155
|
+
current = {
|
|
156
|
+
id: m[1],
|
|
157
|
+
raw: line.replace(NUMBERED_ITEM_RE, "").trim()
|
|
158
|
+
};
|
|
159
|
+
} else if (!current) {
|
|
160
|
+
// 编号前的引言/说明,单独作为一个前置子句
|
|
161
|
+
current = { id: "0", raw: line.trim() };
|
|
162
|
+
} else {
|
|
163
|
+
current.raw += (current.raw ? "\n" : "") + line.trim();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
pushCurrent();
|
|
167
|
+
|
|
168
|
+
// 只有单个编号时也返回单子句,避免把“1. 执行吧”切成无意义列表
|
|
169
|
+
if (clauses.length <= 1 && clauses[0]?.id === "1") {
|
|
170
|
+
return [{ id: "1", raw: raw }];
|
|
171
|
+
}
|
|
172
|
+
return clauses;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function classifyClause(clause) {
|
|
176
|
+
const text = String(clause.raw || "").trim();
|
|
177
|
+
const tag = extractTag(text);
|
|
178
|
+
const tagType = normalizeTag(tag);
|
|
179
|
+
const hasQuestion = QUESTION_RE.test(text);
|
|
180
|
+
const hasDefer = DEFER_RE.test(text);
|
|
181
|
+
const hasAction = ACTION_RE.test(text);
|
|
182
|
+
const hasPlan = PLAN_RE.test(text);
|
|
183
|
+
const hasStrongExec = STRONG_EXEC_RE.test(text);
|
|
184
|
+
|
|
185
|
+
let type = "info";
|
|
186
|
+
let ambiguous = false;
|
|
187
|
+
|
|
188
|
+
if (tagType) {
|
|
189
|
+
if (tagType === "execute" && hasQuestion) {
|
|
190
|
+
type = "ambiguous";
|
|
191
|
+
ambiguous = true;
|
|
192
|
+
} else {
|
|
193
|
+
type = tagType;
|
|
194
|
+
}
|
|
195
|
+
} else if (hasQuestion) {
|
|
196
|
+
type = "question";
|
|
197
|
+
} else if (isStatusSignal(text)) {
|
|
198
|
+
type = "status";
|
|
199
|
+
} else if (hasDefer && hasAction) {
|
|
200
|
+
type = "plan";
|
|
201
|
+
} else if (hasPlan && !hasStrongExec) {
|
|
202
|
+
type = "plan";
|
|
203
|
+
} else if (hasAction) {
|
|
204
|
+
type = "execute";
|
|
205
|
+
} else if (hasPlan) {
|
|
206
|
+
type = "plan";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
id: clause.id,
|
|
211
|
+
raw: text,
|
|
212
|
+
type,
|
|
213
|
+
tag: tag || null,
|
|
214
|
+
hasAction,
|
|
215
|
+
hasQuestion,
|
|
216
|
+
deferred: hasDefer,
|
|
217
|
+
ambiguous
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 解析用户消息中的子句级意图。
|
|
223
|
+
* @param {string} text 用户消息纯文本
|
|
224
|
+
* @returns {object} turn.intents 兼容结构
|
|
225
|
+
*/
|
|
226
|
+
export function parseUserIntents(text) {
|
|
227
|
+
const clauses = splitClauses(text);
|
|
228
|
+
const parsed = clauses.map(classifyClause);
|
|
229
|
+
|
|
230
|
+
const hasExecute = parsed.some((c) => c.type === "execute");
|
|
231
|
+
const hasPlan = parsed.some((c) => c.type === "plan");
|
|
232
|
+
const hasQuestion = parsed.some((c) => c.type === "question");
|
|
233
|
+
const hasStatus = parsed.some((c) => c.type === "status");
|
|
234
|
+
const ambiguous = parsed.some((c) => c.ambiguous || c.type === "ambiguous");
|
|
235
|
+
const hasTag = parsed.some((c) => c.tag);
|
|
236
|
+
const numberedCount = (String(text || "").match(/(?:^|\n)\s*\(?\d+[\)、..)]/g) || []).length;
|
|
237
|
+
|
|
238
|
+
let source = "single";
|
|
239
|
+
if (numberedCount >= 2 && hasTag) source = "mixed";
|
|
240
|
+
else if (numberedCount >= 2) source = "numbered-list";
|
|
241
|
+
else if (hasTag) source = "labels";
|
|
242
|
+
|
|
243
|
+
let confidence = "high";
|
|
244
|
+
if (ambiguous) confidence = "low";
|
|
245
|
+
else if (source === "single" && !hasTag) confidence = "medium";
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
raw: String(text || ""),
|
|
249
|
+
source,
|
|
250
|
+
clauses: parsed,
|
|
251
|
+
hasExecute,
|
|
252
|
+
hasPlan,
|
|
253
|
+
hasQuestion,
|
|
254
|
+
hasStatus,
|
|
255
|
+
ambiguous,
|
|
256
|
+
confidence
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 兼容旧判断:是否应禁止变更(无执行授权且存在问询语义) */
|
|
261
|
+
export function shouldDenyMutation(intents, askSeen = false) {
|
|
262
|
+
if (askSeen) return false;
|
|
263
|
+
if (!intents) return true;
|
|
264
|
+
if (intents.hasStatus) return false; // 状态信号消息不触发规则 22 拦截(规则 22⑩,2026-08-24)
|
|
265
|
+
if (intents.ambiguous) return true;
|
|
266
|
+
return !intents.hasExecute;
|
|
267
|
+
}
|