dsh-rule-engine 0.5.6 → 0.5.8
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 +37 -0
- package/lib/core/guard-core.js +23 -3
- package/lib/core/judge.js +85 -0
- package/lib/core/llm-understander.js +5 -2
- package/lib/core/paths.js +10 -0
- package/lib/core/patterns.js +102 -16
- package/lib/core/semantic.js +43 -0
- package/lib/core/state.js +21 -0
- package/lib/core/text-detect.js +13 -3
- package/lib/index.js +165 -16
- package/package.json +2 -1
- package/scripts/health-audit.mjs +60 -0
- package/scripts/verify-all.mjs +81 -0
package/README.md
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# dsh-rule-engine
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
3
6
|
DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。
|
|
4
7
|
|
|
8
|
+
> 当前版本 **0.5.7**(2026-08-26 发布)。本插件面向"规则机器化执行":规则写在 AGENTS.md 里,引擎负责让它们真的被遵守;所有规则动态解析,规则增删改后无需重写插件。
|
|
9
|
+
|
|
5
10
|
## 项目背景
|
|
6
11
|
|
|
7
12
|
这个项目来自一个非常具体的个人需求:
|
|
@@ -22,6 +27,32 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
|
|
|
22
27
|
|
|
23
28
|
当前实现以「模式库兜底」为主,LLM 理解器预留扩展点;所有规则均从 AGENTS.md 实时解析。
|
|
24
29
|
|
|
30
|
+
## 注入噪音治理(0.5.6 / 0.5.7)
|
|
31
|
+
|
|
32
|
+
只提醒真正值得提醒的事——这条原则贯穿 0.5.6 与 0.5.7:
|
|
33
|
+
|
|
34
|
+
- **0.5.6**:同一回复中多条违规 → 聚合为一条注入(明细全在 `/guard log`);回复含「规则 X 已自证/已核对…」标记 → 该规则当轮不再重复触发;C2 规则统计(detected/suppressed/injected,`getRuleStats` 面板接口)。
|
|
35
|
+
- **0.5.7(错误才值得被提醒)**:
|
|
36
|
+
1. **词表只产嫌疑**:语义型命中不再直接定罪,标记 `awaitingJudge` 送裁决;
|
|
37
|
+
2. **LLM 裁决**:给「规则正文 + 完整回复」判定是否真违规——合规声明/引述/非违规 → 不投递(`judge-false`);仅裁决为违规才投递(`judge-pass`);裁决不可用 → 不投递(**fail-closed**,`judge-unavailable`)。模型**追随会话模型**,`sha256` 缓存 + 每日 50 次/会话预算;
|
|
38
|
+
3. **注入轮不检测**:没有真实用户消息的回合(引擎注入触发的轮次)不做检测与投递——这是"引擎自己打乒乓"循环的根治(燃料=它对自身回声的检测);
|
|
39
|
+
4. **投递资格闸**:同规则同会话仅提醒一次(记住已处理);会话每小时至多 3 条弹窗;
|
|
40
|
+
5. **审计完整性**:嫌疑/裁决/拦截/投递全部写入 `/guard log`——"看了不冤枉"的凭据。
|
|
41
|
+
|
|
42
|
+
## 质量与验证(2026-08-26,对齐官方 docs/testing.zh.md)
|
|
43
|
+
|
|
44
|
+
- `npm run test`(全量单测;`test/run-all.mjs` 统一入口,注意 ESM 缓存顺序约定);
|
|
45
|
+
- `node scripts/verify-all.mjs` —— 交付前四层体检:语法(lib 全文件 `node --check`)→ 单元(run-all)→ **组合冒烟**(`test/loader-smoke.e2e.mjs`:真实引擎代码 + 真实审计文件,仅 mock LLM 边界,断言**外部世界**——审计文件里真的出现 judge-false/judge-pass 记录,而非自我报告)→ **真实判例**(近 24h 台账 judge-pass/false 记录数,0 条 = WARN 提示需实弹);
|
|
46
|
+
- `node scripts/health-audit.mjs` —— 找茬清单:近 24h 失败/降级类统计(intent-llm 失败、judge-unavailable、verify-gap、inject-skip…)+ 关键导出接线交叉(疑似未接线 = 告警)——"失败可见化",不再有静默躺 20 小时的降级;
|
|
47
|
+
- 执行协议(本仓库自身交付纪律):方案冻结单(范围/影响面/测试计划/失败预测)→ todo 化 → 小步闭环(每改动立即 `node --check`)→ 对账交付(计划×实际逐项 ✅/❌/跳过原因)。
|
|
48
|
+
|
|
49
|
+
## 0.5.8(2026-08-26 追加)
|
|
50
|
+
|
|
51
|
+
- **白名单持久化**:"允许使用 X"(未归类工具批准)落盘 `~/.dsh/rule-engine-tools.json`——此前为内存态,热重载/重启即清(用户需反复重发,实测 esr_task/esr_close 各被清一次);
|
|
52
|
+
- **只读命令词表补全**:`Select-Object / Out-String / Format-Table / Measure-Object / Sort-Object / Where-Object / Group-Object / Out-Null / ConvertTo-Json` 等常见只读 cmdlet 纳入只读判定(此前 `Get-Item | Select-Object` 被误判为变更拦截)——含"管道内含写操作仍判变更"的安全回归;
|
|
53
|
+
- **`npm run verify`** 注册(= `scripts/verify-all.mjs` 四层体检别名);
|
|
54
|
+
- 工作区内低风险变更豁免(12A 正文对齐:只读诊断脚本不再被拦——0.5.7 的延续项)。
|
|
55
|
+
|
|
25
56
|
## 任务契约与反过度工程(可选)
|
|
26
57
|
|
|
27
58
|
- 默认**关闭**;可在规则引擎设置页开启「任务边界与反过度工程」总开关。
|
|
@@ -88,6 +119,7 @@ dsh plugin --profile web add dsh-rule-engine
|
|
|
88
119
|
- 技能目录实时联动:`ctx.skills` 目录变化后自动刷新,已禁用/不存在的技能不触发 12B
|
|
89
120
|
- LLM 增量理解:对非 high 置信规则调用 `ctx.llm` 补全结构化理解,失败自动回退模式库;AGENTS.md 变化触发重载后会自动补一次增量理解(按规则+版本去重,不重复烧 token)
|
|
90
121
|
- D 级自证泛化:按规则特征触发自证提示,每规则每会话限 3 次
|
|
122
|
+
- D 级自证泛化(0.5.7 起取代上行语义):词表只产嫌疑,LLM 裁决确认错误才提醒;同规则同会话仅提醒一次 + 会话每小时 3 条弹窗预算;无真实用户消息的回合不检测不投递(乒乓根治)
|
|
91
123
|
- 授权记录默认 10 分钟 TTL,无路径的全局授权 TTL 缩短为 2 分钟;可用 `/guard revoke` 撤销
|
|
92
124
|
- 用户直接命令式指令(如“删除这个文件”)也视为授权
|
|
93
125
|
- 规则 1 支持“用户明确要求重试”豁免
|
|
@@ -116,6 +148,7 @@ dsh plugin --profile web add dsh-rule-engine
|
|
|
116
148
|
|
|
117
149
|
5. **跨会话持久化**
|
|
118
150
|
授权/备份目前为内存态,重启失效。持久化涉及写入保护、并发与恢复,风险较高,暂未实现。
|
|
151
|
+
注(0.5.7):验证通过记录 verifyPass 已持久化(`~/.dsh/rule-engine-verify.json`,热重载/重启不丢)——规则 23④ 证据链;授权/备份仍为内存态。
|
|
119
152
|
|
|
120
153
|
6. **输出文本实时拦截**
|
|
121
154
|
受 DSH 官方架构限制,`assistant/message` 无法“拦下不发”,只能事后审计 + 纠正注入;这是平台边界,不是插件能单独突破的。
|
|
@@ -147,6 +180,10 @@ npm test
|
|
|
147
180
|
bash scripts/build.sh
|
|
148
181
|
```
|
|
149
182
|
|
|
183
|
+
交付前体检(0.5.7 起):`node scripts/verify-all.mjs`(四层:语法/单元/组合冒烟/真实判例)与 `node scripts/health-audit.mjs`(找茬)——详见「质量与验证」。
|
|
184
|
+
|
|
185
|
+
发布:`node ../../scripts/release-plugin.mjs <插件目录> <版本号>`(一键 npm + git + GitHub Release,见 `scripts/release-plugin.mjs` 注释)。
|
|
186
|
+
|
|
150
187
|
## License
|
|
151
188
|
|
|
152
189
|
MIT
|
package/lib/core/guard-core.js
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
isReadOnlyTool,
|
|
21
21
|
isSensitiveToolCall,
|
|
22
22
|
isVariablePath,
|
|
23
|
+
isVerificationCommand,
|
|
24
|
+
isLowRiskWorkspaceNew,
|
|
23
25
|
pathTarget
|
|
24
26
|
} from "./patterns.js";
|
|
25
27
|
import { authMatches, describeAuth, describeOp, describeScopes, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText, scopesFromIntents } from "./authorization.js";
|
|
@@ -337,6 +339,12 @@ function matchRule(cfg, ctx) {
|
|
|
337
339
|
}
|
|
338
340
|
if (cls === "unknown") return null; // 物理不拦;pre-execute ask 层首调询问
|
|
339
341
|
if (denyMutation) {
|
|
342
|
+
// v0.5.7 后续(用户拍板):工作区内低风险变更豁免——12A 正文豁免的机器实现
|
|
343
|
+
//(只读诊断脚本被拦的最终修复:此前 --profile 模板/多根/验证伴生已修,本项补齐)
|
|
344
|
+
if (isLowRiskWorkspaceNew(name, args)) {
|
|
345
|
+
audit?.({ kind: "allow", rule: cfg.ruleId, name: "低风险新建豁免", tool: name, reason: `工作区内低风险变更(12A 正文豁免):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
340
348
|
// LLM 意图兜底(方案 A,2026-08-24):词表判拦 + LLM 高/低置信判 execute → 放行
|
|
341
349
|
//(非对称:LLM 只解救不收紧;审计在预取时已记 intent-llm)
|
|
342
350
|
const verdict = verdictForDeny(session.turn, state.llmIntentCfg || {});
|
|
@@ -353,6 +361,18 @@ function matchRule(cfg, ctx) {
|
|
|
353
361
|
if (isReadOnlyTool(name, args)) return null;
|
|
354
362
|
if (!COVERED_MUTATION_TOOLS.has(name) && !looksLikeFileMutation(name, args)) return null;
|
|
355
363
|
const op = operationOf(name, args);
|
|
364
|
+
// v0.5.7 P0-1(用户拍板 2026-08-26):验证类命令伴生放行——本回合已有 write/any/command
|
|
365
|
+
// 授权(用户已批"修改/执行")时,运行测试/冷加载/审计/语法检查属于该变更的验证闭环,
|
|
366
|
+
// 不再被 write vs command 类型不匹配误拦(今天实弹连卡 4 次)。
|
|
367
|
+
if ((name === "pwsh" || name === "bash") && isVerificationCommand(cmd)) {
|
|
368
|
+
const verScopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
|
|
369
|
+
? session.turn.scopes
|
|
370
|
+
: scopesFromIntents(turnIntents);
|
|
371
|
+
if (verScopes.some((s) => s.type === "write" || s.type === "any" || s.type === "command")) {
|
|
372
|
+
audit?.({ kind: "allow", rule: cfg.ruleId, name: "验证命令伴生放行", tool: name, reason: `本回合已有 ${describeScopes(verScopes)} 授权 → 验证命令伴生(${describeOp(op)})`, session: sessionIdOf(exec) });
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
356
376
|
// 保护性备份豁免(2026-08-26,backup 口径修复):规则 12A 豁免③ / 13A ⑦ 均豁免
|
|
357
377
|
// 复制到 .backups/.bak 的保护性备份——与 13A 分支口径一致,backup 类型不再被 22 粒度误拦。
|
|
358
378
|
if (op.type === "backup") return null;
|
|
@@ -411,7 +431,7 @@ function matchRule(cfg, ctx) {
|
|
|
411
431
|
// 统一入口命令豁免:dsh-manual-write.mjs 每次写入前自身执行备份(backup() 保留 5 份),
|
|
412
432
|
// 引擎静态扫描看不到脚本内部动作(已知盲区);入口命令也已被 __self-protect 限定为唯一写通道。
|
|
413
433
|
if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd)) return null;
|
|
414
|
-
const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args) && !/git\s+(push|commit)/i.test(cmd)));
|
|
434
|
+
const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args, sessionIdOf(exec)) && !/git\s+(push|commit)/i.test(cmd)));
|
|
415
435
|
const highRiskWrite = (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p);
|
|
416
436
|
if (highRiskWrite && unlock && !isHighRiskEntryFile(p)) return null;
|
|
417
437
|
if (destructive || highRiskWrite) {
|
|
@@ -486,7 +506,7 @@ function matchRule(cfg, ctx) {
|
|
|
486
506
|
|
|
487
507
|
// 规则 12A:敏感操作需要匹配授权证据
|
|
488
508
|
if (cfg.handler === "rule12a-approval") {
|
|
489
|
-
if (isSensitiveToolCall(name, args)) {
|
|
509
|
+
if (isSensitiveToolCall(name, args, sessionIdOf(exec))) {
|
|
490
510
|
// 规则 19:dsh-usage-manual/SKILL.md 正文更新免逐次确认(仅手册本身)
|
|
491
511
|
if (p && MANUAL_PATH_RE.test(p)) return null;
|
|
492
512
|
// /guard unlock 本身即用户对受保护配置的授权
|
|
@@ -577,7 +597,7 @@ function matchRule(cfg, ctx) {
|
|
|
577
597
|
if (hints.includes("manual") && session.turn.toolCount === 0 && !session.manualReadSeen && !isManualReadTool(name, args)) {
|
|
578
598
|
return makeHit(cfg, `【硬拦截】${cfg.title}`);
|
|
579
599
|
}
|
|
580
|
-
if (hints.includes("sensitive") && isSensitiveToolCall(name, args)) {
|
|
600
|
+
if (hints.includes("sensitive") && isSensitiveToolCall(name, args, sessionIdOf(exec))) {
|
|
581
601
|
if (p && MANUAL_PATH_RE.test(p)) return null;
|
|
582
602
|
if (unlock && isProtectedConfigPath(p)) return null;
|
|
583
603
|
const op = operationOf(name, args);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// judge.js - D 级嫌疑裁决器(v0.5.7,用户拍板 2026-08-26)。
|
|
2
|
+
// 原则:词表命中只是"嫌疑";"是否是错误"由 LLM 确认——只有裁决为违规的才进入投递。
|
|
3
|
+
// 模型:追随会话模型(复用 llm-understander.resolveRoute,与现有 LLM 意图判定同一路由机制,
|
|
4
|
+
// 用户拍板:不单独固定模型,避免独立路由稳定性问题)。
|
|
5
|
+
// 保护:sha256(ruleId+text) 缓存(同文本不重复调用);每会话每日 JUDGE_DAILY_LIMIT 次预算;
|
|
6
|
+
// 调用失败/超时/无路由 → 返回 unavailable(fail-closed:不投递,只审计)。
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { resolveRoute, parseJsonOutput } from "./llm-understander.js";
|
|
9
|
+
|
|
10
|
+
/** 每日裁决预算(用户拍板:50 次/会话/天;超预算 → unavailable,只审计不投递)。 */
|
|
11
|
+
export const JUDGE_DAILY_LIMIT = 50;
|
|
12
|
+
|
|
13
|
+
export function judgeCacheKey(ruleId, text) {
|
|
14
|
+
return createHash("sha256").update(`${ruleId}\n${text}`).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function judgeBudgetKey(sessionId, now = Date.now()) {
|
|
18
|
+
const d = new Date(now);
|
|
19
|
+
return `${sessionId}:${d.getUTCFullYear()}-${d.getUTCMonth() + 1}-${d.getUTCDate()}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 裁决一次嫌疑。
|
|
24
|
+
* @returns {Promise<{action:"deliver"|"suppress"|"unavailable", note:string, model?:string}>}
|
|
25
|
+
* deliver = 确认真违规 → 允许投递
|
|
26
|
+
* suppress = 确认合规声明/引述/非违规 → 不投递(审计 judge-false)
|
|
27
|
+
* unavailable = 无法裁决(预算满/无路由/调用失败)→ 不投递(fail-closed,审计 judge-unavailable)
|
|
28
|
+
*/
|
|
29
|
+
export async function judgeViolation(ctx, state, sessionId, violation, text) {
|
|
30
|
+
const key = judgeCacheKey(violation.ruleId, text);
|
|
31
|
+
const cached = state.judgeCache?.get(key);
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
|
|
34
|
+
const bKey = judgeBudgetKey(sessionId);
|
|
35
|
+
const budget = state.judgeBudget?.get(bKey) || { count: 0 };
|
|
36
|
+
if (budget.count >= JUDGE_DAILY_LIMIT) {
|
|
37
|
+
return { action: "unavailable", note: `裁决日预算已满(${JUDGE_DAILY_LIMIT}/日/会话)` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const route = await resolveRoute(ctx);
|
|
42
|
+
if (!route) return { action: "unavailable", note: "无法解析模型路由(追随会话模型解析失败)" };
|
|
43
|
+
const rawCfg = Array.isArray(state.configs)
|
|
44
|
+
? state.configs.find((c) => String(c.ruleId) === String(violation.ruleId))
|
|
45
|
+
: undefined;
|
|
46
|
+
const prompt = [
|
|
47
|
+
"你是 DSH 规则裁决器。判断下面这段【执行者回复】是否违反【规则】。只输出 JSON,不要其他内容。",
|
|
48
|
+
'JSON 格式:{"violated": true|false, "note": "一句话理由"}',
|
|
49
|
+
"判定要点:",
|
|
50
|
+
"1. 回复若是在表达合规声明(如“我不再/将避免/规则 X 已自证/无重复推销”)或引述规则文本——violated=false;",
|
|
51
|
+
"2. 只有确实发生规则禁止的行为(如真的再次推销/真的发布未声明/真的遗漏事项)才 violated=true;",
|
|
52
|
+
"3. 拿不准时给出倾向性判断(false 偏不打扰)。",
|
|
53
|
+
"规则编号:" + violation.ruleId,
|
|
54
|
+
"规则标题:" + violation.title,
|
|
55
|
+
"检测提示:" + String(violation.reason || ""),
|
|
56
|
+
"规则正文:" + (rawCfg?.body || "(未提供)"),
|
|
57
|
+
"执行者回复:",
|
|
58
|
+
text,
|
|
59
|
+
"只输出 JSON。"
|
|
60
|
+
].join("\n");
|
|
61
|
+
const messages = [{ role: "user", content: [{ type: "text", text: prompt }] }];
|
|
62
|
+
let out = "";
|
|
63
|
+
for await (const chunk of ctx.llm.stream({
|
|
64
|
+
provider: route.provider,
|
|
65
|
+
model: route.model,
|
|
66
|
+
messages,
|
|
67
|
+
maxTokens: 300,
|
|
68
|
+
temperature: 0
|
|
69
|
+
})) {
|
|
70
|
+
if (chunk && chunk.type === "text-delta" && typeof chunk.text === "string") out += chunk.text;
|
|
71
|
+
}
|
|
72
|
+
budget.count += 1;
|
|
73
|
+
state.judgeBudget.set(bKey, budget);
|
|
74
|
+
const parsed = parseJsonOutput(out);
|
|
75
|
+
const verdict = {
|
|
76
|
+
action: parsed?.violated === true ? "deliver" : "suppress",
|
|
77
|
+
note: typeof parsed?.note === "string" ? parsed.note.slice(0, 120) : "",
|
|
78
|
+
model: route.model
|
|
79
|
+
};
|
|
80
|
+
state.judgeCache.set(key, verdict);
|
|
81
|
+
return verdict;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { action: "unavailable", note: `裁决调用失败:${error instanceof Error ? error.message : String(error)}` };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -7,7 +7,10 @@ export async function resolveRoute(ctx) {
|
|
|
7
7
|
try {
|
|
8
8
|
const providers = ctx.llm.listProviders?.() || [];
|
|
9
9
|
if (providers.length === 0) return null;
|
|
10
|
-
|
|
10
|
+
// v0.5.7 修复(实弹抓到,2026-08-26):必须用 provider 的 **id**(如 "deepseek-official")调
|
|
11
|
+
// listModels/resolve——pi-ai 按 id 找 profile,name(显示名)可能不同 → 传 name 会抛
|
|
12
|
+
// NO_ADAPTER → 全部分析失败(旧 llmIntent 自启用起 31 次调用全降级的同根因)。
|
|
13
|
+
const provider = process.env.DSH_LLM_PROVIDER || providers[0]?.id || providers[0]?.name || providers[0];
|
|
11
14
|
const models = await ctx.llm.listModels?.(provider);
|
|
12
15
|
const model = process.env.DSH_LLM_MODEL || (models && models[0]?.id) || (models && models[0]) || null;
|
|
13
16
|
if (!model) return null;
|
|
@@ -35,7 +38,7 @@ function buildPrompt(rule) {
|
|
|
35
38
|
].join("\n");
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
function parseJsonOutput(text) {
|
|
41
|
+
export function parseJsonOutput(text) {
|
|
39
42
|
const t = String(text || "").trim();
|
|
40
43
|
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
41
44
|
const raw = fence ? fence[1].trim() : t;
|
package/lib/core/paths.js
CHANGED
|
@@ -33,3 +33,13 @@ export function disabledRulesFilePath() {
|
|
|
33
33
|
export function baselineFilePath() {
|
|
34
34
|
return join(dshHome(), "rule-engine-baseline.json");
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/** v0.5.7 P0-4:验证通过记录持久化文件(热重载/重启不丢——规则 23④ 证据链) */
|
|
38
|
+
export function verifyFilePath() {
|
|
39
|
+
return join(dshHome(), "rule-engine-verify.json");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** v0.5.7 后续(用户批准):unknown 工具白名单持久化文件("允许使用 X" → 重启不丢) */
|
|
43
|
+
export function toolsWhitelistFilePath() {
|
|
44
|
+
return join(dshHome(), "rule-engine-tools.json");
|
|
45
|
+
}
|
package/lib/core/patterns.js
CHANGED
|
@@ -237,7 +237,7 @@ export function isGetDateCommand(command) {
|
|
|
237
237
|
|
|
238
238
|
const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "read_image"]);
|
|
239
239
|
const READONLY_CMD_RE =
|
|
240
|
-
/(?:\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|echo|Write-Output|Write-Host|Get-Location|pwd|cwd|Get-Help|help)\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|$))|(?:\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;
|
|
240
|
+
/(?:\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|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|Out-Null|ConvertTo-Json|ConvertFrom-Json)\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;
|
|
241
241
|
const MUTATING_CMD_RE =
|
|
242
242
|
/(?: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|(?:^|[^0-9])>>|(?:^|[^0-9])>)/i;
|
|
243
243
|
|
|
@@ -298,15 +298,24 @@ export function needsApprovalReminder(userText) {
|
|
|
298
298
|
return PLAN_INSTRUCTION_RE.test(t) && !WRITE_INSTRUCTION_RE.test(t);
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
let
|
|
301
|
+
let workspaceRoots = []; // 全局注册的工作区根(兜底层)
|
|
302
|
+
const sessionWorkspaceRoots = new Map(); // v0.5.7 P0-3:会话→工作区根(精确层,根随会话走)
|
|
302
303
|
|
|
303
|
-
/** 由插件 apply 阶段从 workspaceRegistry
|
|
304
|
+
/** 由插件 apply 阶段从 workspaceRegistry 设置工作区根目录(单根兼容入口) */
|
|
304
305
|
export function setWorkspaceRoot(p) {
|
|
305
|
-
|
|
306
|
+
setWorkspaceRoots(p ? [p] : []);
|
|
306
307
|
}
|
|
307
308
|
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
/** v0.5.7 P0-3:注册全部工作区根(多工作区环境;兜底层用) */
|
|
310
|
+
export function setWorkspaceRoots(paths) {
|
|
311
|
+
workspaceRoots = Array.isArray(paths) ? paths.filter((p) => typeof p === "string" && p.length > 0) : [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** v0.5.7 P0-3:注册"会话→工作区根"映射——多工作区正确模型:根随会话走(写其他工作区=外部,需授权) */
|
|
315
|
+
export function setSessionWorkspaceRoot(sessionId, root) {
|
|
316
|
+
if (sessionId && typeof root === "string" && root.length > 0) {
|
|
317
|
+
sessionWorkspaceRoots.set(String(sessionId), root);
|
|
318
|
+
}
|
|
310
319
|
}
|
|
311
320
|
|
|
312
321
|
function isPathInside(target, root) {
|
|
@@ -320,11 +329,26 @@ function normalizePathForCompare(p) {
|
|
|
320
329
|
return String(p).replace(/\\/g, "/").toLowerCase().replace(/\/+$/, "");
|
|
321
330
|
}
|
|
322
331
|
|
|
323
|
-
/**
|
|
324
|
-
|
|
332
|
+
/**
|
|
333
|
+
* 判断路径是否在工作区外(用于"工作区外写入"敏感判定)。
|
|
334
|
+
* v0.5.7 P0-3(用户拍板多工作区模型,2026-08-26):
|
|
335
|
+
* ① 会话已知且已映射 → 只用该会话的工作区根(精确:写其他工作区/他盘 = 外部 = 要授权);
|
|
336
|
+
* ② 无会话映射 → 全局注册根列表(任一命中=内部);
|
|
337
|
+
* ③ 仍无 → 环境变量/进程 cwd(最后尝试);
|
|
338
|
+
* ④ 全不可得 → 保守判"内部"(受保护文件/数据目录有独立清单兜底,避免工作区内普通文件被
|
|
339
|
+
* 误判外→12A 全量要授权——此前"只读脚本被拦"的根因)。
|
|
340
|
+
*/
|
|
341
|
+
export function isOutsideWorkspace(p, sessionId) {
|
|
325
342
|
if (typeof p !== "string") return false;
|
|
326
343
|
if (!/^[a-z]:[\\/]/i.test(p) && !p.startsWith("/")) return false; // 只判断绝对路径
|
|
327
|
-
|
|
344
|
+
if (sessionId) {
|
|
345
|
+
const root = sessionWorkspaceRoots.get(String(sessionId));
|
|
346
|
+
if (root) return !isPathInside(p, root);
|
|
347
|
+
}
|
|
348
|
+
const roots = workspaceRoots.length > 0 ? workspaceRoots
|
|
349
|
+
: [process.env.DSH_WORKSPACE, process.cwd()].filter(Boolean);
|
|
350
|
+
if (roots.length === 0) return false;
|
|
351
|
+
return !roots.some((root) => isPathInside(p, root));
|
|
328
352
|
}
|
|
329
353
|
|
|
330
354
|
const ABS_PATH_RE = /[A-Za-z]:[\\/][^\s'"`,。;:!?()【】《》、]+/g;
|
|
@@ -360,9 +384,9 @@ export function absolutePathTokens(command) {
|
|
|
360
384
|
return [...new Set([...quoted, ...unquoted])];
|
|
361
385
|
}
|
|
362
386
|
|
|
363
|
-
function commandHasOutsideWrite(cmd) {
|
|
387
|
+
function commandHasOutsideWrite(cmd, sessionId) {
|
|
364
388
|
if (typeof cmd !== "string") return false;
|
|
365
|
-
return writeTargetPathsFromCommand(cmd).some((p) => isOutsideWorkspace(p));
|
|
389
|
+
return writeTargetPathsFromCommand(cmd).some((p) => isOutsideWorkspace(p, sessionId));
|
|
366
390
|
}
|
|
367
391
|
|
|
368
392
|
export const GENERIC_EXEC_TOOLS = new Set([
|
|
@@ -398,6 +422,18 @@ export function isAuditCommand(command) {
|
|
|
398
422
|
return false;
|
|
399
423
|
}
|
|
400
424
|
|
|
425
|
+
/**
|
|
426
|
+
* v0.5.7 P0-1(用户拍板 2026-08-26):验证类命令判定——运行测试/冷加载探针/挂载审计/语法检查。
|
|
427
|
+
* 与"修/改/写"授权伴生放行使用:用户授权修改后,验证是变更的正常闭环,不再被
|
|
428
|
+
* write vs command 类型不匹配误拦(今天实弹连卡 4 次)。
|
|
429
|
+
*/
|
|
430
|
+
export function isVerificationCommand(command) {
|
|
431
|
+
if (typeof command !== "string") return false;
|
|
432
|
+
if (/\b(?:node|npm|npx)\s+[^\n]*(?:run-all\.mjs|check-plugin-load\.mjs|audit-mount-consistency\.mjs|loader-smoke\.e2e\.mjs|verify-all\.mjs|health-audit\.mjs|--check|--test)\b/i.test(command)) return true;
|
|
433
|
+
if (/\b(?:npm|pnpm)\s+(?:test|run\s+test)\b/i.test(command)) return true;
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
|
|
401
437
|
/** 审计输出是否明确通过(无 DUPLICATES/INCONSISTENT/MISSING 且出现通过标记) */
|
|
402
438
|
export function auditOutputPassed(output) {
|
|
403
439
|
const text = String(output || "");
|
|
@@ -409,8 +445,29 @@ export function auditOutputFailed(output) {
|
|
|
409
445
|
return /DUPLICATES FOUND|INCONSISTENT|\[MISSING\]/i.test(String(output || ""));
|
|
410
446
|
}
|
|
411
447
|
|
|
412
|
-
/**
|
|
413
|
-
|
|
448
|
+
/**
|
|
449
|
+
* v0.5.7 后续(2026-08-26 用户拍板,承诺兑现):工作区内低风险变更判定——规则 12A 正文
|
|
450
|
+
* "工作区内低风险新建"豁免的机器实现(此前只修了相邻项:--profile 模板/多工作区根/验证伴生;
|
|
451
|
+
* 本项未修——只读诊断脚本被拦的承诺闭环于此)。edit/write 且:目标在工作区内 / 非受保护路径 /
|
|
452
|
+
* 非插件装配文件 / 非高风险启动入口 → 低风险变更(22-7 无执行分点回合也放行;12A 侧
|
|
453
|
+
* isSensitiveToolCall 本就对工作区普通文件返回 false)。
|
|
454
|
+
*/
|
|
455
|
+
export function isLowRiskWorkspaceNew(toolName, args) {
|
|
456
|
+
const name = String(toolName || "");
|
|
457
|
+
if (name !== "edit" && name !== "write") return false;
|
|
458
|
+
const p = pathTarget(args);
|
|
459
|
+
if (!p) return false;
|
|
460
|
+
if (isProtectedConfigPath(p)) return false;
|
|
461
|
+
if (isOutsideWorkspace(p)) return false;
|
|
462
|
+
if (isAssemblyMutationTool(name, args)) return false;
|
|
463
|
+
if (isHighRiskEntryFile(p)) return false;
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** 判断是否命中敏感操作(需要授权证据;只读操作永远不算)
|
|
468
|
+
* v0.5.7 P0-3(复查修复 2026-08-26):透传 sessionId——"工作区外"判定需按会话精确根(多工作区
|
|
469
|
+
* 模型:会话根优先;此前遗漏透传导致会话级根从未在运行时激活,只有全局兜底层生效)。 */
|
|
470
|
+
export function isSensitiveToolCall(toolName, args, sessionId) {
|
|
414
471
|
if (isReadOnlyTool(toolName, args)) return false;
|
|
415
472
|
const name = String(toolName || "");
|
|
416
473
|
const p = pathTarget(args);
|
|
@@ -423,13 +480,13 @@ export function isSensitiveToolCall(toolName, args) {
|
|
|
423
480
|
// P0-1c:受保护文件名检查必须配合「写类命令」——纯描述性文本(如 gh release --notes 里的
|
|
424
481
|
// "AGENTS.md" 字样、echo 输出内容)不再触发敏感判定;真写类命令(Set-Content/Remove-Item 等
|
|
425
482
|
// 命中 MUTATING_CMD_RE)提到受保护文件仍判敏感。
|
|
426
|
-
if (cmd && (SENSITIVE_CMD.test(cmd) || (MUTATING_CMD_RE.test(cmd) && PROTECTED_FILENAME_RE.test(cmd)) || commandHasOutsideWrite(cmd))) return true;
|
|
483
|
+
if (cmd && (SENSITIVE_CMD.test(cmd) || (MUTATING_CMD_RE.test(cmd) && PROTECTED_FILENAME_RE.test(cmd)) || commandHasOutsideWrite(cmd, sessionId))) return true;
|
|
427
484
|
}
|
|
428
485
|
if (name === "edit" || name === "write") {
|
|
429
|
-
if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p))) return true;
|
|
486
|
+
if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p, sessionId))) return true;
|
|
430
487
|
}
|
|
431
488
|
if (name === "str_replace_editor" && args?.command !== "view") {
|
|
432
|
-
if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p))) return true;
|
|
489
|
+
if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p, sessionId))) return true;
|
|
433
490
|
}
|
|
434
491
|
if (GENERIC_EXEC_TOOLS.has(name)) return true;
|
|
435
492
|
return false;
|
|
@@ -447,3 +504,32 @@ export const TERM_EXPLANATION_RE =
|
|
|
447
504
|
/** 建议类表述(规则 16 重复推销:会话内同类建议 ≥2 → 自证) */
|
|
448
505
|
export const SUGGEST_RE =
|
|
449
506
|
/我建议|建议用|推荐|更优方案|建议(?:是|考虑|换成|用|来)|档位建议|不如换|优化建议/;
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* v0.5.7 P0.5-6(用户拍板):否定/合规声明语境("不再/避免/停止/无 XX")——那不是重复推销。
|
|
510
|
+
* 粗筛层直接不计数(合规声明不产生重复计数);语义判断权仍归裁决器,
|
|
511
|
+
* 此处只为省钱(不送 LLM 裁决)并消除"自证--触发"噪音。
|
|
512
|
+
*/
|
|
513
|
+
export function isNegatingSuggestion(text) {
|
|
514
|
+
if (typeof text !== "string") return false;
|
|
515
|
+
return /(?:不再|不重复|避免|停止|取消|暂不|未再|不要(?:再)?|以后(?:不|别)|今后(?:不|别)|已呼应|已回应|无(?:新)?(?:建议|重复)|没有(?:新)?(?:建议|重复)|只等待)/.test(text);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* v0.5.7 P0.5-5(用户拍板):承诺词处于引述/改写语境("把'保证'改成…")→ 是引述不是承诺。
|
|
520
|
+
* 判定:词前 8 字符内配对开引号 + 词后 8 字符内配对闭引号;或前后 12 字符内引述标记词。
|
|
521
|
+
*/
|
|
522
|
+
export function isPromiseQuoteContext(text) {
|
|
523
|
+
if (typeof text !== "string") return false;
|
|
524
|
+
for (const m of text.matchAll(/(?:包在我身上|肯定能|绝对没问题|保证|一定可以|万无一失)/g)) {
|
|
525
|
+
const before = text.slice(0, m.index);
|
|
526
|
+
const after = text.slice(m.index + m[0].length);
|
|
527
|
+
// 引述包装 = 承诺词两侧 8 字符内都有引号(ASCII/中文弯引号均算;不区分左右——
|
|
528
|
+
// ASCII 单引号左右同形,分区判定会自相矛盾;双侧近邻引号即视为被引述包裹)。
|
|
529
|
+
const openNear = /[“"「『‘'””」』’]/.test(before.slice(-8));
|
|
530
|
+
const closeNear = /[“"「『‘'””」』’]/.test(after.slice(0, 8));
|
|
531
|
+
if (openNear && closeNear) return true;
|
|
532
|
+
if (/引述|引用|原文|写过|改成|改为|换成/.test(before.slice(-12)) || /改成|改为|换成/.test(after.slice(0, 12))) return true;
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// semantic.js - 投递资格语义层(v0.5.7,用户拍板 2026-08-26)。
|
|
2
|
+
// 原则:只有"错误的行为"才值得被提醒(D 级无法硬拦,提醒是唯一执行手段)。
|
|
3
|
+
// 本文件只放纯函数,不做 LLM——"是否错误"的裁决在 judge.js;
|
|
4
|
+
// 这里的职责是:① 注入轮不检测判定(乒乓根治);② 投递资格闸(提醒一次并记住 + 会话每小时预算)。
|
|
5
|
+
|
|
6
|
+
/** 会话弹窗预算:每小时最多 3 条注入(跨全部规则,含聚合)。 */
|
|
7
|
+
export const INJECT_BUDGET_PER_HOUR = 3;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 第 0 站(检测前传):只有"真实用户在场"的回合才做违规检测与投递。
|
|
11
|
+
* 真实用户在场 = 本回合 user/message 来源为 user,或 ask_user_question 获得用户答复。
|
|
12
|
+
* 引擎注入触发的回合(source=plugin / 依赖注入出的无用户消息轮)不检测——
|
|
13
|
+
* 那些回合的"回复-检测-再注入"正是乒乓循环的燃料(实测 2026-08-26 12:37 六圈)。
|
|
14
|
+
*/
|
|
15
|
+
export function shouldDetectTurn({ realUserSeen }) {
|
|
16
|
+
return realUserSeen === true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 投递资格闸:决定这条注入该不该"进对话"(无论结果如何都会写审计)。
|
|
21
|
+
* 规则:① 同规则同会话仅投递一次(__self-cert 聚合除外);
|
|
22
|
+
* ② 会话每小时至多 INJECT_BUDGET_PER_HOUR 条(超出只审计不投递)。
|
|
23
|
+
* 两者同时满足才投递;被拦的违规全部留在 /guard log 审计(inject-skip)。
|
|
24
|
+
* @param {object} p
|
|
25
|
+
* @param {number} p.now 当前时间戳
|
|
26
|
+
* @param {string} p.ruleId 规则 id(__self-cert 不做每规则去重)
|
|
27
|
+
* @param {number|null} p.firstAt 该规则在本会话首次投递时间(null=从未投递)
|
|
28
|
+
* @param {number[]} p.budgetTimes 本会话投递时间戳列表(用于预算窗口统计)
|
|
29
|
+
* @returns {{ok:true}|{ok:false, reason:"dedup"|"budget"}}
|
|
30
|
+
*/
|
|
31
|
+
export function shouldDeliver({ now, ruleId, firstAt, budgetTimes }) {
|
|
32
|
+
// 机制类注入(`__` 前缀:__engram-gap / __version-guard / __self-cert 聚合等)是事件驱动、
|
|
33
|
+
// 低频且重要,不做"每规则一次"去重(总量由下面的每小时预算约束);只有真实规则 id 才"提醒一次并记住"。
|
|
34
|
+
const isMechanism = String(ruleId || "").startsWith("__");
|
|
35
|
+
if (!isMechanism && firstAt != null) {
|
|
36
|
+
return { ok: false, reason: "dedup" };
|
|
37
|
+
}
|
|
38
|
+
const recent = (budgetTimes || []).filter((t) => now - t < 60 * 60 * 1000);
|
|
39
|
+
if (recent.length >= INJECT_BUDGET_PER_HOUR) {
|
|
40
|
+
return { ok: false, reason: "budget" };
|
|
41
|
+
}
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}
|
package/lib/core/state.js
CHANGED
|
@@ -28,6 +28,11 @@ export function createState() {
|
|
|
28
28
|
sessions: new Map(),
|
|
29
29
|
retryCounts: new Map(),
|
|
30
30
|
injectCounts: new Map(),
|
|
31
|
+
injectAt: new Map(), // v0.5.7:key `${sessionId}:${ruleId}` → 首次投递时间(提醒一次并记住)
|
|
32
|
+
injectBudget: new Map(), // v0.5.7:sessionId → 投递时间戳数组(会话每小时预算)
|
|
33
|
+
judgeCache: new Map(), // v0.5.7:sha256(ruleId+text) → 裁决结果(同文本不重复调用)
|
|
34
|
+
judgeBudget: new Map(), // v0.5.7:`${sessionId}:${UTC日期}` → {count}(每日裁决预算)
|
|
35
|
+
judgeFn: null, // v0.5.7:可注入裁决函数(生产=judge.js LLM 实现;测试=stub)
|
|
31
36
|
globalAuthorizations: [],
|
|
32
37
|
skillNames: null,
|
|
33
38
|
llmEnrichedKeys: new Set(),
|
|
@@ -97,6 +102,21 @@ export function pruneState(state) {
|
|
|
97
102
|
const keys = [...state.injectCounts.keys()];
|
|
98
103
|
for (let i = 0; i < keys.length - MAX_INJECT_KEYS; i++) state.injectCounts.delete(keys[i]);
|
|
99
104
|
}
|
|
105
|
+
// v0.5.7:投递资格/裁决状态超限清理(优先保留新 key 语义——按插入序删最旧)
|
|
106
|
+
for (const m of [state.injectAt, state.judgeCache]) {
|
|
107
|
+
if (m.size > MAX_INJECT_KEYS) {
|
|
108
|
+
const keys = [...m.keys()];
|
|
109
|
+
for (let i = 0; i < keys.length - MAX_INJECT_KEYS; i++) m.delete(keys[i]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (state.injectBudget.size > MAX_SESSIONS) {
|
|
113
|
+
const keys = [...state.injectBudget.keys()];
|
|
114
|
+
for (let i = 0; i < keys.length - MAX_SESSIONS; i++) state.injectBudget.delete(keys[i]);
|
|
115
|
+
}
|
|
116
|
+
if (state.judgeBudget.size > MAX_SESSIONS * 2) {
|
|
117
|
+
const keys = [...state.judgeBudget.keys()];
|
|
118
|
+
for (let i = 0; i < keys.length - MAX_SESSIONS * 2; i++) state.judgeBudget.delete(keys[i]);
|
|
119
|
+
}
|
|
100
120
|
// 清理过期授权(顺便释放内存)
|
|
101
121
|
for (const s of state.sessions.values()) {
|
|
102
122
|
s.authorizations = s.authorizations.filter((a) => !a.expiresAt || a.expiresAt > now);
|
|
@@ -206,6 +226,7 @@ export function freshTurn() {
|
|
|
206
226
|
return {
|
|
207
227
|
number: 0,
|
|
208
228
|
userText: "",
|
|
229
|
+
realUserSeen: false, // v0.5.7:本回合是否有真实用户消息(注入轮不检测的依据)
|
|
209
230
|
askSeen: false,
|
|
210
231
|
askRejected: false, // 本回合 ask 已被拒(防连环 ask,弹窗消减)
|
|
211
232
|
questionOnly: false,
|
package/lib/core/text-detect.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
URL_RE,
|
|
9
9
|
TECH_TERM_RE,
|
|
10
10
|
TERM_EXPLANATION_RE,
|
|
11
|
-
SUGGEST_RE
|
|
11
|
+
SUGGEST_RE,
|
|
12
|
+
isNegatingSuggestion,
|
|
13
|
+
isPromiseQuoteContext
|
|
12
14
|
} from "./patterns.js";
|
|
13
15
|
import { detectOverengineeringText } from "./overengineering.js";
|
|
14
16
|
|
|
@@ -88,7 +90,8 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
const promiseCfg = byId.get("7");
|
|
91
|
-
|
|
93
|
+
// v0.5.7 P0.5-5:承诺词处于引述/改写语境("把'保证'改成…")→ 引述不是承诺,不触发
|
|
94
|
+
if (promiseCfg && PROMISE_WORDS.test(text) && !isPromiseQuoteContext(text)) {
|
|
92
95
|
hits.push({
|
|
93
96
|
ruleId: "7",
|
|
94
97
|
title: promiseCfg.title,
|
|
@@ -133,8 +136,10 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
|
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
// 批次 5:重复推销(规则 16——会话内同类建议 ≥2 次 → 自证,防反复推销)
|
|
139
|
+
// v0.5.7 P0.5-6:否定/合规声明语境("不再/无 XX")不计数——那不是重复推销(粗筛省钱,
|
|
140
|
+
// 语义判断权仍归裁决器)
|
|
136
141
|
const suggestCfg = byId.get("16");
|
|
137
|
-
if (suggestCfg && SUGGEST_RE.test(text)) {
|
|
142
|
+
if (suggestCfg && SUGGEST_RE.test(text) && !isNegatingSuggestion(text)) {
|
|
138
143
|
session.suggestionCounts = session.suggestionCounts || {};
|
|
139
144
|
session.suggestionCounts.general = (session.suggestionCounts.general || 0) + 1;
|
|
140
145
|
if (session.suggestionCounts.general >= 2) {
|
|
@@ -253,5 +258,10 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
|
|
|
253
258
|
if (k) k.suppressed++;
|
|
254
259
|
}
|
|
255
260
|
}
|
|
261
|
+
// v0.5.7:D 级语义型命中 = 嫌疑(awaitingJudge)——词表只产嫌疑,"是否错误"由 judge 裁决。
|
|
262
|
+
// 只有裁决 = 违规 的才进入投递(兑现"只有错误的行为才值得被提醒")。
|
|
263
|
+
for (const h of kept) {
|
|
264
|
+
if (h.kind === "self-certify") h.awaitingJudge = true;
|
|
265
|
+
}
|
|
256
266
|
return kept;
|
|
257
267
|
}
|
package/lib/index.js
CHANGED
|
@@ -25,7 +25,9 @@ import {
|
|
|
25
25
|
isReadOnlyTool,
|
|
26
26
|
needsApprovalReminder,
|
|
27
27
|
PRIVATE_NETWORK_RE,
|
|
28
|
-
setWorkspaceRoot
|
|
28
|
+
setWorkspaceRoot,
|
|
29
|
+
setWorkspaceRoots,
|
|
30
|
+
setSessionWorkspaceRoot
|
|
29
31
|
} from "./core/patterns.js";
|
|
30
32
|
import {
|
|
31
33
|
askQuestionCoreText,
|
|
@@ -56,6 +58,8 @@ import { SAFE_UNCOVERED_TOOLS } from "./core/guard-core.js";
|
|
|
56
58
|
import { toolClass } from "./core/tool-catalog.js";
|
|
57
59
|
import { state } from "./core/runtime.js";
|
|
58
60
|
import { detectViolations, extractAssistantText } from "./core/text-detect.js";
|
|
61
|
+
import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
|
|
62
|
+
import { judgeViolation } from "./core/judge.js";
|
|
59
63
|
import {
|
|
60
64
|
applyContract,
|
|
61
65
|
contractSummary,
|
|
@@ -75,7 +79,7 @@ import {
|
|
|
75
79
|
recordAction
|
|
76
80
|
} from "./core/overengineering.js";
|
|
77
81
|
import { writeUnderstanding } from "./core/understanding-store.js";
|
|
78
|
-
import { agentsFilePath, auditFilePath } from "./core/paths.js";
|
|
82
|
+
import { agentsFilePath, auditFilePath, verifyFilePath, toolsWhitelistFilePath } from "./core/paths.js";
|
|
79
83
|
import { isVersionedFile, validateEditedFile } from "./core/version-guard.js";
|
|
80
84
|
import { computeMountSignature, profileNameFromArgs } from "./core/mount-signature.js";
|
|
81
85
|
import { detectSilentError, extractToolOutput } from "./core/silent-error.js";
|
|
@@ -173,8 +177,31 @@ const pendingInjectTimers = new Set();
|
|
|
173
177
|
function maybeInject(ctx, sessionId, violation) {
|
|
174
178
|
if (!pluginConfig.correctInject) return;
|
|
175
179
|
const key = `${sessionId}:${violation.ruleId}`;
|
|
180
|
+
// v0.5.7 投递资格闸(用户拍板"提醒一次并记住 + 会话每小时 3 条预算",2026-08-26):
|
|
181
|
+
// 同规则同会话仅投递一次(__self-cert 聚合除外),会话每小时至多 3 条。
|
|
182
|
+
// 被拦的违规仍写审计(inject-skip)——只有"弹进对话"被省掉,审计完整性不降级。
|
|
183
|
+
const gate = shouldDeliver({
|
|
184
|
+
now: Date.now(),
|
|
185
|
+
ruleId: violation.ruleId,
|
|
186
|
+
firstAt: state.injectAt.get(key) ?? null,
|
|
187
|
+
budgetTimes: state.injectBudget.get(sessionId) || []
|
|
188
|
+
});
|
|
189
|
+
if (!gate.ok) {
|
|
190
|
+
audit({
|
|
191
|
+
kind: "inject-skip",
|
|
192
|
+
rule: violation.ruleId,
|
|
193
|
+
name: "投递资格拦截",
|
|
194
|
+
event: "inject",
|
|
195
|
+
reason: `未投递(${gate.reason}):${String(violation.reason || "").slice(0, 80)}`,
|
|
196
|
+
session: sessionId
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
state.injectAt.set(key, Date.now());
|
|
201
|
+
const budgetArr = state.injectBudget.get(sessionId) || [];
|
|
202
|
+
budgetArr.push(Date.now());
|
|
203
|
+
state.injectBudget.set(sessionId, budgetArr.slice(-20));
|
|
176
204
|
const count = state.injectCounts.get(key) || 0;
|
|
177
|
-
if (count >= pluginConfig.injectLimitPerRulePerSession) return;
|
|
178
205
|
state.injectCounts.set(key, count + 1);
|
|
179
206
|
// C2 统计:注入(injected)计数(实际投递尝试,上限内)
|
|
180
207
|
{
|
|
@@ -214,6 +241,53 @@ function maybeInject(ctx, sessionId, violation) {
|
|
|
214
241
|
pendingInjectTimers.add(timer);
|
|
215
242
|
}
|
|
216
243
|
|
|
244
|
+
/**
|
|
245
|
+
* v0.5.7:D 级嫌疑异步裁决并投递(用户拍板"只有错误的行为才值得被提醒")。
|
|
246
|
+
* 裁决函数可注入(state.judgeFn,测试用 stub;生产 = judge.js 的 LLM 实现)。
|
|
247
|
+
* 结果审计:judge-pass(确认违规→投递)/ judge-false(合规声明/引述→不投)/
|
|
248
|
+
* judge-unavailable(失败/超时/预算满→fail-closed 不投)——三类全部留 /guard log 可对质。
|
|
249
|
+
*/
|
|
250
|
+
async function deliverSuspects(ctx, sessionId, suspects, text) {
|
|
251
|
+
const judgeFn = state.judgeFn || judgeViolation;
|
|
252
|
+
const results = await Promise.all(suspects.map(async (v) => {
|
|
253
|
+
try {
|
|
254
|
+
const verdict = await judgeFn(ctx, state, sessionId, v, text);
|
|
255
|
+
const kind = verdict.action === "deliver" ? "judge-pass"
|
|
256
|
+
: verdict.action === "suppress" ? "judge-false"
|
|
257
|
+
: "judge-unavailable";
|
|
258
|
+
audit({
|
|
259
|
+
kind,
|
|
260
|
+
rule: v.ruleId,
|
|
261
|
+
name: "违规裁决",
|
|
262
|
+
event: "assistant/message",
|
|
263
|
+
reason: `${verdict.action}:${verdict.note || ""}${verdict.model ? `(model=${verdict.model})` : ""}`,
|
|
264
|
+
session: sessionId
|
|
265
|
+
});
|
|
266
|
+
return verdict.action === "deliver" ? v : null;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
audit({
|
|
269
|
+
kind: "judge-unavailable",
|
|
270
|
+
rule: v.ruleId,
|
|
271
|
+
name: "裁决异常",
|
|
272
|
+
event: "assistant/message",
|
|
273
|
+
reason: `未投递:${error instanceof Error ? error.message : String(error)}`,
|
|
274
|
+
session: sessionId
|
|
275
|
+
});
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}));
|
|
279
|
+
const deliver = results.filter(Boolean);
|
|
280
|
+
if (deliver.length > 1) {
|
|
281
|
+
const briefs = deliver.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`).join(";");
|
|
282
|
+
maybeInject(ctx, sessionId, {
|
|
283
|
+
ruleId: "__self-cert",
|
|
284
|
+
reason: `裁决通过 ${deliver.length} 项:${briefs}——请按 /guard log 明细应对`
|
|
285
|
+
});
|
|
286
|
+
} else if (deliver.length === 1) {
|
|
287
|
+
maybeInject(ctx, sessionId, deliver[0]);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
217
291
|
async function refreshSkills(ctx) {
|
|
218
292
|
try {
|
|
219
293
|
const list = await ctx.skills.list();
|
|
@@ -223,6 +297,24 @@ async function refreshSkills(ctx) {
|
|
|
223
297
|
}
|
|
224
298
|
}
|
|
225
299
|
|
|
300
|
+
/** v0.5.7 P0-4:验证通过记录持久化(文件损坏/写失败不阻断运行时) */
|
|
301
|
+
function persistVerifyPass(state) {
|
|
302
|
+
try {
|
|
303
|
+
writeFileSync(verifyFilePath(), JSON.stringify((state.verifyPass || []).slice(-100)), "utf8");
|
|
304
|
+
} catch {
|
|
305
|
+
// 持久化失败仅降级(内存态仍有效),不阻断
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** v0.5.7 后续(用户批准):unknown 工具白名单持久化(~/.dsh/rule-engine-tools.json——"允许使用 X"重启/热重载不丢) */
|
|
310
|
+
function persistToolWhitelist(state) {
|
|
311
|
+
try {
|
|
312
|
+
writeFileSync(toolsWhitelistFilePath(), JSON.stringify([...(state.unknownToolApproved || [])]), "utf8");
|
|
313
|
+
} catch {
|
|
314
|
+
// 持久化失败仅降级(内存态仍有效),不阻断
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
226
318
|
export function handleSessionEvent(ctx, session, event) {
|
|
227
319
|
maybeReloadIfChanged(state);
|
|
228
320
|
const sid = session?.id || "global";
|
|
@@ -294,6 +386,7 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
294
386
|
if (toolClass(toolName, {}) !== "unknown") continue; // 只对未归类工具有意义(已知工具不走 unknown 分支)
|
|
295
387
|
const isNew = !state.unknownToolApproved.has(toolName);
|
|
296
388
|
state.unknownToolApproved.add(toolName);
|
|
389
|
+
persistToolWhitelist(state); // v0.5.7 后续:白名单持久化(重启/热重载不丢——用户批准"这句不用每次发")
|
|
297
390
|
audit({
|
|
298
391
|
kind: "unknown-tool-whitelist",
|
|
299
392
|
rule: "__unknown-tool",
|
|
@@ -305,6 +398,7 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
305
398
|
}
|
|
306
399
|
s.lastUserText = text;
|
|
307
400
|
s.turn.userText = text;
|
|
401
|
+
s.turn.realUserSeen = true; // v0.5.7:真实用户在场(注入轮不检测的依据——乒乓根治)
|
|
308
402
|
s.turn.questionOnly = isQuestionMessage(text);
|
|
309
403
|
s.turn.intents = parseUserIntents(text);
|
|
310
404
|
// 规则 22 粒度升级(2026-08-24):本回合授权范围 = 各 execute 子句推导出的 type+path 范围
|
|
@@ -567,10 +661,11 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
567
661
|
// pendingToolCalls 状态(what 降级记录),verify-pass 语义不变(输出即证据)。
|
|
568
662
|
if (!isError) {
|
|
569
663
|
const output = extractToolOutput(d);
|
|
570
|
-
if (/\bALL TESTS PASSED\b|RESULT:\s*PASS/.test(output)) {
|
|
664
|
+
if (/\bALL TESTS PASSED\b|RESULT:\s*PASS|\bVERIFY ALL OK\b/.test(output)) {
|
|
571
665
|
if (!state.verifyPass) state.verifyPass = [];
|
|
572
666
|
state.verifyPass.push({ at: Date.now(), sessionId: sid, what: pendingCall?.name || "tool-result" });
|
|
573
667
|
if (state.verifyPass.length > 100) state.verifyPass.splice(0, state.verifyPass.length - 100);
|
|
668
|
+
persistVerifyPass(state); // v0.5.7 P0-4:持久化(热重载/重启不丢)
|
|
574
669
|
}
|
|
575
670
|
}
|
|
576
671
|
|
|
@@ -597,6 +692,7 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
597
692
|
if (pending) {
|
|
598
693
|
const result = d.result ?? d.value ?? d;
|
|
599
694
|
if (askResultApproved(result)) {
|
|
695
|
+
s.turn.realUserSeen = true; // v0.5.7:ask 弹窗答复也算"真实用户在场"
|
|
600
696
|
const qText = askQuestionText(pending.questions);
|
|
601
697
|
const selectedText = askResultSelectedText(result);
|
|
602
698
|
// 授权范围只取“问题核心 + 用户实际选择”,不再把全部选项描述纳入;
|
|
@@ -624,6 +720,7 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
624
720
|
if (!state.unknownToolApproved) state.unknownToolApproved = new Set();
|
|
625
721
|
const isNew = !state.unknownToolApproved.has(wname);
|
|
626
722
|
state.unknownToolApproved.add(wname);
|
|
723
|
+
persistToolWhitelist(state); // v0.5.7 后续:白名单持久化(见上)
|
|
627
724
|
audit({
|
|
628
725
|
kind: "unknown-tool-whitelist",
|
|
629
726
|
rule: "__unknown-tool",
|
|
@@ -671,6 +768,9 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
671
768
|
}
|
|
672
769
|
if (event.type === "assistant/message") {
|
|
673
770
|
const text = extractAssistantText(d.message);
|
|
771
|
+
// v0.5.7 第 0 站:注入轮(无真实用户消息)不检测不投递——这是乒乓循环的根治:
|
|
772
|
+
// 引擎自己的注入 → 我的回复 → 再检测 的燃料被掐断(实测 2026-08-26 12:37 六圈即此循环)。
|
|
773
|
+
if (!shouldDetectTurn({ realUserSeen: s.turn.realUserSeen })) return;
|
|
674
774
|
// 交付声明机器闸门(机制批 M3,规则 23④):完成类声明 → 核对同会话 30 分钟内 verify-pass 记录,缺失注入纠正
|
|
675
775
|
if (/完成|已通过|已修复|搞定|验证通过|全部通过|修复完成|落盘完成/.test(text)) {
|
|
676
776
|
const windowStart = Date.now() - 30 * 60 * 1000;
|
|
@@ -684,7 +784,15 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
684
784
|
reason: "交付/完成类声明缺少同会话近期 verify-pass(ALL TESTS PASSED / check-plugin-load RESULT: PASS)记录",
|
|
685
785
|
session: sid
|
|
686
786
|
});
|
|
687
|
-
|
|
787
|
+
// v0.5.7 后续(用户拍板"暗示型统一裁决"):23④ 词面命中只是嫌疑——"完成"≠交付声明
|
|
788
|
+
//("完成社区检索"类误触的修复):送 LLM 裁决,确认违规才投递。
|
|
789
|
+
void deliverSuspects(ctx, sid, [{
|
|
790
|
+
ruleId: "23",
|
|
791
|
+
title: "变更与交付验证(执行等级:D 强)",
|
|
792
|
+
kind: "self-certify",
|
|
793
|
+
awaitingJudge: true,
|
|
794
|
+
reason: "规则 23④:完成/通过类声明需同会话近期验证记录(测试全绿或冷加载 PASS),缺失已记审计"
|
|
795
|
+
}], text);
|
|
688
796
|
}
|
|
689
797
|
}
|
|
690
798
|
// 需求基线覆盖核查(RB-09,2026-08-24):方案/重构/整体完成类声明 → 基线仍存在 planned 需求 → 审计纠正(fail-closed)
|
|
@@ -728,18 +836,24 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
728
836
|
session: sid
|
|
729
837
|
});
|
|
730
838
|
}
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
839
|
+
// v0.5.7 分流水线:B 级机器型(correct,词表=确定证据)立即投递;
|
|
840
|
+
// D 级语义型命中 = 嫌疑(awaitingJudge)→ 先裁决,只有裁决=违规的才投递
|
|
841
|
+
// (用户拍板:"只有错误的行为才值得被提醒"——词表只产嫌疑,不再定罪)。
|
|
842
|
+
const immediate = violations.filter((v) => v.kind !== "self-certify");
|
|
843
|
+
const suspects = violations.filter((v) => v.awaitingJudge === true);
|
|
844
|
+
if (immediate.length > 1) {
|
|
845
|
+
const briefs = immediate
|
|
735
846
|
.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`)
|
|
736
847
|
.join(";");
|
|
737
848
|
maybeInject(ctx, sid, {
|
|
738
849
|
ruleId: "__self-cert",
|
|
739
|
-
reason: `本轮检出 ${
|
|
850
|
+
reason: `本轮检出 ${immediate.length} 项:${briefs}——请按 /guard log 明细应对`
|
|
740
851
|
});
|
|
741
|
-
} else if (
|
|
742
|
-
maybeInject(ctx, sid,
|
|
852
|
+
} else if (immediate.length === 1) {
|
|
853
|
+
maybeInject(ctx, sid, immediate[0]);
|
|
854
|
+
}
|
|
855
|
+
if (suspects.length > 0) {
|
|
856
|
+
void deliverSuspects(ctx, sid, suspects, text);
|
|
743
857
|
}
|
|
744
858
|
if (violations.length) {
|
|
745
859
|
state.lastActive = violations.map((v) => ({ ruleId: v.ruleId, title: v.title, reason: v.reason }));
|
|
@@ -964,12 +1078,36 @@ async function executeGuard(ctx, invocation) {
|
|
|
964
1078
|
// ── 插件主体 ────────────────────────────────────────────────────────────────
|
|
965
1079
|
|
|
966
1080
|
export function apply(ctx) {
|
|
967
|
-
// 0.
|
|
1081
|
+
// 0. v0.5.7 P0-3:注册全部工作区根(多工作区环境;精确层=会话级,在 guard 回调按会话同步)
|
|
968
1082
|
try {
|
|
969
|
-
const
|
|
970
|
-
|
|
1083
|
+
const list = ctx.workspaceRegistry?.list?.() || [];
|
|
1084
|
+
setWorkspaceRoots(list.map((e) => e && (e.path || e.record?.path)).filter(Boolean));
|
|
971
1085
|
} catch {
|
|
972
|
-
|
|
1086
|
+
setWorkspaceRoots([]);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// v0.5.7 P0-4:加载持久化验证记录(热重载/重启后规则 23④ 证据链不丢;仅保留 30 分钟内)
|
|
1090
|
+
try {
|
|
1091
|
+
const raw = readFileSync(verifyFilePath(), "utf8");
|
|
1092
|
+
const arr = JSON.parse(raw);
|
|
1093
|
+
if (Array.isArray(arr)) {
|
|
1094
|
+
state.verifyPass = arr
|
|
1095
|
+
.filter((v) => v && v.at && Date.now() - Number(v.at) < 30 * 60 * 1000)
|
|
1096
|
+
.slice(-100);
|
|
1097
|
+
}
|
|
1098
|
+
} catch {
|
|
1099
|
+
// 首次运行/文件缺失:无历史记录,正常
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// v0.5.7 后续(用户批准):加载白名单持久化("允许使用 X"重启/热重载不丢)
|
|
1103
|
+
try {
|
|
1104
|
+
const wlRaw = readFileSync(toolsWhitelistFilePath(), "utf8");
|
|
1105
|
+
const wlArr = JSON.parse(wlRaw);
|
|
1106
|
+
if (Array.isArray(wlArr)) {
|
|
1107
|
+
state.unknownToolApproved = new Set(wlArr.filter((n) => typeof n === "string"));
|
|
1108
|
+
}
|
|
1109
|
+
} catch {
|
|
1110
|
+
// 首次运行/无文件:空白名单
|
|
973
1111
|
}
|
|
974
1112
|
|
|
975
1113
|
// 0.0.1 LLM 意图兜底配置(guardDecision 为纯函数,经 state 传入)
|
|
@@ -1024,6 +1162,17 @@ export function apply(ctx) {
|
|
|
1024
1162
|
ctx.effect(
|
|
1025
1163
|
function* () {
|
|
1026
1164
|
yield ctx.tools.guard((exec) => {
|
|
1165
|
+
// v0.5.7 P0-3:多工作区正确模型——根随会话走。每个工具调用前按当前会话同步其工作区根
|
|
1166
|
+
//(官方 workspaceRegistry.sessionPath 提供会话→工作区映射;新工作区零维护自动识别)。
|
|
1167
|
+
try {
|
|
1168
|
+
const gsid = sessionIdOfExec(exec);
|
|
1169
|
+
if (gsid && gsid !== "global" && ctx.workspaceRegistry?.sessionPath) {
|
|
1170
|
+
const root = ctx.workspaceRegistry.sessionPath(gsid);
|
|
1171
|
+
if (root) setSessionWorkspaceRoot(gsid, root);
|
|
1172
|
+
}
|
|
1173
|
+
} catch {
|
|
1174
|
+
// 会话未映射时安静走全局兜底,不阻塞裁决
|
|
1175
|
+
}
|
|
1027
1176
|
// 落盘授权粒度提醒(机制批 M7,2026-08-24):受保护文件统一入口调用 + 本回合用户消息为方案性指令
|
|
1028
1177
|
//(调整/补充/评估/建议且无落盘词)→ approval-gap 审计 + 注入提醒(规则 22 自证③:方案性指令 ≠ 落盘授权)
|
|
1029
1178
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rule-engine",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
4
4
|
"description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "node test/run-all.mjs",
|
|
45
45
|
"check": "node --check lib/index.js",
|
|
46
|
+
"verify": "node scripts/verify-all.mjs",
|
|
46
47
|
"audit:mount": "node scripts/audit-mount-consistency.mjs --profile web"
|
|
47
48
|
},
|
|
48
49
|
"peerDependencies": {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// health-audit.mjs - 健康审计(找茬,不是证明;2026-08-26)。
|
|
2
|
+
// 输出"问题清单"(近 24h),无问题也要列出"查了什么":
|
|
3
|
+
// ① 失败/降级类统计:intent-llm(成功/失败降级)、judge-pass/false/unavailable、
|
|
4
|
+
// verify-gap、inject-skip、source-skip——失败可见化("LLM 意图 31 次全降级"从此自浮现);
|
|
5
|
+
// ② 接口接线交叉:关键导出符号在 lib 下引用计数 ≤1(仅定义,无引用)→ 疑似未接线;
|
|
6
|
+
// ③ 与 verify-all 的真实判例口径一致(供人工核对)。
|
|
7
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const auditPath = join(process.env.DSH_HOME || join(process.env.USERPROFILE || "", ".dsh"), "rule-engine.log.jsonl");
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const cuts = { intentFail: 0, intentOk: 0, judgePass: 0, judgeFalse: 0, judgeUnavail: 0, verifyGap: 0, injectSkip: 0, sourceSkip: 0 };
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
for (const line of readFileSync(auditPath, "utf8").split("\n")) {
|
|
18
|
+
const m = line.match(/"ts":"([^"]+)"/);
|
|
19
|
+
if (!m || now - new Date(m[1]).getTime() > 24 * 3600 * 1000) continue;
|
|
20
|
+
if (line.includes('"kind":"intent-llm"')) (line.includes("LLM 失败降级") ? cuts.intentFail++ : cuts.intentOk++);
|
|
21
|
+
if (line.includes('"kind":"judge-pass"')) cuts.judgePass++;
|
|
22
|
+
if (line.includes('"kind":"judge-false"')) cuts.judgeFalse++;
|
|
23
|
+
if (line.includes('"kind":"judge-unavailable"')) cuts.judgeUnavail++;
|
|
24
|
+
if (line.includes('"kind":"verify-gap"')) cuts.verifyGap++;
|
|
25
|
+
if (line.includes('"kind":"inject-skip"')) cuts.injectSkip++;
|
|
26
|
+
if (line.includes('"kind":"source-skip"')) cuts.sourceSkip++;
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// 无日志文件:一切 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ② 接线交叉:导出符号引用计数(>1 = 定义+引用;=1 = 疑似仅定义未接线)
|
|
33
|
+
const EXPORTS = [
|
|
34
|
+
"setSessionWorkspaceRoot", "setWorkspaceRoots", "isVerificationCommand",
|
|
35
|
+
"isNegatingSuggestion", "isPromiseQuoteContext", "judgeViolation",
|
|
36
|
+
"shouldDetectTurn", "shouldDeliver", "isReadOnlyCommand"
|
|
37
|
+
];
|
|
38
|
+
let libText = "";
|
|
39
|
+
(function walk(d) {
|
|
40
|
+
for (const f of readdirSync(d)) {
|
|
41
|
+
const p = join(d, f);
|
|
42
|
+
if (statSync(p).isDirectory()) walk(p);
|
|
43
|
+
else if (/\.js$/.test(f)) libText += readFileSync(p, "utf8") + "\n";
|
|
44
|
+
}
|
|
45
|
+
})(join(root, "lib"));
|
|
46
|
+
const orphans = EXPORTS.filter((n) => ((libText.match(new RegExp("\\b" + n + "\\b", "g")) || []).length) <= 1);
|
|
47
|
+
|
|
48
|
+
console.log("== 健康审计(近 24h)==");
|
|
49
|
+
console.log(`intent-llm 失败降级 ${cuts.intentFail} 条 / 成功 ${cuts.intentOk} 条`);
|
|
50
|
+
console.log(`judge-pass ${cuts.judgePass} 条 / judge-false ${cuts.judgeFalse} 条 / judge-unavailable ${cuts.judgeUnavail} 条`);
|
|
51
|
+
console.log(`verify-gap ${cuts.verifyGap} 条 / inject-skip ${cuts.injectSkip} 条 / source-skip ${cuts.sourceSkip} 条`);
|
|
52
|
+
console.log(`接线交叉:${EXPORTS.length} 个关键导出 → 疑似未接线 ${orphans.length ? orphans.join("、") : "0 个"}`);
|
|
53
|
+
if (cuts.judgePass + cuts.judgeFalse === 0) {
|
|
54
|
+
console.log("⚠️ 问题:近 24h 无真实判例——裁决器无运行证据(需实弹)");
|
|
55
|
+
}
|
|
56
|
+
if (cuts.intentFail > 0) {
|
|
57
|
+
console.log(`⚠️ 问题:intent-llm 失败降级 ${cuts.intentFail} 条——LLM 意图判定不可用(需查模型路由/密钥)`);
|
|
58
|
+
}
|
|
59
|
+
console.log(orphans.length ? `❌ 问题:疑似未接线:${orphans.join("、")}` : `✅ 接线:全部导出有引用`);
|
|
60
|
+
console.log("(已查:失败统计 8 类 + 接线交叉 9 个导出;无问题也列出如上)");
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// verify-all.mjs - 交付前体检报告(2026-08-26,对齐官方 docs/testing.zh.md 分层验证)。
|
|
2
|
+
// 四层:① 语法(lib 全部 .js node --check)
|
|
3
|
+
// ② 单元(test/run-all.mjs——单元+机制层,不含 e2e)
|
|
4
|
+
// ③ 组合冒烟(test/loader-smoke.e2e.mjs——真实接线 + 外部世界断言)
|
|
5
|
+
// ④ 真实判例(外部世界:近 24h 台账中 judge-pass/judge-false 记录数;
|
|
6
|
+
// 0 条 = WARN 提示需实弹,≥1 条 = 有真实裁决证据——读文件,不是自我报告)
|
|
7
|
+
// 任一 ❌ → exit 1;⚠️(WARN)不阻塞但必须明示。
|
|
8
|
+
// 注意:子进程输出捕获需完整权限运行(受限模式 EPERM)。
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const lines = [];
|
|
16
|
+
|
|
17
|
+
function step(name, cmd, args) {
|
|
18
|
+
try {
|
|
19
|
+
const out = execFileSync(cmd, args, { cwd: root, encoding: "utf8", stdio: "pipe", maxBuffer: 64 * 1024 * 1024 });
|
|
20
|
+
lines.push(`✅ ${name}`);
|
|
21
|
+
return out;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
lines.push(`❌ ${name}`);
|
|
24
|
+
lines.push(String(e.stdout || e.stderr || e.message).slice(-500));
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── ① 语法 ──
|
|
30
|
+
const files = [];
|
|
31
|
+
(function walk(d) {
|
|
32
|
+
for (const f of readdirSync(d)) {
|
|
33
|
+
const p = join(d, f);
|
|
34
|
+
if (statSync(p).isDirectory()) walk(p);
|
|
35
|
+
else if (/\.js$/.test(f)) files.push(p);
|
|
36
|
+
}
|
|
37
|
+
})(join(root, "lib"));
|
|
38
|
+
let sOk = true;
|
|
39
|
+
for (const f of files) {
|
|
40
|
+
try {
|
|
41
|
+
execFileSync(process.execPath, ["--check", f], { cwd: root, stdio: "pipe", encoding: "utf8" });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
sOk = false;
|
|
44
|
+
lines.push(`❌ 语法 ${f}`);
|
|
45
|
+
lines.push(String(e.stderr || e.message).slice(-300));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (sOk) lines.push(`✅ 语法(${files.length} 个 lib 文件)`);
|
|
49
|
+
|
|
50
|
+
// ── ② 单元 ──
|
|
51
|
+
step("单元(test/run-all.mjs)", process.execPath, [join(root, "test", "run-all.mjs")]);
|
|
52
|
+
|
|
53
|
+
// ── ③ 组合冒烟 ──
|
|
54
|
+
step("组合冒烟(loader-smoke,真实接线+外部世界断言)", process.execPath, [join(root, "test", "loader-smoke.e2e.mjs")]);
|
|
55
|
+
|
|
56
|
+
// ── ④ 真实判例(外部世界) ──
|
|
57
|
+
let judgeReal = 0;
|
|
58
|
+
const auditPath = join(process.env.DSH_HOME || join(process.env.USERPROFILE || "", ".dsh"), "rule-engine.log.jsonl");
|
|
59
|
+
const since = Date.now() - 24 * 3600 * 1000;
|
|
60
|
+
try {
|
|
61
|
+
for (const line of readFileSync(auditPath, "utf8").split("\n")) {
|
|
62
|
+
if (!line.includes("judge-pass") && !line.includes("judge-false")) continue;
|
|
63
|
+
const m = line.match(/"ts":"([^"]+)"/);
|
|
64
|
+
if (m && new Date(m[1]).getTime() >= since) judgeReal++;
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// 无日志:提示即可
|
|
68
|
+
}
|
|
69
|
+
if (judgeReal > 0) {
|
|
70
|
+
lines.push(`✅ 真实判例(近 24h judge-pass/judge-false = ${judgeReal} 条——裁决器真实工作证据)`);
|
|
71
|
+
} else {
|
|
72
|
+
lines.push("⚠️ 真实判例:近 24h 无 judge-pass/judge-false 记录——裁决器需要一次实弹(非阻塞 WARN,但说明运行证据尚未产生)");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(lines.join("\n"));
|
|
76
|
+
const failed = lines.filter((l) => l.startsWith("❌"));
|
|
77
|
+
if (failed.length) {
|
|
78
|
+
console.log(`\nVERIFY FAIL:${failed.length} 项问题,见上`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
console.log("\nVERIFY ALL OK");
|