dsh-rule-engine 0.3.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.
@@ -0,0 +1,87 @@
1
+ // mount-signature.js - 规则 27 装配状态哈希
2
+ // 用“装配内容”而不是单调 revision 判断是否需要重跑全量审计。
3
+ // 覆盖:profile bundles、本地 dependencies(link/file/github/http)、用户 patch、根 patch、bundle 内部 patch、运行时注入 registry。
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { dshHome } from "./paths.js";
8
+
9
+ function tryRead(p) {
10
+ try {
11
+ return existsSync(p) ? readFileSync(p, "utf8") : "";
12
+ } catch {
13
+ return "";
14
+ }
15
+ }
16
+
17
+ function resolveBundlePkg(b, profileDir, home) {
18
+ const candidates = [
19
+ join(profileDir, "node_modules", b, "package.json"),
20
+ join(home, "profiles", "node_modules", b, "package.json")
21
+ ];
22
+ if (b.startsWith("@")) {
23
+ const [scope, name] = b.split("/");
24
+ candidates.push(join(home, "profiles", "node_modules", scope, name, "package.json"));
25
+ }
26
+ for (const c of candidates) if (existsSync(c)) return c;
27
+ return null;
28
+ }
29
+
30
+ /** 从工具参数/命令中提取目标 profile 名(缺省 web) */
31
+ export function profileNameFromArgs(args = {}) {
32
+ const p = String(args.file_path || args.path || "");
33
+ const pm = p.match(/profiles[\\/]([^\\/]+)[\\/]/i);
34
+ if (pm) return pm[1];
35
+ if (args.profile) return String(args.profile);
36
+ const cmd = String(args.command || args.code || "");
37
+ let idx = cmd.indexOf("--profile");
38
+ if (idx >= 0) {
39
+ const rest = cmd.slice(idx + "--profile".length).trim();
40
+ const val = rest.replace(/^=/, "").split(/\s+/)[0];
41
+ if (val) return val;
42
+ }
43
+ return "web";
44
+ }
45
+
46
+ /** 计算装配状态哈希;profile 缺失时返回固定 missing 标记,避免误伤 */
47
+ export function computeMountSignature(profileName = "web") {
48
+ const home = dshHome();
49
+ const profileDir = join(home, "profiles", profileName);
50
+ const pkgPath = join(profileDir, "package.json");
51
+ if (!existsSync(pkgPath)) return `missing-profile:${profileName}`;
52
+ const hash = createHash("sha256");
53
+ let pkg;
54
+ try {
55
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
56
+ } catch (e) {
57
+ return `profile-parse-error:${profileName}:${e instanceof Error ? e.message : String(e)}`;
58
+ }
59
+ const bundles = pkg?.dsh?.profile?.bundles || [];
60
+ const deps = pkg?.dependencies || {};
61
+ hash.update("bundles:" + JSON.stringify(bundles));
62
+ // 只把会影响装配类型判断的本地依赖纳入哈希;普通 registry 版本变化不触发重审计
63
+ const localDeps = {};
64
+ for (const [name, spec] of Object.entries(deps)) {
65
+ if (typeof spec === "string" && /^(link:|file:|github:|http)/i.test(spec)) localDeps[name] = spec;
66
+ }
67
+ hash.update("localDeps:" + JSON.stringify(localDeps));
68
+ hash.update("profilePatch:" + tryRead(join(profileDir, "cordis.patch.yml")));
69
+ hash.update("rootPatch:" + tryRead(join(home, "cordis.patch.yml")));
70
+ hash.update("injectRegistry:" + tryRead(join(home, "super-injector", "registry.json")));
71
+ for (const b of bundles) {
72
+ const bp = resolveBundlePkg(b, profileDir, home);
73
+ if (!bp) {
74
+ hash.update("bundleMissing:" + b);
75
+ continue;
76
+ }
77
+ try {
78
+ const pkgB = JSON.parse(readFileSync(bp, "utf8"));
79
+ hash.update("bundlePkg:" + b + ":" + JSON.stringify({ version: pkgB.version, dsh: pkgB.dsh }));
80
+ const patchRel = pkgB?.dsh?.bundle?.patch;
81
+ if (patchRel) hash.update("bundlePatch:" + b + ":" + tryRead(join(dirname(bp), patchRel)));
82
+ } catch (e) {
83
+ hash.update("bundlePkgError:" + b + ":" + (e instanceof Error ? e.message : String(e)));
84
+ }
85
+ }
86
+ return hash.digest("hex");
87
+ }
@@ -0,0 +1,101 @@
1
+ // parser.js - 解析 AGENTS.md 规则容器。
2
+ // 纯 Node,可独立测试。把 AGENTS.md 解析为规则列表,规则正文保留四要素原文。
3
+ import { readFileSync, statSync } from "node:fs";
4
+ import { agentsFilePath } from "./paths.js";
5
+
6
+ const RULE_HEADER_RE = /^###\s*\[规则\s*([0-9A-Za-z]+)\]\s*(.+?)\s*$/;
7
+ const SECTION_RE = /^##\s+(.+?)\s*$/;
8
+ const FREE_ZONE_START_RE = /^\s*<!--\s*free-zone:start\s*-->\s*$/;
9
+ const FREE_ZONE_END_RE = /^\s*<!--\s*free-zone:end\s*-->\s*$/;
10
+
11
+ /**
12
+ * 从 AGENTS.md 解析全部规则。
13
+ * @returns {{ok: boolean, missing: boolean, error?: string, rules: Array, raw: string, mtimeMs: number}}
14
+ */
15
+ export function loadRules() {
16
+ const file = agentsFilePath();
17
+ let raw;
18
+ let mtimeMs = 0;
19
+ try {
20
+ raw = readFileSync(file, "utf8");
21
+ mtimeMs = statSync(file).mtimeMs;
22
+ } catch (error) {
23
+ if (error && error.code === "ENOENT") {
24
+ return { ok: false, missing: true, error: `未找到 ${file}`, rules: [], raw: "", mtimeMs: 0 };
25
+ }
26
+ return { ok: false, missing: false, error: String(error), rules: [], raw: "", mtimeMs: 0 };
27
+ }
28
+ const bom = raw.charCodeAt(0) === 0xfeff;
29
+ const text = bom ? raw.slice(1) : raw;
30
+ const lines = text.split("\n");
31
+ const rules = [];
32
+ let currentSection = "未分区";
33
+ let current = null;
34
+ let inFreeZone = false;
35
+ for (let i = 0; i < lines.length; i++) {
36
+ const line = lines[i];
37
+ // 自由区域边界:区内内容完全不解析(不产生规则、不切换分区),对引擎透明
38
+ if (FREE_ZONE_START_RE.test(line)) {
39
+ if (current) rules.push(finalize(current, lines));
40
+ current = null;
41
+ inFreeZone = true;
42
+ continue;
43
+ }
44
+ if (FREE_ZONE_END_RE.test(line)) {
45
+ inFreeZone = false;
46
+ continue;
47
+ }
48
+ if (inFreeZone) continue;
49
+ const sec = line.match(SECTION_RE);
50
+ if (sec) {
51
+ if (current) rules.push(finalize(current, lines));
52
+ currentSection = sec[1].trim();
53
+ current = null;
54
+ continue;
55
+ }
56
+ const rule = line.match(RULE_HEADER_RE);
57
+ if (rule) {
58
+ if (current) rules.push(finalize(current, lines));
59
+ current = {
60
+ index: rule[1],
61
+ title: rule[2].trim(),
62
+ section: currentSection,
63
+ startLine: i,
64
+ endLine: i + 1
65
+ };
66
+ } else if (current) {
67
+ current.endLine = i + 1;
68
+ }
69
+ }
70
+ if (current) rules.push(finalize(current, lines));
71
+ return { ok: true, missing: false, error: null, rules, raw: text, mtimeMs };
72
+ }
73
+
74
+ function finalize(rule, lines) {
75
+ const body = lines.slice(rule.startLine + 1, rule.endLine).join("\n").trim();
76
+ const levelMatch = rule.title.match(/执行等级[::]\s*([A-DM+]+(?:\s*[强弱])?)/);
77
+ return {
78
+ index: rule.index,
79
+ title: rule.title,
80
+ section: rule.section,
81
+ startLine: rule.startLine,
82
+ endLine: rule.endLine,
83
+ level: levelMatch ? levelMatch[1].replace(/\s+/g, "") : "",
84
+ body
85
+ };
86
+ }
87
+
88
+ /** 从规则正文提取四要素 */
89
+ export function extractElements(body) {
90
+ const out = { trigger: "", check: "", action: "", exemption: "" };
91
+ const grab = (label) => {
92
+ const re = new RegExp(`\\*\\*${label}\\*\\*[::]\\s*([^\\n]*(?:\\n(?!\\s*\\*\\*)[^\\n]*)*)`, "i");
93
+ const m = body.match(re);
94
+ return m ? m[1].trim() : "";
95
+ };
96
+ out.trigger = grab("触发");
97
+ out.check = grab("检查");
98
+ out.action = grab("动作");
99
+ out.exemption = grab("豁免");
100
+ return out;
101
+ }
@@ -0,0 +1,24 @@
1
+ // paths.js - 解析 DSH 用户目录。
2
+ // 不依赖 @deepseek-ai/dsh-home-paths,优先使用 DSH_HOME 环境变量,回退到 ~/.dsh。
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export function dshHome() {
7
+ return process.env.DSH_HOME || join(homedir(), ".dsh");
8
+ }
9
+
10
+ export function agentsFilePath() {
11
+ return join(dshHome(), "AGENTS.md");
12
+ }
13
+
14
+ export function auditFilePath() {
15
+ return join(dshHome(), "rule-engine.log.jsonl");
16
+ }
17
+
18
+ export function configFilePath() {
19
+ return join(dshHome(), "rule-engine.json");
20
+ }
21
+
22
+ export function understandingFilePath() {
23
+ return join(dshHome(), "rule-understanding.json");
24
+ }
@@ -0,0 +1,351 @@
1
+ // patterns.js - 规则引擎共享的正则与常量。
2
+ // 注意:本文件只放可测试的纯函数/常量,不依赖 Cordis。
3
+
4
+ export const INLINE_CMD =
5
+ /\b(?:node|pwsh|powershell)\s+(?:-[ep]|--eval|--print|-Command|-c)\b/i;
6
+
7
+ export const BOM_WRITE =
8
+ /(?:set-content|add-content|out-file|writealltext)[\s\S]{0,300}?(?:-Encoding\s+UTF8|utf8)[\s\S]{0,300}?\.(?:json|ya?ml)\b|(?:set-content|add-content|out-file|writealltext)[\s\S]{0,300}?\.(?:json|ya?ml)\b[\s\S]{0,300}?(?:-Encoding\s+UTF8|utf8)/i;
9
+
10
+ export const DESTRUCTIVE_CMD =
11
+ /(?:remove-item|rm\s+-r|rmdir\s+\/s|rd\s+\/s|del\s+(?:\/[a-z]+\s+)*\/[a-z]*s[a-z]*|move-item|rename-item|copy-item\s+[^\n]*?(?:-\s*force|overwrite))/i;
12
+
13
+ export const SENSITIVE_CMD =
14
+ /(?:git\s+(?:push|commit)|remove-item|rm\s+-r|rmdir\s+\/s|rd\s+\/s|del\s+\/s|move-item|rename-item|copy-item\s+[^\n]*?(?:-\s*force|overwrite))/i;
15
+
16
+ export const CONFIG_FILE_RE =
17
+ /(?:^|[\\/])(?:AGENTS\.md|settings\.yaml|\.credentials\.yaml|workspace\.json|cordis\.patch\.yml|rule-understanding\.json|rule-guard\.json|rule-engine\.json)$/i;
18
+
19
+ export const DATA_DIR_RE =
20
+ /(?:^|[\\/])\.dsh[\\/](?:sessions|storages|\.backups)[\\/]/i;
21
+
22
+ export const TIME_WORDS =
23
+ /今天|昨天|前天|上周|本周|刚才|\d+\s*分钟前|\d{1,2}\s*月\s*\d{1,2}\s*日|\d{4}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日/;
24
+
25
+ export const PROMISE_WORDS =
26
+ /包在我身上|肯定能|绝对没问题|保证(?!不|无法)|一定可以|放心(?:,|,)?肯定|万无一失/;
27
+
28
+ export const URL_RE = /https?:\/\/[^\s]+/i;
29
+
30
+ export const SOURCE_MARK = /来源|出处|via|source|reference|引自|参考/i;
31
+
32
+ export const CJK_RE = /[\u4e00-\u9fff]/;
33
+
34
+ export const MANUAL_PATH_RE = /dsh-usage-manual[\\/]SKILL\.md/i;
35
+
36
+ export const DSH_KEYWORDS_RE =
37
+ /DSH|dsh|插件|技能|规则|配置|迁移|手册|会话|装配|profile|bundle/i;
38
+
39
+ export const SKILL_EXEMPT = new Set(["dsh-usage-manual", "task-planner"]);
40
+
41
+ export const SELF_PROTECT_PATHS = [
42
+ "**/rule-engine.json",
43
+ "**/rule-understanding.json",
44
+ "**/rule-guard.json"
45
+ ];
46
+
47
+ /** 判断一条命令文本是否是「读取手册」类命令 */
48
+ export function isManualReadCommand(command) {
49
+ if (typeof command !== "string") return false;
50
+ return MANUAL_PATH_RE.test(command) && /get-content|cat|type|read|grep|findstr|str_replace_editor/i.test(command);
51
+ }
52
+
53
+ /** 判断一次工具调用是否算作「已读手册」 */
54
+ export function isManualReadTool(toolName, args) {
55
+ const name = String(toolName || "");
56
+ if (name === "read" || name === "grep" || name === "str_replace_editor") {
57
+ const p = String(args?.file_path || args?.path || args?.pattern || "");
58
+ if (MANUAL_PATH_RE.test(p)) return true;
59
+ }
60
+ if (name === "pwsh" || name === "bash") {
61
+ return isManualReadCommand(args?.command || args?.code || "");
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /** 从工具参数中提取目标路径(edit/write/read 等) */
67
+ export function pathTarget(args) {
68
+ if (!args || typeof args !== "object") return null;
69
+ const p = args.file_path ?? args.path;
70
+ return typeof p === "string" ? p : null;
71
+ }
72
+
73
+ /** 从工具参数中提取命令文本(pwsh/bash) */
74
+ export function commandText(args) {
75
+ if (!args || typeof args !== "object") return null;
76
+ const c = args.command ?? args.code;
77
+ return typeof c === "string" ? c : null;
78
+ }
79
+
80
+ /** 判断是否命中配置文件保护路径 */
81
+ export function isProtectedConfigPath(p) {
82
+ if (typeof p !== "string") return false;
83
+ const n = p.replace(/\\/g, "/").toLowerCase();
84
+ return CONFIG_FILE_RE.test(n) || DATA_DIR_RE.test(n);
85
+ }
86
+
87
+ /** 从 Copy-Item 命令中提取源与目标路径(优先支持带引号/空格的 Windows 路径) */
88
+ export function extractCopyPaths(command) {
89
+ if (typeof command !== "string") return null;
90
+ const quoted = command.match(
91
+ /(?:copy-item\s+)?(?:-literalpath|-path)?\s*["']([^"']+)["']\s+(?:-destination\s*)?["']([^"']+)["']/i
92
+ );
93
+ if (quoted && quoted[1] && quoted[2]) {
94
+ return { source: quoted[1].trim(), dest: quoted[2].trim() };
95
+ }
96
+ const simple = command.match(
97
+ /copy-item\s+(?:-literalpath|-path)?\s*([^\s"']+)\s+(?:-destination\s*)?([^\s"']+)/i
98
+ );
99
+ if (simple && simple[1] && simple[2]) {
100
+ return { source: simple[1].trim(), dest: simple[2].trim() };
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /** 判断路径是否含 shell 变量($var / ${var} / %var%),含变量的路径无法可靠解析为真实目标 */
106
+ export function isVariablePath(p) {
107
+ if (typeof p !== "string") return false;
108
+ return /\$[A-Za-z_][A-Za-z0-9_]*|%\w+%|\$\([^)]*\)/.test(p);
109
+ }
110
+
111
+ /** 从命令文本中提取“真正会被写入/删除的目标路径”(Copy-Item 只取 Destination,不再把源当写目标) */
112
+ export function writeTargetPathsFromCommand(command) {
113
+ if (typeof command !== "string") return [];
114
+ const cmd = command;
115
+ if (/\bcopy-item\b/i.test(cmd)) {
116
+ const cp = extractCopyPaths(cmd);
117
+ if (cp && cp.dest) return [cp.dest];
118
+ // 解析失败时保守返回全部绝对路径,避免漏拦
119
+ return absolutePathTokens(cmd);
120
+ }
121
+ if (/\b(?:move-item|rename-item)\b/i.test(cmd)) {
122
+ // 移动/重命名:源被删除、目标被写入,保守都算写目标
123
+ return absolutePathTokens(cmd);
124
+ }
125
+ if (/(?:set-content|add-content|out-file|writealltext|new-item|remove-item|clear-content)/i.test(cmd)) {
126
+ return absolutePathTokens(cmd);
127
+ }
128
+ return [];
129
+ }
130
+
131
+ function isBackupDestination(dest) {
132
+ return /\.bak$/i.test(dest) || /\.backups[\\/]|trash-/i.test(dest);
133
+ }
134
+
135
+ /** 从备份工具调用推导目标路径与备份路径;无法推导或非备份目标返回 null */
136
+ export function backupPathsFromTool(toolName, args) {
137
+ const name = String(toolName || "");
138
+ const p = pathTarget(args);
139
+ const cmd = commandText(args);
140
+ if (name === "pwsh" || name === "bash") {
141
+ const paths = extractCopyPaths(cmd || "");
142
+ if (paths && /^[a-z]:[\\/]/i.test(paths.source) && /^[a-z]:[\\/]/i.test(paths.dest) && isBackupDestination(paths.dest)) {
143
+ return { targetPath: paths.source, backupPath: paths.dest };
144
+ }
145
+ }
146
+ if (p) {
147
+ if (/\.bak$/i.test(p)) {
148
+ return { targetPath: p.replace(/\.bak$/i, ""), backupPath: p };
149
+ }
150
+ if (/\.backups[\\/]|trash-/i.test(p)) {
151
+ return { targetPath: p, backupPath: p };
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+
157
+ /** 判断命令是否包含备份动作(简单启发式) */
158
+ export function isBackupCommand(command) {
159
+ if (typeof command !== "string") return false;
160
+ return /backup|\.backups|copy-item[^\n]*\.bak|robocopy[^\n]*\/e|copy-item[^\n]*trash-/i.test(command);
161
+ }
162
+
163
+ /** 判断一次工具调用是否算作备份动作 */
164
+ export function isBackupTool(toolName, args) {
165
+ const name = String(toolName || "");
166
+ const p = pathTarget(args);
167
+ if (p && /\.backups[\\/]|trash-|\.bak$/i.test(p)) return true;
168
+ if (name === "pwsh" || name === "bash") return isBackupCommand(commandText(args) || "");
169
+ return false;
170
+ }
171
+
172
+ /** 判断一次工具调用是否是授权询问 */
173
+ export function isAskTool(toolName) {
174
+ return String(toolName || "") === "ask_user_question";
175
+ }
176
+
177
+ /** 判断命令是否包含 Get-Date 核对 */
178
+ export function isGetDateCommand(command) {
179
+ return typeof command === "string" && /\bget-date\b/i.test(command);
180
+ }
181
+
182
+ const PS1_FILE_RE = /\.(?:ps1|psm1|psd1)(?=[\s'"`]|$)/i;
183
+
184
+ /** 判断是否命中规则 9 的「含中文 .ps1 未按 UTF-8 带 BOM」硬拦项 */
185
+ export function isChinesePs1Violation(toolName, args) {
186
+ const name = String(toolName || "");
187
+ const p = pathTarget(args);
188
+ const cmd = commandText(args);
189
+ const hasCJK = (s) => typeof s === "string" && CJK_RE.test(s);
190
+ if ((name === "write" || name === "edit") && p && PS1_FILE_RE.test(p)) {
191
+ const content = args?.content ?? args?.new_string ?? "";
192
+ if (hasCJK(content)) return true;
193
+ }
194
+ if ((name === "pwsh" || name === "bash") && cmd && PS1_FILE_RE.test(cmd) && hasCJK(cmd)) {
195
+ if (/(?:-Encoding\s+UTF8|utf8)/i.test(cmd)) return false;
196
+ return true;
197
+ }
198
+ return false;
199
+ }
200
+
201
+ const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "read_image"]);
202
+ const READONLY_CMD_RE =
203
+ /\b(?:Get-Content|Get-ChildItem|Get-Item|Get-Command|Get-Date|Select-String|Find-String|Test-Path|Get-Process|Get-Service|cat|type|dir|ls|grep|findstr|more|netstat|where)\b/i;
204
+ const MUTATING_CMD_RE =
205
+ /(?:Set-Content|Add-Content|Out-File|Remove-Item|Move-Item|Copy-Item|Rename-Item|New-Item|Clear-Content|git\s+(?:push|commit)|rm\s+-r|rmdir\s+\/s|del\s+\/s|>|>>)/i;
206
+
207
+ /** 判断命令文本是否只读(读文件/查询类,无写入/删除/提交副作用) */
208
+ export function isReadOnlyCommand(command) {
209
+ if (typeof command !== "string") return false;
210
+ if (MUTATING_CMD_RE.test(command)) return false;
211
+ return READONLY_CMD_RE.test(command);
212
+ }
213
+
214
+ /** 判断一次工具调用是否只读(必须无条件放行) */
215
+ export function isReadOnlyTool(toolName, args) {
216
+ const name = String(toolName || "");
217
+ if (READ_ONLY_TOOLS.has(name)) return true;
218
+ if (name === "str_replace_editor" && args?.command === "view") return true;
219
+ if (name === "pwsh" || name === "bash") {
220
+ return isReadOnlyCommand(commandText(args) || "");
221
+ }
222
+ return false;
223
+ }
224
+
225
+ let workspaceRootOverride = "";
226
+
227
+ /** 由插件 apply 阶段从 workspaceRegistry 设置工作区根目录 */
228
+ export function setWorkspaceRoot(p) {
229
+ workspaceRootOverride = p || "";
230
+ }
231
+
232
+ function workspaceRoot() {
233
+ return workspaceRootOverride || process.env.DSH_WORKSPACE || "";
234
+ }
235
+
236
+ function isPathInside(target, root) {
237
+ const t = normalizePathForCompare(target);
238
+ const r = normalizePathForCompare(root);
239
+ if (!t || !r) return false;
240
+ return t === r || t.startsWith(r.endsWith("/") ? r : r + "/");
241
+ }
242
+
243
+ function normalizePathForCompare(p) {
244
+ return String(p).replace(/\\/g, "/").toLowerCase().replace(/\/+$/, "");
245
+ }
246
+
247
+ /** 判断路径是否在工作区外(用于“工作区外写入”敏感判定) */
248
+ export function isOutsideWorkspace(p) {
249
+ if (typeof p !== "string") return false;
250
+ if (!/^[a-z]:[\\/]/i.test(p) && !p.startsWith("/")) return false; // 只判断绝对路径
251
+ return !isPathInside(p, workspaceRoot());
252
+ }
253
+
254
+ const ABS_PATH_RE = /[A-Za-z]:[\\/][^\s'"`,。;:!?()【】《》、]+/g;
255
+ const QUOTED_ABS_PATH_RE = /["']([A-Za-z]:[\\/][^"']+)["']/g;
256
+
257
+ /** 判断绝对路径 token 是否是可执行程序(命令本身,非文件目标) */
258
+ function isExecutableToken(tok) {
259
+ const base = tok.replace(/\\/g, "/").split("/").pop() || "";
260
+ return /\.(?:exe|cmd|bat|com|ps1|psm1|psd1|sh|bash|mjs|cjs)$/i.test(base);
261
+ }
262
+
263
+ /** 提取命令中的绝对路径(支持带引号含空格路径;去重;排除可执行程序本身与引号路径的前缀重复) */
264
+ export function absolutePathTokens(command) {
265
+ if (typeof command !== "string") return [];
266
+ const quoted = [];
267
+ let m;
268
+ QUOTED_ABS_PATH_RE.lastIndex = 0;
269
+ while ((m = QUOTED_ABS_PATH_RE.exec(command))) {
270
+ const tok = m[1].trim();
271
+ if (!isExecutableToken(tok)) quoted.push(tok);
272
+ }
273
+ const unquoted = [];
274
+ ABS_PATH_RE.lastIndex = 0;
275
+ while ((m = ABS_PATH_RE.exec(command))) {
276
+ const tok = m[0].trim();
277
+ if (isExecutableToken(tok)) continue; // 程序名不是文件目标
278
+ if (quoted.some((q) => q.toLowerCase().startsWith(tok.toLowerCase()))) continue;
279
+ unquoted.push(tok);
280
+ }
281
+ return [...new Set([...quoted, ...unquoted])];
282
+ }
283
+
284
+ function commandHasOutsideWrite(cmd) {
285
+ if (typeof cmd !== "string") return false;
286
+ return writeTargetPathsFromCommand(cmd).some((p) => isOutsideWorkspace(p));
287
+ }
288
+
289
+ export const GENERIC_EXEC_TOOLS = new Set([
290
+ "dev_stage_add",
291
+ "dev_stage_call",
292
+ "dev_stage_promote",
293
+ "dev_stage_demote"
294
+ ]);
295
+
296
+ const ASSEMBLY_TOOLS = new Set(["dev_install_package", "dev_inject_plugin", "dev_uninject_plugin"]);
297
+ const ASSEMBLY_PATH_RE = /cordis\.patch\.yml|dsh\.profile\.bundles|profiles[\\/][^\\/]+[\\/]package\.json$/i;
298
+ const ASSEMBLY_CMD_RE = /(?:cordis\.patch\.yml|dsh\.profile\.bundles)/i;
299
+ const ASSEMBLY_WRITE_CMD_RE = /(?:set-content|add-content|out-file|writealltext|copy-item|move-item|rename-item|remove-item|new-item)/i;
300
+
301
+ /** 判断一次工具调用是否属于 DSH 插件装配变更(规则 27 C 时序的“变更”侧) */
302
+ export function isAssemblyMutationTool(toolName, args) {
303
+ const name = String(toolName || "");
304
+ if (ASSEMBLY_TOOLS.has(name)) return true;
305
+ const p = pathTarget(args);
306
+ const cmd = commandText(args);
307
+ if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && p && ASSEMBLY_PATH_RE.test(p)) return true;
308
+ if ((name === "pwsh" || name === "bash") && cmd && ASSEMBLY_CMD_RE.test(cmd) && ASSEMBLY_WRITE_CMD_RE.test(cmd)) return true;
309
+ return false;
310
+ }
311
+
312
+ /** 判断命令是否为全量挂载审计脚本(规则 27 的“审计”侧);读取/搜索脚本内容不算执行审计 */
313
+ export function isAuditCommand(command) {
314
+ 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);
317
+ }
318
+
319
+ /** 审计输出是否明确通过(无 DUPLICATES/INCONSISTENT/MISSING 且出现通过标记) */
320
+ export function auditOutputPassed(output) {
321
+ const text = String(output || "");
322
+ return /MOUNT CONSISTENT|NO duplicate loader entry ids found/i.test(text) && !/DUPLICATES FOUND/i.test(text) && !/INCONSISTENT/i.test(text) && !/\[MISSING\]/i.test(text);
323
+ }
324
+
325
+ /** 审计输出是否明确发现重复/缺失/不一致(含 3.6 缺失检测的 INCONSISTENT 与 [MISSING] 标记) */
326
+ export function auditOutputFailed(output) {
327
+ return /DUPLICATES FOUND|INCONSISTENT|\[MISSING\]/i.test(String(output || ""));
328
+ }
329
+
330
+ /** 判断是否命中敏感操作(需要授权证据;只读操作永远不算) */
331
+ export function isSensitiveToolCall(toolName, args) {
332
+ if (isReadOnlyTool(toolName, args)) return false;
333
+ const name = String(toolName || "");
334
+ const p = pathTarget(args);
335
+ const cmd = commandText(args);
336
+ if (isProtectedConfigPath(p)) {
337
+ // 只有变更类工具才受配置写保护;read/grep/glob 已在上方放行
338
+ if (name === "edit" || name === "write" || name === "pwsh" || name === "bash") return true;
339
+ }
340
+ if (name === "pwsh" || name === "bash") {
341
+ if (cmd && (SENSITIVE_CMD.test(cmd) || /(?:AGENTS\.md|settings\.yaml|\.credentials\.yaml|workspace\.json|cordis\.patch\.yml|rule-understanding\.json|rule-engine\.json)/i.test(cmd) || commandHasOutsideWrite(cmd))) return true;
342
+ }
343
+ if (name === "edit" || name === "write") {
344
+ if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p))) return true;
345
+ }
346
+ if (name === "str_replace_editor" && args?.command !== "view") {
347
+ if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p))) return true;
348
+ }
349
+ if (GENERIC_EXEC_TOOLS.has(name)) return true;
350
+ return false;
351
+ }
@@ -0,0 +1,5 @@
1
+ // runtime.js - 规则引擎运行时单例状态。
2
+ // index.js(守卫/事件)与 service.js(设置面板/远程服务)共享同一份 state。
3
+ import { createState } from "./state.js";
4
+
5
+ export const state = createState();
@@ -0,0 +1,44 @@
1
+ // silent-error.js - 命令输出“静默错误”检测(P2)
2
+ // 纯函数,可独立测试。
3
+ const SUSPICIOUS_LINE_RE = /^(false|0|null|undefined|)$/i;
4
+
5
+ /** 从工具结果中提取纯文本输出(兼容 tool/result 的 message.content[0].content 结构) */
6
+ export function extractToolOutput(result) {
7
+ if (!result) return "";
8
+ const msg = result?.message ?? result;
9
+ const content = Array.isArray(msg?.content)
10
+ ? msg.content
11
+ : Array.isArray(result?.content)
12
+ ? result.content
13
+ : null;
14
+ if (!content) return "";
15
+ const block = content.find((b) => b && b.type === "tool-result");
16
+ if (!block) return "";
17
+ const inner = Array.isArray(block.content) ? block.content : [];
18
+ return inner
19
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
20
+ .map((b) => b.text)
21
+ .join("\n");
22
+ }
23
+
24
+ /**
25
+ * 检测可疑静默错误。
26
+ * @param {string} output 本次命令输出
27
+ * @param {string} previousOutput 上一条命令输出
28
+ * @returns {{suspicious: boolean, reason?: string}}
29
+ */
30
+ export function detectSilentError(output, previousOutput = "") {
31
+ const text = String(output || "").trim();
32
+ if (!text) return { suspicious: false, reason: undefined };
33
+
34
+ const lines = text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
35
+ if (lines.length <= 3 && lines.every((l) => SUSPICIOUS_LINE_RE.test(l))) {
36
+ return { suspicious: true, reason: "命令输出全为 false/0/null/undefined 等可疑值" };
37
+ }
38
+
39
+ if (previousOutput && text === String(previousOutput).trim()) {
40
+ return { suspicious: true, reason: "命令输出与上一条完全一致,疑似静默错误" };
41
+ }
42
+
43
+ return { suspicious: false, reason: undefined };
44
+ }