@leonhardthust/dsh-decision-engine 1.9.0 → 1.9.1

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,33 @@
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
+ ## 验证边界
30
+
31
+ 125 项专项回归、212 项组合回归通过;24 个独立合成情景的四档矩阵通过。组合之间有重叠,不相加成独立样本数。模型 stub 的 allow/deny/failure 仅验证路由不变量,不代表真实危险 FNR 或概率校准。
32
+
33
+ 本次按 hunk 集成决策历史状态展示,不覆盖其他任务的 dark-mode UI 改动;其他任务是否已提交以 Git 记录为准。发布仍需干净提交的 Linux 完整 CI 成功;已安装的 1.9.0 不会因源码修改自动升级。
package/docs/releasing.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # npm 发布
2
2
 
3
- 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.9.0`,公开包。
3
+ 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.9.1`,公开包。
4
4
 
5
5
  本分支基于 `zuosus/dsh-decision-engine` 的 MIT 代码,保留原作者与许可证;npm scope、源码仓库、bundle 引用和浏览器主模块 / charts chunk 标识已同步为本分支身份。内部 API、样式标识和用户数据目录保留旧名称以兼容已有配置。不要同时启用原包与本 scoped fork,它们提供相同的服务与页面。
6
6
 
@@ -8,28 +8,28 @@
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
10
  - `.github/workflows/publish.yml`:版本 tag push 或 main 上手动运行。只有 `LeonhardtHUST/dsh-decision-engine` 可以进入发布;发布必须等待上述 CI 成功。
11
- - 手动运行必须填写与 manifest 完全一致的 `v1.9.0`。后续发版同步修改 manifest 与 tag。预发布版本不能覆盖 npm `latest`。
11
+ - 手动运行必须填写与 manifest 完全一致的 `v1.9.1`。后续发版同步修改 manifest 与 tag。预发布版本不能覆盖 npm `latest`。
12
12
  - checkout 与 setup-vp Action 固定到已核对的 commit SHA;不通过 `pull_request_target` 运行不可信 PR。
13
- - 验证任务只有 `contents: read`;只有发布 job 有 `id-token: write`。代码 checkout 不保留 Git 凭据。此工作流不会自动修改版本、提交源码或推送 Git。
13
+ - 验证任务只有 `contents: read`;只有发布 job 有 `id-token: write`。发布 step 只使用 OIDC(trusted publishing),不再读取任何 npm token secret。代码 checkout 不保留 Git 凭据。此工作流不会自动修改版本、提交源码或推送 Git。
14
14
 
15
- ## npm 授权
15
+ ## npm 授权:一次性登记,工作流只用 OIDC
16
16
 
17
- 首次发包通常需要先用有 `@leonhardthust` scope 发布权限的账号登录:
17
+ 发布工作流**只走 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 的做法已废弃。
18
+
19
+ 首次登记是一次性、由有 `@leonhardthust` scope 发布权限的账号手动执行的动作。npm 的信任关系管理 API 要求 2FA/一次性口令(OTP),非交互环境只会返回 `EOTP`,因此必须在能弹出浏览器完成验证的终端里跑:
18
20
 
19
21
  ```powershell
20
- $env:HTTPS_PROXY = "http://localhost:7897"
21
22
  $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/"
23
+ $env:HTTPS_PROXY = "http://localhost:7897"
24
+ $env:npm_config_cache = Join-Path (Get-Location) "release-artifacts/npm-cache"
25
+ npm trust github @leonhardthust/dsh-decision-engine --file publish.yml --repository LeonhardtHUST/dsh-decision-engine --allow-publish --registry "https://registry.npmjs.org/" --yes
25
26
  ```
