dsh-rule-engine 0.5.17 → 0.6.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/README.md +268 -236
- package/lib/core/authorization.js +58 -7
- package/lib/core/contract.js +1 -1
- package/lib/core/guard-core.js +44 -22
- package/lib/core/patterns.js +20 -13
- package/lib/core/state.js +1 -1
- package/lib/core/text-detect.js +5 -4
- package/lib/index.js +97 -11
- package/lib/service.js +5 -2
- package/package.json +60 -58
- package/scripts/audit-mount-consistency.mjs +198 -198
- package/scripts/check-tool-coverage.mjs +63 -41
- package/scripts/local-residue-scan.mjs +40 -0
- package/scripts/publish-aptitude-check.mjs +102 -144
- package/scripts/readme-version-check.mjs +41 -0
- package/scripts/release-plugin.mjs +339 -329
- package/scripts/verify-all.mjs +8 -1
|
@@ -51,12 +51,63 @@ const TYPE_HINTS = [
|
|
|
51
51
|
// 只读/审查类(置末位,2026-08-25 RB-05):不覆盖在前类别的变更语义(“修改并审查”仍按 write/delete),
|
|
52
52
|
// 仅当无其它类型命中时给只读类型——配合 ACTION_RE 只读词(审查/阅读/验证等),使“审查文件”类执行分点
|
|
53
53
|
// 授权类型为 analysis(只读),不再落入 any 宽泛授权。
|
|
54
|
-
{ re: /审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|评估|分析|研究|排查|核实|梳理|调查|诊断|读取|读(?:出|一下|一遍|完)?|展示|打开|提取/i, type: "analysis" }
|
|
54
|
+
{ re: /审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|评估|分析|研究|排查|核实|梳理|调查|诊断|读取|读(?:出|一下|一遍|完)?|展示|打开|提取/i, type: "analysis" },
|
|
55
|
+
// C4(2026-09-04):archive 独立类型(解压/归档高频用例;物理确认最小范围)
|
|
56
|
+
{ re: /解压|解包|解压缩|归档|unzip|expand-archive|extract-archive|tar\s+-x|7z\s+x/i, type: "archive" }
|
|
55
57
|
];
|
|
56
58
|
|
|
59
|
+
// ── C4(2026-09-04):类型提示配置化 + 物理确认类型枚举 ──
|
|
60
|
+
// typeHintsOverride = null → 内置表;非空 → 完全替换(clear 语义);空 → 还原内置。
|
|
61
|
+
let typeHintsOverride = null;
|
|
62
|
+
|
|
63
|
+
function isValidHint(h) {
|
|
64
|
+
if (!h || typeof h.type !== "string" || h.type.length === 0 || h.type === "any") return false;
|
|
65
|
+
if (typeof h.re !== "string") return false;
|
|
66
|
+
try {
|
|
67
|
+
new RegExp(h.re, "i");
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeHints(list) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const h of list) {
|
|
77
|
+
if (!isValidHint(h)) continue;
|
|
78
|
+
if (!out.some((x) => x.type === h.type)) out.push({ type: h.type, re: new RegExp(h.re, "i") });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** C4:配置层扩展/替换授权类型提示;空列表 = 还原内置;{ clear: true } = 完全替换(不含内置) */
|
|
84
|
+
export function setTypeHints(hints, opts = {}) {
|
|
85
|
+
const list = Array.isArray(hints) ? hints : [];
|
|
86
|
+
if (list.length === 0) {
|
|
87
|
+
typeHintsOverride = null;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const normalized = normalizeHints(list);
|
|
91
|
+
typeHintsOverride = opts?.clear ? normalized : [...TYPE_HINTS, ...normalized];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** C4:当前生效的类型枚举(内置 9 类 + 配置扩展;any=全局通配被禁止,不枚举) */
|
|
95
|
+
export function APPROVE_TYPES() {
|
|
96
|
+
const hints = typeHintsOverride ?? TYPE_HINTS;
|
|
97
|
+
const types = [];
|
|
98
|
+
for (const h of hints) {
|
|
99
|
+
if (h && typeof h.type === "string" && h.type !== "any" && !types.includes(h.type)) types.push(h.type);
|
|
100
|
+
}
|
|
101
|
+
return types;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function currentHints() {
|
|
105
|
+
return typeHintsOverride ?? TYPE_HINTS;
|
|
106
|
+
}
|
|
107
|
+
|
|
57
108
|
// C2(2026-08-28 阶段二):`、`(中文顿号)从非路径字符黑名单移除——Windows 中文目录名
|
|
58
|
-
// 常见顿号("D:\1
|
|
59
|
-
// 真实操作路径匹配失败(ERR-LU50QQ:已授权范围 [write d:/1] 拦 d:/1
|
|
109
|
+
// 常见顿号("D:\1、示例\..."——用户工作目录),原正则在此截断 → 授权记录成 "d:/1",
|
|
110
|
+
// 真实操作路径匹配失败(ERR-LU50QQ:已授权范围 [write d:/1] 拦 d:/1、示例/练习/...)。
|
|
60
111
|
// 保守边界:顿号后跟空格+新词时可能合并(fail-closed——提取过长 → 授权匹配失败 → 拦而非放),
|
|
61
112
|
// 不放大授权;引号包裹分支本就不受此影响。
|
|
62
113
|
const PATH_TOKEN_RE = /["'][A-Za-z]:[\\/](?!\/)[^"']+["']|[A-Za-z]:[\\/](?!\/)[^\s'"`,。;:!?()【】《》]+|(?:^|[\s"'`])\/(?:[^\s"'`,。;:!?()【】《》、]+)/g;
|
|
@@ -69,7 +120,7 @@ export function normalizePath(p) {
|
|
|
69
120
|
/** 从文本推断操作类型(取第一个命中) */
|
|
70
121
|
export function inferTypeFromText(text) {
|
|
71
122
|
const s = String(text || "");
|
|
72
|
-
for (const hint of
|
|
123
|
+
for (const hint of currentHints()) {
|
|
73
124
|
if (hint.re.test(s)) return hint.type;
|
|
74
125
|
}
|
|
75
126
|
return "any";
|
|
@@ -92,7 +143,7 @@ export function classifyAskScopeType(text) {
|
|
|
92
143
|
export function inferTypesFromText(text) {
|
|
93
144
|
const s = String(text || "");
|
|
94
145
|
const types = [];
|
|
95
|
-
for (const hint of
|
|
146
|
+
for (const hint of currentHints()) {
|
|
96
147
|
if (hint.re.test(s)) types.push(hint.type);
|
|
97
148
|
}
|
|
98
149
|
return types.length ? [...new Set(types)] : ["any"];
|
|
@@ -101,8 +152,8 @@ export function inferTypesFromText(text) {
|
|
|
101
152
|
/**
|
|
102
153
|
* 从文本提取路径候选(批次 4 统一入口:pairActionScopes 与 inferPathPrefixesFromText 共用)。
|
|
103
154
|
* 处理两类噪声:
|
|
104
|
-
* 1. 前缀冗余:引号路径同时命中两分支 → 截断前缀(如 "d:/
|
|
105
|
-
* 2. 截断可疑:裸含空格路径(无引号)只能提取到空格前的半截("D
|
|
155
|
+
* 1. 前缀冗余:引号路径同时命中两分支 → 截断前缀(如 "d:/example")被更长匹配覆盖 → 丢弃;
|
|
156
|
+
* 2. 截断可疑:裸含空格路径(无引号)只能提取到空格前的半截("D:\\workspace\docs\..." → "d:/workspace")
|
|
106
157
|
* ——匹配后紧跟空白且后随 token 是路径字符开头且非盘符/引号 → 判定为截断 → 丢弃(fail-closed:
|
|
107
158
|
* 宁缺授权,拦截后请用户给完整/引号路径,也不把半截前缀当成授权)。
|
|
108
159
|
* @returns {Array<{index:number,path:string}>} 去噪后的候选(保留原文索引供就近配对)
|
package/lib/core/contract.js
CHANGED
|
@@ -38,7 +38,7 @@ export function categoryOfCommand(command) {
|
|
|
38
38
|
if (/remove-item|\brm(?:dir)?\b|del(?:ete)?\b|move-item|\bmv\b|rename-item|\bren\b|clear-content|clean|purge|rm\s+-/.test(s)) return "delete";
|
|
39
39
|
if (/install|add\s+--save|pnpm\s+add|npm\s+install|yarn\s+add/.test(s)) return "install";
|
|
40
40
|
if (/build|tsc|tsdown|vite\s+build|webpack|rollup|compile|bundle/.test(s)) return "build";
|
|
41
|
-
if (/test|jest|vitest|playwright|npm\s+test|pnpm\s+test|
|
|
41
|
+
if (/test|jest|vitest|playwright|npm\s+test|pnpm\s+test|verify/.test(s)) return "test";
|
|
42
42
|
if (/audit|mount|consistency|dump-config|inspect/.test(s)) return "audit";
|
|
43
43
|
if (/analyze|analysis|review|read|grep|search/.test(s)) return "analyze";
|
|
44
44
|
return null;
|
package/lib/core/guard-core.js
CHANGED
|
@@ -8,9 +8,7 @@ import {
|
|
|
8
8
|
DESTRUCTIVE_CMD,
|
|
9
9
|
DSH_KEYWORDS_RE,
|
|
10
10
|
INLINE_CMD,
|
|
11
|
-
MANUAL_PATH_RE,
|
|
12
11
|
PROTECTED_FILENAME_RE,
|
|
13
|
-
SKILL_EXEMPT,
|
|
14
12
|
commandText,
|
|
15
13
|
isAssemblyMutationTool,
|
|
16
14
|
isBackupTool,
|
|
@@ -27,6 +25,7 @@ import {
|
|
|
27
25
|
isAnalysisOp,
|
|
28
26
|
isAnalysisScratchPath,
|
|
29
27
|
extractAnalysisScratchPaths,
|
|
28
|
+
matchManualPath,
|
|
30
29
|
pathTarget
|
|
31
30
|
} from "./patterns.js";
|
|
32
31
|
import { authMatches, describeAuth, describeOp, describeScopes, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText, scopesFromIntents } from "./authorization.js";
|
|
@@ -75,11 +74,11 @@ const RULE_HINTS = {
|
|
|
75
74
|
"9": "改用脚本文件或显式 UTF-8 BOM 流程",
|
|
76
75
|
"12A": "先 ask_user_question 获取匹配授权",
|
|
77
76
|
"13A": "先对目标路径执行备份(复制到 .bak/.backups/trash-)",
|
|
78
|
-
"18": "
|
|
77
|
+
"18": "先读取本机配置的手册(路径经 localIntegrations 配置;未配置则该规则无对象)",
|
|
79
78
|
"21": "按规则 21 分级确认后再落盘",
|
|
80
79
|
"22": "先回答/展示方案,或补充明确执行分点(工具类别+路径范围);已授权变更必须落在本回合执行分点/ask 授权范围内",
|
|
81
80
|
"24": "确认插件 dsh.bundle 类型或改用正确挂载",
|
|
82
|
-
"27": "先运行 node scripts/audit-mount-consistency.mjs --profile
|
|
81
|
+
"27": "先运行 node scripts/audit-mount-consistency.mjs --profile <p>"
|
|
83
82
|
};
|
|
84
83
|
|
|
85
84
|
// 0.5.12(F3):拒绝来源前缀——机器可解析 token 化(/guard log 可按来源 grep 过滤)
|
|
@@ -128,9 +127,28 @@ export function isProfilePackageJson(p) {
|
|
|
128
127
|
return typeof p === "string" && /profiles[\\/][^\\/]+[\\/]package\.json$/i.test(p);
|
|
129
128
|
}
|
|
130
129
|
|
|
131
|
-
/** 判定命令是否"
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
/** 判定命令是否"整条命令仅调用统一入口脚本"(无 ; | & 链式/换行/重定向)
|
|
131
|
+
* N1(2026-09-04 P2):排除向文件的重定向(> / >> / &> / fd>file)——`入口 status x > AGENTS.md` 不再被豁免;
|
|
132
|
+
* 仅保留 fd→fd 复制(2>&1 / 1>&2)。
|
|
133
|
+
* G1(2026-09-04 P2):引号感知——引号内的换行/;|& 视为合法参数内容(上会话曾误拦合法多行参数)。
|
|
134
|
+
* entryMarker:统一入口脚本名(来自 localIntegrations.entryScript;无配置 = 不存在"统一入口"概念)。 */
|
|
135
|
+
function isEntryChannelCommand(cmd, entryMarker) {
|
|
136
|
+
if (typeof cmd !== "string") return false;
|
|
137
|
+
if (!entryMarker) return false; // 无配置 = 无对象,自然静默
|
|
138
|
+
const c = cmd.trim();
|
|
139
|
+
if (!c.includes(entryMarker)) return false;
|
|
140
|
+
// ① 链式分隔(引号感知):引号外的 ; | & 换行 → 非法
|
|
141
|
+
let inQ = null;
|
|
142
|
+
for (let i = 0; i < c.length; i++) {
|
|
143
|
+
const ch = c[i];
|
|
144
|
+
if (inQ) { if (ch === inQ) inQ = null; continue; }
|
|
145
|
+
if (ch === '"' || ch === "'") { inQ = ch; continue; }
|
|
146
|
+
if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") return false;
|
|
147
|
+
}
|
|
148
|
+
// ② 重定向:摘除 fd 复制(N>&M / N<&M)后,任何 > / >> / &> 均非法(N1——含数字前置 2>/1> 盲区)
|
|
149
|
+
const noFd = c.replace(/\d*\s*[<>]&\s*\d+/g, "");
|
|
150
|
+
if (/>>|&>|(?:^|[^<])>/.test(noFd)) return false;
|
|
151
|
+
return true;
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
/** 计算 edit/write/str_replace 后的目标文件内容;无法可靠计算时返回 null */
|
|
@@ -312,17 +330,21 @@ export function guardDecision(state, exec, now = Date.now(), opts = {}) {
|
|
|
312
330
|
}
|
|
313
331
|
|
|
314
332
|
// 阶段 C:硬拦 pwsh/bash 绕过统一入口直接写受保护文件(规则 19⑧/21⑨ 的机器执行层)
|
|
315
|
-
// 合法通道 =
|
|
316
|
-
// 0.
|
|
333
|
+
// 合法通道 = 整条命令仅调用统一入口脚本(无 ; | & 链式/换行,防注释文本伪造放行)
|
|
334
|
+
// A2-2(0.6.0):无 localIntegrations.entryScript 配置 = 守卫无对象(不激活);protectedFiles 为通用基线之上的本机追加清单
|
|
317
335
|
if (!unlock && (name === "pwsh" || name === "bash")) {
|
|
318
336
|
const cmd = commandText(args) || "";
|
|
319
|
-
|
|
320
|
-
|
|
337
|
+
const entryMarker = state.localIntegrations?.entryScript;
|
|
338
|
+
if (entryMarker && cmd && isMutationCommand(cmd) && (PROTECTED_FILENAME_RE.test(cmd) || matchManualPath(cmd, state.localIntegrations?.protectedFiles || []))) {
|
|
339
|
+
if (!isEntryChannelCommand(cmd, entryMarker)) {
|
|
321
340
|
return makeHit(
|
|
322
341
|
{ ruleId: "__self-protect", title: "受保护文件禁止绕过统一入口直写", action: "deny" },
|
|
323
|
-
`【硬拦截】受保护文件禁止通过 pwsh/bash 绕过统一入口直写;请使用
|
|
342
|
+
`【硬拦截】受保护文件禁止通过 pwsh/bash 绕过统一入口直写;请使用 ${entryMarker}(整个命令只能调用该脚本,不得链式拼接其他写命令/重定向;或 /guard unlock 临时放行)。`
|
|
324
343
|
);
|
|
325
344
|
}
|
|
345
|
+
} else if (!entryMarker && cmd && isMutationCommand(cmd) && (PROTECTED_FILENAME_RE.test(cmd) || matchManualPath(cmd, state.localIntegrations?.protectedFiles || []))) {
|
|
346
|
+
// A2-2(0.6.0):无 entryScript 配置 = 守卫无对象——登记 skipped 审计(验证"默认无"的留痕;不拦截)
|
|
347
|
+
opts?.audit?.({ kind: "li-skipped", rule: "19", name: "本地集成未配置(守卫无对象)", event: "tool/guard", reason: "skipped: no localIntegrations.entryScript——该守卫在此环境不存在(0.6.0 默认无)", session: sessionIdOf(exec) });
|
|
326
348
|
}
|
|
327
349
|
}
|
|
328
350
|
|
|
@@ -514,17 +536,17 @@ function matchRule(cfg, ctx) {
|
|
|
514
536
|
if (cfg.handler === "rule18-manual-first") {
|
|
515
537
|
const userText = session.turn.userText || session.lastUserText || "";
|
|
516
538
|
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
|
|
539
|
+
if (firstTool && !session.manualReadSeen && DSH_KEYWORDS_RE.test(userText) && !isManualReadTool(name, args, state.localIntegrations?.manualExempt?.paths || [])) {
|
|
540
|
+
return makeHit(cfg, "【硬拦截】任务涉及 DSH,首次工具调用前需先 grep/read 本机手册(路径经 localIntegrations 配置)");
|
|
519
541
|
}
|
|
520
542
|
return null;
|
|
521
543
|
}
|
|
522
544
|
|
|
523
545
|
// 规则 13A:删除/覆盖/高风险写前需有“目标路径对应备份”证据
|
|
524
546
|
if (cfg.handler === "rule13a-backup") {
|
|
525
|
-
//
|
|
547
|
+
// 统一入口命令豁免:入口脚本每次写入前自身执行备份(backup() 保留 5 份),
|
|
526
548
|
// 引擎静态扫描看不到脚本内部动作(已知盲区);入口命令也已被 __self-protect 限定为唯一写通道。
|
|
527
|
-
if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd)) return null;
|
|
549
|
+
if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd, state.localIntegrations?.entryScript)) return null;
|
|
528
550
|
const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args, sessionIdOf(exec)) && !/git\s+(push|commit)/i.test(cmd)));
|
|
529
551
|
const highRiskWrite = (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p);
|
|
530
552
|
if (highRiskWrite && unlock && !isHighRiskEntryFile(p)) return null;
|
|
@@ -567,7 +589,7 @@ function matchRule(cfg, ctx) {
|
|
|
567
589
|
if (cfg.handler === "rule12b-skill") {
|
|
568
590
|
if (name === "skill") {
|
|
569
591
|
const skillName = typeof args?.name === "string" ? args.name : "";
|
|
570
|
-
if (
|
|
592
|
+
if ((state.localIntegrations?.manualExempt?.skills || []).includes(skillName)) return null;
|
|
571
593
|
// 技能目录实时联动:已加载目录且该技能不存在/被禁用时,规则不激活
|
|
572
594
|
if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
|
|
573
595
|
if (denyMutation) {
|
|
@@ -587,7 +609,7 @@ function matchRule(cfg, ctx) {
|
|
|
587
609
|
if ((cfg.hints || []).includes("skill") && name === "skill") {
|
|
588
610
|
// hints 兜底:理解器未分配 handler 但 hints 含 skill 的 cfg——仅 skill 工具时参与,不截胡其它规则
|
|
589
611
|
const skillName = typeof args?.name === "string" ? args.name : "";
|
|
590
|
-
if (
|
|
612
|
+
if ((state.localIntegrations?.manualExempt?.skills || []).includes(skillName)) return null;
|
|
591
613
|
if (state.skillNames && state.skillNames.size > 0 && !state.skillNames.has(skillName)) return null;
|
|
592
614
|
const op = { type: "skill", pathPrefix: "" };
|
|
593
615
|
const auth = findMatchingAuth([...(session.authorizations || []), ...(state.globalAuthorizations || [])], op);
|
|
@@ -616,8 +638,8 @@ function matchRule(cfg, ctx) {
|
|
|
616
638
|
audit?.({ kind: "allow", rule: cfg.ruleId, name: "低风险新建豁免(12A 判据同源)", tool: name, reason: `工作区内低风险新建(12A 判据同源):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
|
|
617
639
|
return null;
|
|
618
640
|
}
|
|
619
|
-
// 规则 19
|
|
620
|
-
if (p &&
|
|
641
|
+
// 规则 19:本机手册正文更新免逐次确认(仅手册本身;路径经配置)
|
|
642
|
+
if (p && (matchManualPath(p, state.localIntegrations?.manualExempt?.paths))) return null;
|
|
621
643
|
// /guard unlock 本身即用户对受保护配置的授权
|
|
622
644
|
if (unlock && isProtectedConfigPath(p)) return null;
|
|
623
645
|
const op = operationOf(name, args);
|
|
@@ -704,11 +726,11 @@ function matchRule(cfg, ctx) {
|
|
|
704
726
|
if (hints.includes("bom-write") && (name === "pwsh" || name === "bash") && cmd && BOM_WRITE.test(cmd)) {
|
|
705
727
|
return makeHit(cfg, `【硬拦截】${cfg.title}`);
|
|
706
728
|
}
|
|
707
|
-
if (hints.includes("manual") && session.turn.toolCount === 0 && !session.manualReadSeen && !isManualReadTool(name, args)) {
|
|
729
|
+
if (hints.includes("manual") && session.turn.toolCount === 0 && !session.manualReadSeen && !isManualReadTool(name, args, state.localIntegrations?.manualExempt?.paths || [])) {
|
|
708
730
|
return makeHit(cfg, `【硬拦截】${cfg.title}`);
|
|
709
731
|
}
|
|
710
732
|
if (hints.includes("sensitive") && isSensitiveToolCall(name, args, sessionIdOf(exec))) {
|
|
711
|
-
if (p &&
|
|
733
|
+
if (p && (matchManualPath(p, state.localIntegrations?.manualExempt?.paths))) return null;
|
|
712
734
|
if (unlock && isProtectedConfigPath(p)) return null;
|
|
713
735
|
const op = operationOf(name, args);
|
|
714
736
|
if (denyMutation) {
|
package/lib/core/patterns.js
CHANGED
|
@@ -54,34 +54,41 @@ export const SOURCE_MARK = /来源|出处|via|source|reference|引自|参考/i;
|
|
|
54
54
|
|
|
55
55
|
export const CJK_RE = /[\u4e00-\u9fff]/;
|
|
56
56
|
|
|
57
|
-
export const MANUAL_PATH_RE = /dsh-usage-manual[\\/]SKILL\.md/i;
|
|
58
|
-
|
|
59
57
|
export const DSH_KEYWORDS_RE =
|
|
60
58
|
/DSH|dsh|插件|技能|规则|配置|迁移|手册|会话|装配|profile|bundle/i;
|
|
61
59
|
|
|
62
|
-
export const SKILL_EXEMPT = new Set(["dsh-usage-manual", "task-planner"]);
|
|
63
|
-
|
|
64
60
|
export const SELF_PROTECT_PATHS = [
|
|
65
61
|
"**/rule-engine.json",
|
|
66
62
|
"**/rule-understanding.json",
|
|
67
63
|
"**/rule-guard.json"
|
|
68
64
|
];
|
|
69
65
|
|
|
70
|
-
/**
|
|
71
|
-
export function isManualReadCommand(command) {
|
|
66
|
+
/** 判断一条命令文本是否是「读取手册」类命令(manualPaths = 本机配置的手册路径列表;无配置 = 无对象,恒 false) */
|
|
67
|
+
export function isManualReadCommand(command, manualPaths = []) {
|
|
72
68
|
if (typeof command !== "string") return false;
|
|
73
|
-
|
|
69
|
+
if (!Array.isArray(manualPaths)) return false;
|
|
70
|
+
const hit = manualPaths.some((pp) => command.toLowerCase().includes(String(pp).toLowerCase()));
|
|
71
|
+
return hit && /get-content|cat|type|read|grep|findstr|str_replace_editor/i.test(command);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 本机追加文件路径匹配(A2-2 算法):相对 DSH_HOME 归一化 + 大小写不敏感 + 包含比较。
|
|
75
|
+
* manualPaths 为空 = 无本机配置 = 恒 false(守卫无对象,自然静默)。 */
|
|
76
|
+
export function matchManualPath(p, manualPaths = []) {
|
|
77
|
+
if (typeof p !== "string" || !Array.isArray(manualPaths)) return false;
|
|
78
|
+
const norm = (s) => s.replace(/\\/g, "/").replace(/^["']+|["']+$/g, "").toLowerCase();
|
|
79
|
+
const n = norm(p);
|
|
80
|
+
return manualPaths.some((pp) => n.includes(norm(String(pp))));
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
/**
|
|
77
|
-
export function isManualReadTool(toolName, args) {
|
|
83
|
+
/** 判断一次工具调用是否算作「已读手册」(manualPaths = 本机配置的手册路径列表;无配置 = 无对象,恒 false) */
|
|
84
|
+
export function isManualReadTool(toolName, args, manualPaths = []) {
|
|
78
85
|
const name = String(toolName || "");
|
|
79
86
|
if (name === "read" || name === "grep" || name === "str_replace_editor") {
|
|
80
87
|
const p = String(args?.file_path || args?.path || args?.pattern || "");
|
|
81
|
-
if (
|
|
88
|
+
if (isManualReadCommand(p, manualPaths)) return true;
|
|
82
89
|
}
|
|
83
90
|
if (name === "pwsh" || name === "bash") {
|
|
84
|
-
return isManualReadCommand(args?.command || args?.code || "");
|
|
91
|
+
return isManualReadCommand(args?.command || args?.code || "", manualPaths);
|
|
85
92
|
}
|
|
86
93
|
return false;
|
|
87
94
|
}
|
|
@@ -252,7 +259,7 @@ const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "read_image"]);
|
|
|
252
259
|
const READONLY_CMD_RE =
|
|
253
260
|
/(?:\b(?:Get-Content|Get-ChildItem|Get-Item|Get-Command|Get-Date|Select-String|Find-String|Test-Path|Get-Process|Get-Service|Get-ItemProperty|Get-Variable|Get-FileHash|cat|type|dir|ls|grep|findstr|more|netstat|where|echo|Write-Output|Write-Host|Get-Location|pwd|cwd|Get-Help|help|Select-Object|Out-String|Format-Table|Format-List|Format-Wide|Measure-Object|Sort-Object|Where-Object|Group-Object|ForEach-Object|Out-Null|ConvertTo-Json|ConvertFrom-Json|Join-Path|Split-Path|Resolve-Path|New-Object|Add-Type|Get-Member|Get-ChildItemProperty|Get-CimInstance|Get-WmiObject)\b)|(?:\bgit\s+(?:-[^\s]+\s+[^\s]+\s+|--[^\s]+\s+)*\b(?:status|log|diff|show|branch|tag|remote|rev-parse|diff-tree|ls-files)(?:\s|$))|(?:\bdsh\s+(?:--version|--help|--dump-config|plugin\s+(?:list|show|status))(?:\s|$))|(?:\bdsh\s+--profile\s+[^\s]+\s+(?:--dump-config|plugin\s+(?:list|show|status))(?:\s|$))|(?:\bnode\s+(?:--check|--test|--version)(?:\s|$))|(?:\bnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bpnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bgh\s+(?:auth\s+status|repo\s+view)(?:\s|$))|(?:\bnpm\s+(?:ls|view)(?:\s|$))/i;
|
|
254
261
|
const MUTATING_CMD_RE =
|
|
255
|
-
/(?:Set-Content|Add-Content|Out-File|Tee-Object|Remove-Item|Move-Item|Copy-Item|Rename-Item|New-Item|Clear-Content|Start-Process|Invoke-Expression|Invoke-Item|Export-Csv|Export-Clixml|Export-ModuleMember|Compress-Archive|Expand-Archive|Add-Type\s+-Output(?:Assembly|Type)|git\s+(?:push|commit)|rm\s+-r|rmdir\s+\/s|del\s+\/s|(?:^|[^0-9])
|
|
262
|
+
/(?:Set-Content|Add-Content|Out-File|Tee-Object|Remove-Item|Move-Item|Copy-Item|Rename-Item|New-Item|Clear-Content|Start-Process|Invoke-Expression|Invoke-Item|Export-Csv|Export-Clixml|Export-ModuleMember|Compress-Archive|Expand-Archive|Add-Type\s+-Output(?:Assembly|Type)|git\s+(?:push|commit)|rm\s+-r|rmdir\s+\/s|del\s+\/s|(?:^|[^0-9])(?:\d*>|\d*>>|>|>>)(?!\s*&\s*\d|\s*\$?(?:null|nul)\b)|(?:curl|wget|iwr|Invoke-WebRequest|Invoke-RestMethod)\s+(?=[^\n;|]*\s(?:-o|--output|-OutFile|OutFile)\s))/i;
|
|
256
263
|
// 0.5.12(F4):PowerShell 别名展开表——写/删除/移动/执行别名 → 标准 cmdlet 名
|
|
257
264
|
// 供 normalizeAliases 做命令规范化(MUTATING_CMD_RE 覆盖完整 cmdlet;别名先展开再判)
|
|
258
265
|
const PS_ALIAS_MAP = [
|
|
@@ -593,7 +600,7 @@ export function isAuditCommand(command) {
|
|
|
593
600
|
*/
|
|
594
601
|
export function isVerificationCommand(command) {
|
|
595
602
|
if (typeof command !== "string") return false;
|
|
596
|
-
if (/\b(?:node|npm|npx)\s+[^\n]*(?:run-all\.mjs|
|
|
603
|
+
if (/\b(?:node|npm|npx)\s+[^\n]*(?:run-all\.mjs|audit-mount-consistency\.mjs|loader-smoke\.e2e\.mjs|verify-all\.mjs|health-audit\.mjs|check-tool-coverage\.mjs|publish-aptitude-check\.mjs|\.test\.mjs\b|--check|--test|--dry-run)\b/i.test(command)) return true;
|
|
597
604
|
if (/\b(?:npm|pnpm)\s+(?:test|run\s+test)\b/i.test(command)) return true;
|
|
598
605
|
if (/\bgh\s+api\b/i.test(command)) {
|
|
599
606
|
// 只读 GET 形态;写形态(--method/-X 显式方法、-F/--field 表单)不豁免(gg api 写=执行类)
|
package/lib/core/state.js
CHANGED
|
@@ -265,7 +265,7 @@ export function freshTurn() {
|
|
|
265
265
|
reasoningText: "",
|
|
266
266
|
// 规则 22 粒度升级(2026-08-24):本回合已获授权范围(execute 子句 + ask 授权)
|
|
267
267
|
scopes: [],
|
|
268
|
-
// M8 双通道机制(2026-08-24
|
|
268
|
+
// M8 双通道机制(2026-08-24):统一入口落盘后同轮 engram_store 校验(entryMarker 经配置)
|
|
269
269
|
manualWriteSeen: false,
|
|
270
270
|
engramStoreSeen: false
|
|
271
271
|
};
|
package/lib/core/text-detect.js
CHANGED
|
@@ -144,7 +144,7 @@ export function detectTimeRule(session, text, timeCfg) {
|
|
|
144
144
|
* @param {string} options.text assistant 纯文本
|
|
145
145
|
* @returns {Array<{ruleId:string,title:string,kind:string,reason:string}>}
|
|
146
146
|
*/
|
|
147
|
-
export function detectViolations({ configs, session, text, reasoningText = "", mountRevision = 0 }) {
|
|
147
|
+
export function detectViolations({ configs, session, text, reasoningText = "", mountRevision = 0, rule5Window = 3 }) {
|
|
148
148
|
const hits = [];
|
|
149
149
|
const byId = new Map(configs.filter((c) => c.confidence !== "low").map((c) => [String(c.ruleId), c]));
|
|
150
150
|
|
|
@@ -195,15 +195,16 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
|
|
|
195
195
|
});
|
|
196
196
|
} else if (INTERNAL_REF_RE.test(text) && !isQuoteOrParaphraseContext(text, INTERNAL_REF_RE)) {
|
|
197
197
|
// 规则 5 扩展(2026-09-01 用户拍板):内部文档引用(手册/踩坑/条款/源码…)须有依据——
|
|
198
|
-
// 近 3
|
|
198
|
+
// 近 rule5Window 回合(默认 3;配置层 rule-engine.json `rule5SourceWindow` 可覆盖,2026-09-03
|
|
199
|
+
// 通用化修正:本机偏好走配置、通用默认保持 3)无对应 read/grep 时提示(B 级:留痕+注入,不拦截)。
|
|
199
200
|
const curTurn = session.turn.number || 0;
|
|
200
201
|
const lastQuery = session.lastQueryTurn ?? -1;
|
|
201
|
-
if (lastQuery < 0 || curTurn - lastQuery >
|
|
202
|
+
if (lastQuery < 0 || curTurn - lastQuery > rule5Window) {
|
|
202
203
|
hits.push({
|
|
203
204
|
ruleId: "5",
|
|
204
205
|
title: sourceCfg.title,
|
|
205
206
|
kind: "correct",
|
|
206
|
-
reason:
|
|
207
|
+
reason: `回答引用内部文档(手册/踩坑/条款/源码等)但近 ${rule5Window} 回合无对应 read/grep——请标注手册位置或删去断言`
|
|
207
208
|
});
|
|
208
209
|
}
|
|
209
210
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// dsh-rule-engine —— DSH 规则执行引擎 v3(host 插件,纯 Node)
|
|
2
2
|
// 容器:解析 AGENTS.md → 理解器 → 匹配机 → 执行框架。
|
|
3
3
|
// 执行框架:ctx.tools.guard() 硬拦 + session/event 文本纠察 + 审计台账 + /guard 命令。
|
|
4
|
-
import { readFileSync, watch, writeFileSync } from "node:fs";
|
|
4
|
+
import { readFileSync, watch, writeFileSync, existsSync } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { audit, readAuditLog } from "./core/audit.js";
|
|
7
7
|
import { loadPluginConfig } from "./core/config.js";
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
import { toolClass } from "./core/tool-catalog.js";
|
|
62
62
|
import { parseWhitelist, mergeWhitelist, serializeWhitelist } from "./core/whitelist.js";
|
|
63
63
|
import { state } from "./core/runtime.js";
|
|
64
|
+
import { APPROVE_TYPES, normalizePath, setTypeHints } from "./core/authorization.js";
|
|
64
65
|
import { DELIVERY_RE, detectViolations, extractAssistantText } from "./core/text-detect.js";
|
|
65
66
|
import { buildTurnCard } from "./core/turn-card.js";
|
|
66
67
|
import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
|
|
@@ -101,6 +102,8 @@ export const name = "dsh-rule-engine";
|
|
|
101
102
|
export const inject = ["tools", "commands", "agents", "workspaceRegistry", "skills", "llm"];
|
|
102
103
|
|
|
103
104
|
const pluginConfig = loadPluginConfig();
|
|
105
|
+
// C4:启动时合并配置层 typeHints(本机扩展授权类型;默认空=仅内置 8+archive 类)
|
|
106
|
+
setTypeHints(pluginConfig.typeHints);
|
|
104
107
|
state.enabled = pluginConfig.enabled;
|
|
105
108
|
applyTaskContractConfig(state, pluginConfig);
|
|
106
109
|
// T3 通用化(2026-08-31):声明式绑定覆盖表注入(rule-engine.json 可选键 handlerOverrides;缺省 {})
|
|
@@ -108,6 +111,44 @@ state.handlerOverrides = pluginConfig?.handlerOverrides || {};
|
|
|
108
111
|
// 残余1 剥离(2026-08-31):本机默认偏好表注入(rule-engine.json 可选键 handlerDefaultMap;
|
|
109
112
|
// 通用部署=空表——代码零本机编号,本机偏好不随包走、升级不丢)
|
|
110
113
|
state.handlerDefaultMap = pluginConfig?.handlerDefaultMap || {};
|
|
114
|
+
// P2(2026-09-04):本机集成参数化层(localIntegrations)——默认空=通用行为不变;
|
|
115
|
+
// 本机专属约定(统一入口脚本名/M8 双通道/手册路径豁免扩展)由配置注入,代码零本机字面量
|
|
116
|
+
state.localIntegrations = pluginConfig?.localIntegrations || {};
|
|
117
|
+
// A-8(0.6.0,启动自检硬验收):localIntegrations 存在但 entryScript 指向的脚本文件在磁盘不存在
|
|
118
|
+
// → 启动即审计告警(失败不阻断加载,但留下可观测证据——无声守卫消失风险的对策)
|
|
119
|
+
// 0.6.0 修正:裸文件名(无路径段)视为脚本名——可能在工作区/全局 PATH,
|
|
120
|
+
// 不告警(防误报);仅当 entryScript 含明确路径段(\ / 盘符)且文件不存在时才告警。
|
|
121
|
+
{
|
|
122
|
+
const li = state.localIntegrations;
|
|
123
|
+
const script = li?.entryScript;
|
|
124
|
+
const isBareName = typeof script === "string" && /^[A-Za-z0-9._-]+$/.test(script.replace(/\\/g, "/"));
|
|
125
|
+
if (typeof script === "string" && script && !isBareName) {
|
|
126
|
+
let found = false;
|
|
127
|
+
try { found = existsSync(script); } catch { found = false; }
|
|
128
|
+
if (!found) {
|
|
129
|
+
audit({
|
|
130
|
+
kind: "entry-script-missing",
|
|
131
|
+
rule: "__entry-script-missing",
|
|
132
|
+
name: "启动自检:统一入口脚本不存在",
|
|
133
|
+
event: "startup",
|
|
134
|
+
reason: `localIntegrations.entryScript 指向的脚本(${script})在磁盘不存在——本机守卫仍按配置工作,但入口通道可能失效;请检查配置或部署该脚本`,
|
|
135
|
+
session: "global"
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// li-skipped(0.6.0 A2-2 补充):无 localIntegrations.entryScript → 启动登记一次 skipped 审计
|
|
141
|
+
// (验证"默认无"留痕;登录于启动/配置加载时,而非每次工具调用——修正此前写类分支被 12A 前置拦截的缺陷)
|
|
142
|
+
if (!state.localIntegrations?.entryScript) {
|
|
143
|
+
audit({
|
|
144
|
+
kind: "li-skipped",
|
|
145
|
+
rule: "19",
|
|
146
|
+
name: "本地集成未配置(守卫无对象)",
|
|
147
|
+
event: "startup",
|
|
148
|
+
reason: "skipped: no localIntegrations.entryScript——该守卫在此环境不存在(0.6.0 默认无)",
|
|
149
|
+
session: "global"
|
|
150
|
+
});
|
|
151
|
+
}
|
|
111
152
|
// reloadRules 内部已统一刷新理解产物(P0-3),此处不再重复写
|
|
112
153
|
reloadRules(state);
|
|
113
154
|
|
|
@@ -434,15 +475,18 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
434
475
|
return;
|
|
435
476
|
}
|
|
436
477
|
if (event.type === "turn/end") {
|
|
437
|
-
// M8
|
|
478
|
+
// M8 双通道机制:本回合用统一入口落盘手册/AGENTS 后,必须同轮 engram_store 沉淀记忆;
|
|
438
479
|
// 缺失 → 审计 + 注入纠正(用户明确要求机制化,2026-08-24)
|
|
439
|
-
|
|
480
|
+
// A3-1(0.6.0):默认关闭 → 显式 enabled:true + 配置 entryMarker 才生效(无配置 = M8 机制整体不存在)
|
|
481
|
+
const m8Cfg = state.localIntegrations?.m8 || {};
|
|
482
|
+
const m8Marker = m8Cfg.entryMarker;
|
|
483
|
+
if (m8Cfg.enabled === true && m8Marker && s.turn.manualWriteSeen && !s.turn.engramStoreSeen) {
|
|
440
484
|
audit({
|
|
441
485
|
kind: "engram-gap",
|
|
442
486
|
rule: "__engram-gap",
|
|
443
487
|
name: "双通道记忆缺失",
|
|
444
488
|
event: "turn/end",
|
|
445
|
-
reason: "
|
|
489
|
+
reason: "本回合经本机配置的统一入口落盘了手册/AGENTS,但未同轮调用 engram_store;按规则 19/77/M8 应在同一回合完成记忆沉淀",
|
|
446
490
|
session: sid
|
|
447
491
|
});
|
|
448
492
|
maybeInject(ctx, sid, {
|
|
@@ -659,7 +703,7 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
659
703
|
pendingCall.backupPaths.push(bpTool);
|
|
660
704
|
}
|
|
661
705
|
}
|
|
662
|
-
if (isManualReadTool(toolName, args)) s.manualReadSeen = true;
|
|
706
|
+
if (isManualReadTool(toolName, args, state.localIntegrations?.manualExempt?.paths || [])) s.manualReadSeen = true;
|
|
663
707
|
// 规则 5/31 扩展(2026-09-01):查询类工具调用记下回合号("近 3 回合有据"判定)
|
|
664
708
|
if (isReadOnlyTool(toolName, args)) s.lastQueryTurn = s.turn.number;
|
|
665
709
|
if (toolName === "skill" && args?.name) s.turn.skillNames.push(args.name);
|
|
@@ -772,10 +816,13 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
772
816
|
}
|
|
773
817
|
}
|
|
774
818
|
|
|
775
|
-
// M8 双通道机制(2026-08-24
|
|
819
|
+
// M8 双通道机制(2026-08-24):统一入口落盘成功 → 标记;同轮 engram_store 成功 → 标记
|
|
820
|
+
// A3-1(0.6.0):默认关闭 → 显式 enabled:true + 配置 entryMarker 才生效(无配置 = M8 机制整体不存在)
|
|
776
821
|
if (!isError && pendingCall) {
|
|
777
822
|
const cmd = pendingCall.args?.command || pendingCall.args?.code || "";
|
|
778
|
-
|
|
823
|
+
const m8Cfg2 = state.localIntegrations?.m8 || {};
|
|
824
|
+
const marker = m8Cfg2.entryMarker;
|
|
825
|
+
if (m8Cfg2.enabled === true && marker && (pendingCall.name === "pwsh" || pendingCall.name === "bash") && cmd.includes(marker)) {
|
|
779
826
|
s.turn.manualWriteSeen = true;
|
|
780
827
|
}
|
|
781
828
|
if (pendingCall.name === "engram_store") {
|
|
@@ -1036,7 +1083,7 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
1036
1083
|
rule: "23",
|
|
1037
1084
|
name: "交付声明缺验证闸门记录",
|
|
1038
1085
|
event: "assistant/message",
|
|
1039
|
-
reason: "交付/完成类声明缺少同会话近期 verify-pass
|
|
1086
|
+
reason: "交付/完成类声明缺少同会话近期 verify-pass(测试全绿或冷加载探针 RESULT: PASS)记录",
|
|
1040
1087
|
session: sid
|
|
1041
1088
|
});
|
|
1042
1089
|
// v0.5.7 后续(用户拍板"暗示型统一裁决"):23④ 词面命中只是嫌疑——"完成"≠交付声明
|
|
@@ -1081,7 +1128,7 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
1081
1128
|
}
|
|
1082
1129
|
}
|
|
1083
1130
|
// F1(2026-08-28 阶段三):规则 2 违规不在此时投递——标记 pendingRule2,turn/end 复核(Get-Date 定案)①
|
|
1084
|
-
let violations = detectViolations({ configs: state.configs, session: s, text, reasoningText: s.turn.reasoningText, mountRevision: state.mountRevision });
|
|
1131
|
+
let violations = detectViolations({ configs: state.configs, session: s, text, reasoningText: s.turn.reasoningText, mountRevision: state.mountRevision, rule5Window: pluginConfig.rule5SourceWindow ?? 3 });
|
|
1085
1132
|
const rule2s = violations.filter((v) => v.ruleId === "2");
|
|
1086
1133
|
for (const v of rule2s) {
|
|
1087
1134
|
if (!s.turn.pendingRule2) s.turn.pendingRule2 = v.reason;
|
|
@@ -1148,7 +1195,8 @@ const COMMAND_SPECS = [
|
|
|
1148
1195
|
{ name: "budget", args: "...", desc: "设置预算(agents=N files=... deps=allow hash=allow)" },
|
|
1149
1196
|
{ name: "contract", args: "", desc: "查看当前任务契约" },
|
|
1150
1197
|
{ name: "contract categories", args: "...", desc: "设定契约类别白名单(0.5.12)" },
|
|
1151
|
-
{ name: "label", args: "<id> <label>", desc: "给审计记录打标(correct/incorrect/inconclusive)" }
|
|
1198
|
+
{ name: "label", args: "<id> <label>", desc: "给审计记录打标(correct/incorrect/inconclusive)" },
|
|
1199
|
+
{ name: "approve", args: "<type> <路径> [min]", desc: "物理确认:授予指定类型+路径的临时授权(仅用户输入;默认 10 分钟)" }
|
|
1152
1200
|
];
|
|
1153
1201
|
|
|
1154
1202
|
const USAGE = [
|
|
@@ -1204,6 +1252,17 @@ function parseCommand(rawInput) {
|
|
|
1204
1252
|
if (m) return { kind: "label", eventId: m[1], label: m[2].toLowerCase() };
|
|
1205
1253
|
m = text.match(/^label\s+clear\s+(.+)$/i);
|
|
1206
1254
|
if (m) return { kind: "label-clear", fingerprint: m[1].trim() };
|
|
1255
|
+
// C4:/guard approve <type> <路径> [min]——物理确认(类型在 executeGuard 校验枚举)
|
|
1256
|
+
m = text.match(/^approve\s+([A-Za-z_][A-Za-z0-9_-]*)\s*("(?:[^"]*)"|'(?:[^']*)'|\S+)(?:\s+(\d+))?$/i);
|
|
1257
|
+
if (m) {
|
|
1258
|
+
const p = m[2];
|
|
1259
|
+
return {
|
|
1260
|
+
kind: "approve",
|
|
1261
|
+
type: m[1].toLowerCase(),
|
|
1262
|
+
path: p.replace(/^["']|["']$/g, ""),
|
|
1263
|
+
minutes: m[3] ? Number(m[3]) : 10
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1207
1266
|
return { kind: "invalid" };
|
|
1208
1267
|
}
|
|
1209
1268
|
|
|
@@ -1281,6 +1340,30 @@ async function executeGuard(ctx, invocation) {
|
|
|
1281
1340
|
text: `已临时放行全部守卫 ${minutes} 分钟。到期自动恢复,也可 /guard reload 后立即恢复。`
|
|
1282
1341
|
};
|
|
1283
1342
|
}
|
|
1343
|
+
case "approve": {
|
|
1344
|
+
// C4(2026-09-03)物理确认:用户亲手输入=词表无法误读;最小范围(类型+路径+时长);无全局通配
|
|
1345
|
+
if (!loadPluginConfig().approveEnabled) {
|
|
1346
|
+
return { kind: "error", text: "/guard approve 未开启:请在 rule-engine.json 设 approveEnabled=true(设置页开关随后续版本)后使用" };
|
|
1347
|
+
}
|
|
1348
|
+
if (!APPROVE_TYPES().includes(command.type)) {
|
|
1349
|
+
return { kind: "error", text: `不支持的类型 ${command.type}(可用:${APPROVE_TYPES().join("/")};any=全局通配被禁止——物理确认必须最小范围)` };
|
|
1350
|
+
}
|
|
1351
|
+
if (!command.path) return { kind: "error", text: "路径缺失:/guard approve <type> <路径> [min](建议路径用双引号包裹,如 /guard approve write \"D:\\...\\file.json\" 10)" };
|
|
1352
|
+
const minutes = Math.min(Math.max(1, command.minutes), 720);
|
|
1353
|
+
const pfx = normalizePath(command.path);
|
|
1354
|
+
const sid = sessionIdOfInvocation(invocation);
|
|
1355
|
+
recordAuthorization(state, sid, {
|
|
1356
|
+
type: command.type,
|
|
1357
|
+
pathPrefix: pfx,
|
|
1358
|
+
source: "physical-confirm",
|
|
1359
|
+
expiresAt: Date.now() + minutes * 60000
|
|
1360
|
+
});
|
|
1361
|
+
audit({ kind: "guard-command", rule: "__physical-confirm", name: "物理确认授权", event: "command", reason: `/guard approve ${command.type} ${pfx} ${minutes}m`, session: sid });
|
|
1362
|
+
return {
|
|
1363
|
+
kind: "success",
|
|
1364
|
+
text: `已物理确认授权:${command.type}|${pfx}|${minutes} 分钟。仅该类型+该路径(含子路径)生效;/guard revoke 可立即撤销;到期自动失效。`
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1284
1367
|
case "lock": {
|
|
1285
1368
|
state.unlockUntil = 0;
|
|
1286
1369
|
state.bypassUntil = 0;
|
|
@@ -1554,7 +1637,10 @@ export function apply(ctx) {
|
|
|
1554
1637
|
//(调整/补充/评估/建议且无落盘词)→ approval-gap 审计 + 注入提醒(规则 22 自证③:方案性指令 ≠ 落盘授权)
|
|
1555
1638
|
{
|
|
1556
1639
|
const cmd = exec?.arguments?.command || exec?.arguments?.code || "";
|
|
1557
|
-
|
|
1640
|
+
const entryScript = state.localIntegrations?.entryScript;
|
|
1641
|
+
// A3-2(0.6.0):入口脚本名从配置读取并转义;无配置不走本分支。
|
|
1642
|
+
// 边界裁决(v1.3 关键裁决 4):正则保持无尾部 \b(保守匹配,xxx.mjs.bak 等变体同样命中)。
|
|
1643
|
+
if ((exec?.name === "pwsh" || exec?.name === "bash") && entryScript && new RegExp(`\\b${entryScript.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(cmd)) {
|
|
1558
1644
|
const s7 = getSessionState(state, sessionIdOfExec(exec));
|
|
1559
1645
|
if (needsApprovalReminder(s7.turn.userText || "")) {
|
|
1560
1646
|
audit({
|
package/lib/service.js
CHANGED
|
@@ -204,7 +204,8 @@ class RuleEngineService extends TypertRemoteService {
|
|
|
204
204
|
askEnabled: conf.askEnabled === true,
|
|
205
205
|
taskContractMode: conf.taskContractMode === "armed" ? "armed" : "observe",
|
|
206
206
|
taskContractDefaults: conf.taskContractDefaults || {},
|
|
207
|
-
turnCard: { enabled: conf.turnCard?.enabled === true }
|
|
207
|
+
turnCard: { enabled: conf.turnCard?.enabled === true },
|
|
208
|
+
approveEnabled: conf.approveEnabled === true
|
|
208
209
|
}
|
|
209
210
|
};
|
|
210
211
|
} catch (error) {
|
|
@@ -215,6 +216,7 @@ class RuleEngineService extends TypertRemoteService {
|
|
|
215
216
|
/** 保存任务契约配置(设置页;写入 rule-engine.json 并热同步 state) */
|
|
216
217
|
async setTaskContractConfig(partial) {
|
|
217
218
|
try {
|
|
219
|
+
audit({ kind: "guard-command", rule: "__settings-save", name: "设置页保存", event: "command", reason: `setTaskContractConfig keys=${JSON.stringify(Object.keys(partial || {}))}`, session: "global" });
|
|
218
220
|
const conf = savePluginConfig(partial || {});
|
|
219
221
|
applyTaskContractConfig(state, conf);
|
|
220
222
|
return {
|
|
@@ -224,7 +226,8 @@ class RuleEngineService extends TypertRemoteService {
|
|
|
224
226
|
askEnabled: conf.askEnabled === true,
|
|
225
227
|
taskContractMode: conf.taskContractMode === "armed" ? "armed" : "observe",
|
|
226
228
|
taskContractDefaults: conf.taskContractDefaults || {},
|
|
227
|
-
turnCard: { enabled: conf.turnCard?.enabled === true }
|
|
229
|
+
turnCard: { enabled: conf.turnCard?.enabled === true },
|
|
230
|
+
approveEnabled: conf.approveEnabled === true
|
|
228
231
|
}
|
|
229
232
|
};
|
|
230
233
|
} catch (error) {
|