dsh-rule-engine 0.5.9 → 0.5.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # dsh-rule-engine
2
2
 
3
3
  ![npm](https://img.shields.io/npm/v/dsh-rule-engine)
4
- ![version](https://img.shields.io/badge/version-0.5.9-blue)
4
+ ![version](https://img.shields.io/badge/version-0.5.10-blue)
5
5
 
6
6
  DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。
7
7
 
@@ -46,6 +46,18 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
46
46
  - `node scripts/health-audit.mjs` —— 找茬清单:近 24h 失败/降级类统计(intent-llm 失败、judge-unavailable、verify-gap、inject-skip…)+ 关键导出接线交叉(疑似未接线 = 告警)——"失败可见化",不再有静默躺 20 小时的降级;
47
47
  - 执行协议(本仓库自身交付纪律):方案冻结单(范围/影响面/测试计划/失败预测)→ todo 化 → 小步闭环(每改动立即 `node --check`)→ 对账交付(计划×实际逐项 ✅/❌/跳过原因)。
48
48
 
49
+ ## 0.5.10(2026-08-27 追加)
50
+
51
+ > 收编:用户审查后的 real-legal 会话建议(建议 2/3/4/5,依据本手册知识条目 K-01~K-06 批判性审查)+ 分析通道(用户多次提出的"只读分析需要写临时脚本/输出"痛点)+ 统一入口加固(2026-08-27 手册损伤事故复盘)。
52
+
53
+ - **分析通道(单真源 `isAnalysisOp`)**:严格只读 ∪ 分析脚本区(repo `scripts/*.mjs`)∪ 分析临时区写(`logs/` `.analysis-tmp/` `.backups/`)→ 任何回合放行 + 审计 `analysis-scratch`;**红线**(红线层在 guard-core):受保护文件名/工作区外(isOutsideWorkspace)/删除移动/覆盖正式文件一律不豁免;
54
+ - **写类判定单真源**:self-protect / 22 / 13A 统一走 `isMutationCommand`——修复 `Write-Output` 被 `write` 子串误杀(WGO654/ES3VCD 案例:纯读命令查受保护文件名被误拦);
55
+ - **只读词表补全**:ForEach-Object / Get-ItemProperty / Get-Variable / Get-FileHash(多行"读+筛选+循环"组合判只读);
56
+ - **统一入口加固**(dsh-manual-write.mjs):① 转义事故特征检测——表格行内字面 `\n` / `\$`(PowerShell 单引号转义事故模式,2026-08-27 手册坏行事故)→ 拒绝;② 写后结构校验——表格块列宽一致(±2 容忍)+ 手册版本记录行存在,不通过自动回滚;
57
+ - **误判打标闭环(建议 4)**:所有硬拦文案尾部附「误判可打标:/guard label <事件号> incorrect」指引;health-audit 新增 deny 总数 vs incorrect/correct 打标占比统计(词表迭代量化依据);
58
+ - **已知坑召回(建议 5)**:工具错误文本命中特征表(SEC_E_NO_CREDENTIALS / ECONNREFUSED:7890 / EPERM 管道 / ERR_MODULE_NOT_FOUND)→ 审计 `error-hint` + 注入指向知识库提示(本轮去重、走投递资格链,不违反注入噪音治理);
59
+ - **质保**:新增强力测试(写类判定/分析通道/临时区红线/转义检测 REFUSED 实测/召回特征)+ verify-all 五层全绿 + 工具箱覆盖门禁回归。
60
+
49
61
  ## 0.5.9(2026-08-27 追加)
50
62
 
51
63
  > 修复方向(用户"整体审查"要求,依据手册知识条目 K-01~K-06):静态工具清单漏分类官方工具
@@ -16,12 +16,17 @@ import {
16
16
  isBackupTool,
17
17
  isHighRiskEntryFile,
18
18
  isManualReadTool,
19
+ isMutationCommand,
20
+ isOutsideWorkspace,
19
21
  isProtectedConfigPath,
20
22
  isReadOnlyTool,
21
23
  isSensitiveToolCall,
22
24
  isVariablePath,
23
25
  isVerificationCommand,
24
26
  isLowRiskWorkspaceNew,
27
+ isAnalysisOp,
28
+ isAnalysisScratchPath,
29
+ extractAnalysisScratchPaths,
25
30
  pathTarget
26
31
  } from "./patterns.js";
27
32
  import { authMatches, describeAuth, describeOp, describeScopes, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText, scopesFromIntents } from "./authorization.js";
@@ -61,7 +66,7 @@ function makeHit(cfg, reason) {
61
66
  ruleId: cfg.ruleId,
62
67
  title: cfg.title,
63
68
  action: "deny",
64
- reason: `${reason}(规则 ${cfg.ruleId}|放行:${hint}|ERR-${errId})`
69
+ reason: `${reason}(规则 ${cfg.ruleId}|放行:${hint}|ERR-${errId}|误判可打标:/guard log 找该 ERR 码 → /guard label <事件号> incorrect)`
65
70
  };
66
71
  }
67
72
 
@@ -242,9 +247,10 @@ export function guardDecision(state, exec, now = Date.now(), opts = {}) {
242
247
 
243
248
  // 阶段 C:硬拦 pwsh/bash 绕过统一入口直接写受保护文件(规则 19⑧/21⑨ 的机器执行层)
244
249
  // 合法通道 = 整条命令仅调用 dsh-manual-write.mjs(无 ; | & 链式/换行,防注释文本伪造放行)
250
+ // 0.5.10:写类判定改 isMutationCommand(单真源)——修复 `Write-Output` 被 `write` 子串误杀(WGO654/ES3VCD 同类)
245
251
  if (!unlock && (name === "pwsh" || name === "bash")) {
246
252
  const cmd = commandText(args) || "";
247
- if (cmd && /(?:set-content|out-file|add-content|write|copy-item|move-item|rename-item|writefilesync|fs\.writefilesync)/i.test(cmd) && PROTECTED_FILENAME_RE.test(cmd)) {
253
+ if (cmd && isMutationCommand(cmd) && PROTECTED_FILENAME_RE.test(cmd)) {
248
254
  if (!isEntryChannelCommand(cmd)) {
249
255
  return makeHit(
250
256
  { ruleId: "__self-protect", title: "受保护文件禁止绕过统一入口直写", action: "deny" },
@@ -324,6 +330,17 @@ function matchRule(cfg, ctx) {
324
330
  // analysis(只读分析放行)/ artifact(产物放行+审计)/ unknown(物理不拦,由 pre-execute
325
331
  // ask/deny 层首调处置,防新插件绕过);mutating 维持原有严格逻辑。0.5.9 单真源:唯一分类表。
326
332
  const cls = toolClass(name, args);
333
+ // 0.5.10 分析通道(用户多次提出:只读分析需要写临时脚本/输出——解压副本/聚合日志等):
334
+ // 严格只读 ∪ 分析临时区写 ∪ 分析脚本区调用 → 任何回合放行 + 审计留痕。
335
+ // 红线:工作区外路径命中临时区段(如 C:\xxx\logs\)不豁免——落入常规变更判定。
336
+ if (isAnalysisOp(name, args)) {
337
+ const scratch = extractAnalysisScratchPaths(commandText(args) || "");
338
+ const outside = scratch.filter((p) => isOutsideWorkspace(p, sessionIdOf(exec)));
339
+ if (outside.length === 0) {
340
+ audit?.({ kind: "analysis-scratch", rule: cfg.ruleId, name: "分析通道放行", tool: name, reason: `分析类操作(只读/临时区/分析脚本区)放行:${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
341
+ return null;
342
+ }
343
+ }
327
344
  if (cls === "analysis") return null;
328
345
  if (cls === "artifact") {
329
346
  audit?.({ kind: "allow", rule: cfg.ruleId, name: "产物类工具放行", tool: name, reason: `分类 artifact(低风险产物写入,留痕可对账):${describeOp(operationOf(name, args))}`, session: sessionIdOf(exec) });
@@ -237,10 +237,15 @@ 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|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;
240
+ /(?:\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)\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
 
244
+ /** 写/删除/提交类命令判定(0.5.10 单真源:guard-core self-protect 等全部复用——修复 Write-Output 被 `write` 子串误杀) */
245
+ export function isMutationCommand(command) {
246
+ return typeof command === "string" && MUTATING_CMD_RE.test(command);
247
+ }
248
+
244
249
  /** 只读命令链按分隔符折段(机制 B,2026-08-24):`; 换行 && || |`(管道前后是命令链;`2>&1`/`2>$null` 的 stderr 重定向不拆分) */
245
250
  function splitCommandSegments(command) {
246
251
  return String(command)
@@ -273,6 +278,74 @@ export function isReadOnlyTool(toolName, args) {
273
278
  return false;
274
279
  }
275
280
 
281
+ // ═══════════ 0.5.10 分析通道(用户多次提出"只读分析需要写临时脚本/输出")═══════════
282
+ // 两类区:① 分析脚本区(scripts/analysis/*.mjs,仓库内可审查)② 分析临时区(logs/ .analysis-tmp/ .backups/,
283
+ // 输出/解压副本/分析脚本产物——不纳入 git 跟踪、不属正式变更)。红线由 guard-core 追加(工作区外/受保护名/覆盖既有)。
284
+ const ANALYSIS_SCRATCH_SEG_RE = /(?:^|[\\/])(?:logs|\.analysis-tmp|\.backups)[\\/]/i;
285
+ // 分析脚本区:仓库内 scripts/ 任意 .mjs(可审查工具库——unzstd/聚合日志/诊断脚本)+ scripts/analysis/ 专用
286
+ const ANALYSIS_SCRIPT_RE = /node\s+[^&;|]*[\\/]scripts[\\/][^\s&;|"]+\.mjs/i;
287
+
288
+ /** 是否分析临时区路径(logs/.analysis-tmp/.backups 段;工作区外红线由 guard-core 用 isOutsideWorkspace 把关) */
289
+ export function isAnalysisScratchPath(p) {
290
+ if (typeof p !== "string" || !p) return false;
291
+ return ANALYSIS_SCRATCH_SEG_RE.test(p.replace(/\\/g, "/"));
292
+ }
293
+
294
+ /** 提取命令文本中命中分析临时区的路径 token(保守:仅取带临时区特征的片段) */
295
+ export function extractAnalysisScratchPaths(command) {
296
+ const n = String(command || "");
297
+ const out = [];
298
+ const re = /(?:logs|\.analysis-tmp|\.backups)[\\/][^\s"'&|;]+/gi;
299
+ let m;
300
+ while ((m = re.exec(n))) out.push(m[0].replace(/\\/g, "/"));
301
+ return out;
302
+ }
303
+
304
+ /**
305
+ * 分析类操作判定(0.5.10 单真源):true = 可按 analysis 放行(无副作用,或副作用仅限分析临时区)。
306
+ * 场景:① 严格只读(isReadOnlyTool);② 分析脚本区调用(node scripts/analysis/*.mjs);
307
+ * ③ 临时区写(命令含写特征且提取到的目标路径全部命中分析临时区,且无受保护文件名)。
308
+ * 红线(guard-core 追加,本函数不含):工作区外路径(isOutsideWorkspace)、受保护名、覆盖既有正式文件。
309
+ */
310
+ export function isAnalysisOp(toolName, args) {
311
+ const name = String(toolName || "");
312
+ if (isReadOnlyTool(name, args)) return true;
313
+ const cmd = commandText(args) || "";
314
+ if (name === "node" || name === "pwsh" || name === "bash") {
315
+ // ② 分析脚本区调用(脚本自身可写临时区/读全局;命令无其他副作用特征即可)
316
+ if (ANALYSIS_SCRIPT_RE.test(cmd)) {
317
+ const paths = extractAnalysisScratchPaths(cmd);
318
+ if (paths.every((p) => isAnalysisScratchPath(p)) && !PROTECTED_FILENAME_RE.test(cmd)) return true;
319
+ }
320
+ // ③ 临时区写:写命令(不含删除/移动——删除/移动属 13A 红线,不走分析豁免)+ 提取路径全部命中临时区 + 无受保护名
321
+ if (isMutationCommand(cmd) && !/(?:Remove-Item|Move-Item|Rename-Item|del\s|rm\s|git\s+push|git\s+commit)/i.test(cmd)) {
322
+ const paths = extractAnalysisScratchPaths(cmd);
323
+ if (paths.length > 0 && paths.every((p) => isAnalysisScratchPath(p)) && !PROTECTED_FILENAME_RE.test(cmd)) {
324
+ return true;
325
+ }
326
+ }
327
+ }
328
+ return false;
329
+ }
330
+
331
+ // ═══════════ 0.5.10 已知环境坑特征表(建议5——错误码自动召回,K-01/K-05 依据)═══════════
332
+ // 工具/命令错误文本命中特征 → 审计 error-hint + 注入指向知识库的提示(低频:仅命中才提示;语义=指路不打扰)。
333
+ const KNOWN_PITFALLS = [
334
+ { key: "SEC_E_NO_CREDENTIALS", re: /SEC_E_NO_CREDENTIALS/i, hint: "TLS 凭据失败(常见于沙箱/受限会话)——先查手册踩坑 60/89;Node fetch 配 NODE_USE_ENV_PROXY 可绕" },
335
+ { key: "proxy-7890-refused", re: /ECONNREFUSED[^\n]*7890/i, hint: "代理端口 7890 无监听——确认代理软件已启动;git/gh 可直连,npm registry 直连本机不可用(发布脚本默认直连,DSH_RELEASE_PROXY 可指定)" },
336
+ { key: "sandbox-pipe-EPERM", re: /EPERM/i, hint: "受限沙箱下子进程管道受限(踩坑 22⑤)——需 danger-full-access 运行" },
337
+ { key: "ERR_MODULE_NOT_FOUND", re: /ERR_MODULE_NOT_FOUND/i, hint: "模块缺失——检查依赖闭包/空壳目录(踩坑 54)" }
338
+ ];
339
+
340
+ /** 已知坑特征匹配(纯函数):错误文本命中特征表 → 返回条目;未命中 → null */
341
+ export function matchKnownPitfall(text) {
342
+ const t = String(text || "");
343
+ for (const p of KNOWN_PITFALLS) if (p.re.test(t)) return p;
344
+ return null;
345
+ }
346
+
347
+ export { KNOWN_PITFALLS };
348
+
276
349
  /**
277
350
  * node -e 内联代码的危险特征判定(机制批 M4:diag-run 受控诊断工具使用)。
278
351
  * 黑名单命中任一 = 判定有写/网络/进程副作用(保守:宁可拒)。规则 9 正文不动(A2=B 方案)。
package/lib/index.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  isGetDateCommand,
24
24
  isManualReadTool,
25
25
  isReadOnlyTool,
26
+ matchKnownPitfall,
26
27
  needsApprovalReminder,
27
28
  PRIVATE_NETWORK_RE,
28
29
  setWorkspaceRoot,
@@ -576,6 +577,33 @@ export function handleSessionEvent(ctx, session, event) {
576
577
  }
577
578
  }
578
579
 
580
+ // 0.5.10 建议5(已知坑错误码召回):错误结果文本命中特征表 → 审计 + 注入指向知识库的提示
581
+ //(本轮去重;仍走 maybeInject 资格链——真实用户在场/预算内才投递,不违反注入噪音治理)
582
+ if (isError) {
583
+ const errText = String(
584
+ resultBlock?.content?.map?.((c) => c?.text || "").join(" ") ||
585
+ d?.message?.content?.map?.((c) => c?.text || "").join(" ") ||
586
+ ""
587
+ );
588
+ const pit = matchKnownPitfall(errText);
589
+ if (pit) {
590
+ if (!s.turn.errorHints) s.turn.errorHints = new Set();
591
+ if (!s.turn.errorHints.has(pit.key)) {
592
+ s.turn.errorHints.add(pit.key);
593
+ audit({
594
+ kind: "error-hint",
595
+ rule: "__error-hint",
596
+ name: "已知坑召回",
597
+ event: "tool/result",
598
+ tool: pendingCall?.name || undefined,
599
+ reason: `${pit.key} 命中特征表(建议5:错误码→知识库)`,
600
+ session: sid
601
+ });
602
+ maybeInject(ctx, sid, { ruleId: "__error-hint", reason: pit.hint });
603
+ }
604
+ }
605
+ }
606
+
579
607
  // M8 双通道机制(2026-08-24):dsh-manual-write 落盘成功 → 标记;同轮 engram_store 成功 → 标记
580
608
  if (!isError && pendingCall) {
581
609
  const cmd = pendingCall.args?.command || pendingCall.args?.code || "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rule-engine",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
12
12
  const auditPath = join(process.env.DSH_HOME || join(process.env.USERPROFILE || "", ".dsh"), "rule-engine.log.jsonl");
13
13
  const now = Date.now();
14
- const cuts = { intentFail: 0, intentOk: 0, judgePass: 0, judgeFalse: 0, judgeUnavail: 0, verifyGap: 0, injectSkip: 0, sourceSkip: 0 };
14
+ const cuts = { intentFail: 0, intentOk: 0, judgePass: 0, judgeFalse: 0, judgeUnavail: 0, verifyGap: 0, injectSkip: 0, sourceSkip: 0, denyTotal: 0, labelIncorrect: 0, labelCorrect: 0, errorHint: 0 };
15
15
 
16
16
  try {
17
17
  for (const line of readFileSync(auditPath, "utf8").split("\n")) {
@@ -24,6 +24,10 @@ try {
24
24
  if (line.includes('"kind":"verify-gap"')) cuts.verifyGap++;
25
25
  if (line.includes('"kind":"inject-skip"')) cuts.injectSkip++;
26
26
  if (line.includes('"kind":"source-skip"')) cuts.sourceSkip++;
27
+ if (line.includes('"kind":"deny"')) cuts.denyTotal++;
28
+ if (line.includes('"kind":"task-label"') && line.includes("incorrect")) cuts.labelIncorrect++;
29
+ if (line.includes('"kind":"task-label"') && line.includes("correct")) cuts.labelCorrect++;
30
+ if (line.includes('"kind":"error-hint"')) cuts.errorHint++;
27
31
  }
28
32
  } catch {
29
33
  // 无日志文件:一切 0
@@ -49,6 +53,8 @@ console.log("== 健康审计(近 24h)==");
49
53
  console.log(`intent-llm 失败降级 ${cuts.intentFail} 条 / 成功 ${cuts.intentOk} 条`);
50
54
  console.log(`judge-pass ${cuts.judgePass} 条 / judge-false ${cuts.judgeFalse} 条 / judge-unavailable ${cuts.judgeUnavail} 条`);
51
55
  console.log(`verify-gap ${cuts.verifyGap} 条 / inject-skip ${cuts.injectSkip} 条 / source-skip ${cuts.sourceSkip} 条`);
56
+ console.log(`误判打标(建议4):deny 总数 ${cuts.denyTotal} 条 → label incorrect ${cuts.labelIncorrect} 条 / correct ${cuts.labelCorrect} 条${cuts.denyTotal > 0 && cuts.labelIncorrect > 0 ? `(incorrect 占比 ${(cuts.labelIncorrect / cuts.denyTotal * 100).toFixed(1)}%)` : ""}——词表迭代量化依据`);
57
+ console.log(`已知坑召回(建议5):error-hint ${cuts.errorHint} 条`);
52
58
  console.log(`接线交叉:${EXPORTS.length} 个关键导出 → 疑似未接线 ${orphans.length ? orphans.join("、") : "0 个"}`);
53
59
  if (cuts.judgePass + cuts.judgeFalse === 0) {
54
60
  console.log("⚠️ 问题:近 24h 无真实判例——裁决器无运行证据(需实弹)");