26
27
 
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/发布策略必须允许该包发布;未授权会明确失败,不静默换身份。
28
+ - 该命令登记 owner/repo/workflow 三元组;同一关系重复登记只是覆盖,不需要先 `revoke`。它不接受把 `--cache` 当位置参数,缓存只用进程环境变量 `npm_config_cache` 指定;registry 始终显式写 npmjs 官方地址。
29
+ - 回读用 `npm trust list @leonhardthust/dsh-decision-engine`,但 **list 与 create 一样会触发 OTP 挑战**。非交互环境返回 `EOTP` 属预期:此时登记状态无法从命令行独立核验,只能查看 npm 包设置页的 publishing access,或观察下一次 OIDC 发布的结果。不要为回读反复重发挑战,不要打开 CLI 打印的 `doneUrl`,也不要把挑战换回的 token 读取、复制或落盘。
30
+ - 登记后不要再配置长期 `NPM_TOKEN` 发布路径;若需回退,改用下文的手动发布,不要在工作流里重新注入长期 token。
31
+ - 认证直接调用原生 npm,不经过 `vp pm login` 的参数透传;已观察到该透传把 registry 错误解析成 `https` 后回落到镜像。仓库依赖与验证任务继续由 vp 管理。CLI 必须显示 npmjs 官方 registry;出现 npmmirror 等镜像时立即取消。代理变量只在本次进程内生效。不要把 npm token 贴到聊天、提交到源码或打印到 CI 日志。网页登录和二次验证由用户完成。
32
+ - 工作流要求 runner 自带的 npm CLI 支持 trusted publishing(最低 npm 11.5.1;管理登记命令已核对本机 npm 11.20.0);OIDC 发布必须带 `--provenance`。
33
33
 
34
34
  不要把仅有 GitHub 仓库 ADMIN 权限理解成拥有 npm scope 权限。
35
35
 
@@ -41,7 +41,7 @@ vp run verify
41
41
  vp run verify:guard
42
42
  vp run pack:release
43
43
  vp run verify:package
44
- $env:RELEASE_TAG = "v1.9.0"
44
+ $env:RELEASE_TAG = "v1.9.1"
45
45
  vp run verify:release
46
46
  ```
47
47
 
@@ -53,10 +53,12 @@ vp run verify:release
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.1.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
 
@@ -64,4 +66,4 @@ npm view @leonhardthust/dsh-decision-engine version --registry "https://registry
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.1`。安装和启用是另外的用户动作,发布工作流不会修改本机 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),
@@ -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),
@@ -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
1
  import { buildTrustedContext } 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,139 @@ 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
+ try { llm = ctx.get?.("llm"); } catch { llm = undefined; }
89
+ fallback = await reviewWithFallback({ config, context, mandatory: config.guardPolicyMode === "observe" ? undefined : mandatory, state, advisory, session, llm, signal: exec.signal });
90
+ decisionSource = ["allow", "deny"].includes(fallback.fallback) ? "fallback_policy" : "fallback_model";
91
+ secondary = {
92
+ status: fallback.selection ? (fallback.reason === "fallback-review-failed" || fallback.kind === "cancel" ? "failed" : "completed") : "not_requested",
93
+ provider: identityOrNull(fallback.selection?.provider), model: identityOrNull(fallback.selection?.model),
94
+ latencyMs: fallback.selection ? Date.now() - started : null, reason: fallback.reason,
95
+ };
96
+ return fallback;
97
+ };
98
+
99
+ const finish = async (rawAction, degradedReason) => {
100
+ const action = guardOutcomeMessage(rawAction, mandatory);
81
101
  const observing = pointMode !== "enforce";
82
102
  const pluginReturnedAction = observing || action.kind === "delegate" ? "defer_to_host" : action.kind;
83
103
  const reviewed = mandatoryByToken.get(exec.token);
84
104
  if (reviewed && !observing) mandatoryByToken.set(exec.token, { ...reviewed, finalAction: action.kind });
105
+ const requested = [primary, secondary].filter((evaluation) => evaluation.status !== "not_requested");
106
+ const modelEvaluation = { status: requested.length ? (requested.at(-1).status === "failed" ? "failed" : "completed") : "not_requested", primary, fallback: secondary };
107
+ const judge = secondary.provider || secondary.model ? secondary : primary;
108
+ const latencyMs = requested.length ? requested.reduce((total, evaluation) => total + (evaluation.latencyMs ?? 0), 0) : null;
85
109
  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,
110
+ callId: exec.callId, sessionId: session?.id, toolName: exec.name, args: exec.arguments, questions: questions ?? {},
111
+ time: new Date().toISOString(), context, effect, mandatory, advisory, modelEvaluation, decisionSource,
112
+ cacheStatus: primary.status === "cache_hit" ? "hit" : primary.status === "not_requested" ? "not_requested" : "miss",
113
+ baseAction: baseAction?.kind ?? action.kind, tolerance: tolerated, pluginReturnedAction,
114
+ degradedReason: degradedReason ?? null, model: judge.model,
115
+ questionsVersion: questions ? GUARD_QUESTIONS_VERSION : "not_requested",
93
116
  });
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 };
117
+ 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
118
  pending.set(keyOf(exec), trace);
