@sema-agent/server 7.125.1 → 7.126.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/MIGRATION.md +195 -2
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/USAGE.md +263 -140
  5. package/deploy/sema-up/README.md +1 -1
  6. package/deploy/sema-up/chart/templates/networkpolicy-sandbox.yaml +47 -0
  7. package/deploy/sema-up/chart/templates/sandbox-rbac.yaml +5 -1
  8. package/deploy/sema-up/chart/templates/server-v4pro.yaml +2 -2
  9. package/deploy/sema-up/chart/templates/server.yaml +1 -1
  10. package/deploy/sema-up/chart/values.yaml +12 -0
  11. package/deploy/sema-up/cluster-up.sh +10 -10
  12. package/deploy/sema-up/kube-up.sh +11 -10
  13. package/deploy/sema-up/lib/dataroot.sh +1 -1
  14. package/deploy/sema-up/lib/envf.sh +5 -5
  15. package/deploy/sema-up/lib/flagshape.sh +5 -5
  16. package/deploy/sema-up/lib/lanes.py +1 -1
  17. package/deploy/sema-up/lib/parse-machines.py +2 -2
  18. package/deploy/sema-up/lib/shell-access.sh +1 -1
  19. package/deploy/sema-up/preflight.sh +4 -4
  20. package/deploy/sema-up/sema-up.sh +33 -33
  21. package/deploy/sema-up/smoke.sh +3 -3
  22. package/deploy/sema-up/stack/compose.yaml +1 -1
  23. package/deploy/sema-up/stack/sema-stack-cluster.yaml +6 -2
  24. package/dist/approval-card.d.ts +20 -4
  25. package/dist/approval-content-kind.d.ts +38 -22
  26. package/dist/approval-content-kind.js +20 -4
  27. package/dist/audit.js +2 -0
  28. package/dist/bench/s1/arms.d.ts +9 -37
  29. package/dist/bench/s1/arms.js +4 -61
  30. package/dist/bench/s1/live-deps.d.ts +4 -10
  31. package/dist/bench/s1/live-deps.js +1 -222
  32. package/dist/bench/s1/oracle.d.ts +1 -1
  33. package/dist/bench/s1/reviewer.d.ts +1 -1
  34. package/dist/bench/s1/row.d.ts +5 -7
  35. package/dist/bench/s1/row.js +0 -2
  36. package/dist/bench/s1/run-firm.d.ts +2 -2
  37. package/dist/bench/s1/run-firm.js +1 -4
  38. package/dist/bench/s1/runner-ctx.d.ts +17 -29
  39. package/dist/bench/s1/runner-ctx.js +3 -7
  40. package/dist/bench/s1/tasks.d.ts +9 -46
  41. package/dist/bench/s1/tasks.js +4 -63
  42. package/dist/boot/execution-env.d.ts +38 -26
  43. package/dist/boot/execution-env.js +71 -38
  44. package/dist/boot/memory-boundary.d.ts +1 -1
  45. package/dist/boot/parked-revive-gate.d.ts +0 -3
  46. package/dist/boot/parked-revive-gate.js +2 -2
  47. package/dist/boot/resolve-spec.d.ts +0 -2
  48. package/dist/boot/resolve-spec.js +12 -17
  49. package/dist/boot/retention-lane.d.ts +12 -1
  50. package/dist/boot/retention-lane.js +2 -0
  51. package/dist/boot/runner-deps.d.ts +5 -5
  52. package/dist/boot/runner-deps.js +5 -16
  53. package/dist/boot/session-faces.d.ts +5 -1
  54. package/dist/boot/session-faces.js +37 -1
  55. package/dist/boot/stage-03-store-backend.d.ts +1 -0
  56. package/dist/boot/stage-03-store-backend.js +2 -2
  57. package/dist/boot/stage-04-stores.d.ts +1 -0
  58. package/dist/boot/stage-05-execution-env.d.ts +2 -2
  59. package/dist/boot/stage-05-execution-env.js +7 -4
  60. package/dist/boot/stage-06-runners.d.ts +2 -3
  61. package/dist/boot/stage-06-runners.js +8 -7
  62. package/dist/boot/stage-07-capability-layer.d.ts +2 -3
  63. package/dist/boot/stage-07-capability-layer.js +3 -4
  64. package/dist/boot/stage-08-reapers.d.ts +2 -3
  65. package/dist/boot/stage-08-reapers.js +2 -2
  66. package/dist/boot/stage-09-leader.d.ts +2 -3
  67. package/dist/boot/stage-09-leader.js +2 -1
  68. package/dist/boot/stage-10-http-server.d.ts +2 -3
  69. package/dist/boot/stage-10-http-server.js +2 -2
  70. package/dist/boot/stores.d.ts +1 -0
  71. package/dist/boot/stores.js +4 -3
  72. package/dist/boot/web-admin.d.ts +5 -0
  73. package/dist/boot/web-admin.js +28 -14
  74. package/dist/brain.d.ts +2 -2
  75. package/dist/brain.js +1 -1
  76. package/dist/capabilities/builtin-tools.d.ts +2 -1
  77. package/dist/capabilities/composite-members.d.ts +1 -1
  78. package/dist/capabilities/composite-members.js +1 -1
  79. package/dist/capabilities/hands-lane.d.ts +9 -6
  80. package/dist/capabilities/hands-lane.js +2 -2
  81. package/dist/capabilities/memory-notice.d.ts +27 -22
  82. package/dist/capabilities/memory-notice.js +15 -17
  83. package/dist/capabilities/scenarios.d.ts +9 -14
  84. package/dist/capabilities/scenarios.js +5 -50
  85. package/dist/capability-facts.d.ts +22 -11
  86. package/dist/capability-facts.js +13 -6
  87. package/dist/config-catalog.js +11 -4
  88. package/dist/config-provider.d.ts +5 -1
  89. package/dist/config-provider.js +23 -4
  90. package/dist/config-types.d.ts +14 -1
  91. package/dist/config.js +39 -6
  92. package/dist/deployment-governance.d.ts +19 -21
  93. package/dist/deployment-governance.js +7 -8
  94. package/dist/env-name-allowlist-knobs.d.ts +5 -5
  95. package/dist/env-name-allowlist-knobs.js +1 -0
  96. package/dist/execution-lane-caps.js +2 -2
  97. package/dist/hooks/hook-runner.d.ts +84 -30
  98. package/dist/hooks/hook-runner.js +74 -41
  99. package/dist/http/admission.js +12 -9
  100. package/dist/http/durable-run-leg.d.ts +5 -4
  101. package/dist/http/durable-run-leg.js +1 -1
  102. package/dist/http/idempotency.d.ts +28 -6
  103. package/dist/http/idempotency.js +10 -2
  104. package/dist/http/principal-gate.d.ts +14 -0
  105. package/dist/http/principal-gate.js +7 -0
  106. package/dist/http/request-guards.d.ts +2 -1
  107. package/dist/http/request-guards.js +14 -14
  108. package/dist/http/resume-legs.d.ts +2 -2
  109. package/dist/http/resume-legs.js +5 -4
  110. package/dist/http/route-ctx.d.ts +8 -6
  111. package/dist/http/routes/a2a-serve.js +11 -7
  112. package/dist/http/routes/approvals-assistant.js +18 -3
  113. package/dist/http/routes/background.d.ts +1 -1
  114. package/dist/http/routes/background.js +1 -1
  115. package/dist/http/routes/capabilities.js +10 -2
  116. package/dist/http/routes/mcp-reconnect.js +1 -1
  117. package/dist/http/routes/runs.d.ts +4 -3
  118. package/dist/http/routes/runs.js +12 -13
  119. package/dist/http/routes/session-sync.js +14 -11
  120. package/dist/http/routes/sessions.js +9 -9
  121. package/dist/http/routes/side-query.js +6 -19
  122. package/dist/http/routes/tasks.js +12 -11
  123. package/dist/http/server.d.ts +4 -0
  124. package/dist/http/server.js +2 -2
  125. package/dist/http/submit-replay.d.ts +15 -8
  126. package/dist/http/submit-replay.js +6 -0
  127. package/dist/http/wire-types.d.ts +7 -9
  128. package/dist/index.d.ts +0 -1
  129. package/dist/index.js +0 -1
  130. package/dist/internal-run-env.d.ts +30 -0
  131. package/dist/internal-run-env.js +11 -0
  132. package/dist/leader/wire.js +3 -2
  133. package/dist/memory-scope.d.ts +19 -7
  134. package/dist/memory-scope.js +25 -8
  135. package/dist/observability/fail-open.d.ts +15 -3
  136. package/dist/observability/fail-open.js +15 -3
  137. package/dist/observability/metrics.d.ts +0 -1
  138. package/dist/observability/metrics.js +0 -3
  139. package/dist/observability/run-terminal.js +69 -4
  140. package/dist/permission-rule-vocab.d.ts +3 -2
  141. package/dist/permission-rule-vocab.js +1 -1
  142. package/dist/plan-cache-probe.d.ts +3 -2
  143. package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
  144. package/dist/plugins/caching-session-store.d.ts +3 -0
  145. package/dist/plugins/caching-session-store.js +3 -0
  146. package/dist/plugins/checkpoint-store-sql.d.ts +42 -28
  147. package/dist/plugins/checkpoint-store-sql.js +57 -11
  148. package/dist/plugins/file-run-store.d.ts +25 -21
  149. package/dist/plugins/file-run-store.js +70 -34
  150. package/dist/plugins/local-checkpoint-store.d.ts +8 -5
  151. package/dist/plugins/local-checkpoint-store.js +5 -15
  152. package/dist/plugins/local-session-store.d.ts +66 -7
  153. package/dist/plugins/local-session-store.js +168 -101
  154. package/dist/plugins/mailbox-recipient-lifecycle.d.ts +1 -1
  155. package/dist/plugins/memory-run-store.d.ts +19 -3
  156. package/dist/plugins/memory-run-store.js +38 -3
  157. package/dist/plugins/one-time-migrations.d.ts +141 -39
  158. package/dist/plugins/one-time-migrations.js +32 -143
  159. package/dist/plugins/pg-pool.js +11 -1
  160. package/dist/plugins/pg-session-storage.d.ts +2 -0
  161. package/dist/plugins/pg-session-storage.js +9 -5
  162. package/dist/plugins/remote-env-adb.d.ts +3 -0
  163. package/dist/plugins/remote-env-adb.js +1 -0
  164. package/dist/plugins/remote-env-device.d.ts +3 -0
  165. package/dist/plugins/remote-env-device.js +1 -0
  166. package/dist/plugins/remote-env-e2b.d.ts +4 -0
  167. package/dist/plugins/remote-env-e2b.js +2 -1
  168. package/dist/plugins/remote-env-host.d.ts +12 -0
  169. package/dist/plugins/remote-env-host.js +11 -1
  170. package/dist/plugins/remote-env-k8s.d.ts +227 -39
  171. package/dist/plugins/remote-env-k8s.js +450 -75
  172. package/dist/plugins/remote-env-local-docker.d.ts +4 -0
  173. package/dist/plugins/remote-env-local-docker.js +2 -1
  174. package/dist/plugins/remote-env-ssh.d.ts +3 -0
  175. package/dist/plugins/remote-env-ssh.js +1 -0
  176. package/dist/plugins/retention-store-sql.d.ts +4 -1
  177. package/dist/plugins/retention-store-sql.js +11 -9
  178. package/dist/plugins/run-store-sql.d.ts +44 -6
  179. package/dist/plugins/run-store-sql.js +92 -27
  180. package/dist/plugins/session-capture-record-store-sql.d.ts +2 -3
  181. package/dist/plugins/sql-driver.d.ts +2 -1
  182. package/dist/plugins/store-backend.js +1 -1
  183. package/dist/plugins/store-contracts.d.ts +17 -3
  184. package/dist/plugins/tidb-pool.js +15 -3
  185. package/dist/plugins/tidb-session-storage.js +3 -2
  186. package/dist/plugins/tidb-session-store.d.ts +2 -0
  187. package/dist/plugins/tidb-session-store.js +6 -2
  188. package/dist/request-key-closure.d.ts +12 -0
  189. package/dist/request-key-closure.js +3 -3
  190. package/dist/router/route-orchestration.d.ts +3 -11
  191. package/dist/router/route-orchestration.js +0 -4
  192. package/dist/rule-entry-list.js +0 -4
  193. package/dist/rules-consent.js +3 -0
  194. package/dist/run-local.d.ts +5 -4
  195. package/dist/run-local.js +23 -17
  196. package/dist/runs.js +1 -0
  197. package/dist/security.d.ts +7 -0
  198. package/dist/security.js +5 -0
  199. package/dist/server-secret-env.d.ts +10 -4
  200. package/dist/server-secret-env.js +4 -1
  201. package/dist/session-incarnation.d.ts +46 -2
  202. package/dist/session-incarnation.js +14 -3
  203. package/dist/session-sync-kernel.d.ts +10 -3
  204. package/dist/spec-fields.d.ts +24 -8
  205. package/dist/spec-fields.js +21 -2
  206. package/dist/task-workflow.d.ts +6 -4
  207. package/dist/task-workflow.js +3 -2
  208. package/dist/tool-approval.d.ts +78 -6
  209. package/dist/tool-approval.js +7 -3
  210. package/dist/trace/core-keyset-guard.d.ts +4 -4
  211. package/dist/trace/engine-notice-wire.d.ts +26 -1
  212. package/dist/trace/engine-notice-wire.js +7 -2
  213. package/dist/trace/project.d.ts +8 -3
  214. package/dist/trace/project.js +15 -1
  215. package/dist/trace/self-minted-wire.d.ts +104 -2
  216. package/dist/trace/self-minted-wire.js +35 -0
  217. package/dist/web-admin-client/apply-effective.d.ts +13 -7
  218. package/dist/web-admin-client/apply-effective.js +11 -26
  219. package/dist/web-admin-client/apply-ledger.d.ts +2 -2
  220. package/dist/web-admin-client/candidate-gate.d.ts +39 -4
  221. package/dist/web-admin-client/candidate-gate.js +9 -2
  222. package/dist/web-admin-client/facade.d.ts +2 -2
  223. package/dist/web-admin-client/http-client.d.ts +3 -1
  224. package/dist/web-admin-client/http-client.js +4 -3
  225. package/dist/web-admin-client/read-warnings.d.ts +33 -5
  226. package/dist/web-admin-client/read-warnings.js +41 -6
  227. package/dist/web-admin-client/types.d.ts +11 -5
  228. package/dist/workspace-cache.d.ts +45 -0
  229. package/dist/workspace-cache.js +30 -0
  230. package/dist/workspace-space-commit.d.ts +116 -0
  231. package/dist/workspace-space-commit.js +117 -0
  232. package/dist/workspace-space.d.ts +75 -9
  233. package/dist/workspace-space.js +52 -1
  234. package/package.json +4 -4
  235. package/dist/boot/session-incarnation-seat.d.ts +0 -41
  236. package/dist/boot/session-incarnation-seat.js +0 -69
  237. package/dist/capabilities/code-review-council.d.ts +0 -49
  238. package/dist/capabilities/code-review-council.js +0 -111
  239. package/dist/capabilities/collab-wire.d.ts +0 -36
  240. package/dist/capabilities/collab-wire.js +0 -2
  241. package/dist/capabilities/collab-workflows.d.ts +0 -47
  242. package/dist/capabilities/collab-workflows.js +0 -90
  243. package/dist/capabilities/prompts/code-review.d.ts +0 -25
  244. package/dist/capabilities/prompts/code-review.js +0 -39
  245. package/dist/capabilities/prompts/team.d.ts +0 -10
  246. package/dist/capabilities/prompts/team.js +0 -6
  247. package/dist/capabilities/team.d.ts +0 -113
  248. package/dist/capabilities/team.js +0 -70
