approval-md 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +101 -13
  3. package/cli.js +134 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +72 -0
  8. package/dist/src/channels/contract.js +161 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.d.ts +25 -0
  17. package/dist/src/channels/tagging.js +63 -6
  18. package/dist/src/channels/tagging.js.map +1 -1
  19. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  20. package/dist/src/channels/telegram-minimal.js +618 -0
  21. package/dist/src/channels/telegram-minimal.js.map +1 -0
  22. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  23. package/dist/src/channels/telegram-webhook.js +536 -0
  24. package/dist/src/channels/telegram-webhook.js.map +1 -0
  25. package/dist/src/channels/telegram.d.ts +725 -20
  26. package/dist/src/channels/telegram.js +1332 -123
  27. package/dist/src/channels/telegram.js.map +1 -1
  28. package/dist/src/cli/amend.js +358 -5
  29. package/dist/src/cli/amend.js.map +1 -1
  30. package/dist/src/cli/attest.js +43 -6
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/audit-card.d.ts +1 -1
  33. package/dist/src/cli/audit-card.js +40 -1
  34. package/dist/src/cli/audit-card.js.map +1 -1
  35. package/dist/src/cli/audit.d.ts +7 -5
  36. package/dist/src/cli/audit.js +22 -10
  37. package/dist/src/cli/audit.js.map +1 -1
  38. package/dist/src/cli/channel-relay.d.ts +108 -0
  39. package/dist/src/cli/channel-relay.js +334 -0
  40. package/dist/src/cli/channel-relay.js.map +1 -0
  41. package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
  42. package/dist/src/cli/channel-telegram-webhook.js +716 -0
  43. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  44. package/dist/src/cli/channel-telegram.d.ts +347 -28
  45. package/dist/src/cli/channel-telegram.js +648 -56
  46. package/dist/src/cli/channel-telegram.js.map +1 -1
  47. package/dist/src/cli/channel.d.ts +1 -1
  48. package/dist/src/cli/channel.js +5 -1
  49. package/dist/src/cli/channel.js.map +1 -1
  50. package/dist/src/cli/daemon.js +37 -5
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +856 -111
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +29 -1
  56. package/dist/src/cli/execute.js +387 -21
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +10 -1
  65. package/dist/src/cli/help.js +283 -54
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook.d.ts +485 -6
  68. package/dist/src/cli/hook.js +2370 -70
  69. package/dist/src/cli/hook.js.map +1 -1
  70. package/dist/src/cli/init.js +16 -7
  71. package/dist/src/cli/init.js.map +1 -1
  72. package/dist/src/cli/instructions.js +24 -3
  73. package/dist/src/cli/instructions.js.map +1 -1
  74. package/dist/src/cli/log-anchor.d.ts +55 -0
  75. package/dist/src/cli/log-anchor.js +199 -60
  76. package/dist/src/cli/log-anchor.js.map +1 -1
  77. package/dist/src/cli/log-unlock.d.ts +23 -0
  78. package/dist/src/cli/log-unlock.js +109 -0
  79. package/dist/src/cli/log-unlock.js.map +1 -0
  80. package/dist/src/cli/main.d.ts +0 -12
  81. package/dist/src/cli/main.js +72 -4
  82. package/dist/src/cli/main.js.map +1 -1
  83. package/dist/src/cli/muse.d.ts +2 -0
  84. package/dist/src/cli/muse.js +62 -0
  85. package/dist/src/cli/muse.js.map +1 -0
  86. package/dist/src/cli/payload.js +79 -1
  87. package/dist/src/cli/payload.js.map +1 -1
  88. package/dist/src/cli/scaffold.js +6 -0
  89. package/dist/src/cli/scaffold.js.map +1 -1
  90. package/dist/src/cli/serve.d.ts +53 -0
  91. package/dist/src/cli/serve.js +270 -0
  92. package/dist/src/cli/serve.js.map +1 -0
  93. package/dist/src/cli/up.js +26 -2
  94. package/dist/src/cli/up.js.map +1 -1
  95. package/dist/src/cli/verb-registry.d.ts +2 -0
  96. package/dist/src/cli/verb-registry.js +338 -39
  97. package/dist/src/cli/verb-registry.js.map +1 -1
  98. package/dist/src/cli/wordmark.d.ts +2 -2
  99. package/dist/src/cli/wordmark.js +2 -2
  100. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  101. package/dist/src/core/attest-bootstrap.js +135 -0
  102. package/dist/src/core/attest-bootstrap.js.map +1 -0
  103. package/dist/src/core/attest.d.ts +20 -3
  104. package/dist/src/core/attest.js +25 -0
  105. package/dist/src/core/attest.js.map +1 -1
  106. package/dist/src/core/audit.d.ts +153 -12
  107. package/dist/src/core/audit.js +415 -20
  108. package/dist/src/core/audit.js.map +1 -1
  109. package/dist/src/core/channel-lease.d.ts +322 -0
  110. package/dist/src/core/channel-lease.js +663 -0
  111. package/dist/src/core/channel-lease.js.map +1 -0
  112. package/dist/src/core/command-class.d.ts +51 -0
  113. package/dist/src/core/command-class.js +766 -8
  114. package/dist/src/core/command-class.js.map +1 -1
  115. package/dist/src/core/daemon-host.d.ts +178 -0
  116. package/dist/src/core/daemon-host.js +228 -0
  117. package/dist/src/core/daemon-host.js.map +1 -0
  118. package/dist/src/core/daemon-identity.d.ts +238 -0
  119. package/dist/src/core/daemon-identity.js +252 -0
  120. package/dist/src/core/daemon-identity.js.map +1 -0
  121. package/dist/src/core/gate.d.ts +364 -6
  122. package/dist/src/core/gate.js +802 -27
  123. package/dist/src/core/gate.js.map +1 -1
  124. package/dist/src/core/harness-version.d.ts +128 -4
  125. package/dist/src/core/harness-version.js +179 -12
  126. package/dist/src/core/harness-version.js.map +1 -1
  127. package/dist/src/core/harness-wait.d.ts +82 -3
  128. package/dist/src/core/harness-wait.js +89 -3
  129. package/dist/src/core/harness-wait.js.map +1 -1
  130. package/dist/src/core/log-lock.d.ts +435 -0
  131. package/dist/src/core/log-lock.js +1093 -0
  132. package/dist/src/core/log-lock.js.map +1 -0
  133. package/dist/src/core/log-subscribe.d.ts +21 -0
  134. package/dist/src/core/log-subscribe.js +16 -6
  135. package/dist/src/core/log-subscribe.js.map +1 -1
  136. package/dist/src/core/log-write-layer.d.ts +55 -0
  137. package/dist/src/core/log-write-layer.js +91 -0
  138. package/dist/src/core/log-write-layer.js.map +1 -0
  139. package/dist/src/core/log.d.ts +130 -22
  140. package/dist/src/core/log.js +473 -50
  141. package/dist/src/core/log.js.map +1 -1
  142. package/dist/src/core/payload-census.d.ts +51 -0
  143. package/dist/src/core/payload-census.js +125 -2
  144. package/dist/src/core/payload-census.js.map +1 -1
  145. package/dist/src/core/payload-store.d.ts +31 -0
  146. package/dist/src/core/payload-store.js +113 -8
  147. package/dist/src/core/payload-store.js.map +1 -1
  148. package/dist/src/core/payload.d.ts +18 -3
  149. package/dist/src/core/payload.js +20 -4
  150. package/dist/src/core/payload.js.map +1 -1
  151. package/dist/src/core/policy-diff.d.ts +10 -0
  152. package/dist/src/core/policy-diff.js +10 -2
  153. package/dist/src/core/policy-diff.js.map +1 -1
  154. package/dist/src/core/policy-explain.d.ts +7 -1
  155. package/dist/src/core/policy-explain.js +16 -1
  156. package/dist/src/core/policy-explain.js.map +1 -1
  157. package/dist/src/core/policy-load.d.ts +24 -0
  158. package/dist/src/core/policy-load.js.map +1 -1
  159. package/dist/src/core/policy-match.d.ts +38 -0
  160. package/dist/src/core/policy-match.js +35 -0
  161. package/dist/src/core/policy-match.js.map +1 -1
  162. package/dist/src/core/policy-proposal.d.ts +40 -0
  163. package/dist/src/core/policy-proposal.js +37 -16
  164. package/dist/src/core/policy-proposal.js.map +1 -1
  165. package/dist/src/core/prompt-layout.d.ts +186 -1
  166. package/dist/src/core/prompt-layout.js +345 -3
  167. package/dist/src/core/prompt-layout.js.map +1 -1
  168. package/dist/src/core/read-scope.d.ts +10 -0
  169. package/dist/src/core/read-scope.js +4 -0
  170. package/dist/src/core/read-scope.js.map +1 -1
  171. package/dist/src/core/run-payload.d.ts +160 -0
  172. package/dist/src/core/run-payload.js +304 -0
  173. package/dist/src/core/run-payload.js.map +1 -0
  174. package/dist/src/core/sender-identity.d.ts +59 -11
  175. package/dist/src/core/sender-identity.js +79 -11
  176. package/dist/src/core/sender-identity.js.map +1 -1
  177. package/dist/src/core/state.d.ts +52 -1
  178. package/dist/src/core/state.js +52 -6
  179. package/dist/src/core/state.js.map +1 -1
  180. package/dist/src/core/telegram-config.d.ts +79 -0
  181. package/dist/src/core/telegram-config.js +108 -0
  182. package/dist/src/core/telegram-config.js.map +1 -1
  183. package/dist/src/core/validate.d.ts +20 -6
  184. package/dist/src/core/validate.js +0 -0
  185. package/dist/src/core/validate.js.map +1 -1
  186. package/dist/src/core/verify.d.ts +23 -1
  187. package/dist/src/core/verify.js +20 -2
  188. package/dist/src/core/verify.js.map +1 -1
  189. package/dist/src/daemon/audit.d.ts +80 -5
  190. package/dist/src/daemon/audit.js +126 -6
  191. package/dist/src/daemon/audit.js.map +1 -1
  192. package/dist/src/daemon/daemon.d.ts +174 -2
  193. package/dist/src/daemon/daemon.js +298 -7
  194. package/dist/src/daemon/daemon.js.map +1 -1
  195. package/dist/src/daemon/git-evidence.d.ts +2 -2
  196. package/dist/src/daemon/git-evidence.js +1 -1
  197. package/dist/src/daemon/projection.d.ts +13 -2
  198. package/dist/src/daemon/projection.js +39 -6
  199. package/dist/src/daemon/projection.js.map +1 -1
  200. package/dist/src/mcp/server.d.ts +39 -3
  201. package/dist/src/mcp/server.js +22 -3
  202. package/dist/src/mcp/server.js.map +1 -1
  203. package/dist/src/muse/server.d.ts +16 -0
  204. package/dist/src/muse/server.js +238 -0
  205. package/dist/src/muse/server.js.map +1 -0
  206. package/dist/src/serve/archive.d.ts +166 -0
  207. package/dist/src/serve/archive.js +363 -0
  208. package/dist/src/serve/archive.js.map +1 -0
  209. package/dist/src/serve/arguments.d.ts +93 -0
  210. package/dist/src/serve/arguments.js +228 -0
  211. package/dist/src/serve/arguments.js.map +1 -0
  212. package/dist/src/serve/credentials.d.ts +91 -0
  213. package/dist/src/serve/credentials.js +143 -0
  214. package/dist/src/serve/credentials.js.map +1 -0
  215. package/dist/src/serve/follow.d.ts +86 -0
  216. package/dist/src/serve/follow.js +101 -0
  217. package/dist/src/serve/follow.js.map +1 -0
  218. package/dist/src/serve/hook-thread.d.ts +134 -0
  219. package/dist/src/serve/hook-thread.js +333 -0
  220. package/dist/src/serve/hook-thread.js.map +1 -0
  221. package/dist/src/serve/hook-worker.d.ts +134 -0
  222. package/dist/src/serve/hook-worker.js +173 -0
  223. package/dist/src/serve/hook-worker.js.map +1 -0
  224. package/dist/src/serve/server.d.ts +412 -0
  225. package/dist/src/serve/server.js +1251 -0
  226. package/dist/src/serve/server.js.map +1 -0
  227. package/docs/README-extended.md +768 -0
  228. package/docs/cli-reference.md +2139 -67
  229. package/package.json +4 -2
  230. package/schema/event.schema.json +413 -30
  231. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  232. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  233. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  234. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  235. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  236. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  237. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  238. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  239. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  240. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  241. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  242. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  243. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  244. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  245. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  246. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  247. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  248. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  249. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  250. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  251. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  252. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  253. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  254. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  255. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  256. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  257. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  258. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  259. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  260. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  261. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  262. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  263. package/schema/fixtures/event/valid/daemon-identity.json +16 -0
  264. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  265. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  266. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  267. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  268. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  269. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  270. package/schema/policy.schema.json +102 -6