96
119
  if (pending.size > 2048) pending.delete(pending.keys().next().value);
97
120
  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"); }
121
+ if (result?.statsFacts !== undefined) {
122
+ try { client.recordDecision({ facts: result.statsFacts, action: action.kind, detail: action.reason, degraded: Boolean(degradedReason) }); }
123
+ catch { ctx.logger?.warn?.("decision-engine: guard statistics unavailable"); }
124
+ }
101
125
  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,
126
+ risk_p: advisory?.risk?.confidence ?? null, raw_decision_confidence: advisory?.risk?.confidence ?? null,
127
+ confidenceKind: advisory?.risk?.confidenceKind ?? "unavailable", p_high: advisory?.risk?.probabilities?.high ?? null,
106
128
  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,
129
+ risk_severity: tolerated?.severity ?? null, calibrated_p_unsafe: null, mandatory: mandatory?.status ?? "unknown",
130
+ tolerance: tolerated ?? null, decisionSource, reasonCode: action.reasonCode, modelEvaluation,
111
131
  };
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 });
132
+ const detailResult = requested.length ? {
133
+ provider: judge.provider ?? "", model: judge.model ?? "", latencyMs,
134
+ cached: judge === primary && primary.status === "cache_hit",
135
+ inputTokens: judge === primary ? result?.inputTokens : undefined,
136
+ outputTokens: judge === primary ? result?.outputTokens : undefined,
137
+ answers: { summary: hashSummary(result?.answers ?? null), model_result: audit.model_result, modelEvaluation },
138
+ } : undefined;
139
+ try {
140
+ recordGuardDetail({ session, pointMode, exec,
141
+ state: { summary: hashSummary(state), contextVersion: context?.contextVersion ?? "unavailable", effect: effect?.kind ?? "unclassified", mandatory: mandatory?.status ?? "unknown", modelEvaluation, decisionSource },
142
+ questions: { status: questions ? primary.status : "not_requested", summary: hashSummary(questions ?? {}), version: questions ? GUARD_QUESTIONS_VERSION : "not_requested" },
143
+ result: detailResult, action: action.kind, detail: action.reason, degraded: Boolean(degradedReason), scores,
144
+ });
145
+ } catch { ctx.logger?.warn?.("decision-engine: guard detail persistence failed"); }
113
146
  if (observing || action.kind === "delegate") return next();
114
147
  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 } : {}) };
148
+ if (action.kind === "deny") return { kind: "deny", reason: action.reason, info: { name: "DecisionEngineGuardError", code: "DECISION_ENGINE_GUARD_DENY", reason: action.reasonCode } };
149
+ return { kind: "ask", reason: action.reason, ...(action.displayReason ? { displayReason: action.displayReason } : {}) };
117
150
  };
151
+
118
152
  try {
119
153
  await reloadOwnConfig();
120
154
  config = getConfig();
121
155
  if (!config.enabled || config.guardEnabled === false) return next();
122
156
  pointMode = effectivePointModes(config).authorize ?? "off";
123
157
  if (pointMode === "off") return next();
124
- if (exec.signal?.aborted) return finish({ kind: "cancel" });
158
+ if (exec.signal?.aborted) return finish({ kind: "cancel", reason: "review-cancelled" });
125
159
  context = await buildTrustedContext({ agent: exec.agent, ctx, exec, host: { downstreamPolicyEnforced: pointMode === "enforce" && config.guardPolicyMode !== "observe" && typeof toolService?.guard === "function" } });
126
160
  const scope = classifyWorkspaceTarget({ tool: exec.name, args: exec.arguments, roots: context.workspaceRoots, cwd: context.cwd ?? session?.header?.cwd });
127
161
  effect = classifyToolEffect({ toolName: exec.name, args: exec.arguments, trustedContext: context, scope });
128
162
  mandatory = evaluateMandatoryPolicy({ context, effect });
163
+ 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
164
  if (exec.token !== undefined) {
130
165
  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; }
166
+ try { approvalAfterSeq = session.snapshotEvents().reduce((last, event) => Number.isFinite(event.seq) ? Math.max(last, event.seq) : last, -1); } catch { approvalAfterSeq = undefined; }
134
167
  mandatoryByToken.set(exec.token, { ...mandatory, approvalAfterSeq, argsHash: hashSummary(exec.arguments).sha256, toolName: exec.name, callId: exec.callId, sessionId: session?.id });
135
168
  if (mandatoryByToken.size > 2048) mandatoryByToken.delete(mandatoryByToken.keys().next().value);
136
169
  }