@@ -43,16 +43,17 @@ export interface ParseHooksConfigResult {
43
43
  * ⇒ **永不命中、一句话都没有**(post 两事件自 7.107.0 / core 7.36.0 起如此,PreToolUse 一直如此)。静默不命中 = 写的人以为在生效的 hook
44
44
  * 其实没跑,硬破坏三句要求的「响亮」没有着落。7.112.0(S-924)只拒**字面**别名;同一个错误写成 `^ListPeers$` / `ListPeers.*` / `(ListPeers)` 照样 200(S-995)。
45
45
  *
46
- * 规则(一条;字面形是它的特例):{@link deadToolMatcherIssue} 对 core 静态目录里**每一条退役别名**问「这只 matcher 选不选它 / 选不选它的正名」:
46
+ * 规则(一条;字面形是它的特例):{@link toolMatcherIssue} 对 core 静态目录里**每一条退役别名**问「这只 matcher 选不选它 / 选不选它的正名」:
47
47
  * matcher **命中别名 ∧ 不命中它的正名** ⇒ `400 request.field_invalid`(`admission.ts` fresh 门)。「选不选」**只有一只读法**:契约包的 `hookMatcherSelects`
48
48
  * (名字表 / 空 / `*` / 正则三形都由它答;正则形**不锚定**,与壳同形;运行期 {@link hookMatcherMatches} 问的也是它,本仓不留副本)。按**一条顺序**判(两档共用,7.114.0 修复轮):
49
49
  * ① **长度上限**:超 {@link MAX_MATCHER_EVAL_CHARS} 的 matcher 两档都不读 ⇒ 未判(reason = oversize);
50
50
  * ② **形只问契约包的 `hookMatcherForm`**(与 `hookMatcherSelects` 同一只解析步,本仓不留形判),闭集三词、两档一张表:
51
51
  * · `literal`(缺席 / 空 / `*` / 名字表形;契约不编、不跑调用方的正则)⇒ 两档都**直接问契约函数**、**不占求值预算**;
52
- * · `regex` ⇒ **`"regex"` 档**(hooks 会被采纳的车道,`taskHooksHonored`)把**同一只契约函数**经 `node:vm` 的 `timeout` 带一次受理共享的预算调用({@link matcherSelectsWithin});
52
+ * · `regex` ⇒ **`"regex"` 档**(hooks 会被采纳的车道,`taskHooksHonored`)把**同一只契约函数**经 `node:vm` 的 `timeout` 带**每次求值各一份固定预算**调用({@link matcherSelectsWithin};
53
+ * 预算**不共享**、不随同一受理里别的 matcher 耗尽、不读钟 —— S-1455);**超时 ⇒ 这只 matcher 是灾难回溯 ⇒ `400 request.field_invalid` 拒**(见下「预算与结局」);
53
54
  * **`"literal"` 档**(多租户车道:hooks 整只被 resolveSpec 忽略)= **不适用**:该车道没有被守的对象(hooks 不装配),不判、不算未判、不登记 —— **零调用方正则执行**
54
55
  * (7.112.0 的字面判保留:裸 `ListPeers` / `Read|ListPeers` 照旧 400);
55
- * · `invalid`(编不过)⇒ `"regex"` 档 = 未判 `invalid_regex`(不进预算求值 —— 形判已答「读不了」);`"literal"` 档 = 不适用(同上)。
56
+ * · `invalid`(编不过)⇒ `"regex"` 档 = **`400 request.field_invalid` 拒**(判决 `invalid_regex`,见下「预算与结局」;不进求值 —— 形判已答「读不了」);`"literal"` 档 = 不适用(同上)。
56
57
  * 形判在 `literal` 档也跑:契约的解析步对非名字表形**构造**(只解析、不执行)一次调用方的 pattern 以分出 regex / invalid —— 长度已被 ① 界住;执行恒为零。
57
58
  * · **为什么两档**(codex r1 [high] + 主会话裁):受理面是**所有**车道,而运行期只有单用户车道跑 hooks。在受理线程上跑调用方的正则 = 给多租户加一个事件循环 DoS 面
58
59
  * (实测:`(.*)*` ×10 + `X` 对 9 字符名字首次执行 1.7 s,18 字符嵌套形 15 s,24 字符 157 s;8 MiB 交替串编译 + 求值 9 s)—— 预算只界住一次请求、界不住「每个请求再拿一份」。
@@ -73,63 +74,116 @@ export interface ParseHooksConfigResult {
73
74
  * · **不在本规则内**:两边都选不中的 matcher(大小写错 / 词内空白 / 纯错字;目录之外的 MCP / 嵌入方工具名同样「选不中目录里任何名」,受理面无从区分错字与它们)=
74
75
  * **「选不到目录里任何名」另一类,候设计(台账另立号,主会话裁)**。
75
76
  *
77
+ * 🔴 **预算与结局(S-1455;受理结局只取决于配置,不取决于机器忙闲)**:regex 档每一次求值(一只正则形 matcher 对一个名字的一次问)各拿**一份固定的墙钟预算**
78
+ * {@link ADMISSION_MATCHER_EVAL_BUDGET_MS}(vm `timeout`,从这次求值开始计)—— **不共享**(此前一次受理共用 100 ms,忙机上会被吃光 ⇒
79
+ * 同一份设置忙机偶尔被收下、闲机恒 400),不随同一受理里 matcher 的个数耗尽,判据里**没有时钟**(没有「截止线」可被调度停顿越过)。预算只防**灾难回溯**:
80
+ * 良性求值 ~0.1 ms,灾难形(指数级)吃满预算也跑不完 —— 两者相差数个数量级,取值给足余量(1 000 ms),让忙机上的调度停顿也跨不过这条线。
81
+ * **超时 ⇒ 确定拒绝**:`400 request.field_invalid`({@link OverBudgetMatcherIssue}),句子点名**哪一只 matcher**(事件 + 组下标,不回显原字节)与**预算**;**按配置顺序遇到的第一只超时 / 命中别名的 matcher 定结局**。
82
+ * 为什么是拒绝而不是放行:运行期 {@link hookMatcherMatches} 在**每次工具调用**里直接问同一只契约函数、**没有任何预算** —— 一只在受理面都跑不完的 matcher 放进去,
83
+ * 每次调用都会把事件循环卡住;放行 = 把一个确定的坏配置推迟成运行期的随机卡死。这条拒不是「越界即拒」的新检测面:被拒的恰是**无法被求值**的 matcher,与 `hook_matcher_invalid`(编不过 ⇒ 该组不跑)同属「这只 matcher 不可用」,
84
+ * 只是编不过的运行期自己安全地不跑它,跑不完的不行。
85
+ * 总用时的上界:首个超时即拒、不再往下求值;未超时的求值各自 < 预算。regex 档只在单用户车道(`taskHooksHonored`,hooks 会装配的那条;调用方就是进程属主,本来就能配任意 `command` 钩子)运行,
86
+ * 多租户车道走 literal 档、零调用方正则执行 —— 受理面的 DoS 面不因此重开。
87
+ *
88
+ * 🔴 **编不过的正则 = 确定的拒绝**(S-1455 收尾;与超预算同类 —— 确定性的坏配置,结局只取决于配置):`"regex"` 档遇契约 `hookMatcherForm` 答 `invalid`(或求值时契约函数答 `"invalid"`)⇒
89
+ * `400 request.field_invalid`({@link InvalidRegexMatcherIssue}),句子点名**哪一只 matcher**(事件 + 组下标 + 原文〔超 {@link MATCHER_ECHO_CHARS} 字符截断〕)与引擎的编译错误句;机读位 `detail` 只有闭集三键。
90
+ * 受理面与运行期问**同一只判**:契约把"编不过"答成 `invalid`,运行期 {@link hookMatcherMatches} 因此不跑那一组(并记 `hook_matcher_invalid`),受理面因此拒 —— 本仓不加第二个判官
91
+ * (编译错误句只是诊断文字,见 {@link matcherCompileSentence};拒不拒由契约函数一个人说了算)。为什么是拒绝而不是放行:放行 = 用户以为在生效的钩子(含会真拦的 PreToolUse deny 钩子)
92
+ * 在每次工具调用里都**悄悄不跑**,硬破坏三句要求的「响亮」没有着落;`"literal"` 档(多租户车道,hooks 不装配)= 不适用,与正则形同一行表。resume 重放不判(照旧采纳、该组不跑)。
93
+ * 🔴 **边界(如实披露)**:判据只守三个工具事件(`matcherReadsWireToolName`)—— 写在别的带 matcher 的事件(`StopFailure` 的 `error` 词 / `PreCompact`·`PostCompact` 的 `trigger` 词)上的坏 matcher 仍受理、运行期该组不跑并记 `hook_matcher_invalid`。
94
+ *
76
95
  * 🔴 **「未判」臂过 `recordFailOpen`**(F 类 tag `MATCHER_UNJUDGED_TAG`;主会话裁,CLAUDE.md「合法 F 类回退走登记 tag」):一只 matcher 没能被判 ⇒ 照旧受理 + 登记一笔,
77
- * detail = `event=… reason=… matcherChars=…`(**不带 matcher 原字节**,S-716 姿势)。三种 reason 同一个 tag:
78
- * `oversize` —— 两档:matcher 超 {@link MAX_MATCHER_EVAL_CHARS} 字符不读(多租户车道上 hooks 不装配,这一笔只为让超长输入显形);
79
- * `over_budget` ——「regex」档正则形:一次受理共享一份 {@link ADMISSION_MATCHER_EVAL_BUDGET_MS} 墙钟预算,用尽 ⇒ 这一只与其后各只正则形未判(`vm` 的 `timeout` 打断回溯,含 tier-up 之后的原生码);
80
- * `invalid_regex` ——「regex」档正则形编不过(运行期另有每次调用的 `hook_matcher_invalid`;这是确定性的坏配置,不是负载 —— 拒它是新检测臂,不在本规则)。
81
- * 放行最坏后果 = 那一只 matcher 没被这条判据守住、照旧永不命中,**不是安全闸**(受理面有效性判);反方向「越界即拒」会把负载抖动 / 病态但合法的 matcher 变成对合法请求的 400。
96
+ * detail = `event=… reason=… matcherChars=…`(**不带 matcher 原字节**,S-716 姿势)。现在只剩一种 reason:
97
+ * `oversize` —— 两档:matcher 超 {@link MAX_MATCHER_EVAL_CHARS} 字符不读(多租户车道上 hooks 不装配,这一笔只为让超长输入显形)。
98
+ * (`over_budget` 臂自 S-1455 起退役:它是「预算用尽 ⇒ 照旧受理」的随负载翻转的结局,现在是上面的确定拒绝;`invalid_regex` 臂自 S-1455 收尾起退役,同理。)
99
+ * 放行最坏后果 = 那一只 matcher 没被这条判据守住、照旧永不命中,**不是安全闸**(受理面有效性判)。
82
100
  * 拒放集合相对 7.113.0 的一处放宽(披露在 CHANGELOG / MIGRATION):超过上限的字面别名 matcher 7.113.0 拒(当时字面判不设上限),自 7.114.0 两档都判未判、受理。
83
101
  *
84
- * 错误句只回显**目录里的**别名与正名(闭集字符串),不回显调用方的原字节(S-716 姿势)。
102
+ * 错误句:死别名与超预算两形只回显**目录里的**别名与正名 / 位置(闭集字符串与数字),不回显调用方的原字节(S-716 姿势);编不过一形**点名那只 matcher 的原文**(截断到 {@link MATCHER_ECHO_CHARS} 字符)与引擎的编译错误句 ——
103
+ * 调用方要改的就是这一串,且它本来就是调用方自己送来的;机读位 `detail` 仍不带这两段。
85
104
  *
86
- * 🔴 **判决是数据,句子与体的机读位同出一只铸点**(S-1025;client-core 请托,additive):{@link deadToolMatcherIssue} 回 {@link DeadToolMatcherIssue}(event / alias / canonical)
87
- * 而不是句子;{@link buildDeadToolMatcherRefusal} 是**唯一**把它铸成拒句 + `field` + `detail` 的函数,受理门只取它的返回、**不解析句子**。体上多出的两键让三端不必解析英文就能把这只拒
88
- * 与普通提交失败分流、提示用户改 matcher;句子逐字同 7.114.0、零新码。
105
+ * 🔴 **判决是数据,句子与体的机读位同出一只铸点**(S-1025;client-core 请托,additive):{@link toolMatcherIssue} 回 {@link ToolMatcherIssue}(闭集三形:{@link DeadToolMatcherIssue} 的 event / alias / canonical,
106
+ * {@link OverBudgetMatcherIssue} 的 event / groupIndex / budgetMs,{@link InvalidRegexMatcherIssue} 的 event / groupIndex〔+ 句子用的 matcher / compileError〕,以 `reason` 区分)而不是句子;{@link buildToolMatcherRefusal} 是**唯一**把它铸成拒句 + `field` + `detail` 的函数,
107
+ * 受理门只取它的返回、**不解析句子**。体上多出的两键让三端不必解析英文就能把这只拒与普通提交失败分流、提示用户改 matcher;死别名那一句逐字同 7.114.0、零新码。
89
108
  */
90
109
  /** 超此长度的 matcher 不在受理面读(两档同一个上限;未判,reason = oversize)。与 {@link MAX_HOOK_COMMAND_CHARS} 同量纲的服务侧防线:真实 matcher(精确名 / 交替 / `mcp__.*`)远在其内;目录全部工具名交替 ~0.5 KiB。 */
91
110
  export declare const MAX_MATCHER_EVAL_CHARS = 8192;
92
- /** 一次 fresh 受理里 regex 档**正则形**求值共享的墙钟预算(毫秒)。字面形不占它。良性求值 ~0.1 ms;灾难回溯的 matcher 把预算吃完就停,后面的正则形组直接「未判」。 */
93
- export declare const ADMISSION_MATCHER_EVAL_BUDGET_MS = 100;
111
+ /**
112
+ * regex 档**每一次正则形求值各一份**的墙钟预算(毫秒;S-1455)。**不共享、不随受理里 matcher 的个数耗尽、判据里不读钟**;字面形不占它。
113
+ * 良性求值 ~0.1 ms;灾难回溯(指数级)吃满它也跑不完 ⇒ 这只 matcher 被 400 拒。取值是「良性求值之上数个数量级」的余量 —— 小了,忙机上的调度停顿会把良性 matcher 读成灾难(受理结局随负载翻转,正是 S-1455 要根除的形)。
114
+ */
115
+ export declare const ADMISSION_MATCHER_EVAL_BUDGET_MS = 1000;
116
+ /** 拒句里回显的 matcher 原文 / 引擎编译错误句各自的字符上限(截断记号 `…(总长)` 由 `clip` 铸;S-1455 收尾)。真实 matcher 远在其内;上限只防一只 8 KiB 的坏 matcher 把拒句撑成一屏。 */
117
+ export declare const MATCHER_ECHO_CHARS = 200;
94
118
  /** 受理面判据的求值深度(见上头注):`"regex"` = 契约字面形直接问、正则形带预算问同一只契约函数;`"literal"` = 7.112.0 的字面判(只判契约字面形,零调用方正则执行;正则形 / 编不过的形不适用)。 */
95
119
  export type MatcherEvalDepth = "regex" | "literal";
96
120
  /**
97
- * **仅供测试注入**的求值预算(生产调用点不传 ⇒ 预算 = {@link ADMISSION_MATCHER_EVAL_BUDGET_MS}、时钟 = `performance.now`)。
98
- * 让「预算是一次受理共享的」「耗尽之后的组」这类格子按求值次序断言,不按机器速度断言(`test/hooks-alias-matcher-s995.test.ts` R4)。
121
+ * **仅供测试注入**的求值预算(生产调用点不传 ⇒ 每次求值的预算 = {@link ADMISSION_MATCHER_EVAL_BUDGET_MS})。让「灾难形被拒」这类格子能用小额度(毫秒级)跑,不必等满生产预算。
122
+ * 只有预算额度、**没有时钟**:判据本身不读钟(S-1455),测试也就没有「在第几次读钟时预算已尽」可造 —— 求值次序、调度停顿都不改变结局。
99
123
  */
100
124
  export interface MatcherEvalBudget {
101
125
  readonly budgetMs?: number;
102
- readonly now?: () => number;
103
126
  }
104
- /** 一条受理面判决(纯数据):`event` 事件下的某只 matcher 选中了退役别名 `alias`、却选不中它的正名 `canonical`。`alias` / `canonical` 恒取自 core 静态目录(闭集字符串,不含调用方原字节)。 */
127
+ /** 一条受理面判决(纯数据)· 死别名:`event` 事件下的某只 matcher 选中了退役别名 `alias`、却选不中它的正名 `canonical`。`alias` / `canonical` 恒取自 core 静态目录(闭集字符串,不含调用方原字节)。 */
105
128
  export interface DeadToolMatcherIssue {
129
+ readonly reason: "dead_alias";
106
130
  readonly event: HookEventName;
107
131
  readonly alias: string;
108
132
  readonly canonical: string;
109
133
  }
110
- /** {@link buildDeadToolMatcherRefusal} 的返回(纯数据):拒句 + 400 体上的两个机读位。`field` 恒为字面 `"settings.hooks"`;`detail` 与 {@link DeadToolMatcherIssue} 同三键(逐个挑出,不 spread)。 */
111
- export interface DeadToolMatcherRefusal {
134
+ /** 一条受理面判决(纯数据)· 求值超预算(S-1455):`event` 事件下第 `groupIndex` 组(从 0 起)的 matcher 单次求值没在 `budgetMs` 内结束(灾难回溯)。三键都是闭集 / 数字,不含调用方原字节。 */
135
+ export interface OverBudgetMatcherIssue {
136
+ readonly reason: "over_budget";
137
+ readonly event: HookEventName;
138
+ readonly groupIndex: number;
139
+ readonly budgetMs: number;
140
+ }
141
+ /**
142
+ * 一条受理面判决(纯数据)· 正则编不过(S-1455 收尾):`event` 事件下第 `groupIndex` 组(从 0 起)的 matcher 在契约包的读法下答 `invalid`。
143
+ * `matcher` = 那只 matcher 的原文、`compileError` = 引擎的编译错误句(各自截到 {@link MATCHER_ECHO_CHARS} 字符):**只供铸拒句用**,机读位 `detail` 不带它们。
144
+ */
145
+ export interface InvalidRegexMatcherIssue {
146
+ readonly reason: "invalid_regex";
147
+ readonly event: HookEventName;
148
+ readonly groupIndex: number;
149
+ readonly matcher: string;
150
+ readonly compileError: string;
151
+ }
152
+ /** 受理面判据的判决(闭集三形,`reason` 区分;每一形都是 400 拒):死别名 · 求值超预算 · 正则编不过。 */
153
+ export type ToolMatcherIssue = DeadToolMatcherIssue | OverBudgetMatcherIssue | InvalidRegexMatcherIssue;
154
+ /** {@link buildToolMatcherRefusal} 的返回(纯数据):拒句 + 400 体上的两个机读位。`field` 恒为字面 `"settings.hooks"`;`detail` 与 {@link ToolMatcherIssue} 同键(按 `reason` 分形,逐个挑出,不 spread)。 */
155
+ export interface ToolMatcherRefusal {
112
156
  readonly message: string;
113
157
  readonly field: "settings.hooks";
114
158
  readonly detail: {
159
+ readonly reason: "dead_alias";
115
160
  readonly event: HookEventName;
116
161
  readonly alias: string;
117
162
  readonly canonical: string;
163
+ } | {
164
+ readonly reason: "over_budget";
165
+ readonly event: HookEventName;
166
+ readonly groupIndex: number;
167
+ readonly budgetMs: number;
168
+ } | {
169
+ readonly reason: "invalid_regex";
170
+ readonly event: HookEventName;
171
+ readonly groupIndex: number;
118
172
  };
119
173
  }
120
174
  /**
121
- * 判决 → 拒句 + 机读位。**拒句的唯一铸点**:句子逐字 = 7.114.0 的那一句(`test/hooks-alias-matcher-s995.test.ts` R7 冻结);受理门 `admission.ts` 用返回值填 400 体,
122
- * 不从句子里反解字段。
175
+ * 判决 → 拒句 + 机读位。**拒句的唯一铸点**:死别名的句子逐字 = 7.114.0 的那一句(`test/hooks-alias-matcher-s995.test.ts` R7 冻结);受理门 `admission.ts` 用返回值填 400 体,
176
+ * 不从句子里反解字段。超预算的句子点名**哪一只**(事件 + 组下标 + `matcher`)与**预算**,不回显 matcher 原字节(S-716 姿势);`reason` 闭集穷举(多一形 = 这里编译红)。
123
177
  */
124
- export declare function buildDeadToolMatcherRefusal(issue: DeadToolMatcherIssue): DeadToolMatcherRefusal;
178
+ export declare function buildToolMatcherRefusal(issue: ToolMatcherIssue): ToolMatcherRefusal;
125
179
  /**
126
- * fresh 提交面专用判据(S-924 / S-995):配置里第一条「永远打不中」的工具事件 matcher 的判决数据({@link DeadToolMatcherIssue});没有 ⇒ undefined。
127
- * 拒句与 400 体的机读位由 {@link buildDeadToolMatcherRefusal} 从这份数据铸(S-1025)。
180
+ * fresh 提交面专用判据(S-924 / S-995 / S-1455):配置里第一条「该拒」的工具事件 matcher 的判决数据({@link ToolMatcherIssue}):「永远打不中」(死别名)或「求值超预算」(灾难回溯);没有 ⇒ undefined。
181
+ * 拒句与 400 体的机读位由 {@link buildToolMatcherRefusal} 从这份数据铸(S-1025)。**按配置顺序(事件 → 组)遇到的第一条定结局**;结局只取决于配置,不取决于求值次序之外的任何东西(无共享预算、无时钟)。
128
182
  * 入参是 {@link parseHooksConfig} 已校验过的配置;`depth` 由调用点按车道定(见头注:`taskHooksHonored(config) ? "regex" : "literal"`)。
129
183
  * `budget` **仅供测试注入**({@link MatcherEvalBudget});生产调用点只传前两个实参。
130
- * **只有 `admission.ts` 的 fresh 提交门调它** —— resume 重放不调(理由见上面的头注)。未判的 matcher 过 `recordFailOpen`(tag `MATCHER_UNJUDGED_TAG`)。
184
+ * **只有 `admission.ts` 的 fresh 提交门调它** —— resume 重放不调(理由见上面的头注)。未判的 matcher 过 `recordFailOpen`(tag `MATCHER_UNJUDGED_TAG`);超预算的不是未判,是拒。
131
185
  */
132
- export declare function deadToolMatcherIssue(config: HooksConfig, depth: MatcherEvalDepth, budget?: MatcherEvalBudget): DeadToolMatcherIssue | undefined;
186
+ export declare function toolMatcherIssue(config: HooksConfig, depth: MatcherEvalDepth, budget?: MatcherEvalBudget): ToolMatcherIssue | undefined;
133
187
  /**
134
188
  * 把 wire 上的 untrusted `settings.hooks` 校验成契约 {@link HooksConfig} + 服务侧上限。
135
189
  * 错误返回 error 字符串而不 throw——submit 路径 400 fail-loud、resume 防御路径 warn-drop,由调用方选。
@@ -139,7 +193,7 @@ export declare function parseHooksConfig(raw: unknown): ParseHooksConfigResult;
139
193
  * 运行期的 matcher 读法席位:**问契约包的 `hookMatcherSelects`,别无其它**(壳与服务端都问这一只,谁都不留副本;读法全文见它的头注,这里不重述)。
140
194
  * 三形(名字表 / 空 `*` 缺省 / 不锚定的 JS 正则)与「编不过 ⇒ `"invalid"`」都由它答;**对所有带 matcher 的事件同一只读法**(工具事件 / StopFailure 的 `error` 词 /
141
195
  * PreCompact·PostCompact 的 `trigger` 词 / `if` 条件的名部),不按事件分档。`"invalid"` ⇒ 该组不跑(fail-closed 到「不跑」),由调用方记账一次(`hook_matcher_invalid`);
142
- * 受理面({@link deadToolMatcherIssue})问的是同一只函数。
196
+ * 受理面({@link toolMatcherIssue})问的是同一只函数。
143
197
  */
144
198
  export declare function hookMatcherMatches(matcher: string | undefined, value: string): boolean | "invalid";
145
199
  /** `if` 条目条件(CC-exact,对照 CC 2.1.187/198 实测行为核验的真语义,两版一致):
@@ -230,8 +284,8 @@ export type HookLlmFailureCode = "no_content" | "hard_timeout";
230
284
  */
231
285
  export declare function buildHookContext(parts: readonly string[]): string | undefined;
232
286
  /**
233
- * 阶段三a:`http` 条目——契约语义 = POST hook 输入 JSON 到 `url`;headers 里的 `$NAME` 仅当 NAME 列在
234
- * `allowedEnvVars` 才从 worker 进程 env 插值(配置本身绝不携带密钥值,契约同边界)。
287
+ * 阶段三a:`http` 条目——契约语义 = POST hook 输入 JSON 到 `url`;headers 里的 `$NAME` / `${NAME}` 仅当 NAME 列在
288
+ * `allowedEnvVars` 才从 {@link hookEnv} 插值(配置本身绝不携带密钥值,契约同边界);读法只有契约包的 `interpolateHookEnvVars` 一只(见函数体)。
235
289
  *
236
290
  * 结果折算成伪 {@link CommandRunResult},让 9 事件的既有折叠逻辑零改动复用:
237
291
  * - 2xx + JSON body → 同 exit-0-stdout-JSON(SyncHookOutput 决策面)。⚠️ 这是对 CC 行为的合理近似
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import vm from "node:vm";
3
3
  import { hostShell, resolveHostShell, adoptHostForegroundChild } from "../plugins/host-platform.js";
4
- import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, HOOK_EVENT_MATCHER_FIELD, hookMatcherForm, hookMatcherSelects, } from "@sema-agent/settings-schema/hooks";
4
+ import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, HOOK_EVENT_MATCHER_FIELD, hookMatcherForm, hookMatcherSelects, interpolateHookEnvVars, } from "@sema-agent/settings-schema/hooks";
5
5
  import { describeToolCatalog, isEngineRefusal, receiveHookResult } from "@sema-agent/core";
6
6
  import { boundedNoticeText } from "../fleet/fleet-bus.js";
7
7
  import { recordFailOpen } from "../observability/fail-open.js";
@@ -59,7 +59,8 @@ function toAssistantTurnError(errorKind) {
59
59
  return Object.prototype.hasOwnProperty.call(BRAIN_TO_TURN_ERROR, errorKind) ? (BRAIN_TO_TURN_ERROR[errorKind] ?? "unknown") : "unknown";
60
60
  }
61
61
  export const MAX_MATCHER_EVAL_CHARS = MAX_HOOK_COMMAND_CHARS;
62
- export const ADMISSION_MATCHER_EVAL_BUDGET_MS = 100;
62
+ export const ADMISSION_MATCHER_EVAL_BUDGET_MS = 1_000;
63
+ export const MATCHER_ECHO_CHARS = 200;
63
64
  const MATCHER_UNJUDGED_TAG = "server.hooks.matcher-unjudged";
64
65
  function matcherReadsWireToolName(event) {
65
66
  return HOOK_EVENT_OWNER[event] === "engine" && HOOK_EVENT_MATCHER_FIELD[event] === "tool_name";
@@ -81,10 +82,7 @@ function engineToolAliases() {
81
82
  return table;
82
83
  }
83
84
  let matcherEvalBox;
84
- function matcherSelectsWithin(matcher, name, budget) {
85
- const remaining = budget.deadlineMs - budget.now();
86
- if (remaining <= 0)
87
- return "over_budget";
85
+ function matcherSelectsWithin(matcher, name, budgetMs) {
88
86
  matcherEvalBox ??= {
89
87
  script: new vm.Script("hookMatcherSelects(matcher, name)"),
90
88
  context: vm.createContext({ hookMatcherSelects, matcher: "", name: "" }),
@@ -92,7 +90,7 @@ function matcherSelectsWithin(matcher, name, budget) {
92
90
  matcherEvalBox.context.matcher = matcher;
93
91
  matcherEvalBox.context.name = name;
94
92
  try {
95
- const verdict = matcherEvalBox.script.runInContext(matcherEvalBox.context, { timeout: Math.max(1, Math.ceil(remaining)) });
93
+ const verdict = matcherEvalBox.script.runInContext(matcherEvalBox.context, { timeout: Math.max(1, Math.ceil(budgetMs)) });
96
94
  return verdict === true ? true : verdict === false ? false : "invalid";
97
95
  }
98
96
  catch (err) {
@@ -101,12 +99,21 @@ function matcherSelectsWithin(matcher, name, budget) {
101
99
  throw err;
102
100
  }
103
101
  }
102
+ function matcherCompileSentence(matcher) {
103
+ try {
104
+ new RegExp(matcher);
105
+ }
106
+ catch (err) {
107
+ return err instanceof Error ? err.message : String(err);
108
+ }
109
+ return "the pattern does not compile as a regular expression";
110
+ }
104
111
  const MATCHER_EVALUATION = {
105
112
  literal: { regex: "inline", literal: "inline" },
106
113
  regex: { regex: "budgeted", literal: "not_applicable" },
107
114
  invalid: { regex: "unreadable", literal: "not_applicable" },
108
115
  };
109
- function deadAliasInMatcher(matcher, depth, budget) {
116
+ function judgeMatcher(matcher, depth, budgetMs) {
110
117
  if (matcher.length > MAX_MATCHER_EVAL_CHARS)
111
118
  return { kind: "unjudged", reason: "oversize" };
112
119
  const evaluate = MATCHER_EVALUATION[hookMatcherForm(matcher)][depth];
@@ -114,7 +121,7 @@ function deadAliasInMatcher(matcher, depth, budget) {
114
121
  case "not_applicable":
115
122
  return undefined;
116
123
  case "unreadable":
117
- return { kind: "unjudged", reason: "invalid_regex" };
124
+ return { kind: "invalid_regex" };
118
125
  case "inline":
119
126
  case "budgeted":
120
127
  break;
@@ -123,48 +130,74 @@ function deadAliasInMatcher(matcher, depth, budget) {
123
130
  return unreachable;
124
131
  }
125
132
  }
126
- const selects = (name) => {
127
- const verdict = evaluate === "inline" ? hookMatcherSelects(matcher, name) : matcherSelectsWithin(matcher, name, budget);
128
- return verdict === "invalid" ? "invalid_regex" : verdict;
129
- };
133
+ const selects = (name) => (evaluate === "inline" ? hookMatcherSelects(matcher, name) : matcherSelectsWithin(matcher, name, budgetMs));
134
+ const unanswered = (verdict) => (verdict === "over_budget" ? { kind: "over_budget" } : { kind: "invalid_regex" });
130
135
  for (const [alias, canonical] of engineToolAliases()) {
131
136
  const selectsAlias = selects(alias);
132
137
  if (typeof selectsAlias === "string")
133
- return { kind: "unjudged", reason: selectsAlias };
138
+ return unanswered(selectsAlias);
134
139
  if (!selectsAlias)
135
140
  continue;
136
141
  const selectsCanonical = selects(canonical);
137
142
  if (typeof selectsCanonical === "string")
138
- return { kind: "unjudged", reason: selectsCanonical };
143
+ return unanswered(selectsCanonical);
139
144
  if (!selectsCanonical)
140
145
  return { kind: "dead", alias, canonical };
141
146
  }
142
147
  return undefined;
143
148
  }
144
- export function buildDeadToolMatcherRefusal(issue) {
145
- return {
146
- message: `settings.hooks.${issue.event}: matcher "${issue.alias}" is an alias; tool-event matchers match the tool's wire name (PostToolUse / PostToolUseFailure since 7.107.0) — use "${issue.canonical}"`,
147
- field: "settings.hooks",
148
- detail: { event: issue.event, alias: issue.alias, canonical: issue.canonical },
149
- };
149
+ export function buildToolMatcherRefusal(issue) {
150
+ switch (issue.reason) {
151
+ case "dead_alias":
152
+ return {
153
+ message: `settings.hooks.${issue.event}: matcher "${issue.alias}" is an alias; tool-event matchers match the tool's wire name (PostToolUse / PostToolUseFailure since 7.107.0) — use "${issue.canonical}"`,
154
+ field: "settings.hooks",
155
+ detail: { reason: issue.reason, event: issue.event, alias: issue.alias, canonical: issue.canonical },
156
+ };
157
+ case "over_budget":
158
+ return {
159
+ message: `settings.hooks.${issue.event}[${issue.groupIndex}].matcher: evaluating the pattern did not finish within ${issue.budgetMs} ms (catastrophic regex backtracking) — rewrite it as a tool-name list or a pattern without nested repetition`,
160
+ field: "settings.hooks",
161
+ detail: { reason: issue.reason, event: issue.event, groupIndex: issue.groupIndex, budgetMs: issue.budgetMs },
162
+ };
163
+ case "invalid_regex":
164
+ return {
165
+ message: `settings.hooks.${issue.event}[${issue.groupIndex}].matcher: ${JSON.stringify(issue.matcher)} is not a valid regular expression (${issue.compileError}) — fix the pattern, or write the tool names as a list such as "Read|Edit"`,
166
+ field: "settings.hooks",
167
+ detail: { reason: issue.reason, event: issue.event, groupIndex: issue.groupIndex },
168
+ };
169
+ default: {
170
+ const unreachable = issue;
171
+ return unreachable;
172
+ }
173
+ }
150
174
  }
151
- export function deadToolMatcherIssue(config, depth, budget = {}) {
152
- const now = budget.now ?? (() => performance.now());
153
- const deadline = { deadlineMs: now() + (budget.budgetMs ?? ADMISSION_MATCHER_EVAL_BUDGET_MS), now };
175
+ export function toolMatcherIssue(config, depth, budget = {}) {
176
+ const budgetMs = budget.budgetMs ?? ADMISSION_MATCHER_EVAL_BUDGET_MS;
154
177
  for (const [event, groups] of Object.entries(config)) {
155
178
  if (!matcherReadsWireToolName(event))
156
179
  continue;
157
- for (const group of groups ?? []) {
180
+ for (const [groupIndex, group] of (groups ?? []).entries()) {
158
181
  if (group.matcher === undefined)
159
182
  continue;
160
- const verdict = deadAliasInMatcher(group.matcher, depth, deadline);
183
+ const verdict = judgeMatcher(group.matcher, depth, budgetMs);
161
184
  if (verdict === undefined)
162
185
  continue;
163
- if (verdict.kind === "unjudged") {
164
- recordFailOpen(MATCHER_UNJUDGED_TAG, `event=${event} reason=${verdict.reason} matcherChars=${group.matcher.length}`);
165
- continue;
186
+ switch (verdict.kind) {
187
+ case "dead":
188
+ return { reason: "dead_alias", event, alias: verdict.alias, canonical: verdict.canonical };
189
+ case "over_budget":
190
+ return { reason: "over_budget", event, groupIndex, budgetMs };
191
+ case "invalid_regex":
192
+ return { reason: "invalid_regex", event, groupIndex, matcher: clip(group.matcher, MATCHER_ECHO_CHARS), compileError: clip(matcherCompileSentence(group.matcher), MATCHER_ECHO_CHARS) };
193
+ case "unjudged":
194
+ recordFailOpen(MATCHER_UNJUDGED_TAG, `event=${event} reason=${verdict.reason} matcherChars=${group.matcher.length}`);
195
+ break;
196
+ default: {
197
+ const unreachable = verdict;
198
+ return unreachable;
199
+ }
166
200
  }
167
- return { event, alias: verdict.alias, canonical: verdict.canonical };
168
201
  }
169
202
  }
170
203
  return undefined;
@@ -558,19 +591,19 @@ async function runLlmHook(entry, payload, ctx, extra) {
558
591
  clearTimeout(hardTopTimer);
559
592
  }
560
593
  }
561
- async function runHttpHook(entry, payload, ctx) {
594
+ const MAX_BLANKED_REFERENCES_LOGGED = 16;
595
+ async function runHttpHook(entry, payload, ctx, event) {
562
596
  const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
563
- const allowed = new Set(entry.allowedEnvVars ?? []);
564
- const envSource = hookEnv(ctx);
565
- const headers = { "content-type": "application/json" };
566
- for (const [k, v] of Object.entries(entry.headers ?? {})) {
567
- headers[k] = v.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, name) => {
568
- if (!allowed.has(name))
569
- return whole;
570
- const ev = envSource[name];
571
- return typeof ev === "string" ? ev : whole;
597
+ const interpolated = interpolateHookEnvVars(entry.headers ?? {}, entry.allowedEnvVars ?? [], hookEnv(ctx));
598
+ if (interpolated.blanked.length > 0) {
599
+ ctx.logger.warn("hook_http_header_env_blanked", {
600
+ event,
601
+ url: buildDisplaySafeUrl(entry.url),
602
+ blankedCount: interpolated.blanked.length,
603
+ blanked: interpolated.blanked.slice(0, MAX_BLANKED_REFERENCES_LOGGED).map(({ header, name, reason }) => ({ header, name, reason })),
572
604
  });
573
605
  }
606
+ const headers = { "content-type": "application/json", ...interpolated.headers };
574
607
  const ac = new AbortController();
575
608
  const timer = setTimeout(() => ac.abort(), timeoutMs);
576
609
  try {
@@ -732,7 +765,7 @@ async function runMatchingEntries(event, groups, matchValue, payload, ctx, onceF
732
765
  onceFired.add(onceKey);
733
766
  if (entry.type === "prompt" || entry.type === "agent")
734
767
  llmEntries++;
735
- const run = entry.type === "http" ? await runHttpHook(entry, payload, ctx)
768
+ const run = entry.type === "http" ? await runHttpHook(entry, payload, ctx, event)
736
769
  : entry.type === "prompt" || entry.type === "agent" ? await runLlmHook(entry, payload, ctx, entry.type === "prompt" ? llmExtra : { system: ccAgentHookSystemFor(event), event })
737
770
  : await runCommandHook(entry, payload, ctx);
738
771
  if (run.spawnError) {
@@ -1,13 +1,13 @@
1
1
  import { APPROVAL_REACH, MCP_IMAGE_MAX_BASE64, isApprovalReach, isPermissionMode, isThinkingLevel, expandTiers } from "@sema-agent/core";
2
2
  import { isAtMentionable, matchCatalogModel, refusedCatalogEntry, selectableCatalog } from "../model-select.js";
3
3
  import { modelUnavailableMessage } from "../model-entry-refusal.js";
4
- import { HttpError } from "../security.js";
4
+ import { HttpError, sessionIdShapeIssue } from "../security.js";
5
5
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS, PERMISSION_MODE_WORDS, parseTaskSettings, settingsPermissionBucketIssue, skillListingBudgetFractionOk, skillListingMaxDescOk, stampEntryPermissionMode, taskHooksHonored, taskSettingsKeyIssue } from "../task-settings.js";
6
6
  import { autonomyModeFloor } from "../runtime-governance.js";
7
7
  import { taskBodyKeyIssue } from "../request-key-closure.js";
8
8
  import { FILE_HISTORY_DISABLED_NO_RESTORE, fileHistoryRestoreConflict, inheritEnvFieldIssue, singleMemberOffFieldIssue } from "../request-value-closure.js";
9
9
  import * as facts from "../capability-facts.js";
10
- import { buildDeadToolMatcherRefusal, deadToolMatcherIssue, parseHooksConfig } from "../hooks/hook-runner.js";
10
+ import { buildToolMatcherRefusal, parseHooksConfig, toolMatcherIssue } from "../hooks/hook-runner.js";
11
11
  import { judgeRequestWebSearch } from "../plugins/web-search.js";
12
12
  import { normalizeApproachNotice, skillToolListOk, skillToolListRefusal, unknownTaskAgentModelRef, validateTaskAgents } from "../spec-fields.js";
13
13
  import { MAX_ADDITIONAL_DIRS } from "../task-cwd.js";
@@ -85,9 +85,12 @@ export function createAdmission(ctx) {
85
85
  sendError(res, 400, "request.field_invalid", "jobId must be a non-empty string of at most 64 characters");
86
86
  return null;
87
87
  }
88
- if (typeof body.sessionId === "string" && body.sessionId.length > 64) {
89
- sendError(res, 400, "request.field_invalid", "sessionId must be at most 64 characters");
90
- return null;
88
+ {
89
+ const sessionIdIssue = sessionIdShapeIssue(body.sessionId);
90
+ if (sessionIdIssue) {
91
+ sendError(res, 400, "request.id_invalid", sessionIdIssue);
92
+ return null;
93
+ }
91
94
  }
92
95
  if (typeof body.systemPrompt === "string" && body.systemPrompt.length > MAX_SYSTEM_PROMPT_CHARS) {
93
96
  sendError(res, 400, "request.field_invalid", `systemPrompt must be at most ${MAX_SYSTEM_PROMPT_CHARS} characters`);
@@ -212,9 +215,9 @@ export function createAdmission(ctx) {
212
215
  sendError(res, 400, "request.field_invalid", hooksParsed.error ?? "settings.hooks: invalid shape");
213
216
  return null;
214
217
  }
215
- const deadMatcher = deadToolMatcherIssue(hooksParsed.config, taskHooksHonored(deps.config) ? "regex" : "literal");
216
- if (deadMatcher !== undefined) {
217
- const refusal = buildDeadToolMatcherRefusal(deadMatcher);
218
+ const toolMatcher = toolMatcherIssue(hooksParsed.config, taskHooksHonored(deps.config) ? "regex" : "literal");
219
+ if (toolMatcher !== undefined) {
220
+ const refusal = buildToolMatcherRefusal(toolMatcher);
218
221
  sendError(res, 400, "request.field_invalid", refusal.message, { field: refusal.field, detail: refusal.detail });
219
222
  return null;
220
223
  }
@@ -502,7 +505,7 @@ export function createAdmission(ctx) {
502
505
  return null;
503
506
  }
504
507
  }
505
- for (const key of ["excludeTools", "deferTools"]) {
508
+ for (const key of ["allowTools", "excludeTools", "deferTools"]) {
506
509
  const v = body[key];
507
510
  if (v !== undefined && (!Array.isArray(v) || v.some((n) => typeof n !== "string" || n.length === 0))) {
508
511
  sendError(res, 400, "request.field_invalid", `${key} must be an array of non-empty tool-name strings`);
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { type RunInternals } from "@sema-agent/core";
17
17
  import type { RunStore } from "../plugins/store-backend.js";
18
- import type { RunRecord } from "../plugins/store-contracts.js";
18
+ import type { RunIdemBinding, RunRecord } from "../plugins/store-contracts.js";
19
19
  import type { PreparedTaskSubmission, RouteCtxBase } from "./route-ctx.js";
20
20
  /**
21
21
  * 🔴 **TRUSTED run internals** —— 由**部署方**(不是模型、不是请求体、不是工具参数)铸给这一跑的
@@ -35,8 +35,9 @@ export interface DurableRunLegArgs {
35
35
  prepared: PreparedTaskSubmission;
36
36
  /** 调用方自带的 taskId(`POST /v1/runs` 的 `body.taskId` 幂等键);缺席 ⇒ 本腿铸 uuidv7。 */
37
37
  clientTaskId?: string;
38
- /** 这次提交的幂等键(`idemKeyOf(raw, caller, "run")`)—— 落 `task_run.idem_key`;缺席 = 调用方没带 `Idempotency-Key`。 */
39
- idemKey?: string;
38
+ /** 这次提交的账本绑定(键 `idemKeyOf(raw, caller, "run")` + 请求体指纹)—— 落 `task_run.idem_key` / `idem_body_fingerprint`;
39
+ * 缺席 = 调用方没带 `Idempotency-Key`。 */
40
+ idem?: RunIdemBinding;
40
41
  /** 🔒 这条 run 的 **fleet 行 scope** —— 必须与 `GET /v1/fleet/stream` 过滤用的是同一个身份,且绝不是
41
42
  * 自报的 `x-agent-principal` 头(自报头会把行打到受害者的 scope 上,或打到 `default` 而真属主的流把它
42
43
  * 过滤掉)。提交门传 `gatedPrincipal(req) ?? "default"`;`/decide` 的第三条车道传 resume 家族的
@@ -71,7 +72,7 @@ export type DurableRunLegOutcome = {
71
72
  /**
72
73
  * 建行 + 驱动。返回 `202` = 新行已建并在跑(体带 `taskId`/`sessionId`);`409` = 会话被别的 run 占着,
73
74
  * **没有**建行(体 = `buildActiveRunConflict` 的既有形,含 `errorCode: "conflict.session_active_run"`);
74
- * `existing` = 这次提交的钥匙(`idemKey` / `clientTaskId`)已经指向一条行,没建新行。
75
+ * `existing` = 这次提交的钥匙(`idem.key` / `clientTaskId`)已经指向一条行,没建新行。
75
76
  * 协议渲染归调用方(HTTP `sendJson` / A2A 协议对象 / `/decide` 的受理体)。
76
77
  */
77
78
  export declare function mintAndDriveDurableRun(ctx: RouteCtxBase, runStore: RunStore, args: DurableRunLegArgs): Promise<DurableRunLegOutcome>;
@@ -15,7 +15,7 @@ export async function mintAndDriveDurableRun(ctx, runStore, args) {
15
15
  const { inflightRuns, preemptableRuns, steerableRuns } = ctx.registry;
16
16
  const sessionId = args.prepared.spec.sessionId ?? uuidv7();
17
17
  const taskId = args.clientTaskId ?? uuidv7();
18
- const created = await runStore.createRun(taskId, sessionId, args.prepared.auth?.principal ?? null, deps.instanceId ?? "default", { ...runMeta(args.prepared, args.source), idemKey: args.idemKey ?? null });
18
+ const created = await runStore.createRun(taskId, sessionId, args.prepared.auth?.principal ?? null, deps.instanceId ?? "default", { ...runMeta(args.prepared, args.source), idem: args.idem ?? null });
19
19
  if (created.ok)
20
20
  clearTurnActivity(taskId);
21
21
  if (created.ok)
@@ -2,6 +2,7 @@ import type { IncomingMessage } from "node:http";
2
2
  import type { ServiceConfig } from "../config-types.js";
3
3
  import type { WiringManifestReceipt } from "./wire-types.js";
4
4
  import type { SubmitReplayFace } from "./submit-replay.js";
5
+ import type { RunIdemBinding } from "../plugins/store-contracts.js";
5
6
  /**
6
7
  * 同进程的**在飞合流** —— 并发同键的提交共享同一个 promise(避免同一进程里双起)。它**不是**重放的真源。
7
8
  *
@@ -78,14 +79,35 @@ export declare function idemIdentityOf(caller: IdemCaller): string;
78
79
  */
79
80
  export declare function idemKeyOf(raw: string, caller: IdemCaller, family: IdemFamily): string;
80
81
  /**
81
- * 在飞合流的键 = 提交幂等键 × 这条腿的**回执形**(`durable` / `sync`)× 这次请求自带的 taskId。
82
+ * 一次提交的**请求体指纹** —— HTTP 三条提交腿(`POST /v1/tasks` · `/v1/tasks/stream` · `/v1/runs`)同一只规范化函数:
83
+ * `sha256(canonicalJson(体 − taskId))` 的 hex(定宽 64)。
82
84
  *
83
- * 合流交出的是**首个请求那条腿的结局**,所以只有「同一条 run、同一种回执形、同一把 taskId」的请求才许合流:durable 腿
84
- * 合到同步腿的在飞结局上会拿到 200 引擎结果而不是 202 收据;带另一个 taskId 的请求合进来就绕过了「key 指向别的行」
85
- * 那条 409。形不同(或 taskId 不同)的请求各自走到建行,由店的原子赢或观察 + 唯一回执函数作答 —— 仍是一条 run,
86
- * 各拿自己腿的回执形。同步腿与流式腿同形(流式腿把同一份体放进 `done.result`),合流照旧。
85
+ * 幂等 = 同一调用方、同一键、**同一请求**:账本行记下绑定 `Idempotency-Key` 的那次请求的指纹(`task_run.idem_body_fingerprint`),
86
+ * 同键再投而指纹不同 ⇒ 唯一回执函数答 409(`http/submit-replay.ts`)。规范化只有两步:
87
+ * · JSON 按键递归排序(唯一序列化器 `canonicalJson`;值为 `undefined` 的键与缺席同义)—— 键序不同的同一份体是同一请求;
88
+ * · 去掉 `taskId` —— 它是同一条律的**另一把钥匙**(调用方自铸的主键),由回执函数的「钥匙指向别的行 ⇒ `conflict.run_exists`」判,
89
+ * 不是请求内容(换腿重投:流式腿不带 → durable 腿带回 `x-task-id`,仍是同一请求)。
90
+ * 头一概不进:身份已在键里(`idemKeyOf` 的身份位),其余头(`Idempotency-Key` 本身、`x-detach-on-disconnect` 一类连接语义)不是提交内容。
91
+ * 入参是**线上收到的那份体**(`readJson` 的产物),不是 `prepareSpec` 之后服务端盖过章的体 —— 指纹只由调用方看得见的字节构成,
92
+ * 否则两台配置不同的副本对同一次重试算出两只指纹。
87
93
  */
88
- export declare function inflightKeyOf(idemKey: string, face: SubmitReplayFace, clientTaskId?: string): string;
94
+ export declare function submissionFingerprintOf(body: unknown): string;
95
+ /**
96
+ * HTTP 提交腿(`POST /v1/tasks` · `/v1/tasks/stream` · `/v1/runs`)的**账本绑定**唯一铸法:键 = `idemKeyOf(raw, caller, "run")`,
97
+ * 指纹 = {@link submissionFingerprintOf}(线上收到的那份体)。三条腿在读到 `Idempotency-Key` 的同一处调它 —— 键与指纹同铸,不各拼一份。
98
+ * (A2A 的绑定不经这里:它的原始键就是消息内容指纹,绑定 = `{ key: idemKeyOf(指纹, …), bodyFingerprint: 指纹 }`,见 `routes/a2a-serve.ts`。)
99
+ */
100
+ export declare function submissionIdemOf(raw: string, caller: IdemCaller, body: unknown): RunIdemBinding;
101
+ /**
102
+ * 在飞合流的键 = 这次提交的**账本绑定**(幂等键 × 请求体指纹)× 这条腿的**回执形**(`durable` / `sync`)× 这次请求自带的 taskId。
103
+ *
104
+ * 合流交出的是**首个请求那条腿的结局**,所以只有「同一请求、同一条 run、同一种回执形、同一把 taskId」的请求才许合流:同键异体的
105
+ * 请求合进来就是静默拿到别的请求的结局(正是 409 `conflict.idempotency_payload_mismatch` 要拦的那件事);durable 腿合到同步腿的在飞
106
+ * 结局上会拿到 200 引擎结果而不是 202 收据;带另一个 taskId 的请求合进来就绕过了「key 指向别的行」那条 409。不许合流的请求各自走到
107
+ * 读行 / 建行,由店的原子赢或观察 + 唯一回执函数作答 —— 同体的仍是一条 run、各拿自己腿的回执形,异体的拿 409。同步腿与流式腿同形
108
+ * (流式腿把同一份体放进 `done.result`),合流照旧。
109
+ */
110
+ export declare function inflightKeyOf(idem: RunIdemBinding, face: SubmitReplayFace, clientTaskId?: string): string;
89
111
  /**
90
112
  * 提交腿的**运行结局**(在飞合流共享的那一只)—— `status` + `body` 是它一直以来的两位;S-528 起多一位
91
113
  * `wiringManifest`。
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { canonicalJson } from "../canonical-json.js";
2
3
  import { gatedPrincipal } from "./principal-gate.js";
3
4
  export class IdempotencyCache {
4
5
  ttlMs;
@@ -77,7 +78,14 @@ export function idemIdentityOf(caller) {
77
78
  export function idemKeyOf(raw, caller, family) {
78
79
  return createHash("sha256").update(`${idemIdentityOf(caller)}\x1f${idemFamilyTag(family)}\x1f${raw}`).digest("hex");
79
80
  }
80
- export function inflightKeyOf(idemKey, face, clientTaskId) {
81
- return `${idemKey}\x1f${face}\x1f${clientTaskId ?? ""}`;
81
+ export function submissionFingerprintOf(body) {
82
+ const content = body !== null && typeof body === "object" && !Array.isArray(body) ? { ...body, taskId: undefined } : body;
83
+ return createHash("sha256").update(canonicalJson(content)).digest("hex");
84
+ }
85
+ export function submissionIdemOf(raw, caller, body) {
86
+ return { key: idemKeyOf(raw, caller, "run"), bodyFingerprint: submissionFingerprintOf(body) };
87
+ }
88
+ export function inflightKeyOf(idem, face, clientTaskId) {
89
+ return `${idem.key}\x1f${idem.bodyFingerprint}\x1f${face}\x1f${clientTaskId ?? ""}`;
82
90
  }
83
91
  //# sourceMappingURL=idempotency.js.map
@@ -92,6 +92,20 @@ export declare function sendUnauthorized(res: ServerResponse, face: Unauthorized
92
92
  * 那样「谁多给了 operator 一条路」在代码上看得见,而不是被偷偷塞进共享谓词里。
93
93
  */
94
94
  export declare function runOwnerReadable(principal: string | undefined, owner: string | null, config: ServiceConfig): boolean;
95
+ /**
96
+ * 🔴 **「这台部署有没有身份边界」的唯一判据**(S-1452)。dev-open = 没配 service token(单一或 per-system)、没配只读 trace token、
97
+ * 也没开 `REQUIRE_PRINCIPAL`:请求里没有任何东西替主体头作保,头是谁都能自报的**标签**(过滤 / 展示用),不是身份。
98
+ * 三处读它、不各写一份:会话列表的 fleet-wide 第一臂(`isFleetWide`)、会话写面的 fleet-wide 第一臂(`sessionOwnerScopeForWrite`)、
99
+ * 会话面的属主谓词({@link sessionOwnerReadable})—— 于是「调用方是谁」在列表与单会话面上只有一只读法。
100
+ */
101
+ export declare function devOpenDeployment(config: ServiceConfig): boolean;
102
+ /**
103
+ * **「这个调用方能不能动这只会话」的唯一谓词**(会话面:视图 / head / 事件流 / 记忆状态与自助抹除 / 改名置顶 / 后台任务 / MCP 重连 /
104
+ * 面板的上一腿 MCP 投影)。= {@link devOpenDeployment} ∨ {@link runOwnerReadable}:没有身份边界的部署上不钉任何人(与会话列表同读,
105
+ * 列表把这只会话列给谁,谁就读得到它);有身份边界的部署上就是 run 面那一条谓词,一字不变。
106
+ * run 面(poll / events / cancel …)不读本谓词 —— 那是 run 资源的门,另一只。
107
+ */
108
+ export declare function sessionOwnerReadable(principal: string | undefined, owner: string | null, config: ServiceConfig): boolean;
95
109
  /** The EXPLICIT operator gate (IMAGE-API-DESIGN.md §P2.4a MUST-FIX) — NOT the bare `isOperator` (whose empty-list
96
110
  * "true-for-all" is an unrestricted-write exec door the instant OPERATOR_PRINCIPALS is empty). Requires a non-empty
97
111
  * operator set AND a present principal IN it. The same form the preempt/resume endpoints already use.
@@ -93,6 +93,13 @@ export function runOwnerReadable(principal, owner, config) {
93
93
  return true;
94
94
  return !config.requirePrincipal && owner === "anon:shell-live" && principal === undefined;
95
95
  }
96
+ export function devOpenDeployment(config) {
97
+ const anyServiceToken = Boolean(config.authToken) || Object.keys(config.authTokens ?? {}).length > 0;
98
+ return !anyServiceToken && !config.traceToken && !config.requirePrincipal;
99
+ }
100
+ export function sessionOwnerReadable(principal, owner, config) {
101
+ return devOpenDeployment(config) || runOwnerReadable(principal, owner, config);
102
+ }
96
103
  export function explicitOperatorOk(principal, operatorPrincipals) {
97
104
  return operatorPrincipals.length > 0 && principal !== undefined && operatorPrincipals.includes(principal);
98
105
  }