@leonhardthust/dsh-decision-engine 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.example.yml +2 -0
- package/docs/audits/2026-11-15-approval-log-audit.md +329 -0
- package/docs/releasing.md +4 -4
- package/lib/guard/listener.js +64 -30
- package/lib/guard/messages.js +17 -7
- package/package.json +1 -1
package/config.example.yml
CHANGED
|
@@ -407,6 +407,8 @@ guardFallbackReasoningEffort: ""
|
|
|
407
407
|
# 本次调用的宿主 allowed-once,没有审批通道时拒绝,模型不能代批。
|
|
408
408
|
# autonomous 也不是「关掉审查」:在宿主 auto 审查或本档下,软性 ask 会交给
|
|
409
409
|
# 上面的 guardFallback 裁决(失败即拒绝),因此它可能比 default 更严。
|
|
410
|
+
# 审批通道为 never 时:mandatory=safe 的调用直接交宿主沙箱;其它 ask 交给
|
|
411
|
+
# guardFallback(未配置/失败仍拒绝)。强制 requires_approval / deny 不变。
|
|
410
412
|
# severity 是排序分数,分位数就绪不代表危险概率已校准;冷启动比校准后更安静。
|
|
411
413
|
# tolerance: default
|
|
412
414
|
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# 本地审批日志审计:分版本问题清单与解决方案
|
|
2
|
+
|
|
3
|
+
> 审计对象:本机 `~/.dsh/dsh-decision-engine/` 下的授权(`point=authorize`)日志与遥测。
|
|
4
|
+
> 方法:只读分析 + 代码交叉验证;不改插件源码、不改配置、不推送。
|
|
5
|
+
> 数据快照:2026-11-15 17:06–17:21(明细持续追加,数字按快照固定)。
|
|
6
|
+
> 分析脚本与两份原始分析记录:`tmp-audit/`(未入库)。
|
|
7
|
+
|
|
8
|
+
## 0. 结论速览
|
|
9
|
+
|
|
10
|
+
1. **审批日志本身没有版本字段**,所以本次按「npm 发布时间 + 本机安装清单 + 日志 schema 断点」推断版本窗口;1.14.0 已发布(17:14)但本机仍装 1.13.0(16:09 安装),1.14.0 无运行数据。
|
|
11
|
+
2. **1.9.0–1.9.1 是误拒重灾区**:拒绝率 15.3% → 23.2%,主因分别是「unknown 一律按需审批」和「模型答案形状不符且无回退」;1.9.2 加回退后降到 2.8%。
|
|
12
|
+
3. **1.9.x 曾把 27 次 ask 丢进「没有审批通道」的会话,100% 被宿主直接拒绝**(edit 26、write 1);1.11.2 修复后不再出现。
|
|
13
|
+
4. **当前 1.13.0 的主要问题不再是「问太多」,而是「直接拒」**:0 次 ask、25 次 deny,其中 **10 次发生在插件自己判定 `mandatory=safe` 的 write/edit 上**;审计期间(17:09–17:18)现场就发生 14 次 `model-material-risk` 拒绝,重试后又能通过。
|
|
14
|
+
5. **判定不稳定 + 重试风暴**:160 次拦截中 100 次(62.5%)在 120 秒内重试同一工具,累计 533 次;其中 31 次在 120 秒内被重新放行——同一调用「先拒后放」。
|
|
15
|
+
6. **观测口径问题仍然存在**:`reasonCode` 被写成含中文阈值的长句(61 种取值)、`hostFinalAction` 从未回填(100% unknown)、遥测一次调用写两条记录且无 `mode`、观察模式明细写的是「拟执行动作」、缓存命中 0(设计使然)。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. 数据与版本口径
|
|
20
|
+
|
|
21
|
+
### 1.1 数据源
|
|
22
|
+
|
|
23
|
+
| 文件 | 大小 | 内容 | 本次用途 |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| `logs/2026-11-1{3,4,5}.log` | 约 16 MB | 决策明细(按日期分片,含 `mode`/`scores`/`content`) | 主数据:authorize 6 175 条(17:06 快照;17:24 已增至 6 552) |
|
|
26
|
+
| `telemetry.jsonl` | 约 28 MB | 遥测(v1 无 audit;v2 带 `guard-audit-v1`) | 宿主结果对照:13 556 行(v2 去重后 3 893 次调用) |
|
|
27
|
+
| `stats.jsonl` | 约 3 MB | 裁决统计(写盘口径) | 交叉校验:8 041 行 |
|
|
28
|
+
| `decisions.jsonl*.migrated` | 约 14.6 MB | 迁移前的旧单文件备份 | 仅确认「logs 已包含旧记录」,未重复计数 |
|
|
29
|
+
|
|
30
|
+
合计扫描:明细 6 873 条记录(其中 authorize 6 175 条,17:06 快照;17:24 已增至 6 908 / 6 552)、遥测 13 556 行、统计 8 041 行;解析失败 0 行。日志仍在追加,计数会小幅上漂,结论不受影响。
|
|
31
|
+
|
|
32
|
+
### 1.2 为什么必须分版本,以及版本是怎么判定的
|
|
33
|
+
|
|
34
|
+
**直接证据(可复核)**
|
|
35
|
+
|
|
36
|
+
- 日志记录**没有** `pluginVersion` / `commit` 字段,无法从日志直接读出版本。
|
|
37
|
+
- 本机安装清单:`~/.dsh/profiles/desktop/package.json` 锁定 `@leonhardthust/dsh-decision-engine@1.13.0`,文件 mtime 为 **11-15 16:09:06**;安装目录内 `package.json.version = 1.13.0`。
|
|
38
|
+
- npm 发布时间(`npm view ... time`,已换算 UTC+8):
|
|
39
|
+
|
|
40
|
+
| 版本 | 发布时间(+08) | 版本 | 发布时间(+08) |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| 1.9.0 | 11-14 22:13 | 1.11.1 | 11-15 14:38 |
|
|
43
|
+
| 1.9.1 | 11-15 00:39 | 1.11.2 | 11-15 15:22 |
|
|
44
|
+
| 1.9.2 | 11-15 10:01 | 1.12.0 | 11-15 15:42 |
|
|
45
|
+
| 1.10.0 | 11-15 13:40 | 1.13.0 | 11-15 16:07 |
|
|
46
|
+
| 1.11.0 | 11-15 14:02 | 1.14.0 | 11-15 17:14(未安装) |
|
|
47
|
+
|
|
48
|
+
**间接证据(日志 schema 断点,用于识别「发布间隔里的本地构建」)**
|
|
49
|
+
|
|
50
|
+
- 11-14 16:41 起,明细 `scores` 首次出现 `decisionSource / mandatory / confidenceKind / reviewMode`,早于 1.9.0 发布 → 该窗口运行的是**未发布的本地构建**(记为「1.9 预览」)。
|
|
51
|
+
- 11-15 13:16 起出现按日期分片(1.10.0 的「决策日志分目录」);同一时期 `tolerance` 文案由「属最前的 1%」变为「落在严重度尾部」、并引入稳定码 `tolerance-ask`。
|
|
52
|
+
- 1.13.0 窗口内 `guardFallback` 行为与安装时间吻合(16:09 安装后立即出现 `fallback-model-deny` 记录)。
|
|
53
|
+
|
|
54
|
+
因此:**版本窗口 = 发布/安装时间**;发布与下一次发布之间若出现 schema 断点,如实标注为「本地构建」。这满足「无法直接划分时按发布时间划分」的口径,但**不能把窗口边界当成精确的代码切换点**(见 §6)。
|
|
55
|
+
|
|
56
|
+
### 1.3 版本时间线与规模
|
|
57
|
+
|
|
58
|
+
| 版本窗口(+08) | authorize 记录 | 主要配置态 | 关键观测 |
|
|
59
|
+
|---|---:|---|---|
|
|
60
|
+
| v1.8.x(≤11-14 16:41) | 647 | enforce;tolerance=relaxed | ask 1.5%;0 deny;p50 167ms |
|
|
61
|
+
| v1.9 预览(11-14 16:41–22:13) | 1 800 | enforce;tolerance 混合 | ask 7.9%(142 次),协调工具占 78 次;0 deny |
|
|
62
|
+
| v1.9.0(22:13–00:39) | 189 | enforce | **deny 15.3%(29 次)**;ask 17.5% |
|
|
63
|
+
| v1.9.1(00:39–10:01) | 168 | enforce | **deny 23.2%(39 次)**;模型失败 21 次 |
|
|
64
|
+
| v1.9.2(10:01–13:40) | 1 772 | observe 1 560 / enforce 212 | 回退 611 次;deny 63(enforce 内 2.8%);p50 1.7s |
|
|
65
|
+
| v1.10.0(13:40–14:02) | 201 | 全 observe | 回退 72;拟 deny 7;p50 2.5s |
|
|
66
|
+
| v1.11.0(14:02–14:38) | 345 | enforce 189 / observe 156 | deny 21;recipient ask 5 |
|
|
67
|
+
| v1.11.1(14:38–15:22) | 331 | enforce;policy=never | **ask 0**;deny 6;接收方误判 4 |
|
|
68
|
+
| v1.11.2(15:22–15:42) | 298 | enforce;never | ask 0;deny 2;回退 25 |
|
|
69
|
+
| v1.12.0(15:42–16:07) | 288 | enforce;never | ask 0;deny 3;max 20.5s |
|
|
70
|
+
| v1.13.0(16:07–17:21) | 417 | enforce;never;sandbox=danger-full-access | **ask 0;deny 25**;其中 10 条 `mandatory=safe` |
|
|
71
|
+
| v1.14.0 | 0 | 已发布未安装 | — |
|
|
72
|
+
|
|
73
|
+
> 除 1.9.2 外,后半段每个版本窗口只有 200–400 条记录(10–40 分钟),**比例噪声大**;下表凡引用比例都同时给出计数。
|
|
74
|
+
|
|
75
|
+
### 1.4 口径陷阱(否则会读错日志)
|
|
76
|
+
|
|
77
|
+
1. **明细与遥测是两套口径**:明细写 `action = action.kind`(observe 时仍写 `deny/ask`),遥测写 `pluginReturnedAction`(observe 时写 `defer_to_host`)。同一次调用在明细里像「被拦」,在遥测里只是「交给宿主」。明细中 `observe=>deny 74`、`observe=>ask 36` 属于**观察模式的拟执行动作**。
|
|
78
|
+
2. **遥测 v1/v2 不可混算**:v1(5 428 条)无 `audit`,无法对照宿主结果且 `deny=0`;v2(7 779 条 = 3 893 次调用)每条调用写两条记录(决策 + 结果回填),无 `mode`。按记录直接统计会把 ask/deny 虚高一倍。
|
|
79
|
+
3. **`hostFinalAction` 从未回填**(100% unknown),`effectOutcome` 没有 `succeeded`,只有 `reported-success/reported-error`;不能说「宿主本会允许」有直接证据,只能给间接证据(§3.1)。
|
|
80
|
+
4. **`.migrated` 是重复备份**:与 `logs/` 逐 id 重叠 5 213/5 214;`logs/` 已完整,无需再读备份,但备份本身没被清理。
|
|
81
|
+
5. **明细 `seq` 不是全局唯一**:会话恢复/插件重启后从 1 重新计数,`sessionId+seq` 有 233 个重复键,但 `id` 唯一(不是重复写盘)。
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 2. 分版本结论
|
|
86
|
+
|
|
87
|
+
### v1.8.x(≤11-14 16:41,647 条)
|
|
88
|
+
|
|
89
|
+
- **没有 fail-closed 拒绝**:0 deny、0 回退、0 模型失败拒绝;ask 仅 1.5%(10 次)。
|
|
90
|
+
- **但已存在协调工具误问**:`send_message` 6 次、`plugin_manager`/`subagent_*` 各 1 次;ask 文本仍是旧版「中风险调用,需人工确认(risk=…)」。
|
|
91
|
+
- **审计字段缺失是版本演进**,不是分支漏填:该窗口记录没有 `decisionSource/mandatory/confidenceKind/reasonCode`,`scores` 是旧形状(`{risk_p, irrev, task, inj, tolerance:{…}}`)。
|
|
92
|
+
- **结论**:此阶段「宽进」但观测能力弱;任何跨版本聚合若把这段与新版混算,`(none)` 会占满分母。
|
|
93
|
+
|
|
94
|
+
### v1.9 预览(11-14 16:41–22:13,1 800 条,未发布本地构建)
|
|
95
|
+
|
|
96
|
+
- **问询量最大**:ask 142 次(7.9%),其中协调类工具 78 次(`pwsh` 32、`edit` 27、`team_task_update` 21、`send_message` 20、`team_task_get` 12、`list_agents` 5、`wait_agent` 4…)。
|
|
97
|
+
- **误报类型已经成型**:`send_message` 因「疑似提示注入」(inj=0.81–0.86)被升级;`subagent_fast` 的提示词本身被判注入;`team_task_get/list_agents` 被判「中风险」。
|
|
98
|
+
- **仍未产生 deny**:安全层次(mandatory/回退)尚未接入,ask 是唯一升级手段。
|
|
99
|
+
- **结论**:这是「审批噪声」问题的起点;后续 1.11.1 只修了 recipient 一类,**注入/中风险路径并未根治**(见 §3.3)。
|
|
100
|
+
|
|
101
|
+
### v1.9.0(11-14 22:13–00:39,189 条)
|
|
102
|
+
|
|
103
|
+
- **拒绝率 15.3%(29/189)**,几乎全部是 `pwsh`(23 次),拒绝原因(当时尚无 `reasonCode` 字段,取 `detail`)为 `unknown-high-impact-effect-requires-approval`(26 次)。
|
|
104
|
+
- 这是安全改造的第一次「过度收紧」:**本地静态规则判不出副作用的命令一律按「需要审批」处理**,而审批通道是 `never`,于是直接拒绝。
|
|
105
|
+
- 同时 ask 17.5%(33 次),说明同一批会话里「问」与「拒」并存,用户无法预测。
|
|
106
|
+
- **结论(已修复)**:1.9.1 起把这类情况改为 `unknown + 模型复核`(`policy.js:26` 现状),不再默认要求审批。
|
|
107
|
+
|
|
108
|
+
### v1.9.1(11-15 00:39–10:01,168 条)
|
|
109
|
+
|
|
110
|
+
- **拒绝率升到 23.2%(39/168)**,拒绝原因(`detail`,同一窗口内多数记录尚无 `reasonCode`)为 `unresolved-effect-needs-model-review` 26 次 + `advisory-failed-unknown-effect` 12 次。
|
|
111
|
+
- 触发链:决策模型返回 **`answer-shape-mismatch`**(该窗口 21 次模型失败、总 24 次 shape 失败中绝大多数集中在此),而当时**没有可用的第二模型回退**,于是 fail-closed → deny。
|
|
112
|
+
- 失败工具:`pwsh` 21、`edit` 12;`edit` 的 12 次连续拒绝在 06:31–06:33(本地)集中出现,属于一次模型解析故障放大成一组硬拒绝。
|
|
113
|
+
- 同时出现**只读 MCP 误拒**:`mcp__blender__get_addon_status`(2)、`get_scene_info`(1)、`mcp__siyuan__system`(1)。
|
|
114
|
+
- **结论(1.9.2 修复)**:该窗口是「模型不可用 = 工具不可用」的典型;1.9.2 恢复回退模型后拒绝率降到 2.8%。
|
|
115
|
+
|
|
116
|
+
### v1.9.2(11-15 10:01–13:40,1 772 条;enforce 仅 212)
|
|
117
|
+
|
|
118
|
+
- 回退模型开始承担裁决:**回退 611 次(占该窗口记录 34%)**,其中 45 次回退后拒绝;deny 63(enforce 内 2.8%)。
|
|
119
|
+
- 回退把「模型失败 → 拒绝」改成「第二个模型审 → 放行/拒绝」;代价是延迟:**p50 1 705ms、p95 5 142ms、max 14 916ms**。
|
|
120
|
+
- 伴随两类误伤:`send_message` 以 `recipient-authority-unverified` 被 ask 18 次/deny 13 次;`mcp__blender__*` 仍有误拒。
|
|
121
|
+
- **结论**:安全可用性明显改善,但把「误拒」换成了「慢」与「协调工具误判」。
|
|
122
|
+
|
|
123
|
+
### v1.10.0(11-15 13:40–14:02,201 条,全 observe)
|
|
124
|
+
|
|
125
|
+
- 日志分片 + 安全档位重构上线;该窗口全为观察模式,拟 deny 7 次(`update_goal` 2、`pwsh` 4、`team_task_create` 1)。
|
|
126
|
+
- **`update_goal` 被回退模型拒绝**是此时出现的新误伤类型:目标维护属于内部状态,不应被当作危险操作。
|
|
127
|
+
- **结论**:observe 下的「拟拒绝」被写进明细,与真实拒绝不可区分(§3.5),从这一版起更明显。
|
|
128
|
+
|
|
129
|
+
### v1.11.0(14:02–14:38,345 条)
|
|
130
|
+
|
|
131
|
+
- 混合模式;deny 21。`recipient-authority-unverified` 仍 4 次,`tolerance` note 6 次,`model-material-risk` 1 次。
|
|
132
|
+
- **结论**:修复前的过渡态;协调工具误判仍在。
|
|
133
|
+
|
|
134
|
+
### v1.11.1(14:38–15:22,331 条)
|
|
135
|
+
|
|
136
|
+
- **`recipient-authority-unverified` 的 ask 从 18/7/5 次降为 0**(`90e5689`「空闲队友消息不再被误判为接收方权限未核验」)。
|
|
137
|
+
- 但仍有 4 次 `send_message` 被 **deny**(同原因码),说明该修复只覆盖「ask 路径」,**mandatory 规则本身仍会把未验证接收方判成需要审批**;在 `never` 下直接 deny。
|
|
138
|
+
- ask 总数 0,deny 6(1.8%)。
|
|
139
|
+
- **结论**:协调工具的「问」消失了,「拒」还在(4 次);1.13.0 仍有 1 次 `team_task_create` 因档位被拒。
|
|
140
|
+
|
|
141
|
+
### v1.11.2(15:22–15:42,298 条)
|
|
142
|
+
|
|
143
|
+
- **`never` 下软性 ask 交给回退审查**(`1b972e9`):ask 0、deny 2(0.7%)。
|
|
144
|
+
- 「27 次问了也没人答」的问题到此消失(全部 27 次发生在 11-14 22:38–11-15 00:56,即 1.9.0/1.9.1 窗口)。
|
|
145
|
+
- **结论(已修复)**:审批不可用时的「无意义 ask」被消除;但**非软性 ask(模型 high / 注入达线 / mandatory unknown)仍直接 deny**,这正是 1.13.0 的现场问题(§3.1)。
|
|
146
|
+
|
|
147
|
+
### v1.12.0(15:42–16:07,288 条)
|
|
148
|
+
|
|
149
|
+
- ask 0、deny 3、回退 14;出现 **20 480ms** 的单次授权(全量最大值),属于 primary 长尾 + 回退叠加。
|
|
150
|
+
- **结论**:拒绝率保持低位,但延迟上限没有跨阶段预算(最坏可到 ~60s)。
|
|
151
|
+
|
|
152
|
+
### v1.13.0(当前安装,16:07–17:21,417 条)
|
|
153
|
+
|
|
154
|
+
- **ask 0、deny 25(约 6%)**;`approvalPolicy=never`、`approvalAvailable=false`、`sandboxMode=danger-full-access` 占 100%。
|
|
155
|
+
- 拒绝构成(按原因 × mandatory × 工具):
|
|
156
|
+
|
|
157
|
+
| 原因 | mandatory | 工具 | 次数 |
|
|
158
|
+
|---|---|---|---:|
|
|
159
|
+
| `model-material-risk` | unknown | pwsh | 10 |
|
|
160
|
+
| `model-material-risk` | safe | write | 3 |
|
|
161
|
+
| `model-material-risk` | safe | edit | 3 |
|
|
162
|
+
| `fallback-model-deny` | safe | edit | 3 |
|
|
163
|
+
| `tolerance=relaxed(严重度…)` | unknown | ask_user_question / pwsh / team_task_create / update_goal | 4 |
|
|
164
|
+
| `write-outside-authorized-workspace` | requires_approval | write | 1 |
|
|
165
|
+
| `model-injection-suspect` | safe | edit | 1 |
|
|
166
|
+
|
|
167
|
+
- **10/25 的拒绝落在插件自己判定 `mandatory=safe` 的 write/edit 上**:本地规则已认为在工作区内、有沙箱边界,但模型的 `medium/high` 意见把它升成 ask,`never` 下再变成 deny(`tolerance.js:405-417` 不对 safe 短路;`listener.js:237-263` 只在 softAsk 时回退)。
|
|
168
|
+
- **现场观测(17:09–17:18,非统计口径)**:本次审计的两个只读分析子代理被连续拒绝 14 次(`model-material-risk`,工具 pwsh/write/edit,其中 write/edit 的 mandatory=safe),重试后相同操作又能通过;其中 1 次 `write` 到 `%TEMP%` 被 `write-outside-authorized-workspace` 拒绝(属预期 fail-closed,但提示了「工作区外一律拒」)。
|
|
169
|
+
- 回退几乎不触发(4 次):`automaticReview` 需要 `permissionPreset=auto` 或 `tolerance=autonomous`,当前配置 `tolerance=relaxed`,所以**没有第二模型兜底**,模型一票即拒。
|
|
170
|
+
- **结论(仍存在)**:1.13.0 从「问太多」转成「直接拒」,且拒的是本地已判安全的写操作;用户/代理看到的是工具报错而不是可批准的卡片。
|
|
171
|
+
|
|
172
|
+
### v1.14.0(17:14 发布,未安装)
|
|
173
|
+
|
|
174
|
+
- 无运行数据;本报告不覆盖。安装后再观察「统计审计修复」是否影响本结论(本次未见其代码路径参与授权裁决)。
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 3. 问题归纳(跨版本主题)
|
|
179
|
+
|
|
180
|
+
### 3.1 P0|审批不可用时,模型风险直接变成硬拒绝(1.13.0 仍存在)
|
|
181
|
+
|
|
182
|
+
- 机制:`approvalPolicy=never` → `approvalAvailable=false`(`lib/guard/context.js:88-93`);模型给出 ask 时,只有 `softAsk`(`mandatory!=unknown` 且 `choice!=high` 且注入未达线 且 `choice=medium` 或档位改过)才走回退,其余 `kind:"deny"`(`lib/guard/listener.js:237-263`);回退失败也 deny(`lib/guard/fallback.js:133`)。
|
|
183
|
+
- 放大项:`tolerance` 对 `mandatory=safe` 不做短路(`lib/tolerance.js:405-417`),所以**本地已判定安全的 write/edit 仍会被模型意见升成 ask → deny**。
|
|
184
|
+
- 证据:1.13.0 的 25 次 deny 中 10 次 `mandatory=safe`;审计期间 14 次现场拒绝;1.9.0/1.9.1 的历史高拒绝率是同一机制的前身(当时更粗)。
|
|
185
|
+
- 影响:合法的文件修改被中止,代理进入重试;在 headless/自动审批环境(本机 `never`)没有人工兜底,等于「模型犹豫 = 任务失败」。
|
|
186
|
+
|
|
187
|
+
### 3.2 P0|判定不稳定:同一操作「先拒后放」(全版本)
|
|
188
|
+
|
|
189
|
+
- 160 次拦截中 100 次(62.5%)在 120s 内重试同一工具,累计 533 次后续调用(`edit` 380 次);
|
|
190
|
+
- 其中 **31 次在 120s 内被重新放行为 `defer_to_host`**(例如 `deny→edit` 7、`deny→pwsh` 7、`ask→edit` 11);
|
|
191
|
+
- 说明裁决对措辞/上下文敏感,**不可稳定复现**;重试既浪费时间又制造「绕过」观感。
|
|
192
|
+
|
|
193
|
+
### 3.3 P1|协调/只读工具被升级或拒绝(历史为主,当前仍有残留)
|
|
194
|
+
|
|
195
|
+
| 版本 | 现象 | 计数 | 现状 |
|
|
196
|
+
|---|---|---|---|
|
|
197
|
+
| 1.8.x–1.9 预览 | `send_message`/`team_task_*`/`list_agents` 被问 | 78 + 142 次 ask 中的大部分 | 1.11.1 后 ask=0 |
|
|
198
|
+
| 1.9.2–1.11.0 | `send_message` 接收方未验证 → ask/deny | ask 18/7/5,deny 13/…/4 | 1.11.1 修 ask;deny 残留 4 |
|
|
199
|
+
| 1.9.0–1.9.2 | 只读 MCP(blender get_*、siyuan system)被拒 | 8 次 | 1.9.2 后 0 |
|
|
200
|
+
| 1.13.0 | `team_task_create`/`update_goal` 因档位被拒 | 各 1 | 仍存在 |
|
|
201
|
+
| 1.13.0 | `edit` 因 `model-injection-suspect` 被拒(mandatory=safe) | 1 | 仍存在 |
|
|
202
|
+
|
|
203
|
+
根因:`effects.js` 白名单只覆盖少数工具;`mcp__*` 一律 `external_network + highImpact`;`internal_message` 需要「同树 + 可接续 + 下游独立审批」三者皆真才算 safe。对 Agent Teams 的协作工具,这等于常态性 unknown。
|
|
204
|
+
|
|
205
|
+
### 3.4 P1|延迟与成本:回退模型是主要成本项(全版本)
|
|
206
|
+
|
|
207
|
+
- 回退占 v2 调用的 **21.9%(854/3 893)**;回退 p50 2 316ms、p90 4 618ms、p99 12 160ms、max 14 679ms;primary p50 仅 188ms。
|
|
208
|
+
- 1.9.2 窗口 p50 1.7s、1.10.0 p50 2.5s,都是回退主导;1.13.0 回退降到 4 次后 p50 回到 177ms。
|
|
209
|
+
- 代码上最坏可串行 ~60s:primary 重试(默认 1 次)+ 端点回退(404)+ 回退模型各带 20s 预算,且**没有跨阶段 deadline**(`lib/index.js:2065-2073,2096-2112`;`lib/guard/fallback.js:87-92`)。全量观测 max 20 480ms。
|
|
210
|
+
- 缓存命中 **0**:精确缓存键含「完整 state + questions」(`lib/index.js:1973-1979`),授权 state 又含参数哈希、参数预览与最近对话摘要(`lib/guard/listener.js:88,177,189`);软缓存按安全评审要求对授权禁用(DEC-005)。因此授权每条都真打模型,属**设计后果**,不是故障;但它意味着「省 token」对授权点不成立。
|
|
211
|
+
|
|
212
|
+
### 3.5 P1|观测口径(当前版本仍存在)
|
|
213
|
+
|
|
214
|
+
| 问题 | 证据 | 影响 |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| `reasonCode` 被写成中文长句 | 61 种取值;`tolerance=relaxed(严重度 0.680 ≥ 0.600…)` 54 条;1.13.0 仍有 4 条 deny | 机器聚合被拆碎、英文界面显示中文、跨版本对账困难 |
|
|
217
|
+
| 明细 observe 写拟执行动作 | `observe=>deny 74`、`observe=>ask 36`;页面无 mode 列 | 事后读日志会误判「观察模式拦了人」 |
|
|
218
|
+
| 两套 action 语义 | 遥测 `defer_to_host` vs 明细 `action.kind`;`outcomes.js` 矩阵没有 `defer_to_host` | 同一调用一边是「拦截」一边是「未裁决」;统计系统性偏差 |
|
|
219
|
+
| 遥测一次调用两条记录 | v2 3 893 次调用 → 7 779 条;按记录统计 ask/deny 翻倍 | 报表虚高 |
|
|
220
|
+
| `hostFinalAction` 从未回填 | 110 次 deny 全部 unknown | 「插件拒了但宿主本会放行」无法证伪,只能间接推断 |
|
|
221
|
+
| 审计自身写失败 | `auditWriteFailures>0` 234 条;7 次调用只落半条 | 少量丢数(<0.2%),但无告警 |
|
|
222
|
+
| 无版本字段 | 0/6 175 条记录带版本 | 本次只能按发布时间推断(§1.2) |
|
|
223
|
+
| `stats.jsonl` 的 `point=unknown` | 160 条(11-14 14:21–14:47,26 分钟窗口) | 统计页出现「未知」桶,授权率分母偏小;随版本修复,但读取端未兜底 |
|
|
224
|
+
| `.migrated` 备份 | 14.6 MB,与 logs 重叠 5 213/5 214 | 占空间;保留策略未覆盖 |
|
|
225
|
+
| `seq` 非唯一 | 233 个 `sessionId+seq` 重复键 | 用 seq 定位记录会错,须用 `id` |
|
|
226
|
+
|
|
227
|
+
### 3.6 正向结论(避免只报坏消息)
|
|
228
|
+
|
|
229
|
+
- **没有「插件拒绝但工具照常执行」的证据**:110 次 deny 的 `effectOutcome` 100% 为 `reported-error`;ask 被拒 27 次 → error,放行 23 次 → success,100% 对应。
|
|
230
|
+
- **本地快路径有效**:1 546 次 `local_policy` 决策不请求模型(延迟 0);全量明细解析失败 0 行。
|
|
231
|
+
- **fail-closed 方向正确**:守卫二次校验只能收紧不能放宽(`lib/guard/listener.js:44-59`),未发现「模型/回退把宿主 deny 洗成 allow」的记录。
|
|
232
|
+
- **治理手段生效**:1.11.1 的接收方修复、1.11.2 的 never 回退、1.9.2 的 MCP 修复都在日志里可验证(对应 ask/deny 归零)。
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 4. 解决方案(按优先级)
|
|
237
|
+
|
|
238
|
+
> 每条给出「动作 → 代码位置 → 验收指标」。P0 针对 1.13.0 现场,P1 针对观测与成本,P2 针对治理。
|
|
239
|
+
|
|
240
|
+
### P0-1 让「本地已判安全」的写操作不再被模型一票否决
|
|
241
|
+
|
|
242
|
+
- **动作**:在 `never`/无审批通道且配置了回退时,把**非 soft 的 ask 也交给 `runFallback()`**;至少对 `mandatory.status === "safe"` 的 `write_workspace`(工作区内、sandbox 已约束)直接 `defer_to_host`,把最终边界留给宿主沙箱。仅当回退未配置/失败时才 deny。
|
|
243
|
+
- **位置**:`lib/guard/listener.js:237-263`(softAsk 分支)、`lib/tolerance.js:405-417`(safe 短路)。
|
|
244
|
+
- **验收**:1.13.0 现场 25 次 deny 中 10 次 `mandatory=safe` 归零;新增回归 `never + safe + model(high) → delegate/fallback`;不允许出现「safe 且 deny」。
|
|
245
|
+
|
|
246
|
+
### P0-2 抑制「先拒后放」与重试风暴
|
|
247
|
+
|
|
248
|
+
- **动作**:对 `(sessionId, tool, argsHash)` 建立短窗口(如 120s)判定记忆:同一调用重复到达时复用首次结论并标注 `reused=true`;同时在 deny 错误里返回「已审查过,请勿重试」的结构化原因,避免代理换措辞重试。
|
|
249
|
+
- **位置**:`lib/guard/listener.js` 入口(`exec.token`/argsSummary 已有);`decision-cache.js` 可复用精确层。
|
|
250
|
+
- **验收**:拦截后 120s 内同工具重试率从 62.5% 降到 <20%;「先拒后放」从 31 次降到 0(同参同 session)。
|
|
251
|
+
|
|
252
|
+
### P0-3 `reasonCode` 稳定化
|
|
253
|
+
|
|
254
|
+
- **动作**:`guardOutcomeMessage` 兜底改为稳定枚举(如 `tolerance-note` / `tolerance-ask` / `tolerance-relaxed`),档位 note 只进 `humanReason`/`detail`;`tolerance.js` 不再把 note 塞进 `reason`。
|
|
255
|
+
- **位置**:`lib/guard/messages.js:52-66`、`lib/guard/listener.js:210,268`、`lib/tolerance.js:450/459/474`。
|
|
256
|
+
- **验收**:`reasonCode` 唯一值从 61 降到 <20;英文 UI 的 reason 列无中文;按 `reasonCode` 聚合的日趋势连续。
|
|
257
|
+
|
|
258
|
+
### P0-4 审计完整性:回填宿主最终动作 + 版本标记
|
|
259
|
+
|
|
260
|
+
- **动作**:`joinHostOutcome` 已有 `hostFinalAction` 参数但调用方从未提供(`lib/guard/audit.js:69-71,80-83`);在 `tools/result` / guard 放行路径补 `hostFinalAction`(allow/deny/error)与 `pluginVersion`(+commit)字段,三处输出(明细/遥测/stats)统一。
|
|
261
|
+
- **验收**:`hostFinalAction=unknown` 占比从 100% 降到 <5%;每条记录 100% 带 `pluginVersion`;`auditWriteFailures` 从 234 降到 0 并告警。
|
|
262
|
+
|
|
263
|
+
### P1-1 回退成本与总预算
|
|
264
|
+
|
|
265
|
+
- **动作**:仅对 `mandatory=unknown`、primary failed、`external_network` 触发回退(当前条件偏宽);给整次授权一个跨阶段 deadline(建议 10s 硬上限,回退扣减已用预算);可对 primary/fallback 做 hedge(并发取先到者)。
|
|
266
|
+
- **位置**:`lib/guard/listener.js:229-234`、`lib/index.js:2065-2073,2096-2112`、`lib/guard/fallback.js:87-92`。
|
|
267
|
+
- **验收**:回退触发率从 21.9% 降到 <10%;授权 p99 从 7.3s 降到 <2s;最坏耗时 <10s。
|
|
268
|
+
|
|
269
|
+
### P1-2 统一两套日志口径
|
|
270
|
+
|
|
271
|
+
- **动作**:遥测补 `mode` 与 `effectiveAction`(或 `enforced:boolean`);明细 observe 写 `wouldAction`;`outcomes.js` 矩阵加入 `defer_to_host` 的明确桶;遥测一条调用只保留一条(或加 `phase` 并在读取端按 `call_id` 去重)。
|
|
272
|
+
- **验收**:同一调用的明细/遥测/统计三处 action 可一一对应;报表 ask/deny 不再翻倍;页面能区分 observe 与 enforce。
|
|
273
|
+
|
|
274
|
+
### P1-3 缓存:要么明确禁用,要么做可用白名单
|
|
275
|
+
|
|
276
|
+
- **动作**:保留「授权禁用软缓存」的安全结论(DEC-005),但在配置页/统计页明示「授权不适用缓存」;若要省 token,另建**显式**白名单缓存:键 `(tool, argsHash, contextVersion, questionsVersion)`,仅 `read_local` 与 `mandatory=safe` 的写操作,TTL ≤ 10min,命中需可撤销。
|
|
277
|
+
- **验收**:命中率可测且仅限白名单;未白名单调用命中率保持 0;不得出现「参数不同却复用」。
|
|
278
|
+
|
|
279
|
+
### P1-4 协调工具与 MCP 只读分级
|
|
280
|
+
|
|
281
|
+
- **动作**:`send_message/team_task_*/wait_agent/list_subagent_models` 在 `effects.js` 里按「宿主已强制」判 `internal_state`/`internal_message`(保留 recipient 校验,但不要 highImpact);`mcp__*` 按工具注解/名称(`get_*`、`list_*`)降级为 unknown 低风险,至少不因「MCP」本身判 high。
|
|
282
|
+
- **验收**:协调类工具 ask/deny 从历史 100+ 次降到 0(1.13.0 仅 1 条 `team_task_create`);只读 MCP 不再被拒。
|
|
283
|
+
|
|
284
|
+
### P2-1 数据治理
|
|
285
|
+
|
|
286
|
+
- **动作**:迁移完成后删除/归档 `.migrated`(14.6MB);给 `telemetry.jsonl` 加保留策略(当前 28MB 无上限);统一 `logs/`、`telemetry`、`stats` 的保留与配额,并在配置页展示占用。
|
|
287
|
+
- **验收**:`~/.dsh/dsh-decision-engine` 总占用可解释;备份不与主数据重复。
|
|
288
|
+
|
|
289
|
+
### P2-2 统计读取端兜底
|
|
290
|
+
|
|
291
|
+
- **动作**:`stats-store`/页面把缺 `point` 的记录归入显式 `unknown` 桶并标注版本窗口,不进入授权率分母;读取端按 `call_id` 去重。
|
|
292
|
+
- **验收**:统计页「unknown」不再静默污染;1.9.2 前窗口在图表上可区分。
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## 5. 复现与证据
|
|
297
|
+
|
|
298
|
+
```powershell
|
|
299
|
+
# 1) 版本发布时间(直接证据)
|
|
300
|
+
npm view @leonhardthust/dsh-decision-engine time --json
|
|
301
|
+
|
|
302
|
+
# 2) 本机安装版本与时间
|
|
303
|
+
Get-Content "$HOME\.dsh\profiles\desktop\package.json" | Select-String 'decision-engine'
|
|
304
|
+
(Get-Item "$HOME\.dsh\profiles\desktop\package.json").LastWriteTime
|
|
305
|
+
|
|
306
|
+
# 3) 明细聚合(本报告 §1–§2 数字)
|
|
307
|
+
node tmp-audit/agg.mjs > tmp-audit/agg.json
|
|
308
|
+
node tmp-audit/deep.mjs > tmp-audit/deep.json
|
|
309
|
+
node tmp-audit/versions2.mjs > tmp-audit/versions2.json
|
|
310
|
+
|
|
311
|
+
# 4) 遥测对照(§3.1/§3.4/§3.5)
|
|
312
|
+
node tmp-audit/analyze-telemetry.mjs "$HOME\.dsh\dsh-decision-engine\telemetry.jsonl" 13556 > tmp-audit/analysis-output.txt
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- 代码级根因与行号:`tmp-audit/findings-code.md`
|
|
316
|
+
- 遥测宿主结果对照:`tmp-audit/findings-telemetry.md`
|
|
317
|
+
- 明细原始数据:`~/.dsh/dsh-decision-engine/logs/*.log`
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 6. 局限与未验证
|
|
322
|
+
|
|
323
|
+
1. **版本边界是推断**:日志无版本字段;发布/安装时间只能给出窗口,窗口内的本地构建(如 11-14 16:41 的 schema 断点)无法精确归属。要根治,先加 `pluginVersion`(P0-4)。
|
|
324
|
+
2. **配置与版本混淆**:1.9.2 后大量窗口是 observe 或 `never`,拒绝率变化同时受「版本修复」与「配置切换」影响;本报告只在同一窗口内比较,跨窗口只作趋势。
|
|
325
|
+
3. **没有人工 ground truth**:无法判定某次拒绝「本应放行」,只能给出「mandatory 已判 safe 却被拒」「同参重试又通过」「只读工具被拒」等强间接证据。
|
|
326
|
+
4. **`hostFinalAction` 缺失**:无法直接证明「宿主本会允许」;`effectOutcome=reported-error` 也无法区分「插件拦截」与「工具自身报错」(对 deny 语义影响很小)。
|
|
327
|
+
5. **遥测在 telemetryLevel=off 期间不写**:0 条不代表 0 次拦截。
|
|
328
|
+
6. **数据持续增长**:本报告数字为 17:06–17:21 快照;明细与遥测仍在追加,比例会小幅漂移。
|
|
329
|
+
7. **未覆盖**:11-12 的早期记录只在 `stats.jsonl` 中留有统计(明细已迁移为 `.migrated`);`goal_summary`/`args_summary` 仅存 sha256,无法还原参数内容(隐私设计预期)。
|
package/docs/releasing.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# npm 发布
|
|
2
2
|
|
|
3
|
-
发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.
|
|
3
|
+
发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.15.0`,公开包。
|
|
4
4
|
|
|
5
5
|
本分支基于 `zuosus/dsh-decision-engine` 的 MIT 代码,保留原作者与许可证;npm scope、源码仓库、bundle 引用和浏览器主模块 / charts chunk 标识已同步为本分支身份。内部 API、样式标识和用户数据目录保留旧名称以兼容已有配置。不要同时启用原包与本 scoped fork,它们提供相同的服务与页面。
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
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`:每次 `main` push 自动检测版本。先比较整个 push 的 `before` 与当前 manifest,拒绝错误包名、非法或倒退版本;再只读查询 npmjs。当前版本已上传则跳过;当前版本高于 npmjs latest 且未上传则进入完整验证及发布。多提交 push 比较整个 push 前后,不仅比较 HEAD 的父提交。
|
|
11
11
|
- 后续发版只需修改 `package.json` 版本并提交到 main;无需创建 tag 或手动点运行。发布依赖检测和本次提交的完整 CI 均成功,不跳过测试。稳定版本发布到 `latest`,预发布版本自动进入 `next`。
|
|
12
|
-
- 当前 `1.
|
|
12
|
+
- 当前 `1.15.0` 包含 DEC-076:审批通道为 `never` 时,`mandatory=safe` 调用交宿主沙箱、非 safe 的 ask 交回退裁决(不再被模型一票否决);同会话同参 120 秒内复用首次裁决;`reasonCode` 稳定枚举化(档位 note 只进 humanReason)。`1.14.0` 的 DEC-075、`1.13.0` 的 DEC-074、`1.12.0` 的 DEC-072/073、`1.11.2` 的 never 回退、`1.11.1` 的 DEC-070、`1.11.0` 的审计修复、`1.10.0` 的五项改动依次见 DEC-062 ~ DEC-075。
|
|
13
13
|
- checkout 与 setup-vp Action 固定到已核对的 commit SHA;不通过 `pull_request_target` 运行不可信 PR。
|
|
14
14
|
- 验证任务只有 `contents: read`;只有发布 job 有 `id-token: write`。发布 step 只使用 OIDC(trusted publishing),不再读取任何 npm token secret。代码 checkout 不保留 Git 凭据。此工作流不会自动修改版本、提交源码或推送 Git。
|
|
15
15
|
|
|
@@ -58,7 +58,7 @@ Windows 全量测试最近仍有 3 个既有 POSIX 文件权限断言失败;
|
|
|
58
58
|
授权及完整验证均满足后:
|
|
59
59
|
|
|
60
60
|
```powershell
|
|
61
|
-
npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.
|
|
61
|
+
npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.15.0.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
|
|
62
62
|
npm view @leonhardthust/dsh-decision-engine version --registry "https://registry.npmjs.org/" --json
|
|
63
63
|
```
|
|
64
64
|
|
|
@@ -66,4 +66,4 @@ npm view @leonhardthust/dsh-decision-engine version --registry "https://registry
|
|
|
66
66
|
|
|
67
67
|
## 安装
|
|
68
68
|
|
|
69
|
-
DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.
|
|
69
|
+
DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.15.0`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
|
package/lib/guard/listener.js
CHANGED
|
@@ -25,11 +25,10 @@ const idleEvaluation = () => ({ status: "not_requested", provider: null, model:
|
|
|
25
25
|
/**
|
|
26
26
|
* 是否走「自动审查」:把软性询问交给回退审查者,而不是直接拒绝。
|
|
27
27
|
*
|
|
28
|
-
* 这里刻意**不**把 `approvalPolicy === "never"`
|
|
28
|
+
* 这里刻意**不**把 `approvalPolicy === "never"` 算进来:这个开关同时
|
|
29
29
|
* 决定「模型失效 / 答案畸形 / 审查异常」是否走回退,而那条链路必须保持
|
|
30
|
-
* fail-closed(失败不能变成一次放行)。never
|
|
31
|
-
*
|
|
32
|
-
* 之外的分支单独交给 `guardFallback` 处理。
|
|
30
|
+
* fail-closed(失败不能变成一次放行)。never 下「模型给出有效意见但结论
|
|
31
|
+
* 是 ask」的情况在下面的分支单独处理:safe 交宿主沙箱,其余交回退裁决。
|
|
33
32
|
*/
|
|
34
33
|
const automaticReview = (context, config) =>
|
|
35
34
|
context?.permissionPreset === "auto"
|
|
@@ -39,6 +38,19 @@ export function mountGuard(ctx, deps) {
|
|
|
39
38
|
const { getConfig, reloadOwnConfig, effectivePointModes, client, toleranceCalibrator, digestRecent, previewArguments, logDecision, recordGuardDetail } = deps;
|
|
40
39
|
const pending = new Map();
|
|
41
40
|
const mandatoryByToken = new Map();
|
|
41
|
+
/**
|
|
42
|
+
* 短窗判定记忆(审计 P0-2)。
|
|
43
|
+
*
|
|
44
|
+
* 无审批通道时,同一 `(session, tool, argsHash)` 的裁决对措辞/上下文很
|
|
45
|
+
* 敏感,重试常出现「先拒后放」。这里把同一调用在 120 秒内的首次结论
|
|
46
|
+
* 复用到后续重试:同参数同会话得到同一个答案,代理不会因为换措辞而
|
|
47
|
+
* 意外通过,也不会白烧一次模型调用。
|
|
48
|
+
*
|
|
49
|
+
* 只在 `approvalAvailable === false` 时启用:一旦有真人/审批通道,
|
|
50
|
+
* 第二次调用可能带着新的批准,不能被旧结论覆盖。
|
|
51
|
+
*/
|
|
52
|
+
const verdictMemo = new Map();
|
|
53
|
+
const VERDICT_MEMO_MS = 120000;
|
|
42
54
|
let auditWriteFailures = 0;
|
|
43
55
|
const toolService = ctx.get?.("tools");
|
|
44
56
|
if (typeof toolService?.guard === "function") {
|
|
@@ -126,12 +138,12 @@ export function mountGuard(ctx, deps) {
|
|
|
126
138
|
degradedReason: degradedReason ?? null, model: judge.model,
|
|
127
139
|
questionsVersion: questions ? GUARD_QUESTIONS_VERSION : "not_requested",
|
|
128
140
|
});
|
|
129
|
-
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 };
|
|
141
|
+
const trace = { ...audit, judge, modelEvaluation, decisionSource, reasonCode: action.reasonCode, humanReason: action.humanReason ?? action.reason, reused: action.reused === true, 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 };
|
|
130
142
|
pending.set(keyOf(exec), trace);
|
|
131
143
|
if (pending.size > 2048) pending.delete(pending.keys().next().value);
|
|
132
144
|
await emit(trace);
|
|
133
145
|
if (result?.statsFacts !== undefined) {
|
|
134
|
-
try { client.recordDecision({ facts: result.statsFacts, action: action.kind, detail: action.reason, degraded: Boolean(degradedReason) }); }
|
|
146
|
+
try { client.recordDecision({ facts: result.statsFacts, action: action.kind, detail: action.humanReason ?? action.reason, degraded: Boolean(degradedReason) }); }
|
|
135
147
|
catch { ctx.logger?.warn?.("decision-engine: guard statistics unavailable"); }
|
|
136
148
|
}
|
|
137
149
|
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 ?? {} };
|
|
@@ -141,7 +153,7 @@ export function mountGuard(ctx, deps) {
|
|
|
141
153
|
confidenceKind: advisory?.risk?.confidenceKind ?? "unavailable", p_high: advisory?.risk?.probabilities?.high ?? null,
|
|
142
154
|
p_non_low: advisory?.risk?.probabilities ? advisory.risk.probabilities.high + advisory.risk.probabilities.medium : null,
|
|
143
155
|
risk_severity: tolerated?.severity ?? null, calibrated_p_unsafe: null, mandatory: mandatory?.status ?? "unknown",
|
|
144
|
-
tolerance: tolerated ?? null, decisionSource, reasonCode: action.reasonCode, modelEvaluation,
|
|
156
|
+
tolerance: tolerated ?? null, decisionSource, reasonCode: action.reasonCode, reused: action.reused === true, modelEvaluation,
|
|
145
157
|
};
|
|
146
158
|
const detailResult = requested.length ? {
|
|
147
159
|
provider: judge.provider ?? "", model: judge.model ?? "", latencyMs,
|
|
@@ -154,7 +166,7 @@ export function mountGuard(ctx, deps) {
|
|
|
154
166
|
recordGuardDetail({ session, pointMode, exec,
|
|
155
167
|
state: { summary: hashSummary(state), contextVersion: context?.contextVersion ?? "unavailable", effect: effect?.kind ?? "unclassified", mandatory: mandatory?.status ?? "unknown", modelEvaluation, decisionSource },
|
|
156
168
|
questions: { status: questions ? primary.status : "not_requested", summary: hashSummary(questions ?? {}), version: questions ? GUARD_QUESTIONS_VERSION : "not_requested" },
|
|
157
|
-
result: detailResult, action: action.kind, detail: action.reason, degraded: Boolean(degradedReason), scores,
|
|
169
|
+
result: detailResult, action: action.kind, detail: action.humanReason ?? action.reason, degraded: Boolean(degradedReason), scores,
|
|
158
170
|
});
|
|
159
171
|
} catch { ctx.logger?.warn?.("decision-engine: guard detail persistence failed"); }
|
|
160
172
|
if (observing || action.kind === "delegate") return next();
|
|
@@ -186,6 +198,15 @@ export function mountGuard(ctx, deps) {
|
|
|
186
198
|
if (enforcingPolicy && mandatory.status === "requires_approval") return finish({ kind: mandatory.approvalAvailable === true ? "ask" : "deny", reason: mandatory.reasonCodes[0] });
|
|
187
199
|
if (mandatory.status === "safe" && ["read_local", "internal_message", "internal_state"].includes(effect.kind)) return finish({ kind: "delegate", reason: "verified-local-operation-defer-to-host" });
|
|
188
200
|
|
|
201
|
+
// 短窗判定记忆(见 verdictMemo 的说明):同会话、同工具、同参数在
|
|
202
|
+
// 120 秒内复用首次结论。放在本地快路径之后、模型调用之前——既省掉
|
|
203
|
+
// 重复模型请求,也让「先拒后放」不再发生。
|
|
204
|
+
const memoKey = `${session?.id ?? "unknown"}\u0000${exec.name}\u0000${hashSummary(exec.arguments).sha256}`;
|
|
205
|
+
const memoized = context.approvalAvailable === false ? verdictMemo.get(memoKey) : undefined;
|
|
206
|
+
if (memoized !== undefined && Date.now() - memoized.at <= VERDICT_MEMO_MS) {
|
|
207
|
+
return finish({ kind: memoized.kind, reason: memoized.reason, reasonCode: memoized.reasonCode, humanReason: memoized.humanReason, reused: true }, memoized.degradedReason);
|
|
208
|
+
}
|
|
209
|
+
|
|
189
210
|
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 } });
|
|
190
211
|
const sliceBudget = Math.floor(Math.max(500, Number(config.maxStateChars) || 6000) / 5);
|
|
191
212
|
state.recent = String(state.recent ?? "").slice(0, sliceBudget);
|
|
@@ -207,7 +228,15 @@ export function mountGuard(ctx, deps) {
|
|
|
207
228
|
if (advisory?.status === "valid") toleranceCalibrator.observe(result.provider ?? "", result.model, severity);
|
|
208
229
|
const cutoffInfo = toleranceCutoff({ level: config.tolerance, calibrator: toleranceCalibrator, provider: result?.provider ?? "", model: result?.model });
|
|
209
230
|
tolerated = resolveTolerance({ level: config.tolerance, legacyKind: baseAction.kind, severity, cutoff: cutoffInfo.cutoff, mandatory: enforcingPolicy ? mandatory : undefined, approvalAvailable: context.approvalAvailable });
|
|
210
|
-
|
|
231
|
+
// 档位改变默认判断时,`reason` 必须是**稳定枚举**:此前直接把档位 note
|
|
232
|
+
// (`tolerance=relaxed(严重度 0.680 ≥ 0.600…)`)当 reason,导致
|
|
233
|
+
// reasonCode 出现 61 种中文长句,机器聚合被拆碎、英文界面显示中文。
|
|
234
|
+
// note 保留进 humanReason,供人读与审计核对。
|
|
235
|
+
let action = tolerated.kind === baseAction.kind
|
|
236
|
+
? baseAction
|
|
237
|
+
: tolerated.kind === "ask"
|
|
238
|
+
? { kind: "ask", reason: "tolerance-ask", reasonCode: "tolerance-ask", humanReason: tolerated.note }
|
|
239
|
+
: { kind: "delegate", reason: "tolerance-delegate", reasonCode: "tolerance-delegate", humanReason: tolerated.note };
|
|
211
240
|
decisionSource = "primary_model";
|
|
212
241
|
// 为什么「本地未知副作用」可以只凭主模型意见收尾(去掉冗余的第二次复核):
|
|
213
242
|
//
|
|
@@ -238,34 +267,39 @@ export function mountGuard(ctx, deps) {
|
|
|
238
267
|
/**
|
|
239
268
|
* 审批通道不存在(DSH 的 `never` = 每个 ask 确定性 rejected)。
|
|
240
269
|
*
|
|
241
|
-
*
|
|
242
|
-
* `
|
|
270
|
+
* 1.13.0 现场问题(审计 P0-1):只有「软性 ask」才走回退,模型对
|
|
271
|
+
* `mandatory=safe` 的工作区写给出 high/注入判断时被直接 deny——
|
|
272
|
+
* 本地静态规则已经确认了边界,却让一条模型意见把合法写操作中止。
|
|
273
|
+
*
|
|
274
|
+
* 现在:
|
|
275
|
+
* - `mandatory=safe`:交宿主沙箱(本地规则已判定工作区内安全);
|
|
276
|
+
* - 其它(unknown / 非 safe):交给用户配置的回退审查者,
|
|
277
|
+
* 由它做唯一裁决;模型不可用或回退失败仍然 fail-closed。
|
|
243
278
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* 「软性询问」,而是一条未知边界(现有情景测试专门钉住
|
|
247
|
-
* `manual unknown + never` 不得伪造批准);
|
|
248
|
-
* - `risk.choice === "high"` / 注入达线:高危与注入嫌疑不接受
|
|
249
|
-
* 「再问一个模型」的软化;
|
|
250
|
-
* - 模型失效 / 答案畸形:没有可复核的有效意见(走上面的失败分支)。
|
|
279
|
+
* 强制约束(requires_approval / deny)在更早的位置已经返回,
|
|
280
|
+
* 不会被这里绕过。
|
|
251
281
|
*/
|
|
252
|
-
|
|
253
|
-
?
|
|
254
|
-
:
|
|
255
|
-
const softAsk = advisory?.status === "valid"
|
|
256
|
-
&& mandatory.status !== "unknown"
|
|
257
|
-
&& advisory.risk.choice !== "high"
|
|
258
|
-
&& !(Number.isFinite(advisory.injectionP) && advisory.injectionP >= injectionThreshold)
|
|
259
|
-
&& (advisory.risk.choice === "medium" || tolerated?.changed === true);
|
|
260
|
-
action = softAsk
|
|
261
|
-
? await runFallback()
|
|
262
|
-
: { ...action, kind: "deny" };
|
|
282
|
+
action = mandatory.status === "safe"
|
|
283
|
+
? { kind: "delegate", reason: "safe-local-operation-host-sandbox" }
|
|
284
|
+
: await runFallback();
|
|
263
285
|
}
|
|
264
286
|
// 档位把「默认放行」升级成「询问」时,`action.reason` 是档位 note(含
|
|
265
287
|
// `severity=` 与阈值小数)——那是审计口径,不能当审批卡标题。这里换成
|
|
266
288
|
// 固定双语文案;note 仍进 `humanReason` / 遥测 detail,事后核对仍能看出
|
|
267
289
|
// 是哪一档、什么分数。放在回退之后:回退给出的裁决有自己的文案。
|
|
268
|
-
if (tolerated.changed && action.kind === "ask") action = toleranceAskAction(action);
|
|
290
|
+
if (tolerated.changed && action.kind === "ask" && action.reasonCode === undefined) action = toleranceAskAction(action);
|
|
291
|
+
// 记录本次裁决,供 120 秒内的同调用重试复用(仅无审批通道时生效)。
|
|
292
|
+
if (context.approvalAvailable === false && ["delegate", "deny", "ask"].includes(action.kind)) {
|
|
293
|
+
verdictMemo.set(memoKey, {
|
|
294
|
+
at: Date.now(),
|
|
295
|
+
kind: action.kind,
|
|
296
|
+
reason: action.reason,
|
|
297
|
+
reasonCode: action.reasonCode ?? "unclassified",
|
|
298
|
+
humanReason: action.humanReason ?? action.reason,
|
|
299
|
+
degradedReason,
|
|
300
|
+
});
|
|
301
|
+
if (verdictMemo.size > 512) verdictMemo.delete(verdictMemo.keys().next().value);
|
|
302
|
+
}
|
|
269
303
|
return finish(action, degradedReason);
|
|
270
304
|
} catch {
|
|
271
305
|
ctx.logger?.warn?.("decision-engine: guard failed; explicit permission constraints remain protected");
|
package/lib/guard/messages.js
CHANGED
|
@@ -5,6 +5,10 @@ const MESSAGES = {
|
|
|
5
5
|
"write-exceeds-read-only-permission": ["当前为只读权限,写入需要用户明确批准", "Writing exceeds the current read-only permission"],
|
|
6
6
|
"write-outside-authorized-workspace": ["写入目标超出已授权工作区,需要用户明确批准", "Writing outside the authorized workspace requires explicit approval"],
|
|
7
7
|
"verified-local-operation-defer-to-host": ["本地规则检查通过,未调用模型;仍需遵守宿主权限", "Local checks passed without a model call; host permissions still apply"],
|
|
8
|
+
"safe-local-operation-host-sandbox": ["本地规则已判定工作区内安全操作,交宿主沙箱执行", "Local checks found a safe in-workspace operation; the host sandbox still applies"],
|
|
9
|
+
"tolerance-delegate": ["安全档位放行此次调用(仅软审查范围)", "The safety level allowed this call within soft review"],
|
|
10
|
+
"tolerance-deny": ["安全档位在无审批通道时阻止了此次调用", "The safety level denied this call because no approval channel exists"],
|
|
11
|
+
"unclassified": ["未分类的裁决原因,详见审计明细", "Unclassified decision reason; see the audit detail"],
|
|
8
12
|
"model-material-risk": ["决策模型提示可能有风险,需要进一步审查", "The decision model flagged potential risk for further review"],
|
|
9
13
|
"model-injection-suspect": ["决策模型发现疑似提示注入,需要进一步审查", "The decision model flagged suspected prompt injection"],
|
|
10
14
|
"model-irreversible-unrelated": ["调用可能不可恢复或偏离任务,需要进一步审查", "Potential irreversible or unrelated effects need further review"],
|
|
@@ -46,21 +50,27 @@ const MESSAGES = {
|
|
|
46
50
|
*/
|
|
47
51
|
export function toleranceAskAction(action) {
|
|
48
52
|
const [zh, en] = MESSAGES["tolerance-ask"];
|
|
49
|
-
|
|
53
|
+
// `humanReason` 保留档位 note(含 severity / 阈值),`reasonCode` 只给稳定
|
|
54
|
+
// 枚举值。审计聚合按 reasonCode,人读按 humanReason——两者不能互换。
|
|
55
|
+
return { ...action, reasonCode: "tolerance-ask", displayReason: { zh, en }, humanReason: action.reason };
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export function guardOutcomeMessage(action, mandatory) {
|
|
53
|
-
// 已显式带 `reasonCode`
|
|
54
|
-
// 是权威口径,`reason` 是调用方给的审计原文,这里只补/保留
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// 那条契约,标题必须说日常语言。
|
|
59
|
+
// 已显式带 `reasonCode` 的裁决(tolerance 触发的 ask / delegate / deny):
|
|
60
|
+
// `reasonCode` 是权威口径,`reason` 是调用方给的审计原文,这里只补/保留
|
|
61
|
+
// `displayReason`。若走下面的通用分支,`reason` 会被文案表覆盖(档位 note
|
|
62
|
+
// 丢失),`displayReason` 又会被填成与 `reason` 逐字相同的旧口径。
|
|
58
63
|
const explicit = action.reasonCode !== undefined && Object.hasOwn(MESSAGES, action.reasonCode) ? MESSAGES[action.reasonCode] : undefined;
|
|
59
64
|
if (explicit) {
|
|
60
65
|
return { ...action, ...(action.kind === "ask" ? { displayReason: action.displayReason ?? { zh: explicit[0], en: explicit[1] } } : {}) };
|
|
61
66
|
}
|
|
62
67
|
const code = action.reason ?? mandatory?.reasonCodes?.[0] ?? action.kind;
|
|
63
68
|
const message = MESSAGES[code];
|
|
64
|
-
if (!message)
|
|
69
|
+
if (!message) {
|
|
70
|
+
// 兜底:`reason` 不是已知枚举(例如档位 note、模型文案)。绝不能把它
|
|
71
|
+
// 直接当 reasonCode——那会把 61 种中文长句灌进机器聚合字段,英文界面
|
|
72
|
+
// 也会显示中文。稳定值统一为 `unclassified`,原文进 humanReason。
|
|
73
|
+
return { ...action, reasonCode: "unclassified", humanReason: action.humanReason ?? code, ...(action.kind === "ask" ? { displayReason: { zh: MESSAGES.unclassified[0], en: MESSAGES.unclassified[1] } } : {}) };
|
|
74
|
+
}
|
|
65
75
|
return { ...action, reason: message[0], reasonCode: code, ...(action.kind === "ask" ? { displayReason: { zh: message[0], en: message[1] } } : {}) };
|
|
66
76
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leonhardthust/dsh-decision-engine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
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",
|