@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/LICENSE +21 -0
  3. package/README.md +181 -2
  4. package/config/edge.example.json +179 -0
  5. package/config/privacy-policy.json +202 -0
  6. package/cordis.desktop-safe.patch.yml +129 -0
  7. package/cordis.patch.yml +382 -0
  8. package/cordis.yml +374 -0
  9. package/lib/attestation.d.ts +2 -0
  10. package/lib/attestation.d.ts.map +1 -0
  11. package/lib/attestation.js +2 -0
  12. package/lib/audit-log.d.ts +2 -0
  13. package/lib/audit-log.d.ts.map +1 -0
  14. package/lib/audit-log.js +2 -0
  15. package/lib/client.js +87 -0
  16. package/lib/cloud-llm-adapter.d.ts +2 -0
  17. package/lib/cloud-llm-adapter.d.ts.map +1 -0
  18. package/lib/cloud-llm-adapter.js +2 -0
  19. package/lib/dsh-adapter/attestation.d.ts +42 -0
  20. package/lib/dsh-adapter/attestation.d.ts.map +1 -0
  21. package/lib/dsh-adapter/attestation.js +40 -0
  22. package/lib/dsh-adapter/audit-log.d.ts +139 -0
  23. package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
  24. package/lib/dsh-adapter/audit-log.js +376 -0
  25. package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
  26. package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
  27. package/lib/dsh-adapter/cloud-egress.js +257 -0
  28. package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
  29. package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
  30. package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
  31. package/lib/dsh-adapter/contract.d.ts +80 -0
  32. package/lib/dsh-adapter/contract.d.ts.map +1 -0
  33. package/lib/dsh-adapter/contract.js +194 -0
  34. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
  35. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
  36. package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
  37. package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
  38. package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
  39. package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
  40. package/lib/dsh-adapter/edge-config.d.ts +484 -0
  41. package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
  42. package/lib/dsh-adapter/edge-config.js +898 -0
  43. package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
  44. package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
  45. package/lib/dsh-adapter/edge-context-view.js +483 -0
  46. package/lib/dsh-adapter/effective-model.d.ts +85 -0
  47. package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
  48. package/lib/dsh-adapter/effective-model.js +171 -0
  49. package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
  50. package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
  51. package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
  52. package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
  53. package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
  54. package/lib/dsh-adapter/etscli-tools.js +393 -0
  55. package/lib/dsh-adapter/file-logger.d.ts +294 -0
  56. package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
  57. package/lib/dsh-adapter/file-logger.js +564 -0
  58. package/lib/dsh-adapter/function-call-external.d.ts +357 -0
  59. package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
  60. package/lib/dsh-adapter/function-call-external.js +1005 -0
  61. package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
  62. package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
  63. package/lib/dsh-adapter/host-egress-guard.js +213 -0
  64. package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
  65. package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
  66. package/lib/dsh-adapter/host-llm-trace.js +344 -0
  67. package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
  68. package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
  69. package/lib/dsh-adapter/http-api-routes.js +79 -0
  70. package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
  71. package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
  72. package/lib/dsh-adapter/http-api-sessions.js +173 -0
  73. package/lib/dsh-adapter/http-api.d.ts +221 -0
  74. package/lib/dsh-adapter/http-api.d.ts.map +1 -0
  75. package/lib/dsh-adapter/http-api.js +1811 -0
  76. package/lib/dsh-adapter/index.d.ts +62 -0
  77. package/lib/dsh-adapter/index.d.ts.map +1 -0
  78. package/lib/dsh-adapter/index.js +97 -0
  79. package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
  80. package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
  81. package/lib/dsh-adapter/input-guard-messages.js +136 -0
  82. package/lib/dsh-adapter/input-guard.d.ts +508 -0
  83. package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
  84. package/lib/dsh-adapter/input-guard.js +2174 -0
  85. package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
  86. package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
  87. package/lib/dsh-adapter/intent-prompts.js +97 -0
  88. package/lib/dsh-adapter/intent-router.d.ts +710 -0
  89. package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
  90. package/lib/dsh-adapter/intent-router.js +2802 -0
  91. package/lib/dsh-adapter/intent-signals.d.ts +208 -0
  92. package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
  93. package/lib/dsh-adapter/intent-signals.js +391 -0
  94. package/lib/dsh-adapter/invariant-install.d.ts +16 -0
  95. package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
  96. package/lib/dsh-adapter/invariant-install.js +15 -0
  97. package/lib/dsh-adapter/invariant.d.ts +16 -0
  98. package/lib/dsh-adapter/invariant.d.ts.map +1 -0
  99. package/lib/dsh-adapter/invariant.js +22 -0
  100. package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
  101. package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
  102. package/lib/dsh-adapter/llm-adapters.js +16 -0
  103. package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
  104. package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
  105. package/lib/dsh-adapter/llm-internal-caller.js +100 -0
  106. package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
  107. package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
  108. package/lib/dsh-adapter/llm-max-turns.js +127 -0
  109. package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
  110. package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
  111. package/lib/dsh-adapter/llm-trace-store.js +205 -0
  112. package/lib/dsh-adapter/llm-trace.d.ts +450 -0
  113. package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
  114. package/lib/dsh-adapter/llm-trace.js +463 -0
  115. package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
  116. package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
  117. package/lib/dsh-adapter/local-llm-adapter.js +787 -0
  118. package/lib/dsh-adapter/local-storage.d.ts +96 -0
  119. package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
  120. package/lib/dsh-adapter/local-storage.js +198 -0
  121. package/lib/dsh-adapter/model-router.d.ts +79 -0
  122. package/lib/dsh-adapter/model-router.d.ts.map +1 -0
  123. package/lib/dsh-adapter/model-router.js +107 -0
  124. package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
  125. package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
  126. package/lib/dsh-adapter/ohttp-gateway.js +32 -0
  127. package/lib/dsh-adapter/openai-sse.d.ts +85 -0
  128. package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
  129. package/lib/dsh-adapter/openai-sse.js +462 -0
  130. package/lib/dsh-adapter/pii-utils.d.ts +96 -0
  131. package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
  132. package/lib/dsh-adapter/pii-utils.js +324 -0
  133. package/lib/dsh-adapter/plugin-host.d.ts +38 -0
  134. package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
  135. package/lib/dsh-adapter/plugin-host.js +38 -0
  136. package/lib/dsh-adapter/plugin.d.ts +46 -0
  137. package/lib/dsh-adapter/plugin.d.ts.map +1 -0
  138. package/lib/dsh-adapter/plugin.js +100 -0
  139. package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
  140. package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
  141. package/lib/dsh-adapter/privacy-policy.js +391 -0
  142. package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
  143. package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
  144. package/lib/dsh-adapter/prompt-guard.js +872 -0
  145. package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
  146. package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
  147. package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
  148. package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
  149. package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
  150. package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
  151. package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
  152. package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
  153. package/lib/dsh-adapter/secure-bridge.js +35 -0
  154. package/lib/dsh-adapter/session-attribution.d.ts +70 -0
  155. package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
  156. package/lib/dsh-adapter/session-attribution.js +76 -0
  157. package/lib/dsh-adapter/sessions.d.ts +86 -0
  158. package/lib/dsh-adapter/sessions.d.ts.map +1 -0
  159. package/lib/dsh-adapter/sessions.js +179 -0
  160. package/lib/dsh-adapter/tcm.d.ts +55 -0
  161. package/lib/dsh-adapter/tcm.d.ts.map +1 -0
  162. package/lib/dsh-adapter/tcm.js +94 -0
  163. package/lib/dsh-adapter/td-bus.d.ts +87 -0
  164. package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
  165. package/lib/dsh-adapter/td-bus.js +196 -0
  166. package/lib/dsh-adapter/tool-runner.d.ts +185 -0
  167. package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
  168. package/lib/dsh-adapter/tool-runner.js +271 -0
  169. package/lib/dsh-adapter/tools.d.ts +118 -0
  170. package/lib/dsh-adapter/tools.d.ts.map +1 -0
  171. package/lib/dsh-adapter/tools.js +292 -0
  172. package/lib/dsh-adapter/types.d.ts +23 -0
  173. package/lib/dsh-adapter/types.d.ts.map +1 -0
  174. package/lib/dsh-adapter/types.js +16 -0
  175. package/lib/dsh-adapter/voice-input.d.ts +30 -0
  176. package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
  177. package/lib/dsh-adapter/voice-input.js +34 -0
  178. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
  179. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
  180. package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
  181. package/lib/http-api.d.ts +2 -0
  182. package/lib/http-api.d.ts.map +1 -0
  183. package/lib/http-api.js +2 -0
  184. package/lib/index.d.ts +48 -0
  185. package/lib/index.d.ts.map +1 -0
  186. package/lib/index.js +49 -0
  187. package/lib/intent-router.d.ts +2 -0
  188. package/lib/intent-router.d.ts.map +1 -0
  189. package/lib/intent-router.js +2 -0
  190. package/lib/llm-adapters.d.ts +2 -0
  191. package/lib/llm-adapters.d.ts.map +1 -0
  192. package/lib/llm-adapters.js +2 -0
  193. package/lib/local-llm-adapter.d.ts +2 -0
  194. package/lib/local-llm-adapter.d.ts.map +1 -0
  195. package/lib/local-llm-adapter.js +2 -0
  196. package/lib/local-storage.d.ts +2 -0
  197. package/lib/local-storage.d.ts.map +1 -0
  198. package/lib/local-storage.js +2 -0
  199. package/lib/model-router.d.ts +2 -0
  200. package/lib/model-router.d.ts.map +1 -0
  201. package/lib/model-router.js +2 -0
  202. package/lib/ohttp-gateway.d.ts +2 -0
  203. package/lib/ohttp-gateway.d.ts.map +1 -0
  204. package/lib/ohttp-gateway.js +2 -0
  205. package/lib/plugin-host.d.ts +2 -0
  206. package/lib/plugin-host.d.ts.map +1 -0
  207. package/lib/plugin-host.js +2 -0
  208. package/lib/privacy-policy.d.ts +2 -0
  209. package/lib/privacy-policy.d.ts.map +1 -0
  210. package/lib/privacy-policy.js +2 -0
  211. package/lib/prompt-guard.d.ts +2 -0
  212. package/lib/prompt-guard.d.ts.map +1 -0
  213. package/lib/prompt-guard.js +2 -0
  214. package/lib/sanitizer-pipeline.d.ts +2 -0
  215. package/lib/sanitizer-pipeline.d.ts.map +1 -0
  216. package/lib/sanitizer-pipeline.js +2 -0
  217. package/lib/secure-bridge.d.ts +2 -0
  218. package/lib/secure-bridge.d.ts.map +1 -0
  219. package/lib/secure-bridge.js +2 -0
  220. package/lib/sessions.d.ts +2 -0
  221. package/lib/sessions.d.ts.map +1 -0
  222. package/lib/sessions.js +2 -0
  223. package/lib/tcm.d.ts +2 -0
  224. package/lib/tcm.d.ts.map +1 -0
  225. package/lib/tcm.js +2 -0
  226. package/lib/td-bus.d.ts +2 -0
  227. package/lib/td-bus.d.ts.map +1 -0
  228. package/lib/td-bus.js +2 -0
  229. package/lib/tools.d.ts +2 -0
  230. package/lib/tools.d.ts.map +1 -0
  231. package/lib/tools.js +2 -0
  232. package/lib/voice-input.d.ts +2 -0
  233. package/lib/voice-input.d.ts.map +1 -0
  234. package/lib/voice-input.js +2 -0
  235. package/package.json +253 -3
  236. package/presets/agent-preset-edge.json +50 -0
  237. package/presets/edge.desktop-safe.patch.yml +503 -0
  238. package/presets/edge.patch.yml +557 -0
