approval-md 0.3.0 → 0.4.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 (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  3. package/cli.js +66 -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 +19 -0
  8. package/dist/src/channels/contract.js +105 -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.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -72,17 +72,17 @@
72
72
  * auditor holding the log alone. See `docs/claude-code-hook.md`.
73
73
  */
74
74
  import { spawnSync } from "node:child_process";
75
- import { existsSync, readFileSync, realpathSync } from "node:fs";
75
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeSync } from "node:fs";
76
76
  import { randomBytes } from "node:crypto";
77
77
  import { tmpdir } from "node:os";
78
78
  import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
79
79
  import { attestationRefusal, checkAttestation } from "../core/attest.js";
80
80
  import { childEnvironment } from "../core/child-env.js";
81
- import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
82
- import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
81
+ import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, protectedPathClass, } from "../core/command-class.js";
82
+ import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, isProposalTask, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
83
83
  import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
84
84
  import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
85
- import { abandonedAfterMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
85
+ import { abandonedAfterMs, HARNESS_CAP_MARGIN_MS, harnessCapFitsMargin, harnessCappedTtlMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
86
86
  import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
87
87
  import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
88
88
  import { payloadHash } from "../core/payload.js";
@@ -90,11 +90,11 @@ import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
90
90
  import { loadPolicy, parseDuration } from "../core/policy-load.js";
91
91
  import { READ_OUT_OF_SCOPE_CLASS, effectiveReadRoots, isInReadScope, readTargetsOf, renderReadRoots, } from "../core/read-scope.js";
92
92
  import { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve as resolvePolicy, } from "../core/policy-match.js";
93
- import { payloadOf, readVerifiedRecords, requestState, useVerifiedSnapshots, } from "../core/state.js";
93
+ import { payloadOf, readVerifiedRecords, requestExpiresAt, requestState, useVerifiedSnapshots, } from "../core/state.js";
94
94
  import { boolFlag, parseFlags, stringFlag } from "./args.js";
95
95
  import { EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
96
96
  import { primaryRoot as resolvePrimaryRoot } from "./git-scope.js";
97
- import { HOOK_GROK_HELP, HOOK_HELP, HOOK_MUSE_HELP } from "./help.js";
97
+ import { HOOK_GROK_HELP, HOOK_HELP, HOOK_HERMES_HELP, HOOK_MUSE_HELP } from "./help.js";
98
98
  import { DEFAULT_LOG_PATH } from "./paths.js";
99
99
  import { refusal as renderRefusal, style, table } from "./style.js";
100
100
  import { usageErrorText } from "./usage.js";
@@ -111,6 +111,22 @@ const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
111
111
  const DEFAULT_TIMEOUT = HOOK_DEFAULT_WAIT;
112
112
  /** Poll interval for the decision wait. */
113
113
  const DEFAULT_INTERVAL_MS = 1_000;
114
+ /**
115
+ * How many CONSECUTIVE torn-tail reads the decision wait reads past before it
116
+ * treats the log as unreadable (APRV-427 review).
117
+ *
118
+ * The poll reads the log while other writers append to it: the daemon beside
119
+ * a CLI hook, and every other hook call beside one run by `approval serve`. An
120
+ * append is one write on an O_APPEND handle, but a reader on a filesystem that
121
+ * grows a file a page at a time can see the front of a line before its end, and
122
+ * the verified read calls that a torn tail. It is a moment in someone else's
123
+ * write, and the next tick reads the whole line. Treating it as terminal
124
+ * withdrew a live question and denied `hook-io` because a DIFFERENT call was
125
+ * mid-append. So a torn read is read again next tick, like a view that lags;
126
+ * only a tail that stays torn for this many ticks in a row (a crashed write,
127
+ * which nobody is going to finish) takes the old withdraw-and-deny path.
128
+ */
129
+ export const TORN_TAIL_RETRIES = 5;
114
130
  /**
115
131
  * How much of the command line goes in the (claimed) summary field.
116
132
  *
@@ -199,8 +215,36 @@ export const HOOK_DENY_CODES = [
199
215
  * directory, once. Past the grace the hook withdraws it (reason `timeout`),
200
216
  * because a question nothing will adopt is a message on a phone that decides
201
217
  * nothing.
218
+ *
219
+ * **Who withdraws, stated because the deny text used to imply a scheduler
220
+ * that does not exist (APRV-410).** This process is over when it denies, so
221
+ * the withdrawal is made by the NEXT gated tool call this actor makes, from
222
+ * the sweep at the top of every gated invocation. It cannot be anyone else: a
223
+ * withdrawal is the requester's own (APRV-106 rule 1, `not-requester`), the
224
+ * gate refuses a `system:` withdrawal outright, and the event schema binds
225
+ * the one `system:` withdrawal that exists to `policy-drift` and to nothing
226
+ * else (APRV-235). So a session that denies here and then makes no further
227
+ * gated call leaves the question pending until the TTL, and a decision on it
228
+ * lands as an ordinary grant. What stops that grant authorizing anything is
229
+ * the other half of APRV-410: a retry past the grace does not carry it
230
+ * (`findHarnessCarry`), so it asks again.
202
231
  */
203
232
  "hook-timeout",
233
+ /**
234
+ * The harness ceiling this hook was told it runs under leaves no window a
235
+ * human could answer in (APRV-423).
236
+ *
237
+ * `--harness-cap` (or the harness's own documented maximum) at or below
238
+ * `HARNESS_CAP_MARGIN_MS` means the question would lapse at the instant it
239
+ * was asked: the request must end a margin before the harness kills the
240
+ * process that opened it, and there is no room left for the asking. Nothing
241
+ * is registered, nothing is requested and nobody's phone lights up. The
242
+ * repair is a configuration change — raise the harness entry's timeout, or
243
+ * state the real one — and it is a refusal rather than a silent widening
244
+ * because a hook that quietly ignored its own ceiling would be back to
245
+ * recording grants on calls nobody holds.
246
+ */
247
+ "hook-harness-cap-too-short",
204
248
  /** The gate refused intake; the gate's own code follows a colon. */
205
249
  "hook-gate-refused",
206
250
  /**
@@ -283,6 +327,32 @@ export const HOOK_DENY_CODES = [
283
327
  * and it cannot recall a prompt the model has already been sent.
284
328
  */
285
329
  "hook-muse-contributor-model",
330
+ /**
331
+ * A Hermes Agent `execute_code` call, refused before anything else looks at
332
+ * it (APRV-398).
333
+ *
334
+ * The tool takes a `code` string and runs it in-process. There is no path, no
335
+ * argv and no working directory: nothing the classifier reads exists in the
336
+ * call, so no class can be resolved and no payload can bind what will happen.
337
+ * A verdict over `{code}` would authorize a program this runtime never
338
+ * parsed, which is the same hole `hook-opaque` closes for `bash -c` and the
339
+ * same one `hook-unsupported-execution-context` closes for a command whose
340
+ * directory is invisible. SPEC.md §11.1 says ambiguity resolves to the
341
+ * stricter path, and the strictest honest answer to "may I run this arbitrary
342
+ * program" is no.
343
+ *
344
+ * Distinct from `hook-opaque`, and the distinction is the repair. That one
345
+ * says a command line carried a construct the classifier could not read, and
346
+ * the fix is to write the command differently. This one says the TOOL has no
347
+ * readable surface at all on this harness, so there is no spelling of an
348
+ * `execute_code` call that would be answered differently: the repair is to do
349
+ * the work through the shell tool, where the words are visible and the
350
+ * classifier can price them, or through `approval run` with a granted token.
351
+ *
352
+ * Distinct from `hook-unclassified`, which says the classifier had nothing to
353
+ * say about bytes it could see. Here there are no bytes to see.
354
+ */
355
+ "hook-hermes-execute-code-unbound",
286
356
  /** Malformed hook input, or a log/filesystem fact that stopped the check. */
287
357
  "hook-io",
288
358
  ];
@@ -356,6 +426,22 @@ export function hookScope(flags, cwd) {
356
426
  function policyRootOf(load, fallback) {
357
427
  return load.ok ? dirname(load.source.path) : fallback;
358
428
  }
429
+ /**
430
+ * The ceiling a harness puts on its hook process, in milliseconds, or `null`
431
+ * (APRV-423): the operator's stated `--harness-cap` clamped to the adapter's
432
+ * contractual maximum, or, with nothing stated, the adapter's documented
433
+ * default, falling back to that maximum.
434
+ *
435
+ * Exported so `approval serve` computes the same number the hook will before
436
+ * it hands the call to a thread (APRV-427 review): the budget a queued call
437
+ * has left is this less the time it has waited.
438
+ */
439
+ export function effectiveHarnessCapMs(adapter, statedCapMs) {
440
+ const ceilingMs = adapter.capCeilingMs ?? null;
441
+ if (statedCapMs === null)
442
+ return adapter.capDefaultMs ?? ceilingMs;
443
+ return ceilingMs === null ? statedCapMs : Math.min(statedCapMs, ceilingMs);
444
+ }
359
445
  const CLAUDE_ADAPTER = {
360
446
  kind: "claude-code",
361
447
  originApp: "claude-code-hook",
@@ -491,6 +577,239 @@ const MUSE_ADAPTER = {
491
577
  shellCwdKey: "workdir",
492
578
  contributorModelGuard: true,
493
579
  };
580
+ /**
581
+ * Hermes Agent's event names, which are its own and not Claude Code's
582
+ * (APRV-398).
583
+ *
584
+ * snake_case, and the VERBS differ too: `pre_tool_call` where every other
585
+ * harness in this table says `PreToolUse`. That is why they are constants
586
+ * rather than two more entries in {@link POST_TOOL_EVENTS} — a harness whose
587
+ * post-event name appeared in that shared list would also accept
588
+ * `PostToolUse` from an event nobody sent, and the dispatch would answer a
589
+ * name this harness never uses.
590
+ */
591
+ export const HERMES_PRE_TOOL_EVENT = "pre_tool_call";
592
+ export const HERMES_POST_TOOL_EVENT = "post_tool_call";
593
+ /**
594
+ * Hermes Agent's read tools, read off its own tool registrations (APRV-398).
595
+ *
596
+ * `read_file` names one `path` (with `offset` and `limit`). `search_files` is
597
+ * BOTH of the readers another harness would ship as two: its `target` enum
598
+ * selects `content` (a grep) or `files` (a name search), and either way the
599
+ * directory it works in is one `path`. So there is no `glob`, no `grep` and no
600
+ * `list_files` here — not because they were left out, but because this harness
601
+ * does not have them.
602
+ *
603
+ * NO TOOL ON THIS HARNESS TAKES A LIST OF PATHS, which is the one thing worth
604
+ * saying out loud: Muse's `search` names an ARRAY under `paths` and needed
605
+ * `firstOutOfScope` to answer a list, and `readToolGate`'s single-path arm
606
+ * answers everything here. A `paths` array arriving from a future release would
607
+ * fall to that same list handling with no change, because the gate reads both.
608
+ *
609
+ * OBSERVED (APRV-415, the live probe of APRV-398): a running session sent
610
+ * `read_file` with `path` and `limit`, and the tools it sent in total were
611
+ * `terminal`, `write_file`, `patch`, `read_file` and `execute_code`.
612
+ * `search_files` was not exercised by the five prompts, so it stays on this list
613
+ * as the registration says, in the direction APRV-347 made safe: a listed tool
614
+ * Hermes never sends is INERT, an unlisted tool it does send would be an
615
+ * unscoped read. A read arriving through `terminal` as `cat` is scoped by the
616
+ * classifier regardless, which is the floor under all of it.
617
+ */
618
+ const HERMES_READ_TOOLS = ["read_file", "search_files"];
619
+ /**
620
+ * The classes Hermes's own side-effecting tools are judged under (APRV-445,
621
+ * from the Agent Village DATA-234 survey of Hermes v2026.9.24).
622
+ *
623
+ * Before this table every one of these reached the not-a-gated-tool allow,
624
+ * because the adapter knew `terminal`, `write_file`, `patch` and the two read
625
+ * tools and nothing else, so a policy line for `cron.manage` or `message.send`
626
+ * was a line no Hermes call could ever reach. They are code, not policy: the
627
+ * classifier decides what a call IS and the policy decides what happens to it,
628
+ * so an operator prices these classes with ordinary `classes:` lines.
629
+ *
630
+ * - `cronjob_manage` (legacy alias `cronjob`): every action that changes the
631
+ * schedule or runs a job is `cron.manage`; `list` is a read. A scheduled job
632
+ * runs later with no `pre_tool_call` at all, so the schedule is the one place
633
+ * a human can see it.
634
+ * - `process_manage` (legacy alias `process`): `write`, `submit`, `kill`,
635
+ * `close` and `handoff` act on a running process and are `process.write`;
636
+ * `list`, `poll`, `log` and `wait` are reads.
637
+ * - `browser_exec`, `browser_cdp` and every other `browser_*`: `browser.exec`.
638
+ * - `skill_manage`: `skill.manage`. `delegate_task`: `agent.delegate`.
639
+ * `send_message`: `message.send` (not agent-callable at v2026.9.24; the rule
640
+ * stands so the day it is, it is already gated).
641
+ *
642
+ * An action this table does not recognise on a tool it does know is judged
643
+ * under the tool's side-effecting class rather than allowed: a new verb on a
644
+ * tool that schedules or drives processes is a new way to do that, and the
645
+ * strict reading is the safe one.
646
+ */
647
+ const HERMES_CRON_READ_ACTIONS = new Set(["list"]);
648
+ const HERMES_PROCESS_READ_ACTIONS = new Set(["list", "poll", "log", "wait"]);
649
+ function hermesToolRule(toolName, toolInput) {
650
+ const action = readString(toolInput, "action");
651
+ const headline = (cls) => `${toolName}${action === null ? "" : ` ${action}`} (${cls})`;
652
+ if (toolName === "cronjob_manage" || toolName === "cronjob") {
653
+ if (action !== null && HERMES_CRON_READ_ACTIONS.has(action)) {
654
+ return { kind: "read", reason: `${toolName} ${action} reads the schedule and changes nothing` };
655
+ }
656
+ return { kind: "gated", cls: "cron.manage", headline: headline("cron.manage") };
657
+ }
658
+ if (toolName === "process_manage" || toolName === "process") {
659
+ if (action !== null && HERMES_PROCESS_READ_ACTIONS.has(action)) {
660
+ return { kind: "read", reason: `${toolName} ${action} reads a process and changes nothing` };
661
+ }
662
+ return { kind: "gated", cls: "process.write", headline: headline("process.write") };
663
+ }
664
+ if (toolName.startsWith("browser_")) {
665
+ return { kind: "gated", cls: "browser.exec", headline: headline("browser.exec") };
666
+ }
667
+ if (toolName === "skill_manage") {
668
+ return { kind: "gated", cls: "skill.manage", headline: headline("skill.manage") };
669
+ }
670
+ if (toolName === "delegate_task") {
671
+ return { kind: "gated", cls: "agent.delegate", headline: headline("agent.delegate") };
672
+ }
673
+ if (toolName === "send_message") {
674
+ return { kind: "gated", cls: "message.send", headline: headline("message.send") };
675
+ }
676
+ return null;
677
+ }
678
+ /**
679
+ * Hermes Agent by Nous Research (APRV-398).
680
+ *
681
+ * The harness Agent Village v2 runs every resident agent on, one per tenant in
682
+ * a Railway sandbox, which is why this adapter exists at all. Its hook is a
683
+ * shell hook like every other entry in this table, configured under `hooks:` in
684
+ * `$HERMES_HOME/config.yaml` rather than in the repository: Hermes documents no
685
+ * project-local configuration directory, so the organ lives in the user home
686
+ * and `core/command-class.ts` matches it there.
687
+ *
688
+ * ## The dialect: exactly one form, and the form is Hermes's own
689
+ *
690
+ * Hermes's shell-hook parser accepts two block dialects, checked in this order:
691
+ * its own `{action:"block", message}` and a Claude-compatible
692
+ * `{decision:"block", reason}`. This adapter emits the NATIVE one and never
693
+ * both.
694
+ *
695
+ * The live probe (APRV-415) measured every dialect one at a time and then all of
696
+ * them at once: `{action:"block"}` at exit 2, `{action:"block"}` at exit 0,
697
+ * `{decision:"block"}`, a bare exit 2, and the mixed payload ALL BLOCKED. So
698
+ * HERMES TOLERATES A SUPERSET, which is exactly where it differs from Muse — a
699
+ * payload carrying several dialects there was unparseable, an unparseable hook
700
+ * was a failed hook, and a failed hook failed OPEN, so being more explicit made
701
+ * the refusal weaker (`docs/muse-hook.md`). One dialect is still what this
702
+ * adapter emits, and the reason is now the smaller one rather than the load-
703
+ * bearing one: the native form is the shape the parser tries first and the one
704
+ * least likely to be dropped by a release that tidies up a compatibility layer,
705
+ * and a verdict whose meaning does not depend on how much of it the harness
706
+ * understood is a verdict a reader can check. The tolerance is a fact about this
707
+ * release, not a licence to hedge in the next one.
708
+ *
709
+ * THE ALLOW IS `{}`, and that is not a placeholder. Hermes has no
710
+ * `{"action":"allow"}`: its parser returns "no directive" for an empty stdout,
711
+ * for `{}`, and for any JSON object naming no directive key, and no directive
712
+ * means the call proceeds. The probe confirmed it: the `allow-empty-object`
713
+ * trial CREATED its file under `fail_closed: true`. `{}` is chosen over an empty
714
+ * stdout because the two are distinguishable to a human reading a log and only
715
+ * one of them says a hook ran and decided; it is chosen over an invented
716
+ * `{"action":"allow"}` because that spelling is an allow only by falling through
717
+ * the parser's unrecognised-directive path — which the probe also measured, and
718
+ * which did fall through on the old build — and a verdict that works by not being
719
+ * understood is a verdict one release could turn into a block. The reason text
720
+ * rides on stderr, where Hermes reads it only as a block message at the blocking
721
+ * exit code, which an allow never uses.
722
+ *
723
+ * ## The first real gate since Claude Code, measured
724
+ *
725
+ * A per-entry `fail_closed: true` turns three hook failures into a BLOCK: a
726
+ * spawn error, a timeout, and stdout that is non-empty and not a JSON object.
727
+ * Grok Build and Muse Code both fail open on all three with no setting to change
728
+ * it, so both adapters are enforcement only while healthy.
729
+ *
730
+ * OBSERVED, 3 OF 3, on Hermes `main` at `118984d7` (APRV-415): the armed crash
731
+ * was refused, the armed garbage was refused, and the armed hang was refused at
732
+ * exactly 300s, the per-entry cap. So this adapter is a gate on a current Hermes
733
+ * rather than a backstop, which no adapter since Claude Code could say.
734
+ *
735
+ * THREE LIMITS RIDE WITH IT, and `docs/hermes-hook.md` states all three rather
736
+ * than selling the headline. The default is `false`, so an entry without the key
737
+ * fails open. The key does not cover a hook that exits non-zero having printed
738
+ * NOTHING, which is left to {@link HERMES_DENY_EXIT}'s unconditional block. And
739
+ * there is a VERSION FLOOR: `v0.21.3` (build `2026.9.14`) does not know the key
740
+ * at all and ignores it silently, with `hermes hooks list` rendering no
741
+ * `fail_closed` flag on either build, so the listing cannot be used to check it.
742
+ * {@link HERMES_FAIL_CLOSED_FLOOR} in `core/harness-version.ts` carries the floor
743
+ * and `approval doctor`'s harness-version row reports a build below it.
744
+ *
745
+ * ## `execute_code` has no readable surface, and may not even be hooked
746
+ *
747
+ * The tool takes a `code` string and no path, no argv and no directory, so
748
+ * nothing a verdict could bind exists in the call. It is refused before the
749
+ * event dispatch with {@link HERMES_EXECUTE_CODE_REFUSAL}.
750
+ *
751
+ * There is a second reason, and it is the stronger one. `execute_code` runs in
752
+ * a persistent kernel whose scripts can call Hermes's other tools IN-PROCESS.
753
+ * Whether those inner calls re-fire `pre_tool_call` is STILL not established and
754
+ * cannot be established from here: the probe refused every `execute_code` call
755
+ * before it ran, twice, which is the refusal working and is also the reason no
756
+ * inner call was ever observed. If they do not re-fire, one `execute_code` call
757
+ * is an unbounded bypass of this entire adapter. Refusing the tool is the only
758
+ * answer available to a hook that cannot see inside it, and it is the
759
+ * fail-closed one.
760
+ */
761
+ const HERMES_ADAPTER = {
762
+ kind: "hermes",
763
+ originApp: "hermes-hook",
764
+ defaultActor: "agent:hermes",
765
+ // Two numbers, because Hermes bounds the hook twice and the shorter wins.
766
+ //
767
+ // The CEILING is 300 s, OBSERVED rather than read off a page: APRV-415's probe
768
+ // armed a hang and Hermes refused it at exactly 300 s, the per-entry cap. No
769
+ // entry can outlive it however it is written, so a stated `--harness-cap` is
770
+ // clamped to it.
771
+ //
772
+ // The DEFAULT is 30 s: `plugins.hook_callback_timeout`, which bounds the whole
773
+ // hook dispatch, fails closed on `pre_tool_call` by itself, and is what a
774
+ // Hermes install runs with until an operator raises it. The first pass of
775
+ // APRV-423 assumed the 300 s ceiling whenever the flag was absent, which on a
776
+ // default install asserted a T+240 s deadline for a hook Hermes kills at 30 s:
777
+ // APRV-410 again, with a fictional deadline. So with no flag the runtime
778
+ // assumes the stricter number, which does not clear the margin, and refuses
779
+ // `hook-harness-cap-too-short` with the repair below. An operator who has
780
+ // raised `hook_callback_timeout` states the smaller of it and the entry's
781
+ // `timeout` with `--harness-cap`, and that statement replaces the default.
782
+ capCeilingMs: 300_000,
783
+ capDefaultMs: 30_000,
784
+ capRepair: "On Hermes: raise `plugins.hook_callback_timeout` above the per-entry `timeout` in the harness's config, then pass `--harness-cap <the smaller of the two>` on the hook command, or start `approval serve` with `--hook-harness-cap <the smaller of the two>` for a hosted tenant (for the documented `hook_callback_timeout: 600` with `timeout: 300`, that is 300s). Without the flag this runtime assumes Hermes's 30s default callback timeout, which leaves no room for a human.",
785
+ // `terminal`, carrying `command` and a PER-CALL `workdir`. Hermes's contract
786
+ // has the field Codex's withholds (APRV-310), and the live probe found that
787
+ // having it is not the same as being sent it: the model passed no `workdir` at
788
+ // all, and the directory the command would have run in is reported nowhere
789
+ // else. So the field makes enforcement POSSIBLE and
790
+ // {@link hermesUnboundDirectory} is what makes it actual, by refusing the call
791
+ // that leaves the field out instead of guessing a directory for it.
792
+ shellTool: "terminal",
793
+ // `write_file` names `path` and `content`; `patch` names `path`,
794
+ // `old_string` and `new_string`. Both shapes are already what
795
+ // `fileToolGate` reads, so neither needed a new key. Both were observed on the
796
+ // live run, `patch` with `new_string`, `old_string` and `path` exactly.
797
+ fileTools: ["write_file", "patch"],
798
+ readTools: HERMES_READ_TOOLS,
799
+ shellCwdKey: "workdir",
800
+ toolRules: hermesToolRule,
801
+ };
802
+ /** The adapter's rule-table verdict for this call, or `null` (APRV-445). */
803
+ function toolRuleOf(adapter, input) {
804
+ if (adapter.toolRules === undefined)
805
+ return null;
806
+ if (input.toolName === adapter.shellTool)
807
+ return null;
808
+ if (adapter.fileTools.includes(input.toolName) || adapter.readTools.includes(input.toolName)) {
809
+ return null;
810
+ }
811
+ return adapter.toolRules(input.toolName, input.toolInput);
812
+ }
494
813
  /**
495
814
  * Every harness this runtime speaks a hook protocol for, by kind (APRV-358).
496
815
  *
@@ -514,7 +833,26 @@ export const HARNESS_ADAPTERS = {
514
833
  codex: CODEX_ADAPTER,
515
834
  grok: GROK_ADAPTER,
516
835
  muse: MUSE_ADAPTER,
836
+ hermes: HERMES_ADAPTER,
517
837
  };
838
+ /**
839
+ * Is `name` this adapter's post-execution event?
840
+ *
841
+ * One reading of the question, so the three places that used to spell it
842
+ * themselves cannot come apart (APRV-398). Codex names one event, Hermes names
843
+ * its own snake_case pair, and everything else uses Claude Code's two; a
844
+ * harness whose names appeared in another harness's list would answer an event
845
+ * nobody sent it.
846
+ */
847
+ function isPostToolEvent(adapter, name) {
848
+ if (name === null)
849
+ return false;
850
+ if (adapter.kind === "codex")
851
+ return name === CODEX_POST_TOOL_EVENT;
852
+ if (adapter.kind === "hermes")
853
+ return name === HERMES_POST_TOOL_EVENT;
854
+ return POST_TOOL_EVENTS.includes(name);
855
+ }
518
856
  /**
519
857
  * The decision object the harness reads from stdout.
520
858
  *
@@ -533,6 +871,33 @@ function decision(permission, reason, harness, codexCommand) {
533
871
  if (harness === "grok") {
534
872
  return `${JSON.stringify({ decision: permission, reason })}\n`;
535
873
  }
874
+ if (harness === "hermes") {
875
+ // APRV-398. Hermes's OWN block form and exactly one form:
876
+ // `{action:"block", message}`. It emits neither the Claude-compatible
877
+ // `{decision:"block", reason}` beside it nor any other key, and the test
878
+ // asserts the top-level keys precisely so a later edit cannot quietly add
879
+ // one — on Muse a verdict carrying an unsupported key was a FAILED hook and
880
+ // a failed hook failed open, which made the more explicit refusal the weaker
881
+ // one. APRV-415 measured Hermes on the same question and it is NOT Muse: the
882
+ // mixed payload blocked, so this harness tolerates a superset. One dialect is
883
+ // kept anyway, because the tolerance is a property of one release and a
884
+ // verdict whose meaning depends on how much of it the harness parsed is not a
885
+ // verdict a reader can check.
886
+ //
887
+ // THE ALLOW IS `{}` AND CARRIES NO REASON, which is the harness's own
888
+ // vocabulary rather than a shrug: Hermes has no allow directive, and a
889
+ // parser that finds no directive lets the call proceed. `{}` over an empty
890
+ // stdout because a human reading a log can tell a hook that decided from a
891
+ // hook that printed nothing; `{}` over an invented `{"action":"allow"}`
892
+ // because that would be an allow only by falling through the parser's
893
+ // unrecognised path, and a verdict that works by not being understood is one
894
+ // release away from being a block. The reason reaches stderr instead (see
895
+ // {@link allow}), where it is read as a block message at the blocking exit
896
+ // code and nowhere else.
897
+ return permission === "deny"
898
+ ? `${JSON.stringify({ action: "block", message: reason })}\n`
899
+ : "{}\n";
900
+ }
536
901
  const hookSpecificOutput = {
537
902
  hookEventName: "PreToolUse",
538
903
  permissionDecision: permission,
@@ -548,6 +913,14 @@ function allow(streams, reason, harness, codexCommand) {
548
913
  if (harness === "codex" && codexCommand === undefined) {
549
914
  return deny(streams, "hook-io", "the Codex allow lost its exact bound tool_input.command", harness);
550
915
  }
916
+ // APRV-398. Hermes's allow is `{}` and has nowhere to put a reason, so the
917
+ // reason goes to stderr rather than being dropped: it names the classes the
918
+ // verdict authorized and carries the loop floor's own words when one stood,
919
+ // which is the line an operator reads back off a session. Hermes reads stderr
920
+ // only as a block message at the blocking exit code, and an allow never uses
921
+ // that exit code, so this cannot become part of a verdict.
922
+ if (harness === "hermes")
923
+ streams.err(`approval hook hermes: allow — ${reason}\n`);
551
924
  streams.out(decision("allow", reason, harness, codexCommand));
552
925
  return EXIT_OK;
553
926
  }
@@ -561,9 +934,150 @@ function allow(streams, reason, harness, codexCommand) {
561
934
  * of its documented contract and leaves the reason where a person can read it.
562
935
  */
563
936
  const GROK_DENY_EXIT = 2;
937
+ /**
938
+ * The exit code a HERMES deny carries, and why it IS 2 (APRV-398).
939
+ *
940
+ * This is the one place the Muse reasoning does not carry over, and the
941
+ * difference is in Hermes's own code rather than in a preference. Hermes defines
942
+ * a blocking exit code, applies it to `pre_tool_call` and to no other event, and
943
+ * treats it as UNCONDITIONAL: the call is blocked whatever stdout said, and the
944
+ * block message is taken from the stdout directive first, from stderr second,
945
+ * and from a default third. So the body and the exit code are not two accounts
946
+ * of one answer that the harness has to reconcile — the harness states the
947
+ * precedence itself, and the two agree by its own rule.
948
+ *
949
+ * That makes 2 strictly stronger than 0 here, and it closes the one gap
950
+ * `fail_closed` leaves open. `fail_closed: true` blocks on a spawn error, a
951
+ * timeout, and stdout that is non-empty and not a JSON object; it does NOT block
952
+ * a hook that exits non-zero having printed nothing, because its condition
953
+ * requires a non-empty stdout. Exiting 2 means every refusal this adapter
954
+ * reaches blocks on the exit code alone, including the ones where the body never
955
+ * made it out.
956
+ *
957
+ * Grok's 2 and this 2 are therefore the same number for opposite reasons, and it
958
+ * is worth not conflating them: there, exit 2 is the ONLY signal Grok reads and
959
+ * the body is decoration. Here the body is the message and the exit code is the
960
+ * floor under it.
961
+ */
962
+ const HERMES_DENY_EXIT = 2;
564
963
  function deny(streams, code, detail, harness) {
565
964
  streams.out(decision("deny", `${code}: ${detail}`, harness));
566
- return harness === "grok" ? GROK_DENY_EXIT : EXIT_OK;
965
+ if (harness === "grok")
966
+ return GROK_DENY_EXIT;
967
+ if (harness === "hermes")
968
+ return HERMES_DENY_EXIT;
969
+ return EXIT_OK;
970
+ }
971
+ /**
972
+ * The block directive a harness reads, as bytes, for a refusal raised OUTSIDE
973
+ * the hook (APRV-421).
974
+ *
975
+ * `approval serve` can refuse a hook request before `commandHook` is reached
976
+ * at all: an oversized body, a credential at the wrong door, a method it does
977
+ * not answer. Each of those is a refusal by the transport, and a transport
978
+ * refusal a harness reads as "no verdict" is a harness that runs the command.
979
+ * So the refusal has to be spoken in the harness's own dialect, and the only
980
+ * correct source of that dialect is the function that prints it here.
981
+ *
982
+ * It renders through {@link deny} for that reason: one construction site per
983
+ * harness, still never `ask`. The exit code returned is the one the verb would
984
+ * have carried; a caller may impose a stricter one, and the transport does.
985
+ */
986
+ export function harnessBlockDirective(code, detail, harness) {
987
+ const out = [];
988
+ const exitCode = deny({ out: (text) => out.push(text), err: () => undefined }, code, detail, harness);
989
+ return { stdout: out.join(""), exitCode };
990
+ }
991
+ /** The machine-readable code a Hermes `execute_code` call is refused with. */
992
+ export const HERMES_EXECUTE_CODE_REFUSAL = "hook-hermes-execute-code-unbound";
993
+ /** The tool that carries a program and no path, no argv and no directory. */
994
+ const HERMES_EXECUTE_CODE_TOOL = "execute_code";
995
+ /**
996
+ * The retry a Hermes session makes after one of these refusals, in one place.
997
+ *
998
+ * It is the whole point of refusing rather than falling back: a session that is
999
+ * told what to send next stays usable, and the live probe watched the model
1000
+ * answer a block by trying the SAME effect through another tool and another
1001
+ * directory. A refusal that did not name the repair would get that behaviour
1002
+ * again, one tool along.
1003
+ */
1004
+ const HERMES_ABSOLUTE_RETRY = "spell the directory in full from `/` and send the call again; the classifier then reads the same path the harness will write";
1005
+ /**
1006
+ * A Hermes call whose effective directory this hook cannot see, as the reason it
1007
+ * is refused, or `null` when the call binds its own directory (APRV-415).
1008
+ *
1009
+ * ## The fact, observed rather than reasoned
1010
+ *
1011
+ * The live probe of APRV-398 (Hermes `main` at `118984d7`, 60 envelopes) settled
1012
+ * three things that together make this refusal necessary:
1013
+ *
1014
+ * 1. the envelope's `cwd` is the Hermes PROCESS directory (`Path.cwd()` in its
1015
+ * payload builder), which is not where the work happens. A session launched in
1016
+ * the scratch project wrote into `$HERMES_HOME/cache/scratch`, and a Telegram
1017
+ * gateway session's `cwd` was the USER HOME;
1018
+ * 2. `terminal` keeps a PER-SESSION recorded working directory that a `cd` in an
1019
+ * earlier call moves, and no field of the event reports it. The model passed
1020
+ * no `workdir` at all on the probe's shell call;
1021
+ * 3. all four file tools resolve a RELATIVE path against that same unreported
1022
+ * directory (`tools/file_tools_paths.py`, `_resolve_path_for_task`).
1023
+ *
1024
+ * So a call that does not state an absolute directory is the APRV-310 shape
1025
+ * across every tool on this harness: the verdict would bind bytes in one
1026
+ * directory and the harness would act in another. That is not a narrower answer
1027
+ * this runtime may reach by guessing, and `describeToolCall`'s fallback to the
1028
+ * event `cwd` — correct on Muse, where `cwd` IS the session root — would bind a
1029
+ * directory that does not even contain the work here.
1030
+ *
1031
+ * ## Why a refusal rather than a fallback, and why it is usable
1032
+ *
1033
+ * SPEC.md §11.1: ambiguity resolves to the stricter path, and a self-reported
1034
+ * field never reduces scrutiny. A relative `workdir` cannot widen anything, and
1035
+ * on this harness it cannot be narrowed into either, because there is no
1036
+ * trustworthy directory to fall back TO. The refusal carries
1037
+ * `hook-unsupported-execution-context`, the same code the Codex `Bash` refusal
1038
+ * carries, because it is the same defect — the harness did not say WHERE — and
1039
+ * the repair differs only in being available: a Hermes session repairs it
1040
+ * itself, in the next call, by naming an absolute path. That is why the reason
1041
+ * names the retry rather than describing the contract.
1042
+ *
1043
+ * The probe also watched what a blocked model does next: it retries the same
1044
+ * effect through another tool or another path. Refusing the shell tool alone
1045
+ * would have moved the work into `write_file`, which is exactly why every gated
1046
+ * tool is covered here and why an ABSENT path is refused beside a relative one.
1047
+ * A missing `path` on this harness does not mean "the workspace" the way Claude
1048
+ * Code's `Glob` does; it means that same unreported recorded directory.
1049
+ */
1050
+ function hermesUnboundDirectory(input, adapter) {
1051
+ const tool = input.toolName;
1052
+ if (tool === adapter.shellTool) {
1053
+ const key = adapter.shellCwdKey ?? "workdir";
1054
+ const declared = readString(input.toolInput, key);
1055
+ if (declared !== null && isAbsolute(declared))
1056
+ return null;
1057
+ return `Hermes \`${tool}\` is refused because the call ${declared === null
1058
+ ? `states no \`${key}\``
1059
+ : `states a relative \`${key}\` (${JSON.stringify(declared)})`}, and this harness reports the effective directory nowhere else: its \`cwd\` is the Hermes process directory, while the command runs in a per-session directory that an earlier \`cd\` moves and no field of this event carries. A verdict over the command alone would bind bytes in a directory the hook cannot see. Set \`${key}\` to an absolute path — ${HERMES_ABSOLUTE_RETRY}. No policy or open window authorizes bytes whose directory the hook cannot bind`;
1060
+ }
1061
+ if (!adapter.fileTools.includes(tool) && !adapter.readTools.includes(tool))
1062
+ return null;
1063
+ // The same keys `fileToolGate` and `readToolGate` read, in their order, plus
1064
+ // the `paths` array neither Hermes tool sends today: a later release that
1065
+ // added one would otherwise arrive as an unbound call that reads as bound.
1066
+ const declared = readString(input.toolInput, "path") ??
1067
+ readString(input.toolInput, "file_path") ??
1068
+ readString(input.toolInput, "notebook_path");
1069
+ const list = input.toolInput["paths"];
1070
+ const entries = Array.isArray(list)
1071
+ ? list.filter((entry) => typeof entry === "string" && entry.trim() !== "")
1072
+ : [];
1073
+ const candidates = declared === null ? entries : [declared, ...entries];
1074
+ if (candidates.length === 0) {
1075
+ return `Hermes \`${tool}\` is refused because the call names no path at all. On this harness an unnamed target is not the workspace: it is the per-session recorded directory that an earlier \`cd\` moves and that no field of this event carries, and a live session's was \`$HERMES_HOME/cache/scratch\` while a gateway session's was the user's home. Name the directory or file with an absolute \`path\` — ${HERMES_ABSOLUTE_RETRY}`;
1076
+ }
1077
+ const relative = candidates.find((candidate) => !isAbsolute(candidate));
1078
+ if (relative === undefined)
1079
+ return null;
1080
+ return `Hermes \`${tool}\` is refused because the path it names is relative (${JSON.stringify(relative)}), and Hermes resolves a relative path against the per-session recorded directory that an earlier \`cd\` moves, which this event does not carry: its \`cwd\` is the Hermes process directory. The verdict and the write would name different files. Send the same call with an absolute path — ${HERMES_ABSOLUTE_RETRY}. No policy or open window authorizes a path the hook cannot place`;
567
1081
  }
568
1082
  /** The machine-readable code a Contributor-tier session is refused with. */
569
1083
  export const MUSE_CONTRIBUTOR_REFUSAL = "hook-muse-contributor-model";
@@ -1276,13 +1790,228 @@ export function refineReadScope(result, roots, cwd) {
1276
1790
  }
1277
1791
  return { result: { ok: true, segments, classes }, notes };
1278
1792
  }
1793
+ // ===========================================================================
1794
+ // Write scope, the disk half (APRV-402)
1795
+ // ===========================================================================
1796
+ /**
1797
+ * ## Why writes need a second pass too
1798
+ *
1799
+ * Deletes and reads each had one and writes did not, which left the oldest of
1800
+ * the three rules the loosest. `files.write.workspace` was decided on the text
1801
+ * alone: a relative destination is the workspace, said the classifier, and a
1802
+ * relative destination is the workspace only while nothing on the way to it is
1803
+ * a symlink out. `cp x build/y`, `tee build/y`, `mkdir build/y`, and since
1804
+ * APRV-397 `tar -x -C build` and `npm pack --pack-destination build`, were all
1805
+ * autonomous with `build` pointing anywhere at all.
1806
+ *
1807
+ * Not a regression and not new with the packaging rows (APRV-402): the hole is
1808
+ * as old as the workspace-write row, and before APRV-397 every packaging
1809
+ * command was denied as unclassified, so nothing that was refused became
1810
+ * allowed by a symlink. What changed is that the rows now say `workspace` often
1811
+ * enough for the gap to be worth the disk.
1812
+ *
1813
+ * ## The shape is the other two passes' shape
1814
+ *
1815
+ * Resolve the nearest EXISTING ancestor of each destination, re-append the
1816
+ * unresolved tail, and TIGHTEN when the result is outside every root. It can
1817
+ * only ever move a segment toward the stricter class, so a caller that skipped
1818
+ * it is no more permissive than one that runs it, which is what lets
1819
+ * `hook classify` and `hook <harness>` share it without either becoming the
1820
+ * authority.
1821
+ *
1822
+ * ## What it costs, measured before it was written (APRV-402 AC1)
1823
+ *
1824
+ * The walk is 0.10 ms to 0.13 ms per destination on a warm cache (0.07 ms for
1825
+ * an absolute one), over 20k iterations of each shape. A write segment names
1826
+ * one or two destinations, so a write command pays about a fifth of a
1827
+ * millisecond. APRV-209 measured a cold gated invocation at about 371 ms, of
1828
+ * which 116 ms is the module graph and 51 ms is `hook.js` alone; this is under
1829
+ * half a percent of that last term and it is paid only by segments the
1830
+ * classifier already called a workspace write. The delete and read passes pay
1831
+ * the same price per target and have since APRV-267 and APRV-347.
1832
+ */
1833
+ /** The class an out-of-scope write tightens to, as `core/command-class.ts` spells it. */
1834
+ const OUT_OF_SCOPE_WRITE_CLASS = "files.delete.out_of_scope";
1835
+ /** The rule a write tightened by this pass reports. */
1836
+ const WRITE_SCOPE_REJECTED_RULE = "write-out-of-scope-resolved";
1837
+ /** The class this pass re-reads, and the only one it will touch. */
1838
+ const WORKSPACE_WRITE = "files.write.workspace";
1839
+ /**
1840
+ * The classifier rules whose destinations this pass re-reads.
1841
+ *
1842
+ * Exactly the rows APRV-402 names: the workspace-write row (`mkdir`, `cp`,
1843
+ * `mv`, `touch`, `tee`, `ln`, `chmod`, `truncate`, `rmdir`) and the six
1844
+ * packaging answers APRV-397 added. `rm-workspace` and `redirect-write` reach
1845
+ * the same class by the same text-only reasoning and are deliberately NOT here:
1846
+ * they are the same hole in two more rules and they are their own decision, not
1847
+ * one to take inside this task's diff.
1848
+ */
1849
+ const WRITE_SCOPE_RULES = [
1850
+ "workspace-write",
1851
+ "tar-extract",
1852
+ "tar-create",
1853
+ "gunzip-write",
1854
+ "base64-write",
1855
+ "openssl-digest-out",
1856
+ "npm-pack",
1857
+ ];
1858
+ /**
1859
+ * The roots a write may land in, resolved.
1860
+ *
1861
+ * The scratch roots are the ones `resolveScratchRoots` already computes and
1862
+ * already guards, so this rule and the delete rule cannot disagree about where
1863
+ * the agent's own scratch is. The working directory is the other one, and it is
1864
+ * the root the classifier's own text rule already implies: "a relative
1865
+ * destination is the workspace" means the workspace is wherever the command
1866
+ * runs.
1867
+ *
1868
+ * `cwd` is harness-supplied, and reading a root out of it does not offend
1869
+ * SPEC.md §11.1 invariant 4, because this pass only ever narrows. The baseline
1870
+ * it narrows from is no check at all, so a poisoned `cwd` buys back exactly
1871
+ * today's answer and never a looser one. Compare the read pass, which checks
1872
+ * containment against the GATE root: that pass decides whether a read is in
1873
+ * scope at all, so the scope has to be one the subject cannot choose. This one
1874
+ * only takes an allowance away.
1875
+ */
1876
+ export function resolveWriteRoots(cwd) {
1877
+ const roots = resolveScratchRoots(cwd);
1878
+ const here = resolvedPath(cwd);
1879
+ if (here !== null && !roots.includes(here))
1880
+ roots.unshift(here);
1881
+ return roots;
1882
+ }
1883
+ /**
1884
+ * Where this destination really is, or `null` when nothing can say.
1885
+ *
1886
+ * The same walk `targetStaysInScratch` and `resolvedReadTarget` do, for the
1887
+ * same reason: the destination usually does NOT exist yet, so the nearest
1888
+ * existing ancestor is resolved and the unresolved tail re-appended. A symlink
1889
+ * anywhere in that chain therefore cannot smuggle a write out of the roots,
1890
+ * which is the escape the pure half cannot see.
1891
+ */
1892
+ function resolvedWriteTarget(target, cwd) {
1893
+ let existing = isAbsolute(target) ? target : resolvePathSegments(cwd, target);
1894
+ const tail = [];
1895
+ for (let depth = 0; depth < 64; depth += 1) {
1896
+ if (existsSync(existing))
1897
+ break;
1898
+ const up = dirname(existing);
1899
+ if (up === existing)
1900
+ return null;
1901
+ tail.unshift(basename(existing));
1902
+ existing = up;
1903
+ }
1904
+ const resolved = resolvedPath(existing);
1905
+ if (resolved === null)
1906
+ return null;
1907
+ return tail.length === 0 ? resolved : join(resolved, ...tail);
1908
+ }
1909
+ /**
1910
+ * The words of a write segment that could name a path on disk.
1911
+ *
1912
+ * Every argument that is not spelled as a flag, plus the value half of
1913
+ * `--opt=value`. This is the delete pass's own filter, and it is deliberately
1914
+ * coarser than the classifier's flag table: it reaches `-C dir`, `-o file` and
1915
+ * `--pack-destination dir` without this file holding a second copy of that
1916
+ * table to drift against. It over-includes (a `tar` archive being READ is a
1917
+ * candidate too, and so is `chmod`'s mode), and over-including can only tighten
1918
+ * a segment that would otherwise have been waved through.
1919
+ *
1920
+ * A bare `-` is not a path: it is stdin or stdout, and every binary in these
1921
+ * rows that accepts it means the stream.
1922
+ */
1923
+ function writeCandidates(args) {
1924
+ const candidates = [];
1925
+ for (const arg of args) {
1926
+ if (arg === "-")
1927
+ continue;
1928
+ if (!arg.startsWith("-")) {
1929
+ candidates.push(arg);
1930
+ continue;
1931
+ }
1932
+ const equals = arg.indexOf("=");
1933
+ if (equals === -1)
1934
+ continue;
1935
+ const value = arg.slice(equals + 1);
1936
+ if (value.length > 0)
1937
+ candidates.push(value);
1938
+ }
1939
+ return candidates;
1940
+ }
1941
+ /**
1942
+ * Tighten a `files.write.workspace` segment to the out-of-scope write class
1943
+ * wherever the disk disagrees with the text.
1944
+ *
1945
+ * IMPURE by design and by contract, exactly as the other two passes are. The
1946
+ * class it tightens to is the one `core/command-class.ts` already answers for
1947
+ * an out-of-scope packaging destination rather than a new name: a class no
1948
+ * policy mentions resolves by `defaults.autonomy` (SPEC.md §7), so minting
1949
+ * `files.write.out_of_scope` here would arrive AUTONOMOUS in every deployment
1950
+ * with permissive defaults, which is the one way a tightening pass could
1951
+ * loosen something.
1952
+ *
1953
+ * `roots` empty means the caller asked for no write scoping, and every segment
1954
+ * comes back untouched: the same "absent yields today's answer" the read pass
1955
+ * promises.
1956
+ */
1957
+ export function refineWriteScope(result, roots, cwd) {
1958
+ if (!result.ok)
1959
+ return { result, notes: [] };
1960
+ if (roots.length === 0)
1961
+ return { result, notes: [] };
1962
+ if (!result.segments.some((segment) => segment.class === WORKSPACE_WRITE && WRITE_SCOPE_RULES.includes(segment.rule))) {
1963
+ return { result, notes: [] };
1964
+ }
1965
+ const notes = [];
1966
+ const segments = result.segments.map((segment) => {
1967
+ if (segment.class !== WORKSPACE_WRITE)
1968
+ return segment;
1969
+ if (!WRITE_SCOPE_RULES.includes(segment.rule))
1970
+ return segment;
1971
+ const words = commandSegmentWords(segment.text);
1972
+ const parsed = words === null ? undefined : words[0];
1973
+ // The classifier read this segment a moment ago, so a parse that disagrees
1974
+ // here is two reads of the same bytes disagreeing. Fail closed.
1975
+ if (parsed === undefined) {
1976
+ notes.push(`${WRITE_SCOPE_REJECTED_RULE}: \`${segment.text}\` could not be re-read, so it is ${OUT_OF_SCOPE_WRITE_CLASS}`);
1977
+ return { ...segment, class: OUT_OF_SCOPE_WRITE_CLASS, rule: WRITE_SCOPE_REJECTED_RULE };
1978
+ }
1979
+ const reject = (path, detail) => {
1980
+ notes.push(`${WRITE_SCOPE_REJECTED_RULE}: ${detail}`);
1981
+ return {
1982
+ ...segment,
1983
+ class: OUT_OF_SCOPE_WRITE_CLASS,
1984
+ rule: WRITE_SCOPE_REJECTED_RULE,
1985
+ path,
1986
+ };
1987
+ };
1988
+ for (const candidate of writeCandidates(parsed.args)) {
1989
+ const resolved = resolvedWriteTarget(candidate, cwd);
1990
+ if (resolved === null) {
1991
+ return reject(candidate, `${candidate} does not resolve to any path this hook can see, so \`${segment.text}\` is ${OUT_OF_SCOPE_WRITE_CLASS}`);
1992
+ }
1993
+ if (!roots.some((root) => resolved === root || isBelow(resolved, root))) {
1994
+ return reject(resolved, `${candidate} resolves to ${resolved}, which is outside the workspace and every scratch root (${roots.join(", ")}), so \`${segment.text}\` is ${OUT_OF_SCOPE_WRITE_CLASS}`);
1995
+ }
1996
+ }
1997
+ return segment;
1998
+ });
1999
+ if (notes.length === 0)
2000
+ return { result, notes };
2001
+ const classes = [];
2002
+ for (const segment of segments) {
2003
+ if (!classes.includes(segment.class))
2004
+ classes.push(segment.class);
2005
+ }
2006
+ return { result: { ok: true, segments, classes }, notes };
2007
+ }
1279
2008
  /**
1280
- * The classifier, its context, and all three impure refinements, in the one
2009
+ * The classifier, its context, and all four impure refinements, in the one
1281
2010
  * order every caller must use.
1282
2011
  *
1283
2012
  * `hook classify` printing a different class from the one `hook claude-code`
1284
2013
  * decides would make the explainer a different program (APRV-108's note), and
1285
- * that stays true now there are three refinements in the chain.
2014
+ * that stays true now there are four refinements in the chain.
1286
2015
  *
1287
2016
  * `readRoots` is the one argument whose ABSENCE is the loose answer rather than
1288
2017
  * the strict one (APRV-347), so it is passed explicitly at every call site: an
@@ -1299,9 +2028,15 @@ export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
1299
2028
  const rewritten = refineRewrite(classified, cwd);
1300
2029
  const scratched = refineScratchDelete(rewritten.result, roots);
1301
2030
  const scoped = refineReadScope(scratched.result, readRoots, cwd);
2031
+ // APRV-402. Its roots are not `readRoots`: a read scope is a read notion and
2032
+ // must not become a write authorization, so this pass asks its own question
2033
+ // of the working directory and the scratch roots. Last in the chain because
2034
+ // it is the narrowest, and because nothing after it would re-read a segment
2035
+ // it has already tightened.
2036
+ const written = refineWriteScope(scoped.result, resolveWriteRoots(cwd), cwd);
1302
2037
  return {
1303
- result: scoped.result,
1304
- notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes],
2038
+ result: written.result,
2039
+ notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes, ...written.notes],
1305
2040
  };
1306
2041
  }
1307
2042
  // ===========================================================================
@@ -1780,6 +2515,33 @@ function readToolGate(toolName, toolInput, roots, cwd) {
1780
2515
  summary: `${toolName} ${file} (outside the read scope)`,
1781
2516
  };
1782
2517
  }
2518
+ /**
2519
+ * A read tool call naming a credential file (APRV-445 refutation, B2): the
2520
+ * Hermes home's `.env` and `auth.json`, and the approval home's vault, keys and
2521
+ * environment map. `null` for anything else.
2522
+ */
2523
+ function credentialReadGate(toolName, toolInput, cwd) {
2524
+ const declared = readString(toolInput, "file_path") ??
2525
+ readString(toolInput, "notebook_path") ??
2526
+ readString(toolInput, "path");
2527
+ if (declared === null)
2528
+ return null;
2529
+ // APRV-445 recheck SF2(e, f): judged through realpath, so a symlinked
2530
+ // parent cannot launder the path, and a directory of credentials handed to a
2531
+ // read tool (a search, a glob, a grep) is gated as the credentials are.
2532
+ const file = canonicalPath(absolute(declared, cwd));
2533
+ // Recheck 3 (SF1a): only a directory that holds credentials (a home root, an
2534
+ // approval home, a home's `approval/`, or a directory under a home holding a
2535
+ // credential file); `skills/`, `workspace/` and the like are ordinary.
2536
+ if (!isCredentialPath(file) && !holdsCredentials(file))
2537
+ return null;
2538
+ const input = { ...toolInput };
2539
+ delete input["description"];
2540
+ return {
2541
+ payload: { tool: toolName, rule: "credential", file, input },
2542
+ summary: `${toolName} ${file} (credential)`,
2543
+ };
2544
+ }
1783
2545
  /** The verdict note for a gated read: what was asked for, and against what. */
1784
2546
  function readScopeNote(gated, roots) {
1785
2547
  return `read-scope: ${gated.declared} resolves to ${gated.file}, which is outside the read scope (${renderReadRoots(roots)})`;
@@ -1911,6 +2673,12 @@ function abandonedRequests(run, records, now, keepHash) {
1911
2673
  continue;
1912
2674
  if (record.actor !== run.actor)
1913
2675
  continue;
2676
+ // APRV-445. Under `approval serve` the hook and `propose` act as ONE actor,
2677
+ // and a proposal's request is harness-executed too, so without this every
2678
+ // proposal pending past the hook's wait plus grace (minutes) would be swept
2679
+ // as abandoned by the next gated tool call, days before its TTL.
2680
+ if (isProposalTask(record.task))
2681
+ continue;
1914
2682
  const key = record.action_key;
1915
2683
  if (typeof key !== "string" || key.length === 0)
1916
2684
  continue;
@@ -2185,7 +2953,7 @@ function recordUnattended(run, task, classes, hash) {
2185
2953
  * both serves the channels and consumes the taps. That is worth saying: on
2186
2954
  * 2026-09-05 taps piled up unconsumed while hooks waited out their windows.
2187
2955
  */
2188
- function announceWait(streams, run, waiting) {
2956
+ function announceWait(streams, run, waiting, expiryOf) {
2189
2957
  const where = run.channels.length === 0
2190
2958
  ? "no channel (this policy configures none, so nothing is delivering the question)"
2191
2959
  : `channel ${run.channels.join(", ")}`;
@@ -2193,7 +2961,21 @@ function announceWait(streams, run, waiting) {
2193
2961
  const adopted = action.origin === "adopted"
2194
2962
  ? " The question was already open for these exact bytes, so this tool call adopts it rather than asking a second time."
2195
2963
  : "";
2196
- streams.err(`approval: ${action.actionKey} (${action.cls}) is waiting for a human on ${where}; a decision on the phone releases it, and this hook blocks for up to ${String(run.timeoutMs)}ms before denying with hook-timeout and leaving the request open for a ${minutesText(run.graceMs)} retry grace.${adopted}\n`);
2964
+ const at = expiryOf.get(action.actionKey);
2965
+ // Second review pass (F7): which of the two ends first decides the sentence.
2966
+ // "Open for a 5m grace" beside "expires at T+240s" was two claims about one
2967
+ // question that could not both hold; the shorter one is the one stated.
2968
+ const holdsOpen = at !== undefined && windowEndsBeforeGrace(run)
2969
+ ? `. The request expires at ${at}, which comes before the ${minutesText(run.graceMs)} retry grace would run out, so that is how long it stays open for a retry; a decision after it is refused rather than granted.`
2970
+ : ` and leaving the request open for a ${minutesText(run.graceMs)} retry grace.${expiryClause(at)}`;
2971
+ streams.err(`approval: ${action.actionKey} (${action.cls}) is waiting for a human on ${where}; a decision on the phone releases it, and this hook blocks for up to ${waitText(run)} before denying with hook-timeout${holdsOpen}${adopted}\n`);
2972
+ }
2973
+ // APRV-423, third review pass. The wait is CLAMPED to what the ceiling
2974
+ // leaves (see `HookRun.waitMs`), so the runtime acts rather than warns: a
2975
+ // wait the harness would have cut short is shortened here, and the operator
2976
+ // is told once so the configured --timeout is not mistaken for the wait.
2977
+ if (run.harnessCapMs !== null && run.waitMs < run.timeoutMs) {
2978
+ streams.err(`approval: this hook's ${String(run.timeoutMs)}ms --timeout is longer than the ${String(run.harnessCapMs)}ms harness ceiling leaves after the ${String(HARNESS_CAP_MARGIN_MS)}ms margin, so it waits ${String(run.waitMs)}ms instead and answers before the harness stops listening. Lower --timeout, or state the real ceiling with --harness-cap.\n`);
2197
2979
  }
2198
2980
  const socket = drawSocketPathFor(run.logPath);
2199
2981
  const listener = drawSocketUsable(socket);
@@ -2201,6 +2983,55 @@ function announceWait(streams, run, waiting) {
2201
2983
  return;
2202
2984
  streams.err(`approval: no listener is running for this log (${listener.reason}: ${socket}), so the request above may sit undelivered and a decision may go unconsumed. Start the gate's ambient runtime in the checkout that owns this log: \`eval "$(approval env)" && approval up\`, which runs the daemon loop and every configured channel in one process.\n`);
2203
2985
  }
2986
+ /**
2987
+ * ` The request expires at <instant> ...`, or `""` when nothing bounds it
2988
+ * (APRV-423).
2989
+ *
2990
+ * One phrasing for the three places that state the deadline — the announce
2991
+ * line, the timeout deny and the expiry deny — so an agent reading two of them
2992
+ * in one session is reading the same sentence about the same moment.
2993
+ */
2994
+ function expiryClause(at) {
2995
+ if (at === undefined)
2996
+ return "";
2997
+ return ` The request expires at ${at}, after which a decision on it is refused rather than granted.`;
2998
+ }
2999
+ /**
3000
+ * Does the window a capped request is judged by close before the retry grace
3001
+ * this invocation would hold the question open for? (APRV-423, second review
3002
+ * pass.)
3003
+ *
3004
+ * Both are durations measured from the `approval.requested` record's own `ts`
3005
+ * (`harnessCappedTtlMs` for the window, `abandonedAfterMs` for the grace), so
3006
+ * the comparison needs no clock. `false` for an uncapped run whatever the
3007
+ * policy's TTL: the uncapped sentences are left byte-for-byte as APRV-287 wrote
3008
+ * them, which `tests/cli-hook.test.ts`'s snapshot case depends on. Computed from
3009
+ * this invocation's own flags, so for an ADOPTED question opened under other
3010
+ * flags it is the current invocation's reading of the two windows.
3011
+ */
3012
+ /**
3013
+ * `55000ms wait`, or `0ms wait (a 240000ms --timeout clamped to what the 30000ms
3014
+ * harness ceiling leaves)` when the ceiling shortened it (APRV-423, third
3015
+ * review pass). One spelling for the announce line, the lagging note and the
3016
+ * `hook-timeout` denies, so a reader is never told a wait this process did not
3017
+ * make.
3018
+ */
3019
+ function waitText(run) {
3020
+ if (run.waitMs === run.timeoutMs || run.harnessCapMs === null) {
3021
+ return `${String(run.waitMs)}ms wait`;
3022
+ }
3023
+ return `${String(run.waitMs)}ms wait (a ${String(run.timeoutMs)}ms --timeout clamped to what the ${String(run.harnessCapMs)}ms harness ceiling leaves)`;
3024
+ }
3025
+ function windowEndsBeforeGrace(run) {
3026
+ if (run.harnessCapMs === null)
3027
+ return false;
3028
+ const window = harnessCappedTtlMs(run.ttlMs, run.harnessCapMs);
3029
+ return window !== null && window <= abandonedAfterMs(run.timeoutMs, run.graceMs);
3030
+ }
3031
+ /** ` (expired at <instant>)`, or `""`. The parenthetical form of the above. */
3032
+ function expiryAt(at) {
3033
+ return at === undefined ? "" : ` (expired at ${at})`;
3034
+ }
2204
3035
  /**
2205
3036
  * The hook's own rendering of a verdict: one decision object on stdout, in the
2206
3037
  * dialect of the harness that asked.
@@ -2299,7 +3130,12 @@ floor = null) {
2299
3130
  streams.err(`approval: withdrew ${String(swept.length)} abandoned harness request(s) nothing retried (${swept.join(", ")}); a tap on one of them now authorizes nothing and the channel says so\n`);
2300
3131
  }
2301
3132
  const actions = classes.map((cls) => {
2302
- const carry = findHarnessCarry(intake.records, hash, cls, intakeTs, run.ttlMs);
3133
+ // APRV-410: the window this invocation is configured for bounds which
3134
+ // GRANT it may carry, so an answer that landed after the asking process
3135
+ // was gone is not adopted by a later one. The sweep above takes back the
3136
+ // pending questions; this refuses the grants that were written on questions
3137
+ // nobody was holding. The two halves are the same fact from both sides.
3138
+ const carry = findHarnessCarry(intake.records, hash, cls, intakeTs, run.ttlMs, abandonedAfterMs(run.timeoutMs, run.graceMs));
2303
3139
  if (carry === null)
2304
3140
  return { cls, actionKey: `${task}:${cls}`, origin: "new" };
2305
3141
  return {
@@ -2311,6 +3147,62 @@ floor = null) {
2311
3147
  const fresh = actions.filter((action) => action.origin === "new");
2312
3148
  const adopted = actions.filter((action) => action.origin === "adopted");
2313
3149
  const carried = actions.filter((action) => action.origin === "carried");
3150
+ /**
3151
+ * The window every request this invocation opens will be judged by
3152
+ * (APRV-423): the policy's TTL, narrowed by the harness ceiling this process
3153
+ * runs under. `null` means nothing bounds it, which is a policy with no
3154
+ * `approval_ttl` under a harness that documents no maximum and an operator
3155
+ * who stated none.
3156
+ */
3157
+ const ownTtlMs = harnessCappedTtlMs(run.ttlMs, run.harnessCapMs);
3158
+ /**
3159
+ * When each waited-on key lapses, ISO-8601, for the lines this invocation
3160
+ * prints. Filled from the runtime's OWN write-boundary timestamps — the `ts`
3161
+ * on the record `request` returned, and the request record intake already
3162
+ * read for an adopted key — so a deny that names a deadline names the one the
3163
+ * gate will judge by, and no second read of the log pays for the sentence.
3164
+ *
3165
+ * CAPPED REQUESTS ONLY (APRV-423). The sentence exists to state a deadline
3166
+ * its reader cannot work out from the policy: under a harness ceiling the
3167
+ * window is neither `approval_ttl` nor the harness's own timeout, and an
3168
+ * agent told "it lapsed" with no instant has no way to tell a four-minute
3169
+ * window from the hour its policy declares. A request the policy alone bounds
3170
+ * already has its deadline stated where its reader can see it (the approver's
3171
+ * own card, `approval status`, the queue), so the hook's lines are left
3172
+ * exactly as APRV-281 and APRV-287 wrote them — which also keeps every
3173
+ * uncapped deny byte-for-byte reproducible across two invocations, the
3174
+ * property `tests/cli-hook.test.ts` pins when it compares a cold read's
3175
+ * verdict with a snapshot-resumed one.
3176
+ */
3177
+ const expiryOf = new Map();
3178
+ // APRV-423, and the last thing checked before this invocation opens a
3179
+ // question. A ceiling at or below the margin leaves no window: the request
3180
+ // would have to lapse before the harness kills this process, and there is no
3181
+ // "before" left. Refused rather than widened, because a hook that ignored its
3182
+ // own ceiling is a hook whose question outlives its asker, which is the whole
3183
+ // failure this cap exists to end (APRV-410). Placed here rather than at the
3184
+ // top of the function so it refuses exactly the act it is about: an ADOPTED
3185
+ // question was opened by a process that cleared this check, and a CARRIED
3186
+ // grant is a human's answer that already exists.
3187
+ if (fresh.length > 0 && run.harnessCapMs !== null && !harnessCapFitsMargin(run.harnessCapMs)) {
3188
+ // Second review pass: the deny names the actions it refused for (so a reader
3189
+ // can still see which class routed this call to a human), says whether the
3190
+ // ceiling was stated or assumed, and ends with the harness's own repair
3191
+ // where the adapter has one.
3192
+ const needing = fresh.map((action) => `${action.actionKey} (${action.cls})`).join(", ");
3193
+ const origin = run.harnessCapStated
3194
+ ? "stated with --harness-cap"
3195
+ : "assumed from the harness's documented defaults because no --harness-cap was passed";
3196
+ // APRV-427 review: where time spent before this invocation was charged,
3197
+ // the operator's number is named as theirs and the remainder as what is
3198
+ // left of it, rather than reporting the remainder as what they stated.
3199
+ const source = run.harnessCapCeilingMs === null
3200
+ ? origin
3201
+ : `~${String(run.harnessCapMs)}ms left of the ${String(run.harnessCapCeilingMs)}ms ceiling ${origin}; the rest was spent before this hook began, waiting in approval serve's queue or for its store lock`;
3202
+ const repair = run.harnessCapRepair ??
3203
+ `Raise the harness entry's timeout past ${String(HARNESS_CAP_MARGIN_MS)}ms and state the real one with --harness-cap, or route this class somewhere a human is not on the critical path.`;
3204
+ return sayDeny("hook-harness-cap-too-short", `${needing} would need a human, but this hook runs under a ${String(run.harnessCapMs)}ms harness ceiling (${source}), and a request must lapse at least ${String(HARNESS_CAP_MARGIN_MS)}ms before that ceiling so the runtime records the expiry while the harness is still listening. That leaves no window in which a human could answer, so nothing was registered, nothing was requested and no question reached a phone. ${repair}`);
3205
+ }
2314
3206
  // Only the classes that need a new question are registered. A retry whose
2315
3207
  // every class carries or adopts registers no task at all — the envelope it
2316
3208
  // would declare already exists, under the key it is about to wait on.
@@ -2359,6 +3251,12 @@ floor = null) {
2359
3251
  payload_hash: hash,
2360
3252
  payload: { value: payload },
2361
3253
  execution: "harness",
3254
+ // APRV-423. The ceiling this process runs under, recorded so the gate,
3255
+ // the daemon's sweep and every channel judge the question by a window
3256
+ // that ends before the harness kills the asker. Omitted entirely when
3257
+ // there is no ceiling to state: an absent field is the pre-APRV-423
3258
+ // shape, and the policy's TTL governs alone.
3259
+ ...(run.harnessCapMs === null ? {} : { harnessCapMs: run.harnessCapMs }),
2362
3260
  ...(floorApplies(action.cls) ? { loopFloor: true } : {}),
2363
3261
  }, run.actor, run.options);
2364
3262
  if (!result.ok) {
@@ -2368,8 +3266,31 @@ floor = null) {
2368
3266
  withdrawPending(run, streams, ownKeys, `intake refused ${action.actionKey}; this command cannot proceed, so the classes already opened for it are questions nobody needs to answer`);
2369
3267
  return sayDeny(`hook-gate-refused:${result.code}`, result.message);
2370
3268
  }
2371
- if (result.record !== null)
3269
+ if (result.record !== null) {
2372
3270
  ownKeys.push(action.actionKey);
3271
+ // The deadline in words, from the `ts` the runtime stamped on the record
3272
+ // it just wrote. Never from this process's own clock: a gate-typed event
3273
+ // carries the write boundary's instant (SPEC.md §11.1 invariant 2), and a
3274
+ // deny that named a deadline measured from anything else would be naming
3275
+ // a moment the gate does not judge by.
3276
+ if (ownTtlMs !== null && run.harnessCapMs !== null) {
3277
+ const lapses = Date.parse(result.record.ts);
3278
+ if (!Number.isNaN(lapses)) {
3279
+ expiryOf.set(action.actionKey, new Date(lapses + ownTtlMs).toISOString());
3280
+ }
3281
+ }
3282
+ }
3283
+ }
3284
+ // An adopted question was opened by an earlier invocation, under whatever
3285
+ // ceiling THAT one ran under, so its deadline is read off the request record
3286
+ // intake already verified rather than recomputed from this process's flags.
3287
+ for (const action of adopted) {
3288
+ const derivation = requestState(intake.records, action.actionKey, intakeTs, run.ttlMs);
3289
+ if (derivation.declared.harness_cap_ms === null)
3290
+ continue;
3291
+ const at = requestExpiresAt(derivation);
3292
+ if (at !== null)
3293
+ expiryOf.set(action.actionKey, at);
2373
3294
  }
2374
3295
  /** Every key that must be granted before this hook says yes. */
2375
3296
  const waitKeys = [...adopted.map((action) => action.actionKey), ...ownKeys];
@@ -2409,21 +3330,50 @@ floor = null) {
2409
3330
  // an earlier tool call, which wait in the same silence and were the case the
2410
3331
  // announce would most easily have missed. A carried grant is not here because
2411
3332
  // nothing is waiting on it.
2412
- announceWait(streams, run, actions.filter((action) => waitKeys.includes(action.actionKey)));
2413
- const deadline = Date.now() + run.timeoutMs;
3333
+ announceWait(streams, run, actions.filter((action) => waitKeys.includes(action.actionKey)), expiryOf);
3334
+ const deadline = Date.now() + run.waitMs;
3335
+ // APRV-427. From the first poll to the last this invocation only READS the
3336
+ // verified log and sleeps, so an embedding caller (`approval serve`) may let
3337
+ // other work on the same store run meanwhile. `leaveWait` hands the stretch
3338
+ // back before anything below appends, and once more whichever way the loop
3339
+ // is left; it is a no-op from the CLI, where there is no seam.
3340
+ let waiting = false;
3341
+ const leaveWait = () => {
3342
+ if (!waiting)
3343
+ return;
3344
+ waiting = false;
3345
+ run.waitSeam?.leaveWait();
3346
+ };
2414
3347
  // A signal arriving mid-wait means the session is going away: nothing will
2415
3348
  // retry this command, so the question this invocation opened is retracted.
2416
3349
  // `process.exit` is deliberate and immediate: the default disposition for
2417
3350
  // these signals is to die, and a handler that only withdrew would leave the
2418
3351
  // hook wedged in its poll loop with the harness waiting on it.
2419
3352
  const onSignal = (signal) => {
3353
+ leaveWait();
2420
3354
  withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
3355
+ // APRV-445. On Hermes an exit with nothing on stdout is an ALLOW unless the
3356
+ // exit is 2, and a harness tearing down may still read the verdict. So the
3357
+ // block directive goes out first, written synchronously because
3358
+ // `process.exit` below does not wait for a stream to drain.
3359
+ if (run.harness === "hermes") {
3360
+ const directive = harnessBlockDirective("hook-interrupted", `the hook received ${signal} while waiting for a decision; nothing authorizes this call`, "hermes");
3361
+ try {
3362
+ writeSync(1, directive.stdout);
3363
+ }
3364
+ catch {
3365
+ // stdout is gone; the exit code below is the whole verdict.
3366
+ }
3367
+ process.exit(HERMES_DENY_EXIT);
3368
+ }
2421
3369
  process.exit(EXIT_USAGE);
2422
3370
  };
2423
3371
  const onTerm = () => onSignal("SIGTERM");
2424
3372
  const onInt = () => onSignal("SIGINT");
2425
- process.on("SIGTERM", onTerm);
2426
- process.on("SIGINT", onInt);
3373
+ // Prepended: under Hermes an earlier guard (`hermesFailClosed`) listens for
3374
+ // the same signals, and this handler, which also withdraws, must answer first.
3375
+ process.prependListener("SIGTERM", onTerm);
3376
+ process.prependListener("SIGINT", onInt);
2427
3377
  /**
2428
3378
  * Has this invocation already said, on stderr, that the verified view lags
2429
3379
  * the requests it is waiting on (APRV-294)? Said once per invocation: the
@@ -2431,22 +3381,96 @@ floor = null) {
2431
3381
  * matters under sixty copies of itself.
2432
3382
  */
2433
3383
  let saidLagging = false;
3384
+ /** Consecutive torn-tail reads, for {@link TORN_TAIL_RETRIES}. */
3385
+ let tornReads = 0;
3386
+ /** The last whole verified view this wait read, for a torn read at the deadline. */
3387
+ let lastClean = null;
3388
+ const callerGone = () => run.waitSeam?.cancelled?.() === true;
3389
+ /** The verdict for a caller that went away: nothing spent, nothing withdrawn. */
3390
+ const sayGone = () => sayDeny("hook-timeout", `the caller that asked went away before ${waitKeys.join(", ")} could be answered to it. NOTHING WAS SPENT AND NOTHING WAS WITHDRAWN: the question, or a grant already recorded on it, is left for a retry of this exact command in this exact directory to adopt within the ${minutesText(run.graceMs)} retry grace.`);
3391
+ const pollRead = run.waitSeam?.pollRead ?? ((path) => readVerifiedRecords(path));
3392
+ /**
3393
+ * The deny for a wait that ran out with the question left OPEN, after
3394
+ * `lead` (APRV-427 review: one builder, so every route to it says the same
3395
+ * thing). Two forms, as the second review pass (F7) fixed them: where the
3396
+ * capped window closes before the retry grace would, the sentence names the
3397
+ * expiry as what bounds the question; otherwise it names the grace and adds
3398
+ * the expiry where there is one.
3399
+ */
3400
+ const keptOpen = (lead, stillLagging) => {
3401
+ // APRV-423. The deadline the still-open question is under, named in the
3402
+ // same breath as the wait that ran out: the two are different numbers and
3403
+ // the second one is the one that decides whether a retry can still adopt
3404
+ // this question. Under a harness ceiling it is also the instant the runtime
3405
+ // will append `approval.expired` at, which is what keeps this deny and that
3406
+ // record from disagreeing about one request.
3407
+ const deadlines = waitKeys
3408
+ .map((key) => expiryOf.get(key))
3409
+ .filter((at) => at !== undefined);
3410
+ const expiresAt = [...new Set(deadlines)].sort().join(", ");
3411
+ if (deadlines.length > 0 && windowEndsBeforeGrace(run)) {
3412
+ return sayDeny("hook-timeout", `${lead} This tool call is denied and NOTHING WAS WITHDRAWN: the harness ceiling this hook runs under bounds the question, and the request(s) expire at ${expiresAt}, which comes before the ${minutesText(run.graceMs)} retry grace would run out, so that is how long they stay open. A decision inside that window authorizes a retry of this exact command in this exact directory, once; retry it after the approver answers, and the retry adopts the same question rather than asking a second one. A decision after ${expiresAt} is refused rather than granted, the runtime records the lapse as approval.expired, and a retry then asks the question fresh.${stillLagging}`);
3413
+ }
3414
+ const expiresText = deadlines.length === 0
3415
+ ? ""
3416
+ : ` The harness ceiling this hook runs under bounds the question too: the request(s) expire at ${expiresAt}, and a decision after that is refused rather than granted — the runtime records the lapse as approval.expired.`;
3417
+ return sayDeny("hook-timeout", `${lead} This tool call is denied and NOTHING WAS WITHDRAWN: the request(s) stay open for the ${minutesText(run.graceMs)} retry grace, and a decision inside that window authorizes a retry of this exact command in this exact directory, once.${expiresText} Retry it after the approver answers; the retry adopts the same question rather than asking a second one. Past the grace the question is taken back (approval.withdrawn, reason timeout) BY THE NEXT GATED TOOL CALL THIS ACTOR (${run.actor}) MAKES, which is the only process that may: a withdrawal is the requester's own (APRV-106), so no daemon and no channel can do it for you. Until that next call the request stays pending, and a decision that lands past the grace is recorded as a grant — it authorizes nothing, because a retry past the grace asks again rather than carrying a grant nobody was holding (APRV-410).${stillLagging}`);
3418
+ };
3419
+ run.waitSeam?.enterWait();
3420
+ waiting = true;
2434
3421
  try {
2435
3422
  for (;;) {
2436
- const read = readVerifiedRecords(run.logPath);
3423
+ if (callerGone())
3424
+ return sayGone();
3425
+ let read = pollRead(run.logPath);
3426
+ if (!read.ok && read.code === "log-torn-tail" && tornReads < TORN_TAIL_RETRIES) {
3427
+ if (Date.now() < deadline) {
3428
+ // Another writer's line, caught half-landed. Read again next tick;
3429
+ // see TORN_TAIL_RETRIES. Said once, like the lagging view below.
3430
+ if (tornReads === 0) {
3431
+ streams.err(`approval: the log's last line was incomplete when this hook read it (another writer mid-append); reading again rather than giving up on ${task}\n`);
3432
+ }
3433
+ tornReads += 1;
3434
+ sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
3435
+ continue;
3436
+ }
3437
+ // AT the deadline (APRV-427 review). A clean read at this tick would
3438
+ // take the timeout path and leave the question open for the retry
3439
+ // grace; someone else's half-written line must not turn that into a
3440
+ // withdrawal. So the tick is judged by the last whole view this wait
3441
+ // read, and the timeout path below runs on it. Only a tail that stays
3442
+ // torn past the retries, a write nobody is going to finish, still
3443
+ // withdraws and denies.
3444
+ if (lastClean === null) {
3445
+ return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}, and the log's last line was incomplete when the wait ended (another writer mid-append).`, "");
3446
+ }
3447
+ read = lastClean;
3448
+ }
2437
3449
  if (!read.ok) {
3450
+ leaveWait();
2438
3451
  withdrawPending(run, streams, ownKeys, `the hook could not read the log while waiting on ${task}`);
2439
3452
  return sayDeny("hook-io", read.message);
2440
3453
  }
3454
+ tornReads = 0;
3455
+ lastClean = read;
2441
3456
  const ts = new Date().toISOString();
2442
3457
  // Only the keys this invocation is waiting on count. Deriving the set
2443
3458
  // from the log again would let an empty or foreign result read as
2444
3459
  // "nothing pending" and fall through to allow; the verified log must show
2445
3460
  // every one of these keys granted before the hook says yes.
2446
- const derived = waitKeys.map((key) => ({
2447
- key,
2448
- state: requestState(read.records, key, ts, run.ttlMs).state,
2449
- }));
3461
+ const derived = waitKeys.map((key) => {
3462
+ const derivation = requestState(read.records, key, ts, run.ttlMs);
3463
+ // APRV-423: the deadline this read judged by, kept beside the state so
3464
+ // the verdict and the sentence that explains it come from one
3465
+ // derivation. A key whose record this view does not carry yet answers
3466
+ // `null`, and the lagging branch below is what handles that.
3467
+ if (derivation.declared.harness_cap_ms !== null) {
3468
+ const at = requestExpiresAt(derivation);
3469
+ if (at !== null)
3470
+ expiryOf.set(key, at);
3471
+ }
3472
+ return { key, state: derivation.state };
3473
+ });
2450
3474
  const states = derived.map((entry) => entry.state);
2451
3475
  /**
2452
3476
  * Keys this process ESTABLISHED exist, that this read does not carry
@@ -2480,7 +3504,7 @@ floor = null) {
2480
3504
  .map((entry) => entry.key);
2481
3505
  if (lagging.length > 0 && !saidLagging) {
2482
3506
  saidLagging = true;
2483
- streams.err(`approval: the verified log does not yet carry ${lagging.join(", ")} (verified head: ${read.head === null ? "empty" : `seq ${String(read.head.seq)}`}). The request(s) were appended by this hook, so this is a view that lags rather than a decision; the hook keeps waiting for the verification to catch up, up to its ${String(run.timeoutMs)}ms wait. A sync or a daemon restart in the last minute is the usual cause (docs/claude-code-hook.md).\n`);
3507
+ streams.err(`approval: the verified log does not yet carry ${lagging.join(", ")} (verified head: ${read.head === null ? "empty" : `seq ${String(read.head.seq)}`}). The request(s) were appended by this hook, so this is a view that lags rather than a decision; the hook keeps waiting for the verification to catch up, up to its ${waitText(run)}. A sync or a daemon restart in the last minute is the usual cause (docs/claude-code-hook.md).\n`);
2484
3508
  }
2485
3509
  if (!states.includes("requested") && lagging.length === 0) {
2486
3510
  // Precedence, as `approval wait` fixes it: a human's "no" outranks a
@@ -2497,11 +3521,27 @@ floor = null) {
2497
3521
  return sayDeny("hook-withdrawn", `the request for ${task} was withdrawn before a decision; nothing is pending and nothing was authorized`);
2498
3522
  }
2499
3523
  if (states.includes("expired")) {
2500
- return sayDeny("hook-expired", `the request for ${task} lapsed before a decision`);
3524
+ // APRV-423. WHICH deadline it lapsed at, because under a harness
3525
+ // ceiling that is not the policy's TTL and an agent told only "it
3526
+ // lapsed" would read a four-minute window as an hour-long one that
3527
+ // somehow failed. The runtime, not this hook, appends the
3528
+ // `approval.expired` record: `decide` materialises it the moment a
3529
+ // late tap is refused, and the daemon's sweep materialises it on its
3530
+ // own cadence. A hook never writes one.
3531
+ const lapsed = derived
3532
+ .filter((entry) => entry.state === "expired")
3533
+ .map((entry) => `${entry.key}${expiryAt(expiryOf.get(entry.key))}`);
3534
+ return sayDeny("hook-expired", `the request for ${task} lapsed before a decision (${lapsed.join(", ")}). A decision arriving now is refused rather than granted, and the runtime records the lapse as approval.expired; run the command again to ask the question fresh.`);
2501
3535
  }
2502
3536
  if (states.every((state) => state === "granted")) {
2503
3537
  // The grants are spent before the allow is printed, so this exact
2504
3538
  // command cannot ride the same authorization twice.
3539
+ leaveWait();
3540
+ // Looked at again now the lock is held: re-taking it may have waited,
3541
+ // and a caller that left meanwhile must not have its grant spent on a
3542
+ // verdict nobody will receive. The retry is who spends it.
3543
+ if (callerGone())
3544
+ return sayGone();
2505
3545
  const failed = consumeGrants(run, spendKeys, hash, task);
2506
3546
  if (failed !== null) {
2507
3547
  return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
@@ -2531,6 +3571,7 @@ floor = null) {
2531
3571
  // Past the grace nobody is coming back for it, and a question nothing
2532
3572
  // will adopt is taken back rather than left for a restarted listener to
2533
3573
  // re-deliver.
3574
+ leaveWait();
2534
3575
  const withdrawn = withdrawAbandoned(run, streams, read.records, ts, null, ownKeys);
2535
3576
  // APRV-294: a wait that ends with the view still short of its own
2536
3577
  // requests says so. The deny is the same deny — the wait ran out — and
@@ -2541,9 +3582,9 @@ floor = null) {
2541
3582
  ? ""
2542
3583
  : ` The verified view still does not carry ${lagging.join(", ")}, which this hook appended: the request(s) exist and the view is behind, so check the log that owns them (\`approval log verify\`, \`approval status\`) rather than reading this as an unanswered question.`;
2543
3584
  if (withdrawn.length > 0) {
2544
- return sayDeny("hook-timeout", `no decision on ${waitKeys.join(", ")} within the hook's ${String(run.timeoutMs)}ms wait, and the ${minutesText(run.graceMs)} retry grace has run out: ${withdrawn.join(", ")} WAS WITHDRAWN (reason timeout). A tap on it now authorizes nothing and the channel says so. Run the command again to ask the question fresh.${stillLagging}`);
3585
+ return sayDeny("hook-timeout", `no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}, and the ${minutesText(run.graceMs)} retry grace has run out: ${withdrawn.join(", ")} WAS WITHDRAWN (reason timeout). A tap on it now authorizes nothing and the channel says so. Run the command again to ask the question fresh.${stillLagging}`);
2545
3586
  }
2546
- return sayDeny("hook-timeout", `no decision on ${waitKeys.join(", ")} within the hook's ${String(run.timeoutMs)}ms wait. This tool call is denied and NOTHING WAS WITHDRAWN: the request(s) stay open for the ${minutesText(run.graceMs)} retry grace, and a decision inside that window authorizes a retry of this exact command in this exact directory, once. Retry it after the approver answers; the retry adopts the same question rather than asking a second one. Past the grace the hook takes the question back (approval.withdrawn, reason timeout), so a late retry asks again rather than adopting a question nobody is holding.${stillLagging}`);
3587
+ return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}.`, stillLagging);
2547
3588
  }
2548
3589
  sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
2549
3590
  }
@@ -2553,10 +3594,12 @@ floor = null) {
2553
3594
  // deny. Unlike the timeout, this process cannot say what state it left
2554
3595
  // behind, so the question it opened is retracted rather than left standing
2555
3596
  // on a failure nobody diagnosed.
3597
+ leaveWait();
2556
3598
  withdrawPending(run, streams, ownKeys, `the requesting hook process failed while waiting (${cause instanceof Error ? cause.message : String(cause)})`);
2557
3599
  throw cause;
2558
3600
  }
2559
3601
  finally {
3602
+ leaveWait();
2560
3603
  process.off("SIGTERM", onTerm);
2561
3604
  process.off("SIGINT", onInt);
2562
3605
  }
@@ -2760,9 +3803,81 @@ function readMuseReportedOutcome(input) {
2760
3803
  }
2761
3804
  return { ok: true, outcome: "completed" };
2762
3805
  }
3806
+ /**
3807
+ * Hermes's outcome, read off `post_tool_call` where the event carries one
3808
+ * (APRV-398).
3809
+ *
3810
+ * The envelope's result field is read for its SHAPE only, exactly as the generic
3811
+ * reader reads Claude Code's: an `exit_code` decides a shell call, an `error`
3812
+ * string or an `error` flag decides anything, and a result this cannot read is
3813
+ * UNREADABLE rather than assumed complete. Nothing of the tool's output text is
3814
+ * read, and nothing from it reaches the log.
3815
+ *
3816
+ * Unreadable is the safe answer in both directions at once and the reasoning is
3817
+ * the generic reader's own: a failure nobody observed trips an escalation on
3818
+ * noise, and a completion nobody observed clears a streak on nothing. Appending
3819
+ * neither leaves the path as vacuous as it was before this adapter existed, for
3820
+ * that call.
3821
+ *
3822
+ * A POST EVENT ON THIS HARNESS IS NOT EVIDENCE THE TOOL RAN, which APRV-415's
3823
+ * probe established and which changes what the missing-result case may conclude.
3824
+ * `post_tool_call` fired 29ms after an exit-2 deny, and it fired again after the
3825
+ * armed hang was refused at the 300s cap: Hermes reports the end of its own
3826
+ * dispatch, not the end of an execution. So an envelope this reader cannot read
3827
+ * is UNREADABLE rather than complete. The event name is Hermes saying its
3828
+ * `post_tool_call` code path ran and nothing more, and crediting a completion on
3829
+ * that alone would clear a failure streak (amended SPEC.md §10.2) on a call that
3830
+ * may have been blocked. A blocked call usually has no `execution.started` to
3831
+ * close, because the pre half appended nothing; the case this protects is the one
3832
+ * where the pre half ALLOWED and something later in Hermes's own executor —
3833
+ * `approvals.mode`, a guardrail, a tool-scope check — stopped the call anyway.
3834
+ */
3835
+ function readHermesReportedOutcome(input) {
3836
+ // A shell result arrives as an object on this harness (`tool_input` is an
3837
+ // object throughout its contract), so the generic object reader is the right
3838
+ // shape; a string result says nothing about success and says so below.
3839
+ const response = input.toolResponse;
3840
+ if (response === null) {
3841
+ return {
3842
+ ok: false,
3843
+ detail: "the post_tool_call event carries no readable result, and on this harness a post event is not evidence the tool ran: it fires for a blocked call and after a refused timeout, so nothing here says the execution completed or failed",
3844
+ };
3845
+ }
3846
+ if (response["interrupted"] === true || input.interrupted) {
3847
+ return {
3848
+ ok: false,
3849
+ detail: "the tool call was interrupted, so it neither completed nor failed on its own terms; an interruption is somebody stopping the session rather than a loop to escalate or a recovery to credit",
3850
+ };
3851
+ }
3852
+ const exitCode = response["exit_code"] ?? response["exitCode"] ?? response["returncode"];
3853
+ if (typeof exitCode === "number") {
3854
+ return { ok: true, outcome: exitCode === 0 ? "completed" : "failed" };
3855
+ }
3856
+ const errorText = response["error"];
3857
+ if (response["error"] === true ||
3858
+ response["status"] === "error" ||
3859
+ (typeof errorText === "string" && errorText.length > 0)) {
3860
+ return { ok: true, outcome: "failed" };
3861
+ }
3862
+ // A result object the harness DID attach, naming neither an exit code nor an
3863
+ // error: read as a completion, which is the generic reader's own precedent. The
3864
+ // residual is stated rather than hidden — if a blocked call turns out to carry a
3865
+ // result object of this shape, this line credits a completion that did not
3866
+ // happen, and the repair is one more key here once a capture names it.
3867
+ return { ok: true, outcome: "completed" };
3868
+ }
2763
3869
  function readReportedOutcome(input, adapter) {
2764
3870
  if (adapter.kind === "codex")
2765
3871
  return readCodexReportedOutcome(input);
3872
+ if (adapter.kind === "hermes") {
3873
+ if (input.hookEventName !== HERMES_POST_TOOL_EVENT) {
3874
+ return {
3875
+ ok: false,
3876
+ detail: `hook_event_name is ${input.hookEventName === null ? "absent" : JSON.stringify(input.hookEventName)}, which is not the event this adapter reports an outcome for (${HERMES_POST_TOOL_EVENT})`,
3877
+ };
3878
+ }
3879
+ return readHermesReportedOutcome(input);
3880
+ }
2766
3881
  if (adapter.kind === "muse") {
2767
3882
  if (input.hookEventName !== "PostToolUse") {
2768
3883
  return {
@@ -2813,7 +3928,9 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
2813
3928
  // APRV-347: a read tool MAY have had a start written for it (one outside
2814
3929
  // the scope), so it belongs in this set. A read inside the scope wrote
2815
3930
  // nothing, and the close below finds no start and says so in its own words.
2816
- !adapter.readTools.includes(input.toolName)) {
3931
+ !adapter.readTools.includes(input.toolName) &&
3932
+ // APRV-445: a tool the rule table gated wrote a start like any other.
3933
+ toolRuleOf(adapter, input)?.kind !== "gated") {
2817
3934
  return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
2818
3935
  }
2819
3936
  if (input.toolUseId === null) {
@@ -2964,6 +4081,20 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
2964
4081
  };
2965
4082
  }
2966
4083
  const classes = classified.classes.filter((cls) => cls !== GATE_SELF_CLASS);
4084
+ // APRV-445 refutation (B2, L10). The classifier matches protected paths by
4085
+ // their SEGMENTS, and a relative word carries only the segments it was
4086
+ // written with: `echo X >> .env` run in `$HERMES_HOME` names `.env`, not
4087
+ // `.hermes/.env`, and classified as a workspace write. Where the harness
4088
+ // states the per-call directory the command runs in, every relative word
4089
+ // and every write target is ALSO judged resolved against it: a write that
4090
+ // lands on an organ takes the organ's class, and a read that lands on a
4091
+ // credential takes `account.credential`. It only ever adds a class.
4092
+ if (shellCwd !== null) {
4093
+ for (const cls of resolvedPathClasses(raw, classified.segments, shellCwd, protectedPaths)) {
4094
+ if (!classes.includes(cls))
4095
+ classes.push(cls);
4096
+ }
4097
+ }
2967
4098
  if (adapter.kind === "codex") {
2968
4099
  // The pure shell classifier sees each segment independently. Preserve
2969
4100
  // Codex hook organs when an earlier simple `cd` changes the directory or
@@ -3045,10 +4176,37 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
3045
4176
  segments: classified.segments,
3046
4177
  };
3047
4178
  }
4179
+ // APRV-445: the harness's own side-effecting tools, by name and arguments.
4180
+ // The payload is the whole call, so a grant binds every argument.
4181
+ const ruled = toolRuleOf(adapter, input);
4182
+ if (ruled !== null) {
4183
+ if (ruled.kind === "read")
4184
+ return { kind: "allow", reason: ruled.reason };
4185
+ return {
4186
+ kind: "gated",
4187
+ classes: [ruled.cls],
4188
+ payload: { tool: input.toolName, input: input.toolInput },
4189
+ headline: ruled.headline,
4190
+ notes: [],
4191
+ };
4192
+ }
3048
4193
  // APRV-347, above the file-tool branch because the two lists are disjoint and
3049
4194
  // a read is the cheaper question to answer: a read inside the scope, or one
3050
4195
  // naming no path at all, returns `null` here and takes the allow below.
3051
4196
  if (adapter.readTools.includes(input.toolName)) {
4197
+ // APRV-445 refutation (B2): a read tool naming a credential file is
4198
+ // `account.credential` wherever the file sits, inside the read scope
4199
+ // included, as the same read through the shell already was.
4200
+ const credential = credentialReadGate(input.toolName, input.toolInput, cwd);
4201
+ if (credential !== null) {
4202
+ return {
4203
+ kind: "gated",
4204
+ classes: ["account.credential"],
4205
+ payload: credential.payload,
4206
+ headline: credential.summary,
4207
+ notes: [],
4208
+ };
4209
+ }
3052
4210
  const read = readToolGate(input.toolName, input.toolInput, readRoots, cwd);
3053
4211
  if (read === null) {
3054
4212
  return {
@@ -3078,6 +4236,465 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
3078
4236
  notes: gated.protectedPath ? [fileTierNote(gated)] : [],
3079
4237
  };
3080
4238
  }
4239
+ /**
4240
+ * The classes a shell command's PATHS take once they are resolved the way the
4241
+ * shell and the filesystem will resolve them (APRV-445 refutation B2 and L10,
4242
+ * recheck SF2 and L-e), for a harness that states the directory each call runs
4243
+ * in.
4244
+ *
4245
+ * The classifier matches organs and credentials by path SEGMENTS of the words
4246
+ * as written. A word written relative (`.env` run in `$HERMES_HOME`), through a
4247
+ * variable (`$HERMES_HOME/.env`), after a `cd`, or through a symlinked parent
4248
+ * (`h/.env` where `h -> .hermes`) carries none of the segments that matter. So
4249
+ * each path a segment touches is also judged here:
4250
+ *
4251
+ * - WRITTEN paths are the redirect targets (heredocs included) and the
4252
+ * write-argument positions of the binaries whose writes are known (L-e):
4253
+ * never the binary, never a word that is merely an argument. A written path
4254
+ * resolving to an organ takes its class; one carrying a glob or a variable
4255
+ * this hook cannot expand is `policy.core` when it lands in one of the gate's
4256
+ * own directories ({@link gateRootKind}) or a pattern in it could name the
4257
+ * home or an organ (recheck 3); an unexpanded `$HERMES_HOME` in a written
4258
+ * path is `policy.core` wherever it is written from. A copy into a directory
4259
+ * writes `<dir>/<source name>`; an extraction writes into its directory.
4260
+ * - READ paths (a read segment's arguments, `<` targets, a copy's sources) that
4261
+ * resolve to a credential take `account.credential`, and so does a directory
4262
+ * that holds credentials ({@link holdsCredentials}) handed to a binary that
4263
+ * reads directories recursively (`grep -r`, `rg`, `find`, `tar`, ...).
4264
+ * - After a `cd` this hook cannot resolve, every later relative write is
4265
+ * `policy.core` and every later relative read `account.credential`.
4266
+ * - `cd` moves the directory for the segments after it. Like the Codex arm, a
4267
+ * `cd` may or may not have run (`||`, a failed `cd`), so the directories
4268
+ * before and after it are both kept and every later path is judged from each.
4269
+ * - Every path is resolved through `realpath` on its deepest existing ancestor,
4270
+ * so a symlinked parent or working directory is judged by where it lands.
4271
+ * - `$HERMES_HOME` / `${HERMES_HOME}` expand to `APPROVAL_HERMES_HOME`, else
4272
+ * `HERMES_HOME`, from this hook process's environment; `~`, `$HOME` and
4273
+ * `${HOME}` to `HOME`. Under `approval serve` the process is the daemon, so a
4274
+ * hosted operator sets `APPROVAL_HERMES_HOME` there; without it, the
4275
+ * conservative rules above apply to the unexpanded spelling.
4276
+ *
4277
+ * What stays out of reach is listed in `docs/hermes-hook.md`: inline programs
4278
+ * (`python -c`, which the classifier already refuses as opaque), `dd`/`install`
4279
+ * (unclassified, so already denied), and a recursive search of an ANCESTOR of
4280
+ * the home with a filename filter.
4281
+ *
4282
+ * It only ever adds classes.
4283
+ */
4284
+ function resolvedPathClasses(raw, segments, cwd, protectedPaths, env = process.env) {
4285
+ const found = [];
4286
+ const add = (cls) => {
4287
+ if (cls !== null && !found.includes(cls))
4288
+ found.push(cls);
4289
+ };
4290
+ const shapes = commandSegmentShapes(raw) ?? [];
4291
+ // Trusted as given: the operator sets it (under co-location, the control
4292
+ // plane sets it from the tenant's home), and nothing the agent writes reaches
4293
+ // this process's environment.
4294
+ const hermesHome = env["APPROVAL_HERMES_HOME"] ?? env["HERMES_HOME"] ?? null;
4295
+ const home = env["HOME"] ?? null;
4296
+ /** The word with the variables this hook can know expanded. */
4297
+ const expand = (word) => {
4298
+ let text = word;
4299
+ let hermesUnknown = false;
4300
+ const hermesRef = /^\$(?:\{HERMES_HOME\}|HERMES_HOME)(?=\/|$)/u;
4301
+ if (hermesRef.test(text)) {
4302
+ if (hermesHome !== null && hermesHome.length > 0)
4303
+ text = text.replace(hermesRef, hermesHome);
4304
+ else
4305
+ hermesUnknown = true;
4306
+ }
4307
+ const homeRef = /^(?:~|\$\{HOME\}|\$HOME)(?=\/|$)/u;
4308
+ if (homeRef.test(text) && home !== null && home.length > 0)
4309
+ text = text.replace(homeRef, home);
4310
+ return { text, hermesUnknown, unresolved: hermesUnknown || /[$`]/u.test(text) };
4311
+ };
4312
+ // After a `cd` this hook cannot resolve (`cd $X`, `cd "$D"`, `cd -`), the
4313
+ // directory every later relative path lands in is unknown (recheck 3, SF2).
4314
+ let cwdUnknown = false;
4315
+ let cwds = [canonicalPath(cwd)];
4316
+ for (const [index, segment] of segments.entries()) {
4317
+ const shape = shapes.length === segments.length
4318
+ ? shapes[index]
4319
+ : shapes.find((candidate) => candidate.text === segment.text);
4320
+ if (shape === undefined)
4321
+ continue;
4322
+ const bin = shape.bin === null ? null : basename(shape.bin);
4323
+ const operands = shape.args.filter((arg) => !arg.startsWith("-"));
4324
+ if (bin === "cd" || bin === "pushd") {
4325
+ const target = operands[0];
4326
+ if (target === undefined) {
4327
+ // A bare `cd` goes HOME; `cd -` and `pushd` with no operand go to a
4328
+ // directory recorded earlier in a shell this hook never saw.
4329
+ if (bin === "cd" && !shape.args.includes("-") && home !== null) {
4330
+ cwds = [...new Set([...cwds, canonicalPath(home)])];
4331
+ }
4332
+ else {
4333
+ cwdUnknown = true;
4334
+ }
4335
+ }
4336
+ else {
4337
+ const expanded = expand(target);
4338
+ if (expanded.unresolved || hasGlobChars(expanded.text)) {
4339
+ cwdUnknown = true;
4340
+ }
4341
+ else {
4342
+ const next = cwds.map((from) => canonicalPath(resolvePathSegments(from, expanded.text)));
4343
+ cwds = [...new Set([...cwds, ...next])];
4344
+ }
4345
+ }
4346
+ continue;
4347
+ }
4348
+ if (bin === null && shape.writes.length === 0 && shape.reads.length === 0)
4349
+ continue;
4350
+ const judgeWrite = (word) => {
4351
+ const target = expand(word);
4352
+ if (target.hermesUnknown) {
4353
+ add("policy.core");
4354
+ return;
4355
+ }
4356
+ if (!isAbsolute(target.text) && cwdUnknown) {
4357
+ add("policy.core");
4358
+ return;
4359
+ }
4360
+ const globbed = target.unresolved || hasGlobChars(target.text);
4361
+ for (const from of cwds) {
4362
+ const absoluteTarget = resolvePathSegments(from, target.text);
4363
+ if (globbed) {
4364
+ // SF3a: a pattern in any component that could name the home, the
4365
+ // approval home or an organ is an organ write; a pattern written
4366
+ // into one of the gate's own directories (SF1b) may be any file
4367
+ // there. Anywhere else it is a workspace write.
4368
+ if (globCouldNameOrgan(absoluteTarget) || writesIntoGateRoot(canonicalPath(dirname(absoluteTarget)))) {
4369
+ add("policy.core");
4370
+ }
4371
+ continue;
4372
+ }
4373
+ add(protectedPathClass(canonicalPath(absoluteTarget), protectedPaths));
4374
+ }
4375
+ };
4376
+ /** A copy or move INTO a directory writes `<dir>/<basename of source>`. */
4377
+ const judgeWriteInto = (directory, sources) => {
4378
+ for (const source of sources) {
4379
+ const name = basename(expand(source).text);
4380
+ if (name.length === 0 || name === "." || name === "..") {
4381
+ // `cp -r stuff/. dir`: the contents land in the directory itself.
4382
+ judgeExtractInto(directory);
4383
+ continue;
4384
+ }
4385
+ judgeWrite(join(directory, name));
4386
+ }
4387
+ };
4388
+ /** An extraction or a contents-copy writes unknown names into `directory`. */
4389
+ const judgeExtractInto = (directory) => {
4390
+ const target = expand(directory);
4391
+ if (target.hermesUnknown || (cwdUnknown && !isAbsolute(target.text))) {
4392
+ add("policy.core");
4393
+ return;
4394
+ }
4395
+ if (target.unresolved)
4396
+ return;
4397
+ for (const from of cwds) {
4398
+ if (writesIntoGateRoot(canonicalPath(resolvePathSegments(from, target.text))))
4399
+ add("policy.core");
4400
+ }
4401
+ };
4402
+ const judgeRead = (word, recursive) => {
4403
+ const target = expand(word);
4404
+ if (target.hermesUnknown) {
4405
+ add("account.credential");
4406
+ return;
4407
+ }
4408
+ if (!isAbsolute(target.text) && cwdUnknown) {
4409
+ add("account.credential");
4410
+ return;
4411
+ }
4412
+ if (target.unresolved)
4413
+ return;
4414
+ for (const from of cwds) {
4415
+ const absoluteTarget = resolvePathSegments(from, target.text);
4416
+ if (hasGlobChars(target.text)) {
4417
+ // SF3b: expand against the directory when it is known; otherwise a
4418
+ // pattern that could name a credential is read as one.
4419
+ const candidates = expandGlob(absoluteTarget);
4420
+ if (candidates === null) {
4421
+ if (globCouldNameCredential(absoluteTarget))
4422
+ add("account.credential");
4423
+ }
4424
+ else {
4425
+ for (const candidate of candidates) {
4426
+ if (isCredentialPath(canonicalPath(candidate)))
4427
+ add("account.credential");
4428
+ }
4429
+ }
4430
+ continue;
4431
+ }
4432
+ const resolved = canonicalPath(absoluteTarget);
4433
+ if (isCredentialPath(resolved))
4434
+ add("account.credential");
4435
+ else if (recursive && holdsCredentials(resolved))
4436
+ add("account.credential");
4437
+ }
4438
+ };
4439
+ for (const target of shape.writes)
4440
+ judgeWrite(target);
4441
+ for (const source of shape.reads)
4442
+ judgeRead(source, false);
4443
+ if (bin === null)
4444
+ continue;
4445
+ // `-t DIR` / `--target-directory=DIR` names the destination up front.
4446
+ const targetDirFlag = () => {
4447
+ for (const [at, arg] of shape.args.entries()) {
4448
+ if (arg === "-t")
4449
+ return shape.args[at + 1] ?? null;
4450
+ if (arg.startsWith("--target-directory="))
4451
+ return arg.slice("--target-directory=".length);
4452
+ }
4453
+ return null;
4454
+ };
4455
+ if (WRITE_EVERY_OPERAND.has(bin)) {
4456
+ for (const operand of operands)
4457
+ judgeWrite(operand);
4458
+ }
4459
+ else if (WRITE_LAST_OPERAND.has(bin)) {
4460
+ const targetDir = targetDirFlag();
4461
+ const sources = targetDir === null ? operands.slice(0, -1) : operands.filter((operand) => operand !== targetDir);
4462
+ const destination = targetDir ?? operands[operands.length - 1];
4463
+ for (const source of sources)
4464
+ judgeRead(source, true);
4465
+ if (destination !== undefined) {
4466
+ const expanded = expand(destination);
4467
+ const intoDirectory = targetDir !== null ||
4468
+ destination.endsWith("/") ||
4469
+ (!expanded.unresolved &&
4470
+ cwds.some((from) => isDirectoryPath(canonicalPath(resolvePathSegments(from, expanded.text)))));
4471
+ if (intoDirectory)
4472
+ judgeWriteInto(destination, sources);
4473
+ else
4474
+ judgeWrite(destination);
4475
+ }
4476
+ }
4477
+ else if (WRITE_AFTER_FIRST_OPERAND.has(bin)) {
4478
+ for (const operand of operands.slice(1))
4479
+ judgeWrite(operand);
4480
+ }
4481
+ else if (bin === "sed" && shape.args.some((arg) => arg === "--in-place" || /^-[a-zA-Z]*i/u.test(arg))) {
4482
+ const scripted = shape.args.some((arg) => arg === "-e" || arg === "-f");
4483
+ for (const operand of scripted ? operands : operands.slice(1))
4484
+ judgeWrite(operand);
4485
+ }
4486
+ else if (bin === "dd") {
4487
+ for (const arg of shape.args) {
4488
+ if (arg.startsWith("of="))
4489
+ judgeWrite(arg.slice(3));
4490
+ if (arg.startsWith("if="))
4491
+ judgeRead(arg.slice(3), false);
4492
+ }
4493
+ }
4494
+ else if (bin === "tar" && isTarExtract(shape.args)) {
4495
+ // Lows: an extraction writes the archive's names into `-C DIR` or the
4496
+ // working directory.
4497
+ judgeExtractInto(flagValue(shape.args, "-C", "--directory") ?? ".");
4498
+ }
4499
+ else if (bin === "unzip") {
4500
+ judgeExtractInto(flagValue(shape.args, "-d", null) ?? ".");
4501
+ }
4502
+ if (segment.class.startsWith("read.") || RECURSIVE_READERS.has(bin)) {
4503
+ for (const operand of operands)
4504
+ judgeRead(operand, RECURSIVE_READERS.has(bin));
4505
+ }
4506
+ }
4507
+ return found;
4508
+ }
4509
+ /** `*`, `?` or `[` in a path word. */
4510
+ function hasGlobChars(text) {
4511
+ return /[*?[]/u.test(text);
4512
+ }
4513
+ /** A shell glob component as an anchored regular expression. */
4514
+ function globRegex(component) {
4515
+ let source = "";
4516
+ for (const char of component) {
4517
+ if (char === "*")
4518
+ source += ".*";
4519
+ else if (char === "?")
4520
+ source += ".";
4521
+ else if (char === "[" || char === "]")
4522
+ source += char;
4523
+ else
4524
+ source += char.replace(/[.+^${}()|\\]/gu, "\\$&");
4525
+ }
4526
+ try {
4527
+ return new RegExp(`^${source}$`, "u");
4528
+ }
4529
+ catch {
4530
+ return /^.*$/u;
4531
+ }
4532
+ }
4533
+ /** Names a credential file goes by, for a pattern to be tested against. */
4534
+ const CREDENTIAL_NAMES = [".env", ".env.local", "auth.json", "env", "vault.enc", "keys"];
4535
+ /** Names an organ goes by, beyond the credentials. */
4536
+ const ORGAN_NAMES = [
4537
+ ...CREDENTIAL_NAMES,
4538
+ "config.yaml",
4539
+ "config.yml",
4540
+ "shell-hooks-allowlist.json",
4541
+ "shell-hooks-allowlist.json.lock",
4542
+ "agent-hooks",
4543
+ "approval",
4544
+ "scripts",
4545
+ ];
4546
+ /** Could some component of `path`'s pattern name the home or the approval home? */
4547
+ function globCouldNameGateHome(path) {
4548
+ return path
4549
+ .split(/[/\\]+/u)
4550
+ .some((component) => hasGlobChars(component) && [".hermes", ".approval"].some((name) => globRegex(component).test(name)));
4551
+ }
4552
+ /** SF3a: a written pattern that could land on an organ. */
4553
+ function globCouldNameOrgan(path) {
4554
+ if (globCouldNameGateHome(path))
4555
+ return true;
4556
+ const last = basename(path);
4557
+ if (!hasGlobChars(last))
4558
+ return false;
4559
+ const pattern = globRegex(last);
4560
+ const parent = dirname(path);
4561
+ const parentIsHome = parent.split(/[/\\]+/u).some((segment) => segment === ".hermes" || segment === ".approval") ||
4562
+ hasGlobChars(parent);
4563
+ return parentIsHome && ORGAN_NAMES.some((name) => pattern.test(name));
4564
+ }
4565
+ /** SF3b: a read pattern whose directory is unknown, that could name a credential. */
4566
+ function globCouldNameCredential(path) {
4567
+ const last = basename(path);
4568
+ if (!hasGlobChars(last))
4569
+ return globCouldNameGateHome(path);
4570
+ const pattern = globRegex(last);
4571
+ return CREDENTIAL_NAMES.some((name) => pattern.test(name)) || globCouldNameGateHome(path);
4572
+ }
4573
+ /** The entries a final-component glob matches, or `null` when the directory is unknown. */
4574
+ function expandGlob(path) {
4575
+ const parent = dirname(path);
4576
+ if (hasGlobChars(parent))
4577
+ return null;
4578
+ try {
4579
+ const pattern = globRegex(basename(path));
4580
+ return readdirSync(canonicalPath(parent))
4581
+ .filter((name) => pattern.test(name))
4582
+ .map((name) => join(parent, name));
4583
+ }
4584
+ catch {
4585
+ return null;
4586
+ }
4587
+ }
4588
+ function isDirectoryPath(path) {
4589
+ try {
4590
+ return statSync(path).isDirectory();
4591
+ }
4592
+ catch {
4593
+ return false;
4594
+ }
4595
+ }
4596
+ /**
4597
+ * The gate's own directories (recheck 3, SF1): a Hermes home (or profile home)
4598
+ * root, an approval home, a home's `approval/`, and its `scripts/`. Everything
4599
+ * else under a home (`workspace/`, `skills/`, `memories/`, `sessions/`) is
4600
+ * ordinary work, which matters because a hosted image runs ALL agent work under
4601
+ * `$HERMES_HOME`.
4602
+ */
4603
+ function gateRootKind(directory) {
4604
+ const segments = directory.split(/[/\\]+/u).filter((segment) => segment.length > 0);
4605
+ const last = segments[segments.length - 1];
4606
+ const isHome = (at) => segments[at] === ".hermes" ||
4607
+ (segments[at - 2] === ".hermes" && segments[at - 1] === "profiles" && segments[at] !== undefined);
4608
+ if (last === undefined)
4609
+ return null;
4610
+ if (isHome(segments.length - 1))
4611
+ return "home";
4612
+ if (last === ".approval")
4613
+ return "approval-home";
4614
+ if (last === "approval" && isHome(segments.length - 2))
4615
+ return "home-approval";
4616
+ if (last === "scripts" && isHome(segments.length - 2))
4617
+ return "scripts";
4618
+ return null;
4619
+ }
4620
+ /** A glob or an unknown name written here may be an organ (SF1b). */
4621
+ function writesIntoGateRoot(directory) {
4622
+ return gateRootKind(directory) !== null;
4623
+ }
4624
+ /**
4625
+ * A directory a recursive read of which reads credentials (SF1a): the home
4626
+ * root, the approval home, a home's `approval/`, and any directory under a home
4627
+ * that directly holds a credential file.
4628
+ */
4629
+ function holdsCredentials(directory) {
4630
+ if (!isDirectoryPath(directory))
4631
+ return false;
4632
+ const kind = gateRootKind(directory);
4633
+ if (kind === "home" || kind === "approval-home" || kind === "home-approval")
4634
+ return true;
4635
+ if (!directory.split(/[/\\]+/u).some((segment) => segment === ".hermes" || segment === ".approval"))
4636
+ return false;
4637
+ return CREDENTIAL_FILE_NAMES.some((name) => existsSync(join(directory, name)));
4638
+ }
4639
+ const CREDENTIAL_FILE_NAMES = [
4640
+ ".env",
4641
+ "auth.json",
4642
+ "config.yaml",
4643
+ "config.yml",
4644
+ "shell-hooks-allowlist.json",
4645
+ "shell-hooks-allowlist.json.lock",
4646
+ ];
4647
+ function isTarExtract(args) {
4648
+ const first = args[0] ?? "";
4649
+ return args.some((arg) => arg === "--extract" || arg === "--get" || /^-[a-zA-Z]*x/u.test(arg)) ||
4650
+ (!first.startsWith("-") && first.includes("x"));
4651
+ }
4652
+ function flagValue(args, short, long) {
4653
+ for (const [at, arg] of args.entries()) {
4654
+ if (arg === short)
4655
+ return args[at + 1] ?? null;
4656
+ if (arg.startsWith(short) && arg.length > short.length && !arg.startsWith("--"))
4657
+ return arg.slice(short.length);
4658
+ if (long !== null && arg.startsWith(`${long}=`))
4659
+ return arg.slice(long.length + 1);
4660
+ if (long !== null && arg === long)
4661
+ return args[at + 1] ?? null;
4662
+ }
4663
+ return null;
4664
+ }
4665
+ /** Binaries every operand of which is a path they create, change or remove. */
4666
+ const WRITE_EVERY_OPERAND = new Set([
4667
+ "touch", "truncate", "mkdir", "rm", "rmdir", "shred", "tee", "unlink",
4668
+ ]);
4669
+ /** Binaries whose LAST operand is the destination and the rest are sources. */
4670
+ const WRITE_LAST_OPERAND = new Set(["cp", "mv", "ln", "install", "rsync"]);
4671
+ /** Binaries whose first operand is a mode or owner and the rest are paths. */
4672
+ const WRITE_AFTER_FIRST_OPERAND = new Set(["chmod", "chown", "chgrp"]);
4673
+ /** Readers that walk a directory they are handed. */
4674
+ const RECURSIVE_READERS = new Set([
4675
+ "grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "tar", "zip", "du", "tree",
4676
+ ]);
4677
+ /**
4678
+ * `path` resolved through the realpath of its deepest existing ancestor, so a
4679
+ * symlinked parent is judged by where it lands and a path that does not exist
4680
+ * yet is judged by where it would be created (APRV-445 recheck SF2(e)).
4681
+ */
4682
+ function canonicalPath(path) {
4683
+ let current = path;
4684
+ const tail = [];
4685
+ for (;;) {
4686
+ try {
4687
+ return resolvePathSegments(realpathSync(current), ...tail.reverse());
4688
+ }
4689
+ catch {
4690
+ const parent = dirname(current);
4691
+ if (parent === current)
4692
+ return resolvePathSegments(path);
4693
+ tail.push(basename(current));
4694
+ current = parent;
4695
+ }
4696
+ }
4697
+ }
3081
4698
  /** The environment variable that turns the sandbox requirement on (APRV-193). */
3082
4699
  export const REQUIRE_SANDBOX_ENV = "APPROVAL_HOOK_REQUIRE_SANDBOX";
3083
4700
  /**
@@ -3281,8 +4898,13 @@ decidedOn) {
3281
4898
  const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
3282
4899
  return allow(streams, `gate-open: ${classes.join(", ")} bypassed by the window opened at seq ${String(window.seq)} by ${window.openedBy} (expires ${window.expiresAt}); recorded as gate.bypassed seq ${String(recorded.record.seq)}${notes.length === 0 ? "" : ` (${notes.join("; ")})`}`, adapter.kind, codexCommand);
3283
4900
  }
3284
- function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3285
- const configurationError = (message) => adapter.kind === "codex" ? deny(streams, "hook-io", message, adapter.kind) : usageError(streams, message);
4901
+ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
4902
+ // Codex and (APRV-445) Hermes answer a misconfigured entry with a verdict:
4903
+ // on both a usage error's empty stdout is a harness that runs the call, and
4904
+ // on Hermes a non-zero exit with an empty stdout is an allow outright.
4905
+ const configurationError = (message) => adapter.kind === "codex" || adapter.kind === "hermes"
4906
+ ? deny(streams, "hook-io", message, adapter.kind)
4907
+ : usageError(streams, message);
3286
4908
  const parsed = parseFlags(argv, {
3287
4909
  ...COMMON_FLAGS,
3288
4910
  ...POLICY_FLAGS,
@@ -3291,6 +4913,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3291
4913
  "--timeout": "string",
3292
4914
  "--interval": "string",
3293
4915
  "--retry-grace": "string",
4916
+ "--harness-cap": "string",
3294
4917
  });
3295
4918
  if (!parsed.ok)
3296
4919
  return configurationError(parsed.message);
@@ -3301,11 +4924,19 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3301
4924
  // Muse gets its own for the same reason and one more: its `.muse/hooks.json`
3302
4925
  // shape is not Claude's file under a different name, and an operator who
3303
4926
  // guessed would get "Hooks: 0 runnable" and a session that looks gated.
4927
+ // Hermes gets its own for a third reason on top of those two (APRV-398):
4928
+ // the file the human commits is YAML, it is not in the repository at all
4929
+ // (`$HERMES_HOME/config.yaml`), and the entry carries the one key that
4930
+ // decides whether this adapter is a gate or a backstop — `fail_closed`. A
4931
+ // reader who copied a JSON block from another harness's help would install
4932
+ // nothing.
3304
4933
  const harnessHelp = adapter.kind === "grok"
3305
4934
  ? HOOK_GROK_HELP
3306
4935
  : adapter.kind === "muse"
3307
4936
  ? HOOK_MUSE_HELP
3308
- : HOOK_HELP;
4937
+ : adapter.kind === "hermes"
4938
+ ? HOOK_HERMES_HELP
4939
+ : HOOK_HELP;
3309
4940
  streams.out(`${harnessHelp}\n`);
3310
4941
  return EXIT_OK;
3311
4942
  }
@@ -3336,6 +4967,35 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3336
4967
  if (graceMs === null) {
3337
4968
  return configurationError(`--retry-grace expects a duration like 5m, 30s, 1ms, got ${JSON.stringify(graceText)}`);
3338
4969
  }
4970
+ // APRV-423. The ceiling this process runs under, which the hook cannot
4971
+ // discover for itself: the harness spawns it and tells it nothing. The
4972
+ // OPERATOR states the entry timeout they configured, and the adapter
4973
+ // contributes the harness's own documented maximum where its contract has
4974
+ // one. The smaller wins, because a stated cap above a contractual ceiling is
4975
+ // a statement the harness will not honour, and a cap may only ever shorten
4976
+ // the window (SPEC.md §11.1 invariant 4).
4977
+ const capText = stringFlag(parsed.flags, "--harness-cap");
4978
+ const statedCapMs = capText === null ? null : parseDuration(capText);
4979
+ if (capText !== null && statedCapMs === null) {
4980
+ return configurationError(`--harness-cap expects a duration like 300s, 4m, got ${JSON.stringify(capText)}`);
4981
+ }
4982
+ // Second review pass. A stated cap is clamped to the contractual ceiling
4983
+ // (Hermes will not honour an entry above 300 s however it is written); an
4984
+ // absent flag takes the harness's DEFAULT bound where the adapter documents
4985
+ // one, and only then falls back to the ceiling. The default is the stricter
4986
+ // number on purpose: an operator who has said nothing is assumed to run the
4987
+ // harness as shipped, and a runtime that assumed the ceiling instead would
4988
+ // assert a deadline the harness will not keep (APRV-410 with a fictional
4989
+ // T+240 s, which is exactly what the first pass did on a default Hermes).
4990
+ const capMs = effectiveHarnessCapMs(adapter, statedCapMs);
4991
+ // APRV-427 review: charged for the time the call spent before this line,
4992
+ // using the remainder the embedding caller admitted it on, unchanged. See
4993
+ // HookWaitSeam.remainingCapMs.
4994
+ const remainingCapMs = waitSeam?.remainingCapMs;
4995
+ const harnessCapMs = capMs === null || remainingCapMs === undefined
4996
+ ? capMs
4997
+ : Math.max(0, Math.min(capMs, remainingCapMs));
4998
+ const harnessCapStated = statedCapMs !== null;
3339
4999
  const parsedInput = parseHookInput(readStdin(), adapter.camelCaseEnvelope === true);
3340
5000
  if (!parsedInput.ok)
3341
5001
  return deny(streams, "hook-io", parsedInput.detail, adapter.kind);
@@ -3376,7 +5036,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3376
5036
  if (adapter.contributorModelGuard === true) {
3377
5037
  const refusal = contributorModelRefusal(input.model);
3378
5038
  if (refusal !== null) {
3379
- const postEvent = input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
5039
+ const postEvent = isPostToolEvent(adapter, input.hookEventName);
3380
5040
  return postEvent
3381
5041
  ? report(streams, MUSE_CONTRIBUTOR_REFUSAL, `${refusal} Nothing was appended.`)
3382
5042
  : deny(streams, MUSE_CONTRIBUTOR_REFUSAL, refusal, adapter.kind);
@@ -3389,7 +5049,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3389
5049
  // fall through, so the verdict is one dialect and nothing else, which is the
3390
5050
  // only shape this harness parses.
3391
5051
  if (adapter.passThroughTools?.includes(input.toolName) === true) {
3392
- const passPostEvent = input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
5052
+ const passPostEvent = isPostToolEvent(adapter, input.hookEventName);
3393
5053
  if (passPostEvent) {
3394
5054
  return report(streams, "post-tool-not-gated", `${input.toolName} is the harness's own bookkeeping tool, so no execution.started was ever written for it`);
3395
5055
  }
@@ -3405,9 +5065,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3405
5065
  // harness whose event this runtime does not recognize is a harness about to
3406
5066
  // run a command, and treating an unknown name as a no-op would be an ungated
3407
5067
  // one.
3408
- const postToolEvent = adapter.kind === "codex"
3409
- ? input.hookEventName === CODEX_POST_TOOL_EVENT
3410
- : input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
5068
+ const postToolEvent = isPostToolEvent(adapter, input.hookEventName);
3411
5069
  if (postToolEvent) {
3412
5070
  // APRV-303. `commandHarnessHook`'s catch turns a throw into a DENY, which is
3413
5071
  // the right answer for a call that has not run yet and exactly the wrong one
@@ -3422,7 +5080,15 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3422
5080
  // path therefore always exits 0. The machine-readable line still goes to
3423
5081
  // stderr; whether Grok shows it is Grok's business, and losing a debug line
3424
5082
  // is a smaller harm than emitting a verdict the protocol will act on.
3425
- const settle = (code) => (adapter.kind === "grok" ? EXIT_OK : code);
5083
+ //
5084
+ // APRV-398. Hermes joins Grok on that rule, and for the same reason read off
5085
+ // its own contract: Hermes documents exit 2 as BLOCKING, so a visibility
5086
+ // exit here would be a block aimed at a tool call that has already run.
5087
+ // This adapter's whole stance on this harness is that the exit status
5088
+ // carries no verdict (see `HERMES_DENY_EXIT`), and that has to hold on both
5089
+ // halves or it holds nowhere. The machine-readable line still goes to
5090
+ // stderr, and whether Hermes shows it is Hermes's business.
5091
+ const settle = (code) => adapter.kind === "grok" || adapter.kind === "hermes" ? EXIT_OK : code;
3426
5092
  try {
3427
5093
  return settle(runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter));
3428
5094
  }
@@ -3439,7 +5105,46 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3439
5105
  if (adapter.kind === "codex" && input.toolName === "Bash") {
3440
5106
  return deny(streams, "hook-unsupported-execution-context", "Codex Bash is disabled because the native hook contract does not expose the effective per-call working directory; no policy or open window can authorize bytes the hook cannot bind", adapter.kind);
3441
5107
  }
3442
- if (input.toolName !== adapter.shellTool && !adapter.fileTools.includes(input.toolName)) {
5108
+ // APRV-398, and placed exactly where the Codex refusal above is placed, for
5109
+ // exactly its reason: before the open window, the gate-self path, the carry
5110
+ // and the registration, because none of those can supply a fact the call does
5111
+ // not carry. Hermes's `execute_code` takes a `code` string and nothing else —
5112
+ // no path, no argv, no working directory — so there is nothing for the
5113
+ // classifier to read and nothing a payload could bind. A verdict over
5114
+ // `{code}` would authorize a program this runtime never parsed.
5115
+ //
5116
+ // It is refused rather than classified `hook-opaque` because the repair
5117
+ // differs: an opaque command line can be rewritten, and there is no spelling
5118
+ // of an `execute_code` call that this hook could answer differently. Nothing
5119
+ // appends on this path and no gate lifecycle opens. An OPEN WINDOW does not
5120
+ // reach it either, which is the strict reading a window deserves here: a
5121
+ // window suspends the policy, and this refusal is not a policy question.
5122
+ if (adapter.kind === "hermes" && input.toolName === HERMES_EXECUTE_CODE_TOOL) {
5123
+ return deny(streams, HERMES_EXECUTE_CODE_REFUSAL, `Hermes ${HERMES_EXECUTE_CODE_TOOL} is refused because the call carries a program and nothing else: no path, no argv and no working directory, so no class can be resolved and no payload can bind what it would do. Run the work through the \`${adapter.shellTool}\` tool, where the words are visible to the classifier, or through \`approval run\` with a granted token. No policy or open window authorizes bytes the hook cannot read`, adapter.kind);
5124
+ }
5125
+ // APRV-415, and placed here for the reason the two refusals above are: before
5126
+ // the open window, the gate-self path, the carry and the registration, because
5127
+ // none of those can supply the directory a Hermes call leaves out. The live
5128
+ // probe established that this harness reports NO effective directory for a
5129
+ // call that does not state one — see {@link hermesUnboundDirectory} — so the
5130
+ // refusal is the Codex `Bash` refusal one harness along, narrowed to the calls
5131
+ // that actually withhold the fact.
5132
+ if (adapter.kind === "hermes") {
5133
+ const unbound = hermesUnboundDirectory(input, adapter);
5134
+ if (unbound !== null) {
5135
+ return deny(streams, "hook-unsupported-execution-context", unbound, adapter.kind);
5136
+ }
5137
+ }
5138
+ // APRV-445: a tool the adapter's own rule table gates goes on to the gated
5139
+ // path below; one it reads is allowed here, with no policy load, exactly as
5140
+ // an in-scope read is.
5141
+ const ruledEarly = toolRuleOf(adapter, input);
5142
+ if (ruledEarly?.kind === "read") {
5143
+ return allow(streams, ruledEarly.reason, adapter.kind, codexCommand);
5144
+ }
5145
+ if (ruledEarly === null &&
5146
+ input.toolName !== adapter.shellTool &&
5147
+ !adapter.fileTools.includes(input.toolName)) {
3443
5148
  if (!adapter.readTools.includes(input.toolName)) {
3444
5149
  return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
3445
5150
  }
@@ -3460,7 +5165,8 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3460
5165
  // spawn, provided the hook is configured with `--dir` as the docs show
3461
5166
  // (otherwise `hookScope` runs `git rev-parse` to find the primary).
3462
5167
  const early = hookScope(parsed.flags, cwd);
3463
- if (readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
5168
+ if (credentialReadGate(input.toolName, input.toolInput, cwd) === null &&
5169
+ readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
3464
5170
  return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
3465
5171
  }
3466
5172
  }
@@ -3511,8 +5217,14 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3511
5217
  timeoutMs,
3512
5218
  intervalMs,
3513
5219
  graceMs,
5220
+ harnessCapMs,
5221
+ harnessCapStated,
5222
+ // The ceiling before the time in line was charged, for the refusal's
5223
+ // wording only: the charged number is the one everything is judged by.
5224
+ ...(harnessCapMs !== capMs && capMs !== null ? { harnessCapCeilingMs: capMs } : {}),
3514
5225
  codexCommand,
3515
5226
  windowRecords: looked.records,
5227
+ waitSeam,
3516
5228
  }));
3517
5229
  }
3518
5230
  /**
@@ -3533,6 +5245,9 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
3533
5245
  */
3534
5246
  export function decideHarnessCall(decide) {
3535
5247
  const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
5248
+ const harnessCapMs = decide.harnessCapMs ?? null;
5249
+ const harnessCapStated = decide.harnessCapStated ?? false;
5250
+ const harnessCapCeilingMs = decide.harnessCapCeilingMs ?? null;
3536
5251
  // The policy is read BEFORE the command is classified (APRV-107): the
3537
5252
  // protected-path set is built-ins plus `policy.protected_paths`, so what
3538
5253
  // counts as a protected path is a policy question and the classifier cannot be
@@ -3595,6 +5310,7 @@ export function decideHarnessCall(decide) {
3595
5310
  ? codexBinding(input, cwd).task
3596
5311
  : `hook:${input.sessionId}:${input.toolUseId ?? randomBytes(8).toString("hex")}`;
3597
5312
  const run = {
5313
+ waitSeam: decide.waitSeam ?? null,
3598
5314
  logPath,
3599
5315
  options,
3600
5316
  actor,
@@ -3602,6 +5318,13 @@ export function decideHarnessCall(decide) {
3602
5318
  intervalMs,
3603
5319
  graceMs,
3604
5320
  ttlMs: load.durations.approvalTtlMs,
5321
+ harnessCapMs,
5322
+ harnessCapStated,
5323
+ harnessCapCeilingMs,
5324
+ harnessCapRepair: adapter.capRepair ?? null,
5325
+ waitMs: harnessCapMs === null
5326
+ ? timeoutMs
5327
+ : Math.max(0, Math.min(timeoutMs, harnessCapMs - HARNESS_CAP_MARGIN_MS)),
3605
5328
  harness: adapter.kind,
3606
5329
  originApp: adapter.originApp,
3607
5330
  ...(codexCommand === undefined ? {} : { codexCommand }),
@@ -3732,6 +5455,33 @@ export function decideHarnessCall(decide) {
3732
5455
  }
3733
5456
  }
3734
5457
  if (floor === null && autonomies.every((autonomy) => autonomy === "autonomous")) {
5458
+ // APRV-410. The sweep that takes back this actor's abandoned questions used
5459
+ // to live only in `gateHarnessCall`, which an autonomous command never
5460
+ // reaches — so a session whose retry classified autonomously left its
5461
+ // expired question standing until the TTL, and a human's tap on it six
5462
+ // minutes past the grace was recorded as a grant nobody held (log seq
5463
+ // 64473, 2026-09-20). The withdrawal has to be made by the REQUESTER, which
5464
+ // is this actor: `withdraw` refuses a `system:` actor in as many words, and
5465
+ // the event schema's cross-rule binds a `system:` withdrawal to
5466
+ // `policy-drift` alone (APRV-235), so the daemon is not a candidate and the
5467
+ // next invocation of the asking actor is the only one there is.
5468
+ //
5469
+ // `windowRecords` is the verified read `lookupWindow` already performed on
5470
+ // this invocation, so the sweep costs one append when there is something to
5471
+ // take back and nothing at all when there is not. `withdraw` re-reads and
5472
+ // compare-and-appends against its own head (SPEC.md §11.1 invariant 5), so
5473
+ // a question decided between that read and this call refuses
5474
+ // `already-decided` and is passed over rather than overwritten.
5475
+ //
5476
+ // `keepHash` is this invocation's own bytes, exactly as the intake sweep
5477
+ // passes it: a sibling hook process waiting on a question about the same
5478
+ // payload is never taken back from under it.
5479
+ if (windowRecords !== null) {
5480
+ const swept = withdrawAbandoned(run, streams, windowRecords, new Date().toISOString(), payloadHash(payload));
5481
+ if (swept.length > 0) {
5482
+ streams.err(`approval: withdrew ${String(swept.length)} abandoned harness request(s) nothing retried (${swept.join(", ")}); a tap on one of them now authorizes nothing\n`);
5483
+ }
5484
+ }
3735
5485
  // No approval lifecycle: an autonomous action has none (amended SPEC.md
3736
5486
  // §6.3), so nothing is requested, decided or granted here. What IS appended
3737
5487
  // since APRV-141 is the execution record itself — the moment the policy
@@ -3755,9 +5505,12 @@ export function decideHarnessCall(decide) {
3755
5505
  // missing one (see `preflightLog`) — and `register` appends the first line.
3756
5506
  return gateHarnessCall(streams, run, classes, payload, headline, task, note, floor);
3757
5507
  }
3758
- function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
5508
+ function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5509
+ if (adapter.kind === "hermes") {
5510
+ return hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
5511
+ }
3759
5512
  try {
3760
- return runHarnessHook(argv, streams, cwd, readStdin, adapter);
5513
+ return runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
3761
5514
  }
3762
5515
  catch (cause) {
3763
5516
  // A hook that throws is a hook the harness treats as a non-blocking error,
@@ -3767,6 +5520,129 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
3767
5520
  return deny(streams, "hook-io", `the hook failed: ${cause instanceof Error ? cause.message : String(cause)}`, adapter.kind);
3768
5521
  }
3769
5522
  }
5523
+ /**
5524
+ * The Hermes hook's last line of defence (APRV-445): whatever happened inside,
5525
+ * a `pre_tool_call` answer leaves this function as a verdict Hermes blocks on or
5526
+ * as an explicit allow, and never as anything Hermes would read as an allow by
5527
+ * default.
5528
+ *
5529
+ * Hermes (v2026.9.24, `agent/shell_hooks.py`) blocks on exit 2 whatever stdout
5530
+ * says, and blocks on any other non-zero exit only when stdout carries a block
5531
+ * directive; ANY OTHER NON-ZERO EXIT WITH AN EMPTY STDOUT IS AN ALLOW, with a
5532
+ * warning in a log nobody reads. So this enforces two rules on every path:
5533
+ *
5534
+ * - a non-zero exit always carries the `{action:"block"}` directive on stdout
5535
+ * and is always exactly 2;
5536
+ * - exit 0 on a pre-event is only ever the adapter's own `{}` allow. A path that
5537
+ * returned 0 having printed nothing is a path nobody wrote a verdict for, and
5538
+ * it blocks.
5539
+ *
5540
+ * A post-event is untouched: it never blocks (see the post path), and it prints
5541
+ * nothing on stdout by design. A throw becomes the same block the other
5542
+ * adapters' catch produces.
5543
+ */
5544
+ function hermesFailClosed(argv, streams, cwd, readStdin, adapter,
5545
+ /** APRV-427: serve's wait seam, passed through untouched (null on the CLI). */
5546
+ waitSeam) {
5547
+ let stdout = "";
5548
+ const tracked = {
5549
+ out: (text) => {
5550
+ stdout += text;
5551
+ streams.out(text);
5552
+ },
5553
+ err: (text) => streams.err(text),
5554
+ };
5555
+ // The post-event question is answered from the raw input, once, before the
5556
+ // run: the run itself may be what fails to read it.
5557
+ let raw = null;
5558
+ const reading = () => {
5559
+ raw ??= readStdin();
5560
+ return raw;
5561
+ };
5562
+ // A signal before the wait installs its own handler. The default disposition
5563
+ // of SIGTERM and SIGINT is to die with nothing on stdout, which Hermes reads
5564
+ // as an allow, and the wait's handler (which withdraws and prints
5565
+ // `hook-interrupted`) exists only from the moment the hook starts waiting.
5566
+ // This guard covers the whole run before and after that stretch; the wait's
5567
+ // handler is prepended, so inside the wait it answers first and exits. CLI
5568
+ // only: under `approval serve` (a wait seam) the process's signals belong to
5569
+ // the server.
5570
+ const onEarlySignal = (signal) => {
5571
+ if (raw !== null && isHermesPostEvent(raw))
5572
+ process.exit(EXIT_OK);
5573
+ if (stdout.length === 0) {
5574
+ try {
5575
+ writeSync(1, harnessBlockDirective("hook-interrupted", `the hook received ${signal} before it reached a verdict; nothing authorizes this call`, "hermes").stdout);
5576
+ }
5577
+ catch {
5578
+ // stdout is gone; the exit code below is the whole verdict.
5579
+ }
5580
+ }
5581
+ process.exit(HERMES_DENY_EXIT);
5582
+ };
5583
+ const onEarlyTerm = () => onEarlySignal("SIGTERM");
5584
+ const onEarlyInt = () => onEarlySignal("SIGINT");
5585
+ if (waitSeam === null) {
5586
+ process.on("SIGTERM", onEarlyTerm);
5587
+ process.on("SIGINT", onEarlyInt);
5588
+ }
5589
+ try {
5590
+ return hermesVerdict();
5591
+ }
5592
+ finally {
5593
+ if (waitSeam === null) {
5594
+ process.off("SIGTERM", onEarlyTerm);
5595
+ process.off("SIGINT", onEarlyInt);
5596
+ }
5597
+ }
5598
+ function hermesVerdict() {
5599
+ let code;
5600
+ try {
5601
+ code = runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
5602
+ }
5603
+ catch (cause) {
5604
+ // A post-event never blocks: the call already ran (see the post path).
5605
+ if (raw !== null && isHermesPostEvent(raw))
5606
+ return EXIT_OK;
5607
+ if (stdout.length > 0) {
5608
+ // A verdict was already printed; one more object would be unparseable.
5609
+ return HERMES_DENY_EXIT;
5610
+ }
5611
+ return deny(tracked, "hook-io", `the hook failed: ${cause instanceof Error ? cause.message : String(cause)}`, adapter.kind);
5612
+ }
5613
+ const post = raw !== null && isHermesPostEvent(raw);
5614
+ if (post)
5615
+ return code;
5616
+ const blocked = /"action"\s*:\s*"block"/u.test(stdout);
5617
+ if (code === EXIT_OK && stdout.length > 0)
5618
+ return code;
5619
+ if (code === EXIT_OK || !blocked) {
5620
+ if (stdout.length > 0) {
5621
+ // Something that is not a block is already on stdout, and a second
5622
+ // object after it is unparseable stdout, which `fail_closed` blocks and a
5623
+ // build without it allows. The exit code is the verdict that cannot be
5624
+ // misread, so it carries the block.
5625
+ return HERMES_DENY_EXIT;
5626
+ }
5627
+ return deny(tracked, "hook-io", code === EXIT_OK
5628
+ ? "the hook reached no verdict for this call; nothing authorizes it"
5629
+ : `the hook exited ${String(code)} without a verdict; nothing authorizes this call`, adapter.kind);
5630
+ }
5631
+ return HERMES_DENY_EXIT;
5632
+ }
5633
+ }
5634
+ /** Is this raw Hermes event a `post_tool_call`? Unreadable input is not. */
5635
+ function isHermesPostEvent(raw) {
5636
+ try {
5637
+ const parsed = JSON.parse(raw);
5638
+ if (typeof parsed !== "object" || parsed === null)
5639
+ return false;
5640
+ return parsed["hook_event_name"] === HERMES_POST_TOOL_EVENT;
5641
+ }
5642
+ catch {
5643
+ return false;
5644
+ }
5645
+ }
3770
5646
  // ===========================================================================
3771
5647
  // Dispatch
3772
5648
  // ===========================================================================
@@ -3774,7 +5650,13 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
3774
5650
  function defaultStdin() {
3775
5651
  return readFileSync(0, "utf8");
3776
5652
  }
3777
- export function commandHook(argv, streams, cwd, readStdin = defaultStdin) {
5653
+ export function commandHook(argv, streams, cwd, readStdin = defaultStdin,
5654
+ /**
5655
+ * APRV-427: where a harness hook's wait begins and ends, for `approval serve`,
5656
+ * which runs the call off its main thread and releases its store lock while
5657
+ * the call polls. Every CLI route omits it. See {@link HookWaitSeam}.
5658
+ */
5659
+ waitSeam = null) {
3778
5660
  const sub = argv[0];
3779
5661
  const rest = argv.slice(1);
3780
5662
  if (sub === undefined) {
@@ -3788,7 +5670,7 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin) {
3788
5670
  // boundary and doctor use, so a kind that can be recorded is a kind that can
3789
5671
  // be invoked, and neither can be added without the other.
3790
5672
  if (isHarnessKind(sub)) {
3791
- return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub]);
5673
+ return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
3792
5674
  }
3793
5675
  if (sub === "classify")
3794
5676
  return commandClassify(rest, streams, cwd);