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.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/cordis.patch.yml +7 -0
- package/lib/core/audit.js +44 -0
- package/lib/core/authorization.js +227 -0
- package/lib/core/config.js +30 -0
- package/lib/core/guard-core.js +453 -0
- package/lib/core/llm-understander.js +104 -0
- package/lib/core/matcher.js +50 -0
- package/lib/core/mount-signature.js +87 -0
- package/lib/core/parser.js +101 -0
- package/lib/core/paths.js +24 -0
- package/lib/core/patterns.js +351 -0
- package/lib/core/runtime.js +5 -0
- package/lib/core/silent-error.js +44 -0
- package/lib/core/state.js +185 -0
- package/lib/core/text-detect.js +185 -0
- package/lib/core/understander.js +127 -0
- package/lib/core/understanding-store.js +27 -0
- package/lib/core/version-guard.js +117 -0
- package/lib/index.js +648 -0
- package/lib/service.js +174 -0
- package/package.json +48 -0
- package/scripts/audit-mount-consistency.mjs +198 -0
- package/scripts/build.sh +13 -0
- package/scripts/check-real.mjs +24 -0
- package/upgrade-impact.json +55 -0
|
@@ -0,0 +1,453 @@
|
|
|
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
|
+
SKILL_EXEMPT,
|
|
13
|
+
commandText,
|
|
14
|
+
isAssemblyMutationTool,
|
|
15
|
+
isBackupTool,
|
|
16
|
+
isChinesePs1Violation,
|
|
17
|
+
isManualReadTool,
|
|
18
|
+
isProtectedConfigPath,
|
|
19
|
+
isReadOnlyTool,
|
|
20
|
+
isSensitiveToolCall,
|
|
21
|
+
isVariablePath,
|
|
22
|
+
pathTarget
|
|
23
|
+
} from "./patterns.js";
|
|
24
|
+
import { describeAuth, describeOp, findMatchingAuth, operationOf } from "./authorization.js";
|
|
25
|
+
import { computeMountSignature, profileNameFromArgs } from "./mount-signature.js";
|
|
26
|
+
import { findBackupForPath, getSessionState, maybeReloadIfChanged } from "./state.js";
|
|
27
|
+
import { isVersionedFile, validateEditedFile } from "./version-guard.js";
|
|
28
|
+
|
|
29
|
+
function sessionIdOf(exec) {
|
|
30
|
+
const agent = exec?.agent;
|
|
31
|
+
if (!agent) return "global";
|
|
32
|
+
if (typeof agent.session === "object" && agent.session?.id) return agent.session.id;
|
|
33
|
+
if (typeof agent.session === "string") return agent.session;
|
|
34
|
+
return "global";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RULE_HINTS = {
|
|
38
|
+
"1": "先分析根因,确认问题后再继续",
|
|
39
|
+
"9": "改用脚本文件或显式 UTF-8 BOM 流程",
|
|
40
|
+
"12A": "先 ask_user_question 获取匹配授权",
|
|
41
|
+
"12D": "先 ask_user_question 获取匹配授权",
|
|
42
|
+
"13A": "先对目标路径执行备份(复制到 .bak/.backups/trash-)",
|
|
43
|
+
"18": "先读取 ~/.dsh/skills/dsh-usage-manual/SKILL.md",
|
|
44
|
+
"21": "按规则 21 分级确认后再落盘",
|
|
45
|
+
"24": "确认插件 dsh.bundle 类型或改用正确挂载",
|
|
46
|
+
"25": "将该工具纳入统一守卫覆盖",
|
|
47
|
+
"27": "先运行 node scripts/audit-mount-consistency.mjs --profile web"
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function makeHit(cfg, reason) {
|
|
51
|
+
const errId = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
52
|
+
const hint = RULE_HINTS[String(cfg.ruleId)] || "见 /guard rules";
|
|
53
|
+
return {
|
|
54
|
+
ruleId: cfg.ruleId,
|
|
55
|
+
title: cfg.title,
|
|
56
|
+
action: "deny",
|
|
57
|
+
reason: `${reason}(规则 ${cfg.ruleId}|放行:${hint}|ERR-${errId})`
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const COVERED_MUTATION_TOOLS = new Set([
|
|
62
|
+
"edit", "write", "str_replace_editor", "pwsh", "bash",
|
|
63
|
+
"dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote"
|
|
64
|
+
]);
|
|
65
|
+
const SAFE_UNCOVERED_TOOLS = new Set([
|
|
66
|
+
"ask_user_question", "todo_write", "subagent", "workflow", "visualize", "skill",
|
|
67
|
+
"read", "grep", "glob", "read_image", "job_list", "job_output", "list_agents",
|
|
68
|
+
"get_goal", "dev_plugin_status", "dev_reload_package", "dev_injected_list",
|
|
69
|
+
"dev_stage_list", "dev_router_status", "dev_self_test"
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function looksLikeFileMutation(name, args) {
|
|
73
|
+
const a = args || {};
|
|
74
|
+
return Boolean(a.file_path || a.path || a.command || a.code || a.execute || a.script || a.fn);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isProfilePackageJson(p) {
|
|
78
|
+
return typeof p === "string" && /profiles[\\/][^\\/]+[\\/]package\.json$/i.test(p);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 计算 edit/write/str_replace 后的目标文件内容;无法可靠计算时返回 null */
|
|
82
|
+
function resultingFileContent(name, args) {
|
|
83
|
+
const p = pathTarget(args);
|
|
84
|
+
if (!p) return null;
|
|
85
|
+
if (name === "write") return typeof args?.content === "string" ? args.content : null;
|
|
86
|
+
if (!existsSync(p)) return null;
|
|
87
|
+
let current;
|
|
88
|
+
try { current = readFileSync(p, "utf8"); } catch { return null; }
|
|
89
|
+
if (name === "edit") {
|
|
90
|
+
const oldS = args?.old_string;
|
|
91
|
+
const newS = args?.new_string;
|
|
92
|
+
if (typeof oldS === "string" && typeof newS === "string" && current.includes(oldS)) return current.replace(oldS, newS);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (name === "str_replace_editor" && args?.command === "str_replace") {
|
|
96
|
+
const oldS = args?.old_str;
|
|
97
|
+
const newS = args?.new_str;
|
|
98
|
+
if (typeof oldS === "string" && typeof newS === "string" && current.includes(oldS)) return current.replace(oldS, newS);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 从 profile 目录解析 bundle 的 package.json(兼容 profiles/web/node_modules 与 profiles/node_modules) */
|
|
105
|
+
function resolveBundlePkgPath(bundleName, profilePkgPath) {
|
|
106
|
+
const profileDir = dirname(profilePkgPath);
|
|
107
|
+
const profilesNodeModules = join(dirname(profileDir), "node_modules");
|
|
108
|
+
const candidates = [];
|
|
109
|
+
if (bundleName.startsWith("@")) {
|
|
110
|
+
const [scope, name] = bundleName.split("/");
|
|
111
|
+
candidates.push(join(profileDir, "node_modules", scope, name, "package.json"));
|
|
112
|
+
candidates.push(join(profilesNodeModules, scope, name, "package.json"));
|
|
113
|
+
} else {
|
|
114
|
+
candidates.push(join(profileDir, "node_modules", bundleName, "package.json"));
|
|
115
|
+
candidates.push(join(profilesNodeModules, bundleName, "package.json"));
|
|
116
|
+
}
|
|
117
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 从 profile package.json 的 dependencies 中解析本地 link/file 依赖的包路径;无法解析返回 null */
|
|
122
|
+
function resolveLocalDependencyPkgPath(bundleName, profilePkgPath, parsed) {
|
|
123
|
+
const dep = parsed?.dependencies?.[bundleName] ?? parsed?.devDependencies?.[bundleName] ?? parsed?.optionalDependencies?.[bundleName];
|
|
124
|
+
if (typeof dep !== "string") return null;
|
|
125
|
+
let localPath = null;
|
|
126
|
+
if (dep.startsWith("link:")) localPath = dep.slice(5);
|
|
127
|
+
else if (dep.startsWith("file:")) localPath = dep.slice(5);
|
|
128
|
+
if (!localPath) return null;
|
|
129
|
+
const resolved = isAbsolute(localPath) ? localPath : resolve(dirname(profilePkgPath), localPath);
|
|
130
|
+
const pkgPath = join(resolved, "package.json");
|
|
131
|
+
return existsSync(pkgPath) ? pkgPath : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 若本次文件变更会写入 profile package.json 的 dsh.profile.bundles,返回其中非 bundle/无法确认的项 */
|
|
135
|
+
export function nonBundleInProfileBundles(name, args) {
|
|
136
|
+
const p = pathTarget(args);
|
|
137
|
+
if (!isProfilePackageJson(p)) return null;
|
|
138
|
+
const content = resultingFileContent(name, args);
|
|
139
|
+
if (!content) return null;
|
|
140
|
+
let parsed;
|
|
141
|
+
try { parsed = JSON.parse(content); } catch { return null; }
|
|
142
|
+
const bundles = parsed?.dsh?.profile?.bundles;
|
|
143
|
+
if (!Array.isArray(bundles)) return null;
|
|
144
|
+
const bad = [];
|
|
145
|
+
for (const b of bundles) {
|
|
146
|
+
const pkgPath = resolveBundlePkgPath(b, p) || resolveLocalDependencyPkgPath(b, p, parsed);
|
|
147
|
+
if (!pkgPath) {
|
|
148
|
+
const dep = parsed?.dependencies?.[b] ?? parsed?.devDependencies?.[b] ?? parsed?.optionalDependencies?.[b];
|
|
149
|
+
const depDesc = typeof dep === "string" ? `dependencies 为 ${dep}` : "dependencies 中无此包";
|
|
150
|
+
bad.push(`${b}(找不到 package.json,无法确认类型;${depDesc}。请先用 dev_install_package 或先安装依赖再写 bundles)`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
155
|
+
if (!pkg?.dsh?.bundle) bad.push(`${b}(未声明 dsh.bundle)`);
|
|
156
|
+
} catch {
|
|
157
|
+
bad.push(`${b}(package.json 读取失败)`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return bad.length ? bad : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 裁决一次工具调用。
|
|
165
|
+
* @param {object} state createState 返回的运行时状态
|
|
166
|
+
* @param {object} exec ToolExecution(至少 name/arguments)
|
|
167
|
+
* @param {number} now
|
|
168
|
+
* @returns {object|null}
|
|
169
|
+
*/
|
|
170
|
+
export function guardDecision(state, exec, now = Date.now()) {
|
|
171
|
+
if (state.enabled === false) return null;
|
|
172
|
+
if (state.bypassUntil > now) return null;
|
|
173
|
+
maybeReloadIfChanged(state, now);
|
|
174
|
+
const name = String(exec?.name || "");
|
|
175
|
+
const args = exec?.arguments || {};
|
|
176
|
+
const session = getSessionState(state, sessionIdOf(exec));
|
|
177
|
+
const unlock = state.unlockUntil > now;
|
|
178
|
+
const p = pathTarget(args);
|
|
179
|
+
const cmd = commandText(args);
|
|
180
|
+
|
|
181
|
+
// 只读操作无条件放行(read/grep/glob/read_image/str_replace_editor view)
|
|
182
|
+
if (isReadOnlyTool(name, args)) return null;
|
|
183
|
+
|
|
184
|
+
// 内部自护:插件配置/理解产物/规则文件禁止模型直写(/guard unlock 可临时放行)
|
|
185
|
+
if (!unlock && (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p)) {
|
|
186
|
+
return makeHit(
|
|
187
|
+
{ ruleId: "__self-protect", title: "规则引擎配置只读(需 /guard unlock)", action: "deny" },
|
|
188
|
+
`【硬拦截】${p} 受规则引擎保护:修改需用户先执行 /guard unlock`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 写前版本校验(建议③):版本化文件(SKILL.md/AGENTS.md/CHANGELOG/README 等)在写入前
|
|
193
|
+
// 用 old/new 模拟结果做校验,不合规直接拒绝——避免"先写后回滚"的副作用与假成功
|
|
194
|
+
// 位置在自护之后:受保护文件需先 unlock(用户明确授权)再接受版本校验
|
|
195
|
+
if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && p && isVersionedFile(p)) {
|
|
196
|
+
try {
|
|
197
|
+
const current = readFileSync(p, "utf8");
|
|
198
|
+
const simulated = resultingFileContent(name, args) ?? current;
|
|
199
|
+
const check = validateEditedFile(current, simulated, args?.old_string ?? args?.old_str ?? "", args?.new_string ?? args?.new_str ?? "");
|
|
200
|
+
if (!check.ok) {
|
|
201
|
+
return makeHit(
|
|
202
|
+
{ ruleId: "__version-guard", title: "版本守卫:写入前校验", action: "deny" },
|
|
203
|
+
`【硬拦截】${p} 是版本化文件,本次编辑未通过版本守卫(写前校验):${check.errors.join(";")}。请修正 old_string/new_string(保留原文逐行或按行包含关系)后重试`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// 文件不可读等异常不阻断(交给写后自检兜底)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const cfg of state.configs) {
|
|
212
|
+
if (cfg.disabled) continue;
|
|
213
|
+
// 低置信规则不硬拦(保守不误拦,交给 /guard rules 人工复核)
|
|
214
|
+
if (cfg.confidence === "low") continue;
|
|
215
|
+
// 分级执行:只有 A 硬拦 / C 时序 / M 元规则才进入工具守卫
|
|
216
|
+
const actions = cfg.actions || [];
|
|
217
|
+
if (!actions.some((a) => a === "deny" || a === "ask" || a === "meta")) continue;
|
|
218
|
+
const hit = matchRule(cfg, { name, args, p, cmd, session, unlock, state, now });
|
|
219
|
+
if (hit) return hit;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function matchRule(cfg, ctx) {
|
|
225
|
+
const { name, args, p, cmd, session, unlock, state, now } = ctx;
|
|
226
|
+
const id = String(cfg.ruleId);
|
|
227
|
+
|
|
228
|
+
// 规则 1:同工具同参数连续失败 ≥2 次后拦第 3 次(失败计数由 tool/result 更新)
|
|
229
|
+
if (cfg.handler === "rule1-retry") {
|
|
230
|
+
const key = `${name}:${JSON.stringify(args || {})}`;
|
|
231
|
+
const count = state.retryCounts.get(key) || 0;
|
|
232
|
+
const userText = session.turn.userText || session.lastUserText || "";
|
|
233
|
+
if (count >= 2 && /(?:重试|再试一次|再来一次|继续试|再试)/.test(userText)) {
|
|
234
|
+
return null; // 用户明确要求重试 → 豁免
|
|
235
|
+
}
|
|
236
|
+
if (count >= 2) {
|
|
237
|
+
return makeHit(cfg, `【硬拦截】同一工具调用已连续失败 ${count} 次,按规则 1 禁止第 ${count + 1} 次重试`);
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 规则 9:内联命令 / BOM 写配置
|
|
243
|
+
if (cfg.handler === "rule9-inline-bom") {
|
|
244
|
+
if ((name === "pwsh" || name === "bash") && cmd) {
|
|
245
|
+
if (INLINE_CMD.test(cmd)) {
|
|
246
|
+
return makeHit(cfg, "【硬拦截】禁止内联命令(node -e / pwsh -c / node -p 等),请先写脚本文件再执行");
|
|
247
|
+
}
|
|
248
|
+
if (BOM_WRITE.test(cmd)) {
|
|
249
|
+
return makeHit(cfg, "【硬拦截】禁止用 Set-Content/Out-File -Encoding UTF8 写 .json/.yaml(会带 BOM)");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (isChinesePs1Violation(name, args)) {
|
|
253
|
+
return makeHit(cfg, "【硬拦截】含中文的 .ps1 必须 UTF-8 带 BOM;当前写入方式可能无 BOM,请改用纯 ASCII 或显式 BOM 流程");
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 规则 18:DSH 任务首次工具调用前必须已读手册
|
|
259
|
+
if (cfg.handler === "rule18-manual-first") {
|
|
260
|
+
const userText = session.turn.userText || session.lastUserText || "";
|
|
261
|
+
const firstTool = session.turn.toolCount === 0;
|
|
262
|
+
if (firstTool && !session.manualReadSeen && DSH_KEYWORDS_RE.test(userText) && !isManualReadTool(name, args)) {
|
|
263
|
+
return makeHit(cfg, "【硬拦截】任务涉及 DSH,首次工具调用前需先 grep/read 手册(~/.dsh/skills/dsh-usage-manual/SKILL.md)");
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 规则 13A:删除/覆盖/高风险写前需有“目标路径对应备份”证据
|
|
269
|
+
if (cfg.handler === "rule13a-backup") {
|
|
270
|
+
const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args) && !/git\s+(push|commit)/i.test(cmd)));
|
|
271
|
+
const highRiskWrite = (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p);
|
|
272
|
+
if (highRiskWrite && unlock) return null;
|
|
273
|
+
if (destructive || highRiskWrite) {
|
|
274
|
+
const op = operationOf(name, args);
|
|
275
|
+
// 备份动作本身(复制到 .bak/.backups/trash-)不需要再“先备份”
|
|
276
|
+
if (op.type === "backup") return null;
|
|
277
|
+
const targetPath = highRiskWrite ? p : op.pathPrefix;
|
|
278
|
+
// 含 shell 变量($var / %var%)的路径无法可靠解析 → 跳过机械备份检查(P0-2,防变量路径误拦)
|
|
279
|
+
if (targetPath && isVariablePath(targetPath)) return null;
|
|
280
|
+
// 已获 12A/12D 授权的操作 = 用户已明确确认本次操作 → 跳过 13A 机械备份(P1-2,一次授权覆盖全规则)
|
|
281
|
+
if (findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op)) return null;
|
|
282
|
+
// 复制/新建到“尚不存在”的目标文件:属于创建新文件,不适用 13A 覆盖备份要求
|
|
283
|
+
const isCreateNewTarget = !highRiskWrite && cmd && /copy-item|new-item/i.test(cmd) && targetPath && !existsSync(targetPath);
|
|
284
|
+
if (!isCreateNewTarget) {
|
|
285
|
+
const backup = findBackupForPath(state, session.id, targetPath);
|
|
286
|
+
if (!backup) {
|
|
287
|
+
const existing = session.backups.map((b) => `${b.targetPath} -> ${b.backupPath}`).join(";") || "无";
|
|
288
|
+
return makeHit(cfg, `【硬拦截】目标路径缺少对应备份(规则 13A):已有备份 [${existing}];本次目标 [${targetPath}]`);
|
|
289
|
+
}
|
|
290
|
+
if (!existsSync(backup.backupPath)) {
|
|
291
|
+
return makeHit(cfg, `【硬拦截】备份记录存在但备份文件不存在(规则 13A):${backup.backupPath}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 规则 12B:技能调用四步时序(关键词→授权→调用;豁免技能除外)
|
|
299
|
+
if (cfg.handler === "rule12b-skill" || (cfg.hints || []).includes("skill")) {
|
|
300
|
+
if (name === "skill") {
|
|
301
|
+
const skillName = typeof args?.name === "string" ? args.name : "";
|
|
302
|
+
if (SKILL_EXEMPT.has(skillName)) return null;
|
|
303
|
+
// 技能目录实时联动:已加载目录且该技能不存在/被禁用时,规则不激活
|
|
304
|
+
if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
|
|
305
|
+
if (session.turn.questionOnly) {
|
|
306
|
+
return makeHit(cfg, `【硬拦截】当前用户消息是询问而非授权,技能 ${skillName} 未获授权`);
|
|
307
|
+
}
|
|
308
|
+
const op = { type: "skill", pathPrefix: "" };
|
|
309
|
+
const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
|
|
310
|
+
if (!auth) {
|
|
311
|
+
const existing = session.authorizations.map(describeAuth).join(";") || "无";
|
|
312
|
+
return makeHit(cfg, `【硬拦截】技能调用缺少匹配授权:${skillName}(已有授权:${existing};本次范围:${describeOp(op)})`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 规则 12A/12D:敏感操作需要匹配授权证据
|
|
319
|
+
if (cfg.handler === "rule12a-approval" || cfg.handler === "rule12d-sensitive") {
|
|
320
|
+
if (isSensitiveToolCall(name, args)) {
|
|
321
|
+
// 规则 19:dsh-usage-manual/SKILL.md 正文更新免逐次确认(仅手册本身)
|
|
322
|
+
if (p && MANUAL_PATH_RE.test(p)) return null;
|
|
323
|
+
// /guard unlock 本身即用户对受保护配置的授权
|
|
324
|
+
if (unlock && isProtectedConfigPath(p)) return null;
|
|
325
|
+
const op = operationOf(name, args);
|
|
326
|
+
if (session.turn.questionOnly) {
|
|
327
|
+
return makeHit(cfg, `【硬拦截】当前用户消息是询问而非授权,未构成授权证据(本次操作:${describeOp(op)})`);
|
|
328
|
+
}
|
|
329
|
+
const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
|
|
330
|
+
if (!auth) {
|
|
331
|
+
const existing = session.authorizations.map(describeAuth).join(";") || "无";
|
|
332
|
+
return makeHit(cfg, `【硬拦截】敏感操作缺少匹配授权:已有授权范围 [${existing}];本次操作范围 [${describeOp(op)}]`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 规则 21:规则/配置文件变更需 unlock(元规则)
|
|
339
|
+
if (cfg.handler === "rule21-meta") {
|
|
340
|
+
if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p) && !unlock) {
|
|
341
|
+
return makeHit(cfg, "【硬拦截】规则/配置文件受保护:修改需用户先执行 /guard unlock");
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 规则 24:插件装配类型确认(A 硬拦)
|
|
347
|
+
if (cfg.handler === "rule24-assembly-type") {
|
|
348
|
+
if (name === "dev_install_package") {
|
|
349
|
+
const dir = args?.dir;
|
|
350
|
+
if (dir) {
|
|
351
|
+
try {
|
|
352
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
353
|
+
if (!pkg?.dsh?.bundle) {
|
|
354
|
+
return makeHit(cfg, `【硬拦截】插件 ${dir} 未声明 dsh.bundle,不能加入 dsh.profile.bundles(规则 24)`);
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
return makeHit(cfg, `【硬拦截】无法读取插件 package.json:${dir}(规则 24)`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// 手工编辑 profile package.json 的 dsh.profile.bundles 时同样做类型检查
|
|
362
|
+
const badBundles = nonBundleInProfileBundles(name, args);
|
|
363
|
+
if (badBundles && badBundles.length) {
|
|
364
|
+
return makeHit(cfg, `【硬拦截】${p} 的 dsh.profile.bundles 包含非 bundle/无法确认类型:${badBundles.join(";")}(规则 24)`);
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 规则 25:插件变更类工具统一守卫覆盖(A 硬拦;未覆盖的变更工具拒绝)
|
|
370
|
+
if (cfg.handler === "rule25-tool-coverage") {
|
|
371
|
+
if (isReadOnlyTool(name, args)) return null;
|
|
372
|
+
if (COVERED_MUTATION_TOOLS.has(name)) return null;
|
|
373
|
+
if (SAFE_UNCOVERED_TOOLS.has(name)) return null;
|
|
374
|
+
if (looksLikeFileMutation(name, args)) {
|
|
375
|
+
return makeHit(cfg, `【硬拦截】未覆盖的变更类工具 ${name},违反规则 25:请先纳入统一守卫覆盖`);
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 规则 27:装配变更后必须先通过全量审计,才能继续装配(C 时序;全局变更 + 本会话审计证据)
|
|
381
|
+
if (cfg.handler === "rule27-mount-audit") {
|
|
382
|
+
if (isAssemblyMutationTool(name, args)) {
|
|
383
|
+
const currentSig = computeMountSignature(profileNameFromArgs(args));
|
|
384
|
+
state.mountSignature = currentSig;
|
|
385
|
+
const auditedSig = session.mountAuditSignature || "";
|
|
386
|
+
const needsAudit = auditedSig
|
|
387
|
+
? currentSig !== auditedSig
|
|
388
|
+
: (state.mountRevision > (session.mountAuditRevision || 0));
|
|
389
|
+
if (needsAudit) {
|
|
390
|
+
const why = auditedSig
|
|
391
|
+
? `装配内容已变化(装配状态哈希 ${currentSig.slice(0, 8)} ≠ 审计通过时 ${auditedSig.slice(0, 8)})`
|
|
392
|
+
: `插件装配已变更(mountRevision=${state.mountRevision})且本会话未通过全量审计`;
|
|
393
|
+
return makeHit(cfg, `【硬拦截】${why},请先运行 node scripts/audit-mount-consistency.mjs --profile <p> 并通过后再继续装配`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 兜底:从理解产物里的 hints 泛化匹配
|
|
400
|
+
const hints = cfg.hints || [];
|
|
401
|
+
if (hints.includes("inline-command") && (name === "pwsh" || name === "bash") && cmd && INLINE_CMD.test(cmd)) {
|
|
402
|
+
return makeHit(cfg, `【硬拦截】${cfg.title}`);
|
|
403
|
+
}
|
|
404
|
+
if (hints.includes("bom-write") && (name === "pwsh" || name === "bash") && cmd && BOM_WRITE.test(cmd)) {
|
|
405
|
+
return makeHit(cfg, `【硬拦截】${cfg.title}`);
|
|
406
|
+
}
|
|
407
|
+
if (hints.includes("manual") && session.turn.toolCount === 0 && !session.manualReadSeen && !isManualReadTool(name, args)) {
|
|
408
|
+
return makeHit(cfg, `【硬拦截】${cfg.title}`);
|
|
409
|
+
}
|
|
410
|
+
if (hints.includes("sensitive") && isSensitiveToolCall(name, args)) {
|
|
411
|
+
if (p && MANUAL_PATH_RE.test(p)) return null;
|
|
412
|
+
if (unlock && isProtectedConfigPath(p)) return null;
|
|
413
|
+
const op = operationOf(name, args);
|
|
414
|
+
if (session.turn.questionOnly) {
|
|
415
|
+
return makeHit(cfg, `【硬拦截】当前用户消息是询问而非授权(本次操作:${describeOp(op)})`);
|
|
416
|
+
}
|
|
417
|
+
const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
|
|
418
|
+
if (!auth) {
|
|
419
|
+
const existing = session.authorizations.map(describeAuth).join(";") || "无";
|
|
420
|
+
return makeHit(cfg, `【硬拦截】${cfg.title}:缺少匹配授权(已有:${existing};本次:${describeOp(op)})`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** 供测试/调试:手动更新备份状态(并创建真实备份文件以满足存在性校验) */
|
|
427
|
+
export function markBackupSeen(state, sessionId, targetPath) {
|
|
428
|
+
const s = getSessionState(state, sessionId);
|
|
429
|
+
s.turn.backupSeen = true;
|
|
430
|
+
if (targetPath) {
|
|
431
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-rule-engine-bak-"));
|
|
432
|
+
const backupPath = join(dir, "backup.bak");
|
|
433
|
+
writeFileSync(backupPath, "backup", "utf8");
|
|
434
|
+
const norm = (p) => String(p).replace(/\\/g, "/").toLowerCase();
|
|
435
|
+
s.backups.push({
|
|
436
|
+
targetPath: norm(targetPath),
|
|
437
|
+
backupPath,
|
|
438
|
+
at: Date.now()
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function markAskSeen(state, sessionId) {
|
|
444
|
+
const s = getSessionState(state, sessionId);
|
|
445
|
+
s.turn.askSeen = true;
|
|
446
|
+
s.authorizations.push({ at: Date.now(), type: "any", pathPrefix: "", source: "test" });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function markManualRead(state, sessionId) {
|
|
450
|
+
getSessionState(state, sessionId).manualReadSeen = true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export { isBackupTool, isManualReadTool };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// llm-understander.js - LLM 增量理解器(P3)
|
|
2
|
+
// 对非 high 置信规则调用 ctx.llm 做结构化理解;失败/不可用时回退模式库。
|
|
3
|
+
// 纯依赖注入 ctx.llm,不 import 官方包。
|
|
4
|
+
|
|
5
|
+
async function resolveRoute(ctx) {
|
|
6
|
+
if (!ctx?.llm) return null;
|
|
7
|
+
try {
|
|
8
|
+
const providers = ctx.llm.listProviders?.() || [];
|
|
9
|
+
if (providers.length === 0) return null;
|
|
10
|
+
const provider = process.env.DSH_LLM_PROVIDER || providers[0]?.name || providers[0];
|
|
11
|
+
const models = await ctx.llm.listModels?.(provider);
|
|
12
|
+
const model = process.env.DSH_LLM_MODEL || (models && models[0]?.id) || (models && models[0]) || null;
|
|
13
|
+
if (!model) return null;
|
|
14
|
+
return { provider, model };
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildPrompt(rule) {
|
|
21
|
+
const body = rule.body || "";
|
|
22
|
+
const elements = rule.elements || {};
|
|
23
|
+
return [
|
|
24
|
+
"你是 DSH 规则理解器。根据规则正文输出严格 JSON,不要输出其他内容。",
|
|
25
|
+
"JSON 格式:",
|
|
26
|
+
'{"actions":["deny"|"correct"|"ask"|"self-certify"|"meta"],"confidence":"high"|"medium"|"low","handler":"短横线标识或空","hints":["字符串提示数组"]}',
|
|
27
|
+
"规则编号:" + rule.ruleId,
|
|
28
|
+
"规则标题:" + rule.title,
|
|
29
|
+
"规则正文:",
|
|
30
|
+
body,
|
|
31
|
+
"触发:" + (elements.trigger || ""),
|
|
32
|
+
"检查:" + (elements.check || ""),
|
|
33
|
+
"动作:" + (elements.action || ""),
|
|
34
|
+
"只输出 JSON。"
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseJsonOutput(text) {
|
|
39
|
+
const t = String(text || "").trim();
|
|
40
|
+
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
41
|
+
const raw = fence ? fence[1].trim() : t;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function callLlm(ctx, route, rule) {
|
|
50
|
+
const messages = [
|
|
51
|
+
{
|
|
52
|
+
role: "user",
|
|
53
|
+
content: [{ type: "text", text: buildPrompt(rule) }]
|
|
54
|
+
}
|
|
55
|
+
];
|
|
56
|
+
let text = "";
|
|
57
|
+
for await (const chunk of ctx.llm.stream({
|
|
58
|
+
provider: route.provider,
|
|
59
|
+
model: route.model,
|
|
60
|
+
messages,
|
|
61
|
+
maxTokens: 500,
|
|
62
|
+
temperature: 0
|
|
63
|
+
})) {
|
|
64
|
+
if (chunk && chunk.type === "text-delta" && typeof chunk.text === "string") {
|
|
65
|
+
text += chunk.text;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return parseJsonOutput(text);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 对非 high 置信规则做一次 LLM 增量理解。
|
|
73
|
+
* @param {object} ctx Cordis context
|
|
74
|
+
* @param {object} state 插件状态(configs 会被原地更新)
|
|
75
|
+
*/
|
|
76
|
+
export async function enrichRulesWithLlm(ctx, state) {
|
|
77
|
+
if (!ctx?.llm || !Array.isArray(state.configs)) return;
|
|
78
|
+
const route = await resolveRoute(ctx);
|
|
79
|
+
if (!route) return;
|
|
80
|
+
if (!state.llmEnrichedKeys) state.llmEnrichedKeys = new Set();
|
|
81
|
+
const mtime = state.mtimeMs || 0;
|
|
82
|
+
const targets = state.configs.filter((c) => {
|
|
83
|
+
if (c.confidence === "high") return false;
|
|
84
|
+
const key = `${c.ruleId}@${mtime}`;
|
|
85
|
+
return !state.llmEnrichedKeys.has(key);
|
|
86
|
+
});
|
|
87
|
+
for (const cfg of targets) {
|
|
88
|
+
const key = `${cfg.ruleId}@${mtime}`;
|
|
89
|
+
cfg.llmTried = true;
|
|
90
|
+
state.llmEnrichedKeys.add(key); // 无论成功失败,每个规则版本只尝试一次
|
|
91
|
+
try {
|
|
92
|
+
const result = await callLlm(ctx, route, cfg);
|
|
93
|
+
if (result && result.confidence) {
|
|
94
|
+
if (Array.isArray(result.actions)) cfg.actions = result.actions;
|
|
95
|
+
if (typeof result.confidence === "string") cfg.confidence = result.confidence;
|
|
96
|
+
if (typeof result.handler === "string") cfg.handler = result.handler;
|
|
97
|
+
if (Array.isArray(result.hints)) cfg.hints = result.hints;
|
|
98
|
+
cfg.llmEnriched = true;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// 单条失败不影响其他规则,保留模式库结果
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// matcher.js - 情境匹配机。
|
|
2
|
+
// 每轮只激活相关规则子集,供 /guard active 展示,也用于控制执行成本。
|
|
3
|
+
import { DSH_KEYWORDS_RE } from "./patterns.js";
|
|
4
|
+
|
|
5
|
+
export function activateForUserMessage(configs, userText) {
|
|
6
|
+
const text = userText || "";
|
|
7
|
+
return configs.filter((cfg) => {
|
|
8
|
+
if (cfg.handler === "rule18-manual-first" && DSH_KEYWORDS_RE.test(text)) return true;
|
|
9
|
+
if (cfg.handler === "rule12b-skill" && /技能|skill/i.test(text)) return true;
|
|
10
|
+
if (cfg.handler === "rule12a-approval" && /执行|创建|删除|覆盖|移动|下载|提交|配置/i.test(text)) return true;
|
|
11
|
+
if (cfg.handler === "rule12c-network" && /下载|网络|curl|境外|clash/i.test(text)) return true;
|
|
12
|
+
if (cfg.handler === "rule13a-backup" && /删除|覆盖|迁移|备份/i.test(text)) return true;
|
|
13
|
+
if (cfg.handler === "rule2-time" && /时间|今天|昨天|日期/i.test(text)) return true;
|
|
14
|
+
if (cfg.handler === "rule7-promise" && /保证|肯定|承诺/i.test(text)) return true;
|
|
15
|
+
if (cfg.handler === "rule5-source" && /引用|来源|URL|链接/i.test(text)) return true;
|
|
16
|
+
if (cfg.handler === "rule11-language" && CJK_PRESENT.test(text)) return true;
|
|
17
|
+
return false;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CJK_PRESENT = /[\u4e00-\u9fff]/;
|
|
22
|
+
|
|
23
|
+
export function activateForToolCall(configs, toolName, args) {
|
|
24
|
+
const name = String(toolName || "");
|
|
25
|
+
const argText = JSON.stringify(args || {});
|
|
26
|
+
return configs.filter((cfg) => {
|
|
27
|
+
if (cfg.handler === "rule9-inline-bom" && (name === "pwsh" || name === "bash")) return true;
|
|
28
|
+
if (cfg.handler === "rule1-retry") return true;
|
|
29
|
+
if (cfg.handler === "rule18-manual-first") return true;
|
|
30
|
+
if (cfg.handler === "rule13a-backup" && (name === "pwsh" || name === "bash" || name === "edit" || name === "write")) return true;
|
|
31
|
+
if (cfg.handler === "rule12b-skill" && name === "skill") return true;
|
|
32
|
+
if (cfg.handler === "rule12a-approval" || cfg.handler === "rule12d-sensitive") {
|
|
33
|
+
if (name === "pwsh" || name === "bash" || name === "edit" || name === "write" || name === "ask_user_question") return true;
|
|
34
|
+
}
|
|
35
|
+
if (cfg.handler === "rule21-meta" && (name === "edit" || name === "write")) return true;
|
|
36
|
+
if (argText) {
|
|
37
|
+
const low = argText.toLowerCase();
|
|
38
|
+
if (cfg.triggerKeywords.some((k) => low.includes(k.toLowerCase()))) return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function activateForAssistant(configs, text) {
|
|
45
|
+
return configs.filter((cfg) => {
|
|
46
|
+
const actions = cfg.actions || [];
|
|
47
|
+
if (actions.includes("correct") || actions.includes("self-certify")) return true;
|
|
48
|
+
return false;
|
|
49
|
+
});
|
|
50
|
+
}
|