@@ -0,0 +1,2174 @@
1
+ /**
2
+ * input-guard — 端侧隐私安全管线的编排器。
3
+ *
4
+ * 把安全/隐私/脱敏逻辑从 transport 层(http-api)上移到 Agent Loop 的
5
+ * 事件链上,让所有入口(http-api / Web UI / CLI / SDK / 未来的 WebSocket
6
+ * 或 gRPC)自动统一走同一套管线。谁也无法绕过。
7
+ *
8
+ * 正向(agent/pre-step):fail-closed 路由闸门 → 攻击检测 → 策略判定 →
9
+ * 意图分类 → 脱敏
10
+ * 反向(llm/stream):对 text-delta chunk 流式反脱敏
11
+ *
12
+ * §7.175 (2026-11-11): 路由改写退役。input-guard 不在 agent/request 里改写
13
+ * provider/model(宿主的 prepareRequest() 会对改写直接抛错)。
14
+ *
15
+ * §7.184 (2026-11-11,主人裁决「选择器完全不动,实际请求改道到本地池」):
16
+ * - **会话选型不再被改写**(§7.176 的钉选 / §7.179 的补钉 / §7.183 的回钉全部
17
+ * 退役)⇒ 宿主模型选择器保持主人选的那个,不跳。
18
+ * - 「本轮必须留在设备上」这条不变式改由**出网那一跳**保证:LOCAL 轮次的请求
19
+ * 在 `llm/stream` 被换成 provider = 本地池(见 handleLlmStream)。
20
+ * - 旧口径(§7.175:判定 LOCAL 而选型不是本地 ⇒ 拒绝这一轮)随之退役 ——
21
+ * 能改道就不必拒;改不动时仍然 fail-closed(抛错,绝不放行到云端)。
22
+ *
23
+ * 设计详见:docs/input-guard-design.md
24
+ *
25
+ * §7.123 (2026-10-27): the pure message-array helpers (find / replace user
26
+ * turns) moved to `./input-guard-messages.ts` — they encode the
27
+ * payload.messages vs decision.messages contract, which is the part of this
28
+ * module that upstream keeps changing. This file keeps the hook LOGIC.
29
+ *
30
+ * @module dsh-edge-agent/input-guard
31
+ */
32
+ import { Service } from '@deepseek-ai/cordis';
33
+ import Schema from '@deepseek-ai/schemastery';
34
+ import { findLastUserText, findUserTexts, replaceUserTextsAt, } from './input-guard-messages.js';
35
+ import { assistantProviderHistogram } from './cloud-egress.js';
36
+ // §7.175 (2026-11-11): 路由改写退役后,input-guard 不再探云池
37
+ // (cloud-llm-adapter 的委托探针 probeCloudTarget 也就不再需要了)。
38
+ import { loadEdgeConfig, localPool } from './edge-config.js';
39
+ // §7.180: 「这一轮真正会跑哪个模型」的解析 —— 与 wire(adapter 的委托
40
+ // 解析链)、模型目录(listModels)共用同一份委托目标缓存 ⇒ 补钉下去的
41
+ // model id 就是真正会跑的那个(§7.176)。
42
+ import { resolveEffectiveLocalModelSync, resolveEffectiveLocalModelWith, } from './effective-model.js';
43
+ import { containsCredential } from './pii-utils.js';
44
+ // §7.144: 宿主 provider 那一跳的 trace(delegate 出去的云池调用不在任何
45
+ // adapter 里,adapter 记不到它 —— 面板里会整条缺失)。
46
+ import { resolveLlmTraceConfig } from './llm-trace.js';
47
+ import { callIdFor, getTraceStore } from './llm-trace-store.js';
48
+ import { HostCallRecorder, OWN_POOL_PROVIDERS, isOwnPoolProvider, } from './host-llm-trace.js';
49
+ // §7.151 (2026-10-29): 出网那一跳的「只带云段历史」守卫。桌面部署的 CLOUD
50
+ // 轮次走的是**宿主原生 provider**(桌面 GUI 选的模型),压根不进
51
+ // cloud-llm-adapter ⇒ adapter 里的 §7.131 裁剪一次都没跑过。守卫补在
52
+ // `llm/stream` waterfall 上 —— 真正的出网那一跳。
53
+ import { HostEgressGuard } from './host-egress-guard.js';
54
+ import { sessionIdField } from './session-attribution.js';
55
+ export const name = 'input-guard';
56
+ export const inject = [
57
+ 'promptGuard',
58
+ 'privacyPolicy',
59
+ 'intentRouter',
60
+ 'sanitizer',
61
+ 'auditLog',
62
+ // §7.36 (2026-09-04): 必须 inject realmSkillBridge,否则 this.ctx.realmSkillBridge
63
+ // 永远 undefined。Cordis Context 只暴露 inject 列出的依赖,setSessionRealm 块
64
+ // 就被 if 跳过。Boot 顺序无死锁:realm-skill-bridge 在 cordis.patch.yml
65
+ // 排在 http-api 之前(已确认 line 317),input-guard 同样靠后启动。
66
+ 'realmSkillBridge',
67
+ // §7.171 (2026-11-10): 确认 local-llm 路由真的存在。
68
+ //
69
+ // local-llm 现在有两种上线方式:
70
+ // - 配置了 `llm.local.baseUrl` → 直连自有池(一直如此);
71
+ // - 没配置 → **DELEGATE 模式**(转发宿主提供商),路由照样注册 ⇒
72
+ // `modelRouter.isAvailable('local-llm')` 为 true,与直连时无差别。
73
+ // 只有 `llm.local.delegate=false`(kill switch)才回到「不注册任何路由」的
74
+ // opt-in 语义 —— 那时 LOCAL 意图必须 fail-closed,不能改写到一条不存在的路由。
75
+ //
76
+ // 刻意**不**把 modelRouter 放进 inject:
77
+ // - localAvailable() 已经用 try { ctx.get(...) } catch {} 做
78
+ // optional 访问 —— 拿不到就保持旧行为(legacy-no-router),不回归。
79
+ // - inject 是 cordis 的**硬依赖**:声明它 = cordis 必须等 model-router
80
+ // 装载完才起 input-guard,还会把 model-router 拉进最小部署形态的
81
+ // 依赖闭包(那里根本没有 local-llm / cloud-llm,model-router 只是空
82
+ // catalog,查它没有意义)。
83
+ // - 同样刻意不 inject llm:宿主 llm registry 会让「云侧判定」多一条
84
+ // 数据源,而边缘模式恒用本地模型(§7.175),云侧判定已退役。
85
+ //
86
+ // NB: 注释里不要用单引号包 service 名 —— 任何「扫 inject 数组原文」的
87
+ // 依赖闭包检查都会把注释里的引号名误当成依赖。
88
+ ];
89
+ export const Config = Schema.object({
90
+ enableInputGuard: Schema.boolean().default(true),
91
+ sanitizeLocalTraffic: Schema.boolean().default(false),
92
+ rejectOnPolicyViolation: Schema.boolean().default(true),
93
+ downgradeOnSanitizeFailure: Schema.boolean().default(true),
94
+ localProvider: Schema.string().default('local-llm'),
95
+ // §7.102: the downgrade target model follows the LOCAL pool in
96
+ // config/edge.json (llm.local.model). The Schema default stays only as a
97
+ // last-resort fallback when no config file is present at all.
98
+ localModel: Schema.string().default('qwen3-4b-instruct-2507'),
99
+ cloudApiKeyEnv: Schema.string().default(
100
+ // mirror cordis.patch.yml:41 的 !!js 表达式;apply() 里再尊重 DSH_EDGE_CLOUD_KEY_ENV 覆盖
101
+ process.env.DSH_EDGE_CLOUD_KEY_ENV ?? 'DEEPSEEK_API_KEY'),
102
+ defaultCloudProvider: Schema.string().default(
103
+ // §7.43: mirror cordis.patch.yml:82 的 !!js;agent-loop 也用同一个值作"current provider"默认
104
+ process.env.DSH_EDGE_PROVIDER ?? 'cloud-llm'),
105
+ defaultCloudModel: Schema.string().default(process.env.DSH_EDGE_MODEL ?? 'deepseek-chat'),
106
+ // §7.179:与 preset yml 的 `config.id` 对齐(门禁复核)。
107
+ presetId: Schema.string().default('edge'),
108
+ });
109
+ /**
110
+ * §7.163: 输出侧 PII 掩码的「尾部待定区」长度(字符)。
111
+ *
112
+ * 流式输出可能把一个 PII 切成多块(手机号断成两块时,两块单独看都匹配不
113
+ * 上 11 位数字的正则),逐块掩码就会漏。所以保留尾部一段待定,只对更早的
114
+ * 安全前缀做掩码。
115
+ *
116
+ * 128 覆盖了当前最长的一条 PII 规则:`credential`
117
+ * (标签最长 `access[ _-]?token` = 17 字符 + 最多 2 个引号/空格 +
118
+ * 分隔符 + 最多 2 个空格 + 64 位值 ≈ 90)。若将来新增更长的规则,
119
+ * 这个数要跟着抬 —— 抬小的后果是「跨 chunk 的长 PII 漏出」。
120
+ */
121
+ const PII_MASK_TAIL_CHARS = 128;
122
+ /**
123
+ * input-guard 服务。
124
+ *
125
+ * 三个全局 hook:
126
+ * - `agent/pre-step`(正向):**fail-closed 路由闸门** → 攻击 → 策略 →
127
+ * 分类 → 脱敏
128
+ * - `agent/request`(只读放行):§7.175 起**不再改写** provider/model
129
+ * - `llm/stream`(反向):§7.184 起把 LOCAL 轮次的请求**改道**到本地池;
130
+ * 并对 text-delta chunk 流式反脱敏
131
+ *
132
+ * 跨 hook 状态用 Map<sessionId, StepSanitizationState> 维护。
133
+ */
134
+ export class InputGuardServiceImpl extends Service {
135
+ config;
136
+ promptGuard;
137
+ privacyPolicy;
138
+ intentRouter;
139
+ sanitizer;
140
+ auditLog;
141
+ /** sessionId → 当前 step 的脱敏状态。用 Map<string, state> 而不是 WeakMap<session>
142
+ * 是因为 pre-step 的 session 引用和 llm/stream options 上的 sessionId
143
+ * 对应的不是同一个对象(pre-step 拿到的是 agent 实例的 session,
144
+ * llm/stream 拿到的是 LLM request envelope 上的 sessionId 字符串)。*/
145
+ stepState = new Map();
146
+ /**
147
+ * §7.106 (2026-10-23): last routed category per session. The attack-block
148
+ * gate consults it: a LOCAL turn cannot egress anything, so credential-flavored
149
+ * "leak" detections are the user operating their own device (密盒登录类),
150
+ * which the operator ruled must pass — warn instead of block. Injection-class
151
+ * reasons stay blocked even under LOCAL (injection can drive local tools).
152
+ * The egress risk of the same credentials later riding session history into a
153
+ * CLOUD turn is closed by the sanitizer's new `credential` PII class, not by
154
+ * this exemption.
155
+ */
156
+ turnCategory = new Map();
157
+ /**
158
+ * §7.144: LLM trace 的行为配置(`audit.llmTrace` 段)。
159
+ *
160
+ * 构造时读一次 —— 这是**观测**配置,不是路由配置,读一次足够;每次
161
+ * 读盘会在每个 LLM 往返上多一次 IO。
162
+ */
163
+ traceConfig;
164
+ /**
165
+ * §7.151: 出网守卫(宿主原生 provider 路径的 §7.131 裁剪)。
166
+ *
167
+ * 必须在**请求派发之前**裁 —— 而宿主把 LOOP 构建的请求 deep-frozen
168
+ * 送来(监听器只读不改),所以裁完要**换掉请求体重新派发**。守卫自己
169
+ * 持有「已裁过」的标记集合,避免重新派发时无限递归。
170
+ */
171
+ hostEgressGuard;
172
+ /**
173
+ * §7.175 (2026-11-11): 「云池现在到底可用吗」的探针(§7.35 / §7.100)随
174
+ * 路由改写一起退役 —— 边缘模式恒用本地模型(主人裁决),云池不参与任何
175
+ * 路由判定。留着它只会把「云端不可用」报成「路由缺失」。*/
176
+ /**
177
+ * §7.171 (2026-11-10): "本地路由现在到底可用吗" —— local-llm 有两种上线方式:
178
+ * 配置了 `llm.local.baseUrl` → 直连自有池;没配置 → DELEGATE 模式(转发宿主
179
+ * 提供商,见 local-llm-adapter.apply)。**两种情况下路由都是注册了的** ⇒
180
+ * `modelRouter.isAvailable('local-llm')` 即可判据,与直连时完全一致。
181
+ *
182
+ * 只有 `llm.local.delegate=false`(kill switch)才会让路由不存在 —— 那时
183
+ * intent=LOCAL 按隐私裁决必须走本地模型,在改写 provider 之前必须确认目标
184
+ * 路由真的存在,否则会把「本地」意图发给一条不存在的路由。
185
+ *
186
+ * 旧上游 / fake ctx 没有 listProviders 时无法证伪 → 保持旧行为(照常改写),
187
+ * 不让老环境因这次收紧而回归。
188
+ */
189
+ localAvailable() {
190
+ const provider = this.config.localProvider;
191
+ let router;
192
+ try {
193
+ router = this.ctx?.get?.('modelRouter');
194
+ }
195
+ catch {
196
+ router = undefined;
197
+ }
198
+ if (!router || typeof router.isAvailable !== 'function') {
199
+ // 老上游 / fake ctx:无法证伪 → 保持旧行为(照常改写),不回归。
200
+ return { ok: true, reason: 'legacy-no-router' };
201
+ }
202
+ try {
203
+ return router.isAvailable(provider)
204
+ ? { ok: true, reason: `local-route-live:${provider}` }
205
+ : { ok: false, reason: `local-route-missing:${provider}` };
206
+ }
207
+ catch {
208
+ return { ok: true, reason: 'legacy-router-unreadable' };
209
+ }
210
+ }
211
+ /**
212
+ * §7.180: 本地池现状 —— 直连(配了 `llm.local.baseUrl`)还是 DELEGATE
213
+ * (没配端点,转发宿主提供商,§7.171),连同 model / delegate 旋钮。
214
+ *
215
+ * 补钉用的 model id 由这份现状**推导**(而不是另读一份宿主选择)⇒ 与
216
+ * wire、模型目录同源(见 §7.180)。
217
+ *
218
+ * protected 而不是 private:单测子类化覆写它,不必依赖机器上那份
219
+ * `~/.dsh-edge-agent/edge.json` 的真实内容。
220
+ */
221
+ localPoolShape() {
222
+ try {
223
+ return localPool();
224
+ }
225
+ catch {
226
+ return {};
227
+ }
228
+ }
229
+ /**
230
+ * §7.184 (2026-11-11): 本轮判定 LOCAL 时,把实际请求**改道**到本地池。
231
+ *
232
+ * 背景(主人裁决 2026-11-11):「选择器完全不动,实际请求改道到本地池」。
233
+ *
234
+ * - 边缘模式的会话**不再改写选型**(§7.176 的钉选 / §7.179 的补钉 /
235
+ * §7.183 的回钉全部退役)⇒ 宿主模型选择器保持主人选的那个,不跳。
236
+ * - 但「本轮必须留在设备上」这条不变式**不能松** ⇒ 真正跑哪儿由**出网
237
+ * 那一跳**决定:LOCAL 轮次的请求在 `llm/stream` 被换成 provider = 本地池。
238
+ *
239
+ * 为什么能在这一跳改:`llm/stream` 的 waterfall **默认处理**才是解析适配器的
240
+ * 地方(dsh-llm `adapterStream()`:`registration = this.registration(options.provider)`)
241
+ * ⇒ 换掉 `options.provider` 就换掉了真正发出去的适配器。
242
+ * 而且 cordis 的 waterfall **不接受替换载荷**(`next()` 无参,载荷被闭包捕获)
243
+ * ⇒ 只能换一个请求对象**重新派发**(与 §7.151 的出网裁剪同一个姿势)。
244
+ *
245
+ * 重新派发会再进一次 waterfall ⇒ 那趟的 provider 已是本地池 ⇒ 天然不再改道
246
+ * (**防自锁**),且本地池按 §7.131 带**全部**历史(本地池不出网,不裁)。
247
+ *
248
+ * fail-closed:改不动(本地路由不存在 / 解析不到 model id / 宿主 LLM runtime
249
+ * 不可用)⇒ **抛错**,绝不放行到可能上云的 provider。
250
+ */
251
+ /** §7.184: 这一通请求要不要改道到本地池? */
252
+ needsLocalReroute(options, state) {
253
+ // 只对「本轮必须留在设备上」的轮次改道。CLOUD / SANITIZED 轮次按设计就是
254
+ // 要出网的(脱敏 + 只带云段历史),不改道。
255
+ if (this.turnCategory.get(state.sessionId) !== 'LOCAL')
256
+ return false;
257
+ const provider = typeof options.provider === 'string' ? options.provider : '';
258
+ if (provider.length === 0)
259
+ return false;
260
+ // 已经是本地池 ⇒ 不用改(重新派发回来的那趟也走这一支 ⇒ 防自锁)。
261
+ return provider !== this.config.localProvider;
262
+ }
263
+ /**
264
+ * §7.184: 换掉请求对象**重新派发**到本地池(与 §7.151 的出网裁剪同一姿势)。
265
+ *
266
+ * 为什么不能就地改写:宿主契约明写 LOOP 构建的请求是 **deep-frozen**
267
+ * (`markAgentLoopRequest` + `deepFreeze`),"listeners read it, never rewrite it"。
268
+ */
269
+ async *dispatchRerouted(options, state) {
270
+ const fromProvider = typeof options.provider === 'string' ? options.provider : '';
271
+ const fromModel = typeof options.model === 'string' ? options.model : '';
272
+ const local = this.localAvailable();
273
+ if (!local.ok) {
274
+ this.audit('security', 'input-guard.reroute.blocked', {
275
+ ...sessionIdField(state.sessionId),
276
+ provider: fromProvider,
277
+ model: fromModel,
278
+ reason: local.reason,
279
+ });
280
+ throw new Error(`input-guard: 本轮 intent 判定为 LOCAL(必须留在设备上),但本地路由 "${this.config.localProvider}" 不可用(${local.reason})。`
281
+ + `为避免本地数据/PII 出境,已阻止本轮上云。`
282
+ // §7.159 门禁按**源码字面量**抽这段报错文案(verify-config-hints.mjs
283
+ // 取文件里第一个「本轮 intent 判定为 LOCAL」所在的 throw)⇒ 这段配置
284
+ // 指引必须字面写在这里,不能改成常量插值;内容与 pre-step 那段一致。
285
+ + `请配置本地 LLM 端点:编辑 ~/.dsh-edge-agent/edge.json 的 llm.local 段,`
286
+ + `填 baseUrl(OpenAI 兼容端点,必须含 /v1)与 model;`
287
+ + `端点需要鉴权时,把密钥写进同一文件的 secrets 段,`
288
+ + `并把 llm.local.keyRef 指向那个键名(keyRef 是 secrets 里的键名,不是环境变量名)。`);
289
+ }
290
+ // 与 wire / 模型目录同源地解析「本地池真正会跑的那个 model id」(§7.180)。
291
+ const shape = this.localPoolShape();
292
+ let effective = resolveEffectiveLocalModelSync(this.ctx, shape, this.config.localModel, this.config.localProvider);
293
+ if (effective === undefined || effective.source === 'requested') {
294
+ // 探针步没定 model ⇒ 需要一次 firstModelId 查询(异步)—— 这里能等。
295
+ effective = await resolveEffectiveLocalModelWith(this.ctx, shape, this.config.localModel, this.config.localProvider);
296
+ }
297
+ if (effective.source === 'requested' || effective.model.length === 0) {
298
+ this.audit('security', 'input-guard.reroute.blocked', {
299
+ ...sessionIdField(state.sessionId),
300
+ provider: fromProvider,
301
+ model: fromModel,
302
+ reason: 'local-model-unresolved',
303
+ });
304
+ throw new Error('input-guard: 本轮 intent 判定为 LOCAL(必须留在设备上),但解析不到本地池真正会跑的模型 —— 已阻止本轮上云。'
305
+ + '请检查 ~/.dsh-edge-agent/edge.json 的 llm.local 段(baseUrl / model)与 secrets 段。');
306
+ }
307
+ const rerouted = { ...options, provider: this.config.localProvider, model: effective.model };
308
+ // §7.151: 取 LLM runtime **必须走 `ctx.get()`**(cordis 4.x 的 ctx 是 Proxy,
309
+ // input-guard 的 inject 里没有 'llm' ⇒ 直取会抛)。
310
+ const llm = (() => {
311
+ try {
312
+ return this.ctx.get?.('llm');
313
+ }
314
+ catch {
315
+ return undefined;
316
+ }
317
+ })();
318
+ if (typeof llm?.stream !== 'function') {
319
+ this.audit('security', 'input-guard.reroute.blocked', {
320
+ ...sessionIdField(state.sessionId),
321
+ provider: fromProvider,
322
+ model: fromModel,
323
+ reason: 'llm-runtime-unavailable',
324
+ });
325
+ throw new Error('input-guard: 无法重新派发到本地池(宿主 LLM runtime 不可用)—— 已阻止本轮上云');
326
+ }
327
+ // 审计行放在**真正派发之前**(runtime 不可用时上面的 blocked 已经抛了
328
+ // ⇒ 不会出现「审计说改道了、其实没改」的假记录)。
329
+ this.audit('info', 'input-guard.reroute.local', {
330
+ ...sessionIdField(state.sessionId),
331
+ from: { provider: fromProvider, model: fromModel },
332
+ to: { provider: this.config.localProvider, model: effective.model },
333
+ source: effective.source,
334
+ ...(effective.via === undefined ? {} : { via: effective.via }),
335
+ mode: shape.baseUrl ? 'direct' : 'delegate',
336
+ });
337
+ const raw = llm.stream.call(llm, rerouted);
338
+ const resolved = (raw != null && typeof raw.then === 'function')
339
+ ? await raw
340
+ : raw;
341
+ yield* resolved;
342
+ }
343
+ constructor(ctx, config, promptGuard, privacyPolicy, intentRouter, sanitizer, auditLog) {
344
+ super(ctx, 'inputGuard');
345
+ this.config = config;
346
+ this.promptGuard = promptGuard;
347
+ this.privacyPolicy = privacyPolicy;
348
+ this.intentRouter = intentRouter;
349
+ this.sanitizer = sanitizer;
350
+ this.auditLog = auditLog;
351
+ // §7.151: 出网守卫(纯逻辑,无外部依赖 —— 构造失败不该拖垮插件)。
352
+ this.hostEgressGuard = new HostEgressGuard(ctx);
353
+ // §7.144: trace 配置读一次(读盘失败也不该让 input-guard 起不来 ——
354
+ // 观测配置坏了就退回出厂默认,主链路不受影响)。
355
+ try {
356
+ this.traceConfig = resolveLlmTraceConfig(loadEdgeConfig());
357
+ }
358
+ catch {
359
+ this.traceConfig = resolveLlmTraceConfig(undefined);
360
+ }
361
+ }
362
+ audit(level, kind, payload) {
363
+ if (!this.auditLog)
364
+ return;
365
+ void this.auditLog.log({ level, kind, source: 'input-guard', payload }).catch(() => { });
366
+ }
367
+ /** 当前 session 是否有活跃的脱敏状态(desanitize 用)。*/
368
+ getStepState(sessionId) {
369
+ return this.stepState.get(sessionId);
370
+ }
371
+ /** Exposed for tests: the step-state map the turn/end restore listener reads. */
372
+ setStepState(sessionId, state) {
373
+ this.putStepState(sessionId, state);
374
+ }
375
+ /** Seed/override the session's last routed category. Tests drive the
376
+ * LOCAL-credential exemption with it; http-api could pre-seed a known
377
+ * route the same way. */
378
+ setTurnCategory(sessionId, category) {
379
+ this.putTurnCategory(sessionId, category);
380
+ }
381
+ /** 会话状态 Map 的 LRU 上限。活跃会话远小于此(http-api 默认
382
+ * maxActiveSessions=8),被淘汰的是早已结束的 session —— 这两个
383
+ * Map 曾经只增不减,长驻进程里随历史会话数无限增长。 */
384
+ static SESSION_STATE_CAP = 512;
385
+ /** LRU 写入:delete-then-set 让最近路由的 session 排到队尾,超限
386
+ * 淘汰队头(最久未访问的 session)。 */
387
+ putStepState(sessionId, state) {
388
+ const map = this.stepState;
389
+ map.delete(sessionId);
390
+ map.set(sessionId, state);
391
+ this.evictOldestSessionState(map);
392
+ }
393
+ putTurnCategory(sessionId, category) {
394
+ const map = this.turnCategory;
395
+ map.delete(sessionId);
396
+ map.set(sessionId, category);
397
+ this.evictOldestSessionState(map);
398
+ }
399
+ evictOldestSessionState(map) {
400
+ while (map.size > InputGuardServiceImpl.SESSION_STATE_CAP) {
401
+ const oldest = map.keys().next().value;
402
+ if (oldest === undefined)
403
+ break;
404
+ map.delete(oldest);
405
+ }
406
+ }
407
+ /**
408
+ * 清洗 messages 数组中的孤儿 tool_call。
409
+ *
410
+ * 背景(2026-08-31):当 session 历史里残留一条 assistant 消息含
411
+ * `tool_calls`,但后续没有匹配的 `role: tool` + `tool_call_id` 时,
412
+ * OpenAI / DeepSeek API 会返回 400 invalid_request_error
413
+ * "An assistant message with 'tool_calls' must be followed by tool
414
+ * messages responding to each 'tool_call_id'."
415
+ *
416
+ * 根因:webui 复用先前 http-api 创建的会话做 session.prompt,但
417
+ * agent-loop 把整个 history 投影到 OpenAI 兼容 schema 时保留了
418
+ * 没有 tool 响应的 tool_call。
419
+ *
420
+ * 修复策略:对每个 orphan tool_call,在 assistant 之后紧邻位置插入一条
421
+ * 合成的 `{role:'user', content:[{type:'tool-result', toolCallId, content:'{\"error\":\"aborted\"}...'}]}`。
422
+ * 这样:
423
+ * - assistant 消息被原样保留(tool-call 块不动)——必须保证 wire 端
424
+ * assistant.tool_calls 包含所有 ids,synthetic 才能与之匹配
425
+ * - wire 转换后 API 看到完整的 tool_calls ↔ tool 配对,校验通过
426
+ * - LLM 收到"之前那次工具调用被中止了"的信号,自然续写
427
+ *
428
+ * 关键:不要剥离 orphan tool-call 块(初版犯的错)。如果剥离,Case B
429
+ * (assistant 同时含 responded + orphan ids)会让合成的 tool-result
430
+ * 找不到匹配的 tool_call_id,wire 端再次 400。
431
+ *
432
+ * 副作用:上游 provider 可能继续推演被中止的工具调用(如果 LLM 智能
433
+ * 不足)。优先级:先让请求能跑通,再考虑 LLM 行为。这是 input-guard
434
+ * 的职责边界:保证 schema 合规,不保证 LLM 行为合理。
435
+ *
436
+ * §7.58 (2026-09-16) Pass 0——重复 tool_call id 唯一化:同一次 sanitize 内
437
+ * 第 2..n 次出现的同 id tool-call 块(及其第 2..n 个配对 tool-result 块,
438
+ * 含 message.source.callId)改名为 `${id}-dup${k}`,避免 DeepSeek 400
439
+ * "Duplicate value for 'tool_call_id' … in message[N]"。来源:(a) 旧版
440
+ * openai-sse.ts delta / block-end 索引错位使一次调用在一条 assistant 消息
441
+ * 里落成两个同 id 块(已源头修复),修复前落盘的 session 历史仍在——
442
+ * 2026-09-15 session 24303d6a turn 2 实锤;(b) 未来其他模型 / adapter 的
443
+ * 同类重复输出。Pass 0 先于 orphan 扫描,保证后续 pass 看到全局唯一 id。
444
+ *
445
+ * 调用方:cloud-llm-adapter / local-llm-adapter 在 buildChatCompletionsBody
446
+ * 前调用。如 input-guard 未启用(enableInputGuard=false)则跳过。
447
+ */
448
+ sanitizeOrphanToolCalls(messages, sessionId) {
449
+ if (!this.config.enableInputGuard) {
450
+ // 不洗:保持原样
451
+ return { messages: [...messages], orphanCount: 0, syntheticInjected: 0, duplicateCount: 0 };
452
+ }
453
+ const out = [];
454
+ let orphanCount = 0;
455
+ let syntheticInjected = 0;
456
+ /**
457
+ * 2026-08-31: dsh 的消息不是 OpenAI 格式。tool_call 是 assistant content 数组
458
+ * 里的 `{type:'tool-call', toolCallId, name, arguments}` 块,tool 响应是
459
+ * 后续 user 消息 content 里的 `{type:'tool-result', toolCallId, content}` 块。
460
+ * 上游 openai-sse.ts 的 buildChatCompletionsBody 把它们转成 OpenAI wire 格式
461
+ * (assistant.tool_calls + role:'tool' 消息)。如果 assistant 里的 toolCallId
462
+ * 在之后没有匹配 tool-result 块,wire 就会带悬空 tool_calls,DeepSeek 返 400。
463
+ *
464
+ * 因此清洗要直接操作 dsh 的 content 块:
465
+ * 1. 收集本条 assistant content 里的所有 toolCallId
466
+ * 2. 在后续所有消息(不限于 role)里找匹配的 tool-result 块
467
+ * 3. 缺失则插入一条 user 消息携带合成 tool-result 块
468
+ */
469
+ /**
470
+ * 从一条消息里抽所有 tool-result 的 id。
471
+ *
472
+ * §7.119 (2026-10-26):要认**两种**形状。
473
+ *
474
+ * 1. 块级:content 里 `type==='tool-result'` 的块,id 在
475
+ * `toolCallId` / `callId` / `id` 三者之一。旧实现只认这一种。
476
+ * 2. **消息级**:DSH 的规范形状 —— `{role:'tool', toolCallId:'call_x',
477
+ * source:{kind:'tool',callId:'call_x'}, content:[{type:'text',…}]}`。
478
+ * id 在**消息顶层**,content 里是结果文本块、没有 tool-result 块。
479
+ * 上游 `createToolResultMessage()` 造的就是这个形状。
480
+ *
481
+ * 只认形状 1 时的后果(实测):真实会话里 **100%** 的工具结果都是
482
+ * 形状 2 → 每一个 tool_call 都被误判成 orphan → 每轮都注入合成的
483
+ * "aborted" tool-result → 会话历史每轮 +3 条消息、无限增长
484
+ * (实测 24 轮后 42 个假 orphan、111 条消息、111KB)。
485
+ * 合成的那批反而能被 wire builder 正确识别,于是模型每轮都被告知
486
+ * "上一个工具调用被中止了",而**真实结果**被 §7.106 的配对守卫降级成
487
+ * "[tool-result (unreferenced) lost its matching call; output:]" 的
488
+ * user 文本。模型的工具语义全程是错的。
489
+ */
490
+ const collectResultIds = (message) => {
491
+ const ids = new Set();
492
+ if (!message || typeof message !== 'object')
493
+ return ids;
494
+ const msg = message;
495
+ // 形状 2:消息级 id(role:'tool' 的规范形状)
496
+ for (const candidate of [msg.toolCallId, msg.source?.callId, msg.source?.toolCallId]) {
497
+ if (typeof candidate === 'string' && candidate !== '')
498
+ ids.add(candidate);
499
+ }
500
+ // 形状 1:content 里的 tool-result 块
501
+ const content = msg.content;
502
+ if (!Array.isArray(content))
503
+ return ids;
504
+ for (const block of content) {
505
+ if (!block || typeof block !== 'object')
506
+ continue;
507
+ const b = block;
508
+ if (b.type === 'tool-result') {
509
+ const id = typeof b.toolCallId === 'string' ? b.toolCallId
510
+ : typeof b.callId === 'string' ? b.callId
511
+ : typeof b.id === 'string' ? b.id
512
+ : undefined;
513
+ if (id)
514
+ ids.add(id);
515
+ }
516
+ }
517
+ return ids;
518
+ };
519
+ /** 从 assistant content 数组里抽所有 tool-call 的 id(保持顺序) */
520
+ const collectToolCallIds = (content) => {
521
+ const ids = [];
522
+ if (!Array.isArray(content))
523
+ return ids;
524
+ for (const block of content) {
525
+ if (!block || typeof block !== 'object')
526
+ continue;
527
+ const b = block;
528
+ if (b.type === 'tool-call') {
529
+ const id = typeof b.toolCallId === 'string' ? b.toolCallId
530
+ : typeof b.callId === 'string' ? b.callId
531
+ : typeof b.id === 'string' ? b.id
532
+ : undefined;
533
+ if (id)
534
+ ids.push(id);
535
+ }
536
+ }
537
+ return ids;
538
+ };
539
+ // ── Pass 0(§7.58, 2026-09-16):重复 tool_call id 唯一化 ─────────────
540
+ // 按文档顺序对同一 id 的出现次序计数:同一 id 的第 k 次「调用」与第 k 次
541
+ // 「结果」天然一一对应(call 恒在其 result 之前出现),第 1 次保留原 id,
542
+ // 第 k≥2 次改名 `${id}-dup${k}`。RESULT 不自算新名,而是引用第 k 个 CALL
543
+ // 改名后的 id(callIdsById 表)——若 result 自己走碰撞让位逻辑,会撞上
544
+ // 刚给配对 call 起的名字而错位成 -dupNx(单测实测踩中)。消息与块一律
545
+ // 浅拷贝——运行期 dsh-llm 对 payload deepFreeze,in-place 修改会炸。改名
546
+ // 撞上历史已有 id 时再加 'x' 后缀(防御极端命名)。
547
+ const blockIdOf = (b) => typeof b.toolCallId === 'string' ? b.toolCallId
548
+ : typeof b.callId === 'string' ? b.callId
549
+ : typeof b.id === 'string' ? b.id
550
+ : undefined;
551
+ const takenIds = new Set();
552
+ for (const m of messages) {
553
+ const c = m.content;
554
+ if (!Array.isArray(c))
555
+ continue;
556
+ for (const block of c) {
557
+ if (!block || typeof block !== 'object')
558
+ continue;
559
+ const b = block;
560
+ if (b.type !== 'tool-call' && b.type !== 'tool-result')
561
+ continue;
562
+ const id = blockIdOf(b);
563
+ if (id !== undefined)
564
+ takenIds.add(id);
565
+ }
566
+ }
567
+ const callOccurrence = new Map();
568
+ const resultOccurrence = new Map();
569
+ // 原 id → 其各次 call 改名后的 id(按出现顺序)。result 的第 k 次出现直接
570
+ // 引用第 k 个 call 的落定 id,配对天然一致,不会互相让位错位。
571
+ const callIdsById = new Map();
572
+ let duplicateCount = 0;
573
+ const pass0 = [];
574
+ for (const m of messages) {
575
+ const content = m.content;
576
+ if (!Array.isArray(content)) {
577
+ pass0.push(m);
578
+ continue;
579
+ }
580
+ let touched = false;
581
+ let renameFrom;
582
+ let renameTo;
583
+ const blocks = content.map((block) => {
584
+ if (!block || typeof block !== 'object')
585
+ return block;
586
+ const b = block;
587
+ if (b.type !== 'tool-call' && b.type !== 'tool-result')
588
+ return block;
589
+ const id = blockIdOf(b);
590
+ if (id === undefined)
591
+ return block;
592
+ const isCall = b.type === 'tool-call';
593
+ const occ = isCall
594
+ ? (callOccurrence.get(id) ?? 0) + 1
595
+ : (resultOccurrence.get(id) ?? 0) + 1;
596
+ if (isCall)
597
+ callOccurrence.set(id, occ);
598
+ else
599
+ resultOccurrence.set(id, occ);
600
+ let nextId;
601
+ if (isCall) {
602
+ nextId = id;
603
+ if (occ > 1) {
604
+ nextId = `${id}-dup${occ}`;
605
+ while (takenIds.has(nextId))
606
+ nextId = `${nextId}x`;
607
+ takenIds.add(nextId);
608
+ duplicateCount++;
609
+ }
610
+ const assigned = callIdsById.get(id);
611
+ if (assigned)
612
+ assigned.push(nextId);
613
+ else
614
+ callIdsById.set(id, [nextId]);
615
+ }
616
+ else {
617
+ const assigned = callIdsById.get(id);
618
+ if (assigned !== undefined && assigned.length >= occ) {
619
+ nextId = assigned[occ - 1];
620
+ }
621
+ else {
622
+ // result 比 call 还多(上游另有问题的历史):自算唯一名兜底,
623
+ // 不指望配对,至少不再触发 DeepSeek 的 per-message id 去重 400。
624
+ nextId = `${id}-dup${occ}`;
625
+ while (takenIds.has(nextId))
626
+ nextId = `${nextId}x`;
627
+ takenIds.add(nextId);
628
+ }
629
+ }
630
+ if (nextId === id)
631
+ return block;
632
+ if (!isCall) {
633
+ renameFrom = id;
634
+ renameTo = nextId;
635
+ }
636
+ touched = true;
637
+ const copy = { ...b };
638
+ if (copy.toolCallId !== undefined)
639
+ copy.toolCallId = nextId;
640
+ if (copy.callId !== undefined)
641
+ copy.callId = nextId;
642
+ if (copy.id !== undefined)
643
+ copy.id = nextId;
644
+ return copy;
645
+ });
646
+ if (!touched) {
647
+ pass0.push(m);
648
+ continue;
649
+ }
650
+ const msgCopy = { ...m, content: blocks };
651
+ // tool-result 消息的 source.callId 也指回 call id(dsh session 记录格式),
652
+ // 同步改名保持配对;wire 构建不读它,但 desanitize / 审计路径会。
653
+ const src = msgCopy.source;
654
+ if (renameTo !== undefined &&
655
+ src !== null && typeof src === 'object' &&
656
+ src.callId === renameFrom) {
657
+ msgCopy.source = { ...src, callId: renameTo };
658
+ }
659
+ pass0.push(msgCopy);
660
+ }
661
+ // 第一遍:扫描所有消息,构建 "所有出现过的 tool-result id" 集合
662
+ // 我们不只盯紧跟 assistant 的"连续 tool 段"——因为 dsh 把 tool-result
663
+ // 放在后续 user 消息的 content 块里,可能隔了几条消息。
664
+ const respondedAll = new Set();
665
+ for (const m of pass0) {
666
+ for (const id of collectResultIds(m))
667
+ respondedAll.add(id);
668
+ }
669
+ // 第二遍:扫 assistant 消息,遇到有 tool-call 的就补缺失响应
670
+ for (let i = 0; i < pass0.length; i++) {
671
+ const msg = pass0[i];
672
+ const role = msg.role;
673
+ const content = msg.content;
674
+ // 复制消息(不可变)
675
+ if (role !== 'assistant') {
676
+ out.push(msg);
677
+ continue;
678
+ }
679
+ const tcIds = collectToolCallIds(content);
680
+ if (tcIds.length === 0) {
681
+ out.push(msg);
682
+ continue;
683
+ }
684
+ // 找出本 assistant 后尚未被响应的 id(在 respondedAll 里检查)
685
+ // 注意:respondedAll 是全局的,可能包含更晚 assistant 的 tool-call
686
+ // 需要的响应。所以只看本 assistant 之前的消息 + 本 assistant 自身。
687
+ // 实际 dsh 行为:tool-result 块总是出现在对应的 assistant tool-call 之后,
688
+ // 但不一定紧跟(中间可能有 user 消息)。所以扫描 i+1 之后所有消息即可。
689
+ const respondedAfter = new Set();
690
+ for (let k = i + 1; k < pass0.length; k++) {
691
+ for (const id of collectResultIds(pass0[k])) {
692
+ respondedAfter.add(id);
693
+ }
694
+ }
695
+ const missing = [];
696
+ for (const id of tcIds) {
697
+ if (!respondedAfter.has(id))
698
+ missing.push(id);
699
+ }
700
+ if (missing.length === 0) {
701
+ out.push(msg);
702
+ continue;
703
+ }
704
+ orphanCount += missing.length;
705
+ syntheticInjected += missing.length;
706
+ // 保留 assistant 消息的 content 原样(包括 orphan tool-call 块)——
707
+ // 必须保证 wire 格式里 assistant.tool_calls 包含所有 ids,synthetic
708
+ // tool-result 才能与之匹配。如果剥离 orphan tool-call,会留下悬空
709
+ // tool-result 块(Case B:assistant 同时有 responded + orphan ids)。
710
+ out.push(msg);
711
+ // 为每个 missing id 插入一条 user 消息携带合成 tool-result 块
712
+ for (const id of missing) {
713
+ const synthetic = {
714
+ role: 'user',
715
+ content: [
716
+ {
717
+ type: 'tool-result',
718
+ toolCallId: id,
719
+ content: JSON.stringify({
720
+ error: 'aborted',
721
+ reason: 'previous tool call was aborted before completion',
722
+ recovered_by: 'input-guard.sanitizeOrphanToolCalls',
723
+ }),
724
+ },
725
+ ],
726
+ };
727
+ out.push(synthetic);
728
+ }
729
+ }
730
+ if (duplicateCount > 0) {
731
+ // §7.58: 重复 tool_call id 改名是"请求能跑通"级别的修复,warn 可见。
732
+ try {
733
+ this.ctx?.logger?.warn?.(`[input-guard] duplicate-tool-call-id summary sessionId=${sessionId ?? 'unknown'} renamedCalls=${duplicateCount} inputMessages=${messages.length}`);
734
+ }
735
+ catch { /* debug only */ }
736
+ this.audit('warn', 'input-guard.duplicate-tool-call-id.recovered', {
737
+ sessionId,
738
+ duplicateCount,
739
+ originalMessageCount: messages.length,
740
+ });
741
+ }
742
+ if (orphanCount > 0 || duplicateCount > 0 || process.env.DSH_EDGE_INPUT_GUARD_DEBUG === 'true') {
743
+ // §7.46.2: orphan tool-call 清洗在 ops log 也开一行 — 跟主 sanitize
744
+ // 路径不同,这是同步纯本地操作(不调 sanitizer),所以只 emit 一行
745
+ // summary,不做 start/done 配对。orphanCount > 0 时输出,让 operator
746
+ // 在 ops log 里能直接 grep 'orphan-tool-call summary' 看到修复量。
747
+ try {
748
+ this.ctx?.logger?.info?.(`[input-guard] orphan-tool-call summary sessionId=${sessionId ?? 'unknown'} orphanCount=${orphanCount} syntheticInjected=${syntheticInjected} duplicateRenamed=${duplicateCount} inputMessages=${messages.length} outputMessages=${out.length}`);
749
+ }
750
+ catch { /* debug only */ }
751
+ this.audit('warn', 'input-guard.orphan-tool-call.recovered', {
752
+ sessionId,
753
+ orphanCount,
754
+ syntheticInjected,
755
+ originalMessageCount: messages.length,
756
+ recoveredMessageCount: out.length,
757
+ });
758
+ }
759
+ return { messages: out, orphanCount, syntheticInjected, duplicateCount };
760
+ }
761
+ /**
762
+ * 把拒收原因挂到 agent 对象上,供 http-api 在订阅 `session/event` 的
763
+ * turn/end 监听器里读取(因为 agent-loop 把 `{ kind: 'reject' }` 翻译成
764
+ * `turnEnds = { kind: 'blocked' }` 时会丢掉 reason 字符串)。
765
+ *
766
+ * 只在 reject 路径写入;正常 turn 不污染 agent 状态。
767
+ */
768
+ attachRejection(agent, rejection) {
769
+ agent._edgeRejection = rejection;
770
+ }
771
+ /**
772
+ * 读取 agent 上的 edge-route(由 http-api / 其他入口在 followup 前写入)。
773
+ * 避免 input-guard 重复跑 intent-router.classify()(每次 ~0.5-1s)。*/
774
+ readEdgeRoute(agent) {
775
+ const route = agent?._edgeRoute;
776
+ if (!route || typeof route !== 'object')
777
+ return undefined;
778
+ if (typeof route.provider !== 'string' || typeof route.model !== 'string')
779
+ return undefined;
780
+ if (!['LOCAL', 'CLOUD', 'SANITIZED'].includes(route.realm))
781
+ return undefined;
782
+ return route;
783
+ }
784
+ /**
785
+ * §7.175 (2026-11-11): 读宿主给本轮选的模型(agent/pre-step payload **顶层**
786
+ * 的 `modelSelection`)。
787
+ *
788
+ * 宿主契约(dsh-agent-loop 0.2.2-rc.1,实测 app.asar):
789
+ * `preStep` 里 `const selection = this.model.current()`,再以
790
+ * `waterfall('agent/pre-step', { messages, ...position, signal, modelSelection: selection })`
791
+ * 派发 —— `modelSelection` 在 payload 顶层,形状 `{ provider, model, reasoningEffort? }`。
792
+ *
793
+ * 返回 undefined = **拿不到**(缺失 / 不是对象 / provider 不是非空字符串)
794
+ * ⇒ 无法证明本轮走本地 ⇒ 调用方必须 fail-closed。
795
+ */
796
+ readModelSelection(payload) {
797
+ const raw = payload?.modelSelection;
798
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
799
+ return undefined;
800
+ const sel = raw;
801
+ if (typeof sel.provider !== 'string' || sel.provider.length === 0)
802
+ return undefined;
803
+ return {
804
+ provider: sel.provider,
805
+ model: typeof sel.model === 'string' ? sel.model : undefined,
806
+ };
807
+ }
808
+ /** 正向 hook:agent/pre-step handler。暴露给测试。*/
809
+ async handlePreStep(payload, next) {
810
+ // 默认 handler 先跑(组装 systemPrompt 段)
811
+ const decision = await next();
812
+ if (!this.config.enableInputGuard)
813
+ return decision;
814
+ if (decision.kind === 'reject') {
815
+ // 上游 hook 链已经拒了一次(多 hook 串联)。把上一家的 reason
816
+ // 也透传到 _edgeRejection,让 http-api 看到 blocked 时能给客户端
817
+ // 反馈原始原因。
818
+ this.attachRejection(payload.agent, {
819
+ kind: 'policy',
820
+ reason: decision.reason ?? 'upstream-reject',
821
+ });
822
+ return decision;
823
+ }
824
+ const sessionId = payload.agent.session.id ?? 'unknown';
825
+ // §7.175 (2026-11-11): fail-closed 路由闸门挪到了**意图分类之后**(见下方
826
+ // 「§7.175 fail-closed 闸门」注释块)—— 它要拿 category 当条件,而 category
827
+ // 在这一段之后才定下来。
828
+ //
829
+ // 为什么不能放在分类之前:闸门的口径是「本轮判定为 LOCAL,却选了非本地
830
+ // provider ⇒ 这一轮会把原文带出设备 ⇒ 拒收」。判定为 LOCAL 这件事本身
831
+ // 要先分类才知道。
832
+ //
833
+ // 分类之前跑的东西都是**本地**的(prompt-guard 与 intent-router 的分类器
834
+ // 都跑 local-llm-adapter,见 edge preset 的「Internal LLM stages never
835
+ // target cloud-llm」),所以挪后不会让任何东西先出网。
836
+ // 注意:next() 后 decision.messages 可能包含 dsh-agent-instructions 注入的
837
+ // 运行上下文(user-role),会"覆盖"在真实用户消息之上。我们要处理的
838
+ // 是原始 payload.messages 里的最后一条 user 消息。
839
+ const originalLastUser = findLastUserText(payload.messages);
840
+ if (!originalLastUser)
841
+ return decision;
842
+ // §7.131 (2026-10-27): 消息来源观测 —— 纯审计,不改任何行为。
843
+ //
844
+ // 出网裁剪(cloud-egress)依赖「每条 assistant 消息的 source.provider」把
845
+ // 会话历史切成「云段 / 本地段」。这个字段由宿主 dsh-llm 写(见
846
+ // `ModelMessageSource extends AssistantProviderMetadata`),但值是运行时
847
+ // 才有的 —— 静态读代码看不出来它到底填没填、填的是什么。
848
+ //
849
+ // 所以记一行分布:operator / 后续维护者能直接确认「这一段历史是本地
850
+ // 池产生的还是云端产生的」,也就能验证裁剪的判据成立。
851
+ //
852
+ // 只在历史里**已有** assistant 消息时记 —— 首轮没有 assistant,分布是空
853
+ // 的,记了也是噪音。审计只落 info/warn/security/error(debug 被过滤),
854
+ // 所以这一行必须是 info。
855
+ const assistantProviders = assistantProviderHistogram(payload.messages);
856
+ if (Object.keys(assistantProviders).length > 0) {
857
+ this.audit('info', 'input-guard.message-sources', {
858
+ sessionId,
859
+ messageCount: payload.messages.length,
860
+ assistantProviders,
861
+ });
862
+ }
863
+ // 1. 攻击检测 — 最便宜、最先阻断
864
+ if (this.promptGuard) {
865
+ // §7.46.6 (2026-09-12): prompt-guard checkInput 在 ops log 留一行 — 让
866
+ // "prompt-guard 跑了没 / 多少 ms / safe 还是 unsafe" 在 ops log
867
+ // 可见(之前只在 audit JSONL)。safe=true 时 summary 行;unsafe
868
+ // 时单独发一行带 reason + riskLevel 便于 grep 排查。
869
+ const pgStart = Date.now();
870
+ let pgOutcome = 'safe';
871
+ let pgReason;
872
+ let pgRiskLevel;
873
+ try {
874
+ const attackCheck = await this.promptGuard.checkInput(originalLastUser.text, sessionId);
875
+ pgOutcome = attackCheck.safe ? 'safe' : 'unsafe';
876
+ pgReason = attackCheck.reason;
877
+ pgRiskLevel = attackCheck.riskLevel;
878
+ try {
879
+ this.ctx?.logger?.info?.(`[input-guard] prompt-guard done sessionId=${sessionId} durationMs=${Date.now() - pgStart} outcome=${pgOutcome}${pgReason ? ` reason=${pgReason}` : ''}${pgRiskLevel ? ` riskLevel=${pgRiskLevel}` : ''}`);
880
+ }
881
+ catch { /* debug only */ }
882
+ if (!attackCheck.safe) {
883
+ // §7.106 operator ruling: LOCAL turn + credential-class detection
884
+ // → warn and pass (nothing egresses; device-login credentials are
885
+ // the user's own operation). Anything else keeps the block path.
886
+ const localTurn = this.turnCategory.get(sessionId) === 'LOCAL';
887
+ const credentialish = /credential|secret|password|passphrase|凭据|密码/iu.test(attackCheck.reason ?? '');
888
+ if (localTurn && credentialish) {
889
+ this.audit('warn', 'input-guard.attack.warned-local', {
890
+ sessionId,
891
+ reason: attackCheck.reason,
892
+ riskLevel: attackCheck.riskLevel,
893
+ });
894
+ }
895
+ else {
896
+ this.audit('security', 'input-guard.blocked.attack', {
897
+ sessionId,
898
+ reason: attackCheck.reason,
899
+ riskLevel: attackCheck.riskLevel,
900
+ preview: redact(originalLastUser.text),
901
+ });
902
+ if (this.config.rejectOnPolicyViolation) {
903
+ this.attachRejection(payload.agent, {
904
+ kind: 'attack',
905
+ reason: attackCheck.reason ?? 'attack-detected',
906
+ riskLevel: attackCheck.riskLevel,
907
+ });
908
+ return { kind: 'reject', reason: attackCheck.reason ?? 'attack-detected' };
909
+ }
910
+ // fail-open on policy: 审计 + 放行(不推荐)
911
+ this.audit('warn', 'input-guard.attack.fail-open', {
912
+ sessionId, reason: attackCheck.reason,
913
+ });
914
+ }
915
+ }
916
+ }
917
+ catch (err) {
918
+ // prompt-guard 失败不阻断(fail-open)— 它是检测层,不是执行层
919
+ this.audit('error', 'input-guard.prompt-guard.error', {
920
+ sessionId, error: err?.message ?? String(err),
921
+ });
922
+ }
923
+ }
924
+ // 2. 策略判定 — privacy-policy REJECT
925
+ // §7.87.13.2 (2026-09-21): hoisted ppRealm/ppConfidence/ppRationale/ppPattern
926
+ // + edgeRoute to function scope so the §3 routing decision can use them
927
+ // outside the if-block.
928
+ let ppRealm;
929
+ let ppRationale;
930
+ let ppConfidence = 0;
931
+ let ppPattern = '';
932
+ if (this.privacyPolicy) {
933
+ // §7.46.6 (2026-09-12): privacy-policy matchRouting 在 ops log 留一行
934
+ // — 让 "privacy-policy 跑没 / 多少 ms / realm=LOCAL|CLOUD|SANITIZED|REJECT"
935
+ // 在 ops log 可见(之前只在 audit)。配合 preflight intent 让
936
+ // operator 看 trace 知道 policy 路由分类(不是 LLM 分类 intent)。
937
+ const ppStart = Date.now();
938
+ try {
939
+ const routing = this.privacyPolicy.matchRouting(originalLastUser.text);
940
+ ppRealm = routing?.realm;
941
+ ppRationale = routing?.rationale;
942
+ ppConfidence = routing?.confidence ?? 0;
943
+ ppPattern = routing?.pattern ?? '';
944
+ try {
945
+ this.ctx?.logger?.info?.(`[input-guard] privacy-policy done sessionId=${sessionId} durationMs=${Date.now() - ppStart} realm=${ppRealm ?? 'none'}${ppRationale ? ` rationale=${ppRationale}` : ''}`);
946
+ }
947
+ catch { /* debug only */ }
948
+ if (routing?.realm === 'REJECT') {
949
+ this.audit('security', 'input-guard.blocked.policy', {
950
+ sessionId,
951
+ confidence: routing.confidence,
952
+ rationale: routing.rationale,
953
+ preview: redact(originalLastUser.text),
954
+ });
955
+ if (this.config.rejectOnPolicyViolation) {
956
+ this.attachRejection(payload.agent, {
957
+ kind: 'policy',
958
+ reason: routing.rationale ?? 'privacy-policy-reject',
959
+ });
960
+ return { kind: 'reject', reason: routing.rationale ?? 'privacy-policy-reject' };
961
+ }
962
+ // fail-open on policy
963
+ this.audit('warn', 'input-guard.policy.fail-open', {
964
+ sessionId, rationale: routing.rationale,
965
+ });
966
+ }
967
+ }
968
+ catch (err) {
969
+ this.audit('error', 'input-guard.privacy-policy.error', {
970
+ sessionId, error: err?.message ?? String(err),
971
+ });
972
+ }
973
+ }
974
+ // 3. 意图分类 — 优先读 agent._edgeRoute(http-api 已分类),否则 fallback classify
975
+ //
976
+ // §7.87.13.2 (2026-09-21): privacy-policy matchRouting result is now
977
+ // AUTHORITATIVE for non-REJECT routing, not just informational. When a
978
+ // non-REJECT rule matches, use its realm directly and skip the LLM
979
+ // classify step (edgeRoute + intentRouter). REJECT was already
980
+ // authoritative; now LOCAL/SANITIZED/CLOUD are too. This makes
981
+ // 身份证号/手机号/etc. keyword rules actually route to LOCAL, not
982
+ // whatever the LLM picks (which would be CLOUD for any query also
983
+ // containing "查天气" / "今天北京").
984
+ //
985
+ // Why this is safe:
986
+ // - REJECT path unchanged (block + fail-closed, already works).
987
+ // - LLM classify still used when no rule matches (most queries).
988
+ // - policy.confidence ≥ 0.7 gates the override (no low-quality rules).
989
+ // - intentSource + audit kind make the override observable.
990
+ // §7.107 (2026-10-23, operator ruling「默认应该是本地,而不是云端」):
991
+ // the fallback category used to be CLOUD ("分类失败用默认 CLOUD
992
+ // (fail-open)"). An undecided turn now STAYS ON THE DEVICE — the cloud
993
+ // is reached only through an explicit signal (the privacy-policy's
994
+ // public-web rule, a CLOUD keyword, a confident classifier verdict that
995
+ // names a web need, or a caller-supplied route). Everything else is
996
+ // LOCAL, and LOCAL with no local endpoint fails closed with an
997
+ // actionable error rather than silently egressing.
998
+ let category = 'LOCAL';
999
+ let edgeRouteUsed = false;
1000
+ let intentConfidence = 0;
1001
+ let intentRationale;
1002
+ let policyClassified = false;
1003
+ let targetRealm = 'LOCAL';
1004
+ // §7.87.13.2 (2026-09-21): edgeRoute hoisted to function scope so §4-5
1005
+ // (cloud-key-missing + sanitize-skip path) can still resolve it after
1006
+ // policyClassified short-circuits the if/else below.
1007
+ let edgeRoute;
1008
+ if (ppRealm && ppRealm !== 'REJECT' && ppConfidence >= 0.7) {
1009
+ // Privacy-policy override — non-REJECT rule wins over LLM/edgeRoute.
1010
+ category = ppRealm;
1011
+ intentConfidence = ppConfidence;
1012
+ intentRationale = ppRationale ?? `policy-rule:${ppRealm.toLowerCase()}`;
1013
+ policyClassified = true;
1014
+ this.audit('info', 'input-guard.policy.route-applied', {
1015
+ sessionId,
1016
+ policyRealm: ppRealm,
1017
+ policyConfidence: ppConfidence,
1018
+ policyRationale: ppRationale,
1019
+ policyPattern: ppPattern,
1020
+ });
1021
+ }
1022
+ else {
1023
+ edgeRoute = this.readEdgeRoute(payload.agent);
1024
+ if (edgeRoute) {
1025
+ if (edgeRoute.category === 'REJECT') {
1026
+ // §7.128: 调用方显式判了 REJECT —— 那是「拦下」的意思。此前这行
1027
+ // 是 `edgeRoute.category === 'REJECT' ? 'CLOUD' : edgeRoute.category`:
1028
+ // 把「拦下」静默升格成「上云」—— 本该拦下的原文反而送上云。
1029
+ // 今天走不到(http-api 的 REJECT 短路先于 _edgeRoute 写入,宿主
1030
+ // 也从不写它 —— app.asar 里 `_edgeRoute` 0 命中),所以是颗
1031
+ // **未引爆的地雷**:任何新增入口(fork / 新路由 / 宿主自己写)
1032
+ // 都会把「拦下」变成「上云」。按与 intentRouter REJECT 完全相同
1033
+ // 的语义处理:fail-closed 拦下,fail-open 交给下面的统一降级点。
1034
+ const reason = edgeRoute.rationale ?? 'edge-route-reject';
1035
+ this.audit('security', 'input-guard.blocked.edge-route', {
1036
+ sessionId, confidence: edgeRoute.confidence, rationale: edgeRoute.rationale,
1037
+ });
1038
+ if (this.config.rejectOnPolicyViolation) {
1039
+ this.attachRejection(payload.agent, { kind: 'intent', reason });
1040
+ return { kind: 'reject', reason };
1041
+ }
1042
+ this.audit('warn', 'input-guard.edge-route.fail-open', {
1043
+ sessionId, rationale: edgeRoute.rationale,
1044
+ });
1045
+ }
1046
+ category = edgeRoute.category;
1047
+ edgeRouteUsed = true;
1048
+ intentConfidence = edgeRoute.confidence ?? 0;
1049
+ intentRationale = edgeRoute.rationale;
1050
+ }
1051
+ else if (this.intentRouter) {
1052
+ try {
1053
+ const intent = await this.intentRouter.classify(originalLastUser.text, { sessionId });
1054
+ category = intent.category;
1055
+ intentConfidence = intent.confidence ?? 0;
1056
+ intentRationale = intent.rationale;
1057
+ if (category === 'REJECT') {
1058
+ this.audit('warn', 'input-guard.blocked.intent', {
1059
+ sessionId, confidence: intent.confidence, rationale: intent.rationale,
1060
+ });
1061
+ if (this.config.rejectOnPolicyViolation) {
1062
+ this.attachRejection(payload.agent, {
1063
+ kind: 'intent',
1064
+ reason: intent.rationale ?? 'intent-reject',
1065
+ });
1066
+ return { kind: 'reject', reason: intent.rationale ?? 'intent-reject' };
1067
+ }
1068
+ }
1069
+ }
1070
+ catch (err) {
1071
+ this.audit('error', 'input-guard.intent-router.error', {
1072
+ sessionId, error: err?.message ?? String(err),
1073
+ });
1074
+ // 分类失败 → 保持默认 LOCAL(§7.107;此前是 CLOUD fail-open)
1075
+ }
1076
+ // §7.107: the public-web gate is NOT here — it is in the intent
1077
+ // router (`classify()` → `gateCloudVerdict`). The router's verdict
1078
+ // is read by two consumers (this hook and http-api's `_edgeRoute`),
1079
+ // and http-api hands it over as an EXPLICIT route that this hook
1080
+ // honours as-is — a gate here would have closed only this path.
1081
+ // A caller-supplied route stays honoured: it IS an explicit
1082
+ // statement of intent.
1083
+ }
1084
+ }
1085
+ // §7.128: **REJECT 绝不走 CLOUD。**
1086
+ // 走到这里说明 rejectOnPolicyViolation=false(fail-open)—— fail-closed
1087
+ // 的两条分支早已 return。REJECT 的意思是「这条别处理」,让它出网(哪怕
1088
+ // 去云池)恰恰是它说的反面:category='REJECT' 会让 shouldSanitize=false
1089
+ // 且 wantsDowngradeToLocal=false,于是原始路由(CLOUD)被原样保留 ——
1090
+ // 文本真的上云。统一在这里降级到 LOCAL:留在设备上继续。
1091
+ if (category === 'REJECT') {
1092
+ this.audit('warn', 'input-guard.reject.degraded-local', {
1093
+ sessionId, rationale: intentRationale,
1094
+ });
1095
+ category = 'LOCAL';
1096
+ intentRationale = intentRationale
1097
+ ? `${intentRationale}+reject-degraded-local`
1098
+ : 'reject-degraded-local';
1099
+ }
1100
+ // §7.106 (2026-10-23) 凭据粘性规则(操作者裁决:凭据交付不应走云端):
1101
+ // 「用户名:X;密码:Y」这类消息是设备操作链(登录)的一环。上一轮被
1102
+ // 实体规则强制 LOCAL 时,交密码的后续轮必须留在 LOCAL——云端拿到的是
1103
+ // [CREDENTIAL_1] 占位符,编排出的登录命令必然失败(功能死路),而
1104
+ // 本地模型能拿真凭据完成 etscli auth login。方向与棘轮一致:只允许
1105
+ // 拧向更保守(LOCAL),绝不放宽。桌面实测驱动:13:03 minimax 轮把密码
1106
+ // 掩成占位符后询问「登录怎么做」,链路卡死。
1107
+ if ((category === 'CLOUD' || category === 'SANITIZED')
1108
+ && this.turnCategory.get(sessionId) === 'LOCAL'
1109
+ && containsCredential(originalLastUser.text)) {
1110
+ this.audit('warn', 'input-guard.intent.sticky-local', {
1111
+ sessionId, from: category, to: 'LOCAL',
1112
+ rationale: 'credential-handoff-follows-local-context',
1113
+ });
1114
+ category = 'LOCAL';
1115
+ intentRationale = 'sticky-credential-local-context';
1116
+ intentConfidence = Math.max(intentConfidence, 0.9);
1117
+ }
1118
+ // §7.46 (2026-09-11): ops log 让"为什么路由到 SANITIZED/LOCAL/CLOUD"
1119
+ // 在每个 chat 都可见;TUI 路径之前只能靠 audit log 翻 JSONL,operator
1120
+ // 调 ops log 完全看不到 routing 决策。
1121
+ // §7.46.3 (2026-09-12): routeSource 字段加在 preflight 行 — 'http-api'
1122
+ // 表示 _edgeRoute 由 http-api 写入(含 category / confidence / rationale
1123
+ // 等已分类信息),'webui' 表示 dsh-web-app / dsh-tui chat 入口没写
1124
+ // _edgeRoute(intent-router fallback classify)。operator 看 ops log
1125
+ // 一眼区分这两种入口,便于排查"哪个入口配的路由决策"。
1126
+ // §7.46.7 (2026-09-12): intentSource 字段加在 preflight 行 — 区分
1127
+ // 意图分类的三条来源路径:(a) 'http-api-classified' _edgeRoute 由
1128
+ // http-api resolveRoute 写入;(b) 'llm-classified' intentRouter.classify()
1129
+ // 跑了 local-llm-adapter;(c) 'default-fallback' 两者都没跑(intentRouter
1130
+ // 抛错 / 不存在)。operator 一眼区分"intent 实际怎么来的"——
1131
+ // 之前只能靠 rationale 字段反推(default:sanitized / llm-classified:* /
1132
+ // http-api-classified:*),mibao trace 看到 default:sanitized 时 root cause
1133
+ // 不明显(是 local-llm hang fallback?还是 http-api resolveRoute 返回?)
1134
+ // §7.128: 映射写死「只有 CLOUD 才是 CLOUD」—— REJECT 永远不是 CLOUD。
1135
+ // 上一段已把 REJECT 降级为 LOCAL,这里是第二道保险:万一将来又有一条
1136
+ // 路径把 REJECT 塞进 category,它也不会被映射成出网。
1137
+ targetRealm = category === 'CLOUD' ? 'CLOUD' : 'LOCAL';
1138
+ // ── §7.175 (2026-11-11): fail-closed 路由闸门 ──────────────────────────
1139
+ //
1140
+ // 口径(主人裁决 2026-11-10「边缘模式恒用本地模型」,2026-11-11 补充
1141
+ // 「用哪个模型是用户自己的行为,别管」):
1142
+ //
1143
+ // · 本轮**判定为 LOCAL**(按端侧策略这一轮必须留在设备上),而宿主选的
1144
+ // provider **不是本地 provider** ⇒ 这一轮会把原文带出设备 ⇒
1145
+ // **reject**(fail-closed)。
1146
+ // 宿主选的 provider 来自 `agents.create({agentOptions.provider})`,
1147
+ // 改不了(宿主的 prepareRequest() 会对被改写的 provider/model 直接
1148
+ // 抛错,见 handleAgentRequest)⇒ 只能拒,不能改。
1149
+ // · 反过来「判定要出网、但用户选的是本地模型」**不拦** —— 用哪个模型
1150
+ // 是用户自己的行为,input-guard 不替用户做这个决定(§7.174 字面口径:
1151
+ // 「本轮 realm 判 LOCAL 而 selection 不是本地模型 ⇒ reject」)。
1152
+ // · modelSelection 拿不到 ⇒ 无法证明这一轮留在本地 ⇒ 按 fail-closed 拒
1153
+ // (只在判定为 LOCAL 时才需要证明它)。
1154
+ //
1155
+ // 命中即 reject(绝不「放行到云端再说」),并把原因挂到
1156
+ // agent._edgeRejection —— agent-loop 翻译成 blocked 时会丢掉 reason
1157
+ // 字符串,http-api 只能靠这张小抄拿到原因(见 attachRejection)。
1158
+ //
1159
+ // §7.175: 读一次,后面两处都用它(闸门 + `intent.local` 审计行)。
1160
+ const selection = this.readModelSelection(payload);
1161
+ if (category === 'LOCAL') {
1162
+ // §7.184 (2026-11-11,主人裁决「选择器完全不动,实际请求改道到本地池」):
1163
+ // 会话选型**不再**决定这一轮放不放行。
1164
+ //
1165
+ // 旧口径(§7.175):判定 LOCAL 而选型不是本地 provider ⇒ **拒绝这一轮**
1166
+ // (`model-selection-not-local`)。那时改不了路由(宿主的 prepareRequest()
1167
+ // 会对改写直接抛错),只能拒。现在能在**出网那一跳**改道(llm/stream 把
1168
+ // provider 换成本地池)⇒ 拒收没有意义:改道过去,这一轮就在本地跑。
1169
+ //
1170
+ // 旧口径里「用户明确选过云端模型 ⇒ 拒」其实与 §7.174「用哪个模型是用户
1171
+ // 自己的行为」相冲突 —— 现在两者都成立:选择器保持用户选的那个,
1172
+ // 而「本轮留在设备上」由改道保证。
1173
+ if (selection !== undefined && selection.provider !== this.config.localProvider) {
1174
+ this.audit('info', 'input-guard.reroute.planned', {
1175
+ sessionId,
1176
+ category,
1177
+ from: { provider: selection.provider, model: selection.model },
1178
+ to: this.config.localProvider,
1179
+ reason: 'edge-mode-local-intent',
1180
+ });
1181
+ }
1182
+ // 本地路由必须真的存在(§7.171):宿主选了本地 provider,但路由没
1183
+ // 注册时这一轮必然失败。此时**不能**改写路由(宿主会抛错),也**不能**
1184
+ // 放行到云端 —— fail-closed:明确报错,并给出可操作的配置指引。
1185
+ //
1186
+ // 这一段只在「判定为 LOCAL」时跑 ⇒ 报错文案里那句「本轮 intent 判定为
1187
+ // LOCAL」是真的(§7.174 的门禁 verify-config-hints.mjs 也按这句字面量
1188
+ // 抽取本提示)。反向情形(判定为 CLOUD、但用户选了本地模型)不归这里管。
1189
+ const local = this.localAvailable();
1190
+ if (!local.ok) {
1191
+ this.audit('security', 'input-guard.local.route-missing', {
1192
+ sessionId,
1193
+ reason: local.reason,
1194
+ provider: this.config.localProvider,
1195
+ });
1196
+ try {
1197
+ this.ctx?.logger?.error?.(`[input-guard] local route unavailable sessionId=${sessionId} provider=${this.config.localProvider} reason=${local.reason} — refusing to fall back to a non-local route`);
1198
+ }
1199
+ catch { /* debug only */ }
1200
+ throw new Error(`input-guard: 本轮 intent 判定为 LOCAL(宿主 modelSelection 指向 "${this.config.localProvider}"),按隐私裁决必须使用本地模型,`
1201
+ + `但本地路由 "${this.config.localProvider}" 不可用(${local.reason})。`
1202
+ + `为避免本地数据/PII 出境,已阻止本轮上云。`
1203
+ // §7.159 (2026-10-30): the old hint pointed at DSH_EDGE_LOCAL_LLM_BASEURL
1204
+ // and called keyRef "an env-slot name". Both were wrong for the person
1205
+ // who actually hits this:
1206
+ //
1207
+ // · the desktop GUI starts the host WITHOUT sourcing any .env, so
1208
+ // process.env has no DSH_EDGE_* — the hint was unfollowable
1209
+ // (measured on a clean Windows device: the operator set the env
1210
+ // var and the route still did not register);
1211
+ // · since §7.102 the pool lives in config/edge.json and `keyRef`
1212
+ // is the LITERAL key under that file's `secrets` section, not the
1213
+ // name of an environment variable.
1214
+ //
1215
+ // So: point at the file first, keep the env vars as the fallback
1216
+ // for headless profiles that CAN set them.
1217
+ + `请配置本地 LLM 端点:编辑 ~/.dsh-edge-agent/edge.json 的 llm.local 段,`
1218
+ + `填 baseUrl(OpenAI 兼容端点,必须含 /v1)与 model;`
1219
+ + `端点需要鉴权时,把密钥写进同一文件的 secrets 段,`
1220
+ + `并把 llm.local.keyRef 指向那个键名(keyRef 是 secrets 里的键名,不是环境变量名)。`
1221
+ + `若能设置进程环境变量,也可用 DSH_EDGE_LOCAL_LLM_BASEURL / DSH_EDGE_LOCAL_LLM_MODEL / `
1222
+ + `DSH_EDGE_LOCAL_LLM_KEY_ENV 覆盖,但桌面 GUI 启动的宿主读不到它们。`);
1223
+ }
1224
+ }
1225
+ const routeSource = edgeRouteUsed ? 'http-api' : 'webui';
1226
+ // §7.87.13.2 (2026-09-21): 'policy-classified' added — matchRouting result
1227
+ // overrode LLM/edgeRoute (see §7.87.13.2 comment block above). Distinct
1228
+ // from 'llm-classified' so ops log shows when privacy-policy is the source
1229
+ // of truth vs LLM fallback.
1230
+ const intentSource = policyClassified
1231
+ ? 'policy-classified'
1232
+ : (edgeRouteUsed
1233
+ ? 'http-api-classified'
1234
+ : (this.intentRouter ? 'llm-classified' : 'default-fallback'));
1235
+ try {
1236
+ this.ctx.logger?.info?.(`[input-guard] preflight intent sessionId=${sessionId} category=${category} confidence=${intentConfidence} rationale=${intentRationale ?? 'n/a'} targetRealm=${targetRealm} routeSource=${routeSource} intentSource=${intentSource}`);
1237
+ }
1238
+ catch { /* debug only */ }
1239
+ this.audit('info', 'input-guard.routed', {
1240
+ sessionId, category, edgeRouteUsed, routeSource,
1241
+ });
1242
+ // §7.106: remember the verdict for the next step's attack-block gate.
1243
+ this.putTurnCategory(sessionId, category);
1244
+ // §7.36 (2026-09-04): 注册 session realm 给 realm-skill-bridge,
1245
+ // 让 webui / tui / dsh-tui chat 入口也能被 Layer 3 tool-realm gating 守卫。
1246
+ //
1247
+ // 之前 setSessionRealm 只在 http-api 入口调用(http-api.ts:511 / 717)。
1248
+ // dsh-web-app / dsh-tui chat 入口走 dsh-agent-loop 时不挂 _edgeRoute,
1249
+ // realmSkillBridge.sessionRealms 没有这个 sessionId →
1250
+ // checkToolRealm 走 fail-open 分支(agentRealm === undefined)→
1251
+ // web_search / web_fetch 等 CLOUD tool 不被 deny,LOCAL LLM 自由上云搜。
1252
+ //
1253
+ // input-guard 在分类后立即补一次 setSessionRealm,使 webui/tui 入口的
1254
+ // session 也能被 realm-skill-bridge 识别。
1255
+ // §7.128: 此前这里是 `if (category !== 'REJECT')` —— REJECT 不注册 realm,
1256
+ // 因为那一轮不跑。但 §7.128 之后 REJECT 已被统一降级点改成 LOCAL
1257
+ // (fail-open 时),那一轮**会**跑:若仍不注册 realm,gating 又走
1258
+ // fail-open(agentRealm === undefined)→ 本地模型可以调 web_search 出网
1259
+ // —— 正好是 REJECT 要防的那件事。所以现在无条件注册(类型也随之收窄到
1260
+ // 'LOCAL' | 'CLOUD' | 'SANITIZED',与 setSessionRealm 签名一致)。
1261
+ // 失败/缺失时静默 no-op(realmSkillBridge 是可选依赖)。
1262
+ const bridge = this.ctx.realmSkillBridge;
1263
+ if (bridge && typeof bridge.setSessionRealm === 'function') {
1264
+ try {
1265
+ bridge.setSessionRealm(sessionId, category);
1266
+ this.audit('info', 'input-guard.session-realm.registered', {
1267
+ sessionId,
1268
+ category,
1269
+ edgeRouteUsed,
1270
+ routeSource: edgeRouteUsed ? 'http-api' : 'webui',
1271
+ });
1272
+ }
1273
+ catch (err) {
1274
+ this.audit('warn', 'input-guard.session-realm.failed', {
1275
+ sessionId, error: err?.message ?? String(err),
1276
+ });
1277
+ }
1278
+ }
1279
+ // §7.175 (2026-11-11): 「云端不可用 → 降级 LOCAL」这条路径退役了。
1280
+ //
1281
+ // 它当年唯一的用途是**改写路由**(agent/request 里把 provider 换成
1282
+ // local-llm)。现在路由由宿主的 modelSelection 决定、input-guard 不再
1283
+ // 改写,而且 §7.175 的 fail-closed 闸门已经把「本轮要派发到非本地
1284
+ // provider」的轮次全部拒掉 —— 能跑到这里的轮次,路由恒为本地。
1285
+ //
1286
+ // 所以云池通不通已经不影响本轮(它根本不会被用到),既不降级、也不
1287
+ // 跳过脱敏:`shouldSanitize` 仍按意图分类决定,下面照常脱敏。
1288
+ // 4. 脱敏(仅 CLOUD 路径,或显式开启 LOCAL)
1289
+ const shouldSanitize = category === 'CLOUD' || category === 'SANITIZED' || this.config.sanitizeLocalTraffic;
1290
+ if (!shouldSanitize || !this.sanitizer) {
1291
+ // §7.175: 意图分类不再决定路由 —— 路由恒为宿主的 modelSelection
1292
+ // (本地,由上面的 fail-closed 闸门保证)。这里保留的只有两件事:
1293
+ // 记下这一轮的状态,以及把「意图判定为 LOCAL」记进审计。
1294
+ //
1295
+ // routeSource='http-api' when _edgeRoute was set on agent (even malformed;
1296
+ // raw presence = http-api wrote it). 'webui' when _edgeRoute absent.
1297
+ const hasRawEdgeRoute = typeof payload.agent?._edgeRoute !== 'undefined';
1298
+ const routeSource = hasRawEdgeRoute ? 'http-api' : 'webui';
1299
+ this.putStepState(sessionId, {
1300
+ sanitized: false, replacements: 0, sessionId, timestamp: Date.now(),
1301
+ routeSource,
1302
+ });
1303
+ if (category === 'LOCAL') {
1304
+ this.audit('security', 'input-guard.intent.local', {
1305
+ sessionId, category, reason: 'intent-local', routeSource,
1306
+ provider: selection?.provider,
1307
+ model: selection?.model,
1308
+ });
1309
+ }
1310
+ return decision;
1311
+ }
1312
+ // §7.46.2 (2026-09-12): sanitize call ops log — 让"调了 sanitizer /
1313
+ // 替换了几个 PII / 多久 / 失败原因"在 ops log 里可见。start 在
1314
+ // `sanitizer.sanitize()` 之前 emit;done 在 success 分支 emit(含
1315
+ // replacementCount 和是否替换文本);wireError 字段跟
1316
+ // cloud/local-llm-adapter §7.46.1 done 行对齐。失败由外层
1317
+ // catch(handlePreStep 原结构)继续处理 sanitize-failed audit。
1318
+ const sanitizeStart = Date.now();
1319
+ // §7.123 (2026-10-27): sanitize EVERY user message, not just the last.
1320
+ // The agent loop ships the whole conversation to the model, so a PII
1321
+ // value typed in an earlier turn rode along in the history unredacted.
1322
+ // `userMessages` comes from payload.messages (the real user turns) —
1323
+ // decision.messages additionally carries synthetic user-role runtime
1324
+ // context injected by dsh-agent-instructions, which is not user content
1325
+ // and must not be rewritten.
1326
+ const userMessages = findUserTexts(payload.messages);
1327
+ try {
1328
+ this.ctx?.logger?.info?.(`[input-guard] sanitize start sessionId=${sessionId} category=${category} inputLen=${originalLastUser.text.length} userMessages=${userMessages.length}`);
1329
+ }
1330
+ catch { /* debug only */ }
1331
+ try {
1332
+ // Sanitize each user turn independently. The sanitizer's mapping table
1333
+ // is session-scoped, so the same PII value across turns collapses onto
1334
+ // the same placeholder (and `maxReplacementsPerSession` still bounds
1335
+ // the session-wide total).
1336
+ const sanitizedByIndex = new Map();
1337
+ const piiTypes = new Set();
1338
+ let totalReplacements = 0;
1339
+ for (const um of userMessages) {
1340
+ const result = await this.sanitizer.sanitize(um.text, { sessionId });
1341
+ if (result.replacements.length === 0)
1342
+ continue;
1343
+ totalReplacements += result.replacements.length;
1344
+ for (const r of result.replacements)
1345
+ piiTypes.add(r.piiType);
1346
+ sanitizedByIndex.set(um.index, result.sanitizedText);
1347
+ }
1348
+ const replacements = totalReplacements;
1349
+ const sanitizeDurationMs = Date.now() - sanitizeStart;
1350
+ if (replacements > 0) {
1351
+ // 用 index(来自 payload.messages)替换 decision.messages 里对应的
1352
+ // user 消息 —— 不是最后一条 user,因为 dsh-agent-instructions 可能在
1353
+ // 末尾追加了 user-role 注入。
1354
+ const newMessages = replaceUserTextsAt(decision.messages ?? payload.messages, sanitizedByIndex);
1355
+ this.putStepState(sessionId, {
1356
+ sanitized: true, replacements, sessionId, timestamp: Date.now(),
1357
+ });
1358
+ this.audit('info', 'input-guard.sanitized', {
1359
+ sessionId, replacementCount: replacements, category,
1360
+ // §7.123: how many user turns were rewritten — the "1" that used
1361
+ // to be implied by "only the last message" is now explicit.
1362
+ sanitizedMessages: sanitizedByIndex.size,
1363
+ userMessages: userMessages.length,
1364
+ piiTypes: [...piiTypes],
1365
+ });
1366
+ // §7.46.2: sanitize done 行 (success, replaced) — 跟 start 行配对
1367
+ try {
1368
+ this.ctx?.logger?.info?.(`[input-guard] sanitize done sessionId=${sessionId} category=${category} durationMs=${sanitizeDurationMs} replacementCount=${replacements} sanitizedMessages=${sanitizedByIndex.size} outcome=replaced`);
1369
+ }
1370
+ catch { /* debug only */ }
1371
+ return { ...decision, messages: newMessages };
1372
+ }
1373
+ // 没替换:状态标记 sanitized=false
1374
+ this.putStepState(sessionId, {
1375
+ sanitized: false, replacements: 0, sessionId, timestamp: Date.now(),
1376
+ });
1377
+ // §7.46.2: sanitize done 行 (success, no replacement) — sanitizer 跑了一
1378
+ // 遍但没替换;区别 outcome=clean 跟 outcome=replaced
1379
+ try {
1380
+ this.ctx?.logger?.info?.(`[input-guard] sanitize done sessionId=${sessionId} category=${category} durationMs=${sanitizeDurationMs} replacementCount=0 outcome=clean`);
1381
+ }
1382
+ catch { /* debug only */ }
1383
+ return decision;
1384
+ }
1385
+ catch (err) {
1386
+ // ── sanitize 失败 ────────────────────────────────────────
1387
+ // 系统故障(不是 user 行为不端)。两条路径:
1388
+ // - downgradeOnSanitizeFailure=true (默认):放行本轮。§7.175 起
1389
+ // 这里**不再有「降级」** —— 路由恒为宿主的 modelSelection(本地,
1390
+ // 由 fail-closed 闸门保证),原始 PII 留在本地模型,不出境。
1391
+ // - downgradeOnSanitizeFailure=false:REJECT step,user 看到空
1392
+ // result(保守策略,配置可选)。
1393
+ const reason = err?.message ?? String(err);
1394
+ const sanitizeDurationMs = Date.now() - sanitizeStart;
1395
+ // §7.46.2: sanitize failed 行 — 让 operator 在 ops log 看到失败原因 +
1396
+ // duration,外层 catch 仍按原逻辑处理 audit。
1397
+ try {
1398
+ this.ctx?.logger?.info?.(`[input-guard] sanitize failed sessionId=${sessionId} category=${category} durationMs=${sanitizeDurationMs} wireError=${reason}`);
1399
+ }
1400
+ catch { /* debug only */ }
1401
+ this.audit('security', 'input-guard.sanitize.failed', {
1402
+ sessionId, error: reason, category,
1403
+ });
1404
+ if (!this.config.downgradeOnSanitizeFailure) {
1405
+ this.attachRejection(payload.agent, {
1406
+ kind: 'sanitizer',
1407
+ reason: 'sanitizer-failed',
1408
+ });
1409
+ return { kind: 'reject', reason: 'sanitizer-failed' };
1410
+ }
1411
+ // 放行:messages 不修改(保留原文)—— 本轮走本地模型,不出境。
1412
+ this.putStepState(sessionId, {
1413
+ sanitized: false, replacements: 0, sessionId, timestamp: Date.now(),
1414
+ });
1415
+ return decision;
1416
+ }
1417
+ }
1418
+ /** `agent/request` handler —— §7.175 起**只放行,不改路由**。
1419
+ *
1420
+ * 为什么不再改写:宿主 dsh-agent-loop 的 prepareRequest() 会拿
1421
+ * `proposedConfig` 与 `this.model.current()`(宿主给本轮选的模型)比对,
1422
+ * provider / model / reasoningEffort 任一不同就**直接抛错**。所以在
1423
+ * agent/request 里改写 provider/model 不是「降级到本地」,而是让整轮
1424
+ * 直接失败。
1425
+ *
1426
+ * 现在:路由恒由宿主的 modelSelection 决定,边缘模式恒为本地模型
1427
+ * (主人裁决 2026-11-10);「本该本地却可能上云」的判定与 fail-closed
1428
+ * 拦截都在 handlePreStep 里做(见那里的 modelSelection 闸门)。
1429
+ *
1430
+ * 这里保留这个 handler 只为把「不改路由」这条契约钉死:seedConfig 原样
1431
+ * 返回,一个字节都不改。
1432
+ *
1433
+ * 注意 DSH waterfall 契约:监听器真实签名是 `(payload, next)`,
1434
+ * 其中 `payload` 已被 dsh-agent 的 fused wrapper 注入 `agent` 字段
1435
+ * (见 @deepseek-ai/dsh-agent/lib/types/dispatch.js#agentEvents)。
1436
+ * 上一层 apply() 必须先 `await next()` 取出 seedConfig 后再传进来,
1437
+ * 因为 next() 是 chain 的下一步,handler 不能跳过它直接返回。*/
1438
+ async handleAgentRequest(payload, seedConfig, next) {
1439
+ // §7.175: 不改路由。seedConfig 原样返回。
1440
+ return seedConfig;
1441
+ }
1442
+ /**
1443
+ * §7.144: 宿主 provider 的调用要不要记?返回一个记录器,或 undefined。
1444
+ *
1445
+ * **不记**的三种情形:
1446
+ *
1447
+ * 1. provider 属于本项目自管池(`local-llm` / `cloud-llm`)—— adapter
1448
+ * 自己已经记过(REQ + RESP),这里再记一遍面板上就是同一通调用出现
1449
+ * 两条。
1450
+ * 2. `purpose` 非空 —— 宿主的内部用途(compaction / session-title),
1451
+ * 不是主人关心的「这一轮调用」,记了只给面板添噪音。
1452
+ * 3. provider 缺失 / trace 总开关关闭 / 任何异常 ⇒ 不记(观测路径永不
1453
+ * 拖垮主链路)。
1454
+ *
1455
+ * 记的那一类是**宿主自己的 provider**(delegate 出去的云池,如
1456
+ * `qwen-token-plan-cn`)—— 它不经过本项目任何 adapter,是面板里唯一
1457
+ * 「完全看不见」的一跳。
1458
+ */
1459
+ hostCallRecorder(options) {
1460
+ try {
1461
+ if (!this.traceConfig.enabled)
1462
+ return undefined;
1463
+ const provider = typeof options?.provider === 'string' ? options.provider : '';
1464
+ if (provider.length === 0)
1465
+ return undefined;
1466
+ const own = OWN_POOL_PROVIDERS.includes(this.config.localProvider)
1467
+ ? OWN_POOL_PROVIDERS
1468
+ : [...OWN_POOL_PROVIDERS, this.config.localProvider];
1469
+ if (isOwnPoolProvider(provider, own))
1470
+ return undefined;
1471
+ const purpose = typeof options?.purpose === 'string' ? options.purpose : '';
1472
+ if (purpose.length > 0)
1473
+ return undefined;
1474
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : '';
1475
+ if (sessionId.length === 0)
1476
+ return undefined;
1477
+ const request = {
1478
+ provider,
1479
+ model: typeof options?.model === 'string' ? options.model : '?',
1480
+ sessionId,
1481
+ messages: Array.isArray(options?.messages)
1482
+ ? options.messages
1483
+ : [],
1484
+ };
1485
+ if (typeof options?.system === 'string')
1486
+ request.system = options.system;
1487
+ if (Array.isArray(options?.tools)) {
1488
+ request.tools = options.tools;
1489
+ }
1490
+ const params = {};
1491
+ if (typeof options?.temperature === 'number')
1492
+ params.temperature = options.temperature;
1493
+ if (typeof options?.maxTokens === 'number')
1494
+ params.maxTokens = options.maxTokens;
1495
+ if (Object.keys(params).length > 0)
1496
+ request.params = params;
1497
+ const sink = {
1498
+ log: (entry) => this.auditLog?.log(entry) ?? Promise.resolve(),
1499
+ store: getTraceStore(),
1500
+ };
1501
+ // §7.134: REQ 与 RESP 共用一个 callId ⇒ 面板上仍是「上请求、下响应」。
1502
+ // 键取 options 的对象身份:同一次往返拿到的是**同一个**引用。
1503
+ return new HostCallRecorder({
1504
+ sink,
1505
+ config: this.traceConfig,
1506
+ request,
1507
+ callId: callIdFor(options),
1508
+ });
1509
+ }
1510
+ catch {
1511
+ return undefined;
1512
+ }
1513
+ }
1514
+ /**
1515
+ * §7.151 (2026-10-29): 换掉请求体**重新派发**这一通出网调用。
1516
+ *
1517
+ * 为什么不能就地改写:宿主契约明写 LOOP 构建的请求是 **deep-frozen**
1518
+ * (`markAgentLoopRequest` + `deepFreeze`),"listeners read it, never rewrite
1519
+ * it"。所以唯一能换掉请求体的姿势就是换一个请求对象再派发一次 ——
1520
+ * 与 `cloud-llm-adapter` 的 delegate 路径同一个姿势,宿主侧已验证可行。
1521
+ *
1522
+ * 重新派发会**再进一次 waterfall**:本方法给裁过的请求对象打标记
1523
+ * (`HostEgressGuard.markTrimmed`),守卫下一次看到它直接放行 ⇒ 不递归、
1524
+ * 不二次裁剪;那一趟里 trace 记录的是**真正发出去的那一份**。
1525
+ *
1526
+ * fail-closed:裁不动就不许出网 —— 放行等于把本地历史原样送上去。
1527
+ */
1528
+ async *dispatchTrimmed(options, egress, state) {
1529
+ const provider = typeof options.provider === 'string' ? options.provider : '';
1530
+ const model = typeof options.model === 'string' ? options.model : '';
1531
+ const trimmed = { ...options, messages: egress.messages };
1532
+ this.hostEgressGuard.markTrimmed(trimmed);
1533
+ this.audit('info', 'llm.cloud.egress', {
1534
+ ...sessionIdField(state.sessionId),
1535
+ provider,
1536
+ model,
1537
+ // via 字段区分是哪一跳裁的:'host-provider' = 宿主原生 provider(桌面
1538
+ // GUI 选的模型,本部署的常态);cloud-llm-adapter 自己出的行没有此字段。
1539
+ via: 'host-provider',
1540
+ keptMessages: egress.messages.length,
1541
+ droppedLocalHistory: egress.droppedCount,
1542
+ replacements: state.replacements,
1543
+ // 本轮 pre-step 是否做过脱敏(脱敏在 pre-step 就完成了 —— 那里改的是
1544
+ // 会话日志里的用户消息,出网请求带的就是脱敏后的文本)。
1545
+ sanitized: state.sanitized,
1546
+ providerHistogram: egress.histogram,
1547
+ });
1548
+ // §7.151 (2026-10-29): 取 LLM runtime **必须走 `ctx.get()`**,不能直取
1549
+ // `ctx.llm` —— cordis 4.x 的 ctx 是 Proxy:input-guard 的 inject 是
1550
+ // `['promptGuard','privacyPolicy','intentRouter','sanitizer','auditLog',
1551
+ // 'realmSkillBridge']`,**没有 'llm'**,而本插件跑在边缘模式 preset 的
1552
+ // scope 里 ⇒ 直取 `ctx.llm` 会**直接抛**
1553
+ // `cannot get property "llm" without inject`(15:54 实机:整轮处理失败)。
1554
+ //
1555
+ // 同文件的 `localAvailable()` 用 `this.ctx.get?.('modelRouter')` 取 runtime
1556
+ // —— 这里跟它同款。
1557
+ const llm = (() => {
1558
+ try {
1559
+ return this.ctx.get?.('llm');
1560
+ }
1561
+ catch {
1562
+ return undefined;
1563
+ }
1564
+ })();
1565
+ if (typeof llm?.stream !== 'function') {
1566
+ this.audit('security', 'llm.cloud.egress.blocked', {
1567
+ ...sessionIdField(state.sessionId),
1568
+ provider,
1569
+ model,
1570
+ reason: 'llm-runtime-unavailable',
1571
+ droppedLocalHistory: egress.droppedCount,
1572
+ });
1573
+ throw new Error('input-guard: 出网裁剪无法重新派发(宿主 LLM runtime 不可用)—— 已阻止本轮出网');
1574
+ }
1575
+ const raw = llm.stream.call(llm, trimmed);
1576
+ const resolved = (raw != null && typeof raw.then === 'function')
1577
+ ? await raw
1578
+ : raw;
1579
+ yield* resolved;
1580
+ }
1581
+ /**
1582
+ * 反向 hook:llm/stream handler。暴露给测试。
1583
+ *
1584
+ * `options` 的索引签名是**如实描述**宿主的载荷:`llm/stream` 派发的是
1585
+ * 完整的 `GenerateOptions`(provider / model / messages / tools / …),
1586
+ * §7.144 的宿主调用记录正是从这些字段里取「发出去的那一坨」。
1587
+ */
1588
+ handleLlmStream(options, next) {
1589
+ const requestSessionId = options?.sessionId;
1590
+ const state = requestSessionId ? this.stepState.get(requestSessionId) : undefined;
1591
+ if (!this.config.enableInputGuard)
1592
+ return next();
1593
+ if (!requestSessionId)
1594
+ return next();
1595
+ // §7.144: 非边缘会话(标准模式等)没有 pre-step 留下的 stepState ⇒
1596
+ // 直接直通、零记录。这条守住 §7.140 的不变式:标准模式会话的审计链
1597
+ // 里一条 edge 事件都不该有。
1598
+ if (!state)
1599
+ return next();
1600
+ // §7.184 (2026-11-11): 本轮判定 LOCAL ⇒ 实际请求**改道**到本地池。
1601
+ //
1602
+ // 位置要紧:改道排在**出网裁剪之前** —— 本地池按 §7.131 带**全部**历史
1603
+ // (本地池不出网,不裁);要是先裁了再改道,本地池就只拿到「云段历史」,
1604
+ // 上下文凭空少一截。
1605
+ if (this.needsLocalReroute(options, state)) {
1606
+ return this.dispatchRerouted(options, state);
1607
+ }
1608
+ // §7.151 (2026-10-29): 出网裁剪(§7.131 口径:只带云段历史)。
1609
+ //
1610
+ // 这一跳在 **trace 记录之前**:被换掉的那通请求根本没出网,记它等于把
1611
+ // 「未裁剪的请求体」挂到面板上 —— 主人 10-29 看到的就是那个。
1612
+ // 裁完的那通会**重新进一次 waterfall**,那时才轮到 trace 记录 ⇒ 面板上
1613
+ // 显示的是真正发出去的那一份(只带云段历史)。
1614
+ //
1615
+ // 位置要紧:裁剪**不依赖 sanitizer**(脱敏栈关掉不等于可以带本地历史
1616
+ // 出网)—— 所以它排在 sanitizer 检查**之前**。
1617
+ //
1618
+ // §7.165:裁剪判断本身抛错时,**保留 fail-closed,但必须留下证据**。
1619
+ //
1620
+ // 抛错路径确实存在:`planCloudEgress` 遍历消息时会读 `message.role`,
1621
+ // 数组里混进 null / undefined 元素就会抛 TypeError。宿主契约下消息都是
1622
+ // 规范对象(不会混进 null),但「守卫不能被人为构造的载荷打穿」这条要
1623
+ // 守住 —— 实测确认:抛错发生在派发**之前**(`plan()` 在 waterfall 监听器
1624
+ // 里同步执行),⇒ 请求**根本没到 provider**(fail-closed,不会泄露)。
1625
+ //
1626
+ // 缺的是证据:原来这一路上没有任何审计行,面板上看起来像「什么都没发生」。
1627
+ // 本项目对面板的纪律是反的 ——「宁可漏显示,也不能显示一件没发生过的事」;
1628
+ // 这里恰恰相反:**发生了拦截却什么都不显示**。所以补一条审计行,并把
1629
+ // 异常原样抛出(fail-closed 语义一个字都不改)。
1630
+ let egress;
1631
+ try {
1632
+ egress = this.hostEgressGuard.plan(options);
1633
+ }
1634
+ catch (err) {
1635
+ this.audit('security', 'llm.cloud.egress.blocked', {
1636
+ ...sessionIdField(state.sessionId),
1637
+ provider: typeof options.provider === 'string' ? options.provider : '',
1638
+ model: typeof options.model === 'string' ? options.model : '',
1639
+ via: 'host-provider',
1640
+ reason: 'plan-error',
1641
+ error: err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200),
1642
+ });
1643
+ throw err;
1644
+ }
1645
+ if (egress !== undefined) {
1646
+ return this.dispatchTrimmed(options, egress, state);
1647
+ }
1648
+ // §7.163: 只有「脱敏会话」需要 sanitizer 做 desanitize;未脱敏会话的
1649
+ // 输出掩码兜底不需要它。所以这个早退只挡「要 desanitize 却没 sanitizer」
1650
+ // 这一种情况 —— 否则掩码兜底会被 sanitizer 的存在与否绑住(生产里
1651
+ // 两者总是一起出现,但绑住没有道理)。
1652
+ const needsDesanitize = state.sanitized === true && state.replacements > 0;
1653
+ if (needsDesanitize && !this.sanitizer)
1654
+ return next();
1655
+ let stream = next();
1656
+ // §7.144: 宿主 provider 那一跳的记录。必须接在下面「没脱敏就直通」的
1657
+ // 早退**之前** —— CLOUD 轮次常常一条 PII 都没有(sanitized=false),
1658
+ // 放到早退之后就永远记不到,面板里云端模型仍然整条缺失。
1659
+ //
1660
+ // 顺序有讲究:recorder 在**内**(先看到脱敏后的占位符原文),desanitize
1661
+ // 在**外**(把占位符还原给主人看)。于是 trace 记的是「云端真正收到 /
1662
+ // 返回的」—— 与发出去的请求一致,不掺还原后的原文。
1663
+ const hostRecorder = this.hostCallRecorder(options);
1664
+ if (hostRecorder !== undefined) {
1665
+ stream = hostRecorder.wrap(stream);
1666
+ }
1667
+ // §7.163 (2026-10-31): 输出侧 PII 掩码兜底。
1668
+ //
1669
+ // 背景:input-guard 迁移(eaec70b)时声明要把 http-api 的
1670
+ // prompt-guard.checkOutput 一起搬过来,但只搬了 desanitize —— 掩码侧
1671
+ // 留在 http-api 的旧位置,随后随 http-api 瘦身被删掉 ⇒ checkOutput
1672
+ // 成了死代码。后果:state.sanitized=false 的会话(LOCAL-only,或 CLOUD
1673
+ // 但本轮没命中任何 PII)的**输出**一路直通,不经任何掩码。
1674
+ //
1675
+ // 审计层(audit-log)与存储层(local-storage)另有 redactObject 兜底,
1676
+ // 但用户可见的输出(面板、客户端、SSE)没有 —— 这条缺口就是这里补的。
1677
+ //
1678
+ // 语义与迁移前一致:
1679
+ // sanitized=true → 本轮发生过 sanitizer 脱敏,输出里的 PII 是
1680
+ // desanitize **还原**出来的原文 ⇒ 跳过掩码,
1681
+ // 否则会把刚还给用户的 PII 又遮掉。
1682
+ // sanitized=false → 没有还原发生过 ⇒ 对输出做不可逆掩码。
1683
+ //
1684
+ // 跨 chunk:PII 可能被流式切成多块(手机号断成两块时各自都匹配不上),
1685
+ // 所以逐块掩码会漏。这里用「尾部待定区」策略:保留尾部
1686
+ // PII_MASK_TAIL_CHARS 个字符待定,只对更早的安全前缀做掩码。
1687
+ // 128 覆盖了最长的一条规则(credential:标签 + 分隔符 + 64 位值 ≈ 90)。
1688
+ //
1689
+ // 位置:必须在「没脱敏就直通」的早退**之后** —— 脱敏会话的输出要交给
1690
+ // desanitize 还原,不能走这条掩码路径。
1691
+ if (!needsDesanitize) {
1692
+ if (!this.promptGuard?.checkOutput)
1693
+ return stream;
1694
+ const maskGuard = this.promptGuard;
1695
+ const maskAudit = this.auditLog;
1696
+ const maskSessionId = state.sessionId;
1697
+ // 迁移前 http-api 的原始写法是 `wasSanitized = !!sanitizationInfo`,
1698
+ // 即「本轮发生过脱敏替换 ⇒ 输出里的 PII 是 desanitize 还原出来的
1699
+ // 原文 ⇒ 跳过掩码」。这里落在掩码分支里 ⇒ 恒为 false(没脱敏过)。
1700
+ const wasSanitized = false;
1701
+ const maskBuffer = { value: '' };
1702
+ // 最近一个 text-delta 的 block index —— 补发待定区时要带上它,
1703
+ // 否则宿主 BlockAssembler 会把补发的文本算成另一个 block。
1704
+ let lastTextIndex = undefined;
1705
+ /**
1706
+ * 掩码一段文本,并把「掩了几条、哪几类」记进审计。
1707
+ *
1708
+ * §7.163 实机复盘(10-31):最初只在 text-delta 的 head 分支记审计,
1709
+ * 而实机上真正执行掩码的常常是 **block-end** 那条(短回复走不到 head
1710
+ * 分支)⇒ 面板上看不到任何掩码记录,事实与记录不符。现在三条路径
1711
+ * (delta head / block-end 的待定区 / block-end 的 block.text / 流尾
1712
+ * 补发)统一走这里。
1713
+ */
1714
+ const maskText = async (text) => {
1715
+ const filtered = await maskGuard.checkOutput(text, { sanitized: wasSanitized });
1716
+ if (filtered.redacted.length > 0 && maskAudit) {
1717
+ void maskAudit.log({
1718
+ level: 'info',
1719
+ kind: 'input-guard.output.masked',
1720
+ source: 'input-guard',
1721
+ payload: {
1722
+ sessionId: maskSessionId,
1723
+ count: filtered.redacted.length,
1724
+ types: [...new Set(filtered.redacted.map((r) => r.type))],
1725
+ },
1726
+ }).catch(() => { });
1727
+ }
1728
+ return filtered.filtered;
1729
+ };
1730
+ const masked = (async function* () {
1731
+ try {
1732
+ for await (const chunk of stream) {
1733
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string' && chunk.text.length > 0) {
1734
+ lastTextIndex = chunk.index;
1735
+ maskBuffer.value += chunk.text;
1736
+ // 待定区不足一个安全前缀长度时先攒着,避免把半个 PII 放出去。
1737
+ if (maskBuffer.value.length <= PII_MASK_TAIL_CHARS)
1738
+ continue;
1739
+ const cut = maskBuffer.value.length - PII_MASK_TAIL_CHARS;
1740
+ const head = maskBuffer.value.slice(0, cut);
1741
+ maskBuffer.value = maskBuffer.value.slice(cut);
1742
+ const maskedHead = await maskText(head);
1743
+ if (maskedHead.length > 0) {
1744
+ yield { ...chunk, text: maskedHead };
1745
+ }
1746
+ continue;
1747
+ }
1748
+ if (chunk.type === 'block-end') {
1749
+ // block-end 带完整 block.text(宿主的 BlockAssembler 以它为准,
1750
+ // delta 只是增量路径)。所以待定区在 block-end 之前补发成
1751
+ // text-delta —— 只看 delta 的消费方才不会丢掉这一段 ——
1752
+ // 而 block.text 本身也整段过一遍掩码。
1753
+ const block = chunk.block;
1754
+ const tail = maskBuffer.value;
1755
+ maskBuffer.value = '';
1756
+ if (tail.length > 0) {
1757
+ try {
1758
+ const maskedTail = await maskText(tail);
1759
+ if (maskedTail.length > 0) {
1760
+ yield { type: 'text-delta', index: lastTextIndex, text: maskedTail };
1761
+ }
1762
+ }
1763
+ catch {
1764
+ yield { type: 'text-delta', index: lastTextIndex, text: tail };
1765
+ }
1766
+ }
1767
+ if (block && typeof block.text === 'string' && block.text.length > 0) {
1768
+ try {
1769
+ const maskedBlock = await maskText(block.text);
1770
+ yield { ...chunk, block: { ...block, text: maskedBlock } };
1771
+ }
1772
+ catch {
1773
+ yield chunk;
1774
+ }
1775
+ }
1776
+ else {
1777
+ yield chunk;
1778
+ }
1779
+ continue;
1780
+ }
1781
+ yield chunk;
1782
+ }
1783
+ // 流结束:把待定区最后一段也掩码后吐出(否则尾部 PII 永远不发出去)。
1784
+ if (maskBuffer.value.length > 0) {
1785
+ const tail = maskBuffer.value;
1786
+ maskBuffer.value = '';
1787
+ try {
1788
+ const maskedTail = await maskText(tail);
1789
+ if (maskedTail.length > 0) {
1790
+ yield { type: 'text-delta', index: lastTextIndex, text: maskedTail };
1791
+ }
1792
+ }
1793
+ catch {
1794
+ yield { type: 'text-delta', index: lastTextIndex, text: tail };
1795
+ }
1796
+ }
1797
+ }
1798
+ catch (err) {
1799
+ // 掩码是兜底不是主防线:失败时放行原文并留审计,绝不阻断回复。
1800
+ if (maskAudit) {
1801
+ void maskAudit.log({
1802
+ level: 'error',
1803
+ kind: 'input-guard.output.mask-error',
1804
+ source: 'input-guard',
1805
+ payload: { sessionId: maskSessionId, error: err?.message ?? String(err) },
1806
+ }).catch(() => { });
1807
+ }
1808
+ yield { type: 'text-delta', index: lastTextIndex, text: maskBuffer.value };
1809
+ maskBuffer.value = '';
1810
+ }
1811
+ })();
1812
+ return masked;
1813
+ }
1814
+ // 走到这里说明 needsDesanitize 为真 ⇒ sanitizer 必然存在(上面早退过)。
1815
+ // 这个 if 只是为了让类型收窄,运行时不可达。
1816
+ const sanitizer = this.sanitizer;
1817
+ if (!sanitizer)
1818
+ return stream;
1819
+ const sessionId = state.sessionId;
1820
+ const auditLog = this.auditLog;
1821
+ // §7.46.3 (2026-09-12): desanitize ops log — 让"LLM reply 里把占位符
1822
+ // 还原回 PII"在 ops log 可见。operator 看到 reply 含 PII 时,ops log
1823
+ // 直接 grep sessionId 就能知道这是 desanitize 还原的结果(新泄漏 vs
1824
+ // 还原回原文)。每个 step 只 emit 一行 summary(不是 per-call)—
1825
+ // 因为 text-delta 阶段 desanitize 被 flushBuffer 拆成多次小调用,
1826
+ // per-call 会刷 spam;step 结束后汇总一次更合适。
1827
+ let desanitizeChunkCount = 0;
1828
+ let desanitizeErrorCount = 0;
1829
+ let desanitizeRestoredBytes = 0;
1830
+ // §7.46.3: capture ctx logger 引用 — wrapped 是匿名 async generator
1831
+ // function,内部 this 不继承外层 class this,必须在外层拿到 logger
1832
+ // 再传进去;直接用 `this.ctx?.logger?.info?.(...)` 在 async generator
1833
+ // body 里 TS 报 'this' implicitly has type 'any'.
1834
+ const loggerForDesanitize = this.ctx?.logger;
1835
+ // 包装 stream:对 text-delta 调 desanitize,其他 chunk 透传
1836
+ //
1837
+ // 注意:占位符(如 [PHONE_1])可能被 LLM 流式输出时拆成多个 chunk
1838
+ // (如 `['[', 'PH', 'ONE', '_', '1]', ...]`)。每 chunk 单独 desanitize
1839
+ // 找不到完整占位符,导致还原失败。
1840
+ //
1841
+ // 解决:用 streamingPlaceholderBuffer 跨 chunk 累积文本。每收到新 chunk
1842
+ // 就把 buffer+chunk 合并、desanitize,然后 flush 掉所有"不可能是占位符
1843
+ // 前缀"的前缀部分,剩下的尾部(可能是不完整的 `[PHONE_`)留到下一 chunk。
1844
+ //
1845
+ // flush 策略:找出 buffer 中最后一个 `[` 之后的内容,若该 `[` 没有匹配的 `]`
1846
+ // 则保留从该 `[` 开始的所有文本作为新 buffer;前面的文本一定不含未完成的
1847
+ // 占位符,可以安全 desanitize 后 yield。
1848
+ const streamingPlaceholderBuffer = { value: '' };
1849
+ let chunkCount = 0;
1850
+ let textDeltaCount = 0;
1851
+ const desanitizeCallCount = 0;
1852
+ const wrapped = (async function* () {
1853
+ try {
1854
+ for await (const chunk of stream) {
1855
+ chunkCount++;
1856
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string' && chunk.text.length > 0) {
1857
+ textDeltaCount++;
1858
+ try {
1859
+ const restored = await flushBuffer(sanitizer, sessionId, streamingPlaceholderBuffer, chunk.text);
1860
+ if (restored.length > 0) {
1861
+ desanitizeRestoredBytes += restored.length;
1862
+ desanitizeChunkCount++;
1863
+ yield { ...chunk, text: restored };
1864
+ }
1865
+ }
1866
+ catch (err) {
1867
+ desanitizeErrorCount++;
1868
+ if (auditLog) {
1869
+ void auditLog.log({
1870
+ level: 'error',
1871
+ kind: 'input-guard.desanitize.error',
1872
+ source: 'input-guard',
1873
+ payload: { sessionId, error: err?.message ?? String(err) },
1874
+ }).catch(() => { });
1875
+ }
1876
+ yield chunk;
1877
+ }
1878
+ }
1879
+ else if (chunk.type === 'block-end') {
1880
+ // block-end 带完整的最终 block.text(在 BlockAssembler 中会覆盖
1881
+ // 由 text-delta 累积的 partial.text)。我们必须在它到达前
1882
+ // 对完整 block.text 做一次 desanitize,否则还原后的 PII
1883
+ // 会被 block-end 里的原始占位符文本覆盖掉。
1884
+ const block = chunk.block;
1885
+ if (block && typeof block.text === 'string' && block.text.length > 0) {
1886
+ try {
1887
+ const restored = await sanitizer.desanitize(block.text, sessionId);
1888
+ desanitizeRestoredBytes += restored.length;
1889
+ desanitizeChunkCount++;
1890
+ yield { ...chunk, block: { ...block, text: restored } };
1891
+ }
1892
+ catch (err) {
1893
+ desanitizeErrorCount++;
1894
+ yield chunk;
1895
+ }
1896
+ }
1897
+ else {
1898
+ yield chunk;
1899
+ }
1900
+ }
1901
+ else {
1902
+ yield chunk;
1903
+ }
1904
+ }
1905
+ // stream 结束:flush 残留 buffer(含可能未关闭的占位符,原样透传避免丢失内容)
1906
+ if (streamingPlaceholderBuffer.value.length > 0) {
1907
+ try {
1908
+ const tail = await sanitizer.desanitize(streamingPlaceholderBuffer.value, sessionId);
1909
+ desanitizeRestoredBytes += tail.length;
1910
+ desanitizeChunkCount++;
1911
+ yield { type: 'text-delta', text: tail };
1912
+ }
1913
+ catch {
1914
+ desanitizeErrorCount++;
1915
+ yield { type: 'text-delta', text: streamingPlaceholderBuffer.value };
1916
+ }
1917
+ streamingPlaceholderBuffer.value = '';
1918
+ }
1919
+ }
1920
+ finally {
1921
+ // §7.46.3: step 结束 emit desanitize summary(仅在至少还原过一次
1922
+ // 时输出,避免无 PII 的 chat 也刷 spam;errorCount > 0 时也输出
1923
+ // 让 operator 立刻看到 desanitize 失败)。replacedCount 是
1924
+ // sanitize done 行 outcome=replaced 时的 N,配合 grep 能完整
1925
+ // 看到 "sanitize 替换 N → LLM 看到 N → desanitize 还原 N"。
1926
+ if (desanitizeChunkCount > 0 || desanitizeErrorCount > 0) {
1927
+ try {
1928
+ loggerForDesanitize?.info?.(`[input-guard] desanitize done sessionId=${sessionId} chunkCount=${desanitizeChunkCount} errorCount=${desanitizeErrorCount} restoredBytes=${desanitizeRestoredBytes}`);
1929
+ }
1930
+ catch { /* debug only */ }
1931
+ }
1932
+ // step 结束,清除状态(避免下一 step 错配)
1933
+ // 注:agent-loop 在 step 完成后会再次进入 agent/pre-step,那里会重置状态
1934
+ }
1935
+ })();
1936
+ return wrapped;
1937
+ }
1938
+ }
1939
+ /** 找 messages 数组里最后一条 user 消息的文本。*/
1940
+ /** 审计日志用 redact:截取前 60 字符。不引入 pii-utils 循环依赖。*/
1941
+ function redact(text) {
1942
+ const slice = text.slice(0, 60);
1943
+ return slice.length < text.length ? `${slice}…[+${text.length - slice.length}]` : slice;
1944
+ }
1945
+ /**
1946
+ * 流式反脱敏:把 incoming chunk 追加到 cross-chunk buffer 后,
1947
+ * 找出所有"确定无未关闭占位符"的前缀做 desanitize 并返回。
1948
+ * 剩下的尾部(可能是 `[PHONE_` 这种未关闭的前缀)保留在 buffer.value
1949
+ * 里,等下一个 chunk 进来再判断。
1950
+ *
1951
+ * 占位符规则:`[A-Z_]+_\d+`(如 `[PHONE_1]`、`[EMAIL_2]`)。所有占位符
1952
+ * 都用 `[` 开始、`]` 结束。flush 策略:保留从最后一个未配对的 `[` 起
1953
+ * 的所有文本,前面所有文本一定没有未关闭的占位符。
1954
+ */
1955
+ async function flushBuffer(sanitizer, sessionId, buffer, incoming) {
1956
+ buffer.value += incoming;
1957
+ // 找最后一个未配对的 `[`:从尾部开始扫描 `]` 计数,遇 `[` 减 1;
1958
+ // 计数降到 -1 的位置就是未配对的 `[`,从此处开始保留。
1959
+ let unmatchedOpen = -1;
1960
+ for (let i = buffer.value.length - 1; i >= 0; i--) {
1961
+ const c = buffer.value[i];
1962
+ if (c === ']')
1963
+ unmatchedOpen++;
1964
+ else if (c === '[') {
1965
+ if (unmatchedOpen === 0) {
1966
+ unmatchedOpen = -1;
1967
+ continue;
1968
+ }
1969
+ unmatchedOpen--;
1970
+ if (unmatchedOpen < 0) {
1971
+ unmatchedOpen = i;
1972
+ break;
1973
+ }
1974
+ }
1975
+ }
1976
+ let safePrefix;
1977
+ let tail;
1978
+ if (unmatchedOpen < 0) {
1979
+ safePrefix = buffer.value;
1980
+ tail = '';
1981
+ }
1982
+ else {
1983
+ safePrefix = buffer.value.slice(0, unmatchedOpen);
1984
+ tail = buffer.value.slice(unmatchedOpen);
1985
+ }
1986
+ buffer.value = tail;
1987
+ if (safePrefix.length === 0)
1988
+ return '';
1989
+ return await sanitizer.desanitize(safePrefix, sessionId);
1990
+ }
1991
+ export async function apply(ctx, config) {
1992
+ // §7.102: the guard switches come from config/edge.json (privacy.*).
1993
+ // The cordis patch row no longer reads process.env — the desktop GUI
1994
+ // never had those variables.
1995
+ const privacy = loadEdgeConfig().privacy;
1996
+ if (privacy) {
1997
+ if (privacy.inputGuardEnabled !== undefined) {
1998
+ ;
1999
+ config.enableInputGuard = privacy.inputGuardEnabled;
2000
+ }
2001
+ if (privacy.rejectPolicyEnabled !== undefined) {
2002
+ ;
2003
+ config.rejectOnPolicyViolation = privacy.rejectPolicyEnabled;
2004
+ }
2005
+ if (privacy.sanitizeDowngradeEnabled !== undefined) {
2006
+ ;
2007
+ config.downgradeOnSanitizeFailure = privacy.sanitizeDowngradeEnabled;
2008
+ }
2009
+ }
2010
+ // §7.102: the LOCAL pool's model follows config/edge.json (llm.local.model).
2011
+ // The file is the single source of truth — the cordis patch row no longer
2012
+ // carries local-LLM config.
2013
+ const localModel = localPool().model;
2014
+ if (localModel && config.localModel !== localModel) {
2015
+ ctx.logger?.info?.(`[input-guard] localModel from config/edge.json: ${config.localModel} → ${localModel}`);
2016
+ config.localModel = localModel;
2017
+ }
2018
+ // §7.35 (2026-09-03): 运行时尊重 DSH_EDGE_CLOUD_KEY_ENV 覆盖。
2019
+ // Schema default 在模块加载时求值;如果用户在 apply() 之后才 export env,
2020
+ // 这里补一刀。跟 cordis.patch.yml:41 的 !!js 表达式保持一致。
2021
+ // §7.175: input-guard 已不再消费 cloudApiKeyEnv(云池不参与路由判定),
2022
+ // 这里只是把 schema 默认值同步成运行期的实际值,便于排查时看日志一致。
2023
+ if (process.env.DSH_EDGE_CLOUD_KEY_ENV && process.env.DSH_EDGE_CLOUD_KEY_ENV !== config.cloudApiKeyEnv) {
2024
+ ctx.logger?.info?.(`[input-guard] cloudApiKeyEnv override: ${config.cloudApiKeyEnv} → ${process.env.DSH_EDGE_CLOUD_KEY_ENV}`);
2025
+ config.cloudApiKeyEnv = process.env.DSH_EDGE_CLOUD_KEY_ENV;
2026
+ }
2027
+ const promptGuard = ctx.promptGuard;
2028
+ const privacyPolicy = ctx.privacyPolicy;
2029
+ const intentRouter = ctx.intentRouter;
2030
+ const sanitizer = ctx.sanitizer;
2031
+ const auditLog = ctx.auditLog;
2032
+ // 启动期 fail-fast:这些依赖缺失时,管线不会报错,而是每一条请求都在
2033
+ // 运行期抛出 → 被 catch → 静默降级 LOCAL。表面上"能用",实际上云端
2034
+ // 能力整体失效且无人察觉。宁可在启动时就说清楚缺了谁。
2035
+ // (enableInputGuard=false 是显式关闭,不在此列。)
2036
+ if (config.enableInputGuard) {
2037
+ const missing = [];
2038
+ if (!privacyPolicy)
2039
+ missing.push('privacyPolicy');
2040
+ if (!sanitizer)
2041
+ missing.push('sanitizer');
2042
+ if (!intentRouter)
2043
+ missing.push('intentRouter');
2044
+ if (!promptGuard)
2045
+ missing.push('promptGuard');
2046
+ if (missing.length > 0) {
2047
+ const detail = missing.join(', ');
2048
+ void auditLog?.log?.({
2049
+ level: 'security',
2050
+ kind: 'input-guard.dependency.missing',
2051
+ source: 'input-guard',
2052
+ payload: { missing, hint: 'check plugin boot order in cordis.yml' },
2053
+ });
2054
+ throw new Error(`input-guard: required services unavailable (${detail}) — check plugin boot order, ` +
2055
+ 'or set DSH_EDGE_INPUT_GUARD=false to disable the guard explicitly');
2056
+ }
2057
+ }
2058
+ const service = new InputGuardServiceImpl(ctx, config, promptGuard, privacyPolicy, intentRouter, sanitizer, auditLog);
2059
+ // 类型安全的 ctx.on 访问(cordis 的 ctx.on 在类型上不一定暴露)
2060
+ const on = ctx.on;
2061
+ // 2026-09-03 §7.26 probe: log ctx.on reachability + each hook registration
2062
+ // outcome so we can tell from audit log whether hook registration failed
2063
+ // (no-ctx-on) vs hook registered but never dispatched (silent filter).
2064
+ // NOTE: level='info' because audit-log default minLevel is 'info' and 'debug'
2065
+ // entries are silently dropped at audit-log.ts:163.
2066
+ void auditLog?.log?.({
2067
+ level: 'info',
2068
+ kind: 'input-guard.boot.probe',
2069
+ source: 'input-guard',
2070
+ payload: {
2071
+ hasCtxOn: Boolean(on),
2072
+ ctxOnType: typeof on,
2073
+ },
2074
+ });
2075
+ const disposers = [];
2076
+ if (on) {
2077
+ // 正向:agent/pre-step。
2078
+ //
2079
+ // §7.136(2026-10-28,主人裁决):**去掉 `{ global: true }`**。
2080
+ //
2081
+ // 宿主的 scope 过滤(@deepseek-ai/dsh-scope lib/index.js#scopeTarget):
2082
+ // agent 事件用 `agentCarrier(agent) = scopeTarget(agent, agent)` 派发,
2083
+ // 载体的 filter 规则是「监听器自己的 scope 必须是派发 scope 或其祖先」
2084
+ // (事件沿 scope 链向上流,不向下)。带 `{ global: true }` 会**绕过**这个
2085
+ // 过滤 —— 于是本 hook 在**任何**工作方式下都会触发,标准模式也照样被
2086
+ // 护栏改写。
2087
+ //
2088
+ // 本插件挂在**边缘模式 preset 的 scope** 里;绑定到该 preset 的 Agent 的
2089
+ // scope 链包含 preset scope ⇒ hook 生效;绑定到标准模式的 Agent 链上
2090
+ // 只有标准 preset 的 scope ⇒ hook 不触发 ⇒ 标准模式是纯宿主。
2091
+ //
2092
+ // 动态创建的 Agent(http-api 每次 followup 新建)同样经宿主工厂创建,
2093
+ // 注册表会把它 bind 到当前 preset ⇒ scope 链一样成立(§7.36 那条
2094
+ // 「per-agent 注册收不到动态 agent」的顾虑在 scope 语义下不成立)。
2095
+ const d1 = on('agent/pre-step', async (payload, next) => {
2096
+ return service.handlePreStep(payload, next);
2097
+ });
2098
+ void auditLog?.log?.({
2099
+ level: 'info',
2100
+ kind: 'input-guard.hook.registered',
2101
+ source: 'input-guard',
2102
+ payload: { event: 'agent/pre-step', scopedTo: 'agent-preset', disposerReturned: typeof d1 === 'function' },
2103
+ });
2104
+ if (d1)
2105
+ disposers.push(d1);
2106
+ // 反向:llm/stream。
2107
+ //
2108
+ // 这个事件由 @deepseek-ai/dsh-llm 派发,**不带 scope 载体**(宿主里
2109
+ // scopeTarget 只出现在 dsh-agent / dsh-tools / dsh-system-prompt 等),
2110
+ // 所以 scope 过滤对它不生效 —— 保留 global 以免改变既有行为。
2111
+ // 它本身是「无害的」:handleLlmStream 只在 pre-step 已经 sanitize 过
2112
+ // (stepState.sanitized)时才做事;pre-step 现在只在边缘模式触发 ⇒
2113
+ // 标准模式下这里恒等于直通。
2114
+ //
2115
+ // agent-loop 在 buildRequest 时把 sessionId 写到 GenerateOptions,
2116
+ // 我们直接从 options.sessionId 读,跨 hook 状态用 Map<string, state>
2117
+ // 维护(pre-step 的 session 对象和 llm/stream 的 sessionId 字符串不
2118
+ // 是同一个引用,WeakMap<object> 行不通)。
2119
+ const d2 = on('llm/stream', (options, next) => {
2120
+ return service.handleLlmStream(options, next);
2121
+ }, { global: true });
2122
+ void auditLog?.log?.({
2123
+ level: 'info',
2124
+ kind: 'input-guard.hook.registered',
2125
+ source: 'input-guard',
2126
+ payload: { event: 'llm/stream', global: true, disposerReturned: typeof d2 === 'function' },
2127
+ });
2128
+ if (d2)
2129
+ disposers.push(d2);
2130
+ // agent/request —— §7.175 起只放行,不改路由。
2131
+ //
2132
+ // 曾经这里把 provider/model 从 CLOUD 改写到 LOCAL(降级)。现在不行:
2133
+ // 宿主 dsh-agent-loop 的 prepareRequest() 会拿 proposedConfig 与
2134
+ // `this.model.current()` 比对,provider / model / reasoningEffort 任一
2135
+ // 不同就直接抛错 —— 改写不是「降级」,是让整轮失败。
2136
+ //
2137
+ // 路由恒由宿主的 modelSelection 决定(边缘模式恒为本地模型),「本该
2138
+ // 本地却可能上云」的 fail-closed 拦截在 handlePreStep 里做。
2139
+ //
2140
+ // agent-loop 的 contract:监听器真实签名是 `(payload, next)`,
2141
+ // payload 由 dsh-agent 的 fused wrapper 注入了 `agent` 字段
2142
+ // (dispatch.js#agentEvents)。next() 返回 chain 默认的 seedConfig。
2143
+ // 我们必须先 await next() 拿到 seedConfig 再传下去,不能跳过。
2144
+ //
2145
+ // §7.136:同 agent/pre-step —— 不带 global,靠 scope 过滤只在边缘模式生效。
2146
+ const d3 = on('agent/request', async (payload, next) => {
2147
+ const seedConfig = await next();
2148
+ const result = await service.handleAgentRequest(payload ?? {}, seedConfig, async () => seedConfig);
2149
+ return result;
2150
+ });
2151
+ void auditLog?.log?.({
2152
+ level: 'info',
2153
+ kind: 'input-guard.hook.registered',
2154
+ source: 'input-guard',
2155
+ payload: { event: 'agent/request', scopedTo: 'agent-preset', disposerReturned: typeof d3 === 'function' },
2156
+ });
2157
+ if (d3)
2158
+ disposers.push(d3);
2159
+ // §7.175: §7.106 的「降级后恢复 GUI 模型选择」监听器退役 —— 它恢复的是
2160
+ // 「被改写掉的原路由」,而改写本身已经不存在了:路由恒由宿主的
2161
+ // modelSelection 决定,GUI 显示的就是用户真正选中的那个。
2162
+ // §7.176 / §7.179 / §7.183 的三个监听器(`agent/created` /
2163
+ // `agent-preset/selected` / `session/event`)**全部退役**:它们只为
2164
+ // 「把会话选型钉/回钉到本地池」而存在。
2165
+ //
2166
+ // §7.184(主人裁决「选择器完全不动,实际请求改道到本地池」):边缘模式
2167
+ // **不再改写会话选型** ⇒ 宿主模型选择器保持主人选的那个。
2168
+ // 「本轮必须留在设备上」改由 `llm/stream` 上的改道保证(dispatchRerouted)。
2169
+ }
2170
+ return async () => {
2171
+ for (const d of disposers)
2172
+ d();
2173
+ };
2174
+ }