@leonhardthust/dsh-decision-engine 1.9.0 → 1.9.2

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.
@@ -0,0 +1,54 @@
1
+ # 1.9.1 自动审查热修复
2
+
3
+ ## 根因
4
+
5
+ 1.9.0 把“不认识工具 / 无法静态解析 shell、MCP 或网络效果”直接升级为 `requires_approval`。监听器在调用决策模型之前就返回 ask/deny,Auto 没有机会使用大模型回退。
6
+
7
+ 这些提前返回的记录缺少 provider/model,latency 为 0,state/questions 尚未构建,因而只显示 `unknown-high-impact-effect-requires-approval` 和空分数。它们不是模型瞬间完成的结论。
8
+
9
+ ## 修复
10
+
11
+ - 不确定效果为 `unknown`,不是宿主要求真人审批的证据。Auto 先调用决策模型;不确定效果即使初判 low,也进入独立 LLM 或用户显式选择的回退策略。
12
+ - medium/high、异常和无效模型结果同样进入回退,不直接交给用户。默认模型回退失败、超时或无效输出拒绝;显式 allow/deny 仅调整软审查。
13
+ - 手动模式可询问;审批禁用时不能伪造用户批准。
14
+ - 显式 `sandbox_permissions`、宿主拒绝、已知越界写入、只读环境写入、已识别提权/破坏等硬约束仍先于模型。LLM allow 不写成宿主 allowed-once。
15
+ - 多目标补丁优先保留确定越界;另一条无法解析的路径不能掩盖它。
16
+ - `skill`、Inspect、模型目录等严格只读查询不再自锁。
17
+
18
+ ## 审计与 UI
19
+
20
+ 新增 `decisionSource` 与 `modelEvaluation.primary/fallback`,分开记录未请求、完成、失败、缓存命中及实际 provider/model/latency。
21
+
22
+ - 本地规则显示“本地权限规则 / 未调用模型 / 未调用”,不再展示成未知提供方的 0ms 模型推理。
23
+ - 模型失败与缓存命中使用明确标签;回退模型身份与原 primary 身份分开保留。
24
+ - 早期规则仍构建真实 state/questions 摘要,未请求时 questions.status 为 not_requested。
25
+ - 没有模型事实的规则路径不伪造模型费用记录。
26
+ - 用户提示为可读说明,机器码留在 reasonCode / denial info。
27
+ - 旧记录证据不足时保持原样,不把 0ms 倒推为特定调用状态。
28
+
29
+ ## 1.9.1 实录复查与后续修正(未发布)
30
+
31
+ 按用户指定的北京时间 2026-11-15 00:30 起读取当前日志及 `.1` 轮转备份。首次完整提取快照有 182 条时间范围内记录,其中 39 条拒绝,JSON 均可解析。真实日志仅留在 Git 忽略目录,不提交会话标识、用户正文或工具参数。
32
+
33
+ - 26 条 `unresolved-effect-needs-model-review`:primary 已运行,但非 Auto 分支把 unknown 无条件覆盖为 ask/deny,连 tolerance=delegate 也被拒绝。
34
+ - 12 条 `advisory-failed-unknown-effect`:工作区 edit 已知 safe、四个核心信号有值,但 primary 解析失效;历史未记录具体 validation errors,不能反推原始错误分布。旧解析器确实会因附加 confidence/probabilities 校验失败使整体失效。
35
+ - 1 条 `fallback-model-deny`:唯一一条实际调用独立回退模型后拒绝的记录,不能和其余 38 条混淆。
36
+ - 5 条 MCP 拒绝中,4 条的 primary 与 tolerance 均已有有效 delegate,最终仍被 unknown 分支覆盖;另 1 条 primary 解析失败。5 条全部未请求 fallback。
37
+
38
+ 修正非 Auto 分支:有效 primary 与所选 tolerance 不再被 unknown 无条件覆盖;只有最终确实 ask 且 approval=never 才拒绝。Auto 不确定性仍独立复核,真实硬约束保持不变。
39
+
40
+ 附加概率和 confidence 元数据校验失败时只保留 warning 并弃用该元数据,不归一化、不用它抬高 severity,也不使有效必需信号整体失效;risk choice 或必需 noul 无效仍失败关闭。新审计记录 validationErrors、metadataWarnings、raw_class_probabilities,避免再只剩 answer-shape-mismatch。
41
+
42
+ 服务解析依据宿主源码:先用 agentPresets.serviceFor 读取确实存在的隔离 preset 实例;正常未覆盖时使用 actor.ctx 的共享基础服务。实例存在但方法返回 undefined 不再重试更宽全局实例;解析抛错保持不可用。回退 LLM 同样按 actor 解析。日志新增 permissionPreset、approvalPolicy、automatic 和 serviceSources。历史 39 条没有 permissionPreset,不能断言它们全因 Auto 识别错误造成。
43
+
44
+ 60 项匿名实录形状回归及包含作用域的 231 项组合回归通过;不是对原请求副作用的重放。全量 996 项中 993 通过、3 项失败,均为已有 Windows POSIX 文件权限断言。
45
+
46
+ 本地另对 39 条拒绝的已记录核心信号和阈值运行修正后的监听器,分手动 / Auto 两种显式场景共 78 次;手动场景 28 allow / 11 deny,Auto 场景在固定 LLM allow stub 下为 39 allow(31 次独立复核、8 次核心信号与软摩擦已允许的有限编辑)。这些计数不代表真实模型效果:历史 preset、完整参数与可选原始分布未完整保留,不重建或伪造;固定 stub 也不推翻原先那一次真实 fallback deny。仅验证 unknown 不再无条件覆盖有效判定,以及符合条件的 Auto 请求确实进入回退。原始实录与逐行回放仍在 Git 忽略目录。
47
+
48
+ 真实主端点探测在配置前置检查退出,没有发请求,没有更改用户配置;运行中的已发布 1.9.1 不会自动获得这些源码修正。
49
+
50
+ ## 验证边界
51
+
52
+ 125 项专项回归、212 项组合回归通过;24 个独立合成情景的四档矩阵通过。组合之间有重叠,不相加成独立样本数。模型 stub 的 allow/deny/failure 仅验证路由不变量,不代表真实危险 FNR 或概率校准。
53
+
54
+ 本次按 hunk 集成决策历史状态展示,不覆盖其他任务的 dark-mode UI 改动;其他任务是否已提交以 Git 记录为准。发布仍需干净提交的 Linux 完整 CI 成功;已安装的 1.9.0 不会因源码修改自动升级。
package/docs/releasing.md CHANGED
@@ -1,35 +1,36 @@
1
1
  # npm 发布
2
2
 
3
- 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.9.0`,公开包。
3
+ 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.9.2`,公开包。
4
4
 
5
5
  本分支基于 `zuosus/dsh-decision-engine` 的 MIT 代码,保留原作者与许可证;npm scope、源码仓库、bundle 引用和浏览器主模块 / charts chunk 标识已同步为本分支身份。内部 API、样式标识和用户数据目录保留旧名称以兼容已有配置。不要同时启用原包与本 scoped fork,它们提供相同的服务与页面。
6
6
 
7
7
  ## 工作流
8
8
 
9
9
  - `.github/workflows/ci.yml`:main push、PR、手动运行或发布工作流复用;Ubuntu 24.04 + Node.js 24 + Vite+ 1.6.0。执行 frozen install、完整测试与接线、隔离情景、实际打包和 tarball 检查。失败不会被忽略。
10
- - `.github/workflows/publish.yml`:版本 tag push 或 main 上手动运行。只有 `LeonhardtHUST/dsh-decision-engine` 可以进入发布;发布必须等待上述 CI 成功。
11
- - 手动运行必须填写与 manifest 完全一致的 `v1.9.0`。后续发版同步修改 manifest 与 tag。预发布版本不能覆盖 npm `latest`。
10
+ - `.github/workflows/publish.yml`:每次 `main` push 自动检测版本。先比较整个 push 的 `before` 与当前 manifest,拒绝错误包名、非法或倒退版本;再只读查询 npmjs。当前版本已上传则跳过;当前版本高于 npmjs latest 且未上传则进入完整验证及发布。多提交 push 比较整个 push 前后,不仅比较 HEAD 的父提交。
11
+ - 后续发版只需修改 `package.json` 版本并提交到 main;无需创建 tag 或手动点运行。发布依赖检测和本次提交的完整 CI 均成功,不跳过测试。稳定版本发布到 `latest`,预发布版本自动进入 `next`。
12
+ - 当前 `1.9.2` 包含实录 MCP 误拒、附加元数据校验和 actor/preset 服务解析修复;边界见 `docs/auto-review-hotfix.md`。
12
13
  - checkout 与 setup-vp Action 固定到已核对的 commit SHA;不通过 `pull_request_target` 运行不可信 PR。
13
- - 验证任务只有 `contents: read`;只有发布 job 有 `id-token: write`。代码 checkout 不保留 Git 凭据。此工作流不会自动修改版本、提交源码或推送 Git。
14
+ - 验证任务只有 `contents: read`;只有发布 job 有 `id-token: write`。发布 step 只使用 OIDC(trusted publishing),不再读取任何 npm token secret。代码 checkout 不保留 Git 凭据。此工作流不会自动修改版本、提交源码或推送 Git。
14
15
 
15
- ## npm 授权
16
+ ## npm 授权:一次性登记,工作流只用 OIDC
16
17
 
17
- 首次发包通常需要先用有 `@leonhardthust` scope 发布权限的账号登录:
18
+ 发布工作流**只走 npm Trusted Publishing**:publish job 只有 `contents: read` 与 `id-token: write`,npm 把 GitHub Actions 的 OIDC 身份换成短期凭据并生成 provenance。仓库不再需要 `NPM_TOKEN` secret,`publish.yml` 中也不存在任何 `NODE_AUTH_TOKEN`;首次发包之后直接用长期 token 在 workflow 内 bootstrap 的做法已废弃。
19
+
20
+ 首次登记是一次性、由有 `@leonhardthust` scope 发布权限的账号手动执行的动作。npm 的信任关系管理 API 要求 2FA/一次性口令(OTP),非交互环境只会返回 `EOTP`,因此必须在能弹出浏览器完成验证的终端里跑:
18
21
 
19
22
  ```powershell
20
- $env:HTTPS_PROXY = "http://localhost:7897"
21
23
  $env:HTTP_PROXY = "http://localhost:7897"
22
- npm config get registry --registry "https://registry.npmjs.org/"
23
- npm login --registry "https://registry.npmjs.org/" --auth-type web
24
- npm whoami --registry "https://registry.npmjs.org/"
24
+ $env:HTTPS_PROXY = "http://localhost:7897"
25
+ $env:npm_config_cache = Join-Path (Get-Location) "release-artifacts/npm-cache"
26
+ npm trust github @leonhardthust/dsh-decision-engine --file publish.yml --repository LeonhardtHUST/dsh-decision-engine --allow-publish --registry "https://registry.npmjs.org/" --yes
25
27
  ```
26
28
 
