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.
@@ -1,724 +1,754 @@
1
- // guard-core.js - 工具守卫裁决(纯函数,可独立测试)。
2
- // 被 index.js 的 ctx.tools.guard() 调用;返回 reason 即物理拒绝。
3
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { dirname, isAbsolute, join, resolve } from "node:path";
6
- import {
7
- BOM_WRITE,
8
- DESTRUCTIVE_CMD,
9
- DSH_KEYWORDS_RE,
10
- INLINE_CMD,
11
- MANUAL_PATH_RE,
12
- PROTECTED_FILENAME_RE,
13
- SKILL_EXEMPT,
14
- commandText,
15
- isAssemblyMutationTool,
16
- isBackupTool,
17
- isHighRiskEntryFile,
18
- isManualReadTool,
19
- isMutationCommand,
20
- isOutsideWorkspace,
21
- isProtectedConfigPath,
22
- isReadOnlyTool,
23
- isSensitiveToolCall,
24
- isVariablePath,
25
- isVerificationCommand,
26
- isLowRiskWorkspaceNew,
27
- isAnalysisOp,
28
- isAnalysisScratchPath,
29
- extractAnalysisScratchPaths,
30
- pathTarget
31
- } from "./patterns.js";
32
- import { authMatches, describeAuth, describeOp, describeScopes, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText, scopesFromIntents } from "./authorization.js";
33
- import { parseUserIntents, shouldDenyMutation } from "./intent.js";
34
- import { toolClass } from "./tool-catalog.js";
35
- import { verdictForDeny } from "./llm-intent.js";
36
- import { computeMountSignature, profileNameFromArgs } from "./mount-signature.js";
37
- import { findBackupForPath, getSessionState, maybeReloadIfChanged } from "./state.js";
38
- import { isVersionedFile, validateEditedFile } from "./version-guard.js";
39
- import { decideContractAction, defaultContract, isArmed } from "./contract.js";
40
- import { classifyAction } from "./overengineering.js";
41
-
42
- function sessionIdOf(exec) {
43
- const agent = exec?.agent;
44
- if (!agent) return "global";
45
- if (typeof agent.session === "object" && agent.session?.id) return agent.session.id;
46
- if (typeof agent.session === "string") return agent.session;
47
- return "global";
48
- }
49
-
50
- /**
51
- * B2(2026-08-28 阶段二):委派/子代理会话判定(规则 22④"无用户消息回合(委派…)"的识别层)。
52
- * 官方子代理(dsh-subagent-in-process-driver)把任务 prompt 以 source.kind="user" 注入子会话,
53
- * 引擎按真实用户消息处理 子代理的"任务书"被判"无执行分点"→ 读文件等操作被 ERR-L8QAXS 拦
54
- * (2026-08-28 实弹)。规则 22④ 承诺委派回合不做此判定;判定依据 = 会话元数据
55
- * (agent.session.meta.delegationDepth>0 / origin==="subagent" / 存在 parentSession)。
56
- */
57
- function isDelegatedSession(exec) {
58
- const agent = exec?.agent;
59
- const sess = agent?.session;
60
- if (!sess || typeof sess !== "object") return false;
61
- const meta = sess.meta;
62
- if (meta && typeof meta === "object") {
63
- if (Number(meta.delegationDepth) > 0) return true;
64
- if (meta.origin === "subagent") return true;
65
- if (typeof meta.parentSession === "string") return true;
66
- }
67
- // 兜底:agent 层(部分驱动结构 meta 在 agent 侧)
68
- if (Number(agent?.meta?.delegationDepth) > 0 || agent?.meta?.origin === "subagent") return true;
69
- return false;
70
- }
71
-
72
- const RULE_HINTS = {
73
- "1": "先分析根因,确认问题后再继续",
74
- "9": "改用脚本文件或显式 UTF-8 BOM 流程",
75
- "12A": " ask_user_question 获取匹配授权",
76
- "13A": "先对目标路径执行备份(复制到 .bak/.backups/trash-)",
77
- "18": "先读取 ~/.dsh/skills/dsh-usage-manual/SKILL.md",
78
- "21": "按规则 21 分级确认后再落盘",
79
- "22": "先回答/展示方案,或补充明确执行分点(工具类别+路径范围);已授权变更必须落在本回合执行分点/ask 授权范围内",
80
- "24": "确认插件 dsh.bundle 类型或改用正确挂载",
81
- "27": "先运行 node scripts/audit-mount-consistency.mjs --profile web"
82
- };
83
-
84
- function makeHit(cfg, reason) {
85
- const errId = Math.random().toString(36).slice(2, 8).toUpperCase();
86
- const hint = RULE_HINTS[String(cfg.ruleId)] || "见 /guard rules";
87
- return {
88
- ruleId: cfg.ruleId,
89
- title: cfg.title,
90
- action: "deny",
91
- errId,
92
- reason: `${reason}(规则 ${cfg.ruleId}|放行:${hint}|ERR-${errId}|误判可打标:/guard label ERR-${errId} incorrect)`
93
- };
94
- }
95
-
96
- // 0.5.9(单真源):COVERED_MUTATION_TOOLS / SAFE_UNCOVERED_TOOLS 独立覆盖表已废弃——
97
- // 工具分类以 lib/core/tool-catalog.js 的唯一分类表为准(24④/22/unknown 全部读它),
98
- // 双表漂移即 run_code 事故根因(K-01/K-02/K-06 依据)。
99
-
100
- function looksLikeFileMutation(name, args) {
101
- const a = args || {};
102
- return Boolean(a.file_path || a.path || a.command || a.code || a.execute || a.script || a.fn);
103
- }
104
-
105
- /** 统计 substring 在 text 中出现的次数(0.5.11 唯一性前提辅助) */
106
- function countOccurrences(text, sub) {
107
- if (typeof text !== "string" || typeof sub !== "string" || sub.length === 0) return 0;
108
- let count = 0;
109
- let idx = text.indexOf(sub);
110
- while (idx !== -1) {
111
- count++;
112
- idx = text.indexOf(sub, idx + sub.length);
113
- }
114
- return count;
115
- }
116
-
117
- export function isProfilePackageJson(p) {
118
- return typeof p === "string" && /profiles[\\/][^\\/]+[\\/]package\.json$/i.test(p);
119
- }
120
-
121
- /** 判定命令是否"整条命令仅调用统一入口 dsh-manual-write.mjs"(无 ; | & 链式/换行) */
122
- function isEntryChannelCommand(cmd) {
123
- return typeof cmd === "string" && /^[^\n;|&]*dsh-manual-write\.mjs[^\n;|&]*$/i.test(cmd);
124
- }
125
-
126
- /** 计算 edit/write/str_replace 后的目标文件内容;无法可靠计算时返回 null */
127
- function resultingFileContent(name, args) {
128
- const p = pathTarget(args);
129
- if (!p) return null;
130
- if (name === "write") return typeof args?.content === "string" ? args.content : null;
131
- if (!existsSync(p)) return null;
132
- let current;
133
- try { current = readFileSync(p, "utf8"); } catch { return null; }
134
- if (name === "edit") {
135
- const oldS = args?.old_string;
136
- const newS = args?.new_string;
137
- if (typeof oldS === "string" && typeof newS === "string" && current.includes(oldS)) return current.replace(oldS, newS);
138
- return null;
139
- }
140
- if (name === "str_replace_editor" && args?.command === "str_replace") {
141
- const oldS = args?.old_str;
142
- const newS = args?.new_str;
143
- if (typeof oldS === "string" && typeof newS === "string" && current.includes(oldS)) return current.replace(oldS, newS);
144
- return null;
145
- }
146
- return null;
147
- }
148
-
149
- /** 从 profile 目录解析 bundle 的 package.json(兼容 profiles/web/node_modules 与 profiles/node_modules) */
150
- function resolveBundlePkgPath(bundleName, profilePkgPath) {
151
- const profileDir = dirname(profilePkgPath);
152
- const profilesNodeModules = join(dirname(profileDir), "node_modules");
153
- const candidates = [];
154
- if (bundleName.startsWith("@")) {
155
- const [scope, name] = bundleName.split("/");
156
- candidates.push(join(profileDir, "node_modules", scope, name, "package.json"));
157
- candidates.push(join(profilesNodeModules, scope, name, "package.json"));
158
- } else {
159
- candidates.push(join(profileDir, "node_modules", bundleName, "package.json"));
160
- candidates.push(join(profilesNodeModules, bundleName, "package.json"));
161
- }
162
- for (const c of candidates) if (existsSync(c)) return c;
163
- return null;
164
- }
165
-
166
- /** profile package.json 的 dependencies 中解析本地 link/file 依赖的包路径;无法解析返回 null */
167
- function resolveLocalDependencyPkgPath(bundleName, profilePkgPath, parsed) {
168
- const dep = parsed?.dependencies?.[bundleName] ?? parsed?.devDependencies?.[bundleName] ?? parsed?.optionalDependencies?.[bundleName];
169
- if (typeof dep !== "string") return null;
170
- let localPath = null;
171
- if (dep.startsWith("link:")) localPath = dep.slice(5);
172
- else if (dep.startsWith("file:")) localPath = dep.slice(5);
173
- if (!localPath) return null;
174
- const resolved = isAbsolute(localPath) ? localPath : resolve(dirname(profilePkgPath), localPath);
175
- const pkgPath = join(resolved, "package.json");
176
- return existsSync(pkgPath) ? pkgPath : null;
177
- }
178
-
179
- /** 若本次文件变更会写入 profile package.json dsh.profile.bundles,返回其中非 bundle/无法确认的项 */
180
- export function nonBundleInProfileBundles(name, args) {
181
- const p = pathTarget(args);
182
- if (!isProfilePackageJson(p)) return null;
183
- const content = resultingFileContent(name, args);
184
- if (!content) return null;
185
- let parsed;
186
- try { parsed = JSON.parse(content); } catch { return null; }
187
- const bundles = parsed?.dsh?.profile?.bundles;
188
- if (!Array.isArray(bundles)) return null;
189
- const bad = [];
190
- for (const b of bundles) {
191
- const pkgPath = resolveBundlePkgPath(b, p) || resolveLocalDependencyPkgPath(b, p, parsed);
192
- if (!pkgPath) {
193
- const dep = parsed?.dependencies?.[b] ?? parsed?.devDependencies?.[b] ?? parsed?.optionalDependencies?.[b];
194
- const depDesc = typeof dep === "string" ? `dependencies 为 ${dep}` : "dependencies 中无此包";
195
- bad.push(`${b}(找不到 package.json,无法确认类型;${depDesc}。请先用 dev_install_package 或先安装依赖再写 bundles)`);
196
- continue;
197
- }
198
- try {
199
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
200
- if (!pkg?.dsh?.bundle) bad.push(`${b}(未声明 dsh.bundle)`);
201
- } catch {
202
- bad.push(`${b}(package.json 读取失败)`);
203
- }
204
- }
205
- return bad.length ? bad : null;
206
- }
207
-
208
- /** 任务契约守卫:仅在总开关开启且会话 armed 时硬拦;ask 场景交给 tools/pre-execute */
209
- function taskContractGuardDecision(state, exec, session) {
210
- if (!state.taskContract?.taskContractEnabled) return null;
211
- const contract = session?.contract || defaultContract();
212
- if (!isArmed(contract, state.taskContract)) return null;
213
- const action = classifyAction(exec?.name, exec?.arguments);
214
- const dec = decideContractAction({ contract, action, config: state.taskContract });
215
- // 2026-08-25 预算双判防护:AGENT_BUDGET_EXHAUSTED 唯一裁决点 = tools/pre-execute 钩子(扣减前判定);
216
- // guard 层可能在 pre-execute 扣减之后运行(收到已扣 contract),若在此重判会误拒“预算内”调用(允许 N 实际 N-1)。
217
- if (dec.reasonCode === "AGENT_BUDGET_EXHAUSTED") return null;
218
- if (dec.outcome === "deny") {
219
- return makeHit(
220
- { ruleId: "__task-contract", title: `任务契约:${dec.reasonCode}`, action: "deny" },
221
- `【硬拦截】${dec.reason}(${dec.reasonCode}|任务契约|放行:${dec.nextStep || "..."}|ERR-${Math.random().toString(36).slice(2, 8).toUpperCase()})`
222
- );
223
- }
224
- return null;
225
- }
226
-
227
- /**
228
- * 裁决一次工具调用。
229
- * @param {object} state createState 返回的运行时状态
230
- * @param {object} exec ToolExecution(至少 name/arguments)
231
- * @param {number} now
232
- * @returns {object|null}
233
- */
234
- export function guardDecision(state, exec, now = Date.now(), opts = {}) {
235
- if (state.enabled === false) return null;
236
- if (state.bypassUntil > now) return null;
237
- maybeReloadIfChanged(state, now);
238
- const name = String(exec?.name || "");
239
- const args = exec?.arguments || {};
240
- const session = getSessionState(state, sessionIdOf(exec));
241
- const unlock = state.unlockUntil > now;
242
- const p = pathTarget(args);
243
- const cmd = commandText(args);
244
-
245
- // 只读操作无条件放行(read/grep/glob/read_image/str_replace_editor view)
246
- if (isReadOnlyTool(name, args)) return null;
247
-
248
- // 任务契约守卫(总开关关闭时不生效)
249
- const contractHit = taskContractGuardDecision(state, exec, session);
250
- if (contractHit) return contractHit;
251
-
252
- // ask_user_question 必须真正送达用户;已有授权不再吞弹窗(2026-08-24 修复:
253
- // __already-authorized 之前会把 ask 拦截成内部“已有授权”,导致用户收不到任何弹窗)
254
- if (name === "ask_user_question") {
255
- const qText = askQuestionText(args?.questions);
256
- if (qText) {
257
- // 弹窗消减(2026-08-24):本回合 ask 已被拒 → 再次 ask 直接拦(提示改用普通文本)
258
- if (session.turn.askRejected) {
259
- return makeHit(
260
- { ruleId: "__ask-rejected", title: "本回合 ask 已被拒绝", action: "deny" },
261
- `【提示】本回合的 ask 已遭拒绝(规则 22 沟通直接性)。用户已明确答复,无需再次弹窗询问;用普通文本说明即可。`
262
- );
263
- }
264
- // 5 分钟内已有被拒的 ask 记录 → 拦(防连环弹窗 → not-pending 诱因链)
265
- // C3(2026-08-28):节流池仅含"明确拒绝"(未响应不入池,见 index.js ask 结果处理)
266
- const recentReject = (state.askRejections || []).some((r) => r.sessionId === session.id && now - r.at < 5 * 60 * 1000);
267
- if (recentReject) {
268
- return makeHit(
269
- { ruleId: "__ask-throttle", title: "ask 请求过频", action: "deny" },
270
- `【提示】5 分钟内已有一次被明确拒绝的 ask(规则 22 沟通直接性)。再次弹窗可能无法送达;用普通文本说明即可。`
271
- );
272
- }
273
- }
274
- }
275
-
276
- // 内部自护:插件配置/理解产物/规则文件禁止模型直写(/guard unlock 可临时放行)
277
- if (!unlock && (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p)) {
278
- return makeHit(
279
- { ruleId: "__self-protect", title: "规则引擎配置只读(需 /guard unlock)", action: "deny" },
280
- `【硬拦截】${p} 受规则引擎保护:需要用户输入 /guard unlock 放行(解锁范围含 rule-engine.json / rule-understanding.json / AGENTS.md,默认 10 分钟)。请停止并让用户在对话框输入 /guard unlock。`
281
- );
282
- }
283
-
284
- // 阶段 C:硬拦 pwsh/bash 绕过统一入口直接写受保护文件(规则 19⑧/21⑨ 的机器执行层)
285
- // 合法通道 = 整条命令仅调用 dsh-manual-write.mjs(无 ; | & 链式/换行,防注释文本伪造放行)
286
- // 0.5.10:写类判定改 isMutationCommand(单真源)——修复 `Write-Output` 被 `write` 子串误杀(WGO654/ES3VCD 同类)
287
- if (!unlock && (name === "pwsh" || name === "bash")) {
288
- const cmd = commandText(args) || "";
289
- if (cmd && isMutationCommand(cmd) && PROTECTED_FILENAME_RE.test(cmd)) {
290
- if (!isEntryChannelCommand(cmd)) {
291
- return makeHit(
292
- { ruleId: "__self-protect", title: "受保护文件禁止绕过统一入口直写", action: "deny" },
293
- `【硬拦截】受保护文件禁止通过 pwsh/bash 绕过统一入口直写;请使用 scripts/dsh-manual-write.mjs(整个命令只能调用该脚本,不得链式拼接其他写命令;或 /guard unlock 临时放行)。`
294
- );
295
- }
296
- }
297
- }
298
-
299
- // 写前版本校验(建议③):版本化文件(SKILL.md/AGENTS.md/CHANGELOG/README 等)在写入前
300
- // old/new 模拟结果做校验,不合规直接拒绝——避免"先写后回滚"的副作用与假成功
301
- // 位置在自护之后:受保护文件需先 unlock(用户明确授权)再接受版本校验
302
- if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && p && isVersionedFile(p)) {
303
- try {
304
- const current = readFileSync(p, "utf8");
305
- const simulated = resultingFileContent(name, args) ?? current;
306
- // 0.5.11(用户定稿):单行整句重写放行需 old 唯一匹配——工具层已拒多处匹配;
307
- // 唯一时传给 validateEditedFile/append 作为放行前提(唯一 = 替换位置正确 = 语义编辑非覆盖)。
308
- const oldStr = args?.old_string ?? args?.old_str ?? "";
309
- const uniqueMatch = oldStr.length > 0 ? countOccurrences(current, oldStr) === 1 : false;
310
- const check = validateEditedFile(current, simulated, oldStr, args?.new_string ?? args?.new_str ?? "", uniqueMatch);
311
- if (!check.ok) {
312
- return makeHit(
313
- { ruleId: "__version-guard", title: "版本守卫:写入前校验", action: "deny" },
314
- `【硬拦截】${p} 是版本化文件,本次编辑未通过版本守卫(写前校验):${check.errors.join(";")}。请修正 old_string/new_string(保留原文逐行或按行包含关系)后重试`
315
- );
316
- }
317
- } catch {
318
- // 文件不可读等异常不阻断(交给写后自检兜底)
319
- }
320
- }
321
-
322
- for (const cfg of state.configs) {
323
- if (cfg.disabled) continue;
324
- // 低置信规则不硬拦(保守不误拦,交给 /guard rules 人工复核)——批次 3:跳过必须留痕(N2 防御可观测)
325
- if (cfg.confidence === "low") {
326
- opts?.audit?.({
327
- kind: "n2-skip",
328
- rule: cfg.ruleId,
329
- name: "低置信规则跳过",
330
- event: "tool/guard",
331
- reason: `规则 ${cfg.ruleId} 理解低置信 → 跳过硬拦(保守不误拦;请在 /guard rules 对该规则复核)`,
332
- session: sessionIdOf(exec)
333
- });
334
- continue;
335
- }
336
- // 分级执行:只有 A 硬拦 / C 时序 / M 元规则才进入工具守卫
337
- const actions = cfg.actions || [];
338
- if (!actions.some((a) => a === "deny" || a === "ask" || a === "meta")) continue;
339
- const hit = matchRule(cfg, { name, args, p, cmd, session, unlock, state, now, audit: opts?.audit, exec });
340
- if (hit) return hit;
341
- }
342
- return null;
343
- }
344
-
345
- function matchRule(cfg, ctx) {
346
- const { name, args, p, cmd, session, unlock, state, now, audit, exec } = ctx;
347
- const id = String(cfg.ruleId);
348
- // 防热重载/事件顺序导致意图状态落后:若当前 turn.userText 与已缓存 intents.raw 不一致,
349
- // 或尚无 intents 但有用户文本,则以最新用户文本重新解析并回写,避免用旧状态误拦。
350
- // P0-3 根修(2026-08-24):裁决基底只用“本回合”的真实用户文本(turn.userText),
351
- // 不再回退 lastUserText——上一轮残留文本(如含“为什么”的询问)不得裁决本轮。
352
- const currentText = session.turn.userText || "";
353
- let turnIntents = session.turn.intents;
354
- if (turnIntents && currentText && turnIntents.raw !== currentText) {
355
- turnIntents = parseUserIntents(currentText);
356
- session.turn.intents = turnIntents;
357
- } else if (!turnIntents && currentText) {
358
- turnIntents = parseUserIntents(currentText);
359
- session.turn.intents = turnIntents;
360
- }
361
- // 无用户消息回合(委派/ask 答复后/状态信号)不做规则 22 判定;敏感操作由 12A/13A 独立把关
362
- const denyMutation = turnIntents
363
- ? shouldDenyMutation(turnIntents, session.turn.askSeen && !session.turn.askRejected)
364
- : false;
365
-
366
- // 规则 22 机器化:无执行分点/存在歧义 本回合禁止变更类工具调用
367
- //("你的执行方案难道没问题吗?"虽含"执行"仍是疑问句——同形词不构成指令,不豁免)
368
- if (cfg.handler === "rule22-7-direct") {
369
- // B2(2026-08-28 阶段二):委派/子代理回合不做"无执行分点"判定(规则 22④ 的识别层落地)——
370
- // 子代理任务书以 source.kind="user" 注入,会被当用户消息判"无执行分点"而误拦(ERR-L8QAXS 实弹)。
371
- if (isDelegatedSession(exec)) {
372
- audit?.({ kind: "delegated-skip", rule: cfg.ruleId, name: "委派回合豁免规则22", event: "tool/guard", reason: `委派会话(meta 判定)跳过"无执行分点",敏感操作仍由 12A/13A 把关`, session: sessionIdOf(exec) });
373
- return null;
374
- }
375
- // 机制 B(2026-08-24):工具分类制(纯函数,覆盖现有与未来插件注册的全部工具)——
376
- // analysis(只读分析放行)/ artifact(产物放行+审计)/ unknown(物理不拦,由 pre-execute
377
- // ask/deny 层首调处置,防新插件绕过);mutating 维持原有严格逻辑。0.5.9 单真源:唯一分类表。
378
- const cls = toolClass(name, args);
379
- // 0.5.10 分析通道(用户多次提出:只读分析需要写临时脚本/输出——解压副本/聚合日志等):
380
- // 严格只读 ∪ 分析临时区写 ∪ 分析脚本区调用 → 任何回合放行 + 审计留痕。
381
- // 红线:工作区外路径命中临时区段(如 C:\xxx\logs\)不豁免——落入常规变更判定。
382
- if (isAnalysisOp(name, args)) {
383
- const scratch = extractAnalysisScratchPaths(commandText(args) || "");
384
- const outside = scratch.filter((p) => isOutsideWorkspace(p, sessionIdOf(exec)));
385
- if (outside.length === 0) {
386
- audit?.({ kind: "analysis-scratch", rule: cfg.ruleId, name: "分析通道放行", tool: name, reason: `分析类操作(只读/临时区/分析脚本区)放行:${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
387
- return null;
388
- }
389
- }
390
- if (cls === "analysis") return null;
391
- if (cls === "artifact") {
392
- audit?.({ kind: "allow", rule: cfg.ruleId, name: "产物类工具放行", tool: name, reason: `分类 artifact(低风险产物写入,留痕可对账):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
393
- return null;
394
- }
395
- if (cls === "unknown") return null; // 物理不拦;pre-execute ask 层首调询问
396
- if (denyMutation) {
397
- // A 方案(2026-08-28 用户拍板):方案/调研回合(无执行分点)写工作区正式路径 = 落盘/产出动作
398
- // + "确认后落盘"文案(不静默放行——原 isLowRiskWorkspaceNew 在此静默放行,属越权)。
399
- // 方案性指令不构成落盘授权(规则 22 自证③);分析通道/临时区在 isAnalysisOp 已放行(工具不拦);
400
- // _inbox 按分类文本定位=暂存待归位的产物,同正式路径(拦);工作区外由下方"询问"分支统一兜底。
401
- if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && !isAnalysisOp(name, args) && !isOutsideWorkspace(p, sessionIdOf(exec))) {
402
- return makeHit(cfg, `【硬拦截】本回合是方案/调研指令,写工作区文件属于"落盘/产出"动作——方案性指令不构成落盘授权(规则 22 自证③)。落盘需你明确确认:请回复"落盘""确认后保存"(或对产出位置确认)`);
403
- }
404
- // LLM 意图兜底(方案 A,2026-08-24):词表判拦 + LLM 高/低置信判 execute → 放行
405
- //(非对称:LLM 只解救不收紧;审计在预取时已记 intent-llm)
406
- const verdict = verdictForDeny(session.turn, state.llmIntentCfg || {});
407
- if (!verdict.mutationDenied) return null;
408
- if (isReadOnlyTool(name, args)) return null; // 只读 → 豁免
409
- const llmNote = verdict.source === "llm-low" ? "(LLM 低置信未能挽救)" : "";
410
- return makeHit(cfg, `【硬拦截】用户消息是询问/没有明确执行分点(规则 22)${llmNote}:请先回答/展示方案,待用户明确授权后再执行(放行:存在执行分点,或 ask_user_question 授权答复)`);
411
- }
412
- // 规则 22 粒度升级(2026-08-24,用户点名治本项):
413
- // 存在执行分点时不再"同回合一词放行",而是逐项比对本次变更是否落在
414
- // 本回合 execute 子句或 ask 授权的「工具类别 + 路径前缀」范围内;未覆盖 → 拦。
415
- // 无用户消息/状态信号/ask 答复后回合仍不做规则 22 判定(由 12A/13A 把关)。
416
- if (!turnIntents || !turnIntents.hasExecute) return null;
417
- if (isReadOnlyTool(name, args)) return null;
418
- if (cls === "mutating" || looksLikeFileMutation(name, args)) {} else return null;
419
- const op = operationOf(name, args);
420
- // v0.5.7 P0-1(用户拍板 2026-08-26):验证类命令伴生放行——本回合已有 write/any/command
421
- // 授权(用户已批"修改/执行")时,运行测试/冷加载/审计/语法检查属于该变更的验证闭环,
422
- // 不再被 write vs command 类型不匹配误拦(今天实弹连卡 4 次)。
423
- if ((name === "pwsh" || name === "bash") && isVerificationCommand(cmd)) {
424
- const verScopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
425
- ? session.turn.scopes
426
- : scopesFromIntents(turnIntents);
427
- if (verScopes.some((s) => s.type === "write" || s.type === "any" || s.type === "command")) {
428
- audit?.({ kind: "allow", rule: cfg.ruleId, name: "验证命令伴生放行", tool: name, reason: `本回合已有 ${describeScopes(verScopes)} 授权 → 验证命令伴生(${describeOp(op)})`, session: sessionIdOf(exec) });
429
- return null;
430
- }
431
- }
432
- // 保护性备份豁免(2026-08-26,backup 口径修复):规则 12A 豁免③ / 13A ⑦ 均豁免
433
- // 复制到 .backups/.bak 的保护性备份——与 13A 分支口径一致,backup 类型不再被 22 粒度误拦。
434
- if (op.type === "backup") return null;
435
- // 本回合 scopes(execute 子句 + 本回合 ask 授权已由 handleSessionEvent 写入 turn.scopes
436
- // C1(2026-08-28 阶段一):并入 session.authorizations 中【带 TTL 且未过期】的授权——规则 22
437
- // 粒度与 12A/13A 同口径(findMatchingAuth 均考虑 TTL)。修复"12D 已记录授权、下一回合操作
438
- // 仍被 22 粒度拦"的授权分裂(2026-08-28 实弹:ask 授权后读取仍被拒)。
439
- // 安全边界:只并入显式 TTL 授权(=同任务窗口);无 expiresAt 的长期授权不放宽本轮粒度
440
- // (2026-08-24 旧语义"历史授权不得放宽本轮"对永久授权仍成立——防上轮授权越权本轮跨操作)。
441
- const turnScopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
442
- ? session.turn.scopes
443
- : scopesFromIntents(turnIntents);
444
- const ttlAuths = Array.isArray(session.authorizations)
445
- ? session.authorizations.filter((a) => a && typeof a.expiresAt === "number" && a.expiresAt > now)
446
- : [];
447
- const scopes = [...turnScopes, ...ttlAuths];
448
- if (!scopes.some((scope) => authMatches(scope, op))) {
449
- return makeHit(cfg, `【硬拦截】本次变更操作不在本回合执行分点/授权范围内(规则 22):已授权范围 [${describeScopes(scopes)}];本次操作 [${describeOp(op)}]。请补充明确授权(可用 ask_user_question)后再执行`);
450
- }
451
- // 可观测性(阶段 0,2026-08-24):规则 22 粒度放行是最高频放行路径,必须留痕可查
452
- audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(规则 22 粒度命中):${describeScopes(scopes)} 覆盖 ${describeOp(op)}`, session: sessionIdOf(exec) });
453
- return null;
454
- }
455
-
456
- // 规则 1:同工具同参数连续失败 ≥2 次后拦第 3 次(失败计数由 tool/result 更新)
457
- if (cfg.handler === "rule1-retry") {
458
- const key = `${name}:${JSON.stringify(args || {})}`;
459
- const count = state.retryCounts.get(key) || 0;
460
- const userText = session.turn.userText || session.lastUserText || "";
461
- if (count >= 2 && /(?:重试|再试一次|再来一次|继续试|再试)/.test(userText)) {
462
- return null; // 用户明确要求重试 豁免
463
- }
464
- if (count >= 2) {
465
- return makeHit(cfg, `【硬拦截】同一工具调用已连续失败 ${count} 次,按规则 1 禁止第 ${count + 1} 次重试`);
466
- }
467
- return null;
468
- }
469
-
470
- // 规则 9:内联命令 / BOM 写配置(PS7 语义:仅拦显式 utf8BOM)
471
- if (cfg.handler === "rule9-inline-bom") {
472
- if ((name === "pwsh" || name === "bash") && cmd) {
473
- if (INLINE_CMD.test(cmd)) {
474
- return makeHit(cfg, "【硬拦截】禁止内联命令(node -e / pwsh -c / node -p 等),请先写脚本文件再执行");
475
- }
476
- if (BOM_WRITE.test(cmd)) {
477
- return makeHit(cfg, "【硬拦截】禁止用 Set-Content/Out-File -Encoding utf8BOM 写 .json/.yaml(PS7 显式带 BOM)");
478
- }
479
- }
480
- return null;
481
- }
482
-
483
- // 规则 18:DSH 任务首次工具调用前必须已读手册
484
- if (cfg.handler === "rule18-manual-first") {
485
- const userText = session.turn.userText || session.lastUserText || "";
486
- const firstTool = session.turn.toolCount === 0;
487
- if (firstTool && !session.manualReadSeen && DSH_KEYWORDS_RE.test(userText) && !isManualReadTool(name, args)) {
488
- return makeHit(cfg, "【硬拦截】任务涉及 DSH,首次工具调用前需先 grep/read 手册(~/.dsh/skills/dsh-usage-manual/SKILL.md)");
489
- }
490
- return null;
491
- }
492
-
493
- // 规则 13A:删除/覆盖/高风险写前需有“目标路径对应备份”证据
494
- if (cfg.handler === "rule13a-backup") {
495
- // 统一入口命令豁免:dsh-manual-write.mjs 每次写入前自身执行备份(backup() 保留 5 份),
496
- // 引擎静态扫描看不到脚本内部动作(已知盲区);入口命令也已被 __self-protect 限定为唯一写通道。
497
- if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd)) return null;
498
- const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args, sessionIdOf(exec)) && !/git\s+(push|commit)/i.test(cmd)));
499
- const highRiskWrite = (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p);
500
- if (highRiskWrite && unlock && !isHighRiskEntryFile(p)) return null;
501
- if (destructive || highRiskWrite) {
502
- const op = operationOf(name, args);
503
- // 备份动作本身(复制到 .bak/.backups/trash-)不需要再“先备份”
504
- if (op.type === "backup") return null;
505
- const targetPath = highRiskWrite ? p : op.pathPrefix;
506
- // shell 变量($var / %var%)的路径无法可靠解析 → 跳过机械备份检查(P0-2,防变量路径误拦)
507
- if (targetPath && isVariablePath(targetPath)) return null;
508
- // 已获 12A/12D 授权的操作 = 用户已明确确认本次操作 → 跳过 13A 机械备份(P1-2,一次授权覆盖全规则)
509
- // 但高风险运行入口文件除外:即使已授权也必须有备份证据或明确提示
510
- const auth13 = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
511
- if (auth13 && !isHighRiskEntryFile(targetPath)) {
512
- audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(13A 跳过备份):${describeAuth(auth13)}`, session: sessionIdOf(exec) });
513
- return null;
514
- }
515
- // 复制/新建到“尚不存在”的目标文件:属于创建新文件,不适用 13A 覆盖备份要求
516
- const isCreateNewTarget = !highRiskWrite && cmd && /copy-item|new-item/i.test(cmd) && targetPath && !existsSync(targetPath);
517
- if (!isCreateNewTarget) {
518
- const backup = findBackupForPath(state, session.id, targetPath);
519
- if (!backup) {
520
- const existing = session.backups.map((b) => `${b.targetPath} -> ${b.backupPath}`).join(";") || "无";
521
- const highRiskNote = isHighRiskEntryFile(targetPath) ? "(该文件不在自动备份范围,请先手动备份)" : "";
522
- return makeHit(cfg, `【硬拦截】目标路径缺少对应备份(规则 13A)${highRiskNote}:已有备份 [${existing}];本次目标 [${targetPath}]`);
523
- }
524
- if (!existsSync(backup.backupPath)) {
525
- return makeHit(cfg, `【硬拦截】备份记录存在但备份文件不存在(规则 13A):${backup.backupPath}`);
526
- }
527
- }
528
- }
529
- return null;
530
- }
531
-
532
- // 规则 12B:技能调用四步时序(关键词→授权→调用;豁免技能除外)
533
- // 2026-08-24 修复:旧条件 `handler==='rule12b-skill' || hints.includes('skill')` 会把
534
- // hints "skill" 的其它规则 cfg(如 12A:hints=["ask","skill","sensitive",...])截胡——
535
- // skill 工具时 return null,导致 12A 敏感授权检查静默失效(C-2 实测 Move-Item 放行)。
536
- // 现在:hints 兜底仅在「name === 'skill'」时进入;handler 明确为 rule12b-skill 时维持原语义。
537
- if (cfg.handler === "rule12b-skill") {
538
- if (name === "skill") {
539
- const skillName = typeof args?.name === "string" ? args.name : "";
540
- if (SKILL_EXEMPT.has(skillName)) return null;
541
- // 技能目录实时联动:已加载目录且该技能不存在/被禁用时,规则不激活
542
- if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
543
- if (denyMutation) {
544
- return makeHit(cfg, `【硬拦截】当前用户消息是询问/没有明确执行分点,技能 ${skillName} 未获授权`);
545
- }
546
- const op = { type: "skill", pathPrefix: "" };
547
- const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
548
- if (!auth) {
549
- const existing = session.authorizations.map(describeAuth).join(";") || "无";
550
- return makeHit(cfg, `【硬拦截】技能调用缺少匹配授权:${skillName}(已有授权:${existing};本次范围:${describeOp(op)})`);
551
- }
552
- audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(技能 ${skillName}):${describeAuth(auth)}`, session: sessionIdOf(exec) });
553
- return null;
554
- }
555
- return null;
556
- }
557
- if ((cfg.hints || []).includes("skill") && name === "skill") {
558
- // hints 兜底:理解器未分配 handler 但 hints 含 skill 的 cfg——仅 skill 工具时参与,不截胡其它规则
559
- const skillName = typeof args?.name === "string" ? args.name : "";
560
- if (SKILL_EXEMPT.has(skillName)) return null;
561
- if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
562
- const op = { type: "skill", pathPrefix: "" };
563
- const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
564
- if (!auth) {
565
- const existing = session.authorizations.map(describeAuth).join(";") || "无";
566
- return makeHit(cfg, `【硬拦截】技能调用缺少匹配授权:${skillName}(已有授权:${existing};本次范围:${describeOp(op)})`);
567
- }
568
- return null;
569
- }
570
-
571
- // 规则 12A:敏感操作需要匹配授权证据
572
- if (cfg.handler === "rule12a-approval") {
573
- if (isSensitiveToolCall(name, args, sessionIdOf(exec))) {
574
- // 0.5.11(用户定稿):12A 22-7 判据同源——分析通道/低风险新建豁免在两条分支
575
- // 使用同一组判据(isAnalysisOp/isLowRiskWorkspaceNew),不再各写一套(此前 22-7 放行、
576
- // 12A 仍要求授权的不一致)。红线不变:工作区外(isOutsideWorkspace)不豁免。
577
- if (isAnalysisOp(name, args)) {
578
- const scratch = extractAnalysisScratchPaths(commandText(args) || "");
579
- const outside = scratch.filter((p) => isOutsideWorkspace(p, sessionIdOf(exec)));
580
- if (outside.length === 0) {
581
- audit?.({ kind: "analysis-scratch", rule: cfg.ruleId, name: "分析通道放行", tool: name, reason: `分析类操作放行(12A 判据同源):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
582
- return null;
583
- }
584
- }
585
- if (isLowRiskWorkspaceNew(name, args)) {
586
- audit?.({ kind: "allow", rule: cfg.ruleId, name: "低风险新建豁免(12A 判据同源)", tool: name, reason: `工作区内低风险新建(12A 判据同源):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
587
- return null;
588
- }
589
- // 规则 19:dsh-usage-manual/SKILL.md 正文更新免逐次确认(仅手册本身)
590
- if (p && MANUAL_PATH_RE.test(p)) return null;
591
- // /guard unlock 本身即用户对受保护配置的授权
592
- if (unlock && isProtectedConfigPath(p)) return null;
593
- const op = operationOf(name, args);
594
- // 规则 12A 正文豁免:保护性备份(创建/复制/移动文件到 .backups/ 或 .backups/trash-<时间戳>/)免询问
595
- if (op.type === "backup") return null;
596
- if (denyMutation) {
597
- return makeHit(cfg, `【硬拦截】当前用户消息是询问/没有明确执行分点,未构成授权证据(本次操作:${describeOp(op)})`);
598
- }
599
- const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
600
- if (!auth) {
601
- const existing = session.authorizations.map(describeAuth).join(";") || "无";
602
- return makeHit(cfg, `【硬拦截】敏感操作缺少匹配授权:已有授权范围 [${existing}];本次操作范围 [${describeOp(op)}]`);
603
- }
604
- audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(12A 敏感操作):${describeAuth(auth)}`, session: sessionIdOf(exec) });
605
- return null;
606
- }
607
- return null;
608
- }
609
-
610
- // 规则 21:规则/配置文件变更需 unlock(元规则)
611
- if (cfg.handler === "rule21-meta") {
612
- if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p) && !unlock) {
613
- return makeHit(cfg, "【硬拦截】规则/配置文件受保护:修改需用户先执行 /guard unlock");
614
- }
615
- return null;
616
- }
617
-
618
- // 规则 24:插件装配类型确认(A 硬拦)+ 变更类工具统一覆盖(原规则 25 语义,检查④)
619
- if (cfg.handler === "rule24-assembly-type") {
620
- if (name === "dev_install_package") {
621
- const dir = args?.dir;
622
- if (dir) {
623
- try {
624
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
625
- if (!pkg?.dsh?.bundle) {
626
- return makeHit(cfg, `【硬拦截】插件 ${dir} 未声明 dsh.bundle,不能加入 dsh.profile.bundles(规则 24)`);
627
- }
628
- } catch {
629
- return makeHit(cfg, `【硬拦截】无法读取插件 package.json:${dir}(规则 24)`);
630
- }
631
- }
632
- }
633
- // 手工编辑 profile package.json 的 dsh.profile.bundles 时同样做类型检查
634
- const badBundles = nonBundleInProfileBundles(name, args);
635
- if (badBundles && badBundles.length) {
636
- return makeHit(cfg, `【硬拦截】${p} 的 dsh.profile.bundles 包含非 bundle/无法确认类型:${badBundles.join(";")}(规则 24)`);
637
- }
638
- // 规则 24④:所有能产生文件写入/删除/移动效果的工具都必须纳入统一守卫——
639
- // 0.5.9 单真源:已分类(tool-catalog 唯一表)= 已纳入守卫,放行;未分类且疑似变更 = 运行时拒绝
640
- // (防"改个新工具就绕过守卫";与工具覆盖门禁 K-01/K-06 互补)
641
- if (isReadOnlyTool(name, args)) return null;
642
- const cls24 = toolClass(name, args);
643
- if (cls24 !== "unknown") return null;
644
- if (looksLikeFileMutation(name, args)) {
645
- return makeHit(cfg, `【硬拦截】未覆盖的变更类工具 ${name},违反规则 24④:请先纳入统一守卫覆盖`);
646
- }
647
- return null;
648
- }
649
-
650
- // 规则 27:装配变更后必须先通过全量审计,才能继续装配(C 时序;全局变更 + 本会话审计证据)
651
- if (cfg.handler === "rule27-mount-audit") {
652
- if (isAssemblyMutationTool(name, args)) {
653
- const currentSig = computeMountSignature(profileNameFromArgs(args));
654
- state.mountSignature = currentSig;
655
- const auditedSig = session.mountAuditSignature || "";
656
- const needsAudit = auditedSig
657
- ? currentSig !== auditedSig
658
- : (state.mountRevision > (session.mountAuditRevision || 0));
659
- if (needsAudit) {
660
- const why = auditedSig
661
- ? `装配内容已变化(装配状态哈希 ${currentSig.slice(0, 8)} ≠ 审计通过时 ${auditedSig.slice(0, 8)})`
662
- : `插件装配已变更(mountRevision=${state.mountRevision})且本会话未通过全量审计`;
663
- return makeHit(cfg, `【硬拦截】${why},请先运行 node scripts/audit-mount-consistency.mjs --profile <p> 并通过后再继续装配`);
664
- }
665
- }
666
- return null;
667
- }
668
-
669
- // 兜底:从理解产物里的 hints 泛化匹配
670
- const hints = cfg.hints || [];
671
- if (hints.includes("inline-command") && (name === "pwsh" || name === "bash") && cmd && INLINE_CMD.test(cmd)) {
672
- return makeHit(cfg, `【硬拦截】${cfg.title}`);
673
- }
674
- if (hints.includes("bom-write") && (name === "pwsh" || name === "bash") && cmd && BOM_WRITE.test(cmd)) {
675
- return makeHit(cfg, `【硬拦截】${cfg.title}`);
676
- }
677
- if (hints.includes("manual") && session.turn.toolCount === 0 && !session.manualReadSeen && !isManualReadTool(name, args)) {
678
- return makeHit(cfg, `【硬拦截】${cfg.title}`);
679
- }
680
- if (hints.includes("sensitive") && isSensitiveToolCall(name, args, sessionIdOf(exec))) {
681
- if (p && MANUAL_PATH_RE.test(p)) return null;
682
- if (unlock && isProtectedConfigPath(p)) return null;
683
- const op = operationOf(name, args);
684
- if (denyMutation) {
685
- return makeHit(cfg, `【硬拦截】当前用户消息是询问/没有明确执行分点(本次操作:${describeOp(op)})`);
686
- }
687
- const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
688
- if (!auth) {
689
- const existing = session.authorizations.map(describeAuth).join(";") || "无";
690
- return makeHit(cfg, `【硬拦截】${cfg.title}:缺少匹配授权(已有:${existing};本次:${describeOp(op)})`);
691
- }
692
- audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(${cfg.title}):${describeAuth(auth)}`, session: sessionIdOf(exec) });
693
- }
694
- return null;
695
- }
696
-
697
- /** 供测试/调试:手动更新备份状态(并创建真实备份文件以满足存在性校验) */
698
- export function markBackupSeen(state, sessionId, targetPath) {
699
- const s = getSessionState(state, sessionId);
700
- s.turn.backupSeen = true;
701
- if (targetPath) {
702
- const dir = mkdtempSync(join(tmpdir(), "dsh-rule-engine-bak-"));
703
- const backupPath = join(dir, "backup.bak");
704
- writeFileSync(backupPath, "backup", "utf8");
705
- const norm = (p) => String(p).replace(/\\/g, "/").toLowerCase();
706
- s.backups.push({
707
- targetPath: norm(targetPath),
708
- backupPath,
709
- at: Date.now()
710
- });
711
- }
712
- }
713
-
714
- export function markAskSeen(state, sessionId) {
715
- const s = getSessionState(state, sessionId);
716
- s.turn.askSeen = true;
717
- s.authorizations.push({ at: Date.now(), type: "any", pathPrefix: "", source: "test" });
718
- }
719
-
720
- export function markManualRead(state, sessionId) {
721
- getSessionState(state, sessionId).manualReadSeen = true;
722
- }
723
-
724
- export { isBackupTool, isManualReadTool };
1
+ // guard-core.js - 工具守卫裁决(纯函数,可独立测试)。
2
+ // 被 index.js 的 ctx.tools.guard() 调用;返回 reason 即物理拒绝。
3
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import {
7
+ BOM_WRITE,
8
+ DESTRUCTIVE_CMD,
9
+ DSH_KEYWORDS_RE,
10
+ INLINE_CMD,
11
+ MANUAL_PATH_RE,
12
+ PROTECTED_FILENAME_RE,
13
+ SKILL_EXEMPT,
14
+ commandText,
15
+ isAssemblyMutationTool,
16
+ isBackupTool,
17
+ isHighRiskEntryFile,
18
+ isManualReadTool,
19
+ isMutationCommand,
20
+ isOutsideWorkspace,
21
+ isProtectedConfigPath,
22
+ isReadOnlyTool,
23
+ isSensitiveToolCall,
24
+ isVariablePath,
25
+ isVerificationCommand,
26
+ isLowRiskWorkspaceNew,
27
+ isAnalysisOp,
28
+ isAnalysisScratchPath,
29
+ extractAnalysisScratchPaths,
30
+ pathTarget
31
+ } from "./patterns.js";
32
+ import { authMatches, describeAuth, describeOp, describeScopes, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText, scopesFromIntents } from "./authorization.js";
33
+ import { parseUserIntents, shouldDenyMutation } from "./intent.js";
34
+ import { toolClass } from "./tool-catalog.js";
35
+ import { verdictForDeny } from "./llm-intent.js";
36
+ import { computeMountSignature, profileNameFromArgs } from "./mount-signature.js";
37
+ import { findBackupForPath, getSessionState, maybeReloadIfChanged } from "./state.js";
38
+ import { isVersionedFile, validateEditedFile } from "./version-guard.js";
39
+ import { decideContractAction, defaultContract, isArmed } from "./contract.js";
40
+ import { classifyAction } from "./overengineering.js";
41
+ import { labelFingerprint, labelsAllowFingerprint } from "./label-fingerprint.js";
42
+
43
+ function sessionIdOf(exec) {
44
+ const agent = exec?.agent;
45
+ if (!agent) return "global";
46
+ if (typeof agent.session === "object" && agent.session?.id) return agent.session.id;
47
+ if (typeof agent.session === "string") return agent.session;
48
+ return "global";
49
+ }
50
+
51
+ /**
52
+ * B2(2026-08-28 阶段二):委派/子代理会话判定(规则 22④"无用户消息回合(委派…)"的识别层)。
53
+ * 官方子代理(dsh-subagent-in-process-driver)把任务 prompt 以 source.kind="user" 注入子会话,
54
+ * 引擎按真实用户消息处理 子代理的"任务书"被判"无执行分点"→ 读文件等操作被 ERR-L8QAXS
55
+ * (2026-08-28 实弹)。规则 22④ 承诺委派回合不做此判定;判定依据 = 会话元数据
56
+ * (agent.session.meta.delegationDepth>0 / origin==="subagent" / 存在 parentSession)。
57
+ */
58
+ function isDelegatedSession(exec) {
59
+ const agent = exec?.agent;
60
+ const sess = agent?.session;
61
+ if (!sess || typeof sess !== "object") return false;
62
+ const meta = sess.meta;
63
+ if (meta && typeof meta === "object") {
64
+ if (Number(meta.delegationDepth) > 0) return true;
65
+ if (meta.origin === "subagent") return true;
66
+ if (typeof meta.parentSession === "string") return true;
67
+ }
68
+ // 兜底:agent 层(部分驱动结构 meta agent 侧)
69
+ if (Number(agent?.meta?.delegationDepth) > 0 || agent?.meta?.origin === "subagent") return true;
70
+ return false;
71
+ }
72
+
73
+ const RULE_HINTS = {
74
+ "1": "先分析根因,确认问题后再继续",
75
+ "9": "改用脚本文件或显式 UTF-8 BOM 流程",
76
+ "12A": " ask_user_question 获取匹配授权",
77
+ "13A": "先对目标路径执行备份(复制到 .bak/.backups/trash-)",
78
+ "18": "先读取 ~/.dsh/skills/dsh-usage-manual/SKILL.md",
79
+ "21": "按规则 21 分级确认后再落盘",
80
+ "22": "先回答/展示方案,或补充明确执行分点(工具类别+路径范围);已授权变更必须落在本回合执行分点/ask 授权范围内",
81
+ "24": "确认插件 dsh.bundle 类型或改用正确挂载",
82
+ "27": "先运行 node scripts/audit-mount-consistency.mjs --profile web"
83
+ };
84
+
85
+ // 0.5.12(F3):拒绝来源前缀——机器可解析 token 化(/guard log 可按来源 grep 过滤)
86
+ // 约定:[guardian:rule22](规则层)/ [guardian:contract](任务契约/机制类)/ [guardian:guard](守卫层)
87
+ // 官方 approval 层在引擎外,无法加前缀(F3 边界:只做自己三层 + 文档化官方行为)
88
+ export function prefixSource(ruleId) {
89
+ if (!ruleId) return "[guardian:guard]";
90
+ if (String(ruleId).startsWith("__")) return "[guardian:contract]";
91
+ return `[guardian:rule${ruleId}]`;
92
+ }
93
+
94
+ function makeHit(cfg, reason) {
95
+ const errId = Math.random().toString(36).slice(2, 8).toUpperCase();
96
+ const hint = RULE_HINTS[String(cfg.ruleId)] || "见 /guard rules";
97
+ return {
98
+ ruleId: cfg.ruleId,
99
+ title: cfg.title,
100
+ action: "deny",
101
+ errId,
102
+ reason: `${prefixSource(cfg.ruleId)} ${reason}(规则 ${cfg.ruleId}|放行:${hint}|ERR-${errId}|误判可打标:/guard label ERR-${errId} incorrect)`
103
+ };
104
+ }
105
+
106
+ // 0.5.9(单真源):COVERED_MUTATION_TOOLS / SAFE_UNCOVERED_TOOLS 独立覆盖表已废弃——
107
+ // 工具分类以 lib/core/tool-catalog.js 的唯一分类表为准(24④/22/unknown 全部读它),
108
+ // 双表漂移即 run_code 事故根因(K-01/K-02/K-06 依据)。
109
+
110
+ function looksLikeFileMutation(name, args) {
111
+ const a = args || {};
112
+ return Boolean(a.file_path || a.path || a.command || a.code || a.execute || a.script || a.fn);
113
+ }
114
+
115
+ /** 统计 substring 在 text 中出现的次数(0.5.11 唯一性前提辅助) */
116
+ function countOccurrences(text, sub) {
117
+ if (typeof text !== "string" || typeof sub !== "string" || sub.length === 0) return 0;
118
+ let count = 0;
119
+ let idx = text.indexOf(sub);
120
+ while (idx !== -1) {
121
+ count++;
122
+ idx = text.indexOf(sub, idx + sub.length);
123
+ }
124
+ return count;
125
+ }
126
+
127
+ export function isProfilePackageJson(p) {
128
+ return typeof p === "string" && /profiles[\\/][^\\/]+[\\/]package\.json$/i.test(p);
129
+ }
130
+
131
+ /** 判定命令是否"整条命令仅调用统一入口 dsh-manual-write.mjs"(无 ; | & 链式/换行) */
132
+ function isEntryChannelCommand(cmd) {
133
+ return typeof cmd === "string" && /^[^\n;|&]*dsh-manual-write\.mjs[^\n;|&]*$/i.test(cmd);
134
+ }
135
+
136
+ /** 计算 edit/write/str_replace 后的目标文件内容;无法可靠计算时返回 null */
137
+ function resultingFileContent(name, args) {
138
+ const p = pathTarget(args);
139
+ if (!p) return null;
140
+ if (name === "write") return typeof args?.content === "string" ? args.content : null;
141
+ if (!existsSync(p)) return null;
142
+ let current;
143
+ try { current = readFileSync(p, "utf8"); } catch { return null; }
144
+ if (name === "edit") {
145
+ const oldS = args?.old_string;
146
+ const newS = args?.new_string;
147
+ if (typeof oldS === "string" && typeof newS === "string" && current.includes(oldS)) return current.replace(oldS, newS);
148
+ return null;
149
+ }
150
+ if (name === "str_replace_editor" && args?.command === "str_replace") {
151
+ const oldS = args?.old_str;
152
+ const newS = args?.new_str;
153
+ if (typeof oldS === "string" && typeof newS === "string" && current.includes(oldS)) return current.replace(oldS, newS);
154
+ return null;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** profile 目录解析 bundle 的 package.json(兼容 profiles/web/node_modules 与 profiles/node_modules) */
160
+ function resolveBundlePkgPath(bundleName, profilePkgPath) {
161
+ const profileDir = dirname(profilePkgPath);
162
+ const profilesNodeModules = join(dirname(profileDir), "node_modules");
163
+ const candidates = [];
164
+ if (bundleName.startsWith("@")) {
165
+ const [scope, name] = bundleName.split("/");
166
+ candidates.push(join(profileDir, "node_modules", scope, name, "package.json"));
167
+ candidates.push(join(profilesNodeModules, scope, name, "package.json"));
168
+ } else {
169
+ candidates.push(join(profileDir, "node_modules", bundleName, "package.json"));
170
+ candidates.push(join(profilesNodeModules, bundleName, "package.json"));
171
+ }
172
+ for (const c of candidates) if (existsSync(c)) return c;
173
+ return null;
174
+ }
175
+
176
+ /** profile package.json dependencies 中解析本地 link/file 依赖的包路径;无法解析返回 null */
177
+ function resolveLocalDependencyPkgPath(bundleName, profilePkgPath, parsed) {
178
+ const dep = parsed?.dependencies?.[bundleName] ?? parsed?.devDependencies?.[bundleName] ?? parsed?.optionalDependencies?.[bundleName];
179
+ if (typeof dep !== "string") return null;
180
+ let localPath = null;
181
+ if (dep.startsWith("link:")) localPath = dep.slice(5);
182
+ else if (dep.startsWith("file:")) localPath = dep.slice(5);
183
+ if (!localPath) return null;
184
+ const resolved = isAbsolute(localPath) ? localPath : resolve(dirname(profilePkgPath), localPath);
185
+ const pkgPath = join(resolved, "package.json");
186
+ return existsSync(pkgPath) ? pkgPath : null;
187
+ }
188
+
189
+ /** 若本次文件变更会写入 profile package.json 的 dsh.profile.bundles,返回其中非 bundle/无法确认的项 */
190
+ export function nonBundleInProfileBundles(name, args) {
191
+ const p = pathTarget(args);
192
+ if (!isProfilePackageJson(p)) return null;
193
+ const content = resultingFileContent(name, args);
194
+ if (!content) return null;
195
+ let parsed;
196
+ try { parsed = JSON.parse(content); } catch { return null; }
197
+ const bundles = parsed?.dsh?.profile?.bundles;
198
+ if (!Array.isArray(bundles)) return null;
199
+ const bad = [];
200
+ for (const b of bundles) {
201
+ const pkgPath = resolveBundlePkgPath(b, p) || resolveLocalDependencyPkgPath(b, p, parsed);
202
+ if (!pkgPath) {
203
+ const dep = parsed?.dependencies?.[b] ?? parsed?.devDependencies?.[b] ?? parsed?.optionalDependencies?.[b];
204
+ const depDesc = typeof dep === "string" ? `dependencies 为 ${dep}` : "dependencies 中无此包";
205
+ bad.push(`${b}(找不到 package.json,无法确认类型;${depDesc}。请先用 dev_install_package 或先安装依赖再写 bundles)`);
206
+ continue;
207
+ }
208
+ try {
209
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
210
+ if (!pkg?.dsh?.bundle) bad.push(`${b}(未声明 dsh.bundle)`);
211
+ } catch {
212
+ bad.push(`${b}(package.json 读取失败)`);
213
+ }
214
+ }
215
+ return bad.length ? bad : null;
216
+ }
217
+
218
+ /** 任务契约守卫:仅在总开关开启且会话 armed 时硬拦;ask 场景交给 tools/pre-execute */
219
+ function taskContractGuardDecision(state, exec, session) {
220
+ if (!state.taskContract?.taskContractEnabled) return null;
221
+ const contract = session?.contract || defaultContract();
222
+ if (!isArmed(contract, state.taskContract)) return null;
223
+ const action = classifyAction(exec?.name, exec?.arguments);
224
+ const dec = decideContractAction({ contract, action, config: state.taskContract });
225
+ // 2026-08-25 预算双判防护:AGENT_BUDGET_EXHAUSTED 唯一裁决点 = tools/pre-execute 钩子(扣减前判定);
226
+ // guard 层可能在 pre-execute 扣减之后运行(收到已扣 contract),若在此重判会误拒“预算内”调用(允许 N 实际 N-1)。
227
+ if (dec.reasonCode === "AGENT_BUDGET_EXHAUSTED") return null;
228
+ if (dec.outcome === "deny") {
229
+ return makeHit(
230
+ { ruleId: "__task-contract", title: `任务契约:${dec.reasonCode}`, action: "deny" },
231
+ `【硬拦截】${dec.reason}(${dec.reasonCode}|任务契约|放行:${dec.nextStep || "..."}|ERR-${Math.random().toString(36).slice(2, 8).toUpperCase()})`
232
+ );
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * 裁决一次工具调用。
239
+ * @param {object} state createState 返回的运行时状态
240
+ * @param {object} exec ToolExecution(至少 name/arguments)
241
+ * @param {number} now
242
+ * @returns {object|null}
243
+ */
244
+ export function guardDecision(state, exec, now = Date.now(), opts = {}) {
245
+ if (state.enabled === false) return null;
246
+ if (state.bypassUntil > now) return null;
247
+ maybeReloadIfChanged(state, now);
248
+ const name = String(exec?.name || "");
249
+ const args = exec?.arguments || {};
250
+ const session = getSessionState(state, sessionIdOf(exec));
251
+ const unlock = state.unlockUntil > now;
252
+ const p = pathTarget(args);
253
+ const cmd = commandText(args);
254
+
255
+ // 只读操作无条件放行(read/grep/glob/read_image/str_replace_editor view)
256
+ if (isReadOnlyTool(name, args)) return null;
257
+
258
+ // 0.5.12(F2):打标指纹放行——同指纹命令已被用户 /guard label incorrect 确认过 →
259
+ // 直接放行并记 label-hits 审计(可查、可撤)。危险命令指纹为空 → 永不命中。
260
+ // 只对规则层拒绝(rule22/13A 等)生效——官方审批层与 self-protect 不受影响。
261
+ if (name === "pwsh" || name === "bash") {
262
+ const fp = labelFingerprint(cmd);
263
+ if (fp && labelsAllowFingerprint(state.labelRows || [], fp, now)) {
264
+ opts?.audit?.({
265
+ kind: "label-hits",
266
+ rule: "__label-fingerprint",
267
+ name: "打标指纹放行",
268
+ event: "tool/guard",
269
+ tool: name,
270
+ args: { command: cmd },
271
+ reason: `命令命中打标指纹(用户已确认 incorrect):${fp.slice(0, 120)}`,
272
+ session: sessionIdOf(exec)
273
+ });
274
+ return null;
275
+ }
276
+ }
277
+
278
+ // 任务契约守卫(总开关关闭时不生效)
279
+ const contractHit = taskContractGuardDecision(state, exec, session);
280
+ if (contractHit) return contractHit;
281
+
282
+ // ask_user_question 必须真正送达用户;已有授权不再吞弹窗(2026-08-24 修复:
283
+ // __already-authorized 之前会把 ask 拦截成内部“已有授权”,导致用户收不到任何弹窗)
284
+ if (name === "ask_user_question") {
285
+ const qText = askQuestionText(args?.questions);
286
+ if (qText) {
287
+ // 弹窗消减(2026-08-24):本回合 ask 已被拒 再次 ask 直接拦(提示改用普通文本)
288
+ if (session.turn.askRejected) {
289
+ return makeHit(
290
+ { ruleId: "__ask-rejected", title: "本回合 ask 已被拒绝", action: "deny" },
291
+ `【提示】本回合的 ask 已遭拒绝(规则 22 沟通直接性)。用户已明确答复,无需再次弹窗询问;用普通文本说明即可。`
292
+ );
293
+ }
294
+ // 5 分钟内已有被拒的 ask 记录 → 拦(防连环弹窗 → not-pending 诱因链)
295
+ // C3(2026-08-28):节流池仅含"明确拒绝"(未响应不入池,见 index.js ask 结果处理)
296
+ const recentReject = (state.askRejections || []).some((r) => r.sessionId === session.id && now - r.at < 5 * 60 * 1000);
297
+ if (recentReject) {
298
+ return makeHit(
299
+ { ruleId: "__ask-throttle", title: "ask 请求过频", action: "deny" },
300
+ `【提示】5 分钟内已有一次被明确拒绝的 ask(规则 22 沟通直接性)。再次弹窗可能无法送达;用普通文本说明即可。`
301
+ );
302
+ }
303
+ }
304
+ }
305
+
306
+ // 内部自护:插件配置/理解产物/规则文件禁止模型直写(/guard unlock 可临时放行)
307
+ if (!unlock && (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p)) {
308
+ return makeHit(
309
+ { ruleId: "__self-protect", title: "规则引擎配置只读(需 /guard unlock)", action: "deny" },
310
+ `【硬拦截】${p} 受规则引擎保护:需要用户输入 /guard unlock 放行(解锁范围含 rule-engine.json / rule-understanding.json / AGENTS.md,默认 10 分钟)。请停止并让用户在对话框输入 /guard unlock。`
311
+ );
312
+ }
313
+
314
+ // 阶段 C:硬拦 pwsh/bash 绕过统一入口直接写受保护文件(规则 19⑧/21⑨ 的机器执行层)
315
+ // 合法通道 = 整条命令仅调用 dsh-manual-write.mjs(无 ; | & 链式/换行,防注释文本伪造放行)
316
+ // 0.5.10:写类判定改 isMutationCommand(单真源)——修复 `Write-Output` 被 `write` 子串误杀(WGO654/ES3VCD 同类)
317
+ if (!unlock && (name === "pwsh" || name === "bash")) {
318
+ const cmd = commandText(args) || "";
319
+ if (cmd && isMutationCommand(cmd) && PROTECTED_FILENAME_RE.test(cmd)) {
320
+ if (!isEntryChannelCommand(cmd)) {
321
+ return makeHit(
322
+ { ruleId: "__self-protect", title: "受保护文件禁止绕过统一入口直写", action: "deny" },
323
+ `【硬拦截】受保护文件禁止通过 pwsh/bash 绕过统一入口直写;请使用 scripts/dsh-manual-write.mjs(整个命令只能调用该脚本,不得链式拼接其他写命令;或 /guard unlock 临时放行)。`
324
+ );
325
+ }
326
+ }
327
+ }
328
+
329
+ // 写前版本校验(建议③):版本化文件(SKILL.md/AGENTS.md/CHANGELOG/README 等)在写入前
330
+ // 用 old/new 模拟结果做校验,不合规直接拒绝——避免"先写后回滚"的副作用与假成功
331
+ // 位置在自护之后:受保护文件需先 unlock(用户明确授权)再接受版本校验
332
+ if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && p && isVersionedFile(p)) {
333
+ try {
334
+ const current = readFileSync(p, "utf8");
335
+ const simulated = resultingFileContent(name, args) ?? current;
336
+ // 0.5.11(用户定稿):单行整句重写放行需 old 唯一匹配——工具层已拒多处匹配;
337
+ // 唯一时传给 validateEditedFile/append 作为放行前提(唯一 = 替换位置正确 = 语义编辑非覆盖)。
338
+ const oldStr = args?.old_string ?? args?.old_str ?? "";
339
+ const uniqueMatch = oldStr.length > 0 ? countOccurrences(current, oldStr) === 1 : false;
340
+ const check = validateEditedFile(current, simulated, oldStr, args?.new_string ?? args?.new_str ?? "", uniqueMatch);
341
+ if (!check.ok) {
342
+ return makeHit(
343
+ { ruleId: "__version-guard", title: "版本守卫:写入前校验", action: "deny" },
344
+ `【硬拦截】${p} 是版本化文件,本次编辑未通过版本守卫(写前校验):${check.errors.join(";")}。请修正 old_string/new_string(保留原文逐行或按行包含关系)后重试`
345
+ );
346
+ }
347
+ } catch {
348
+ // 文件不可读等异常不阻断(交给写后自检兜底)
349
+ }
350
+ }
351
+
352
+ for (const cfg of state.configs) {
353
+ if (cfg.disabled) continue;
354
+ // 低置信规则不硬拦(保守不误拦,交给 /guard rules 人工复核)——批次 3:跳过必须留痕(N2 防御可观测)
355
+ if (cfg.confidence === "low") {
356
+ opts?.audit?.({
357
+ kind: "n2-skip",
358
+ rule: cfg.ruleId,
359
+ name: "低置信规则跳过",
360
+ event: "tool/guard",
361
+ reason: `规则 ${cfg.ruleId} 理解低置信 跳过硬拦(保守不误拦;请在 /guard rules 对该规则复核)`,
362
+ session: sessionIdOf(exec)
363
+ });
364
+ continue;
365
+ }
366
+ // 分级执行:只有 A 硬拦 / C 时序 / M 元规则才进入工具守卫
367
+ const actions = cfg.actions || [];
368
+ if (!actions.some((a) => a === "deny" || a === "ask" || a === "meta")) continue;
369
+ const hit = matchRule(cfg, { name, args, p, cmd, session, unlock, state, now, audit: opts?.audit, exec });
370
+ if (hit) return hit;
371
+ }
372
+ return null;
373
+ }
374
+
375
+ function matchRule(cfg, ctx) {
376
+ const { name, args, p, cmd, session, unlock, state, now, audit, exec } = ctx;
377
+ const id = String(cfg.ruleId);
378
+ // 防热重载/事件顺序导致意图状态落后:若当前 turn.userText 与已缓存 intents.raw 不一致,
379
+ // 或尚无 intents 但有用户文本,则以最新用户文本重新解析并回写,避免用旧状态误拦。
380
+ // P0-3 根修(2026-08-24):裁决基底只用“本回合”的真实用户文本(turn.userText),
381
+ // 不再回退 lastUserText——上一轮残留文本(如含“为什么”的询问)不得裁决本轮。
382
+ const currentText = session.turn.userText || "";
383
+ let turnIntents = session.turn.intents;
384
+ if (turnIntents && currentText && turnIntents.raw !== currentText) {
385
+ turnIntents = parseUserIntents(currentText);
386
+ session.turn.intents = turnIntents;
387
+ } else if (!turnIntents && currentText) {
388
+ turnIntents = parseUserIntents(currentText);
389
+ session.turn.intents = turnIntents;
390
+ }
391
+ // 无用户消息回合(委派/ask 答复后/状态信号)不做规则 22 判定;敏感操作由 12A/13A 独立把关
392
+ const denyMutation = turnIntents
393
+ ? shouldDenyMutation(turnIntents, session.turn.askSeen && !session.turn.askRejected)
394
+ : false;
395
+
396
+ // 规则 22 机器化:无执行分点/存在歧义 → 本回合禁止变更类工具调用
397
+ //("你的执行方案难道没问题吗?"虽含"执行"仍是疑问句——同形词不构成指令,不豁免)
398
+ if (cfg.handler === "rule22-7-direct") {
399
+ // B2(2026-08-28 阶段二):委派/子代理回合不做"无执行分点"判定(规则 22 的识别层落地)——
400
+ // 子代理任务书以 source.kind="user" 注入,会被当用户消息判"无执行分点"而误拦(ERR-L8QAXS 实弹)。
401
+ if (isDelegatedSession(exec)) {
402
+ audit?.({ kind: "delegated-skip", rule: cfg.ruleId, name: "委派回合豁免规则22", event: "tool/guard", reason: `委派会话(meta 判定)跳过"无执行分点",敏感操作仍由 12A/13A 把关`, session: sessionIdOf(exec) });
403
+ return null;
404
+ }
405
+ // 机制 B(2026-08-24):工具分类制(纯函数,覆盖现有与未来插件注册的全部工具)——
406
+ // analysis(只读分析放行)/ artifact(产物放行+审计)/ unknown(物理不拦,由 pre-execute
407
+ // ask/deny 层首调处置,防新插件绕过);mutating 维持原有严格逻辑。0.5.9 单真源:唯一分类表。
408
+ const cls = toolClass(name, args);
409
+ // 0.5.10 分析通道(用户多次提出:只读分析需要写临时脚本/输出——解压副本/聚合日志等):
410
+ // 严格只读 分析临时区写 分析脚本区调用 → 任何回合放行 + 审计留痕。
411
+ // 红线:工作区外路径命中临时区段(如 C:\xxx\logs\)不豁免——落入常规变更判定。
412
+ if (isAnalysisOp(name, args)) {
413
+ const scratch = extractAnalysisScratchPaths(commandText(args) || "");
414
+ const outside = scratch.filter((p) => isOutsideWorkspace(p, sessionIdOf(exec)));
415
+ if (outside.length === 0) {
416
+ audit?.({ kind: "analysis-scratch", rule: cfg.ruleId, name: "分析通道放行", tool: name, reason: `分析类操作(只读/临时区/分析脚本区)放行:${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
417
+ return null;
418
+ }
419
+ }
420
+ if (cls === "analysis") return null;
421
+ if (cls === "artifact") {
422
+ audit?.({ kind: "allow", rule: cfg.ruleId, name: "产物类工具放行", tool: name, reason: `分类 artifact(低风险产物写入,留痕可对账):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
423
+ return null;
424
+ }
425
+ if (cls === "unknown") return null; // 物理不拦;pre-execute ask 层首调询问
426
+ if (denyMutation) {
427
+ // A 方案(2026-08-28 用户拍板):方案/调研回合(无执行分点)写工作区正式路径 = 落盘/产出动作
428
+ // + "确认后落盘"文案(不静默放行——原 isLowRiskWorkspaceNew 在此静默放行,属越权)。
429
+ // 方案性指令不构成落盘授权(规则 22 自证③);分析通道/临时区在 isAnalysisOp 已放行(工具不拦);
430
+ // _inbox 按分类文本定位=暂存待归位的产物,同正式路径(拦);工作区外由下方"询问"分支统一兜底。
431
+ if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && !isAnalysisOp(name, args) && !isOutsideWorkspace(p, sessionIdOf(exec))) {
432
+ return makeHit(cfg, `【硬拦截】本回合是方案/调研指令,写工作区文件属于"落盘/产出"动作——方案性指令不构成落盘授权(规则 22 自证③)。落盘需你明确确认:请回复"落盘"或"确认后保存"(或对产出位置确认)`);
433
+ }
434
+ // LLM 意图兜底(方案 A,2026-08-24):词表判拦 + LLM 高/低置信判 execute → 放行
435
+ //(非对称:LLM 只解救不收紧;审计在预取时已记 intent-llm
436
+ const verdict = verdictForDeny(session.turn, state.llmIntentCfg || {});
437
+ if (!verdict.mutationDenied) return null;
438
+ if (isReadOnlyTool(name, args)) return null; // 只读 → 豁免
439
+ const llmNote = verdict.source === "llm-low" ? "(LLM 低置信未能挽救)" : "";
440
+ return makeHit(cfg, `【硬拦截】用户消息是询问/没有明确执行分点(规则 22)${llmNote}:请先回答/展示方案,待用户明确授权后再执行(放行:存在执行分点,或 ask_user_question 授权答复)`);
441
+ }
442
+ // 规则 22 粒度升级(2026-08-24,用户点名治本项):
443
+ // 存在执行分点时不再"同回合一词放行",而是逐项比对本次变更是否落在
444
+ // 本回合 execute 子句或 ask 授权的「工具类别 + 路径前缀」范围内;未覆盖 → 拦。
445
+ // 无用户消息/状态信号/ask 答复后回合仍不做规则 22 判定(由 12A/13A 把关)。
446
+ if (!turnIntents || !turnIntents.hasExecute) return null;
447
+ if (isReadOnlyTool(name, args)) return null;
448
+ if (cls === "mutating" || looksLikeFileMutation(name, args)) {} else return null;
449
+ const op = operationOf(name, args);
450
+ // v0.5.7 P0-1(用户拍板 2026-08-26):验证类命令伴生放行——本回合已有 write/any/command
451
+ // 授权(用户已批"修改/执行")时,运行测试/冷加载/审计/语法检查属于该变更的验证闭环,
452
+ // 不再被 write vs command 类型不匹配误拦(今天实弹连卡 4 次)。
453
+ if ((name === "pwsh" || name === "bash") && isVerificationCommand(cmd)) {
454
+ const verScopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
455
+ ? session.turn.scopes
456
+ : scopesFromIntents(turnIntents);
457
+ if (verScopes.some((s) => s.type === "write" || s.type === "any" || s.type === "command")) {
458
+ audit?.({ kind: "allow", rule: cfg.ruleId, name: "验证命令伴生放行", tool: name, reason: `本回合已有 ${describeScopes(verScopes)} 授权 → 验证命令伴生(${describeOp(op)})`, session: sessionIdOf(exec) });
459
+ return null;
460
+ }
461
+ }
462
+ // 保护性备份豁免(2026-08-26,backup 口径修复):规则 12A 豁免③ / 13A ⑦ 均豁免
463
+ // 复制到 .backups/.bak 的保护性备份——与 13A 分支口径一致,backup 类型不再被 22 粒度误拦。
464
+ if (op.type === "backup") return null;
465
+ // 本回合 scopes(execute 子句 + 本回合 ask 授权已由 handleSessionEvent 写入 turn.scopes)
466
+ // C1(2026-08-28 阶段一):并入 session.authorizations 中【带 TTL 且未过期】的授权——规则 22
467
+ // 粒度与 12A/13A 同口径(findMatchingAuth 均考虑 TTL)。修复"12D 已记录授权、下一回合操作
468
+ // 仍被 22 粒度拦"的授权分裂(2026-08-28 实弹:ask 授权后读取仍被拒)。
469
+ // 安全边界:只并入显式 TTL 授权(=同任务窗口);无 expiresAt 的长期授权不放宽本轮粒度
470
+ // (2026-08-24 旧语义"历史授权不得放宽本轮"对永久授权仍成立——防上轮授权越权本轮跨操作)。
471
+ const turnScopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
472
+ ? session.turn.scopes
473
+ : scopesFromIntents(turnIntents);
474
+ const ttlAuths = Array.isArray(session.authorizations)
475
+ ? session.authorizations.filter((a) => a && typeof a.expiresAt === "number" && a.expiresAt > now)
476
+ : [];
477
+ const scopes = [...turnScopes, ...ttlAuths];
478
+ if (!scopes.some((scope) => authMatches(scope, op))) {
479
+ return makeHit(cfg, `【硬拦截】本次变更操作不在本回合执行分点/授权范围内(规则 22):已授权范围 [${describeScopes(scopes)}];本次操作 [${describeOp(op)}]。请补充明确授权(可用 ask_user_question)后再执行`);
480
+ }
481
+ // 可观测性(阶段 0,2026-08-24):规则 22 粒度放行是最高频放行路径,必须留痕可查
482
+ audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(规则 22 粒度命中):${describeScopes(scopes)} 覆盖 ${describeOp(op)}`, session: sessionIdOf(exec) });
483
+ return null;
484
+ }
485
+
486
+ // 规则 1:同工具同参数连续失败 ≥2 次后拦第 3 次(失败计数由 tool/result 更新)
487
+ if (cfg.handler === "rule1-retry") {
488
+ const key = `${name}:${JSON.stringify(args || {})}`;
489
+ const count = state.retryCounts.get(key) || 0;
490
+ const userText = session.turn.userText || session.lastUserText || "";
491
+ if (count >= 2 && /(?:重试|再试一次|再来一次|继续试|再试)/.test(userText)) {
492
+ return null; // 用户明确要求重试 → 豁免
493
+ }
494
+ if (count >= 2) {
495
+ return makeHit(cfg, `【硬拦截】同一工具调用已连续失败 ${count} 次,按规则 1 禁止第 ${count + 1} 次重试`);
496
+ }
497
+ return null;
498
+ }
499
+
500
+ // 规则 9:内联命令 / BOM 写配置(PS7 语义:仅拦显式 utf8BOM)
501
+ if (cfg.handler === "rule9-inline-bom") {
502
+ if ((name === "pwsh" || name === "bash") && cmd) {
503
+ if (INLINE_CMD.test(cmd)) {
504
+ return makeHit(cfg, "【硬拦截】禁止内联命令(node -e / pwsh -c / node -p 等),请先写脚本文件再执行");
505
+ }
506
+ if (BOM_WRITE.test(cmd)) {
507
+ return makeHit(cfg, "【硬拦截】禁止用 Set-Content/Out-File -Encoding utf8BOM 写 .json/.yaml(PS7 显式带 BOM)");
508
+ }
509
+ }
510
+ return null;
511
+ }
512
+
513
+ // 规则 18:DSH 任务首次工具调用前必须已读手册
514
+ if (cfg.handler === "rule18-manual-first") {
515
+ const userText = session.turn.userText || session.lastUserText || "";
516
+ const firstTool = session.turn.toolCount === 0;
517
+ if (firstTool && !session.manualReadSeen && DSH_KEYWORDS_RE.test(userText) && !isManualReadTool(name, args)) {
518
+ return makeHit(cfg, "【硬拦截】任务涉及 DSH,首次工具调用前需先 grep/read 手册(~/.dsh/skills/dsh-usage-manual/SKILL.md)");
519
+ }
520
+ return null;
521
+ }
522
+
523
+ // 规则 13A:删除/覆盖/高风险写前需有“目标路径对应备份”证据
524
+ if (cfg.handler === "rule13a-backup") {
525
+ // 统一入口命令豁免:dsh-manual-write.mjs 每次写入前自身执行备份(backup() 保留 5 份),
526
+ // 引擎静态扫描看不到脚本内部动作(已知盲区);入口命令也已被 __self-protect 限定为唯一写通道。
527
+ if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd)) return null;
528
+ const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args, sessionIdOf(exec)) && !/git\s+(push|commit)/i.test(cmd)));
529
+ const highRiskWrite = (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p);
530
+ if (highRiskWrite && unlock && !isHighRiskEntryFile(p)) return null;
531
+ if (destructive || highRiskWrite) {
532
+ const op = operationOf(name, args);
533
+ // 备份动作本身(复制到 .bak/.backups/trash-)不需要再“先备份”
534
+ if (op.type === "backup") return null;
535
+ const targetPath = highRiskWrite ? p : op.pathPrefix;
536
+ // shell 变量($var / %var%)的路径无法可靠解析 → 跳过机械备份检查(P0-2,防变量路径误拦)
537
+ if (targetPath && isVariablePath(targetPath)) return null;
538
+ // 已获 12A/12D 授权的操作 = 用户已明确确认本次操作 → 跳过 13A 机械备份(P1-2,一次授权覆盖全规则)
539
+ // 但高风险运行入口文件除外:即使已授权也必须有备份证据或明确提示
540
+ const auth13 = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
541
+ if (auth13 && !isHighRiskEntryFile(targetPath)) {
542
+ audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(13A 跳过备份):${describeAuth(auth13)}`, session: sessionIdOf(exec) });
543
+ return null;
544
+ }
545
+ // 复制/新建到“尚不存在”的目标文件:属于创建新文件,不适用 13A 覆盖备份要求
546
+ const isCreateNewTarget = !highRiskWrite && cmd && /copy-item|new-item/i.test(cmd) && targetPath && !existsSync(targetPath);
547
+ if (!isCreateNewTarget) {
548
+ const backup = findBackupForPath(state, session.id, targetPath);
549
+ if (!backup) {
550
+ const existing = session.backups.map((b) => `${b.targetPath} -> ${b.backupPath}`).join(";") || "无";
551
+ const highRiskNote = isHighRiskEntryFile(targetPath) ? "(该文件不在自动备份范围,请先手动备份)" : "";
552
+ return makeHit(cfg, `【硬拦截】目标路径缺少对应备份(规则 13A)${highRiskNote}:已有备份 [${existing}];本次目标 [${targetPath}]`);
553
+ }
554
+ if (!existsSync(backup.backupPath)) {
555
+ return makeHit(cfg, `【硬拦截】备份记录存在但备份文件不存在(规则 13A):${backup.backupPath}`);
556
+ }
557
+ }
558
+ }
559
+ return null;
560
+ }
561
+
562
+ // 规则 12B:技能调用四步时序(关键词→授权→调用;豁免技能除外)
563
+ // 2026-08-24 修复:旧条件 `handler==='rule12b-skill' || hints.includes('skill')` 会把
564
+ // hints 含 "skill" 的其它规则 cfg(如 12A:hints=["ask","skill","sensitive",...])截胡——
565
+ // skill 工具时 return null,导致 12A 敏感授权检查静默失效(C-2 实测 Move-Item 放行)。
566
+ // 现在:hints 兜底仅在「name === 'skill'」时进入;handler 明确为 rule12b-skill 时维持原语义。
567
+ if (cfg.handler === "rule12b-skill") {
568
+ if (name === "skill") {
569
+ const skillName = typeof args?.name === "string" ? args.name : "";
570
+ if (SKILL_EXEMPT.has(skillName)) return null;
571
+ // 技能目录实时联动:已加载目录且该技能不存在/被禁用时,规则不激活
572
+ if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
573
+ if (denyMutation) {
574
+ return makeHit(cfg, `【硬拦截】当前用户消息是询问/没有明确执行分点,技能 ${skillName} 未获授权`);
575
+ }
576
+ const op = { type: "skill", pathPrefix: "" };
577
+ const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
578
+ if (!auth) {
579
+ const existing = session.authorizations.map(describeAuth).join(";") || "无";
580
+ return makeHit(cfg, `【硬拦截】技能调用缺少匹配授权:${skillName}(已有授权:${existing};本次范围:${describeOp(op)})`);
581
+ }
582
+ audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(技能 ${skillName}):${describeAuth(auth)}`, session: sessionIdOf(exec) });
583
+ return null;
584
+ }
585
+ return null;
586
+ }
587
+ if ((cfg.hints || []).includes("skill") && name === "skill") {
588
+ // hints 兜底:理解器未分配 handler 但 hints 含 skill 的 cfg——仅 skill 工具时参与,不截胡其它规则
589
+ const skillName = typeof args?.name === "string" ? args.name : "";
590
+ if (SKILL_EXEMPT.has(skillName)) return null;
591
+ if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
592
+ const op = { type: "skill", pathPrefix: "" };
593
+ const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
594
+ if (!auth) {
595
+ const existing = session.authorizations.map(describeAuth).join("") || "无";
596
+ return makeHit(cfg, `【硬拦截】技能调用缺少匹配授权:${skillName}(已有授权:${existing};本次范围:${describeOp(op)})`);
597
+ }
598
+ return null;
599
+ }
600
+
601
+ // 规则 12A:敏感操作需要匹配授权证据
602
+ if (cfg.handler === "rule12a-approval") {
603
+ if (isSensitiveToolCall(name, args, sessionIdOf(exec))) {
604
+ // 0.5.11(用户定稿):12A 22-7 判据同源——分析通道/低风险新建豁免在两条分支
605
+ // 使用同一组判据(isAnalysisOp/isLowRiskWorkspaceNew),不再各写一套(此前 22-7 放行、
606
+ // 12A 仍要求授权的不一致)。红线不变:工作区外(isOutsideWorkspace)不豁免。
607
+ if (isAnalysisOp(name, args)) {
608
+ const scratch = extractAnalysisScratchPaths(commandText(args) || "");
609
+ const outside = scratch.filter((p) => isOutsideWorkspace(p, sessionIdOf(exec)));
610
+ if (outside.length === 0) {
611
+ audit?.({ kind: "analysis-scratch", rule: cfg.ruleId, name: "分析通道放行", tool: name, reason: `分析类操作放行(12A 判据同源):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
612
+ return null;
613
+ }
614
+ }
615
+ if (isLowRiskWorkspaceNew(name, args)) {
616
+ audit?.({ kind: "allow", rule: cfg.ruleId, name: "低风险新建豁免(12A 判据同源)", tool: name, reason: `工作区内低风险新建(12A 判据同源):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
617
+ return null;
618
+ }
619
+ // 规则 19:dsh-usage-manual/SKILL.md 正文更新免逐次确认(仅手册本身)
620
+ if (p && MANUAL_PATH_RE.test(p)) return null;
621
+ // /guard unlock 本身即用户对受保护配置的授权
622
+ if (unlock && isProtectedConfigPath(p)) return null;
623
+ const op = operationOf(name, args);
624
+ // 规则 12A 正文豁免:保护性备份(创建/复制/移动文件到 .backups/ .backups/trash-<时间戳>/)免询问
625
+ if (op.type === "backup") return null;
626
+ if (denyMutation) {
627
+ return makeHit(cfg, `【硬拦截】当前用户消息是询问/没有明确执行分点,未构成授权证据(本次操作:${describeOp(op)})`);
628
+ }
629
+ const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
630
+ if (!auth) {
631
+ const existing = session.authorizations.map(describeAuth).join(";") || "无";
632
+ return makeHit(cfg, `【硬拦截】敏感操作缺少匹配授权:已有授权范围 [${existing}];本次操作范围 [${describeOp(op)}]`);
633
+ }
634
+ audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(12A 敏感操作):${describeAuth(auth)}`, session: sessionIdOf(exec) });
635
+ return null;
636
+ }
637
+ return null;
638
+ }
639
+
640
+ // 规则 21:规则/配置文件变更需 unlock(元规则)
641
+ if (cfg.handler === "rule21-meta") {
642
+ if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p) && !unlock) {
643
+ return makeHit(cfg, "【硬拦截】规则/配置文件受保护:修改需用户先执行 /guard unlock");
644
+ }
645
+ return null;
646
+ }
647
+
648
+ // 规则 24:插件装配类型确认(A 硬拦)+ 变更类工具统一覆盖(原规则 25 语义,检查④)
649
+ if (cfg.handler === "rule24-assembly-type") {
650
+ if (name === "dev_install_package") {
651
+ const dir = args?.dir;
652
+ if (dir) {
653
+ try {
654
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
655
+ if (!pkg?.dsh?.bundle) {
656
+ return makeHit(cfg, `【硬拦截】插件 ${dir} 未声明 dsh.bundle,不能加入 dsh.profile.bundles(规则 24)`);
657
+ }
658
+ } catch {
659
+ return makeHit(cfg, `【硬拦截】无法读取插件 package.json:${dir}(规则 24)`);
660
+ }
661
+ }
662
+ }
663
+ // 手工编辑 profile package.json 的 dsh.profile.bundles 时同样做类型检查
664
+ const badBundles = nonBundleInProfileBundles(name, args);
665
+ if (badBundles && badBundles.length) {
666
+ return makeHit(cfg, `【硬拦截】${p} 的 dsh.profile.bundles 包含非 bundle/无法确认类型:${badBundles.join(";")}(规则 24)`);
667
+ }
668
+ // 规则 24④:所有能产生文件写入/删除/移动效果的工具都必须纳入统一守卫——
669
+ // 0.5.9 单真源:已分类(tool-catalog 唯一表)= 已纳入守卫,放行;未分类且疑似变更 = 运行时拒绝
670
+ // (防"改个新工具就绕过守卫";与工具覆盖门禁 K-01/K-06 互补)
671
+ if (isReadOnlyTool(name, args)) return null;
672
+ const cls24 = toolClass(name, args);
673
+ if (cls24 !== "unknown") return null;
674
+ if (looksLikeFileMutation(name, args)) {
675
+ return makeHit(cfg, `【硬拦截】未覆盖的变更类工具 ${name},违反规则 24④:请先纳入统一守卫覆盖`);
676
+ }
677
+ return null;
678
+ }
679
+
680
+ // 规则 27:装配变更后必须先通过全量审计,才能继续装配(C 时序;全局变更 + 本会话审计证据)
681
+ if (cfg.handler === "rule27-mount-audit") {
682
+ if (isAssemblyMutationTool(name, args)) {
683
+ const currentSig = computeMountSignature(profileNameFromArgs(args));
684
+ state.mountSignature = currentSig;
685
+ const auditedSig = session.mountAuditSignature || "";
686
+ const needsAudit = auditedSig
687
+ ? currentSig !== auditedSig
688
+ : (state.mountRevision > (session.mountAuditRevision || 0));
689
+ if (needsAudit) {
690
+ const why = auditedSig
691
+ ? `装配内容已变化(装配状态哈希 ${currentSig.slice(0, 8)} ≠ 审计通过时 ${auditedSig.slice(0, 8)})`
692
+ : `插件装配已变更(mountRevision=${state.mountRevision})且本会话未通过全量审计`;
693
+ return makeHit(cfg, `【硬拦截】${why},请先运行 node scripts/audit-mount-consistency.mjs --profile <p> 并通过后再继续装配`);
694
+ }
695
+ }
696
+ return null;
697
+ }
698
+
699
+ // 兜底:从理解产物里的 hints 泛化匹配
700
+ const hints = cfg.hints || [];
701
+ if (hints.includes("inline-command") && (name === "pwsh" || name === "bash") && cmd && INLINE_CMD.test(cmd)) {
702
+ return makeHit(cfg, `【硬拦截】${cfg.title}`);
703
+ }
704
+ if (hints.includes("bom-write") && (name === "pwsh" || name === "bash") && cmd && BOM_WRITE.test(cmd)) {
705
+ return makeHit(cfg, `【硬拦截】${cfg.title}`);
706
+ }
707
+ if (hints.includes("manual") && session.turn.toolCount === 0 && !session.manualReadSeen && !isManualReadTool(name, args)) {
708
+ return makeHit(cfg, `【硬拦截】${cfg.title}`);
709
+ }
710
+ if (hints.includes("sensitive") && isSensitiveToolCall(name, args, sessionIdOf(exec))) {
711
+ if (p && MANUAL_PATH_RE.test(p)) return null;
712
+ if (unlock && isProtectedConfigPath(p)) return null;
713
+ const op = operationOf(name, args);
714
+ if (denyMutation) {
715
+ return makeHit(cfg, `【硬拦截】当前用户消息是询问/没有明确执行分点(本次操作:${describeOp(op)})`);
716
+ }
717
+ const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
718
+ if (!auth) {
719
+ const existing = session.authorizations.map(describeAuth).join(";") || "无";
720
+ return makeHit(cfg, `【硬拦截】${cfg.title}:缺少匹配授权(已有:${existing};本次:${describeOp(op)})`);
721
+ }
722
+ audit?.({ kind: "allow", rule: cfg.ruleId, name: "授权命中放行", tool: name, reason: `由授权放行(${cfg.title}):${describeAuth(auth)}`, session: sessionIdOf(exec) });
723
+ }
724
+ return null;
725
+ }
726
+
727
+ /** 供测试/调试:手动更新备份状态(并创建真实备份文件以满足存在性校验) */
728
+ export function markBackupSeen(state, sessionId, targetPath) {
729
+ const s = getSessionState(state, sessionId);
730
+ s.turn.backupSeen = true;
731
+ if (targetPath) {
732
+ const dir = mkdtempSync(join(tmpdir(), "dsh-rule-engine-bak-"));
733
+ const backupPath = join(dir, "backup.bak");
734
+ writeFileSync(backupPath, "backup", "utf8");
735
+ const norm = (p) => String(p).replace(/\\/g, "/").toLowerCase();
736
+ s.backups.push({
737
+ targetPath: norm(targetPath),
738
+ backupPath,
739
+ at: Date.now()
740
+ });
741
+ }
742
+ }
743
+
744
+ export function markAskSeen(state, sessionId) {
745
+ const s = getSessionState(state, sessionId);
746
+ s.turn.askSeen = true;
747
+ s.authorizations.push({ at: Date.now(), type: "any", pathPrefix: "", source: "test" });
748
+ }
749
+
750
+ export function markManualRead(state, sessionId) {
751
+ getSessionState(state, sessionId).manualReadSeen = true;
752
+ }
753
+
754
+ export { isBackupTool, isManualReadTool };