@@ -0,0 +1,1251 @@
1
+ /**
2
+ * `approval serve` — the agent-facing surface over HTTP, for a harness that
3
+ * has no local log and no local policy (APRV-421).
4
+ *
5
+ * A tenant whose harness runs in a sandbox (Agent Village's Hermes tenants,
6
+ * the Meta Muse connector packet) has nowhere for `approval hook <harness>` to
7
+ * write and nothing for it to read. The hosted daemon runs elsewhere, one
8
+ * process per tenant (design/hosted-daemon-identity.md); what was missing was a
9
+ * way to reach it. This is that door, and it is a DOOR rather than a second
10
+ * gate.
11
+ *
12
+ * ## Transport only, and the file says so by importing rather than repeating
13
+ *
14
+ * Every verdict, every verb and every batch of records here comes from the
15
+ * function the CLI dispatches to:
16
+ *
17
+ * - the verb surface is `mcp/server.ts`'s — {@link publishedVerbs},
18
+ * {@link toolDefinitions}, {@link buildArgv} and {@link invokeVerb}, so the
19
+ * catalog MATCHES `approval mcp serve` because it IS `approval mcp serve`'s,
20
+ * `--as` is absent from every published schema for the same one reason, and
21
+ * the launch identity is appended last by the same one line;
22
+ * - a hook call is `commandHook` with the request body as its stdin, so the
23
+ * bytes on the response are the bytes the stdin form prints, for every
24
+ * adapter in `HARNESS_ADAPTERS`, with no dialect knowledge in this file;
25
+ * - a follow page is `core/log-subscribe.ts`'s verified subscription, drained
26
+ * once (`serve/follow.ts`).
27
+ *
28
+ * Nothing here classifies a command, resolves a policy, mints a token or
29
+ * verifies a chain. Two implementations of the gate sequence would be two
30
+ * gates, and the second would be the one nobody reviewed.
31
+ *
32
+ * ## Two credentials, two parties
33
+ *
34
+ * `serve/credentials.ts` holds the reasoning. The short form: the agent
35
+ * credential opens the verbs and the hook, the tenant credential opens the
36
+ * log, the export and status, and the agent credential never reads the log it
37
+ * is judged by.
38
+ *
39
+ * ## Which daemon wrote it (APRV-448)
40
+ *
41
+ * Every record this process appends carries the `daemon` field, with the id
42
+ * `approval status` reports for the same store: `APPROVAL_DAEMON_ID` from the
43
+ * launch environment, or the id derived from the store's instance, resolved
44
+ * ONCE at startup by `core/daemon-host.ts`'s `resolveDaemonId`, the function
45
+ * the daemon loop resolves its own id with. The listener's thread declares it
46
+ * before it binds, and every hook thread declares the same resolution before
47
+ * its first job (`serve/hook-worker.ts`), so a verb's records and a hook's
48
+ * carry one id. No request names any part of it.
49
+ *
50
+ * The attested policy's `daemons` allowlist is enforced at the write boundary
51
+ * exactly as it is for the daemon: refreshed from the verified log inside the
52
+ * store lock before every verb call and every hook call, and an id the list
53
+ * does not admit is refused `daemon-not-allowed` with nothing written. This
54
+ * process does NOT mark itself the daemon (`core/daemon-actor.ts`): that mark
55
+ * routes an advance under the daemon's own autonomous class, and a process that
56
+ * dispatches the verbs an agent asks for must never hold it.
57
+ *
58
+ * ## What it does not do
59
+ *
60
+ * - **No TLS.** There is no certificate handling in this process and there is
61
+ * not going to be: the supported deployment is a loopback bind behind a
62
+ * proxy the operator owns, and a non-loopback bind requires an explicit flag
63
+ * and prints a banner (`cli/serve.ts`).
64
+ * - **No record of its own.** This server appends nothing on its own account.
65
+ * Every event in the log under it was written by a verb a caller asked for,
66
+ * under the identity the operator fixed at launch.
67
+ * - **No state a restart loses.** The only things held between requests are
68
+ * the store lock and a few warm hook threads, which are properties of the
69
+ * process rather than of any caller. Follow cursors belong to the caller, so
70
+ * a host that sleeps and wakes serves the same next page it would have served
71
+ * before.
72
+ *
73
+ * ## What is serialised, and what is not (APRV-427)
74
+ *
75
+ * One lock per store ({@link storeLock}). It covers every stretch of work in
76
+ * this process that may append to the store or must read it as one snapshot:
77
+ * a verb call, a follow page, the export, and a hook call's MUTATION sections
78
+ * (everything up to its poll loop, and everything after it). It does not cover
79
+ * a hook call's WAIT: the call runs on a worker thread (`serve/hook-thread.ts`)
80
+ * and hands the lock back for as long as it polls, so one question waiting on
81
+ * a human no longer holds the tenant's `status`, `queue` and follow, or the
82
+ * next hook call's own question, until the human answers. The catalog takes no
83
+ * lock at all. Across processes the guarantee is unchanged and is not this
84
+ * lock's: every append goes through `core/log.ts`'s lockfile and
85
+ * compare-and-append.
86
+ * - **No `.approval/env`.** SPEC.md §11.1 invariant 7, inherited verbatim from
87
+ * the MCP server: the environment a gate operation runs under is the one the
88
+ * host launched this process with.
89
+ */
90
+ import { createServer, } from "node:http";
91
+ import { chmodSync, lstatSync, realpathSync, statSync, unlinkSync } from "node:fs";
92
+ import { connect } from "node:net";
93
+ import { basename, dirname, resolve } from "node:path";
94
+ import { StringDecoder } from "node:string_decoder";
95
+ import { effectiveHarnessCapMs, harnessBlockDirective, HARNESS_ADAPTERS } from "../cli/hook.js";
96
+ import { DEFAULT_LOG_PATH, resolvePath } from "../cli/paths.js";
97
+ import {} from "../cli/verb-registry.js";
98
+ import { isDaemonProcess } from "../core/daemon-actor.js";
99
+ import { declareResolvedDaemonIdentity, refreshDaemonAllowlistFrom, resolveDaemonId, } from "../core/daemon-host.js";
100
+ import { clearDaemonIdentity, daemonIdentity } from "../core/daemon-identity.js";
101
+ import { isHarnessKind } from "../core/harness-version.js";
102
+ import { withAppendLock } from "../core/log.js";
103
+ import { parseDuration } from "../core/policy-load.js";
104
+ import { buildArgv, invokeVerb, publishedVerbs, serializer, toolDefinitions, toolName, } from "../mcp/server.js";
105
+ import { buildStoreArchive, ExportHardLinkError, ExportSymlinkError } from "./archive.js";
106
+ import { checkVerbArguments } from "./arguments.js";
107
+ import { DEFAULT_HOOK_QUEUE, DEFAULT_HOOK_THREADS, HookCancelledError, HookSaturatedError, hookThreads, } from "./hook-thread.js";
108
+ /**
109
+ * Identity is resolved by the MCP server's own function, re-exported here so
110
+ * that `cli/serve.ts` reaches it through ITS server exactly as `cli/mcp.ts`
111
+ * reaches it through the MCP one.
112
+ *
113
+ * Two transports fixing an identity by two rules would be two answers to
114
+ * "which identities may this process act as", and the answer has to be one:
115
+ * `agent:<id>` and nothing else, refused at startup, never supplied by a
116
+ * caller.
117
+ */
118
+ export { resolveAgentActor } from "../mcp/server.js";
119
+ import { bearerOf } from "./credentials.js";
120
+ import { DEFAULT_FOLLOW_LIMIT, followFailureExit, MAX_FOLLOW_LIMIT, followPage, } from "./follow.js";
121
+ // ---------------------------------------------------------------------------
122
+ // Limits
123
+ // ---------------------------------------------------------------------------
124
+ /** Largest request body accepted. A tool call and a hook envelope are small. */
125
+ export const MAX_BODY_BYTES = 1024 * 1024;
126
+ /**
127
+ * How much of an oversized body this server will read and throw away before
128
+ * dropping the socket.
129
+ *
130
+ * An oversized request is refused at {@link MAX_BODY_BYTES}, but the refusal
131
+ * cannot be WRITTEN until the client stops writing, or the response closes the
132
+ * socket under it and the caller sees a transport error instead of a verdict.
133
+ * So the remainder is discarded as it arrives, up to this much, and beyond it
134
+ * the connection is simply dropped: a caller streaming without end is not a
135
+ * caller owed a polite answer.
136
+ */
137
+ export const DRAIN_LIMIT_BYTES = 16 * 1024 * 1024;
138
+ /**
139
+ * How much of a captured stream travels back in a response body.
140
+ *
141
+ * Both streams are bounded, and both carry a flag saying whether they were
142
+ * clipped. Generous enough that a verdict is never clipped in practice, and
143
+ * present so that a verb which decided to print a megabyte cannot make a
144
+ * response unsendable. Clipping happens at a CODEPOINT boundary
145
+ * ({@link clip}), so what comes back is always valid text.
146
+ */
147
+ export const MAX_STREAM_BYTES = 256 * 1024;
148
+ /**
149
+ * The exit code every server-authored refusal carries.
150
+ *
151
+ * NON-ZERO, always, and this is the finding that made the whole response
152
+ * contract change. A refusal used to travel as an HTTP status with the exit
153
+ * code in a header that refusals did not set, so a client following the
154
+ * documented rule — write stdout, exit `x-approval-exit-code` — read a missing
155
+ * header as zero, and zero is ALLOW on Claude Code, Cursor, Codex and Muse. A
156
+ * 413 on an oversized hook envelope was therefore an allow. Nothing on this
157
+ * surface may be able to turn a refusal into a permission, so the exit code is
158
+ * in the body, it is always present, and on a refusal it is always this.
159
+ */
160
+ export const REFUSAL_EXIT_CODE = 2;
161
+ // ---------------------------------------------------------------------------
162
+ // The route table
163
+ // ---------------------------------------------------------------------------
164
+ /**
165
+ * The verbs the AGENT credential may call. An ALLOWLIST, and the direction is
166
+ * the whole of it.
167
+ *
168
+ * The catalog is the registry's and does not change (`mcp/server.ts` owns it,
169
+ * and this surface publishes exactly what `approval mcp serve` publishes).
170
+ * This is authorization, which is a different question asked one layer later,
171
+ * and it is asked per verb.
172
+ *
173
+ * ## Why a list of what a harness MAY do
174
+ *
175
+ * The threat model this transport exists for is a sandboxed harness on another
176
+ * machine, not an operator at their own laptop. `approval mcp serve` is run BY
177
+ * the operator, on the operator's box, over a pipe the operator handed it; its
178
+ * whole catalog is reachable there because the party at the other end is the
179
+ * party who started it. Here the party at the other end is the party under
180
+ * oversight, and the operative rule is that it never reads the log it is
181
+ * judged by, nor spends the host's credentials, nor runs argv on the host.
182
+ *
183
+ * A denylist would have to name `log tail`, `log export`, `queue`, `coverage`,
184
+ * `doctor`, every `adapter <name>`, `run`, `sandbox`, `token`, `reindex`,
185
+ * `render` and every verb that lands next. This list names what a harness
186
+ * under oversight needs in order to ASK and to ACT ON a grant, so a verb added
187
+ * to the registry tomorrow is TENANT-scoped until somebody decides otherwise.
188
+ * Fail closed, per SPEC.md §11, which is the same reasoning `GUEST_VERBS`
189
+ * gives for being positive.
190
+ *
191
+ * What is on it, and why each:
192
+ *
193
+ * - `instructions`, `hook_classify` — read the guide, and find out what the
194
+ * classifier makes of a command. Neither reads the log or the policy file;
195
+ * both tell an agent what it is about to be judged for before it asks.
196
+ * - `request`, `wait`, `withdraw` — ask, wait for the answer, and retract your
197
+ * own question. This is the gate sequence, and it is the reason the agent
198
+ * credential exists.
199
+ * - `propose`, `start` (APRV-445) — the same sequence for an action the agent
200
+ * declares itself, with its payload as a flag value: register-and-request in
201
+ * a class the operator declared by name and opened with `agent_may_request`,
202
+ * and record the execution the grant or the policy authorized. Neither takes
203
+ * a path, and `start` is requester-only.
204
+ * That is the whole list. It is seven verbs, and the shortness is the point: a
205
+ * harness under oversight asks and is answered, and everything else about the
206
+ * gate belongs to the party the gate is for.
207
+ *
208
+ * ## What came OFF this list in review, and why
209
+ *
210
+ * - `register` takes a task-file POSITIONAL, which is a host path. The hook
211
+ * endpoint is a harness's way to register: it synthesises the envelope from
212
+ * the tool call, so nothing is lost and a host path stops being something a
213
+ * remote caller names.
214
+ * - `log_verify` and `gate_status` hand back the log's length, its head hash
215
+ * and the live state of the gate. "Not a record" is not the same as "not
216
+ * about the log": a head hash moving is a decision being recorded, and an
217
+ * open window is the human's current posture toward this very agent.
218
+ * - `policy_check` and `policy_test` answer what the policy does with a class,
219
+ * and `policy_check` names the absolute store path while doing it.
220
+ * - **Both payload builders**, under the rule that a builder is the agent's
221
+ * only if it builds from the request's own arguments and never reaches a
222
+ * host resource. `payload_hash` takes a FILE, and on this transport that
223
+ * file is on the daemon's machine while the caller is not: every path it
224
+ * could name is one the host put there, so answering would be a hash oracle
225
+ * that also files the bytes into the payload store.
226
+ * `payload_agentmail-draft` takes two ids rather than a path, but it reads
227
+ * the draft over HTTPS with `AGENTMAIL_API_KEY` FROM THIS SERVER'S OWN
228
+ * ENVIRONMENT, so a remote caller would be spending the host's credential on
229
+ * ids it chose — the same fault wearing a network instead of a filesystem.
230
+ * The narrow reading was taken on purpose: publishing a builder that should
231
+ * have been withheld is a capability leak, and withholding one that should
232
+ * have been published is a line of configuration.
233
+ *
234
+ * Everything else is the tenant's, including the ones easiest to wave through:
235
+ * `log_tail` and `log_export` return RECORDS, `queue` returns the tenant's
236
+ * pending decisions, `run` and `sandbox` spawn argv on the daemon's machine,
237
+ * every `adapter_<name>` spends vault credentials, and `status` is the
238
+ * tenant's view of the oversight the agent is under. All of them stay
239
+ * PUBLISHED, so the surface is honest about what exists, and all of them
240
+ * answer the tenant credential only.
241
+ */
242
+ export const AGENT_VERBS = new Set([
243
+ "instructions",
244
+ "hook_classify",
245
+ "request",
246
+ "wait",
247
+ "withdraw",
248
+ "propose",
249
+ "start",
250
+ ]);
251
+ /**
252
+ * The frozen refusal vocabulary this server authors itself (SPEC.md §11.1
253
+ * invariant 6).
254
+ *
255
+ * A refusal a VERB produced keeps the verb's own code, unchanged, because it
256
+ * is the CLI's refusal arriving over a different transport; so does a refusal
257
+ * `buildArgv` produced, `mcp-` prefix and all, because that function is the
258
+ * one both transports call and renaming its codes here would make one surface
259
+ * lie about the other. What is listed below is only what this file decides.
260
+ */
261
+ export const SERVE_REFUSAL_CODES = [
262
+ "serve-unauthorized",
263
+ "serve-agent-forbidden",
264
+ "serve-tenant-forbidden",
265
+ "serve-unknown-path",
266
+ "serve-malformed-url",
267
+ "serve-unknown-verb",
268
+ "serve-unknown-harness",
269
+ "serve-method-not-allowed",
270
+ "serve-body-too-large",
271
+ "serve-body-unreadable",
272
+ "serve-invalid-cursor",
273
+ "serve-path-pinned",
274
+ "serve-path-outside-store",
275
+ "serve-positional-flag",
276
+ "serve-flag-not-permitted",
277
+ "serve-export-symlink",
278
+ "serve-export-hardlink",
279
+ "serve-export-failed",
280
+ "serve-hook-failed",
281
+ "serve-hook-saturated",
282
+ ];
283
+ // ---------------------------------------------------------------------------
284
+ // Small HTTP helpers
285
+ // ---------------------------------------------------------------------------
286
+ /**
287
+ * A refusal, always as a RESULT BODY, and always carrying an exit code.
288
+ *
289
+ * Never a bare HTTP error: the status is a hint for the plumbing between here
290
+ * and the caller, and the `{"error":{"code","message"}}` object is the answer.
291
+ * A client branches on `error.code` exactly as it would on the CLI's stderr,
292
+ * and every code it can see is either this file's (above), a verb's own, or
293
+ * the subscription's.
294
+ *
295
+ * `exit_code` is {@link REFUSAL_EXIT_CODE} on every one of them, and that is
296
+ * the review finding this shape exists for. A client's rule is "write
297
+ * `stdout`, exit `exit_code`"; if a refusal carried no exit code, the client's
298
+ * default would be zero, and zero is ALLOW on four of the six harness
299
+ * dialects. A refusal must never be readable as a permission, so it carries a
300
+ * blocking code as data rather than leaving one to be inferred.
301
+ */
302
+ function refuse(res, status, code, message, extra = {}) {
303
+ send(res, status, JSON.stringify({ error: { code, message }, exit_code: REFUSAL_EXIT_CODE, ...extra }), { "content-type": "application/json" });
304
+ }
305
+ /**
306
+ * A refusal on the HOOK route, spoken in the harness's own dialect.
307
+ *
308
+ * Same body as {@link refuse}, plus `stdout` carrying the block directive
309
+ * `approval hook <harness>` itself prints. A client that writes `stdout` and
310
+ * exits `exit_code` therefore blocks on BOTH halves of every dialect: the body
311
+ * blocks Claude Code, Cursor, Codex and Muse, and the exit code blocks Grok
312
+ * Build and Hermes. A client that instead branches on `error.code` sees the
313
+ * refusal for what it is.
314
+ *
315
+ * Where the harness is not known — an unroutable path, an unrecognised name, a
316
+ * credential that failed before the URL was parsed — there is no dialect to
317
+ * speak and `stdout` is empty. That case is why `docs/cli-reference.md` states
318
+ * the client rule as it does: a missing or unparseable body is a BLOCK.
319
+ */
320
+ function refuseHook(res, status, harness, code, message) {
321
+ const directive = harnessBlockDirective(code, message, harness);
322
+ refuse(res, status, code, message, {
323
+ stdout: directive.stdout,
324
+ stderr: "",
325
+ stdout_truncated: false,
326
+ stderr_truncated: false,
327
+ });
328
+ }
329
+ function send(res, status, body, headers = {}) {
330
+ const bytes = typeof body === "string" ? Buffer.from(body, "utf8") : body;
331
+ res.writeHead(status, { ...headers, "content-length": bytes.length });
332
+ res.end(bytes);
333
+ }
334
+ /** Read one request body, bounded by {@link MAX_BODY_BYTES}. */
335
+ async function readBody(req) {
336
+ const chunks = [];
337
+ let size = 0;
338
+ let oversized = false;
339
+ try {
340
+ for await (const chunk of req) {
341
+ const buffer = chunk;
342
+ size += buffer.length;
343
+ if (size > MAX_BODY_BYTES) {
344
+ // DO NOT return here. The client is still uploading, and a response
345
+ // written while it writes closes the socket under it: the caller then
346
+ // sees ECONNRESET instead of the 413, and a hook client that got a
347
+ // transport error rather than a refusal has no verdict to read. So the
348
+ // rest is DISCARDED as it arrives — never buffered, so the memory is
349
+ // bounded by one chunk — and the refusal is written once the client
350
+ // has finished speaking.
351
+ //
352
+ // Bounded twice over: the drain stops at DRAIN_LIMIT_BYTES, after
353
+ // which the socket is dropped, so a caller that streams forever is not
354
+ // a caller this loop follows forever.
355
+ oversized = true;
356
+ chunks.length = 0;
357
+ if (size > DRAIN_LIMIT_BYTES) {
358
+ req.destroy();
359
+ break;
360
+ }
361
+ continue;
362
+ }
363
+ chunks.push(buffer);
364
+ }
365
+ if (oversized) {
366
+ return {
367
+ ok: false,
368
+ status: 413,
369
+ code: "serve-body-too-large",
370
+ message: `request body exceeds ${String(MAX_BODY_BYTES)} bytes`,
371
+ };
372
+ }
373
+ }
374
+ catch (cause) {
375
+ if (oversized) {
376
+ return {
377
+ ok: false,
378
+ status: 413,
379
+ code: "serve-body-too-large",
380
+ message: `request body exceeds ${String(MAX_BODY_BYTES)} bytes`,
381
+ };
382
+ }
383
+ return {
384
+ ok: false,
385
+ status: 400,
386
+ code: "serve-body-unreadable",
387
+ message: cause instanceof Error ? cause.message : String(cause),
388
+ };
389
+ }
390
+ return { ok: true, text: Buffer.concat(chunks).toString("utf8") };
391
+ }
392
+ /**
393
+ * One captured stream, bounded and clipped at a CODEPOINT boundary.
394
+ *
395
+ * A naive byte slice can cut a multi-byte sequence in half and produce a
396
+ * string that is not valid text, which for a JSON body means either a
397
+ * replacement character where a verdict had a word or, worse, a body a strict
398
+ * client refuses to parse. `StringDecoder` emits only the complete codepoints
399
+ * in what it was given and keeps the incomplete tail to itself, which is
400
+ * exactly the clip wanted here.
401
+ */
402
+ export function clip(text) {
403
+ const bytes = Buffer.from(text, "utf8");
404
+ if (bytes.length <= MAX_STREAM_BYTES)
405
+ return { text, truncated: false };
406
+ return {
407
+ text: new StringDecoder("utf8").write(bytes.subarray(0, MAX_STREAM_BYTES)),
408
+ truncated: true,
409
+ };
410
+ }
411
+ /**
412
+ * The ONE body shape a verb call and a hook call answer with.
413
+ *
414
+ * The exit code and both streams, exactly as the CLI produced them. A caller
415
+ * reproducing the invocation locally writes `stdout`, writes `stderr`, and
416
+ * exits `exit_code`; a caller that wants the verb's machine-readable refusal
417
+ * parses `stderr`, which is the stream the CLI prints refusals on.
418
+ *
419
+ * The alternative — unwrapping the verb's own JSON object into the response —
420
+ * was what this surface did before review, and it had two faults. It put the
421
+ * exit code in a header that refusals forgot to set, and it made a verb that
422
+ * printed something unexpected into a transport-level failure with its own
423
+ * invented code. Returning the streams says less and cannot be wrong about it.
424
+ */
425
+ function streamsBody(result) {
426
+ const out = clip(result.stdout);
427
+ const err = clip(result.stderr);
428
+ return JSON.stringify({
429
+ exit_code: result.code,
430
+ stdout: out.text,
431
+ stderr: err.text,
432
+ stdout_truncated: out.truncated,
433
+ stderr_truncated: err.truncated,
434
+ });
435
+ }
436
+ /**
437
+ * The published surface: `approval mcp serve`'s tool list, with each entry's
438
+ * scope stated.
439
+ *
440
+ * Derived, not restated. A verb added to the registry tomorrow appears here
441
+ * the same day it appears as an MCP tool, with `--as` already deleted from its
442
+ * schema by the same function, and `grant` is absent for the same reason it is
443
+ * absent there — the registry marks it `human_only` and no transport publishes
444
+ * a human's authority. Its SCOPE is tenant until somebody puts it on
445
+ * {@link AGENT_VERBS}, which is the fail-closed half of the same derivation.
446
+ */
447
+ export function serveCatalog() {
448
+ return toolDefinitions().map((tool) => ({
449
+ name: tool.name,
450
+ title: tool.title ?? tool.name,
451
+ description: tool.description ?? "",
452
+ inputSchema: tool.inputSchema,
453
+ scope: AGENT_VERBS.has(tool.name) ? "agent" : "tenant",
454
+ }));
455
+ }
456
+ /** Every name this surface publishes, for the scope rule below. */
457
+ const PUBLISHED_TOOL_NAMES = new Set(publishedVerbs().map((spec) => toolName(spec)));
458
+ /**
459
+ * The flags every hook call is made with.
460
+ *
461
+ * Exported so a test can hand the SAME argv to `commandHook` directly and
462
+ * compare the bytes. That comparison is the acceptance criterion, and it is
463
+ * only honest if both sides are the same invocation of the same verb: a test
464
+ * that built its own flag list would be comparing two spellings.
465
+ *
466
+ * `--as` is appended LAST, as everywhere else on this surface, so the identity
467
+ * the operator fixed when they started the process wins over anything that
468
+ * arrived by another route (`parseFlags` keeps the last occurrence).
469
+ */
470
+ export function hookArgv(options) {
471
+ return [
472
+ "--dir",
473
+ options.cwd,
474
+ ...(options.log === undefined ? [] : ["--log", options.log]),
475
+ ...(options.policy === undefined ? [] : ["--policy", options.policy]),
476
+ ...(options.hookTimeout === undefined ? [] : ["--timeout", options.hookTimeout]),
477
+ ...(options.hookHarnessCap === undefined
478
+ ? []
479
+ : ["--harness-cap", options.hookHarnessCap]),
480
+ "--as",
481
+ options.actor,
482
+ ];
483
+ }
484
+ /** The route a path names, or null. Method is checked by the handler. */
485
+ export function routeOf(pathname) {
486
+ if (pathname === "/verbs")
487
+ return { kind: "catalog" };
488
+ if (pathname === "/log/follow")
489
+ return { kind: "follow" };
490
+ if (pathname === "/export")
491
+ return { kind: "export" };
492
+ if (pathname === "/status")
493
+ return { kind: "status" };
494
+ const verb = /^\/verb\/(?<name>[a-z0-9_-]+)$/u.exec(pathname);
495
+ if (verb?.groups !== undefined)
496
+ return { kind: "verb", name: verb.groups["name"] };
497
+ const hook = /^\/hook\/(?<harness>[a-z0-9-]+)$/u.exec(pathname);
498
+ if (hook?.groups !== undefined) {
499
+ return { kind: "hook", harness: hook.groups["harness"] };
500
+ }
501
+ return null;
502
+ }
503
+ /**
504
+ * Which credential opens a route.
505
+ *
506
+ * `null` means either one does, which is true of the catalog alone: it is the
507
+ * published contract, it reads no log and it appends nothing, and both parties
508
+ * have a reason to ask what this server is.
509
+ */
510
+ export function scopeOf(route) {
511
+ switch (route.kind) {
512
+ case "catalog":
513
+ return null;
514
+ case "follow":
515
+ case "export":
516
+ case "status":
517
+ return "tenant";
518
+ case "hook":
519
+ return "agent";
520
+ default:
521
+ // Three answers, and the third is the one worth reading twice.
522
+ //
523
+ // On the allowlist: the agent's. Published and not on it: the tenant's,
524
+ // which is where a verb added to the registry tomorrow lands.
525
+ //
526
+ // NOT PUBLISHED AT ALL: the agent's, so the TENANT credential is refused
527
+ // rather than falling through to a not-found. The names in that third
528
+ // case are the verbs `mcp/server.ts` withholds for TRANSPORT reasons —
529
+ // `consume` above all, which is the token spend, an agent-side act that
530
+ // `run` wraps. The tenant credential must not reach it under any
531
+ // spelling, and a caller that guessed a path is owed the true fact about
532
+ // itself rather than a probe that answers differently. The agent
533
+ // credential reaches the same arm and gets `serve-unknown-verb`, which
534
+ // is also true: the verb is not on this surface.
535
+ if (AGENT_VERBS.has(route.name))
536
+ return "agent";
537
+ return PUBLISHED_TOOL_NAMES.has(route.name) ? "tenant" : "agent";
538
+ }
539
+ }
540
+ // ---------------------------------------------------------------------------
541
+ // The listener
542
+ // ---------------------------------------------------------------------------
543
+ /**
544
+ * Start the listener. Resolves once it is bound and its real port is known;
545
+ * rejects when the bind fails, so the CLI can report it and exit.
546
+ */
547
+ export async function serveApproval(options) {
548
+ const host = options.host ?? "127.0.0.1";
549
+ const notice = options.notice ?? (() => { });
550
+ // ALWAYS pinned, on every verb call and in every scope, whether or not the
551
+ // operator named them (review finding 3). One process serves one store, and
552
+ // before this these three were injected only when the operator had passed
553
+ // them and `--dir` never was — so a published `--log` from a caller was the
554
+ // store the verb actually read. `buildArgv` appends these LAST, so a
555
+ // caller's value loses even if the guard above somehow let one through.
556
+ const paths = {
557
+ actor: options.actor,
558
+ cwd: options.cwd,
559
+ dir: options.cwd,
560
+ log: options.log ?? logPathOf(options.cwd),
561
+ // `--policy` names a FILE, so it is pinned only where the operator named
562
+ // one. Where they did not, `--dir` above is the pin: the CLI resolves the
563
+ // policy from that directory, which is exactly "the launch configuration's
564
+ // policy". Pinning a directory here would hand every verb a path it would
565
+ // try to read as a policy document.
566
+ ...(options.policy === undefined ? {} : { policy: options.policy }),
567
+ };
568
+ // The store's lock (APRV-427): every verb call, follow page and export takes
569
+ // it whole, and a hook call takes it for its mutation sections only. See the
570
+ // header's "What is serialised" for the scope, and `storeLock` for why it is
571
+ // keyed by store.
572
+ const storeLog = paths.log ?? logPathOf(options.cwd);
573
+ const serialize = storeLock(storeLog, options.cwd);
574
+ // APRV-448: which daemon this process writes as, resolved once by the
575
+ // function the daemon loop uses and declared before anything can append. The
576
+ // CLI refused an unusable `APPROVAL_DAEMON_ID` before it got here; an embedder
577
+ // that did not is refused here, before the bind.
578
+ const daemon = resolveDaemonId(storeLog, options.env ?? process.env);
579
+ if (!daemon.ok)
580
+ throw new Error(daemon.message);
581
+ const daemonId = daemon.id;
582
+ // The declaration is the PROCESS's, so one process writes as one daemon
583
+ // (APRV-448 review). A second listener for a store whose id differs from the
584
+ // one already declared here (by another listener, or by a daemon loop in the
585
+ // same process) would restamp the first store's records with the second's
586
+ // id; it is refused before it binds instead.
587
+ const prior = daemonIdentity();
588
+ if (prior !== null && prior.id !== null && prior.id !== daemonId) {
589
+ throw new Error(`this process already writes as daemon ${prior.id}, and this store's id is ${daemonId}: one process stamps one daemon id, so serve a second store from its own process`);
590
+ }
591
+ declareResolvedDaemonIdentity(daemon);
592
+ // The store's policy, located exactly as the verbs locate it: the pinned file
593
+ // when the operator named one, otherwise discovered from the store root.
594
+ const policyWhere = options.policy === undefined ? { dir: options.cwd } : { file: options.policy };
595
+ /** Put the attested `daemons` list in force; called inside the store lock. */
596
+ const refreshAllowlist = () => {
597
+ refreshDaemonAllowlistFrom(storeLog, policyWhere);
598
+ };
599
+ refreshAllowlist();
600
+ // Hook calls run on worker threads, because a hook's wait is a synchronous
601
+ // `Atomics.wait` and on this thread it would stop the listener itself. Each
602
+ // thread declares the resolution above as its own (`serve/hook-worker.ts`).
603
+ const hooks = hookThreads(serialize, { daemon, policy: policyWhere }, {
604
+ threads: options.hookThreads ?? DEFAULT_HOOK_THREADS,
605
+ queue: options.hookQueue ?? DEFAULT_HOOK_QUEUE,
606
+ });
607
+ const byName = new Map(publishedVerbs().map((spec) => [toolName(spec), spec]));
608
+ const sockets = new Set();
609
+ let requests = 0;
610
+ let closing = false;
611
+ async function handleVerb(res, spec, args, scope) {
612
+ // Every scope, before the argv is built: no caller names a store, no
613
+ // caller names a path outside the one this process serves, and no
614
+ // positional arrives wearing a dash.
615
+ const checked = checkVerbArguments(spec, args, options.cwd, options.cwd, scope);
616
+ if (!checked.ok) {
617
+ refuse(res, 403, checked.code, checked.message);
618
+ return;
619
+ }
620
+ const built = buildArgv(spec, args, paths);
621
+ if (!built.ok) {
622
+ refuse(res, 400, built.code, built.message);
623
+ return;
624
+ }
625
+ const result = await serialize(() => {
626
+ // The allowlist this call's appends are judged against, resolved inside
627
+ // the lock from the verified log and the attested policy (APRV-448).
628
+ refreshAllowlist();
629
+ return invokeVerb(spec, built.argv, paths);
630
+ });
631
+ // The CLI's own streams and the CLI's own exit code. A refusal arrives as
632
+ // the `{"error":{...}}` the verb printed, on the stream it printed it on.
633
+ send(res, 200, streamsBody(result), { "content-type": "application/json" });
634
+ }
635
+ /** `--hook-harness-cap`, parsed once; the CLI has already refused a bad one. */
636
+ const statedHookCapMs = options.hookHarnessCap === undefined ? null : parseDuration(options.hookHarnessCap);
637
+ async function handleHook(res, harness, body, arrivedAt) {
638
+ // The same function `main()` dispatches to (`commandHook`, on a worker
639
+ // thread), with the request body as the stdin the verb would have read.
640
+ // The verdict object, its dialect, its reason and its exit code are decided
641
+ // entirely inside it; the thread changes where it runs and which stretches
642
+ // of it hold the store lock, and nothing about what it answers.
643
+ //
644
+ // A client that goes away before its answer (APRV-427 review) aborts the
645
+ // call: in line it leaves the line, and on its thread it stops at the next
646
+ // poll tick WITHOUT spending a grant or withdrawing the question, so the
647
+ // harness's retry adopts one or the other. Nothing is sent after that,
648
+ // and nothing is sent once the listener is closing either: the socket is
649
+ // about to be destroyed, and a caller with no answer blocks.
650
+ const gone = new AbortController();
651
+ res.once("close", () => {
652
+ if (!res.writableFinished)
653
+ gone.abort();
654
+ });
655
+ // The listener's own list, brought up to date before the call is handed to
656
+ // a thread (APRV-448 review): the thread starts no wider than it.
657
+ refreshAllowlist();
658
+ let outcome;
659
+ try {
660
+ outcome = await hooks.run([harness, ...hookArgv(options)], options.cwd, paths.log ?? logPathOf(options.cwd), body, gone.signal,
661
+ // The harness's ceiling runs from ARRIVAL, so the time this call
662
+ // spends in line and waiting for the lock is charged to it (APRV-427
663
+ // review), with the same ceiling the hook itself will compute.
664
+ { arrivedAt, capMs: effectiveHarnessCapMs(HARNESS_ADAPTERS[harness], statedHookCapMs) });
665
+ }
666
+ catch (cause) {
667
+ if (cause instanceof HookCancelledError || gone.signal.aborted || closing)
668
+ return;
669
+ if (cause instanceof HookSaturatedError) {
670
+ // Every slot and every place in line is taken. Refused at once rather
671
+ // than queued without bound, because each running call is a thread
672
+ // holding its own copy of the log; answered as a block, so the harness
673
+ // does not proceed on a question nobody asked.
674
+ refuseHook(res, 503, harness, "serve-hook-saturated", cause.message);
675
+ return;
676
+ }
677
+ // The THREAD failed (it was terminated, or it died), which is not a
678
+ // verdict. Answered as a refusal in the harness's own dialect, so a
679
+ // client that writes `stdout` and exits `exit_code` blocks.
680
+ refuseHook(res, 500, harness, "serve-hook-failed", `the hook call did not complete: ${cause instanceof Error ? cause.message : String(cause)}`);
681
+ return;
682
+ }
683
+ // `stdout` is BYTE-FOR-BYTE what `approval hook <harness>` printed, and
684
+ // `exit_code` is the code it exited. Both travel in the body, because a
685
+ // header is a thing a refusal can forget to set and an exit code that can
686
+ // go missing is an allow waiting to happen. `stderr` rides along because
687
+ // Hermes's ALLOW is `{}` and carries its reason there and nowhere else.
688
+ if (gone.signal.aborted || closing)
689
+ return;
690
+ send(res, 200, streamsBody(outcome), { "content-type": "application/json" });
691
+ }
692
+ async function handleFollow(res, url) {
693
+ const fromText = url.searchParams.get("from");
694
+ const hashText = url.searchParams.get("cursor_hash");
695
+ const limitText = url.searchParams.get("limit");
696
+ // Every query parameter is validated BEFORE anything reads a file, and a
697
+ // value out of range is refused rather than clamped. A clamp is a silent
698
+ // answer to a different question than the one asked, and the caller cannot
699
+ // tell it happened.
700
+ if (fromText !== null && !/^\d+$/u.test(fromText)) {
701
+ refuse(res, 400, "serve-invalid-cursor", `from expects a whole number, got ${JSON.stringify(fromText)}`);
702
+ return;
703
+ }
704
+ const from = fromText === null ? 0 : Number(fromText);
705
+ // `99999999999999999999` matches the digits above and is not a safe
706
+ // integer: it reached the subscription, which threw a TypeError, which
707
+ // escaped a GET as a 500. Refused here, in this verb's own vocabulary.
708
+ if (!Number.isSafeInteger(from)) {
709
+ refuse(res, 400, "serve-invalid-cursor", `from ${JSON.stringify(fromText)} is larger than the largest sequence number this runtime can represent`);
710
+ return;
711
+ }
712
+ if (limitText !== null && !/^\d+$/u.test(limitText)) {
713
+ refuse(res, 400, "serve-invalid-cursor", `limit expects a whole number, got ${JSON.stringify(limitText)}`);
714
+ return;
715
+ }
716
+ const limit = limitText === null ? DEFAULT_FOLLOW_LIMIT : Number(limitText);
717
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_FOLLOW_LIMIT) {
718
+ refuse(res, 400, "serve-invalid-cursor", `limit expects 1 to ${String(MAX_FOLLOW_LIMIT)}, got ${JSON.stringify(limitText)}`);
719
+ return;
720
+ }
721
+ if (hashText !== null && !/^[a-f0-9]{64}$/u.test(hashText)) {
722
+ refuse(res, 400, "serve-invalid-cursor", "cursor_hash expects a lowercase 64-character SHA-256 digest");
723
+ return;
724
+ }
725
+ if (hashText !== null && from === 0) {
726
+ refuse(res, 400, "serve-invalid-cursor", "cursor_hash requires from greater than zero");
727
+ return;
728
+ }
729
+ // A RESUME MUST CARRY ITS HASH. `core/log-subscribe.ts` calls the
730
+ // sequence-only form "a weaker bootstrap ... by design", and it is weaker
731
+ // in exactly one way: it cannot detect a fully recomputed replacement
732
+ // prefix on its FIRST read. In the streaming form that costs one read,
733
+ // because the process then retains the digest it verified and catches any
734
+ // later replacement. Here every request is a first read, so the weakness
735
+ // would be permanent: a replaced prefix would be served silently, forever,
736
+ // to the caller who omitted the hash, while an honest caller who kept it
737
+ // got the refusal. `from=0` is the only hashless form, because replaying
738
+ // from genesis binds nothing and claims nothing.
739
+ if (from > 0 && hashText === null) {
740
+ refuse(res, 400, "serve-invalid-cursor", `from=${String(from)} requires cursor_hash: a resume names the prefix it consumed, and a sequence number alone cannot tell this log from one whose records were recomputed. Send the hash from the cursor of your last page, or from=0 to replay the whole verified log`);
741
+ return;
742
+ }
743
+ const cursor = { seq: from, hash: hashText };
744
+ // Under the store lock, so no append THIS process makes lands underneath a
745
+ // page: a verified read walks the whole chain, and an append landing under
746
+ // one is a torn read this endpoint would report as an integrity failure of
747
+ // the tenant's own log. A hook call in its wait holds no lock and appends
748
+ // nothing, so it does not delay a page (APRV-427).
749
+ const result = await serialize(() => followPage(options.log ?? logPathOf(options.cwd), cursor, limit));
750
+ if (!result.ok) {
751
+ // The subscription's own terminal failure, with the CLI's own code, the
752
+ // CLI's own message, and NO RECORDS. `reason` carries `cursor-mismatch`
753
+ // where there is one, so the caller need not read English to tell a torn
754
+ // prefix from a cursor that belongs to another log.
755
+ send(res, result.code === "integrity" ? 409 : 500, JSON.stringify({
756
+ error: { code: result.code, message: result.message, reason: result.reason },
757
+ exit_code: followFailureExit(result.code),
758
+ records: [],
759
+ }), { "content-type": "application/json" });
760
+ return;
761
+ }
762
+ send(res, 200, JSON.stringify({ ...result.page, exit_code: 0 }), {
763
+ "content-type": "application/json",
764
+ });
765
+ }
766
+ async function handleExport(res) {
767
+ // Under the store lock AND the append lock.
768
+ //
769
+ // The store lock is about this process: no verb and no hook's mutation
770
+ // section appends while the snapshot is taken, and `gzipSync` blocks the
771
+ // event loop for as long as the store is large. The APPEND lock is about
772
+ // every other process — a daemon, a CLI
773
+ // run beside this server — because the walk reads the log and the payload
774
+ // store as one snapshot, and an append landing between the two copies a
775
+ // record whose payload bytes are not there yet. `withAppendLock` is the
776
+ // same exclusion `approval log sync` takes for the same reason, and it
777
+ // hands the callback no write primitive: this endpoint gains only the
778
+ // guarantee that nobody is appending while it reads.
779
+ let archive;
780
+ try {
781
+ archive = await serialize(async () => {
782
+ const logPath = options.log ?? logPathOf(options.cwd);
783
+ const locked = withAppendLock(logPath, () => buildStoreArchive(options.cwd, logPath));
784
+ if (!locked.ok)
785
+ throw new Error(locked.error.message);
786
+ return locked.value;
787
+ });
788
+ }
789
+ catch (cause) {
790
+ if (cause instanceof ExportHardLinkError) {
791
+ refuse(res, 409, "serve-export-hardlink", cause.message, {
792
+ path: cause.path,
793
+ links: cause.links,
794
+ });
795
+ return;
796
+ }
797
+ if (cause instanceof ExportSymlinkError) {
798
+ // Named, and the whole export refused rather than the link skipped: an
799
+ // archive silently missing a file is an archive nobody can tell from a
800
+ // complete one, and a tenant checking their exit against their own
801
+ // `payload_hash` values would find the gap only by doing the check.
802
+ refuse(res, 409, "serve-export-symlink", cause.message, { path: cause.path });
803
+ return;
804
+ }
805
+ refuse(res, 500, "serve-export-failed", `the store at ${options.cwd} could not be archived: ${cause instanceof Error ? cause.message : String(cause)}`);
806
+ return;
807
+ }
808
+ send(res, 200, archive.bytes, {
809
+ "content-type": "application/gzip",
810
+ "content-disposition": `attachment; filename="approval-export-${daemonId}.tar.gz"`,
811
+ "x-approval-export-paths": JSON.stringify(archive.paths),
812
+ });
813
+ }
814
+ const http = createServer((req, res) => {
815
+ // When the call arrived: a hook call's harness budget is charged from here.
816
+ const arrivedAt = Date.now();
817
+ void (async () => {
818
+ requests += 1;
819
+ try {
820
+ // AUTHENTICATED FIRST, before the URL is even parsed. Two reasons, and
821
+ // the second was a review finding: there is no unauthenticated surface
822
+ // here, not a health check and not a 404, so a caller with no
823
+ // credential learns nothing about what exists; and `new URL()` throws
824
+ // on a malformed `Host` header, which used to happen BEFORE this check
825
+ // and handed an unauthenticated caller a 500.
826
+ //
827
+ // Duplicate `Authorization` headers are refused outright rather than
828
+ // resolved. Node keeps the FIRST and discards the rest for this header,
829
+ // so a proxy, a client library or an attacker sending two can make the
830
+ // credential this server checks differ from the one a reader of the
831
+ // request would say was sent. There is no correct way to choose; the
832
+ // request is malformed and is treated as such.
833
+ // WHICH DIALECT this request would be answered in, read off the raw
834
+ // request target before anything else (second review pass, finding 3).
835
+ //
836
+ // It decides the SHAPE OF A REFUSAL and nothing else: no route is
837
+ // taken from it, no scope, no verb. It has to happen here because the
838
+ // two refusals below — a rotated credential, a malformed Host — fire
839
+ // before the URL is parsed, and a hook client that gets a body with no
840
+ // block directive reads exit 0 as ALLOW on four of the six dialects. A
841
+ // credential rotation should not open a gate.
842
+ //
843
+ // It discloses nothing: the path set is documented, the refusal's code
844
+ // and message are unchanged, and a name that is not a harness this
845
+ // runtime speaks for gets the generic body.
846
+ const early = /^\/hook\/(?<harness>[a-z0-9-]+)(?:[/?#]|$)/u.exec(req.url ?? "");
847
+ const earlyHarness = early?.groups !== undefined && isHarnessKind(early.groups["harness"])
848
+ ? early.groups["harness"]
849
+ : null;
850
+ const refuseEarly = (status, code, message) => {
851
+ if (earlyHarness === null)
852
+ refuse(res, status, code, message);
853
+ else
854
+ refuseHook(res, status, earlyHarness, code, message);
855
+ };
856
+ const offered = req.rawHeaders.filter((entry, index) => index % 2 === 0 && entry.toLowerCase() === "authorization").length;
857
+ const bearer = offered > 1 ? null : bearerOf(req.headers.authorization);
858
+ const scope = bearer === null ? null : options.credentials.identify(bearer);
859
+ if (scope === null) {
860
+ refuseEarly(401, "serve-unauthorized", offered > 1
861
+ ? `this request carries ${String(offered)} Authorization headers. Exactly one is accepted: where there are several, the one this server would check is not the one a reader of the request would name`
862
+ : "no usable Authorization: Bearer <credential>. This server has two credentials, one for the agent surface and one for the tenant surface, and every path requires one of them");
863
+ return;
864
+ }
865
+ let url;
866
+ try {
867
+ url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
868
+ }
869
+ catch {
870
+ // After authentication, so this says nothing to a stranger. The
871
+ // `Host` header is the usual culprit and is named without being
872
+ // echoed back.
873
+ refuseEarly(400, "serve-malformed-url", "the request line and Host header do not form a URL this server can parse");
874
+ return;
875
+ }
876
+ const route = routeOf(url.pathname);
877
+ if (route === null) {
878
+ refuse(res, 404, "serve-unknown-path", `no endpoint at ${url.pathname}; this server answers /verbs, /verb/<name>, /hook/<harness>, /log/follow, /export and /status`);
879
+ return;
880
+ }
881
+ // On the hook route every refusal from here on is spoken in the
882
+ // harness's own dialect as well as in this server's, so a client that
883
+ // writes `stdout` and exits `exit_code` blocks rather than proceeds.
884
+ const harness = route.kind === "hook" && isHarnessKind(route.harness) ? route.harness : null;
885
+ const deny = (status, code, message) => {
886
+ if (harness === null)
887
+ refuse(res, status, code, message);
888
+ else
889
+ refuseHook(res, status, harness, code, message);
890
+ };
891
+ const required = scopeOf(route);
892
+ if (required !== null && required !== scope) {
893
+ const code = scope === "agent" ? "serve-agent-forbidden" : "serve-tenant-forbidden";
894
+ deny(403, code, scope === "agent"
895
+ ? `${url.pathname} answers to the TENANT credential. The agent credential reaches the hook and the verbs a harness under oversight needs in order to ask and to act on a grant; it never reaches the log it is judged by, the store export, the tenant's own status report, or anything that runs on the host`
896
+ : `${url.pathname} answers to the AGENT credential. The tenant credential reaches the log, the export, status and every verb that touches the host; it does not act as the agent, because an action taken with it would be recorded under an identity nobody was acting as`);
897
+ return;
898
+ }
899
+ const method = req.method ?? "GET";
900
+ const wants = route.kind === "verb" || route.kind === "hook" ? "POST" : "GET";
901
+ if (method !== wants) {
902
+ deny(405, "serve-method-not-allowed", `${url.pathname} answers ${wants}, not ${method}`);
903
+ return;
904
+ }
905
+ if (route.kind === "catalog") {
906
+ send(res, 200, JSON.stringify({ actor: options.actor, verbs: serveCatalog() }), {
907
+ "content-type": "application/json",
908
+ });
909
+ return;
910
+ }
911
+ if (route.kind === "follow") {
912
+ await handleFollow(res, url);
913
+ return;
914
+ }
915
+ if (route.kind === "export") {
916
+ await handleExport(res);
917
+ return;
918
+ }
919
+ if (route.kind === "status") {
920
+ const spec = byName.get("status");
921
+ if (spec === undefined) {
922
+ refuse(res, 500, "serve-unknown-verb", "the registry publishes no `status` verb");
923
+ return;
924
+ }
925
+ await handleVerb(res, spec, {}, scope);
926
+ return;
927
+ }
928
+ if (route.kind === "hook" && harness === null) {
929
+ refuse(res, 404, "serve-unknown-harness", `no hook adapter for ${JSON.stringify(route.harness)}; this runtime speaks ${Object.keys(HARNESS_ADAPTERS).join(", ")}`);
930
+ return;
931
+ }
932
+ const body = await readBody(req);
933
+ if (!body.ok) {
934
+ // The oversized-envelope case the review found: this used to be a
935
+ // 413 with no exit code anywhere, which a client turned into exit 0,
936
+ // and exit 0 is ALLOW on four of the six dialects. It is now a block
937
+ // in the harness's own words.
938
+ deny(body.status, body.code, body.message);
939
+ return;
940
+ }
941
+ if (harness !== null) {
942
+ // NOT wrapped in the store lock: a hook call takes it for its own
943
+ // mutation sections and releases it while it waits (APRV-427).
944
+ await handleHook(res, harness, body.text, arrivedAt);
945
+ return;
946
+ }
947
+ // Only the verb route reaches here: the hook arm returned above, and
948
+ // every other route was handled before the body was read.
949
+ if (route.kind !== "verb") {
950
+ refuse(res, 500, "serve-unknown-path", `no handler for ${url.pathname}`);
951
+ return;
952
+ }
953
+ const spec = byName.get(route.name);
954
+ if (spec === undefined) {
955
+ refuse(res, 404, "serve-unknown-verb", `no verb ${JSON.stringify(route.name)} on this surface. GET /verbs lists what is published; a verb that records a human's authority is not among them, and never will be`);
956
+ return;
957
+ }
958
+ let args = {};
959
+ if (body.text.trim().length > 0) {
960
+ try {
961
+ args = JSON.parse(body.text);
962
+ }
963
+ catch (cause) {
964
+ refuse(res, 400, "serve-body-unreadable", `request body is not JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
965
+ return;
966
+ }
967
+ }
968
+ await handleVerb(res, spec, args, scope);
969
+ }
970
+ catch (cause) {
971
+ // A throw out of a handler is still a refusal with a code, because a
972
+ // caller that got a bare 500 would have nothing to branch on.
973
+ if (!res.headersSent) {
974
+ refuse(res, 500, "serve-body-unreadable", `the request failed: ${cause instanceof Error ? cause.message : String(cause)}`);
975
+ }
976
+ else {
977
+ res.end();
978
+ }
979
+ }
980
+ })();
981
+ });
982
+ http.on("connection", (socket) => {
983
+ sockets.add(socket);
984
+ socket.on("close", () => sockets.delete(socket));
985
+ });
986
+ const socketPath = options.socketPath ?? null;
987
+ if (socketPath !== null) {
988
+ const checked = checkUnixSocketTarget(socketPath);
989
+ if (!checked.ok)
990
+ throw new Error(checked.message);
991
+ await clearStaleSocket(socketPath);
992
+ }
993
+ await new Promise((settle, fail) => {
994
+ const onError = (cause) => fail(cause);
995
+ http.once("error", onError);
996
+ const ready = () => {
997
+ http.off("error", onError);
998
+ settle();
999
+ };
1000
+ if (socketPath !== null)
1001
+ http.listen(socketPath, ready);
1002
+ else
1003
+ http.listen(options.port, host, ready);
1004
+ });
1005
+ // APRV-445 refutation (L3). The socket is opened to 0666 only where nobody
1006
+ // but the serving uid can write the directory. Between the bind and the
1007
+ // chmod the socket carries the umask's narrower mode, which fails closed;
1008
+ // what a group- or other-writable directory would add is someone able to
1009
+ // swap the socket inside that window, so there the chmod is skipped and the
1010
+ // socket keeps the umask's mode (the operator widens the umask or the
1011
+ // directory's ACL instead). Other-writable directories are refused outright.
1012
+ const widen = socketPath !== null && (statSync(dirname(socketPath)).mode & 0o022) === 0;
1013
+ if (socketPath !== null && !widen) {
1014
+ notice(`approval: serve left ${socketPath} at its umask mode: its directory is group-writable, and only a directory the serving uid alone can write is trusted to hold a 0666 socket\n`);
1015
+ }
1016
+ if (socketPath !== null && widen) {
1017
+ // The directory is the access control (see `checkUnixSocketTarget`); the
1018
+ // socket itself is opened to every uid that can reach it through that
1019
+ // directory, which is the sandbox user the operator put there. Narrowing
1020
+ // the socket instead would make the operator's group or ACL on the
1021
+ // directory a dead letter for a uid it was written for.
1022
+ try {
1023
+ chmodSync(socketPath, UNIX_SOCKET_MODE);
1024
+ }
1025
+ catch (cause) {
1026
+ await new Promise((settle) => http.close(() => settle()));
1027
+ throw new Error(`the socket ${socketPath} was bound and could not be opened to mode ${UNIX_SOCKET_MODE.toString(8)}: ${cause instanceof Error ? cause.message : String(cause)}`);
1028
+ }
1029
+ }
1030
+ const address = http.address();
1031
+ const boundPort = socketPath === null && typeof address === "object" && address !== null ? address.port : options.port;
1032
+ const boundHost = socketPath !== null
1033
+ ? `unix:${socketPath}`
1034
+ : typeof address === "object" && address !== null
1035
+ ? address.address
1036
+ : host;
1037
+ notice(socketPath === null
1038
+ ? `approval: serve bound ${boundHost}:${String(boundPort)}\n`
1039
+ : `approval: serve bound ${boundHost}\n`);
1040
+ return {
1041
+ host: boundHost,
1042
+ port: boundPort,
1043
+ socketPath,
1044
+ daemonId,
1045
+ requests: () => requests,
1046
+ hookThreads: () => hooks.stats(),
1047
+ close: async () => {
1048
+ if (closing)
1049
+ return;
1050
+ closing = true;
1051
+ // In this order (APRV-427 review). Stop accepting first. Then cancel
1052
+ // every hook call, which stops each waiting one at its next tick with
1053
+ // nothing spent and nothing withdrawn (its question stays open for the
1054
+ // retry grace, as for a killed `approval hook` process), and terminate
1055
+ // the threads from inside the store lock, which also waits out any verb
1056
+ // or mutation section still running. Only then are the sockets
1057
+ // destroyed, so no in-flight append is cut off by its connection going.
1058
+ const stopped = new Promise((settle) => http.close(() => settle()));
1059
+ await hooks.close();
1060
+ for (const socket of sockets)
1061
+ socket.destroy();
1062
+ sockets.clear();
1063
+ await stopped;
1064
+ if (socketPath !== null) {
1065
+ try {
1066
+ unlinkSync(socketPath);
1067
+ }
1068
+ catch {
1069
+ // Already gone: the platform removed it on close.
1070
+ }
1071
+ }
1072
+ // APRV-448 review: a closed listener stops stamping the embedding
1073
+ // process's appends. Left alone when a daemon loop runs in this process
1074
+ // (it declared the same id for the same store and still writes), and when
1075
+ // the declaration in force is no longer this listener's.
1076
+ if (!isDaemonProcess() && daemonIdentity()?.id === daemonId)
1077
+ clearDaemonIdentity();
1078
+ },
1079
+ };
1080
+ }
1081
+ // ---------------------------------------------------------------------------
1082
+ // Unix-socket listen target (APRV-445)
1083
+ // ---------------------------------------------------------------------------
1084
+ /**
1085
+ * The mode a unix socket is given once bound: everyone the DIRECTORY admits.
1086
+ *
1087
+ * A hosted tenant runs the agent's sandbox as a different uid from this server,
1088
+ * on the same machine, and the socket is how the two meet without a TCP port
1089
+ * anybody on the host could dial. Who may connect is therefore decided by the
1090
+ * directory the socket sits in, which the operator owns and sets (`0710` with
1091
+ * the sandbox's group, an ACL, a bind mount), and the socket itself does not
1092
+ * second-guess it.
1093
+ */
1094
+ export const UNIX_SOCKET_MODE = 0o666;
1095
+ /**
1096
+ * Refuse a socket path this process should not bind (APRV-445).
1097
+ *
1098
+ * Absolute, inside an existing directory OWNED BY THE SERVING UID (the directory
1099
+ * is the access control, so a directory somebody else owns is somebody else's
1100
+ * access control), and not an existing file of any kind other than a socket: a
1101
+ * stale socket is replaced, and anything else at that path is a file this
1102
+ * server would destroy by binding over it. Synchronous and side-effect free, so
1103
+ * the CLI can refuse before any listener exists.
1104
+ */
1105
+ export function checkUnixSocketTarget(socketPath) {
1106
+ if (!socketPath.startsWith("/")) {
1107
+ return { ok: false, message: `unix socket path ${JSON.stringify(socketPath)} is not absolute` };
1108
+ }
1109
+ const directory = dirname(socketPath);
1110
+ let owner;
1111
+ let mode;
1112
+ try {
1113
+ const stats = statSync(directory);
1114
+ if (!stats.isDirectory()) {
1115
+ return { ok: false, message: `${directory} is not a directory, so ${socketPath} cannot be bound in it` };
1116
+ }
1117
+ owner = stats.uid;
1118
+ mode = stats.mode & 0o777;
1119
+ }
1120
+ catch (cause) {
1121
+ return {
1122
+ ok: false,
1123
+ message: `the socket's directory ${directory} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
1124
+ };
1125
+ }
1126
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
1127
+ if (uid === null || owner !== uid) {
1128
+ return {
1129
+ ok: false,
1130
+ message: `the socket's directory ${directory} is owned by uid ${String(owner)}, not by the serving uid ${String(uid)}. The directory is what decides who may connect to this socket (the socket itself is opened ${UNIX_SOCKET_MODE.toString(8)}), so it must be this process's own`,
1131
+ };
1132
+ }
1133
+ // APRV-445 refutation (L3). An other-writable directory (`/tmp` is the
1134
+ // usual one) lets anybody on the host unlink this socket and bind their own
1135
+ // in its place, between this process's bind and every client's connect, so
1136
+ // the directory would decide nothing. Refused rather than used.
1137
+ if ((mode & 0o002) !== 0) {
1138
+ return {
1139
+ ok: false,
1140
+ message: `the socket's directory ${directory} is writable by every user (mode ${mode.toString(8)}), so anyone could replace the socket; use a directory only the serving uid (and, if you mean it, its group) can write`,
1141
+ };
1142
+ }
1143
+ try {
1144
+ const existing = lstatSync(socketPath);
1145
+ if (!existing.isSocket()) {
1146
+ return {
1147
+ ok: false,
1148
+ message: `${socketPath} exists and is not a socket; binding there would destroy it, so nothing was bound`,
1149
+ };
1150
+ }
1151
+ }
1152
+ catch {
1153
+ // Absent: the ordinary case.
1154
+ }
1155
+ return { ok: true };
1156
+ }
1157
+ /**
1158
+ * Remove a socket file nothing answers on (APRV-445).
1159
+ *
1160
+ * A server killed without closing leaves its socket file behind, and the next
1161
+ * bind fails `EADDRINUSE` on a path nobody is listening at. A socket something
1162
+ * DOES answer on is another live server, possibly another tenant's gate, and is
1163
+ * refused rather than taken over.
1164
+ */
1165
+ async function clearStaleSocket(socketPath) {
1166
+ try {
1167
+ if (!lstatSync(socketPath).isSocket())
1168
+ return;
1169
+ }
1170
+ catch {
1171
+ return;
1172
+ }
1173
+ // APRV-445 refutation (L2): only a refusal that PROVES nobody listens makes
1174
+ // the file stale. ECONNREFUSED is a socket with no listener and ENOENT is a
1175
+ // file that vanished meanwhile; anything else (EACCES, EAGAIN on a full
1176
+ // backlog, a timeout) is a socket this process cannot judge, and it is left
1177
+ // alone rather than unlinked from under whoever owns it.
1178
+ const probed = await new Promise((settle) => {
1179
+ const probe = connect(socketPath);
1180
+ probe.once("connect", () => {
1181
+ probe.destroy();
1182
+ settle("live");
1183
+ });
1184
+ probe.once("error", (cause) => {
1185
+ settle(cause.code === "ECONNREFUSED" || cause.code === "ENOENT" ? "stale" : cause.code ?? "unknown");
1186
+ });
1187
+ });
1188
+ if (probed === "live") {
1189
+ throw new Error(`${socketPath} is a live socket: another process is listening on it, so this server will not take it over`);
1190
+ }
1191
+ if (probed !== "stale") {
1192
+ throw new Error(`${socketPath} exists and probing it failed with ${probed}, which does not prove nothing listens there; it was left in place`);
1193
+ }
1194
+ try {
1195
+ unlinkSync(socketPath);
1196
+ }
1197
+ catch (cause) {
1198
+ if (cause.code !== "ENOENT")
1199
+ throw cause;
1200
+ }
1201
+ }
1202
+ /**
1203
+ * One lock per store this process serves, keyed by the store's log path
1204
+ * (APRV-427).
1205
+ *
1206
+ * A listener serves one store today (`--dir`, `--log` and `--policy` are pinned
1207
+ * at launch), so each listener finds exactly one entry here. The map is what
1208
+ * makes the lock a property of the STORE rather than of a listener: two
1209
+ * listeners in one process over one store (a test, or a future multi-tenant
1210
+ * host) share a lock, and a host serving several stores gets one lock each, so
1211
+ * one tenant's writes never wait on another's. It lives for the process and
1212
+ * holds one closure per store, which is the whole of its cost.
1213
+ */
1214
+ const STORE_LOCKS = new Map();
1215
+ /**
1216
+ * The lock for the store whose log is `logPath` (resolved against `root`).
1217
+ *
1218
+ * Exported so a test can hold the SAME lock the listener takes and watch what
1219
+ * waits on it: that is how the suite shows a hook call's appends are inside
1220
+ * the lock and its wait is outside it, rather than inferring it from timing.
1221
+ */
1222
+ export function storeLock(logPath, root) {
1223
+ const key = storeKey(resolve(root, logPath));
1224
+ let lock = STORE_LOCKS.get(key);
1225
+ if (lock === undefined) {
1226
+ lock = serializer();
1227
+ STORE_LOCKS.set(key, lock);
1228
+ }
1229
+ return lock;
1230
+ }
1231
+ /**
1232
+ * The key a store's lock is filed under: the log path with its DIRECTORY
1233
+ * resolved through `realpath`, so two spellings of one store (a symlinked
1234
+ * root, `/tmp` against `/private/tmp`) share one lock instead of each getting
1235
+ * their own. The directory rather than the file, because a store whose log has
1236
+ * not been written yet has no file to resolve; where even the directory is
1237
+ * missing, the resolved spelling is the best key there is.
1238
+ */
1239
+ function storeKey(logPath) {
1240
+ try {
1241
+ return resolve(realpathSync(dirname(logPath)), basename(logPath));
1242
+ }
1243
+ catch {
1244
+ return logPath;
1245
+ }
1246
+ }
1247
+ /** The default log under a store root, from the CLI's own constant. */
1248
+ function logPathOf(root) {
1249
+ return resolvePath(null, DEFAULT_LOG_PATH, root);
1250
+ }
1251
+ //# sourceMappingURL=server.js.map