dsh-rule-engine 0.6.3 → 0.6.4
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 +24 -8
- package/lib/core/authorization.js +12 -4
- package/lib/core/guard-core.js +55 -18
- package/lib/core/matcher.js +19 -16
- package/lib/core/measure-kinds.js +127 -0
- package/lib/core/patterns.js +38 -5
- package/lib/core/state.js +8 -1
- package/lib/core/tool-catalog.js +11 -3
- package/lib/core/understander.js +23 -34
- package/lib/index.js +14 -5
- package/package.json +1 -1
- package/scripts/dualtrack-check.mjs +163 -17
- package/scripts/publish-aptitude-check.mjs +5 -2
- package/scripts/release-plugin.mjs +36 -5
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# dsh-rule-engine
|
|
2
2
|
|
|
3
3
|

|
|
4
|
-

|
|
5
5
|
|
|
6
|
-
DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 +
|
|
6
|
+
DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。**当前版本 0.6.4**(**适用环境:DSH `0.1.5-rc.2`**——本机实测:装配成功 + 冷加载探针 PASS + `npm test` ALL TESTS PASSED + `npm run verify` 13 层全绿;兼容声明面见 `package.json` 的 `dshCompat`:`>=0.1.0-rc.3 <0.2.0`,覆盖 0.1.5 线)。本版三项修复:**D2**(待决 ask 提升为会话级——修复"用户消息与 ask 答复并发"致授权全部未登记)+ **B1/C1**(规则 24 官方 bundle 豁免兑现;装配不一致的收敛豁免)+ **A2**(ask 授权登记改复数路径、`authMatches` 支持多路径匹配)。
|
|
7
7
|
|
|
8
8
|
> 当前版本 **0.6.3**(0.1.2-rc.1 适配:Remote 新合同(typert-protocol `Remote(undefined, …)` 形态,typeof-null 陷阱修复)+ peer 锚扩 `>=0.1.2-rc.0`;白名单 3 轮扩充(gh 只读态 / `gh api` 只读 / `git ls-remote` / `cmdkey /list` + Do- 结构词回归修复);E7 豁免预插(release-plugin bump 后自动追加 pnpm minimumReleaseAgeExclude——⑬ 口径防发布后红灯窗口,单源 `scripts/lib/pnpm-exempt.mjs`))。本插件面向"规则机器化执行":规则写在 AGENTS.md 里,引擎负责让它们真的被遵守;所有规则动态解析,规则增删改后无需重写插件。
|
|
9
|
+
>
|
|
10
|
+
> **0.6.4(最新;取代上行版本号)**——**适用环境验证:DSH `0.1.5-rc.2`**(本机实测:装配成功 + 冷加载探针 PASS + `npm test` ALL TESTS PASSED + `npm run verify` 13 层全绿;兼容声明面见 `package.json` 的 `dshCompat`:`>=0.1.0-rc.3 <0.2.0`,覆盖 0.1.5 线)。本版三项修复:**D2**(待决 ask 提升为会话级——修复"用户消息与 ask 答复并发"致授权全部未登记)+ **B1/C1**(规则 24 官方 bundle 豁免兑现;装配不一致的收敛豁免——守卫不再拦下唯一能收敛的动作)+ **A2**(ask 授权登记改复数路径、`authMatches` 支持多路径匹配——修复"多路径只取一个、取最长取到次要路径")。
|
|
9
11
|
|
|
10
12
|
## 项目背景
|
|
11
13
|
|
|
@@ -23,7 +25,7 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
|
|
|
23
25
|
本插件定位:**通用规则执行引擎**——任何用户的 AGENTS.md 规则集均可使用;本机工作流偏好仅作默认兜底(配置层),不含任何强制绑定。
|
|
24
26
|
|
|
25
27
|
- **声明式绑定**:规则正文可写 `<!-- handler: xxx -->`(或经插件配置 `handlerOverrides: {规则编号: 执行器名}`)显式绑定引擎执行器;未声明的规则按"纯自证"处理(参与匹配/自证提示,不参与机器硬拦);
|
|
26
|
-
-
|
|
28
|
+
- **措施类型(kind)**:声明/配置写 kind 名(`approval`/`backup`/`inline-command`…)即绑定对应执行器;历史内部名(`rule12a-approval` 等)经兼容别名表归一为同一 kind,**旧配置与旧规则正文无需改动**;
|
|
27
29
|
- **禁用语义**:管理器禁用的规则以"禁用占位"存在——`/guard rules` 显示"(已禁用)",不参与硬拦/纠察;恢复启用后自动重新生效(禁用 ≠ 删除,不再"消失");
|
|
28
30
|
- **无 AGENTS.md 也可用**:无规则文件时引擎零错加载、零规则、零误拦(发布门禁验证);
|
|
29
31
|
- **本机偏好表下沉配置**:内置默认偏好表已从代码移除——通用部署=空表(代码零本机编号);本机偏好经 `rule-engine.json` 的 `handlerDefaultMap` 提供(可整体替换/清空,引擎升级不丢);
|
|
@@ -241,7 +243,9 @@ console.log(qualityTrend(sig).summary); // 方向:rework 改善 / 持平 /
|
|
|
241
243
|
> 更早版本(0.1.0-0.5.5)与本机历史要点见 git 历史;各版本内部"用户定稿"等决策细节不再随发布物携带。
|
|
242
244
|
|
|
243
245
|
| 版本 | 日期 | 要点 |
|
|
244
|
-
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| **0.6.4** | 2026-09-10 | **本版**:**D2**(待决 ask 提升为会话级——修复"用户消息与 ask 答复并发"致授权全部未登记)+ **B1/C1**(规则 24 官方 bundle 豁免兑现;装配不一致的收敛豁免——守卫不再拦下唯一能收敛的动作)+ **A2**(ask 授权登记改复数路径、`authMatches` 支持多路径匹配)。**适用环境验证 = DSH `0.1.5-rc.2`**(装配 + 冷加载探针 + `npm test` + `verify` 13 层全绿) |
|
|
248
|
+
| **0.6.3** | 2026-09-09 | docs(readme): 词表配置章节补齐 dualtrack(markers/whitelist/加载优先级/空表 REFUSED) |
|
|
245
249
|
| **0.6.2** | 2026-09-08 | fix(0.6.2): whitelist 3-round + 0.1.2 adaptation + peer-anchor 0.1.2 + E7 pre-plug wiring |
|
|
246
250
|
|---|---|---|
|
|
247
251
|
| **0.6.1+(本地增强,未发版)** | 2026-09-07 | 只读豁免清单扩充(用户定调"纯只读顺畅"):`gh api` 只读态(无 -X/--method/graphql/-F/-f 的段)、`gh release/issue/pr/run view`、`cmdkey /list`、`git ls-remote` 显式;写形态(gh api -F/graphql、git fetch、curl 下载、重定向落盘)保持拦截;26 用例全绿 + 语法体检 + 热重载生效(发版需 bump 0.6.2) |
|
|
@@ -260,11 +264,11 @@ console.log(qualityTrend(sig).summary); // 方向:rework 改善 / 持平 /
|
|
|
260
264
|
| **0.5.7** | 2026-08-26 | 注入噪音治理(词表只产嫌疑 + LLM 裁决 + fail-closed + 投递资格闸 + 审计完整性)、注入通道重入修复(宏任务投递)、语义层(awaitingJudge/judge-pass/false/unavailable) |
|
|
261
265
|
| **0.5.6** | 2026-08-26 | 同回复聚合注入、已自证规则不重复触发、C2 规则统计(detected/suppressed/injected) |
|
|
262
266
|
|
|
263
|
-
## 发行固定源
|
|
264
|
-
|
|
265
|
-
|
|
267
|
+
## 发行固定源
|
|
268
|
+
|
|
269
|
+
> **0.6.4(当前)** —— 本版三项修复:**D2**(待决 ask 提升为会话级)/ **B1/C1**(规则 24 官方 bundle 豁免兑现 + 装配不一致的收敛豁免)/ **A2**(ask 授权登记改复数路径 + `authMatches` 多路径匹配)。**适用环境验证 = DSH `0.1.5-rc.2`**(装配 + 冷加载探针 + `npm test` + `verify` 13 层全绿)。**发布 commit 待三通道完成后回填**(届时 `git checkout <hash>` 可复现 npm `dsh-rule-engine@0.6.4` 与 GitHub Release v0.6.4 同源代码)。
|
|
266
270
|
|
|
267
|
-
- **0.6.3(当前)** 固定于 main Commit `
|
|
271
|
+
- **0.6.3(当前)** 固定于 main Commit `3e87f9d`(`git checkout 3e87f9d` 可复现 npm `dsh-rule-engine@0.6.3` 与 GitHub Release v0.6.3 同源代码——0.6.3 = 分层残留闸 `dualtrack-check`(判据 A:中文≠个人化)+ 词表全量配置化(lexicons / patterns / criticismPersonal / dualtrack 走 `rule-engine.json`)+ 第三批第 1 批文案层 + 三项门禁修复(`--init` 覆盖保护 / loader-smoke 中文夹具 / 中文目录顿号兼容)。
|
|
268
272
|
- **0.6.2** 固定于 main Commit `92da194538fce2f56fa7c6712c70711865772686`(`git checkout 92da194538fce2f56fa7c6712c70711865772686` 可复现 npm `dsh-rule-engine@0.6.2` 与 GitHub Release v0.6.2 同源代码——0.6.2 = 0.1.2-rc.1 适配(Remote 新合同 `Remote(undefined, …)` / peer 锚扩 `>=0.1.2-rc.0`)+ 白名单 3 轮扩充 + Do- 结构词回归修复 + E7 豁免预插机制上线)。
|
|
269
273
|
- **0.6.1** 固定于 main Commit `051e2da`(`git checkout 051e2da` 可复现 npm `dsh-rule-engine@0.6.1` 与 GitHub Release v0.6.1 同源代码——0.6.1 = 豁免预插(release-plugin bump 后自动追加 pnpm minimumReleaseAgeExclude,⑬ 绝对口径防发布后红灯窗口——踩坑 18 镜像)+ 豁免判定单源化(scripts/lib/pnpm-exempt.mjs 与 verify-all ⑬ 共享,4 单测锁定)+ viewFails 发布语境 STRICT 计 ❌ + ⑬ 块头注释绝对口径(E1/E2/E3 收尾批)。
|
|
270
274
|
- **0.6.0** 固定于 main Commit `be5b8c93`(可复现 `dsh-rule-engine@0.6.0` 与 Release v0.6.0——0.6.0 = 本机集成层(localIntegrations 四键)+ 本机痕迹消号 + li-skipped/entry-script-missing 启动审计 + 发布门禁 B1/B2(readme-version-check / local-residue-scan,挂 verify-all/release-plugin/check:meta)+ check-tool-coverage 素材 fail-closed;词表文件 `scripts/local-residue-markers.txt` 为本机门禁工具,不入库、不进发布物(见 .gitignore / package.json files 排除)。固定源之后的提交仅限 README 指针文本)。
|
|
@@ -397,10 +401,22 @@ dsh plugin --profile web add dsh-rule-engine
|
|
|
397
401
|
- dsh-visualize(Nagi-ovo)
|
|
398
402
|
- dsh-rules-manager(jilian-dsh)
|
|
399
403
|
- dsh-vision-router、dsh-example-injector 等未列出的作者
|
|
404
|
+
- **设计思想与机制来源(本项目直接内化/借鉴)**:
|
|
405
|
+
- stop-that-shit(lennney):任务边界、反过度工程、预算与四类越界——任务契约模块的设计源头。
|
|
406
|
+
- dsh-agi-harness(yjh051108):闸=最小决策单元、Wilson 下界、拒因即指路、自由面/盲区显式声明、任务签名+质量趋势(quality-ledger)、变异测试+棘轮。
|
|
407
|
+
- Claude Code 权限范式(Anthropic 官方文档):默认询问、deny 永远赢、通配符规则只加不放。
|
|
408
|
+
- mattpocock/skills「writing-for-agents」:写给 agent 的文档方法论。
|
|
409
|
+
- dsh-zvec-grep(sugarforever):后台任务型插件写法。
|
|
410
|
+
- **升级与审查工具链**:
|
|
411
|
+
- oh-my-dsh / dsh-plugin-upgrade-skill(社区):0.1.2 升级卡库与对策集。
|
|
412
|
+
- build-dsh-plugin(AI-Scarlett):插件完备性审计。
|
|
413
|
+
- cordis-plugin-thinking-loop-guard(argszero):纯思考空转的源码级判读。
|
|
400
414
|
- **学习参考的社区文档/库作者**:
|
|
401
415
|
- dsh-handbook(Electricitysheep)
|
|
402
416
|
- SandBase deepseek-harness-handbook(sandbaseai)
|
|
417
|
+
- awesome-dsh-plugin / Oh! dsh(生态目录)
|
|
403
418
|
- 以及 DSH 官方文档镜像与源码维护者
|
|
419
|
+
- **贡献建议与实证的个人**:TheBuilderJR、Reximmortal1021、AI-Scarlett、ckcfcc、goatliamia 等。
|
|
404
420
|
|
|
405
421
|
## 免责声明
|
|
406
422
|
|
|
@@ -374,13 +374,17 @@ function pathUnderAuth(p, authPath) {
|
|
|
374
374
|
export function authMatches(auth, op) {
|
|
375
375
|
if (!auth || !op) return false;
|
|
376
376
|
if (auth.type !== "any" && op.type !== "any" && auth.type !== op.type) return false;
|
|
377
|
-
|
|
377
|
+
// A2(2026-09-10):授权侧支持多路径——ask 多路径问题文本经 index.js L1029 记入 pathPrefixes。
|
|
378
|
+
// 语义:任一授权路径覆盖任一操作候选路径即匹配(此前只认单一 auth.pathPrefix,ask 侧只记一条 → 其余路径的授权形同虚设)。
|
|
379
|
+
const authPaths = Array.isArray(auth.pathPrefixes) && auth.pathPrefixes.length > 0
|
|
380
|
+
? auth.pathPrefixes.map(normalizePath).filter(Boolean)
|
|
381
|
+
: (auth.pathPrefix ? [normalizePath(auth.pathPrefix)] : []);
|
|
382
|
+
if (authPaths.length > 0) {
|
|
378
383
|
const candidates = Array.isArray(op.pathPrefixes) && op.pathPrefixes.length > 0
|
|
379
384
|
? op.pathPrefixes
|
|
380
385
|
: (op.pathPrefix ? [op.pathPrefix] : []);
|
|
381
386
|
if (candidates.length === 0) return false;
|
|
382
|
-
|
|
383
|
-
if (!candidates.some((p) => pathUnderAuth(normalizePath(p), authPath))) return false;
|
|
387
|
+
if (!candidates.some((p) => authPaths.some((a) => pathUnderAuth(normalizePath(p), a)))) return false;
|
|
384
388
|
}
|
|
385
389
|
// 柱子 B(2026-08-31):对象锚定——授权声明了显式命名对象 → 操作命令文本必须命中该对象
|
|
386
390
|
//("推送 X 仓库"不覆盖"推送 Y 仓库";未锚定对象=不收紧)
|
|
@@ -402,7 +406,11 @@ export function findMatchingAuth(auths, op, now = Date.now()) {
|
|
|
402
406
|
export function describeAuth(auth) {
|
|
403
407
|
if (!auth) return "无";
|
|
404
408
|
const type = auth.type || "any";
|
|
405
|
-
|
|
409
|
+
// A2(2026-09-10):多路径授权逐条显示(ask 多路径问题文本);单路径/全局维持原措辞。
|
|
410
|
+
const paths = Array.isArray(auth.pathPrefixes) && auth.pathPrefixes.length > 0
|
|
411
|
+
? auth.pathPrefixes
|
|
412
|
+
: (auth.pathPrefix ? [auth.pathPrefix] : []);
|
|
413
|
+
const path = paths.length === 0 ? "全局" : paths.length === 1 ? `路径 ${paths[0]}` : `路径 ${paths.join(" / ")}`;
|
|
406
414
|
const expires = auth.expiresAt ? `|到期 ${new Date(auth.expiresAt).toISOString()}` : "";
|
|
407
415
|
return `${type}|${path}|${new Date(auth.at || 0).toISOString()}${expires}|仅本次运行有效`;
|
|
408
416
|
}
|
package/lib/core/guard-core.js
CHANGED
|
@@ -37,6 +37,7 @@ import { computeMountSignature, profileNameFromArgs } from "./mount-signature.js
|
|
|
37
37
|
import { findBackupForPath, getSessionState, maybeReloadIfChanged } from "./state.js";
|
|
38
38
|
import { isVersionedFile, validateEditedFile } from "./version-guard.js";
|
|
39
39
|
import { decideContractAction, defaultContract, isArmed } from "./contract.js";
|
|
40
|
+
import { kindOf } from "./measure-kinds.js";
|
|
40
41
|
import { classifyAction } from "./overengineering.js";
|
|
41
42
|
import { labelFingerprint, labelsAllowFingerprint } from "./label-fingerprint.js";
|
|
42
43
|
|
|
@@ -286,23 +287,26 @@ function resolveLocalDependencyPkgPath(bundleName, profilePkgPath, parsed) {
|
|
|
286
287
|
return existsSync(pkgPath) ? pkgPath : null;
|
|
287
288
|
}
|
|
288
289
|
|
|
289
|
-
/**
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
290
|
+
/** 官方 bundle 白名单(B1 修复,2026-09-10):规则 24 自己的豁免条款原文即「官方 bundle 由官方机制管理,
|
|
291
|
+
* 模型不主动修改」——引擎此前未在代码里兑现。这两个包**永远**位于 DSH 本体或 profiles 共享层,
|
|
292
|
+
* 独立 DSH_HOME 的新 profile 两者都可能尚未建立 → 校验必然失败并**卡死 profile 声明编辑**(试点主要阻塞点)。 */
|
|
293
|
+
const OFFICIAL_BUNDLES = new Set(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]);
|
|
294
|
+
|
|
295
|
+
/** 解析给定 profile package.json **内容**中非 bundle / 无法确认的项(内容级纯函数,供 B1 与 C1 复用) */
|
|
296
|
+
export function nonBundleInContent(content, profilePkgPath) {
|
|
295
297
|
let parsed;
|
|
296
298
|
try { parsed = JSON.parse(content); } catch { return null; }
|
|
297
299
|
const bundles = parsed?.dsh?.profile?.bundles;
|
|
298
300
|
if (!Array.isArray(bundles)) return null;
|
|
299
301
|
const bad = [];
|
|
300
302
|
for (const b of bundles) {
|
|
301
|
-
|
|
303
|
+
if (OFFICIAL_BUNDLES.has(b)) continue; // B1:官方 bundle 直接放行(规则 24 豁免条款)
|
|
304
|
+
const pkgPath = resolveBundlePkgPath(b, profilePkgPath) || resolveLocalDependencyPkgPath(b, profilePkgPath, parsed);
|
|
302
305
|
if (!pkgPath) {
|
|
303
306
|
const dep = parsed?.dependencies?.[b] ?? parsed?.devDependencies?.[b] ?? parsed?.optionalDependencies?.[b];
|
|
304
307
|
const depDesc = typeof dep === "string" ? `dependencies 为 ${dep}` : "dependencies 中无此包";
|
|
305
|
-
|
|
308
|
+
// B1 建议 3:给出可执行的排查方向(独立 DSH_HOME 场景最常见的成因)
|
|
309
|
+
bad.push(`${b}(找不到 package.json,无法确认类型;${depDesc}。请先用 dev_install_package 或先安装依赖再写 bundles;若为独立 DSH_HOME,请确认 profiles/node_modules 共享层存在——该层由 DSH 真实启动时的 healProfilesModuleFallback 自动建立)`);
|
|
306
310
|
continue;
|
|
307
311
|
}
|
|
308
312
|
try {
|
|
@@ -315,6 +319,31 @@ export function nonBundleInProfileBundles(name, args) {
|
|
|
315
319
|
return bad.length ? bad : null;
|
|
316
320
|
}
|
|
317
321
|
|
|
322
|
+
/** 若本次文件变更会写入 profile package.json 的 dsh.profile.bundles,返回其中非 bundle/无法确认的项 */
|
|
323
|
+
export function nonBundleInProfileBundles(name, args) {
|
|
324
|
+
const p = pathTarget(args);
|
|
325
|
+
if (!isProfilePackageJson(p)) return null;
|
|
326
|
+
const content = resultingFileContent(name, args);
|
|
327
|
+
if (!content) return null;
|
|
328
|
+
return nonBundleInContent(content, p);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** C1 修复(2026-09-10):判定本次变更是否为「使装配声明趋于自洽」的修复动作。
|
|
332
|
+
* 判据:变更**前** profile package.json 的 bundles 不自洽(存在解析不到的项),变更**后**自洽。
|
|
333
|
+
* 用途:mount-audit 守卫的**收敛豁免**——解除「不一致态只能靠被拦动作修复」的死循环。
|
|
334
|
+
* 保守边界:读不到原文件、或变更后仍不自洽 → 不豁免。 */
|
|
335
|
+
export function isConvergingBundleFix(name, args) {
|
|
336
|
+
const p = pathTarget(args);
|
|
337
|
+
if (!isProfilePackageJson(p)) return false;
|
|
338
|
+
const after = resultingFileContent(name, args);
|
|
339
|
+
if (!after) return false;
|
|
340
|
+
let before = null;
|
|
341
|
+
try { before = readFileSync(p, "utf8"); } catch { return false; }
|
|
342
|
+
const beforeBad = nonBundleInContent(before, p);
|
|
343
|
+
const afterBad = nonBundleInContent(after, p);
|
|
344
|
+
return Array.isArray(beforeBad) && beforeBad.length > 0 && (!afterBad || afterBad.length === 0);
|
|
345
|
+
}
|
|
346
|
+
|
|
318
347
|
/** 任务契约守卫:仅在总开关开启且会话 armed 时硬拦;ask 场景交给 tools/pre-execute */
|
|
319
348
|
function taskContractGuardDecision(state, exec, session) {
|
|
320
349
|
if (!state.taskContract?.taskContractEnabled) return null;
|
|
@@ -499,7 +528,7 @@ function matchRule(cfg, ctx) {
|
|
|
499
528
|
|
|
500
529
|
// 规则 22 机器化:无执行分点/存在歧义 → 本回合禁止变更类工具调用
|
|
501
530
|
//("你的执行方案难道没问题吗?"虽含"执行"仍是疑问句——同形词不构成指令,不豁免)
|
|
502
|
-
if (cfg.handler === "
|
|
531
|
+
if (kindOf(cfg.handler) === "intent-direct") {
|
|
503
532
|
// B2(2026-08-28 阶段二):委派/子代理回合不做"无执行分点"判定(规则 22④ 的识别层落地)——
|
|
504
533
|
// 子代理任务书以 source.kind="user" 注入,会被当用户消息判"无执行分点"而误拦(ERR-L8QAXS 实弹)。
|
|
505
534
|
if (isDelegatedSession(exec)) {
|
|
@@ -588,7 +617,7 @@ function matchRule(cfg, ctx) {
|
|
|
588
617
|
}
|
|
589
618
|
|
|
590
619
|
// 规则 1:同工具同参数连续失败 ≥2 次后拦第 3 次(失败计数由 tool/result 更新)
|
|
591
|
-
if (cfg.handler === "
|
|
620
|
+
if (kindOf(cfg.handler) === "retry") {
|
|
592
621
|
const key = `${name}:${JSON.stringify(args || {})}`;
|
|
593
622
|
const count = state.retryCounts.get(key) || 0;
|
|
594
623
|
const userText = session.turn.userText || session.lastUserText || "";
|
|
@@ -602,7 +631,7 @@ function matchRule(cfg, ctx) {
|
|
|
602
631
|
}
|
|
603
632
|
|
|
604
633
|
// 规则 9:内联命令 / BOM 写配置(PS7 语义:仅拦显式 utf8BOM)
|
|
605
|
-
if (cfg.handler === "
|
|
634
|
+
if (kindOf(cfg.handler) === "inline-command") {
|
|
606
635
|
if ((name === "pwsh" || name === "bash") && cmd) {
|
|
607
636
|
if (INLINE_CMD.test(cmd)) {
|
|
608
637
|
return makeHit(cfg, "【硬拦截】禁止内联命令(node -e / pwsh -c / node -p 等),请先写脚本文件再执行");
|
|
@@ -615,7 +644,7 @@ function matchRule(cfg, ctx) {
|
|
|
615
644
|
}
|
|
616
645
|
|
|
617
646
|
// 规则 18:DSH 任务首次工具调用前必须已读手册
|
|
618
|
-
if (cfg.handler === "
|
|
647
|
+
if (kindOf(cfg.handler) === "manual-first") {
|
|
619
648
|
const userText = session.turn.userText || session.lastUserText || "";
|
|
620
649
|
const firstTool = session.turn.toolCount === 0;
|
|
621
650
|
if (firstTool && !session.manualReadSeen && dshKeywordsRe().test(userText) && !isManualReadTool(name, args, state.localIntegrations?.manualExempt?.paths || [])) {
|
|
@@ -625,7 +654,7 @@ function matchRule(cfg, ctx) {
|
|
|
625
654
|
}
|
|
626
655
|
|
|
627
656
|
// 规则 13A:删除/覆盖/高风险写前需有“目标路径对应备份”证据
|
|
628
|
-
if (cfg.handler === "
|
|
657
|
+
if (kindOf(cfg.handler) === "backup") {
|
|
629
658
|
// 统一入口命令豁免:入口脚本每次写入前自身执行备份(backup() 保留 5 份),
|
|
630
659
|
// 引擎静态扫描看不到脚本内部动作(已知盲区);入口命令也已被 __self-protect 限定为唯一写通道。
|
|
631
660
|
if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd, state.localIntegrations?.entryScript)) return null;
|
|
@@ -668,7 +697,7 @@ function matchRule(cfg, ctx) {
|
|
|
668
697
|
// hints 含 "skill" 的其它规则 cfg(如 12A:hints=["ask","skill","sensitive",...])截胡——
|
|
669
698
|
// 非 skill 工具时 return null,导致 12A 敏感授权检查静默失效(C-2 实测 Move-Item 放行)。
|
|
670
699
|
// 现在:hints 兜底仅在「name === 'skill'」时进入;handler 明确为 rule12b-skill 时维持原语义。
|
|
671
|
-
if (cfg.handler === "
|
|
700
|
+
if (kindOf(cfg.handler) === "skill-auth") {
|
|
672
701
|
if (name === "skill") {
|
|
673
702
|
const skillName = typeof args?.name === "string" ? args.name : "";
|
|
674
703
|
if ((state.localIntegrations?.manualExempt?.skills || []).includes(skillName)) return null;
|
|
@@ -703,7 +732,7 @@ function matchRule(cfg, ctx) {
|
|
|
703
732
|
}
|
|
704
733
|
|
|
705
734
|
// 规则 12A:敏感操作需要匹配授权证据
|
|
706
|
-
if (cfg.handler === "
|
|
735
|
+
if (kindOf(cfg.handler) === "approval") {
|
|
707
736
|
if (isSensitiveToolCall(name, args, sessionIdOf(exec))) {
|
|
708
737
|
// 0.5.11(用户定稿):12A 与 22-7 判据同源——分析通道/低风险新建豁免在两条分支
|
|
709
738
|
// 使用同一组判据(isAnalysisOp/isLowRiskWorkspaceNew),不再各写一套(此前 22-7 放行、
|
|
@@ -742,7 +771,7 @@ function matchRule(cfg, ctx) {
|
|
|
742
771
|
}
|
|
743
772
|
|
|
744
773
|
// 规则 21:规则/配置文件变更需 unlock(元规则)
|
|
745
|
-
if (cfg.handler === "
|
|
774
|
+
if (kindOf(cfg.handler) === "meta") {
|
|
746
775
|
if ((name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p) && !unlock) {
|
|
747
776
|
return makeHit(cfg, "【硬拦截】规则/配置文件受保护:修改需用户先执行 /guard unlock");
|
|
748
777
|
}
|
|
@@ -750,7 +779,7 @@ function matchRule(cfg, ctx) {
|
|
|
750
779
|
}
|
|
751
780
|
|
|
752
781
|
// 规则 24:插件装配类型确认(A 硬拦)+ 变更类工具统一覆盖(原规则 25 语义,检查④)
|
|
753
|
-
if (cfg.handler === "
|
|
782
|
+
if (kindOf(cfg.handler) === "assembly") {
|
|
754
783
|
if (name === "dev_install_package") {
|
|
755
784
|
const dir = args?.dir;
|
|
756
785
|
if (dir) {
|
|
@@ -782,7 +811,7 @@ function matchRule(cfg, ctx) {
|
|
|
782
811
|
}
|
|
783
812
|
|
|
784
813
|
// 规则 27:装配变更后必须先通过全量审计,才能继续装配(C 时序;全局变更 + 本会话审计证据)
|
|
785
|
-
if (cfg.handler === "
|
|
814
|
+
if (kindOf(cfg.handler) === "mount-audit") {
|
|
786
815
|
if (isAssemblyMutationTool(name, args)) {
|
|
787
816
|
const currentSig = computeMountSignature(profileNameFromArgs(args));
|
|
788
817
|
state.mountSignature = currentSig;
|
|
@@ -791,6 +820,14 @@ function matchRule(cfg, ctx) {
|
|
|
791
820
|
? currentSig !== auditedSig
|
|
792
821
|
: (state.mountRevision > (session.mountAuditRevision || 0));
|
|
793
822
|
if (needsAudit) {
|
|
823
|
+
// C1 修复(2026-09-10):**收敛豁免**——允许"使装配趋于自洽"的变更,解除不一致态的死循环。
|
|
824
|
+
// 背景(独立 DSH_HOME 试点):改 dependencies → 通过;改 bundles 补齐 → 被拦;
|
|
825
|
+
// 试图回滚 dependencies → **同样被拦** → 审计失败项(bundles-missing)恰恰只能靠被拦的动作修复
|
|
826
|
+
// → 无法收敛(试点靠用户手工写文件才解开)。
|
|
827
|
+
// ① 回滚豁免:变更后签名回到"审计通过时的签名"
|
|
828
|
+
// ② 修复豁免:变更前声明不自洽(bundles 有解析不到的项)而变更后自洽
|
|
829
|
+
if (auditedSig && currentSig === auditedSig) return null; // ① 回到已审计状态
|
|
830
|
+
if (isConvergingBundleFix(name, args)) return null; // ② 修复审计点名的缺失/非法项
|
|
794
831
|
const why = auditedSig
|
|
795
832
|
? `装配内容已变化(装配状态哈希 ${currentSig.slice(0, 8)} ≠ 审计通过时 ${auditedSig.slice(0, 8)})`
|
|
796
833
|
: `插件装配已变更(mountRevision=${state.mountRevision})且本会话未通过全量审计`;
|
package/lib/core/matcher.js
CHANGED
|
@@ -10,19 +10,21 @@ import {
|
|
|
10
10
|
activateTimeRe,
|
|
11
11
|
dshKeywordsRe
|
|
12
12
|
} from "./patterns.js";
|
|
13
|
+
import { kindOf } from "./measure-kinds.js";
|
|
13
14
|
|
|
14
15
|
export function activateForUserMessage(configs, userText) {
|
|
15
16
|
const text = userText || "";
|
|
16
17
|
return configs.filter((cfg) => {
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
if (
|
|
20
|
-
if (
|
|
21
|
-
if (
|
|
22
|
-
if (
|
|
23
|
-
if (
|
|
24
|
-
if (
|
|
25
|
-
if (
|
|
18
|
+
const kind = kindOf(cfg.handler);
|
|
19
|
+
if (kind === "manual-first" && dshKeywordsRe().test(text)) return true;
|
|
20
|
+
if (kind === "skill-auth" && activateSkillRe().test(text)) return true;
|
|
21
|
+
if (kind === "approval" && activateApprovalRe().test(text)) return true;
|
|
22
|
+
if (kind === "network" && activateNetworkRe().test(text)) return true;
|
|
23
|
+
if (kind === "backup" && activateBackupRe().test(text)) return true;
|
|
24
|
+
if (kind === "time" && activateTimeRe().test(text)) return true;
|
|
25
|
+
if (kind === "promise" && activatePromiseRe().test(text)) return true;
|
|
26
|
+
if (kind === "source" && activateSourceRe().test(text)) return true;
|
|
27
|
+
if (kind === "language" && CJK_PRESENT.test(text)) return true;
|
|
26
28
|
return false;
|
|
27
29
|
});
|
|
28
30
|
}
|
|
@@ -33,15 +35,16 @@ export function activateForToolCall(configs, toolName, args) {
|
|
|
33
35
|
const name = String(toolName || "");
|
|
34
36
|
const argText = JSON.stringify(args || {});
|
|
35
37
|
return configs.filter((cfg) => {
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
if (
|
|
39
|
-
if (
|
|
40
|
-
if (
|
|
41
|
-
if (
|
|
38
|
+
const kind = kindOf(cfg.handler);
|
|
39
|
+
if (kind === "inline-command" && (name === "pwsh" || name === "bash")) return true;
|
|
40
|
+
if (kind === "retry") return true;
|
|
41
|
+
if (kind === "manual-first") return true;
|
|
42
|
+
if (kind === "backup" && (name === "pwsh" || name === "bash" || name === "edit" || name === "write")) return true;
|
|
43
|
+
if (kind === "skill-auth" && name === "skill") return true;
|
|
44
|
+
if (kind === "approval" || kind === "sensitive") {
|
|
42
45
|
if (name === "pwsh" || name === "bash" || name === "edit" || name === "write" || name === "ask_user_question") return true;
|
|
43
46
|
}
|
|
44
|
-
if (
|
|
47
|
+
if (kind === "meta" && (name === "edit" || name === "write")) return true;
|
|
45
48
|
if (argText) {
|
|
46
49
|
const low = argText.toLowerCase();
|
|
47
50
|
if (cfg.triggerKeywords.some((k) => low.includes(k.toLowerCase()))) return true;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// measure-kinds.js - 措施类型注册表(架构 v3 §三 L1:通用层,零规则号)
|
|
2
|
+
//
|
|
3
|
+
// 依据:reports/2026-09-10-引擎分层架构-通用层零规则内容-v1.md
|
|
4
|
+
// §三 L1「措施类型注册表(measure kinds):每型一个通用解释器,命名去规则号」;
|
|
5
|
+
// §八 P1「措施类型注册表 + 解释器骨架;guard-core/matcher 改 kind → interpreter;内部名去规则号」。
|
|
6
|
+
//
|
|
7
|
+
// 三层分工(P1):
|
|
8
|
+
// ① MEASURE_KINDS —— 通用层的 kind 清单(**零规则号**,随包发布);
|
|
9
|
+
// ② LEGACY_HANDLER_ALIASES —— 历史内部名(带规则号)→ kind 的**兼容映射**
|
|
10
|
+
// (P4 清淤时迁个人层配置;保留在此是为了不破坏既有配置与规则正文声明);
|
|
11
|
+
// ③ kindOf() —— 唯一归一入口,接受 旧名 / kind 名 / 未知名 三种输入。
|
|
12
|
+
//
|
|
13
|
+
// 兼容面(P1 行为等价的前提):本机 rule-engine.json 的 handlerDefaultMap/handlerOverrides、
|
|
14
|
+
// 规则正文 `<!-- handler: … -->`、以及测试夹具,**仍可写旧内部名**——经 kindOf 归一后
|
|
15
|
+
// 与 kind 名完全等价。新用户/新规则写 kind 名即可,无需知道任何规则号。
|
|
16
|
+
export const MEASURE_KINDS = {
|
|
17
|
+
retry: "连续失败重试闸(第 3 次拒绝)",
|
|
18
|
+
time: "时间表述取证",
|
|
19
|
+
source: "引用出处标注",
|
|
20
|
+
promise: "承诺保守",
|
|
21
|
+
"inline-command": "禁止内联命令",
|
|
22
|
+
language: "语言与表达",
|
|
23
|
+
approval: "敏感操作授权确认",
|
|
24
|
+
"skill-auth": "技能调用授权",
|
|
25
|
+
network: "下载与网络",
|
|
26
|
+
backup: "写入前备份",
|
|
27
|
+
"manual-first": "先查手册再动手",
|
|
28
|
+
meta: "元规则管理",
|
|
29
|
+
"intent-direct": "无执行分点直拦",
|
|
30
|
+
"runtime-verify": "交付验证证据",
|
|
31
|
+
assembly: "插件装配类型守卫",
|
|
32
|
+
"release-asset": "发布资产规范",
|
|
33
|
+
"mount-audit": "挂载唯一性审计",
|
|
34
|
+
sensitive: "敏感操作(注入面)"
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** 历史内部名 → kind(兼容层;键含规则号,P4 迁个人层配置) */
|
|
38
|
+
export const LEGACY_HANDLER_ALIASES = {
|
|
39
|
+
"rule1-retry": "retry",
|
|
40
|
+
"rule2-time": "time",
|
|
41
|
+
"rule5-source": "source",
|
|
42
|
+
"rule7-promise": "promise",
|
|
43
|
+
"rule9-inline-bom": "inline-command",
|
|
44
|
+
"rule11-language": "language",
|
|
45
|
+
"rule12a-approval": "approval",
|
|
46
|
+
"rule12b-skill": "skill-auth",
|
|
47
|
+
"rule12c-network": "network",
|
|
48
|
+
"rule12d-sensitive": "sensitive",
|
|
49
|
+
"rule13a-backup": "backup",
|
|
50
|
+
"rule18-manual-first": "manual-first",
|
|
51
|
+
"rule21-meta": "meta",
|
|
52
|
+
"rule22-7-direct": "intent-direct",
|
|
53
|
+
"rule23-runtime-verify": "runtime-verify",
|
|
54
|
+
"rule24-assembly-type": "assembly",
|
|
55
|
+
"rule26-release-asset": "release-asset",
|
|
56
|
+
"rule27-mount-audit": "mount-audit"
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 归一为 kind 名。
|
|
61
|
+
* 旧内部名 → kind;kind 名 → 原样(幂等);未知名 → 原样(由覆盖自省提示未覆盖)。
|
|
62
|
+
* @param {string} name
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function kindOf(name) {
|
|
66
|
+
const s = String(name || "").trim();
|
|
67
|
+
if (!s) return "";
|
|
68
|
+
return LEGACY_HANDLER_ALIASES[s] || s;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 兼容旧调用名(understander 对外导出同义函数) */
|
|
72
|
+
export function normalizeHandlerName(name) {
|
|
73
|
+
return kindOf(name);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── P2(2026-09-10):个人层「措施声明」解析 + 校验(架构正本 §三 L2/L3)──
|
|
77
|
+
|
|
78
|
+
/** 规则正文内联声明:`<!-- handler: <kind> -->` 或 `<!-- handler: <kind> {json} -->`
|
|
79
|
+
* 设计(方案 A,用户 2026-09-10 拍板):**扩展既有 handler 声明**而非新增独立块——
|
|
80
|
+
* 向后兼容(旧声明零影响)、单点解析入口。正则捕获 `-->` 之前的**全部内容**,之后再拆分,
|
|
81
|
+
* 避免用嵌套正则直接匹配 JSON(`}` 截断风险)。 */
|
|
82
|
+
const HANDLER_DECL_RE = /<!--\s*handler\s*:\s*([\s\S]*?)-->/i;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 解析个人层措施声明。
|
|
86
|
+
* @param {string} body 规则正文
|
|
87
|
+
* @returns {null | {kind: string, params: object} | {error: string}}
|
|
88
|
+
* null = 无声明;{kind, params} = 解析成功;{error} = 声明非法(调用方须 fail-closed)
|
|
89
|
+
*/
|
|
90
|
+
export function parseHandlerDecl(body) {
|
|
91
|
+
const m = String(body || "").match(HANDLER_DECL_RE);
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
const raw = String(m[1] || "").trim();
|
|
94
|
+
if (!raw) return { error: "声明为空(`<!-- handler: -->`)" };
|
|
95
|
+
const km = /^([A-Za-z0-9][A-Za-z0-9-]*)\s*([\s\S]*)$/.exec(raw);
|
|
96
|
+
if (!km) return { error: `声明格式非法:${raw.slice(0, 60)}` };
|
|
97
|
+
const kind = kindOf(km[1]);
|
|
98
|
+
const rest = String(km[2] || "").trim();
|
|
99
|
+
let params = {};
|
|
100
|
+
if (rest) {
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(rest);
|
|
104
|
+
} catch {
|
|
105
|
+
return { error: `参数不是合法 JSON:${rest.slice(0, 60)}` };
|
|
106
|
+
}
|
|
107
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
108
|
+
return { error: `参数必须是 JSON 对象:${rest.slice(0, 60)}` };
|
|
109
|
+
}
|
|
110
|
+
params = parsed;
|
|
111
|
+
}
|
|
112
|
+
return { kind, params };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 校验措施声明(架构正本 §三 L3:**kind 未注册即拒绝并明确报错,fail-closed**)。
|
|
117
|
+
* @param {null | {kind: string} | {error: string}} decl parseHandlerDecl 的输出
|
|
118
|
+
* @returns {null | string} null = 通过(或无声明);字符串 = 明确错误原因
|
|
119
|
+
*/
|
|
120
|
+
export function validateMeasure(decl) {
|
|
121
|
+
if (!decl) return null;
|
|
122
|
+
if (decl.error) return decl.error;
|
|
123
|
+
if (!Object.prototype.hasOwnProperty.call(MEASURE_KINDS, decl.kind)) {
|
|
124
|
+
return `kind 未注册:${decl.kind}(已注册:${Object.keys(MEASURE_KINDS).join(" / ")})`;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
package/lib/core/patterns.js
CHANGED
|
@@ -303,6 +303,12 @@ const ALLOW_SEG_RE =
|
|
|
303
303
|
// 0.5.12(F4):纯字符串/纯输出段(整段是引号包裹字符串或 $() 子表达式)——不是命令,放行。
|
|
304
304
|
// 保守边界:只匹配"整段即字符串"(^...$ 锚定),不让 node -e "..." / curl -o "..." 借道。
|
|
305
305
|
const PURE_STRING_SEG_RE = /^(?:"[^"\r\n]*"|'[^'\r\n]*'|`[^`\r\n]*`|\$\(\s*[^()\r\n]*\s*\))+$/;
|
|
306
|
+
// F5(2026-09-10 修复,第三方实测):`Remove-Item Env:NAME` 的**操作对象是进程环境变量,不是文件**,
|
|
307
|
+
// 此前被 MUTATING_CMD_RE 的 Remove-Item 命中判写(ERR-JCSE3O 的类判定来源)。
|
|
308
|
+
// 边界(保守):仅当整段目标都是 Env:/$env: 形式时豁免;`Remove-Item <路径>`、混合目标、
|
|
309
|
+
// 选项在目标之前(如 `Remove-Item -Force Env:X`)一律不豁免——宁可误拦不可放行真删除。
|
|
310
|
+
const ENV_SCOPE_REMOVE_SEG_RE =
|
|
311
|
+
/^\s*Remove-Item\s+(?:\$env:|Env:)[\w()]+\s*(?:-[\w]+\s+(?:"[^"]*"|'[^']*'|[^\s;|]+)\s*)*$/i;
|
|
306
312
|
// 剥离 try/catch/finally 控制块:块内语句与主语句同样接受只读判定(Try-* 不是 cmdlet)。
|
|
307
313
|
// 注意:不做全量 [{}] 替换——if/else 子表达式($(if(...){...}else{...}))会被切碎误判。
|
|
308
314
|
function stripControlBlocks(command) {
|
|
@@ -319,12 +325,38 @@ export function isMutationCommand(command) {
|
|
|
319
325
|
return typeof command === "string" && MUTATING_CMD_RE.test(command);
|
|
320
326
|
}
|
|
321
327
|
|
|
322
|
-
/** 只读命令链按分隔符折段(机制 B,2026-08-24):`; 换行 && || |`(管道前后是命令链;`2>&1`/`2>$null` 的 stderr 重定向不拆分)
|
|
328
|
+
/** 只读命令链按分隔符折段(机制 B,2026-08-24):`; 换行 && || |`(管道前后是命令链;`2>&1`/`2>$null` 的 stderr 重定向不拆分)
|
|
329
|
+
* F5(2026-09-10 修复,第三方实测):**引号感知**——此前裸拆 `|` 会把**引号内的 `|`** 当分隔符
|
|
330
|
+
* (正则 alternation `-Pattern 'a|b'`、格式串 `"{0} | x"`、`-notmatch 'x|y'`)→ 切出无法识别的残段
|
|
331
|
+
* → L354 保守判非只读 → 整条只读命令被规则 22 按 write 拦(ERR-4WOAKG / 9T886X / Z8M7YC / IKXLU8)。
|
|
332
|
+
* 现:`'…'` / `"…"` / `` `…` `` 内的分隔符不拆。转义语义按 PS:`'` 内不转义(`''` 表一个单引号);
|
|
333
|
+
* `"` 与 `` ` `` 内 `\` / `` ` `` 转义下一字符(兼容跨语言写法)。 */
|
|
323
334
|
function splitCommandSegments(command) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
335
|
+
const s = String(command);
|
|
336
|
+
const out = [];
|
|
337
|
+
let buf = "";
|
|
338
|
+
let quote = null;
|
|
339
|
+
for (let i = 0; i < s.length; i++) {
|
|
340
|
+
const c = s[i];
|
|
341
|
+
if (quote) {
|
|
342
|
+
buf += c;
|
|
343
|
+
if (quote !== "'" && (c === "\\" || c === "`")) { buf += s[i + 1] ?? ""; i++; continue; }
|
|
344
|
+
if (c === quote) quote = null;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (c === "'" || c === '"' || c === "`") { quote = c; buf += c; continue; }
|
|
348
|
+
if (c === "\r") continue;
|
|
349
|
+
if (c === "\n" || c === ";") { out.push(buf); buf = ""; continue; }
|
|
350
|
+
if (c === "&" && s[i + 1] === "&") { out.push(buf); buf = ""; i++; continue; }
|
|
351
|
+
if (c === "|") {
|
|
352
|
+
if (s[i + 1] === "|") { out.push(buf); buf = ""; i++; continue; }
|
|
353
|
+
if (i > 0 && /\d/.test(s[i - 1])) { buf += c; continue; } // 保留原 (?<!\d) 语义
|
|
354
|
+
out.push(buf); buf = ""; continue;
|
|
355
|
+
}
|
|
356
|
+
buf += c;
|
|
357
|
+
}
|
|
358
|
+
out.push(buf);
|
|
359
|
+
return out.map((x) => x.trim()).filter(Boolean);
|
|
328
360
|
}
|
|
329
361
|
|
|
330
362
|
/** 判断命令文本是否只读(读文件/查询类,无写入/删除/提交副作用)。
|
|
@@ -345,6 +377,7 @@ export function isReadOnlyCommand(command) {
|
|
|
345
377
|
const s = String(seg).trim();
|
|
346
378
|
if (!s) return true; // 剥离残留的空段放行
|
|
347
379
|
if (s.startsWith("#")) return true;
|
|
380
|
+
if (ENV_SCOPE_REMOVE_SEG_RE.test(s)) return true; // F5:环境变量操作非文件删除(须在 MUTATING 之前)
|
|
348
381
|
if (MUTATING_CMD_RE.test(s)) return false;
|
|
349
382
|
if (MUTATING_METHOD_RE.test(s)) return false;
|
|
350
383
|
if (COM_CONTENT_ASSIGN_RE.test(s)) return false; // $doc.Text=... 是写文档
|
package/lib/core/state.js
CHANGED
|
@@ -156,6 +156,12 @@ export function getSessionState(state, sessionId) {
|
|
|
156
156
|
selfCertCount: new Map(),
|
|
157
157
|
authorizations: [],
|
|
158
158
|
backups: [],
|
|
159
|
+
// D2 修复(2026-09-10,独立 DSH_HOME 试点反馈):待决 ask 提升为**会话级**。
|
|
160
|
+
// 此前放在 turn 级:用户先发新消息 → turn/start → resetTurn 整体替换 s.turn
|
|
161
|
+
// → pendingAsk 被刷成 null → 随后到达的 ask 答复走不进登记分支,
|
|
162
|
+
// 实测「用户消息与 ask 答复并发」时一次 ask 的授权**全部未登记**(ERR-M9Z47H)。
|
|
163
|
+
// 语义依据:pendingAsk 描述的是「一次工具调用的待决状态」,跨回合有效,不属回合文本状态。
|
|
164
|
+
pendingAsk: null,
|
|
159
165
|
mountAuditRevision: 0,
|
|
160
166
|
mountAuditSignature: "",
|
|
161
167
|
lastCommandOutput: "",
|
|
@@ -252,7 +258,8 @@ export function freshTurn() {
|
|
|
252
258
|
intents: null,
|
|
253
259
|
intentState: "lexicon", // lexicon | llm-pending | llm-ready(LLM 意图兜底状态)
|
|
254
260
|
llmIntent: null, // { raw, verdict, reason, confidence, at }(LLM 裁决,非高置信时词表兜底)
|
|
255
|
-
pendingAsk
|
|
261
|
+
// pendingAsk 已迁至**会话级**(见 getSessionState)——D2 修复,2026-09-10。
|
|
262
|
+
// 此处不再定义回合级 pendingAsk:避免两处同名状态互相遮蔽。
|
|
256
263
|
pendingToolCalls: new Map(),
|
|
257
264
|
getDateSeen: false,
|
|
258
265
|
backupSeen: false,
|
package/lib/core/tool-catalog.js
CHANGED
|
@@ -36,7 +36,10 @@ const ANALYSIS_TOOLS = new Set([
|
|
|
36
36
|
"cordis_inspect_list", "cordis_inspect_query", "cordis_inspect_self",
|
|
37
37
|
"ask_user_question",
|
|
38
38
|
// ESR 状态查询(读工作区记忆状态,无持久副作用)
|
|
39
|
-
"esr_status", "esr_ready", "esr_model"
|
|
39
|
+
"esr_status", "esr_ready", "esr_model",
|
|
40
|
+
// univer-office 只读族(2026-09-09 补录:规则 24④ 实测 univer_execute 被硬拦——该族未分类;
|
|
41
|
+
// 逐工具分类而非前缀一刀切:只读/查询类 → analysis)
|
|
42
|
+
"univer_status", "univer_inspect", "univer_lint", "univer_api", "univer_resources"
|
|
40
43
|
]);
|
|
41
44
|
|
|
42
45
|
// 产物类:工具自然行为会写文件(artifact/工作区/会话存储),低风险——放行 + 审计。
|
|
@@ -59,7 +62,9 @@ const ARTIFACT_TOOLS = new Set([
|
|
|
59
62
|
// (12A isSensitiveToolCall / 13A / 24 装配判定)链上覆盖,不再各自归类。
|
|
60
63
|
"dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote",
|
|
61
64
|
// ESR 工程状态写操作(写工作区记忆存储,engram_store 同族)
|
|
62
|
-
"esr_task", "esr_node", "esr_link", "esr_claim", "esr_close", "esr_unclaim", "esr_dep", "esr_gc"
|
|
65
|
+
"esr_task", "esr_node", "esr_link", "esr_claim", "esr_close", "esr_unclaim", "esr_dep", "esr_gc",
|
|
66
|
+
// univer-office 产物族(2026-09-09 补录):截图写 artifact;univer_new 只新建不覆盖
|
|
67
|
+
"univer_screenshot", "univer_new"
|
|
63
68
|
]);
|
|
64
69
|
|
|
65
70
|
// 变更类:维持现有严格授权(规则 22 粒度比对 / 12A 敏感授权 / 13A 备份)
|
|
@@ -77,7 +82,10 @@ const MUTATING_TOOLS = new Set([
|
|
|
77
82
|
"followup_task", "spawn_teammate", "team_task_create", "team_task_get",
|
|
78
83
|
"team_task_list", "team_task_update", "wait_agent",
|
|
79
84
|
// 本机命名空间兜底(dev_ 前缀 = 开发侧工具,行为可写可管理;具体已枚举,前缀兜底未来新增)
|
|
80
|
-
"dev_router_status"
|
|
85
|
+
"dev_router_status",
|
|
86
|
+
// univer-office 变更族(2026-09-09 补录,规则 24④:未分类 + 疑似写 → 硬拦;逐工具分类纳入守卫链)
|
|
87
|
+
"univer_execute", "univer_import", "univer_unit", "univer_worktree",
|
|
88
|
+
"univer_compile_svg", "univer_export", "univer_print_pdf"
|
|
81
89
|
]);
|
|
82
90
|
|
|
83
91
|
// 前缀规则:生态命名空间的工具自动归类(0.5.9 新)——将来的新插件遵循惯例即被覆盖。
|
package/lib/core/understander.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
URL_RE,
|
|
14
14
|
sourceMarkRe
|
|
15
15
|
} from "./patterns.js";
|
|
16
|
+
import { normalizeHandlerName, parseHandlerDecl, validateMeasure } from "./measure-kinds.js";
|
|
16
17
|
|
|
17
18
|
// 2026-08-31(残余1 剥离,用户定稿):本机默认偏好表从代码下沉到本机配置——
|
|
18
19
|
// rule-engine.json 的 handlerDefaultMap(可选键,可整体替换/清空)。代码层默认表为空
|
|
@@ -87,40 +88,21 @@ function hintPatterns(checkText) {
|
|
|
87
88
|
return [...new Set(hints)];
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
/** 规则正文内联 handler 声明(T3):`<!-- handler: rule22-7-direct -->` */
|
|
91
|
-
const HANDLER_DECL_RE = /<!--\s*handler\s*:\s*([a-z0-9][a-z0-9-]*)\s*-->/i;
|
|
92
|
-
|
|
93
91
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
92
|
+
* 措施类型归一 + 声明解析(架构 v3 P1/P2,2026-09-10):
|
|
93
|
+
* 注册表(MEASURE_KINDS)+ 兼容别名表(LEGACY_HANDLER_ALIASES)+ 归一入口(kindOf)
|
|
94
|
+
* + 声明解析(parseHandlerDecl)+ 校验(validateMeasure)均在 ./measure-kinds.js
|
|
95
|
+
* —— 本文件不含任何规则号字面量。
|
|
96
|
+
* 归一语义:历史内部名 → kind;kind 名 → 原样(幂等);未知名 → 原样(覆盖自省提示未覆盖)。
|
|
98
97
|
*/
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"skill-auth": "rule12b-skill",
|
|
108
|
-
network: "rule12c-network",
|
|
109
|
-
backup: "rule13a-backup",
|
|
110
|
-
"manual-first": "rule18-manual-first",
|
|
111
|
-
meta: "rule21-meta",
|
|
112
|
-
"intent-direct": "rule22-7-direct",
|
|
113
|
-
"runtime-verify": "rule23-runtime-verify",
|
|
114
|
-
assembly: "rule24-assembly-type",
|
|
115
|
-
"release-asset": "rule26-release-asset",
|
|
116
|
-
"mount-audit": "rule27-mount-audit"
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
/** 语义名归一(内部名原样返回;未知名保持原样——由覆盖自省提示未覆盖) */
|
|
120
|
-
export function normalizeHandlerName(name) {
|
|
121
|
-
const s = String(name || "");
|
|
122
|
-
return HANDLER_ALIASES[s] || s;
|
|
123
|
-
}
|
|
98
|
+
export {
|
|
99
|
+
normalizeHandlerName,
|
|
100
|
+
kindOf,
|
|
101
|
+
MEASURE_KINDS,
|
|
102
|
+
LEGACY_HANDLER_ALIASES,
|
|
103
|
+
parseHandlerDecl,
|
|
104
|
+
validateMeasure
|
|
105
|
+
} from "./measure-kinds.js";
|
|
124
106
|
|
|
125
107
|
/**
|
|
126
108
|
* handler 绑定解析(T3 通用化,2026-08-31)——优先级:
|
|
@@ -135,8 +117,10 @@ export function resolveHandler(rule, opts = {}) {
|
|
|
135
117
|
const overrides = opts?.handlerOverrides;
|
|
136
118
|
if (overrides && typeof overrides === "object" && overrides[id] !== undefined) return normalizeHandlerName(overrides[id]);
|
|
137
119
|
const body = String(rule?.body || "");
|
|
138
|
-
const decl = body
|
|
139
|
-
|
|
120
|
+
const decl = parseHandlerDecl(body);
|
|
121
|
+
// P2(架构正本 §三 L3):声明非法(JSON 坏 / 空声明)**或 kind 未注册** → fail-closed(不执行);
|
|
122
|
+
// 错误详情由 understandRule 写入 config.measureError(此处只保证不产出可执行的 handler)。
|
|
123
|
+
if (decl) return validateMeasure(decl) ? "" : normalizeHandlerName(decl.kind);
|
|
140
124
|
// 剥离后(2026-08-31):代码层无默认表;defaultMap 由调用方注入(本机=配置 handlerDefaultMap)
|
|
141
125
|
const map = opts?.defaultMap || {};
|
|
142
126
|
return normalizeHandlerName(map[rule?.index] || "");
|
|
@@ -156,6 +140,9 @@ export function understandRule(rule, opts = {}) {
|
|
|
156
140
|
const triggerKeywords = splitKeywords(elems.trigger);
|
|
157
141
|
const confidence = level && elems.trigger && elems.check && (elems.action || level.toUpperCase().includes("D")) ? "high" : level ? "medium" : "low";
|
|
158
142
|
const handler = resolveHandler(rule, opts);
|
|
143
|
+
// P2(2026-09-10):个人层措施声明 → 措施实例 {kind, params};kind 未注册 / 声明非法 → fail-closed
|
|
144
|
+
const decl = parseHandlerDecl(rule.body || "");
|
|
145
|
+
const measureError = validateMeasure(decl);
|
|
159
146
|
const config = {
|
|
160
147
|
ruleId: rule.index,
|
|
161
148
|
title: rule.title,
|
|
@@ -164,6 +151,8 @@ export function understandRule(rule, opts = {}) {
|
|
|
164
151
|
actions,
|
|
165
152
|
confidence,
|
|
166
153
|
handler,
|
|
154
|
+
measure: decl && !measureError ? decl : null,
|
|
155
|
+
measureError: measureError || "",
|
|
167
156
|
triggerKeywords,
|
|
168
157
|
hints,
|
|
169
158
|
elements: elems,
|
package/lib/index.js
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
askResultSelectedText,
|
|
40
40
|
classifyAskScopeType,
|
|
41
41
|
inferPathPrefixFromText,
|
|
42
|
+
inferPathPrefixesFromText,
|
|
42
43
|
inferTypeFromText,
|
|
43
44
|
isAuthMessage,
|
|
44
45
|
isDirectiveMessage,
|
|
@@ -710,7 +711,8 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
710
711
|
if (s.turn.toolCount === 1) s.turn.firstToolName = toolName;
|
|
711
712
|
if (toolName === "ask_user_question") {
|
|
712
713
|
s.turn.askSeen = true;
|
|
713
|
-
|
|
714
|
+
// D2 修复(2026-09-10):写**会话级**(不随 turn/start 清空);at 供陈旧值保护
|
|
715
|
+
s.pendingAsk = { callId: d.callId, questions: args?.questions || [], at: Date.now() };
|
|
714
716
|
}
|
|
715
717
|
const pendingCall = { name: toolName, args };
|
|
716
718
|
const targetPath = args?.file_path ?? args?.path;
|
|
@@ -1013,8 +1015,9 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
1013
1015
|
if (output) s.lastCommandOutput = output;
|
|
1014
1016
|
}
|
|
1015
1017
|
|
|
1016
|
-
const pending = s.
|
|
1017
|
-
|
|
1018
|
+
const pending = s.pendingAsk;
|
|
1019
|
+
// D2 修复(2026-09-10):会话级待决 ask + 陈旧值保护(30 分钟未答视为作废,防跨回合误登记)
|
|
1020
|
+
if (pending && (!pending.at || Date.now() - pending.at <= 30 * 60 * 1000)) {
|
|
1018
1021
|
const result = d.result ?? d.value ?? d;
|
|
1019
1022
|
if (askResultApproved(result)) {
|
|
1020
1023
|
s.turn.realUserSeen = true; // v0.5.7:ask 弹窗答复也算"真实用户在场"
|
|
@@ -1024,7 +1027,12 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
1024
1027
|
// 授权范围只取“问题核心 + 用户实际选择”,不再把全部选项描述纳入;
|
|
1025
1028
|
// 避免“仅会话内说明/不跨会话保留”等选项说明把一次小授权放大成全局 12h(2026-08-24 修复)
|
|
1026
1029
|
const scopeText = `${askQuestionCoreText(pending.questions)} ${selectedText}`.trim();
|
|
1027
|
-
|
|
1030
|
+
// A2 修复(2026-09-10):原用单数版 inferPathPrefixFromText——只记一个路径且取"最长"。
|
|
1031
|
+
// 探针实测:多路径问题文本下"最长"往往是次要路径(如"去向 .backups/trash-x")而非主路径,
|
|
1032
|
+
// 主操作路径丢失 → 授权匹配必然失败。改为复数版:pathPrefix 取首个(按文本出现顺序=主路径,
|
|
1033
|
+
// 与用户消息侧 scopesFromIntents 的复数口径对齐),全部路径记入 pathPrefixes 供 authMatches 任一匹配。
|
|
1034
|
+
const pathPrefixes = inferPathPrefixesFromText(scopeText || qText);
|
|
1035
|
+
const pathPrefix = pathPrefixes[0] || "";
|
|
1028
1036
|
// 0.5.10 建议1①:ask 答复结构化——操作类型从答复文本推断(write/command/any),不再一律 any
|
|
1029
1037
|
// (修复"弹窗答复接不住真想操作"的粒度问题——V476AL 同族)
|
|
1030
1038
|
const authRecord = {
|
|
@@ -1032,6 +1040,7 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
1032
1040
|
pathPrefix,
|
|
1033
1041
|
source: "ask"
|
|
1034
1042
|
};
|
|
1043
|
+
if (pathPrefixes.length > 1) authRecord.pathPrefixes = pathPrefixes;
|
|
1035
1044
|
const sessionWide = isSessionWideAskText(scopeText) || isSessionWideAskText(selectedText);
|
|
1036
1045
|
if (sessionWide) authRecord.expiresAt = Date.now() + 12 * 60 * 60 * 1000;
|
|
1037
1046
|
else if (!pathPrefix) authRecord.expiresAt = Date.now() + 2 * 60 * 1000;
|
|
@@ -1098,7 +1107,7 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
1098
1107
|
session: sid
|
|
1099
1108
|
});
|
|
1100
1109
|
}
|
|
1101
|
-
s.
|
|
1110
|
+
s.pendingAsk = null; // D2 修复(2026-09-10):清理**会话级**待决 ask
|
|
1102
1111
|
}
|
|
1103
1112
|
return;
|
|
1104
1113
|
}
|
package/package.json
CHANGED
|
@@ -11,11 +11,20 @@
|
|
|
11
11
|
// —— 通用中文文案(字符串字面量内的 CJK)**不计入**:第三方判据明示「中文≠个人化,
|
|
12
12
|
// 通用功能词/提示语是产品能力」。runtime 计数仍在 --report 里显示,供参考。
|
|
13
13
|
//
|
|
14
|
+
// 扫描判据 D(2026-09-10 分层架构 v3 · P0 追加):规则号字面量
|
|
15
|
+
// 依据:reports/2026-09-10-引擎分层架构-通用层零规则内容-v1.md §七。
|
|
16
|
+
// 模式:rule\d+[A-Za-z]?- / byId\.get\("\d+"\) / \[guardian:rule\d+\] / rule-hint\.\d+
|
|
17
|
+
// / ruleId\s*[=!]==?\s*"<N>"(2026-09-10 用户拍板纳入;§七 原列四条,此条据 §二 现状证据补入)
|
|
18
|
+
// 为何单列一判据:「机制以规则号为骨架」比「文案含编号」更根本,且与判据 A 不同维度
|
|
19
|
+
// (A 抓本机私有词/映射键;D 抓通用层住着规则体系),故各自独立棘轮。
|
|
20
|
+
// 口径:去注释后扫描(注释属开发溯源,非机制骨架);不设白名单——代码层零规则号。
|
|
21
|
+
//
|
|
14
22
|
// 棘轮(ratchet):基线记录各文件计数,只许降不许升。
|
|
15
23
|
// node scripts/dualtrack-check.mjs # 比对基线(CI/发布门禁用)
|
|
16
24
|
// node scripts/dualtrack-check.mjs --init # 首次生成基线
|
|
17
25
|
// node scripts/dualtrack-check.mjs --update # 手动更新基线(须在提交说明里写清改了什么)
|
|
18
26
|
// node scripts/dualtrack-check.mjs --report # 只打印各文件计数
|
|
27
|
+
// node scripts/dualtrack-check.mjs --root <dir> # 改扫描根(仅测试隔离用;非默认根会显著提示)
|
|
19
28
|
//
|
|
20
29
|
// 白名单(scripts/dualtrack-whitelist.json):
|
|
21
30
|
// files —— 整文件豁免(如 lib/lang/**,第 3 批语言包)
|
|
@@ -26,7 +35,14 @@ import path from "node:path";
|
|
|
26
35
|
import { fileURLToPath } from "node:url";
|
|
27
36
|
import { loadMarkers } from "../lib/core/dualtrack-markers.js";
|
|
28
37
|
|
|
29
|
-
|
|
38
|
+
// ── 命令行参数(提前解析:--root 必须在下面的路径常量之前生效)──
|
|
39
|
+
const ARGV = process.argv.slice(2);
|
|
40
|
+
const args = new Set(ARGV);
|
|
41
|
+
const rootIdx = ARGV.indexOf("--root");
|
|
42
|
+
const IS_CUSTOM_ROOT = rootIdx >= 0;
|
|
43
|
+
const ROOT = IS_CUSTOM_ROOT && ARGV[rootIdx + 1]
|
|
44
|
+
? path.resolve(ARGV[rootIdx + 1])
|
|
45
|
+
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
46
|
const LIB = path.join(ROOT, "lib");
|
|
31
47
|
const BASELINE_FILE = path.join(ROOT, "scripts", "dualtrack-baseline.json");
|
|
32
48
|
const WHITELIST_FILE = path.join(ROOT, "scripts", "dualtrack-whitelist.json");
|
|
@@ -35,6 +51,15 @@ const RESIDUE_FILE = path.join(ROOT, "scripts", "local-residue-markers.txt");
|
|
|
35
51
|
const CJK = /[\u4e00-\u9fff]/;
|
|
36
52
|
const MAP_KEY_RE = /^\d+[A-Z]?$/;
|
|
37
53
|
|
|
54
|
+
// ── 判据 D:规则号字面量(§七;口径=去注释后扫描,无白名单)──
|
|
55
|
+
const RULE_LITERAL_PATTERNS = [
|
|
56
|
+
["executor-name", /rule\d+[A-Za-z]?-/g, "内部执行器名 rule<N>[x]-"],
|
|
57
|
+
["byid-get", /byId\.get\(\s*["'`]\d+["'`]\s*\)/g, '按规则号取规则 byId.get("<N>")'],
|
|
58
|
+
["guardian-tag", /\[guardian:rule\d+\]/g, "对外文案 [guardian:rule<N>]"],
|
|
59
|
+
["rule-hint", /rule-hint\.\d+/g, "rule-hint.<N>"],
|
|
60
|
+
["ruleid-eq", /ruleId\s*[=!]==?\s*["'`]\d+[A-Za-z]?["'`]/g, '硬编码规则号比较 ruleId === "<N>"']
|
|
61
|
+
];
|
|
62
|
+
|
|
38
63
|
// ── 白名单 ──
|
|
39
64
|
/** 白名单两层(2026-09-09):包内通用白名单 + 本机 rule-engine.json 的 dualtrack.whitelist 合并。
|
|
40
65
|
* 包内那份随包发布(如 lib/lang/**);本机那份记录「我豁免我自己的某条」,不进包。 */
|
|
@@ -77,6 +102,73 @@ function fileExempt(relPath, patterns) {
|
|
|
77
102
|
return false;
|
|
78
103
|
}
|
|
79
104
|
|
|
105
|
+
// ── 去注释(判据 D 用):保留字符串/模板/正则原文,注释替换为空白(保持行结构) ──
|
|
106
|
+
function stripComments(src) {
|
|
107
|
+
let out = "";
|
|
108
|
+
let i = 0;
|
|
109
|
+
const n = src.length;
|
|
110
|
+
let prevSig = "";
|
|
111
|
+
const push = (s) => { out += s; };
|
|
112
|
+
while (i < n) {
|
|
113
|
+
const c = src[i];
|
|
114
|
+
const c2 = src[i + 1];
|
|
115
|
+
if (c === "/" && c2 === "/") {
|
|
116
|
+
while (i < n && src[i] !== "\n") { push(" "); i++; }
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (c === "/" && c2 === "*") {
|
|
120
|
+
push(" "); i += 2;
|
|
121
|
+
while (i < n && !(src[i] === "*" && src[i + 1] === "/")) {
|
|
122
|
+
push(src[i] === "\n" ? "\n" : " ");
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
if (i < n) { push(" "); i += 2; }
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
129
|
+
const q = c;
|
|
130
|
+
push(c); i++;
|
|
131
|
+
while (i < n) {
|
|
132
|
+
if (src[i] === "\\") { push(src[i] + (src[i + 1] || "")); i += 2; continue; }
|
|
133
|
+
push(src[i]);
|
|
134
|
+
if (src[i] === q) { i++; break; }
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
prevSig = "str";
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
// 正则字面量:仅在可能的正则位置整体复制(否则 / 是除号)
|
|
141
|
+
if (c === "/" && prevSig !== "ident" && prevSig !== ")" && prevSig !== "]" && prevSig !== "str") {
|
|
142
|
+
let j = i + 1;
|
|
143
|
+
let inClass = false;
|
|
144
|
+
let closed = false;
|
|
145
|
+
while (j < n) {
|
|
146
|
+
const ch = src[j];
|
|
147
|
+
if (ch === "\\") { j += 2; continue; }
|
|
148
|
+
if (ch === "\n") break;
|
|
149
|
+
if (ch === "[") inClass = true;
|
|
150
|
+
else if (ch === "]") inClass = false;
|
|
151
|
+
else if (ch === "/" && !inClass) { closed = true; break; }
|
|
152
|
+
j++;
|
|
153
|
+
}
|
|
154
|
+
if (closed) {
|
|
155
|
+
push(src.slice(i, j + 1));
|
|
156
|
+
i = j + 1;
|
|
157
|
+
while (i < n && /[a-z]/i.test(src[i])) { push(src[i]); i++; }
|
|
158
|
+
prevSig = "regex";
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
push(c);
|
|
163
|
+
if (/[A-Za-z0-9_$]/.test(c)) prevSig = "ident";
|
|
164
|
+
else if (c === ")") prevSig = ")";
|
|
165
|
+
else if (c === "]") prevSig = "]";
|
|
166
|
+
else if (!/\s/.test(c)) prevSig = "other";
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
80
172
|
// ── 词法扫描:剔除注释,收集字符串字面量与对象键 ──
|
|
81
173
|
/**
|
|
82
174
|
* @returns {{strings: string[], mapKeys: string[]}}
|
|
@@ -203,6 +295,16 @@ function tokenize(src) {
|
|
|
203
295
|
function scanFile(absPath, relPath, whitelist, residueMarks) {
|
|
204
296
|
const src = fs.readFileSync(absPath, "utf8");
|
|
205
297
|
const { strings, mapKeys } = tokenize(src);
|
|
298
|
+
// 判据 D:去注释后扫规则号字面量
|
|
299
|
+
const code = stripComments(src);
|
|
300
|
+
let ruleLiterals = 0;
|
|
301
|
+
const ruleLiteralHits = [];
|
|
302
|
+
for (const [id, re, desc] of RULE_LITERAL_PATTERNS) {
|
|
303
|
+
const m = code.match(re);
|
|
304
|
+
if (!m) continue;
|
|
305
|
+
ruleLiterals += m.length;
|
|
306
|
+
if (ruleLiteralHits.length < 3) ruleLiteralHits.push(`${id}×${m.length}(${desc})如 ${m[0]}`);
|
|
307
|
+
}
|
|
206
308
|
let runtime = 0;
|
|
207
309
|
const runtimeHits = [];
|
|
208
310
|
for (const s of strings) {
|
|
@@ -228,11 +330,13 @@ function scanFile(absPath, relPath, whitelist, residueMarks) {
|
|
|
228
330
|
runtime,
|
|
229
331
|
mapKeys: mapHits.length,
|
|
230
332
|
local,
|
|
333
|
+
ruleLiterals,
|
|
231
334
|
// 判据 A:total 只计「本机性」两类;通用中文文案(runtime)不计入闸
|
|
232
335
|
total: mapHits.length + local,
|
|
233
336
|
runtimeHits,
|
|
234
337
|
mapHits: mapHits.slice(0, 5),
|
|
235
|
-
localHits
|
|
338
|
+
localHits,
|
|
339
|
+
ruleLiteralHits
|
|
236
340
|
};
|
|
237
341
|
}
|
|
238
342
|
|
|
@@ -247,7 +351,7 @@ function walk(dir, base = dir, out = []) {
|
|
|
247
351
|
}
|
|
248
352
|
|
|
249
353
|
// ── 主流程 ──
|
|
250
|
-
|
|
354
|
+
if (IS_CUSTOM_ROOT) console.log(`SCAN ROOT(非默认,仅测试隔离用):${ROOT}\n`);
|
|
251
355
|
const whitelist = loadWhitelist();
|
|
252
356
|
// 本机标识词表:与 B2 扫描器共用唯一加载器(本机配置优先 → 环境变量 → 包内示例)
|
|
253
357
|
const { markers: residueMarks, source: markersSource } = loadMarkers({ root: ROOT });
|
|
@@ -259,16 +363,22 @@ if (residueMarks.length === 0) {
|
|
|
259
363
|
const files = walk(LIB).filter((rel) => !fileExempt(rel, whitelist.files));
|
|
260
364
|
const results = files.map((rel) => scanFile(path.join(LIB, rel), rel, whitelist, residueMarks));
|
|
261
365
|
const counts = {};
|
|
262
|
-
|
|
366
|
+
const ruleCounts = {};
|
|
367
|
+
for (const r of results) {
|
|
368
|
+
counts[`lib/${r.relPath}`] = r.total;
|
|
369
|
+
ruleCounts[`lib/${r.relPath}`] = r.ruleLiterals;
|
|
370
|
+
}
|
|
263
371
|
const grandTotal = results.reduce((a, r) => a + r.total, 0);
|
|
372
|
+
const ruleGrandTotal = results.reduce((a, r) => a + r.ruleLiterals, 0);
|
|
264
373
|
|
|
265
374
|
if (args.has("--report")) {
|
|
266
|
-
for (const r of results.sort((a, b) => b.total - a.total)) {
|
|
267
|
-
if (r.total === 0) continue;
|
|
268
|
-
console.log(` ${String(r.total).padStart(4)} lib/${r.relPath} [映射键 ${r.mapKeys} / 本机标识 ${r.local} / 通用中文文案 ${r.runtime}(不计入闸)]`);
|
|
375
|
+
for (const r of results.sort((a, b) => (b.total + b.ruleLiterals) - (a.total + a.ruleLiterals))) {
|
|
376
|
+
if (r.total === 0 && r.ruleLiterals === 0) continue;
|
|
377
|
+
console.log(` ${String(r.total).padStart(4)} lib/${r.relPath} [映射键 ${r.mapKeys} / 本机标识 ${r.local} / 通用中文文案 ${r.runtime}(不计入闸)] [规则号字面量 ${r.ruleLiterals}]`);
|
|
269
378
|
if (r.mapHits.length) console.log(` 命中键:${r.mapHits.join(" / ")}`);
|
|
379
|
+
if (r.ruleLiteralHits.length) console.log(` 规则号字面量:${r.ruleLiteralHits.join(";")}`);
|
|
270
380
|
}
|
|
271
|
-
console.log(`\nDUALTRACK REPORT:${results.length}
|
|
381
|
+
console.log(`\nDUALTRACK REPORT:${results.length} 文件,判据 A(分层残留)合计 ${grandTotal},判据 D(规则号字面量)合计 ${ruleGrandTotal}`);
|
|
272
382
|
process.exit(0);
|
|
273
383
|
}
|
|
274
384
|
|
|
@@ -287,13 +397,15 @@ if (args.has("--init") || args.has("--update")) {
|
|
|
287
397
|
}
|
|
288
398
|
const payload = {
|
|
289
399
|
generatedAt: new Date().toISOString(),
|
|
290
|
-
note: "dualtrack
|
|
400
|
+
note: "dualtrack 棘轮基线:各文件计数,只许降不许升。files/total=判据 A(分层残留);ruleLiteralFiles/ruleLiteralsTotal=判据 D(规则号字面量)。--init 首次生成,--update 手动更新(须在提交说明里写清改了什么)。",
|
|
291
401
|
total: grandTotal,
|
|
292
|
-
files: counts
|
|
402
|
+
files: counts,
|
|
403
|
+
ruleLiteralsTotal: ruleGrandTotal,
|
|
404
|
+
ruleLiteralFiles: ruleCounts
|
|
293
405
|
};
|
|
294
406
|
fs.writeFileSync(BASELINE_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
295
407
|
console.log(`${isUpdate ? "UPDATED" : "INITIALIZED"} ${BASELINE_FILE}`);
|
|
296
|
-
console.log(
|
|
408
|
+
console.log(`基线:判据 A 合计 ${grandTotal}(${Object.keys(counts).length} 文件)/判据 D 合计 ${ruleGrandTotal}`);
|
|
297
409
|
process.exit(0);
|
|
298
410
|
}
|
|
299
411
|
|
|
@@ -324,12 +436,46 @@ for (const [file, b] of Object.entries(base)) {
|
|
|
324
436
|
if (counts[file] === undefined && b > 0) improvements.push(`${file}: 文件已删除(基线 ${b})`);
|
|
325
437
|
}
|
|
326
438
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
439
|
+
// 判据 D 比对(独立棘轮;基线缺该维度 → fail-closed,提示 --update)
|
|
440
|
+
const ruleBase = baseline.ruleLiteralFiles;
|
|
441
|
+
const ruleRegressions = [];
|
|
442
|
+
const ruleImprovements = [];
|
|
443
|
+
if (!ruleBase || typeof ruleBase !== "object") {
|
|
444
|
+
ruleRegressions.push("基线缺「规则号字面量」维度(ruleLiteralFiles)——判据 D 于 2026-09-10 新增,请运行 --update 记录基线");
|
|
445
|
+
} else {
|
|
446
|
+
for (const [file, count] of Object.entries(ruleCounts)) {
|
|
447
|
+
const b = ruleBase[file];
|
|
448
|
+
if (b === undefined) {
|
|
449
|
+
if (count > 0) ruleRegressions.push(`${file}: 新增文件含规则号字面量 ${count} 处(基线无此文件)`);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (count > b) {
|
|
453
|
+
const r = results.find((x) => `lib/${x.relPath}` === file);
|
|
454
|
+
const detail = r ? r.ruleLiteralHits.slice(0, 2).join(";") : "";
|
|
455
|
+
ruleRegressions.push(`${file}: ${count} > 基线 ${b}${detail ? `(如:${detail})` : ""}`);
|
|
456
|
+
} else if (count < b) {
|
|
457
|
+
ruleImprovements.push(`${file}: ${count} < 基线 ${b}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (const [file, b] of Object.entries(ruleBase)) {
|
|
461
|
+
if (ruleCounts[file] === undefined && b > 0) ruleImprovements.push(`${file}: 文件已删除(基线 ${b})`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (regressions.length > 0 || ruleRegressions.length > 0) {
|
|
466
|
+
if (regressions.length > 0) {
|
|
467
|
+
console.error(`DUALTRACK FAIL(判据 A 分层残留):${regressions.length} 项超出基线(只许降不许升)`);
|
|
468
|
+
for (const r of regressions) console.error(` ✗ ${r}`);
|
|
469
|
+
}
|
|
470
|
+
if (ruleRegressions.length > 0) {
|
|
471
|
+
console.error(`DUALTRACK FAIL(判据 D 规则号字面量):${ruleRegressions.length} 项超出基线(只许降不许升)`);
|
|
472
|
+
for (const r of ruleRegressions) console.error(` ✗ ${r}`);
|
|
473
|
+
}
|
|
474
|
+
console.error("\n 处置:分层残留 → 迁个人层/配置层,或(确属通用功能词)登记 scripts/dualtrack-whitelist.json");
|
|
475
|
+
console.error(" 规则号字面量 → 改为通用 kind 名(架构 v3 §三 L1);判据 D 不设白名单。");
|
|
331
476
|
process.exit(1);
|
|
332
477
|
}
|
|
333
|
-
|
|
334
|
-
|
|
478
|
+
const downCount = improvements.length + ruleImprovements.length;
|
|
479
|
+
console.log(`DUALTRACK OK:判据 A 合计 ${grandTotal}(基线 ${baseline.total ?? "?"})/判据 D 合计 ${ruleGrandTotal}(基线 ${baseline.ruleLiteralsTotal ?? "?"})${downCount ? `,${downCount} 个文件下降可 --update` : ""}`);
|
|
480
|
+
for (const im of [...improvements, ...ruleImprovements].slice(0, 5)) console.log(` ↓ ${im}`);
|
|
335
481
|
process.exit(0);
|
|
@@ -64,8 +64,11 @@ process.env.DSH_WORKSPACE = root;
|
|
|
64
64
|
const plain = understandRule(arbitrary);
|
|
65
65
|
check(plain.handler === "", "③ 任意编号 + 无声明:handler 为空(纯自证分流,不误绑定本机执行器)");
|
|
66
66
|
check(plain.actions.includes("deny") && plain.actions.includes("self-certify"), "③ 任意编号:A+D 动作解析正确(deny+self-certify)");
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// 架构 v3 P1:声明值统一归一为 kind 名——kind 名(陌生用户视角)与历史内部名(兼容)都须绑定
|
|
68
|
+
const declaredKind = understandRule({ ...arbitrary, body: arbitrary.body + "\n<!-- handler: approval -->" });
|
|
69
|
+
check(declaredKind.handler === "approval", "③ 任意编号 + 声明(kind 名):绑定指定执行器");
|
|
70
|
+
const declaredLegacy = understandRule({ ...arbitrary, body: arbitrary.body + "\n<!-- handler: rule12a-approval -->" });
|
|
71
|
+
check(declaredLegacy.handler === "approval", "③ 任意编号 + 声明(历史内部名):归一为同一 kind");
|
|
69
72
|
const covered = analyzeCoverage(understandAll([arbitrary]));
|
|
70
73
|
check(covered.uncovered.some((u) => u.ruleId === "X-77"), "③ 任意编号 A 级无 handler:被标记未覆盖(提示正确)");
|
|
71
74
|
// 通用化闭环:defaultMap 清空 → 陌生规则无兜底绑定(纯声明式世界,不借道本机偏好表);
|
|
@@ -166,7 +166,8 @@ if (dryRun) {
|
|
|
166
166
|
// §1.2(2026-09-08)dry-run 可观测项(单测锚点)
|
|
167
167
|
console.log(`[DRY-RUN] plugins.json 条目: ${entry ? `${entry.name} (dir=${entry.dir})` : "(未命中——将回退目录路径参数)"}`);
|
|
168
168
|
console.log(`[DRY-RUN] dist-tags 轮询: ${Math.max(30, Number(process.env.RELEASE_DIST_TAG_POLL_SEC) || 180)}s(RELEASE_DIST_TAG_POLL_SEC 可配)`);
|
|
169
|
-
console.log(`[DRY-RUN] README 四点同步: 徽章 / 正文当前版本 / 历史表新行 /
|
|
169
|
+
console.log(`[DRY-RUN] README 四点同步: 徽章 / 正文当前版本 / 历史表新行 / 固定源自动维护`);
|
|
170
|
+
console.log(`[DRY-RUN] 固定源将更新为: **${nextVer}(当前)** 锚点 ${quiet(`cd /d "${dir}" && git rev-parse HEAD`).trim().slice(0, 7)}(发布后自动回填 release 提交号)`);
|
|
170
171
|
process.exit(0);
|
|
171
172
|
}
|
|
172
173
|
writeFileSync(pkgPath, readFileSync(pkgPath, "utf8").replace(`"version": "${oldVer}"`, `"version": "${nextVer}"`));
|
|
@@ -186,19 +187,32 @@ for (const readme of ["README.md", "README.en.md"]) {
|
|
|
186
187
|
if (replaced !== text) writeFileSync(rp, replaced);
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
|
-
// §1.2-②(2026-09-08 第二批):README 正文"当前版本"行同步
|
|
190
|
+
// §1.2-②(2026-09-08 第二批):README 正文"当前版本"行同步
|
|
191
|
+
// 方案 A(2026-09-09,踩坑 140 根治):固定源行**自动维护**——bump 时插入「(当前)」行(锚点=改号前 HEAD),
|
|
192
|
+
// 发布提交后由 §5.5 回填真实 release 提交号;旧「(当前)」标记与遗留占位注释一并清理。
|
|
193
|
+
// 旧做法只插占位注释,而 B1 门禁要求「固定源=package 版本」→ 每次发布必撞(0.6.3 首发实证)。
|
|
194
|
+
const headBeforeShort = quiet(`cd /d "${dir}" && git rev-parse HEAD`).trim().slice(0, 7);
|
|
195
|
+
let fixedSourceBumpLine = "";
|
|
190
196
|
for (const readme of ["README.md", "README.en.md"]) {
|
|
191
197
|
const rp = join(dir, readme);
|
|
192
198
|
if (!existsSync(rp)) continue;
|
|
193
199
|
let rtxt = readFileSync(rp, "utf8");
|
|
194
200
|
const before = rtxt;
|
|
195
201
|
rtxt = rtxt.replace(/(> 当前版本 \*\*)\d+\.\d+\.\d+(\*\*)/, `$1${nextVer}$2`);
|
|
196
|
-
if (readme === "README.md" && rtxt.includes("## 发行固定源")
|
|
197
|
-
|
|
202
|
+
if (readme === "README.md" && rtxt.includes("## 发行固定源")) {
|
|
203
|
+
// ① 旧「(当前)」标记降级(只保留版本号加粗)
|
|
204
|
+
rtxt = rtxt.replace(/\*\*(\d+\.\d+\.\d+)(当前)\*\*/g, "**$1**");
|
|
205
|
+
// ② 清理遗留占位注释(方案 A 起不再产生)
|
|
206
|
+
rtxt = rtxt.replace(/\n<!-- fixed-source: [^>]*-->\n/g, "\n");
|
|
207
|
+
// ③ 插入新版本行(幂等:已存在则跳过)
|
|
208
|
+
if (!rtxt.includes(`- **${nextVer}(当前)**`)) {
|
|
209
|
+
fixedSourceBumpLine = `- **${nextVer}(当前)** 固定于 main Commit \`${headBeforeShort}\`(\`git checkout ${headBeforeShort}\` + 发布 bump 可复现 npm \`${name}@${nextVer}\` 与 GitHub Release v${nextVer} 同源代码)。**注:release 提交号由发布脚本自动回填。**`;
|
|
210
|
+
rtxt = rtxt.replace("## 发行固定源\n", `## 发行固定源\n\n${fixedSourceBumpLine}\n`);
|
|
211
|
+
}
|
|
198
212
|
}
|
|
199
213
|
if (rtxt !== before) writeFileSync(rp, rtxt);
|
|
200
214
|
}
|
|
201
|
-
console.log(
|
|
215
|
+
console.log(`版本已 bump(package.json + README 徽章 + 正文当前版本 + 固定源 ${nextVer}(锚点 ${headBeforeShort}))`);
|
|
202
216
|
|
|
203
217
|
// B1(2026-09-03):README 版本表行自动插入(阶段 C 人脑核对已失效的自动化;幂等:已存在 nextVer 行则跳过)
|
|
204
218
|
// 位置:版本表(| 版本 | 日期 | 要点 |)表头后第一数据行之前(最新在上);内容用调用方 notes 参数或 git log 最近提交信息
|
|
@@ -344,6 +358,23 @@ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec}`);
|
|
|
344
358
|
run(`cd /d "${repoRoot}" && git -c core.autocrlf=false commit -m "release: ${name} v${nextVer}" || exit 0`);
|
|
345
359
|
run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
|
|
346
360
|
|
|
361
|
+
// ── 5.5 固定源回填(方案 A,2026-09-09):用 release 提交号替换 bump 时的锚点 ──
|
|
362
|
+
if (fixedSourceBumpLine) {
|
|
363
|
+
const relShort = quiet(`cd /d "${repoRoot}" && git rev-parse HEAD`).trim().slice(0, 7);
|
|
364
|
+
const rd = join(dir, "README.md");
|
|
365
|
+
const rtxt = existsSync(rd) ? readFileSync(rd, "utf8") : "";
|
|
366
|
+
const finalLine = `- **${nextVer}(当前)** 固定于 main Commit \`${relShort}\`(\`git checkout ${relShort}\` 可复现 npm \`${name}@${nextVer}\` 与 GitHub Release v${nextVer} 同源代码)。`;
|
|
367
|
+
if (rtxt.includes(fixedSourceBumpLine)) {
|
|
368
|
+
writeFileSync(rd, rtxt.replace(fixedSourceBumpLine, finalLine), "utf8");
|
|
369
|
+
console.log(`\n=== 固定源回填 ===\n${nextVer}:锚点 ${headBeforeShort} → release 提交 ${relShort}`);
|
|
370
|
+
run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec}`);
|
|
371
|
+
run(`cd /d "${repoRoot}" && git -c core.autocrlf=false commit -m "docs(readme): ${nextVer} 固定源回填 ${relShort}(发布后自动同步)" || exit 0`);
|
|
372
|
+
run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
|
|
373
|
+
} else {
|
|
374
|
+
console.log("(固定源回填跳过:未匹配到 bump 行——可能已手工回填)");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
347
378
|
// ── 6. GitHub Release(带正式 tgz asset,规则 26;清单标记 skipRelease 的包不建)─────
|
|
348
379
|
if (entry?.skipRelease) {
|
|
349
380
|
console.log("(清单标记 skipRelease,跳过 GitHub Release)");
|