137
170
  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);
171
+ if (enforcingPolicy && mandatory.status === "deny") return finish({ kind: "deny", reason: mandatory.reasonCodes[0] });
172
+ if (enforcingPolicy && mandatory.status === "requires_approval") return finish({ kind: mandatory.approvalAvailable === true ? "ask" : "deny", reason: mandatory.reasonCodes[0] });
173
+ if (mandatory.status === "safe" && ["read_local", "internal_message", "internal_state"].includes(effect.kind)) return finish({ kind: "delegate", reason: "verified-local-operation-defer-to-host" });
174
+
175
+ 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 } });
176
+ const sliceBudget = Math.floor(Math.max(500, Number(config.maxStateChars) || 6000) / 5);
158
177
  state.recent = String(state.recent ?? "").slice(0, sliceBudget);
159
178
  state.args_preview = String(state.args_preview ?? "").slice(0, sliceBudget);
160
179
  state.trustedContext.originalUserGoal = String(state.trustedContext.originalUserGoal).slice(0, sliceBudget);
161
180
  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);
181
+ const primaryStarted = Date.now();
182
+ try {
183
+ result = await client.classify({ point: "authorize", tag: "guard", mode: config.mode, pointMode, tool: exec.name, state, questions, signal: exec.signal });
184
+ advisory = result && !result.degraded ? parseModelAdvisory(result.answers) : undefined;
185
+ 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") };
186
+ } catch {
187
+ primary = { ...idleEvaluation(), status: "failed", latencyMs: Date.now() - primaryStarted, reason: "primary-review-exception" };
188
+ }
189
+ if (exec.signal?.aborted) return finish({ kind: "cancel", reason: "review-cancelled" });
190
+ const degradedReason = primary.status === "failed" ? primary.reason : undefined;
165
191
  baseAction = advisory?.status === "valid" ? decideModelAdvisory(advisory, config) : { kind: "ask", reason: "advisory-unavailable" };
166
192
  const severity = advisory?.status === "valid" ? severityOf(advisory, { injectionFloorThreshold: config.guardInjectionAskThreshold }) : 1;
167
193
  if (advisory?.status === "valid") toleranceCalibrator.observe(result.provider ?? "", result.model, severity);
168
194
  const cutoffInfo = toleranceCutoff({ level: config.tolerance, calibrator: toleranceCalibrator, provider: result?.provider ?? "", model: result?.model });
169
195
  tolerated = resolveTolerance({ level: config.tolerance, legacyKind: baseAction.kind, severity, cutoff: cutoffInfo.cutoff, mandatory: enforcingPolicy ? mandatory : undefined, approvalAvailable: context.approvalAvailable });
170
196
  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") {
176
- action = { kind: context.approvalAvailable === true ? "ask" : "deny", reason: "advisory-failed-unknown-effect" };
197
+ decisionSource = "primary_model";
198
+ const unresolved = mandatory.status === "unknown";
199
+ if (automaticReview(context, config) && (unresolved || baseAction.kind === "ask" || action.kind === "ask" || primary.status === "failed")) {
200
+ action = await runFallback();
201
+ } else if (unresolved || primary.status === "failed") {
202
+ action = { kind: context.approvalAvailable === true ? "ask" : "deny", reason: unresolved ? "unresolved-effect-needs-model-review" : "advisory-failed-unknown-effect" };
177
203
  }
178
- return finish(action, degradedReason ?? (advisory?.status !== "valid" ? "answer-shape-mismatch" : undefined));
204
+ return finish(action, degradedReason);
179
205
  } catch {
180
- ctx.logger?.warn?.("decision-engine: guard failed; unsafe or unknown effects remain closed");
206
+ ctx.logger?.warn?.("decision-engine: guard failed; explicit permission constraints remain protected");
181
207
  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");
208
+ if (["safe", "unknown"].includes(mandatory?.status) && automaticReview(context, config)) return finish(await runFallback(), "guard-exception");
209
+ return finish({ kind: ["safe", "unknown"].includes(mandatory?.status) && context?.approvalAvailable === true ? "ask" : "deny", reason: "guard-exception" }, "guard-exception");
187
210
  }
188
211
  };
189
212
  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.1",
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",