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.
- package/lib/core/authorization.js +17 -7
- package/lib/core/guard-core.js +23 -5
- package/lib/core/parser.js +1 -1
- package/lib/core/patterns.js +40 -5
- package/lib/core/version-guard.js +25 -0
- package/package.json +2 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
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
|
}
|
package/lib/core/guard-core.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/lib/core/parser.js
CHANGED
|
@@ -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}
|
|
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
|
};
|
package/lib/core/patterns.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
316
|
-
|
|
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
|
+
"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
|
|
41
|
+
"@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.3"
|
|
42
42
|
},
|
|
43
43
|
"dsh": {
|
|
44
44
|
"bundle": {
|