27
- 认证直接调用原生 npm,不经过当前 `vp pm login` 的参数透传;已观察到该透传把 registry 错误解析成 `https` 后回落到镜像。仓库依赖与验证任务继续由 vp 管理。CLI 必须显示 npmjs 官方 registry;出现 npmmirror 等镜像时立即取消。代理环境变量只影响这个终端。不要把 npm token 贴到聊天、提交到源码或打印到 CI 日志。网页登录和二次验证由用户完成。
28
-
29
- CI 发布支持两种授权:
30
-
31
- 1. **npm Trusted Publishing(优先)**:在 npm 包设置登记 GitHub owner=`LeonhardtHUST`、repo=`dsh-decision-engine`、workflow=`publish.yml`。工作流用 OIDC 和支持 trusted publishing 的 npm CLI 发布,并生成 provenance;不必保存长期 token。首次创建 scoped 包后再配置可信发布者。
32
- 2. **已有发布凭据**:仓库 Actions secret `NPM_TOKEN`。工作流仅在最后的授权与发布步骤提供它,临时 npmrc 使用环境变量引用,不把 token 插进文件内容。token 权限与 npm 的 2FA/发布策略必须允许该包发布;未授权会明确失败,不静默换身份。
29
+ - 该命令登记 owner/repo/workflow 三元组;同一关系重复登记只是覆盖,不需要先 `revoke`。它不接受把 `--cache` 当位置参数,缓存只用进程环境变量 `npm_config_cache` 指定;registry 始终显式写 npmjs 官方地址。
30
+ - 回读用 `npm trust list @leonhardthust/dsh-decision-engine`,但 **list 与 create 一样会触发 OTP 挑战**。非交互环境返回 `EOTP` 属预期:此时登记状态无法从命令行独立核验,只能查看 npm 包设置页的 publishing access,或观察下一次 OIDC 发布的结果。不要为回读反复重发挑战,不要打开 CLI 打印的 `doneUrl`,也不要把挑战换回的 token 读取、复制或落盘。
31
+ - 登记后不要再配置长期 `NPM_TOKEN` 发布路径;若需回退,改用下文的手动发布,不要在工作流里重新注入长期 token。
32
+ - 认证直接调用原生 npm,不经过 `vp pm login` 的参数透传;已观察到该透传把 registry 错误解析成 `https` 后回落到镜像。仓库依赖与验证任务继续由 vp 管理。CLI 必须显示 npmjs 官方 registry;出现 npmmirror 等镜像时立即取消。代理变量只在本次进程内生效。不要把 npm token 贴到聊天、提交到源码或打印到 CI 日志。网页登录和二次验证由用户完成。
33
+ - 工作流要求 runner 自带的 npm CLI 支持 trusted publishing(最低 npm 11.5.1;管理登记命令已核对本机 npm 11.20.0);OIDC 发布必须带 `--provenance`。
33
34
 
34
35
  不要把仅有 GitHub 仓库 ADMIN 权限理解成拥有 npm scope 权限。
35
36
 
@@ -41,7 +42,6 @@ vp run verify
41
42
  vp run verify:guard
42
43
  vp run pack:release
43
44
  vp run verify:package
44
- $env:RELEASE_TAG = "v1.9.0"
45
45
  vp run verify:release
46
46
  ```
47
47
 
@@ -49,19 +49,21 @@ vp run verify:release
49
49
 
50
50
  `verify:package` 读取真正的 gzip/tar,检查 manifest、scoped 模块 id、bundle 引用、全部安全模块、locale、图表资源和相对 import。配置、凭据、日志、测试目录等不能进入包。输出文件数、大小和 SHA-512 integrity。
51
51
 
52
- 当前 Windows 完整测试的 5 个基线失败及安全边界见 `bench/guard-revision/validation.md`(仅在源码仓库保留完整证据)。发布 CI 选择 Linux,依然必须运行完整测试,不跳过或忽略它们;本机专测通过不等同于 CI 发布成功。
52
+ Windows 全量测试最近仍有 3 个既有 POSIX 文件权限断言失败;旧验证检查点及安全边界见 `bench/guard-revision/validation.md` 与 `docs/auto-review-hotfix.md`(完整证据仅在源码仓库保留)。发布 CI 选择 Linux,依然必须运行完整测试,不跳过或忽略它们;本机专测通过不等同于 CI 发布成功。
53
53
 
54
54
  ## 手动发布相同的已验证 tarball
55
55
 
56
+ 这是本地兜底路径:需要交互式 `npm login` 并当场完成 2FA/OTP,不走 OIDC,也不会生成本地 provenance。日常发版应让 Actions 工作流发布。
57
+
56
58
  授权及完整验证均满足后:
57
59
 
58
60
  ```powershell
59
- npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.9.0.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
61
+ npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.9.2.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
60
62
  npm view @leonhardthust/dsh-decision-engine version --registry "https://registry.npmjs.org/" --json
61
63
  ```
62
64
 
63
- 本地发布不伪造 GitHub provenance;在 GitHub Actions 中由工作流增加 `--provenance`。发布操作不可覆盖已存在的同版本;版本冲突必须停止,不自动递增重试。
65
+ 本地发布不伪造 GitHub provenance;在 GitHub Actions 中由工作流增加 `--provenance`。发布操作不可覆盖已存在的同版本;已发布版本的后续提交会跳过发布。若升版后的 CI 失败,修复代码并推送 main 会自动重试仍未发布的版本,无需再升版或手动触发。npmjs 查询错误会停止,不能把网络或权限错误当成未发布。自动检测不自动修改版本或源码。
64
66
 
65
67
  ## 安装
66
68
 
67
- DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.9.0`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
69
+ DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.9.2`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
@@ -62,13 +62,13 @@ ToolExecution
62
62
  安全分级不再等于一个模型风险分数。
63
63
 
64
64
  1. **safe**:已知无副作用本地查询、受宿主修订协议约束的任务状态操作、经过独立核验的内部消息;有限写入仅在宿主目标解析与允许范围内进入模型审查。
65
- 2. **requires_approval**:越界写入、只读环境写入、权限提升、可能破坏、未知高影响执行、未知外部能力、接收方授权未核验。
65
+ 2. **requires_approval**:已确认的越界写入、只读环境写入、显式权限提升、已识别的破坏操作、接收方授权未核验。不能仅因不认识某工具或无法解析 shell,就宣称宿主要求真人审批。
66
66
  3. **deny**:宿主已明确拒绝工具可见性;所有档位保持拒绝。
67
- 4. **unknown**:缺失的来源与权限不能默认为授权;高影响 unknown 升级 requires_approval,不能靠模型低风险放行。
67
+ 4. **unknown**:缺失的副作用证据不等于权限升级。未知 shell/MCP/网络效果进入决策模型;Auto 下即使初判 low,仍使用独立 LLM 或用户显式选择的回退策略复核。人工模式保留询问或拒绝;模型失效不伪造用户批准。
68
68
 
69
69
  `danger-full-access` 只表示沙箱约束,不是所有操作的授权。`approval=never` 下 required 调用直接拒绝。审批可用也不是批准:最终单调 guard 要求本次 allowed-once,并核对被审查参数摘要与调用身份,防止其他 waterfall 把 ask 改为 allow 或修改参数。
70
70
 
71
- 严格安全白名单不包括任意 `web_fetch`、MCP、shell 或补丁;`todo_write` / `team_task_update` 是有限状态修改,不伪装成只读。无法可靠解析 shell 时不通过正则“证明安全”;危险关键词只帮助说明原因,没命中仍是未知高影响。这会提高无人审批环境下任意 shell/MCP 的拒绝率,是当前宿主缺少精细预授权接口的明确安全退化。
71
+ 严格安全直通不包括任意 `web_fetch`、MCP、shell 或补丁;`todo_write` / `team_task_update` 是有限状态修改,不伪装成只读。无法可靠解析 shell 时不通过正则“证明安全”,而是标记不确定并交模型复核。1.9.0 曾把这种不确定性直接升级为强制人工审批,导致 Auto 完全绕过模型,是已修复的严重回归,不是可接受的安全降级。多目标补丁只要有已知越界目标,其他未知路径也不能掩盖该硬约束。
72
72
 
73
73
  ### 自动审查与 Fallback
74
74
 
package/lib/client.js CHANGED
@@ -405,6 +405,13 @@ window.__ModuleLoader__.load({
405
405
 
406
406
  /** i18n:labels —— 服务端下发、且本文件别处没有同名词的中文文案。 */
407
407
  const EN_LABELS = {
408
+ "本地权限规则": "Local permission rules",
409
+ "未调用模型": "Model not called",
410
+ "未调用": "Not called",
411
+ "本地检查通过": "Local checks passed",
412
+ "提供方未返回": "Provider not returned",
413
+ "模型标识未返回": "Model identity not returned",
414
+ "模型请求失败": "Model request failed",
408
415
  // provider 展示名来自服务端(lib/providers.js 的 providerCatalog)。
409
416
  // 不收进 EN_CONFIG/EN_STATS,因为它是**服务端数据**:将来新增带中文名
410
417
  // 的预设,只要在这里补一行;界面在渲染边界统一翻译,不用改调用点。
@@ -906,8 +913,78 @@ window.__ModuleLoader__.load({
906
913
  return false;
907
914
  }
908
915
 
916
+ /**
917
+ * 把 `rgb(r, g, b)` / `rgba(r, g, b, a)` 解析成三个通道;读不懂返回 `null`。
918
+ *
919
+ * 只认**计算后**的颜色格式(样式表算完就是 rgb/rgba),别的一律当
920
+ * 「没探到」:宁可退回浏览器默认,也不拿一个猜出来的深浅去上色。
921
+ * alpha 太低的底(`transparent`、`rgba(0,0,0,0)`)同样返回 `null`——
922
+ * 半透明的底看不出深浅,用它判主题只会判错。
923
+ */
924
+ function parseCssRgb(text) {
925
+ if (typeof text !== "string") return null;
926
+ const match = /^rgba?\(([^)]+)\)$/i.exec(text.trim());
927
+ if (match === null) return null;
928
+ const parts = match[1].split(/[\s,/]+/).filter((part) => part !== "");
929
+ if (parts.length < 3) return null;
930
+ const channels = parts.slice(0, 3).map((part) => Number.parseFloat(part));
931
+ if (channels.some((value) => !Number.isFinite(value))) return null;
932
+ const alpha = parts.length >= 4 ? Number.parseFloat(parts[3]) : 1;
933
+ if (!Number.isFinite(alpha) || alpha < 0.5) return null;
934
+ return channels;
935
+ }
936
+
937
+ /**
938
+ * 宿主此刻是深色还是浅色主题:`"dark"` / `"light"`,探不到返回 `null`。
939
+ *
940
+ * 为什么必须真探一次:原生 `<select>` 的**弹层**是浏览器自己画的一层,
941
+ * 它既不继承页面的透明底,也不吃 `color: inherit`——弹层底色只看
942
+ * `color-scheme`(默认浅色 = 白),而选项文字继承的是页面文字色。
943
+ * 深色主题下于是变成**白底白字**:四个选项一个都看不见,只剩一条
944
+ * 蓝色高亮(用户截图,DEC-061)。
945
+ *
946
+ * 为什么不用 `prefers-color-scheme`:那个只反映操作系统偏好。用户可以在
947
+ * DSH 里单独切主题,两者不一致时它就会答错——而答错的后果正是这次
948
+ * 要修的那个不可读状态。这里改问宿主自己:造一个 0 尺寸的探针挂上
949
+ * 宿主的基色令牌(`--dsw-alias-bg-base`),读它的**计算值**再算亮度。
950
+ *
951
+ * 探不到就返回 `null`(Node 渲染测试没有 `document`、宿主没定义该令牌、
952
+ * 探针被 CSP 拦下):调用方退回 `color-scheme: light dark`,交回浏览器
953
+ * 按系统偏好判——不比现状更糟,也绝不写死一个颜色。
954
+ */
955
+ function hostColorScheme() {
956
+ try {
957
+ if (typeof document === "undefined" || typeof getComputedStyle !== "function") return null;
958
+ if (typeof document.createElement !== "function") return null;
959
+ const host = document.body ?? document.documentElement;
960
+ if (!host || typeof host.appendChild !== "function") return null;
961
+ const probe = document.createElement("span");
962
+ // 0 尺寸 + 固定定位:不参与布局、不出滚动条、不挡点击。
963
+ probe.style.cssText =
964
+ "position:fixed;left:-9999px;top:0;width:0;height:0;pointer-events:none;background:var(--dsw-alias-bg-base, transparent)";
965
+ host.appendChild(probe);
966
+ let raw = "";
967
+ try {
968
+ raw = getComputedStyle(probe).backgroundColor ?? "";
969
+ } finally {
970
+ // 探针必须收掉:留在 DOM 里会多一个节点(宿主的无障碍树也会看见它)。
971
+ if (typeof probe.remove === "function") probe.remove();
972
+ else if (typeof host.removeChild === "function") host.removeChild(probe);
973
+ }
974
+ const channels = parseCssRgb(raw);
975
+ if (channels === null) return null;
976
+ // 相对亮度的简化算法(WCAG 的 0.2126R + 0.7152G + 0.0722B,按 0–255 归一)。
977
+ // 只需要分「深底 / 浅底」,不必做伽马校正。
978
+ const luminance = (0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]) / 255;
979
+ return luminance > 0.5 ? "light" : "dark";
980
+ } catch {
981
+ /* 探针失败一律当「没探到」:调用方会走浏览器默认 */
982
+ return null;
983
+ }
984
+ }
985
+
909
986
  /* ============================================================
910
- * 配置页的分组折叠(DEC-051)
987
+ * 配置页的样式表:分组折叠(DEC-051)+ 原生下拉弹层(DEC-061)
911
988
  *
912
989
  * 样式参考 @xbzbing/dsh-git-panel 的插件配置页:分组标题是一行
913
990
  * 可点的按钮(chevron 指示开合),正文挂在标题下面;标题左侧有
@@ -923,11 +1000,16 @@ window.__ModuleLoader__.load({
923
1000
  *
924
1001
  * 失败方向是安全的:样式表注不上(Node 测试环境 / CSP 挡住)时,
925
1002
  * 分组照样渲染、照样能展开收起,只是没有那条语义竖线与 hover 底色。
1003
+ *
1004
+ * 这张表还挂着第二条规则(DEC-061):原生 `<select>` 的**弹层**配色。
1005
+ * 弹层不是页面的子节点,内联样式只能管到收起态的那一格;选项的底色
1006
+ * 必须靠 `.de-cfg select option` 这种选择器落到 `option` 元素上,
1007
+ * 浏览器才会用它去画弹层里的每一行。理由详见下面那两条规则上的注释。
926
1008
  * ============================================================ */
927
1009
 
928
- /** 分组折叠样式的 style 标签 id(幂等判定用)。 */
929
- const CFG_SECTION_STYLE_ID = "dsh-decision-engine-cfg-section-css";
930
- const CFG_SECTION_CSS = `
1010
+ /** 面板样式表的 style 标签 id(幂等判定用)。 */
1011
+ const PANEL_STYLE_ID = "dsh-decision-engine-panel-css";
1012
+ const PANEL_CSS = `
931
1013
  .de-cfg{display:flex;flex-direction:column;gap:0}
932
1014
  .de-cfg__section{display:flex;flex-direction:column;gap:10px;padding:14px 0}
933
1015
  .de-cfg__section:first-child{padding-top:0}
@@ -955,25 +1037,39 @@ window.__ModuleLoader__.load({
955
1037
  .de-cfg__section-body[hidden]{display:none}
956
1038
  .de-cfg__chevron{flex:none;color:var(--dsw-alias-label-tertiary,inherit);transition:transform .15s ease}
957
1039
  .de-cfg__chevron--open{transform:rotate(90deg)}
1040
+ /* 原生 <select> 的弹层(DEC-061)。
1041
+ 弹层是浏览器自己画的一层:不继承页面的透明底,也不吃 color:inherit,
1042
+ 所以每一行必须有**不透明**的底色和主文本色,否则深色主题下就是
1043
+ 白底白字(用户截图:四个选项一个都看不见)。
1044
+
1045
+ 两处都用宿主令牌,fallback 给一对系统色(Canvas / CanvasText):这一对
1046
+ 由浏览器保证互相有对比度,令牌改名或缺失时最坏是「不贴合主题」,不是
1047
+ 「看不见」。底色取 bg-layer-2 而不是 bg-base:宿主自己就把它用在浮层上
1048
+ (代码查看器、loading 浮条),弹层正是一层浮在页面上的东西。color-scheme
1049
+ 决定弹层外框与滚动条按深还是按浅画:值来自 hostColorScheme()(写进
1050
+ .de-cfg 根节点的 --de-select-scheme),探不到时退回 light dark,交回浏览器
1051
+ 按系统偏好判。 */
1052
+ .de-cfg select{color-scheme:var(--de-select-scheme,light dark)}
1053
+ .de-cfg select option{background-color:var(--dsw-alias-bg-layer-2,Canvas);color:var(--dsw-alias-label-primary,CanvasText)}
958
1054
  `;
