dsh-rule-engine 0.3.0 → 0.4.0

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,6 +1,6 @@
1
1
  // authorization.js - 授权证据结构化匹配(P0:授权关联具体操作,区分询问与授权)
2
2
  // 纯函数,可独立测试。
3
- import { GENERIC_EXEC_TOOLS, pathTarget, commandText, writeTargetPathsFromCommand } from "./patterns.js";
3
+ import { GENERIC_EXEC_TOOLS, pathTarget, commandText, writeTargetPathsFromCommand, extractCopyPaths } from "./patterns.js";
4
4
 
5
5
  export const AUTH_TTL_MS = 10 * 60 * 1000;
6
6
 
@@ -55,19 +55,25 @@ export function operationOf(toolName, args) {
55
55
  const cmd = commandText(args);
56
56
  let type = "any";
57
57
  let pathPrefix = "";
58
+ let pathPrefixes = [];
58
59
 
59
60
  if (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) {
60
61
  type = "write";
61
62
  pathPrefix = normalizePath(p || "");
63
+ if (pathPrefix) pathPrefixes = [pathPrefix];
62
64
  } else if (name === "pwsh" || name === "bash") {
63
65
  const text = cmd || "";
64
66
  type = inferTypeFromText(text);
65
67
  if (/git\s+(push|commit)/i.test(text)) type = "git";
66
68
  else if (/remove-item|rm\s+-r|rmdir|del\s+/i.test(text)) type = "delete";
67
- else if (/backup|copy-item[^\n]*\.(?:bak|backups)[\\/]?|copy-item[^\n]*trash-/i.test(text)) type = "backup";
69
+ else if (/backup|(?:copy-item|move-item|rename-item)[^\n]*(?:\.bak|\.backups|trash-)/i.test(text)) type = "backup";
68
70
  else if (/set-content|add-content|out-file|writealltext|copy-item|move-item|rename-item/i.test(text)) type = "write";
69
71
  const writeTargets = writeTargetPathsFromCommand(text).map(normalizePath).filter(Boolean);
70
- pathPrefix = writeTargets.sort((a, b) => b.length - a.length)[0] || inferPathPrefixFromText(text);
72
+ pathPrefixes = [...writeTargets];
73
+ const cp = extractCopyPaths(text);
74
+ if (cp && cp.source) pathPrefixes.push(normalizePath(cp.source));
75
+ pathPrefixes = [...new Set(pathPrefixes.filter(Boolean))];
76
+ pathPrefix = pathPrefixes.sort((a, b) => b.length - a.length)[0] || inferPathPrefixFromText(text);
71
77
  } else if (name === "skill") {
72
78
  type = "skill";
73
79
  pathPrefix = "";
@@ -79,16 +85,20 @@ export function operationOf(toolName, args) {
79
85
  pathPrefix = "";
80
86
  }
81
87
 
82
- return { type, pathPrefix };
88
+ return { type, pathPrefix, pathPrefixes };
83
89
  }
84
90
 
85
- /** 判断授权记录是否匹配本次操作 */
91
+ /** 判断授权记录是否匹配本次操作(支持源/目标多路径任一匹配) */
86
92
  export function authMatches(auth, op) {
87
93
  if (!auth || !op) return false;
88
94
  if (auth.type !== "any" && op.type !== "any" && auth.type !== op.type) return false;
89
95
  if (auth.pathPrefix) {
90
- const p = normalizePath(op.pathPrefix || "");
91
- if (!p.startsWith(auth.pathPrefix)) return false;
96
+ const candidates = Array.isArray(op.pathPrefixes) && op.pathPrefixes.length > 0
97
+ ? op.pathPrefixes
98
+ : (op.pathPrefix ? [op.pathPrefix] : []);
99
+ if (candidates.length === 0) return false;
100
+ const authPath = normalizePath(auth.pathPrefix);
101
+ if (!candidates.some((p) => normalizePath(p).startsWith(authPath))) return false;
92
102
  }
93
103
  return true;
94
104
  }
@@ -14,6 +14,7 @@ import {
14
14
  isAssemblyMutationTool,
15
15
  isBackupTool,
16
16
  isChinesePs1Violation,
17
+ isHighRiskEntryFile,
17
18
  isManualReadTool,
18
19
  isProtectedConfigPath,
19
20
  isReadOnlyTool,
@@ -21,7 +22,7 @@ import {
21
22
  isVariablePath,
22
23
  pathTarget
23
24
  } from "./patterns.js";
24
- import { describeAuth, describeOp, findMatchingAuth, operationOf } from "./authorization.js";
25
+ import { describeAuth, describeOp, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText } from "./authorization.js";
25
26
  import { computeMountSignature, profileNameFromArgs } from "./mount-signature.js";
26
27
  import { findBackupForPath, getSessionState, maybeReloadIfChanged } from "./state.js";
27
28
  import { isVersionedFile, validateEditedFile } from "./version-guard.js";
@@ -181,11 +182,26 @@ export function guardDecision(state, exec, now = Date.now()) {
181
182
  // 只读操作无条件放行(read/grep/glob/read_image/str_replace_editor view)
182
183
  if (isReadOnlyTool(name, args)) return null;
183
184
 
185
+ // E3:已有匹配授权时,拦截重复 ask_user_question,避免 AI 反复询问已授权事项
186
+ if (name === "ask_user_question") {
187
+ const qText = askQuestionText(args?.questions);
188
+ if (qText) {
189
+ const op = { type: inferTypeFromText(qText), pathPrefix: inferPathPrefixFromText(qText) };
190
+ const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
191
+ if (auth) {
192
+ return makeHit(
193
+ { ruleId: "__already-authorized", title: "已有授权,无需重复询问", action: "deny" },
194
+ `【提示】本次询问范围 ${describeOp(op)} 已有匹配授权:${describeAuth(auth)}。无需重复 ask,请继续执行。`
195
+ );
196
+ }
197
+ }
198
+ }
199
+
184
200
  // 内部自护:插件配置/理解产物/规则文件禁止模型直写(/guard unlock 可临时放行)
185
201
  if (!unlock && (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p)) {
186
202
  return makeHit(
187
203
  { ruleId: "__self-protect", title: "规则引擎配置只读(需 /guard unlock)", action: "deny" },
188
- `【硬拦截】${p} 受规则引擎保护:修改需用户先执行 /guard unlock`
204
+ `【硬拦截】${p} 受规则引擎保护:需要用户输入 /guard unlock 放行(解锁范围含 rule-engine.json / rule-understanding.json / AGENTS.md,默认 10 分钟)。请停止并让用户在对话框输入 /guard unlock。`
189
205
  );
190
206
  }
191
207
 
@@ -269,7 +285,7 @@ function matchRule(cfg, ctx) {
269
285
  if (cfg.handler === "rule13a-backup") {
270
286
  const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args) && !/git\s+(push|commit)/i.test(cmd)));
271
287
  const highRiskWrite = (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p);
272
- if (highRiskWrite && unlock) return null;
288
+ if (highRiskWrite && unlock && !isHighRiskEntryFile(p)) return null;
273
289
  if (destructive || highRiskWrite) {
274
290
  const op = operationOf(name, args);
275
291
  // 备份动作本身(复制到 .bak/.backups/trash-)不需要再“先备份”
@@ -278,14 +294,16 @@ function matchRule(cfg, ctx) {
278
294
  // 含 shell 变量($var / %var%)的路径无法可靠解析 → 跳过机械备份检查(P0-2,防变量路径误拦)
279
295
  if (targetPath && isVariablePath(targetPath)) return null;
280
296
  // 已获 12A/12D 授权的操作 = 用户已明确确认本次操作 → 跳过 13A 机械备份(P1-2,一次授权覆盖全规则)
281
- if (findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op)) return null;
297
+ // 但高风险运行入口文件除外:即使已授权也必须有备份证据或明确提示
298
+ if (findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op) && !isHighRiskEntryFile(targetPath)) return null;
282
299
  // 复制/新建到“尚不存在”的目标文件:属于创建新文件,不适用 13A 覆盖备份要求
283
300
  const isCreateNewTarget = !highRiskWrite && cmd && /copy-item|new-item/i.test(cmd) && targetPath && !existsSync(targetPath);
284
301
  if (!isCreateNewTarget) {
285
302
  const backup = findBackupForPath(state, session.id, targetPath);
286
303
  if (!backup) {
287
304
  const existing = session.backups.map((b) => `${b.targetPath} -> ${b.backupPath}`).join(";") || "无";
288
- return makeHit(cfg, `【硬拦截】目标路径缺少对应备份(规则 13A):已有备份 [${existing}];本次目标 [${targetPath}]`);
305
+ const highRiskNote = isHighRiskEntryFile(targetPath) ? "(该文件不在自动备份范围,请先手动备份)" : "";
306
+ return makeHit(cfg, `【硬拦截】目标路径缺少对应备份(规则 13A)${highRiskNote}:已有备份 [${existing}];本次目标 [${targetPath}]`);
289
307
  }
290
308
  if (!existsSync(backup.backupPath)) {
291
309
  return makeHit(cfg, `【硬拦截】备份记录存在但备份文件不存在(规则 13A):${backup.backupPath}`);
@@ -89,7 +89,7 @@ function finalize(rule, lines) {
89
89
  export function extractElements(body) {
90
90
  const out = { trigger: "", check: "", action: "", exemption: "" };
91
91
  const grab = (label) => {
92
- const re = new RegExp(`\\*\\*${label}\\*\\*[::]\\s*([^\\n]*(?:\\n(?!\\s*\\*\\*)[^\\n]*)*)`, "i");
92
+ const re = new RegExp(`\\*\\*${label}(?:([^)]*))?\\*\\*[::]\\s*([^\\n]*(?:\\n(?!\\s*\\*\\*)[^\\n]*)*)`, "i");
93
93
  const m = body.match(re);
94
94
  return m ? m[1].trim() : "";
95
95
  };
@@ -108,7 +108,17 @@ export function isVariablePath(p) {
108
108
  return /\$[A-Za-z_][A-Za-z0-9_]*|%\w+%|\$\([^)]*\)/.test(p);
109
109
  }
110
110
 
111
- /** 从命令文本中提取“真正会被写入/删除的目标路径”(Copy-Item 只取 Destination,不再把源当写目标) */
111
+ /** 判断是否为“不在自动备份范围的高风险运行入口文件”(Electron 壳、启动脚本、CLI 入口等) */
112
+ export function isHighRiskEntryFile(p) {
113
+ if (typeof p !== "string") return false;
114
+ const n = p.replace(/\\/g, "/").toLowerCase();
115
+ if (/(?:^|[\\/])(?:dsh\.cmd|dsh\.ps1|dsh)$/i.test(n)) return true;
116
+ if (/(?:^|[\\/])main\.js$/i.test(n) && /(?:dsh-desktop|electron|dsh-web|dsh-client)/i.test(n)) return true;
117
+ if (/(?:^|[\\/])bin\.js$/i.test(n) && /@deepseek-ai[\\/]dsh[\\/]lib/i.test(n)) return true;
118
+ return /(?:^|[\\/])(?:startup|launcher|entry)[\\/][^\\/]+\.(?:js|mjs|cjs)$/i.test(n);
119
+ }
120
+
121
+ /** 从命令文本中提取“真正会被写入/删除的目标路径”(Copy-Item 只取 Destination;Set-Content 等只取 -Path/-LiteralPath/重定向目标,不再把值字符串里的路径当写目标) */
112
122
  export function writeTargetPathsFromCommand(command) {
113
123
  if (typeof command !== "string") return [];
114
124
  const cmd = command;
@@ -123,11 +133,30 @@ export function writeTargetPathsFromCommand(command) {
123
133
  return absolutePathTokens(cmd);
124
134
  }
125
135
  if (/(?:set-content|add-content|out-file|writealltext|new-item|remove-item|clear-content)/i.test(cmd)) {
126
- return absolutePathTokens(cmd);
136
+ return extractCommandWriteTargets(cmd);
127
137
  }
128
138
  return [];
129
139
  }
130
140
 
141
+ /** 从写类命令中提取真正的目标路径:优先 -Path/-LiteralPath/-FilePath/-Destination/-Target,其次重定向,最后取第一个绝对路径 */
142
+ function extractCommandWriteTargets(command) {
143
+ const targets = [];
144
+ const flagRe = /-(?:path|literalpath|filepath|destination|target)\s+/ig;
145
+ let m;
146
+ while ((m = flagRe.exec(command))) {
147
+ const rest = command.slice(m.index + m[0].length);
148
+ const toks = absolutePathTokens(rest);
149
+ if (toks.length > 0) targets.push(toks[0]);
150
+ }
151
+ const redirRe = /(?:^|[\s>])(?:>>|>)\s*["']?([A-Za-z]:[\\/][^"';\s]+)/g;
152
+ while ((m = redirRe.exec(command))) {
153
+ targets.push(m[1].trim());
154
+ }
155
+ if (targets.length > 0) return [...new Set(targets)];
156
+ const fallback = absolutePathTokens(command);
157
+ return fallback.length > 0 ? [fallback[0]] : [];
158
+ }
159
+
131
160
  function isBackupDestination(dest) {
132
161
  return /\.bak$/i.test(dest) || /\.backups[\\/]|trash-/i.test(dest);
133
162
  }
@@ -260,19 +289,22 @@ function isExecutableToken(tok) {
260
289
  return /\.(?:exe|cmd|bat|com|ps1|psm1|psd1|sh|bash|mjs|cjs)$/i.test(base);
261
290
  }
262
291
 
263
- /** 提取命令中的绝对路径(支持带引号含空格路径;去重;排除可执行程序本身与引号路径的前缀重复) */
292
+ /** 提取命令中的绝对路径(支持带引号含空格路径;去重;排除可执行程序本身、URL scheme 与引号路径的前缀重复) */
264
293
  export function absolutePathTokens(command) {
265
294
  if (typeof command !== "string") return [];
295
+ const isHttpScheme = (idx) => idx >= 4 && command.slice(idx - 4, idx).toLowerCase() === "http";
266
296
  const quoted = [];
267
297
  let m;
268
298
  QUOTED_ABS_PATH_RE.lastIndex = 0;
269
299
  while ((m = QUOTED_ABS_PATH_RE.exec(command))) {
300
+ if (isHttpScheme(m.index)) continue;
270
301
  const tok = m[1].trim();
271
302
  if (!isExecutableToken(tok)) quoted.push(tok);
272
303
  }
273
304
  const unquoted = [];
274
305
  ABS_PATH_RE.lastIndex = 0;
275
306
  while ((m = ABS_PATH_RE.exec(command))) {
307
+ if (isHttpScheme(m.index)) continue;
276
308
  const tok = m[0].trim();
277
309
  if (isExecutableToken(tok)) continue; // 程序名不是文件目标
278
310
  if (quoted.some((q) => q.toLowerCase().startsWith(tok.toLowerCase()))) continue;
@@ -312,8 +344,11 @@ export function isAssemblyMutationTool(toolName, args) {
312
344
  /** 判断命令是否为全量挂载审计脚本(规则 27 的“审计”侧);读取/搜索脚本内容不算执行审计 */
313
345
  export function isAuditCommand(command) {
314
346
  if (typeof command !== "string") return false;
315
- if (/(?:get-content|cat|type|select-string|findstr|grep|more)\b/i.test(command)) return false;
316
- return /\b(?:node|npm|npx|bun|deno)\s+[^\n]*audit-mount-consistency\.mjs/i.test(command);
347
+ // 只要真正执行 audit-mount-consistency.mjs(node/npm/npx/bun/deno 开头),即使后面带管道过滤也算审计
348
+ if (/\b(?:node|npm|npx|bun|deno)\s+[^\n]*audit-mount-consistency\.mjs/i.test(command)) return true;
349
+ // 读取/搜索审计脚本本身不是执行审计
350
+ if (/(?:get-content|cat|type|findstr|grep|more)\b/i.test(command)) return false;
351
+ return false;
317
352
  }
318
353
 
319
354
  /** 审计输出是否明确通过(无 DUPLICATES/INCONSISTENT/MISSING 且出现通过标记) */
@@ -92,6 +92,29 @@ function isSameLineReplacement(oldString, newString) {
92
92
  return lineAnchor(oldString) === lineAnchor(newString);
93
93
  }
94
94
 
95
+ /** 表格行内单元格替换:同一行首列一致即放行(如插件快照表格 1.4.5→1.4.6) */
96
+ function isTableRowReplacement(oldString, newString) {
97
+ if (!isSingleLine(oldString) || !isSingleLine(newString)) return false;
98
+ if (!isTableRow(oldString) || !isTableRow(newString)) return false;
99
+ const firstCell = (s) => {
100
+ const cells = s.trim().split("|").filter((c) => c.trim().length > 0);
101
+ return cells.length > 0 ? cells[0].trim() : "";
102
+ };
103
+ return firstCell(oldString) !== "" && firstCell(oldString) === firstCell(newString);
104
+ }
105
+
106
+ /** 中间插入多行段落:旧内容所有非空行按序出现在新内容中即放行 */
107
+ function isOrderedLineInsertion(oldString, newString) {
108
+ const oldLines = String(oldString || "").split(/\r?\n/).filter((l) => l.trim().length > 0);
109
+ const newLines = String(newString || "").split(/\r?\n/).filter((l) => l.trim().length > 0);
110
+ if (oldLines.length === 0) return true;
111
+ let i = 0;
112
+ for (const line of newLines) {
113
+ if (i < oldLines.length && line.trim() === oldLines[i].trim()) i++;
114
+ }
115
+ return i === oldLines.length;
116
+ }
117
+
95
118
  /** 校验 append/删除式编辑:新增需包含 old_string;删除/缩短时 new_string 可为 old_string 的子串;版本行重编号放行;非表格单行修改放行 */
96
119
  export function validateEditAppend(oldString, newString) {
97
120
  if (typeof oldString !== "string" || typeof newString !== "string") return { ok: true, errors: [] };
@@ -100,6 +123,8 @@ export function validateEditAppend(oldString, newString) {
100
123
  if (newString.includes(oldString)) return { ok: true, errors: [] };
101
124
  if (oldString.includes(newString)) return { ok: true, errors: [] };
102
125
  if (isSameLineReplacement(oldString, newString)) return { ok: true, errors: [] };
126
+ if (isTableRowReplacement(oldString, newString)) return { ok: true, errors: [] };
127
+ if (isOrderedLineInsertion(oldString, newString)) return { ok: true, errors: [] };
103
128
  return {
104
129
  ok: false,
105
130
  errors: ["new_string 未包含 old_string,疑似覆盖上一行"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rule-engine",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -38,7 +38,7 @@
38
38
  "audit:mount": "node scripts/audit-mount-consistency.mjs --profile web"
39
39
  },
40
40
  "peerDependencies": {
41
- "@deepseek-ai/dsh-typert-protocol": ">=0.0.1-rc.3"
41
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.3"
42
42
  },
43
43
  "dsh": {
44
44
  "bundle": {