959
1055
 
960
1056
  /**
961
- * 幂等地把分组折叠样式注进 `document.head`。
1057
+ * 幂等地把面板样式表注进 `document.head`。
962
1058
  *
963
1059
  * 与 `ensureRowEnterCss` 同一套路:注不上就**不注**(不抛错),
964
- * 分组照样渲染——只是少了竖线与 hover 底色,不影响功能。
1060
+ * 分组与下拉照常可用——只是少了竖线、hover 底色与弹层配色。
965
1061
  */
966
- function ensureCfgSectionCss() {
1062
+ function ensurePanelCss() {
967
1063
  try {
968
1064
  if (typeof document === "undefined" || typeof document.createElement !== "function") return false;
969
1065
  const parent = document.head ?? document.body;
970
1066
  if (!parent) return false;
971
- if (typeof document.getElementById === "function" && document.getElementById(CFG_SECTION_STYLE_ID)) return true;
1067
+ if (typeof document.getElementById === "function" && document.getElementById(PANEL_STYLE_ID)) return true;
972
1068
  const tag = document.createElement("style");
973
- tag.id = CFG_SECTION_STYLE_ID;
1069
+ tag.id = PANEL_STYLE_ID;
974
1070
  tag.dataset.plugin = "dsh-decision-engine";
975
- tag.dataset.pluginCss = CFG_SECTION_STYLE_ID;
976
- tag.textContent = CFG_SECTION_CSS;
1071
+ tag.dataset.pluginCss = PANEL_STYLE_ID;
1072
+ tag.textContent = PANEL_CSS;
977
1073
  parent.appendChild(tag);
978
1074
  return true;
979
1075
  } catch {
@@ -1028,7 +1124,7 @@ window.__ModuleLoader__.load({
1028
1124
  /**
1029
1125
  * 把图表样式注进 `document.head`(幂等)。
1030
1126
  *
1031
- * 与 `ensureCfgSectionCss` 同一套路:注不上就不注,图表照样渲染,
1127
+ * 与 `ensurePanelCss` 同一套路:注不上就不注,图表照样渲染,
1032
1128
  * 只是少了配色与描边(SVG 会退回成光秃秃的默认黑)。
1033
1129
  * 样式内容随分块一起下发(分块导出的 `styles` 字段),由
1034
1130
  * `scripts/build-microcharts.mjs` 从库里拷来并接上 DSH 主题变量。
@@ -3426,6 +3522,19 @@ window.__ModuleLoader__.load({
3426
3522
  * @param props.sessionId - 当前会话 id(`conversation.view` 的标准 prop)。
3427
3523
  * @param props.pointLabels - 决策点 id → 中文名。
3428
3524
  */
3525
+ function guardModelDisplay(entry) {
3526
+ const evaluation = entry?.scores?.modelEvaluation;
3527
+ if (!evaluation) return { provider: entry?.provider ?? "", model: entry?.model ?? "", latency: `${Number(entry?.latencyMs) || 0}ms` };
3528
+ if (evaluation.status === "not_requested") return { provider: t("本地权限规则"), model: t("未调用模型"), latency: t("未调用"), verdict: entry?.action === "delegate" ? t("本地检查通过") : undefined };
3529
+ const judge = evaluation.fallback?.provider || evaluation.fallback?.model ? evaluation.fallback : evaluation.primary;
3530
+ const readable = (value, missing) => typeof value === "string" && value && value !== "unknown" ? value : t(missing);
3531
+ return {
3532
+ provider: readable(judge?.provider ?? entry?.provider, "提供方未返回"),
3533
+ model: readable(judge?.model ?? entry?.model, "模型标识未返回"),
3534
+ latency: evaluation.status === "failed" ? t("模型请求失败") : evaluation.primary?.status === "cache_hit" && evaluation.fallback?.status === "not_requested" ? t("缓存命中") : `${Number(entry?.latencyMs) || 0}ms`,
3535
+ };
3536
+ }
3537
+
3429
3538
  function DecisionsView(props) {
3430
3539
  const sessionId = typeof props?.sessionId === "string" ? props.sessionId : "";
3431
3540
  const [page, setPage] = react.useState(null);
@@ -3641,6 +3750,7 @@ window.__ModuleLoader__.load({
3641
3750
  const color = VERDICT_COLORS[action] ?? DEFAULT_VERDICT_COLOR;
3642
3751
  const point = typeof entry?.point === "string" ? entry.point : "";
3643
3752
  const time = typeof entry?.ts === "string" ? entry.ts : "";
3753
+ const modelDisplay = guardModelDisplay(entry);
3644
3754
  // 本地时间(不是 UTC 挂钟):见 formatLocalDateTime 的说明。
3645
3755
  const local = formatLocalDateTime(time);
3646
3756
  return h(
@@ -3654,8 +3764,8 @@ window.__ModuleLoader__.load({
3654
3764
  },
3655
3765
  h("td", { style: S.decisionsTd }, local),
3656
3766
  h("td", { style: S.decisionsTd }, pointLabels[point] ?? point),
3657
- h("td", { style: S.decisionsTd }, entry?.provider ?? ""),
3658
- h("td", { style: S.decisionsTd }, entry?.model ?? ""),
3767
+ h("td", { style: S.decisionsTd }, modelDisplay.provider),
3768
+ h("td", { style: S.decisionsTd }, modelDisplay.model),
3659
3769
  h(
3660
3770
  "td",
3661
3771
  { style: S.decisionsTd },
@@ -3665,11 +3775,11 @@ window.__ModuleLoader__.load({
3665
3775
  // `title` 留**原始机器词**:明细文件与遥测里就是它,
3666
3776
  // 需要对照时停一下鼠标就能看到。
3667
3777
  { style: S.decisionsVerdict(color), title: action },
3668
- VERDICT_LABELS[action] ?? action,
3778
+ modelDisplay.verdict ?? VERDICT_LABELS[action] ?? action,
3669
3779
  )
3670
3780
  : "",
3671
3781
  ),
3672
- h("td", { style: S.decisionsTd }, `${Number(entry?.latencyMs) || 0}ms`),
3782
+ h("td", { style: S.decisionsTd }, modelDisplay.latency),
3673
3783
  // 「内容」列:这次到底存没存内容。判据是 `capture` 字段——
3674
3784
  // `meta` 只可能来自超限裁剪(DEC-029 之后没有「只记元数据」的档位)。
3675
3785
  h(
@@ -5942,6 +6052,11 @@ window.__ModuleLoader__.load({
5942
6052
  i18n.scheduleGuardCatalogLoad = scheduleGuardCatalogLoad;
5943
6053
  i18n.reduceGuardCatalog = reduceGuardCatalog;
5944
6054
  i18n.GUARD_CATALOG_DEBOUNCE_MS = GUARD_CATALOG_DEBOUNCE_MS;
6055
+ // 主题探针(DEC-061)同样挂出来:原生下拉弹层的深浅只靠真机截图验不了,
6056
+ // 而它的输入(宿主基色)可以在测试里造——探到 dark / light / 探不到
6057
+ // 三条路径都必须钉住,否则「深色主题下白底白字」随时会回来。
6058
+ i18n.hostColorScheme = hostColorScheme;
6059
+ i18n.parseCssRgb = parseCssRgb;
5945
6060
 
5946
6061
  /**
5947
6062
  * 分组图标(DEC-051):折叠状态下也要一眼认出这一组是什么。
@@ -6188,11 +6303,11 @@ window.__ModuleLoader__.load({
6188
6303
  unsubscribe();
6189
6304
  };
6190
6305
  }, [tab, chartsReady]);
6191
- // 分组折叠的样式表(竖线与语义色)注进 document.head。
6306
+ // 面板样式表(分组竖线/语义色 + 原生下拉弹层配色)注进 document.head。
6192
6307
  // 与「决策历史」页的入场动画同一套路:注不上就不注(Node 测试环境
6193
6308
  // 没有 document),分组照样渲染、照样能展开收起,只是少了竖线。
6194
6309
  react.useEffect(() => {
6195
- ensureCfgSectionCss();
6310
+ ensurePanelCss();
6196
6311
  }, []);
6197
6312
  // 语言变化时重绘本页。放在所有 useState 之后:测试是按「第 n 个
6198
6313
  // useState」注入状态的,插在中间会把后面的槽位全部挪位。
@@ -8152,9 +8267,20 @@ window.__ModuleLoader__.load({
8152
8267
  // 外面包一层 `.de-cfg`:分组之间的顶边细线用 `.de-cfg__section +
8153
8268
  // `.de-cfg__section` 表达,只有当分组**是同一个父元素的兄弟**时才生效;
8154
8269
  // 摊平到页面根节点会把 footerItems 夹在中间,相邻关系就不成立了。
8270
+ //
8271
+ // `--de-select-scheme`(DEC-061):原生下拉的弹层按这个值决定深浅。
8272
+ // 每次渲染探一次宿主的基色,而不是缓存一次——主题可以在不重载页面的
8273
+ // 情况下切换,缓存会让弹层永远停在旧主题上。探针是一次 0 尺寸
8274
+ // `getComputedStyle`,在这个设置页里没有滚动/动画路径,代价可忽略。
8275
+ // 探不到就**不写**这个变量,让 CSS 的 `light dark` 兜底。
8276
+ const selectScheme = hostColorScheme();
8155
8277
  const configBody = h(
8156
8278
  "div",
8157
- { className: "de-cfg", key: "config-body" },
8279
+ {
8280
+ className: "de-cfg",
8281
+ key: "config-body",
8282
+ ...(selectScheme === null ? {} : { style: { "--de-select-scheme": selectScheme } }),
8283
+ },
8158
8284
  sectionBlock("connection", SECTION_META.connection.tone, SECTION_META.connection.title, connectionItems),
8159
8285
  sectionBlock("guard", SECTION_META.guard.tone, SECTION_META.guard.title, guardItems),
8160
8286
  sectionBlock("record", SECTION_META.record.tone, SECTION_META.record.title, recordItems),
@@ -2,6 +2,7 @@ export const GUARD_QUESTIONS_VERSION = "guard-questions-v2";
2
2
  export const CONFIDENCE_KINDS = ["normalized", "provider_raw", "unavailable"];
3
3
 
4
4
  const probability = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
5
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
5
6
 
6
7
  export function guardQuestions() {
7
8
  return {
@@ -31,26 +32,42 @@ export function guardQuestions() {
31
32
 
32
33
  export function parseModelAdvisory(answers, options = {}) {
33
34
  const errors = [];
35
+ const warnings = [];
34
36
  const source = answers && typeof answers === "object" && !Array.isArray(answers) ? answers : {};
35
37
  const risk = source.risk;
36
38
  const choice = risk?.type === "choice" && ["low", "medium", "high"].includes(risk.choice) ? risk.choice : "unknown";
37
39
  if (choice === "unknown") errors.push("risk-choice-unavailable");
38
40
  const declaredKind = risk?.confidenceKind ?? options.confidenceKind ?? "unavailable";
39
- const confidenceKind = CONFIDENCE_KINDS.includes(declaredKind) ? declaredKind : "unavailable";
40
- if (!CONFIDENCE_KINDS.includes(declaredKind)) errors.push("confidence-kind-invalid");
41
- const confidence = typeof risk?.confidence === "number" && Number.isFinite(risk.confidence) ? risk.confidence : null;
42
- if (risk?.confidence !== undefined && confidence === null) errors.push("confidence-invalid");
43
- if (confidenceKind === "normalized" && !probability(confidence)) errors.push("normalized-confidence-out-of-range");
44
- if (confidenceKind === "provider_raw" && confidence === null) errors.push("provider-confidence-unavailable");
45
- let probabilities = null;
46
- if (risk?.probabilities !== undefined) {
47
- const values = risk.probabilities;
48
- if (values && typeof values === "object" && !Array.isArray(values)
49
- && ["low", "medium", "high"].every((key) => probability(values[key]))
50
- && Math.abs(values.low + values.medium + values.high - 1) <= 1e-6) {
51
- probabilities = { low: values.low, medium: values.medium, high: values.high };
52
- } else errors.push("class-probabilities-invalid");
41
+ let confidenceKind = CONFIDENCE_KINDS.includes(declaredKind) ? declaredKind : "unavailable";
42
+ if (!CONFIDENCE_KINDS.includes(declaredKind)) warnings.push("confidence-kind-invalid");
43
+ const confidence = finite(risk?.confidence) ? risk.confidence : null;
44
+ if (risk?.confidence !== undefined && confidence === null) warnings.push("confidence-invalid");
45
+ if (confidenceKind === "normalized" && !probability(confidence)) {
46
+ warnings.push("normalized-confidence-out-of-range");
47
+ confidenceKind = "unavailable";
53
48
  }
49
+ if (confidenceKind === "provider_raw" && confidence === null) {
50
+ warnings.push("provider-confidence-unavailable");
51
+ confidenceKind = "unavailable";
52
+ }
53
+ let distribution = null;
54
+ let probabilityStatus = "unavailable";
55
+ const supplied = risk?.probabilities;
56
+ if (supplied !== undefined) {
57
+ if (supplied && typeof supplied === "object" && !Array.isArray(supplied)
58
+ && ["low", "medium", "high"].every((key) => probability(supplied[key]))
59
+ && Math.abs(supplied.low + supplied.medium + supplied.high - 1) <= 1e-6) {
60
+ distribution = { low: supplied.low, medium: supplied.medium, high: supplied.high };
61
+ probabilityStatus = "validated-distribution";
62
+ } else {
63
+ warnings.push("class-probabilities-invalid");
64
+ probabilityStatus = "invalid-distribution";
65
+ }
66
+ }
67
+ const rawProbabilities = supplied && typeof supplied === "object" && !Array.isArray(supplied)
68
+ ? Object.fromEntries(["low", "medium", "high"].map((key) => [key, finite(supplied[key]) ? supplied[key] : null])) : null;
69
+ const probabilities = warnings.length ? null : distribution;
70
+ if (distribution && warnings.length) probabilityStatus = "ignored-metadata-warning";
54
71
  const scalar = (name) => {
55
72
  const answer = source[name];
56
73
  if (answer?.type !== "noul" || !probability(answer.noul)) {
@@ -67,18 +84,16 @@ export function parseModelAdvisory(answers, options = {}) {
67
84
  risk: { choice, confidence, confidenceKind, ...(probabilities ? { probabilities } : {}) },
68
85
  irreversibleP, taskP, injectionP,
69
86
  metadata: {
70
- questionsVersion: GUARD_QUESTIONS_VERSION,
71
- confidenceKind,
87
+ questionsVersion: GUARD_QUESTIONS_VERSION, confidenceKind,
72
88
  raw_decision_confidence: confidence,
89
+ raw_class_probabilities: rawProbabilities,
73
90
  class_probabilities: probabilities ?? "unknown",
74
91
  p_high: probabilities?.high ?? null,
75
92
  p_non_low: probabilities ? probabilities.medium + probabilities.high : null,
76
- calibrated_p_unsafe: "unknown",
77
- authorization_status: "unknown",
78
- source_trust: "unknown",
79
- probabilityStatus: probabilities ? "validated-distribution" : "unavailable",
93
+ calibrated_p_unsafe: "unknown", authorization_status: "unknown", source_trust: "unknown",
94
+ probabilityStatus, warnings, validationErrors: errors,
80
95
  },
81
- errors,
96
+ errors, warnings,
82
97
  };
83
98
  }
84
99
 
@@ -41,11 +41,13 @@ export function createGuardAudit(options = {}) {
41
41
  args_summary: hashSummary(options.args),
42
42
  goal_summary: hashSummary(context.originalUserGoal),
43
43
  context_summary: hashSummary(context),
44
- questions_summary: hashSummary(options.questions),
45
- questions_version: advisory.metadata?.questionsVersion ?? "unknown",
46
- model_summary: hashSummary(options.model),
44
+ questions_summary: hashSummary(options.questions ?? {}),
45
+ questions_version: options.questionsVersion ?? advisory.metadata?.questionsVersion ?? "not_requested",
46
+ model_summary: hashSummary(options.modelEvaluation ?? options.model ?? null),
47
+ modelEvaluation: options.modelEvaluation ?? { status: "not_requested" },
48
+ decisionSource: options.decisionSource ?? "local_policy",
47
49
  model_result: {
48
- status: advisory.status ?? "unknown",
50
+ status: options.modelEvaluation?.primary?.status ?? advisory.status ?? "not_requested",
49
51
  risk: ["low", "medium", "high"].includes(advisory.risk?.choice) ? advisory.risk.choice : "unknown",
50
52
  confidenceKind: ["normalized", "provider_raw", "unavailable"].includes(advisory.metadata?.confidenceKind) ? advisory.metadata.confidenceKind : "unavailable",
51
53
  raw_decision_confidence: finite(advisory.metadata?.raw_decision_confidence),
@@ -53,10 +55,14 @@ export function createGuardAudit(options = {}) {
53
55
  p_non_low: finite(advisory.metadata?.p_non_low),
54
56
  irreversibleP: finite(advisory.irreversibleP), taskP: finite(advisory.taskP), injectionP: finite(advisory.injectionP),
55
57
  calibrated_p_unsafe: "unknown",
58
+ validationErrors: advisory.errors ?? [],
59
+ metadataWarnings: advisory.metadata?.warnings ?? [],
60
+ probabilityStatus: advisory.metadata?.probabilityStatus ?? "unavailable",
61
+ raw_class_probabilities: advisory.metadata?.raw_class_probabilities ?? null,
56
62
  },
57
63
  mandatory: { status: ["safe", "requires_approval", "deny", "unknown"].includes(mandatory.status) ? mandatory.status : "unknown", reasonCodes: mandatory.reasonCodes ?? [], evidence_summary: hashSummary(mandatory.evidence) },
58
64
  effect: { kind: effect.kind ?? "unknown", highImpact: effect.highImpact ?? "unknown", summary: hashSummary(effect) },
59
- permission: { approvalPolicy: context.approvalPolicy ?? "unknown", approvalAvailable: context.approvalAvailable ?? "unknown", sandboxMode: context.sandboxMode ?? "unknown" },
65
+ permission: { permissionPreset: context.permissionPreset ?? "unknown", serviceSources: context.serviceSources ?? {}, approvalPolicy: context.approvalPolicy ?? "unknown", approvalAvailable: context.approvalAvailable ?? "unknown", sandboxMode: context.sandboxMode ?? "unknown" },
60
66
  baseAction: actionOf(options.baseAction),
61
67
  tolerance: { level: options.tolerance?.level ?? "unknown", changed: options.tolerance?.changed ?? "unknown", action: actionOf(options.tolerance?.kind), severity: finite(options.tolerance?.severity), severityMeaning: "ranking-score-not-calibrated-probability" },
62
68
  pluginReturnedAction: actionOf(options.pluginReturnedAction),
@@ -2,12 +2,29 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  export const TRUSTED_CONTEXT_VERSION = "guard-context-v1";
4
4
 
5
- function service(ctx, name) {
6
- try {
7
- return ctx?.get?.(name);
8
- } catch {
5
+ function rootService(ctx, name) {
6
+ try { return ctx?.get?.(name); } catch { return undefined; }
7
+ }
8
+
9
+ export function guardService(ctx, agent, name, sources = {}) {
10
+ const actor = agent?.ctx;
11
+ if (actor) {
12
+ const presets = rootService(ctx, "agentPresets");
13
+ if (typeof presets?.serviceFor === "function") {
14
+ try {
15
+ const scoped = presets.serviceFor(agent, name);
16
+ if (scoped !== undefined) { sources[name] = "preset"; return scoped; }
17
+ } catch { sources[name] = "preset-error"; return undefined; }
18
+ }
19
+ if (typeof actor.get === "function") {
20
+ try { sources[name] = "actor-host"; return actor.get(name); }
21
+ catch { sources[name] = "actor-error"; return undefined; }
22
+ }
23
+ sources[name] = "actor-unavailable";
9
24
  return undefined;
10
25
  }
26
+ sources[name] = "legacy-host";
27
+ return rootService(ctx, name);
11
28
  }
12
29
 
13
30
  function textOf(message) {
@@ -40,7 +57,9 @@ export async function buildTrustedContext({ agent, ctx, exec, host = {}, downstr
40
57
  messageTarget: { id: "unknown", membership: "unknown", continuable: "unknown", permissions: "unknown", downstreamIndependentApproval: "unknown" },
41
58
  availability,
42
59
  evidence,
60
+ serviceSources: {},
43
61
  };
62
+ const scopedService = (name) => guardService(ctx, agent, name, context.serviceSources);
44
63
  const read = async (field, operation) => {
45
64
  try {
46
65
  const value = await operation();
@@ -62,32 +81,32 @@ export async function buildTrustedContext({ agent, ctx, exec, host = {}, downstr
62
81
  availability.originalUserGoal = { available: false, reason: "no-host-user-message" };
63
82
  }
64
83
  }
65
- const presets = service(ctx, "permissionPresets");
84
+ const presets = scopedService("permissionPresets");
66
85
  const presetName = await read("permissionPreset", () => session && presets?.current?.(session));
67
86
  if (typeof presetName === "string") context.permissionPreset = presetName;
68
87
  const preset = await read("permissionBundle", () => presetName && presets?.resolve?.(presetName));
69
- const approval = service(ctx, "approval");
88
+ const approval = scopedService("approval");
70
89
  const override = await read("approvalOverride", () => session && approval?.overrideOf?.(session));
71
90
  const policy = override ?? preset?.approval;
72
91
  if (policy === "ask" || policy === "never") context.approvalPolicy = policy;
73
92
  context.approvalAvailable = policy === "never" ? false
74
93
  : policy === "ask" && typeof approval?.request === "function" ? true : "unknown";
75
94
  availability.approvalPolicy = { available: context.approvalPolicy !== "unknown", reason: override !== undefined ? "host-session-override" : preset?.approval ? "host-preset" : "host-default-unavailable" };
76
- const sandboxPolicy = service(ctx, "sandboxPolicy");
95
+ const sandboxPolicy = scopedService("sandboxPolicy");
77
96
  const sandbox = await read("sandboxPolicy", () => session && sandboxPolicy?.resolve?.({ session }));
78
97
  const sandboxMode = sandbox?.mode ?? preset?.sandbox;
79
98
  if (["read-only", "workspace-write", "danger-full-access"].includes(sandboxMode)) context.sandboxMode = sandboxMode;
80
99
  if (typeof sandbox?.workspaceRoot === "string") {
81
100
  context.workspaceRoots = [sandbox.workspaceRoot, ...(sandbox.additionalWritableRoots ?? []).filter((root) => typeof root === "string")];
82
101
  }
83
- const tools = service(ctx, "tools");
102
+ const tools = scopedService("tools");
84
103
  if (typeof tools?.executable === "function" && exec?.name) {
85
104
  const definition = await read("toolAccess", () => tools.executable(exec.name, agent, exec.toolSet));
86
105
  context.toolAccess = availability.toolAccess.available ? "available" : availability.toolAccess.reason === "host-field-unavailable" ? "denied" : "unknown";
87
106
  if (definition) evidence.push({ source: "tools.executable", field: "toolAccess", value: "available" });
88
107
  }
89
108
  const args = exec?.arguments;
90
- const fs = service(ctx, "fs");
109
+ const fs = scopedService("fs");
91
110
  if (["write", "edit"].includes(exec?.name) && typeof args?.file_path === "string" && context.workspaceRoots.length && typeof fs?.resolve === "function" && typeof fs?.contains === "function") {
92
111
  const contained = await read("fileTargetScope", async () => {
93
112
  const target = await fs.resolve(args.file_path, { cwd: context.workspaceRoots[0], signal: exec.signal });
@@ -100,7 +119,7 @@ export async function buildTrustedContext({ agent, ctx, exec, host = {}, downstr
100
119
  }
101
120
  }
102
121
  if (exec?.name === "send_message" && typeof args?.agent === "string") {
103
- const subagents = service(ctx, "subagents");
122
+ const subagents = scopedService("subagents");
104
123
  const target = await read("messageTarget", async () => {
105
124
  if (!agent || typeof subagents?.rootOf !== "function" || typeof subagents?.resolveName !== "function" || typeof subagents?.listDescendants !== "function") return undefined;
106
125
  const root = subagents.rootOf(agent);
@@ -110,7 +129,7 @@ export async function buildTrustedContext({ agent, ctx, exec, host = {}, downstr
110
129
  const descendants = await subagents.listDescendants(rootId, exec.signal);
111
130
  const member = descendants.find((entry) => entry.kind === "child" && entry.id === targetId && entry.mode === "continuable");
112
131
  const verified = targetId === rootId || Boolean(member);
113
- const resident = targetId === rootId ? root : service(ctx, "agents")?.get?.(targetId);
132
+ const resident = targetId === rootId ? root : scopedService("agents")?.get?.(targetId);
114
133
  const hostLocal = (resident?.id ?? resident?.session?.id) === targetId;
115
134
  return { id: targetId, membership: verified ? "same-tree" : "unknown", continuable: verified ? true : "unknown", hostLocal, permissions: "unknown", downstreamIndependentApproval: verified && hostLocal && downstreamPolicyEnforced === true ? true : "unknown" };
116
135
  });
@@ -38,6 +38,10 @@ export function classifyToolEffect({ toolName, args, trustedContext = {}, scope
38
38
  }
39
39
  }
40
40
  if (toolName === "working_directory" && fields(args, [])) return known("read_local", "working-directory-query", { recoverability: "no-mutation" });
41
+ if (toolName === "skill" && fields(args, ["name"], ["name"]) && typeof args.name === "string") return known("read_local", "known-skill-document-query", { recoverability: "no-mutation" });
42
+ if (toolName === "cordis_inspect_list" && fields(args, [])) return known("read_local", "known-runtime-schema-query", { recoverability: "no-mutation" });
43
+ if (toolName === "list_subagent_models" && fields(args, ["provider", "model"]) && strings(args, ["provider", "model"])) return known("read_local", "known-model-route-query", { recoverability: "no-mutation" });
44
+ if (toolName === "cordis_inspect_query" && fields(args, ["platform", "provider", "method", "input"], ["platform", "provider", "method"]) && ["host", "client"].includes(args.platform) && strings(args, ["provider", "method"])) return known("read_local", "known-runtime-schema-query", { recoverability: "no-mutation" });
41
45
  if (["read", "read_image", "glob", "grep"].includes(toolName)) {
42
46
  const schemas = {
43
47
  read: ["file_path", "offset", "limit"], read_image: ["file_path"],
@@ -91,6 +95,10 @@ export function classifyToolEffect({ toolName, args, trustedContext = {}, scope
91
95
  }
92
96
  return { ...effect, reasonCodes: ["unparsed-high-impact-execution"] };
93
97
  }
94
- if (toolName === "apply_patch") return { ...effect, reasonCodes: ["unparsed-multi-file-mutation"] };
98
+ if (toolName === "apply_patch") {
99
+ const patch = args.input ?? args.patch;
100
+ if (typeof patch === "string") return known("write_workspace", "patch-file-mutation", { highImpact: true, canonical: "unknown" });
101
+ return { ...effect, reasonCodes: ["unparsed-multi-file-mutation"] };
102
+ }
95
103
  return effect;
96
104
  }
@@ -1,11 +1,12 @@
1
- import { buildTrustedContext } from "./context.js";
1
+ import { buildTrustedContext, guardService } from "./context.js";
2
2
  import { classifyToolEffect } from "./effects.js";
3
3
  import { evaluateMandatoryPolicy } from "./policy.js";
4
- import { guardQuestions, parseModelAdvisory, decideModelAdvisory } from "./advisory.js";
4
+ import { GUARD_QUESTIONS_VERSION, guardQuestions, parseModelAdvisory, decideModelAdvisory } from "./advisory.js";
5
5
  import { createGuardAudit, hashSummary, joinHostOutcome } from "./audit.js";
6
6
  import { classifyWorkspaceTarget } from "../scope.js";
7
7
  import { resolveTolerance, severityOf, toleranceCutoff } from "../tolerance.js";
8
8
  import { redactGuardData, reviewWithFallback } from "./fallback.js";
9
+ import { guardOutcomeMessage } from "./messages.js";
9
10
 
10
11
  export function approvalOutcomeForCall(session, callId, toolName, afterSeq) {
11
12
  if (!Number.isFinite(afterSeq) || !callId || typeof session?.snapshotEvents !== "function") return "unknown";
@@ -18,11 +19,16 @@ export function approvalOutcomeForCall(session, callId, toolName, afterSeq) {
18
19
  } catch { return "unknown"; }
19
20
  }
20
21
 
22
+ const numberOrNull = (value) => Number.isFinite(value) ? value : null;
23
+ const identityOrNull = (value) => typeof value === "string" && value ? redactGuardData(value).replace(/[\r\n\0]/g, " ").slice(0, 256) : null;
24
+ const idleEvaluation = () => ({ status: "not_requested", provider: null, model: null, latencyMs: null, reason: null });
25
+ const automaticReview = (context, config) => context?.permissionPreset === "auto" || config?.tolerance === "autonomous";
26
+
21
27
  export function mountGuard(ctx, deps) {
22
28
  const { getConfig, reloadOwnConfig, effectivePointModes, client, toleranceCalibrator, digestRecent, previewArguments, logDecision, recordGuardDetail } = deps;
23
29
  const pending = new Map();
24
- let auditWriteFailures = 0;
25
30
  const mandatoryByToken = new Map();
31
+ let auditWriteFailures = 0;
26
32
  const toolService = ctx.get?.("tools");
27
33
  if (typeof toolService?.guard === "function") {
28
34
  const dispose = toolService.guard((exec) => {
@@ -30,14 +36,13 @@ export function mountGuard(ctx, deps) {
30
36
  if (!config.enabled || config.guardEnabled === false || config.guardPolicyMode === "observe" || effectivePointModes(config).authorize !== "enforce") return;
31
37
  const recorded = mandatoryByToken.get(exec.token);
32
38
  if (recorded && (recorded.argsHash !== hashSummary(exec.arguments).sha256 || recorded.toolName !== exec.name || recorded.callId !== exec.callId || recorded.sessionId !== exec.agent?.session?.id)) {
33
- return { reason: "decision-engine: reviewed call identity changed", info: { name: "DecisionEngineGuardError", code: "DECISION_ENGINE_REVIEW_CHANGED" } };
39
+ return { reason: "审查后的工具参数或调用身份发生变化,已阻止执行", info: { name: "DecisionEngineGuardError", code: "DECISION_ENGINE_REVIEW_CHANGED" } };
34
40
  }
35
41
  if (recorded?.finalAction === "deny" || recorded?.finalAction === "cancel" || recorded?.status === "deny" || (recorded?.status === "requires_approval" && approvalOutcomeForCall(exec.agent?.session, exec.callId, exec.name, recorded.approvalAfterSeq) !== "allowed-once")) {
36
- return { reason: "decision-engine: mandatory denial cannot be overridden", info: { name: "DecisionEngineGuardError", code: "DECISION_ENGINE_MANDATORY_DENY" } };
42
+ return { reason: "此调用需要本次用户批准,模型或回退策略不能覆盖该权限约束", info: { name: "DecisionEngineGuardError", code: "DECISION_ENGINE_MANDATORY_DENY" } };
37
43
  }
38
- if (!recorded) {
39
- const effect = classifyToolEffect({ toolName: exec.name, args: exec.arguments });
40
- if (effect.highImpact !== false) return { reason: "decision-engine: high-impact call has no mandatory review", info: { name: "DecisionEngineGuardError", code: "DECISION_ENGINE_MISSING_REVIEW" } };
44
+ if (!recorded && classifyToolEffect({ toolName: exec.name, args: exec.arguments }).highImpact !== false) {
45
+ return { reason: "此调用尚未经过完整审查,已阻止执行", info: { name: "DecisionEngineGuardError", code: "DECISION_ENGINE_MISSING_REVIEW" } };
41
46
  }
42
47
  });
43
48
  ctx.effect?.(() => dispose);
@@ -45,12 +50,9 @@ export function mountGuard(ctx, deps) {
45
50
  const keyOf = (exec) => `${exec.agent?.session?.id ?? "unknown"}:${exec.callId ?? "unknown"}`;
46
51
  const emit = async (audit) => {
47
52
  try {
48
- const persisted = await logDecision({ ts: new Date().toISOString(), point: "authorize", tag: "guard", action: audit.pluginReturnedAction ?? "unknown", audit, auditWriteFailures });
53
+ const persisted = await logDecision({ ts: new Date().toISOString(), point: "authorize", tag: "guard", action: audit.pluginReturnedAction, provider: audit.judge?.provider ?? "", model: audit.judge?.model ?? "", audit, auditWriteFailures });
49
54
  if (persisted === false) { auditWriteFailures += 1; ctx.logger?.warn?.("decision-engine: guard audit persistence failed"); }
50
- } catch {
51
- auditWriteFailures += 1;
52
- ctx.logger?.warn?.("decision-engine: guard audit persistence failed");
53
- }
55
+ } catch { auditWriteFailures += 1; ctx.logger?.warn?.("decision-engine: guard audit persistence failed"); }
54
56
  };
55
57
  ctx.on("tools/result", (exec, result) => {
56
58
  const key = keyOf(exec);
@@ -59,11 +61,11 @@ export function mountGuard(ctx, deps) {
59
61
  pending.delete(key);
60
62
  const reviewed = mandatoryByToken.get(exec.token);
61
63
  mandatoryByToken.delete(exec.token);
62
- const approvalOutcome = approvalOutcomeForCall(exec.agent?.session, exec.callId, exec.name, reviewed?.approvalAfterSeq);
63
- void emit(joinHostOutcome(audit, { callId: exec.callId, sessionId: exec.agent?.session?.id, result, approvalOutcome }));
64
+ void emit(joinHostOutcome(audit, { callId: exec.callId, sessionId: exec.agent?.session?.id, result, approvalOutcome: approvalOutcomeForCall(exec.agent?.session, exec.callId, exec.name, reviewed?.approvalAfterSeq) }));
64
65
  });
65
66
 
66
67
  const listener = async (exec, next) => {
68
+ const session = exec.agent?.session;
67
69
  let context;
68
70
  let effect;
69
71
  let mandatory;
@@ -72,118 +74,144 @@ export function mountGuard(ctx, deps) {
72
74
  let baseAction;
73
75
  let tolerated;
74
76
  let fallback;
75
- let state;
77
+ let state = { tool: exec.name, argsSummary: hashSummary(exec.arguments) };
76
78
  let questions;
77
79
  let pointMode = "off";
78
80
  let config;
79
- const session = exec.agent?.session;
80
- const finish = async (action, degradedReason) => {
81
+ let primary = idleEvaluation();
82
+ let secondary = idleEvaluation();
83
+ let decisionSource = "local_policy";
84
+
85
+ const runFallback = async () => {
86
+ const started = Date.now();
87
+ let llm;
88
+ const serviceSources = {};
89
+ llm = guardService(ctx, exec.agent, "llm", serviceSources);
90
+ fallback = await reviewWithFallback({ config, context, mandatory: config.guardPolicyMode === "observe" ? undefined : mandatory, state, advisory, session, llm, signal: exec.signal });
91
+ decisionSource = ["allow", "deny"].includes(fallback.fallback) ? "fallback_policy" : "fallback_model";
92
+ secondary = {
93
+ status: fallback.selection ? (fallback.reason === "fallback-review-failed" || fallback.kind === "cancel" ? "failed" : "completed") : "not_requested",
94
+ provider: identityOrNull(fallback.selection?.provider), model: identityOrNull(fallback.selection?.model),
95
+ latencyMs: fallback.selection ? Date.now() - started : null, reason: fallback.reason, serviceScope: serviceSources.llm ?? "unavailable",
96
+ };
97
+ return fallback;
98
+ };
99
+
100
+ const finish = async (rawAction, degradedReason) => {
101
+ const action = guardOutcomeMessage(rawAction, mandatory);
81
102
  const observing = pointMode !== "enforce";
82
103
  const pluginReturnedAction = observing || action.kind === "delegate" ? "defer_to_host" : action.kind;
83
104
  const reviewed = mandatoryByToken.get(exec.token);
84
105
  if (reviewed && !observing) mandatoryByToken.set(exec.token, { ...reviewed, finalAction: action.kind });
106
+ const requested = [primary, secondary].filter((evaluation) => evaluation.status !== "not_requested");
107
+ const modelEvaluation = { status: requested.length ? (requested.at(-1).status === "failed" ? "failed" : "completed") : "not_requested", primary, fallback: secondary };
108
+ const judge = secondary.provider || secondary.model ? secondary : primary;
109
+ const latencyMs = requested.length ? requested.reduce((total, evaluation) => total + (evaluation.latencyMs ?? 0), 0) : null;
85
110
  const audit = createGuardAudit({
86
- callId: exec.callId, sessionId: session?.id, toolName: exec.name, args: exec.arguments, questions,
87
- time: new Date().toISOString(), cacheStatus: result?.cached ? "hit" : "miss",
88
- context, effect, mandatory, advisory, baseAction: baseAction?.kind ?? action.kind,
89
- tolerance: tolerated, pluginReturnedAction, degradedReason: degradedReason ?? null,
90
- model: result?.model ?? fallback?.selection?.model ?? "unknown",
91
- provider: result?.provider ?? fallback?.selection?.provider ?? "unknown",
92
- cached: result?.cached === true,
111
+ callId: exec.callId, sessionId: session?.id, toolName: exec.name, args: exec.arguments, questions: questions ?? {},
112
+ time: new Date().toISOString(), context, effect, mandatory, advisory, modelEvaluation, decisionSource,
113
+ cacheStatus: primary.status === "cache_hit" ? "hit" : primary.status === "not_requested" ? "not_requested" : "miss",
114
+ baseAction: baseAction?.kind ?? action.kind, tolerance: tolerated, pluginReturnedAction,
115
+ degradedReason: degradedReason ?? null, model: judge.model,
116
+ questionsVersion: questions ? GUARD_QUESTIONS_VERSION : "not_requested",
93
117
  });
94
- const trace = { ...audit, callId: exec.callId ?? "unknown", sessionId: session?.id ?? "unknown", pluginReturnedAction, baseAction: baseAction?.kind ?? action.kind, tolerance: { ...audit.tolerance, level: config?.tolerance ?? "default" }, fallback: fallback ?? null, degradedReason: degradedReason ?? null };
118
+ const trace = { ...audit, judge, modelEvaluation, decisionSource, reasonCode: action.reasonCode, humanReason: action.reason, callId: exec.callId ?? "unknown", sessionId: session?.id ?? "unknown", pluginReturnedAction, baseAction: baseAction?.kind ?? action.kind, tolerance: { ...audit.tolerance, level: config?.tolerance ?? "default" }, fallback: fallback ?? null, degradedReason: degradedReason ?? null };
95
119
  pending.set(keyOf(exec), trace);
96
120
  if (pending.size > 2048) pending.delete(pending.keys().next().value);
97
121
  await emit(trace);
98
- try {
99
- client.recordDecision({ facts: result?.statsFacts, action: action.kind, detail: action.reason ?? action.kind, degraded: Boolean(degradedReason) });
100
- } catch { ctx.logger?.warn?.("decision-engine: guard statistics unavailable"); }
122
+ if (result?.statsFacts !== undefined) {
123
+ try { client.recordDecision({ facts: result.statsFacts, action: action.kind, detail: action.reason, degraded: Boolean(degradedReason) }); }
124
+ catch { ctx.logger?.warn?.("decision-engine: guard statistics unavailable"); }
125
+ }
126
+ const reviewMode = { automatic: automaticReview(context, config), permissionPreset: context?.permissionPreset ?? "unknown", approvalPolicy: context?.approvalPolicy ?? "unknown", approvalAvailable: context?.approvalAvailable ?? "unknown", sandboxMode: context?.sandboxMode ?? "unknown", serviceSources: context?.serviceSources ?? {} };
101
127
  const scores = {
102
- risk_p: advisory?.risk?.confidence ?? null,
103
- raw_decision_confidence: advisory?.risk?.confidence ?? null,
104
- confidenceKind: advisory?.risk?.confidenceKind ?? advisory?.confidenceKind ?? "unavailable",
105
- p_high: advisory?.risk?.probabilities?.high ?? null,
128
+ reviewMode,
129
+ risk_p: advisory?.risk?.confidence ?? null, raw_decision_confidence: advisory?.risk?.confidence ?? null,
130
+ confidenceKind: advisory?.risk?.confidenceKind ?? "unavailable", p_high: advisory?.risk?.probabilities?.high ?? null,
106
131
  p_non_low: advisory?.risk?.probabilities ? advisory.risk.probabilities.high + advisory.risk.probabilities.medium : null,
107
- risk_severity: tolerated?.severity ?? null,
108
- calibrated_p_unsafe: null,
109
- mandatory: mandatory?.status ?? "unknown",
110
- tolerance: tolerated ?? null,
132
+ risk_severity: tolerated?.severity ?? null, calibrated_p_unsafe: null, mandatory: mandatory?.status ?? "unknown",
133
+ tolerance: tolerated ?? null, decisionSource, reasonCode: action.reasonCode, modelEvaluation,
111
134
  };
112
- recordGuardDetail({ session, pointMode, exec, state: { summary: hashSummary(state), contextVersion: context?.contextVersion ?? "unknown", effect: effect?.kind ?? "unknown", mandatory: mandatory?.status ?? "unknown" }, questions: { summary: hashSummary(questions), version: advisory?.metadata?.questionsVersion ?? "unknown" }, result: result ? { ...result, answers: { summary: hashSummary(result.answers), model_result: audit.model_result } } : undefined, action: action.kind, detail: action.reason ?? action.kind, degraded: Boolean(degradedReason), scores });
135
+ const detailResult = requested.length ? {
136
+ provider: judge.provider ?? "", model: judge.model ?? "", latencyMs,
137
+ cached: judge === primary && primary.status === "cache_hit",
138
+ inputTokens: judge === primary ? result?.inputTokens : undefined,
139
+ outputTokens: judge === primary ? result?.outputTokens : undefined,
140
+ answers: { summary: hashSummary(result?.answers ?? null), model_result: audit.model_result, modelEvaluation },
141
+ } : undefined;
142
+ try {
143
+ recordGuardDetail({ session, pointMode, exec,
144
+ state: { summary: hashSummary(state), contextVersion: context?.contextVersion ?? "unavailable", effect: effect?.kind ?? "unclassified", mandatory: mandatory?.status ?? "unknown", modelEvaluation, decisionSource },
145
+ questions: { status: questions ? primary.status : "not_requested", summary: hashSummary(questions ?? {}), version: questions ? GUARD_QUESTIONS_VERSION : "not_requested" },
146
+ result: detailResult, action: action.kind, detail: action.reason, degraded: Boolean(degradedReason), scores,
147
+ });
148
+ } catch { ctx.logger?.warn?.("decision-engine: guard detail persistence failed"); }
113
149
  if (observing || action.kind === "delegate") return next();
114
150
  if (action.kind === "cancel") return { kind: "cancel" };
115
- if (action.kind === "deny") return { kind: "deny", reason: action.reason ?? "decision-engine: mandatory policy denied", info: { name: "DecisionEngineGuardError", code: "DECISION_ENGINE_GUARD_DENY" } };
116
- return { kind: "ask", reason: action.reason ?? "decision-engine: requires approval", ...(action.displayReason ? { displayReason: action.displayReason } : {}) };
151
+ if (action.kind === "deny") return { kind: "deny", reason: action.reason, info: { name: "DecisionEngineGuardError", code: "DECISION_ENGINE_GUARD_DENY", reason: action.reasonCode } };
152
+ return { kind: "ask", reason: action.reason, ...(action.displayReason ? { displayReason: action.displayReason } : {}) };
117
153
  };
154
+
118
155
  try {
119
156
  await reloadOwnConfig();
120
157
  config = getConfig();
121
158
  if (!config.enabled || config.guardEnabled === false) return next();
122
159
  pointMode = effectivePointModes(config).authorize ?? "off";
123
160
  if (pointMode === "off") return next();
124
- if (exec.signal?.aborted) return finish({ kind: "cancel" });
161
+ if (exec.signal?.aborted) return finish({ kind: "cancel", reason: "review-cancelled" });
125
162
  context = await buildTrustedContext({ agent: exec.agent, ctx, exec, host: { downstreamPolicyEnforced: pointMode === "enforce" && config.guardPolicyMode !== "observe" && typeof toolService?.guard === "function" } });
126
163
  const scope = classifyWorkspaceTarget({ tool: exec.name, args: exec.arguments, roots: context.workspaceRoots, cwd: context.cwd ?? session?.header?.cwd });
127
164
  effect = classifyToolEffect({ toolName: exec.name, args: exec.arguments, trustedContext: context, scope });
128
165
  mandatory = evaluateMandatoryPolicy({ context, effect });
166
+ state = { ...state, contextVersion: context.contextVersion, cwd: session?.header?.cwd ?? null, cwdSource: "session-header", workspaceRoots: context.workspaceRoots, effect: { kind: effect.kind, scope: scope.kind }, permissionContext: { mandatory: mandatory.status, approvalPolicy: context.approvalPolicy, sandboxMode: context.sandboxMode }, reasonCodes: mandatory.reasonCodes };
129
167
  if (exec.token !== undefined) {
130
168
  let approvalAfterSeq;
131
- try {
132
- approvalAfterSeq = session.snapshotEvents().reduce((last, event) => Number.isFinite(event.seq) ? Math.max(last, event.seq) : last, -1);
133
- } catch { approvalAfterSeq = undefined; }
169
+ try { approvalAfterSeq = session.snapshotEvents().reduce((last, event) => Number.isFinite(event.seq) ? Math.max(last, event.seq) : last, -1); } catch { approvalAfterSeq = undefined; }
134
170
  mandatoryByToken.set(exec.token, { ...mandatory, approvalAfterSeq, argsHash: hashSummary(exec.arguments).sha256, toolName: exec.name, callId: exec.callId, sessionId: session?.id });
135
171
  if (mandatoryByToken.size > 2048) mandatoryByToken.delete(mandatoryByToken.keys().next().value);
136
172
  }
137
173
  const enforcingPolicy = config.guardPolicyMode !== "observe";
138
- if (enforcingPolicy && mandatory.status === "deny") return finish({ kind: "deny", reason: mandatory.reasonCodes.join(", ") });
139
- if (enforcingPolicy && mandatory.status === "requires_approval") {
140
- return finish({ kind: mandatory.approvalAvailable === true ? "ask" : "deny", reason: mandatory.reasonCodes.join(", ") });
141
- }
142
- if (mandatory.status === "safe" && ["read_local", "internal_message", "internal_state"].includes(effect.kind)) {
143
- return finish({ kind: "delegate", reason: "verified-local-operation-defer-to-host" });
144
- }
145
- state = redactGuardData({
146
- tool: exec.name,
147
- args_preview: previewArguments(redactGuardData(exec.arguments), config.argsHeadChars, config.argsTailChars),
148
- recent: digestRecent(session, config.recentMessages, config.recentMessageChars),
149
- recentAuthority: "untrusted-context-only",
150
- trustedContext: { originalUserGoal: context.originalUserGoal ?? "unknown", goalSource: context.goalSource },
151
- goalSource: context.goalSource,
152
- effect: { kind: effect.kind, scope: scope.kind },
153
- authorization: { mandatory: mandatory.status, approvalPolicy: context.approvalPolicy, sandboxMode: context.sandboxMode },
154
- contextVersion: context.contextVersion,
155
- });
156
- const budget = Math.max(500, Number(config.maxStateChars) || 6000);
157
- const sliceBudget = Math.floor(budget / 5);
174
+ if (enforcingPolicy && mandatory.status === "deny") return finish({ kind: "deny", reason: mandatory.reasonCodes[0] });
175
+ if (enforcingPolicy && mandatory.status === "requires_approval") return finish({ kind: mandatory.approvalAvailable === true ? "ask" : "deny", reason: mandatory.reasonCodes[0] });
176
+ if (mandatory.status === "safe" && ["read_local", "internal_message", "internal_state"].includes(effect.kind)) return finish({ kind: "delegate", reason: "verified-local-operation-defer-to-host" });
177
+
178
+ state = redactGuardData({ ...state, args_preview: previewArguments(redactGuardData(exec.arguments), config.argsHeadChars, config.argsTailChars), recent: digestRecent(session, config.recentMessages, config.recentMessageChars), recentAuthority: "untrusted-context-only", trustedContext: { originalUserGoal: context.originalUserGoal ?? "unknown", goalSource: context.goalSource } });
179
+ const sliceBudget = Math.floor(Math.max(500, Number(config.maxStateChars) || 6000) / 5);
158
180
  state.recent = String(state.recent ?? "").slice(0, sliceBudget);
159
181
  state.args_preview = String(state.args_preview ?? "").slice(0, sliceBudget);
160
182
  state.trustedContext.originalUserGoal = String(state.trustedContext.originalUserGoal).slice(0, sliceBudget);
161
183
  questions = guardQuestions();
162
- result = await client.classify({ point: "authorize", tag: "guard", mode: config.mode, pointMode, tool: exec.name, state, questions, signal: exec.signal });
163
- const degradedReason = result === null || result?.degraded ? result?.degraded ?? "classify-unavailable" : null;
164
- advisory = degradedReason ? undefined : parseModelAdvisory(result.answers);
184
+ const primaryStarted = Date.now();
185
+ try {
186
+ result = await client.classify({ point: "authorize", tag: "guard", mode: config.mode, pointMode, tool: exec.name, state, questions, signal: exec.signal });
187
+ advisory = result && !result.degraded ? parseModelAdvisory(result.answers) : undefined;
188
+ primary = { status: advisory?.status === "valid" ? (result.cached ? "cache_hit" : "completed") : "failed", provider: identityOrNull(result?.provider), model: identityOrNull(result?.model), latencyMs: numberOrNull(result?.latencyMs) ?? Date.now() - primaryStarted, reason: result?.degraded ?? (advisory?.status === "valid" ? null : "answer-shape-mismatch"), validationErrors: advisory?.errors ?? [], metadataWarnings: advisory?.metadata?.warnings ?? [], probabilityStatus: advisory?.metadata?.probabilityStatus ?? "unavailable" };
189
+ } catch {
190
+ primary = { ...idleEvaluation(), status: "failed", latencyMs: Date.now() - primaryStarted, reason: "primary-review-exception" };
191
+ }
192
+ if (exec.signal?.aborted) return finish({ kind: "cancel", reason: "review-cancelled" });
193
+ const degradedReason = primary.status === "failed" ? primary.reason : undefined;
165
194
  baseAction = advisory?.status === "valid" ? decideModelAdvisory(advisory, config) : { kind: "ask", reason: "advisory-unavailable" };
166
195
  const severity = advisory?.status === "valid" ? severityOf(advisory, { injectionFloorThreshold: config.guardInjectionAskThreshold }) : 1;
167
196
  if (advisory?.status === "valid") toleranceCalibrator.observe(result.provider ?? "", result.model, severity);
168
197
  const cutoffInfo = toleranceCutoff({ level: config.tolerance, calibrator: toleranceCalibrator, provider: result?.provider ?? "", model: result?.model });
169
198
  tolerated = resolveTolerance({ level: config.tolerance, legacyKind: baseAction.kind, severity, cutoff: cutoffInfo.cutoff, mandatory: enforcingPolicy ? mandatory : undefined, approvalAvailable: context.approvalAvailable });
170
199
  let action = tolerated.kind === baseAction.kind ? baseAction : { kind: tolerated.kind, reason: tolerated.note };
171
- const automatic = context.permissionPreset === "auto" || config.tolerance === "autonomous";
172
- if (automatic && (baseAction.kind === "ask" || degradedReason || advisory?.status !== "valid")) {
173
- fallback = await reviewWithFallback({ config, context, mandatory: enforcingPolicy ? mandatory : undefined, state, advisory, session, llm: ctx.get?.("llm"), signal: exec.signal });
174
- action = fallback;
175
- } else if (advisory?.status !== "valid" && mandatory.status !== "safe") {
200
+ decisionSource = "primary_model";
201
+ const unresolved = mandatory.status === "unknown";
202
+ if (automaticReview(context, config) && (unresolved || baseAction.kind === "ask" || action.kind === "ask" || primary.status === "failed")) {
203
+ action = await runFallback();
204
+ } else if (primary.status === "failed") {
176
205
  action = { kind: context.approvalAvailable === true ? "ask" : "deny", reason: "advisory-failed-unknown-effect" };
206
+ } else if (action.kind === "ask" && context.approvalPolicy === "never") {
207
+ action = { ...action, kind: "deny" };
177
208
  }
178
- return finish(action, degradedReason ?? (advisory?.status !== "valid" ? "answer-shape-mismatch" : undefined));
209
+ return finish(action, degradedReason);
179
210
  } catch {
180
- ctx.logger?.warn?.("decision-engine: guard failed; unsafe or unknown effects remain closed");
211
+ ctx.logger?.warn?.("decision-engine: guard failed; explicit permission constraints remain protected");
181
212
  if (pointMode === "off") return next();
182
- if (mandatory?.status === "safe" && (context?.permissionPreset === "auto" || config?.tolerance === "autonomous")) {
183
- fallback = await reviewWithFallback({ config, context, mandatory, state: state ?? { tool: exec.name }, session, llm: ctx.get?.("llm"), signal: exec.signal });
184
- return finish(fallback, "guard-exception");
185
- }
186
- return finish({ kind: mandatory?.status === "safe" && context?.approvalAvailable === true ? "ask" : "deny", reason: "guard-exception" }, "guard-exception");
213
+ if (["safe", "unknown"].includes(mandatory?.status) && automaticReview(context, config)) return finish(await runFallback(), "guard-exception");
214
+ return finish({ kind: ["safe", "unknown"].includes(mandatory?.status) && context?.approvalAvailable === true ? "ask" : "deny", reason: "guard-exception" }, "guard-exception");
187
215
  }
188
216
  };
189
217
  ctx.on("tools/pre-execute", listener);
@@ -0,0 +1,31 @@
1
+ const MESSAGES = {
2
+ "host-tool-access-denied": ["宿主未授权此工具,已阻止调用", "The host has not authorized this tool"],
3
+ "recipient-authority-unverified": ["无法核验接收方权限,需要用户确认", "The recipient's authority could not be verified"],
4
+ "high-impact-requires-explicit-approval": ["此操作涉及提权或破坏性变更,需要用户明确批准", "Privilege escalation or destructive changes require explicit approval"],
5
+ "write-exceeds-read-only-permission": ["当前为只读权限,写入需要用户明确批准", "Writing exceeds the current read-only permission"],
6
+ "write-outside-authorized-workspace": ["写入目标超出已授权工作区,需要用户明确批准", "Writing outside the authorized workspace requires explicit approval"],
7
+ "verified-local-operation-defer-to-host": ["本地规则检查通过,未调用模型;仍需遵守宿主权限", "Local checks passed without a model call; host permissions still apply"],
8
+ "model-material-risk": ["决策模型提示可能有风险,需要进一步审查", "The decision model flagged potential risk for further review"],
9
+ "model-injection-suspect": ["决策模型发现疑似提示注入,需要进一步审查", "The decision model flagged suspected prompt injection"],
10
+ "model-irreversible-unrelated": ["调用可能不可恢复或偏离任务,需要进一步审查", "Potential irreversible or unrelated effects need further review"],
11
+ "model-low-risk-advisory": ["决策模型评估为低风险,已交由宿主执行权限检查", "The decision model assessed low risk; host permissions still apply"],
12
+ "unresolved-effect-needs-model-review": ["无法静态判断工具副作用,需模型复核,不代表需要权限升级", "The tool effect needs model review; uncertainty is not a permission escalation"],
13
+ "fallback-model-allow": ["回退大模型已复核并允许此次调用,宿主权限约束仍然生效", "The fallback model allowed this call; host permissions still apply"],
14
+ "fallback-model-deny": ["回退大模型已复核并拒绝此次调用", "The fallback model denied this call"],
15
+ "fallback-allow-soft-review-only": ["已按回退策略放行软审查,未授予额外宿主权限", "The fallback policy allowed soft review without granting additional host permissions"],
16
+ "fallback-deny": ["已按回退策略阻止此次调用", "The fallback policy denied this call"],
17
+ "fallback-model-unavailable": ["回退模型未配置或宿主模型服务不可用,已安全阻止调用", "The fallback route or host model service is unavailable; the call was denied"],
18
+ "fallback-review-failed": ["回退大模型请求失败、超时或回答无效,已安全阻止调用", "Fallback review failed, timed out or returned an invalid answer; the call was denied"],
19
+ "mandatory-policy-protected": ["此调用受到明确权限约束,回退策略不能代替用户批准", "Fallback review cannot replace approval required by an explicit permission constraint"],
20
+ "advisory-unavailable": ["决策模型未能给出有效结果,需要进一步审查", "The decision model did not return a valid result"],
21
+ "advisory-failed-unknown-effect": ["模型审查失败,无法确认此工具的副作用", "Model review failed and the tool's effects remain unresolved"],
22
+ "guard-exception": ["审查链发生内部错误,未将错误视为授权", "The review pipeline failed; an error does not grant authorization"],
23
+ "review-cancelled": ["审查已取消", "Review was cancelled"],
24
+ };
25
+
26
+ export function guardOutcomeMessage(action, mandatory) {
27
+ const code = action.reason ?? mandatory?.reasonCodes?.[0] ?? action.kind;
28
+ const message = MESSAGES[code];
29
+ if (!message) return { ...action, reasonCode: code };
30
+ return { ...action, reason: message[0], reasonCode: code, ...(action.kind === "ask" ? { displayReason: { zh: message[0], en: message[1] } } : {}) };
31
+ }
@@ -20,9 +20,9 @@ export function evaluateMandatoryPolicy({ context = {}, effect = {} } = {}) {
20
20
  if (["workspace-write", "danger-full-access"].includes(context.sandboxMode) && effect.canonical === "inside" && effect.known === true) {
21
21
  return result("safe", "bounded-canonical-workspace-write-defer-to-host");
22
22
  }
23
- return result(effect.highImpact === true ? "requires_approval" : "unknown", "workspace-write-authority-incomplete");
23
+ return result("unknown", "workspace-write-authority-incomplete");
24
24
  }
25
- if (effect.kind === "external_network") return result("requires_approval", "external-effect-authorization-unavailable");
26
- if (effect.highImpact === true) return result("requires_approval", "unknown-high-impact-effect-requires-approval");
25
+ if (effect.kind === "external_network") return result("unknown", "external-effect-needs-model-review");
26
+ if (effect.highImpact === true) return result("unknown", "unresolved-effect-needs-model-review");
27
27
  return result("unknown", "effect-or-permission-unverified");
28
28
  }
package/lib/scope.js CHANGED
@@ -54,15 +54,14 @@ export function classifyWorkspaceTarget(options) {
54
54
  return { kind: SCOPE_KINDS.unknown, targets, coverage: "unknown", canonical: "unknown" };
55
55
  }
56
56
  const resolved = targets.map((target) => absolutePath(target, options.cwd));
57
- if (resolved.some((target) => !target)) {
58
- return { kind: SCOPE_KINDS.unknown, targets, coverage: "partial", canonical: "unknown" };
59
- }
60
- const outside = targets.filter((target) => !isUnderRoots(target, roots, options.cwd));
57
+ const partial = resolved.some((target) => !target);
58
+ const outside = targets.filter((target, index) => resolved[index] && !isUnderRoots(target, roots, options.cwd));
59
+ if (!outside.length && partial) return { kind: SCOPE_KINDS.unknown, targets, coverage: "partial", canonical: "unknown" };
61
60
  return {
62
61
  kind: outside.length ? SCOPE_KINDS.outsideWorkspace : SCOPE_NONE,
63
62
  targets,
64
63
  outside,
65
- coverage: "lexical-only",
64
+ coverage: partial ? "partial" : "lexical-only",
66
65
  canonical: "unknown",
67
66
  };
68
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leonhardthust/dsh-decision-engine",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — a small decision model answers structured questions before the main model acts, so calls can be allowed, escalated, rerouted to a cheaper model, or skipped. All eight decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, COT translation) are implemented and individually switchable. Works against any service exposing a decision endpoint: built-in presets (system one, bamboonode, SiliconFlow, Silicon Flow) or a custom base URL.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",