approval-md 0.0.1 → 0.1.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 (522) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +5 -0
  3. package/README.md +909 -4
  4. package/SPEC.md +445 -32
  5. package/cli.js +29 -3
  6. package/dist/src/adapters/agentmail.js +1200 -0
  7. package/dist/src/adapters/agentmail.js.map +1 -0
  8. package/dist/src/adapters/conformance.js +461 -0
  9. package/dist/src/adapters/conformance.js.map +1 -0
  10. package/dist/src/adapters/contract.js +941 -0
  11. package/dist/src/adapters/contract.js.map +1 -0
  12. package/dist/src/adapters/email.js +749 -0
  13. package/dist/src/adapters/email.js.map +1 -0
  14. package/dist/src/adapters/env-passphrase.js +132 -0
  15. package/dist/src/adapters/env-passphrase.js.map +1 -0
  16. package/dist/src/adapters/registry.js +76 -0
  17. package/dist/src/adapters/registry.js.map +1 -0
  18. package/dist/src/adapters/smtp.js +499 -0
  19. package/dist/src/adapters/smtp.js.map +1 -0
  20. package/dist/src/adapters/vault-provider.js +161 -0
  21. package/dist/src/adapters/vault-provider.js.map +1 -0
  22. package/dist/src/channels/batch.js +121 -0
  23. package/dist/src/channels/batch.js.map +1 -0
  24. package/dist/src/channels/cli.js +468 -0
  25. package/dist/src/channels/cli.js.map +1 -0
  26. package/dist/src/channels/conformance.js +445 -0
  27. package/dist/src/channels/conformance.js.map +1 -0
  28. package/dist/src/channels/contract.js +494 -0
  29. package/dist/src/channels/contract.js.map +1 -0
  30. package/dist/src/channels/payload-view.js +43 -0
  31. package/dist/src/channels/payload-view.js.map +1 -0
  32. package/dist/src/channels/render-queue.js +564 -0
  33. package/dist/src/channels/render-queue.js.map +1 -0
  34. package/dist/src/channels/tagging.js +723 -0
  35. package/dist/src/channels/tagging.js.map +1 -0
  36. package/dist/src/channels/telegram.js +3190 -0
  37. package/dist/src/channels/telegram.js.map +1 -0
  38. package/dist/src/channels/web.js +903 -0
  39. package/dist/src/channels/web.js.map +1 -0
  40. package/dist/src/cli/adapter.js +278 -0
  41. package/dist/src/cli/adapter.js.map +1 -0
  42. package/dist/src/cli/amend.js +2171 -0
  43. package/dist/src/cli/amend.js.map +1 -0
  44. package/dist/src/cli/args.js +86 -0
  45. package/dist/src/cli/args.js.map +1 -0
  46. package/dist/src/cli/attest.js +307 -0
  47. package/dist/src/cli/attest.js.map +1 -0
  48. package/dist/src/cli/audit-card.js +201 -0
  49. package/dist/src/cli/audit-card.js.map +1 -0
  50. package/dist/src/cli/audit.js +460 -0
  51. package/dist/src/cli/audit.js.map +1 -0
  52. package/dist/src/cli/channel-telegram.js +2063 -0
  53. package/dist/src/cli/channel-telegram.js.map +1 -0
  54. package/dist/src/cli/channel-web.js +357 -0
  55. package/dist/src/cli/channel-web.js.map +1 -0
  56. package/dist/src/cli/channel.js +438 -0
  57. package/dist/src/cli/channel.js.map +1 -0
  58. package/dist/src/cli/checkpoint-tap.js +238 -0
  59. package/dist/src/cli/checkpoint-tap.js.map +1 -0
  60. package/dist/src/cli/coverage.js +343 -0
  61. package/dist/src/cli/coverage.js.map +1 -0
  62. package/dist/src/cli/daemon.js +631 -0
  63. package/dist/src/cli/daemon.js.map +1 -0
  64. package/dist/src/cli/doctor.js +2648 -0
  65. package/dist/src/cli/doctor.js.map +1 -0
  66. package/dist/src/cli/env.js +302 -0
  67. package/dist/src/cli/env.js.map +1 -0
  68. package/dist/src/cli/execute.js +1682 -0
  69. package/dist/src/cli/execute.js.map +1 -0
  70. package/dist/src/cli/exit-codes.js +82 -0
  71. package/dist/src/cli/exit-codes.js.map +1 -0
  72. package/dist/src/cli/feedback.js +205 -0
  73. package/dist/src/cli/feedback.js.map +1 -0
  74. package/dist/src/cli/gate-window.js +294 -0
  75. package/dist/src/cli/gate-window.js.map +1 -0
  76. package/dist/src/cli/gate.js +557 -0
  77. package/dist/src/cli/gate.js.map +1 -0
  78. package/dist/src/cli/git-scope.js +295 -0
  79. package/dist/src/cli/git-scope.js.map +1 -0
  80. package/dist/src/cli/gloss-attach.js +107 -0
  81. package/dist/src/cli/gloss-attach.js.map +1 -0
  82. package/dist/src/cli/gloss-codex-child.js +149 -0
  83. package/dist/src/cli/gloss-codex-child.js.map +1 -0
  84. package/dist/src/cli/gloss-codex.js +255 -0
  85. package/dist/src/cli/gloss-codex.js.map +1 -0
  86. package/dist/src/cli/gloss-options.js +79 -0
  87. package/dist/src/cli/gloss-options.js.map +1 -0
  88. package/dist/src/cli/gloss.js +362 -0
  89. package/dist/src/cli/gloss.js.map +1 -0
  90. package/dist/src/cli/help.js +2217 -0
  91. package/dist/src/cli/help.js.map +1 -0
  92. package/dist/src/cli/hook.js +2743 -0
  93. package/dist/src/cli/hook.js.map +1 -0
  94. package/dist/src/cli/import.js +175 -0
  95. package/dist/src/cli/import.js.map +1 -0
  96. package/dist/src/cli/init.js +336 -0
  97. package/dist/src/cli/init.js.map +1 -0
  98. package/dist/src/cli/instructions.js +262 -0
  99. package/dist/src/cli/instructions.js.map +1 -0
  100. package/dist/src/cli/journal.js +238 -0
  101. package/dist/src/cli/journal.js.map +1 -0
  102. package/dist/src/cli/log-advance.js +749 -0
  103. package/dist/src/cli/log-advance.js.map +1 -0
  104. package/dist/src/cli/log-anchor.js +387 -0
  105. package/dist/src/cli/log-anchor.js.map +1 -0
  106. package/dist/src/cli/log-checkpoint.js +128 -0
  107. package/dist/src/cli/log-checkpoint.js.map +1 -0
  108. package/dist/src/cli/log-sync.js +849 -0
  109. package/dist/src/cli/log-sync.js.map +1 -0
  110. package/dist/src/cli/log-verbs.js +354 -0
  111. package/dist/src/cli/log-verbs.js.map +1 -0
  112. package/dist/src/cli/long-help.js +148 -0
  113. package/dist/src/cli/long-help.js.map +1 -0
  114. package/dist/src/cli/main.js +1056 -0
  115. package/dist/src/cli/main.js.map +1 -0
  116. package/dist/src/cli/mcp.js +306 -0
  117. package/dist/src/cli/mcp.js.map +1 -0
  118. package/dist/src/cli/paths.js +79 -0
  119. package/dist/src/cli/paths.js.map +1 -0
  120. package/dist/src/cli/payload.js +253 -0
  121. package/dist/src/cli/payload.js.map +1 -0
  122. package/dist/src/cli/policy.js +229 -0
  123. package/dist/src/cli/policy.js.map +1 -0
  124. package/dist/src/cli/preflight.js +888 -0
  125. package/dist/src/cli/preflight.js.map +1 -0
  126. package/dist/src/cli/progress.js +112 -0
  127. package/dist/src/cli/progress.js.map +1 -0
  128. package/dist/src/cli/prompt.js +312 -0
  129. package/dist/src/cli/prompt.js.map +1 -0
  130. package/dist/src/cli/records.js +66 -0
  131. package/dist/src/cli/records.js.map +1 -0
  132. package/dist/src/cli/render.js +132 -0
  133. package/dist/src/cli/render.js.map +1 -0
  134. package/dist/src/cli/sandbox.js +150 -0
  135. package/dist/src/cli/sandbox.js.map +1 -0
  136. package/dist/src/cli/scaffold.js +137 -0
  137. package/dist/src/cli/scaffold.js.map +1 -0
  138. package/dist/src/cli/setup-adapter.js +475 -0
  139. package/dist/src/cli/setup-adapter.js.map +1 -0
  140. package/dist/src/cli/setup-channel.js +635 -0
  141. package/dist/src/cli/setup-channel.js.map +1 -0
  142. package/dist/src/cli/setup-checkpoint.js +196 -0
  143. package/dist/src/cli/setup-checkpoint.js.map +1 -0
  144. package/dist/src/cli/setup-common.js +376 -0
  145. package/dist/src/cli/setup-common.js.map +1 -0
  146. package/dist/src/cli/setup-flow.js +476 -0
  147. package/dist/src/cli/setup-flow.js.map +1 -0
  148. package/dist/src/cli/setup-service.js +308 -0
  149. package/dist/src/cli/setup-service.js.map +1 -0
  150. package/dist/src/cli/setup.js +473 -0
  151. package/dist/src/cli/setup.js.map +1 -0
  152. package/dist/src/cli/style.js +469 -0
  153. package/dist/src/cli/style.js.map +1 -0
  154. package/dist/src/cli/token.js +274 -0
  155. package/dist/src/cli/token.js.map +1 -0
  156. package/dist/src/cli/up.js +847 -0
  157. package/dist/src/cli/up.js.map +1 -0
  158. package/dist/src/cli/usage.js +91 -0
  159. package/dist/src/cli/usage.js.map +1 -0
  160. package/dist/src/cli/values.js +189 -0
  161. package/dist/src/cli/values.js.map +1 -0
  162. package/dist/src/cli/vault.js +362 -0
  163. package/dist/src/cli/vault.js.map +1 -0
  164. package/dist/src/cli/verb-registry.js +2173 -0
  165. package/dist/src/cli/verb-registry.js.map +1 -0
  166. package/dist/src/cli/wordmark.js +52 -0
  167. package/dist/src/cli/wordmark.js.map +1 -0
  168. package/dist/src/core/advance-cycle.js +200 -0
  169. package/dist/src/core/advance-cycle.js.map +1 -0
  170. package/dist/src/core/agents-md.js +747 -0
  171. package/dist/src/core/agents-md.js.map +1 -0
  172. package/dist/src/core/attest.js +577 -0
  173. package/dist/src/core/attest.js.map +1 -0
  174. package/dist/src/core/audit.js +882 -0
  175. package/dist/src/core/audit.js.map +1 -0
  176. package/dist/src/core/budgets.js +449 -0
  177. package/dist/src/core/budgets.js.map +1 -0
  178. package/dist/src/core/checkpoint.js +738 -0
  179. package/dist/src/core/checkpoint.js.map +1 -0
  180. package/dist/src/core/child-env.js +86 -0
  181. package/dist/src/core/child-env.js.map +1 -0
  182. package/dist/src/core/clock.js +43 -0
  183. package/dist/src/core/clock.js.map +1 -0
  184. package/dist/src/core/command-class.js +2321 -0
  185. package/dist/src/core/command-class.js.map +1 -0
  186. package/dist/src/core/coverage-sources/adapter.js +71 -0
  187. package/dist/src/core/coverage-sources/adapter.js.map +1 -0
  188. package/dist/src/core/coverage-sources/gh.js +136 -0
  189. package/dist/src/core/coverage-sources/gh.js.map +1 -0
  190. package/dist/src/core/coverage-sources/git.js +269 -0
  191. package/dist/src/core/coverage-sources/git.js.map +1 -0
  192. package/dist/src/core/coverage.js +337 -0
  193. package/dist/src/core/coverage.js.map +1 -0
  194. package/dist/src/core/credential-spec.js +23 -0
  195. package/dist/src/core/credential-spec.js.map +1 -0
  196. package/dist/src/core/dark-session.js +714 -0
  197. package/dist/src/core/dark-session.js.map +1 -0
  198. package/dist/src/core/decision-refusal.js +265 -0
  199. package/dist/src/core/decision-refusal.js.map +1 -0
  200. package/dist/src/core/env-file.js +837 -0
  201. package/dist/src/core/env-file.js.map +1 -0
  202. package/dist/src/core/execute.js +1233 -0
  203. package/dist/src/core/execute.js.map +1 -0
  204. package/dist/src/core/frontmatter.js +100 -0
  205. package/dist/src/core/frontmatter.js.map +1 -0
  206. package/dist/src/core/gate-window.js +506 -0
  207. package/dist/src/core/gate-window.js.map +1 -0
  208. package/dist/src/core/gate.js +2947 -0
  209. package/dist/src/core/gate.js.map +1 -0
  210. package/dist/src/core/git-run.js +93 -0
  211. package/dist/src/core/git-run.js.map +1 -0
  212. package/dist/src/core/harness-version.js +210 -0
  213. package/dist/src/core/harness-version.js.map +1 -0
  214. package/dist/src/core/harness-wait.js +58 -0
  215. package/dist/src/core/harness-wait.js.map +1 -0
  216. package/dist/src/core/head-retry.js +121 -0
  217. package/dist/src/core/head-retry.js.map +1 -0
  218. package/dist/src/core/instance.js +319 -0
  219. package/dist/src/core/instance.js.map +1 -0
  220. package/dist/src/core/intake-limits.js +350 -0
  221. package/dist/src/core/intake-limits.js.map +1 -0
  222. package/dist/src/core/jcs.js +132 -0
  223. package/dist/src/core/jcs.js.map +1 -0
  224. package/dist/src/core/journal.js +200 -0
  225. package/dist/src/core/journal.js.map +1 -0
  226. package/dist/src/core/live-draw.js +703 -0
  227. package/dist/src/core/live-draw.js.map +1 -0
  228. package/dist/src/core/log-reconcile.js +136 -0
  229. package/dist/src/core/log-reconcile.js.map +1 -0
  230. package/dist/src/core/log.js +546 -0
  231. package/dist/src/core/log.js.map +1 -0
  232. package/dist/src/core/loop.js +476 -0
  233. package/dist/src/core/loop.js.map +1 -0
  234. package/dist/src/core/md-fence.js +74 -0
  235. package/dist/src/core/md-fence.js.map +1 -0
  236. package/dist/src/core/money.js +195 -0
  237. package/dist/src/core/money.js.map +1 -0
  238. package/dist/src/core/payload-census.js +146 -0
  239. package/dist/src/core/payload-census.js.map +1 -0
  240. package/dist/src/core/payload-store.js +340 -0
  241. package/dist/src/core/payload-store.js.map +1 -0
  242. package/dist/src/core/payload.js +80 -0
  243. package/dist/src/core/payload.js.map +1 -0
  244. package/dist/src/core/policy-diff.js +565 -0
  245. package/dist/src/core/policy-diff.js.map +1 -0
  246. package/dist/src/core/policy-expectations.js +394 -0
  247. package/dist/src/core/policy-expectations.js.map +1 -0
  248. package/dist/src/core/policy-explain.js +230 -0
  249. package/dist/src/core/policy-explain.js.map +1 -0
  250. package/dist/src/core/policy-load.js +524 -0
  251. package/dist/src/core/policy-load.js.map +1 -0
  252. package/dist/src/core/policy-match.js +467 -0
  253. package/dist/src/core/policy-match.js.map +1 -0
  254. package/dist/src/core/policy-proposal.js +458 -0
  255. package/dist/src/core/policy-proposal.js.map +1 -0
  256. package/dist/src/core/prompt-layout.js +422 -0
  257. package/dist/src/core/prompt-layout.js.map +1 -0
  258. package/dist/src/core/protected-path-guard.js +1087 -0
  259. package/dist/src/core/protected-path-guard.js.map +1 -0
  260. package/dist/src/core/registration.js +39 -0
  261. package/dist/src/core/registration.js.map +1 -0
  262. package/dist/src/core/reindex.js +336 -0
  263. package/dist/src/core/reindex.js.map +1 -0
  264. package/dist/src/core/sampler.js +388 -0
  265. package/dist/src/core/sampler.js.map +1 -0
  266. package/dist/src/core/sandbox.js +424 -0
  267. package/dist/src/core/sandbox.js.map +1 -0
  268. package/dist/src/core/seal.js +290 -0
  269. package/dist/src/core/seal.js.map +1 -0
  270. package/dist/src/core/state.js +1009 -0
  271. package/dist/src/core/state.js.map +1 -0
  272. package/dist/src/core/task-file.js +464 -0
  273. package/dist/src/core/task-file.js.map +1 -0
  274. package/dist/src/core/telegram-config.js +114 -0
  275. package/dist/src/core/telegram-config.js.map +1 -0
  276. package/dist/src/core/token.js +578 -0
  277. package/dist/src/core/token.js.map +1 -0
  278. package/dist/src/core/validate.js +0 -0
  279. package/dist/src/core/validate.js.map +1 -0
  280. package/dist/src/core/values.js +153 -0
  281. package/dist/src/core/values.js.map +1 -0
  282. package/dist/src/core/vault.js +612 -0
  283. package/dist/src/core/vault.js.map +1 -0
  284. package/dist/src/core/verified-snapshot.js +506 -0
  285. package/dist/src/core/verified-snapshot.js.map +1 -0
  286. package/dist/src/core/verify.js +549 -0
  287. package/dist/src/core/verify.js.map +1 -0
  288. package/dist/src/core/version.js +9 -0
  289. package/dist/src/core/version.js.map +1 -0
  290. package/dist/src/core/wysiwys.js +728 -0
  291. package/dist/src/core/wysiwys.js.map +1 -0
  292. package/dist/src/daemon/advance-child.js +78 -0
  293. package/dist/src/daemon/advance-child.js.map +1 -0
  294. package/dist/src/daemon/advance.js +849 -0
  295. package/dist/src/daemon/advance.js.map +1 -0
  296. package/dist/src/daemon/audit.js +90 -0
  297. package/dist/src/daemon/audit.js.map +1 -0
  298. package/dist/src/daemon/daemon.js +1988 -0
  299. package/dist/src/daemon/daemon.js.map +1 -0
  300. package/dist/src/daemon/dark-session.js +119 -0
  301. package/dist/src/daemon/dark-session.js.map +1 -0
  302. package/dist/src/daemon/draw-child.js +132 -0
  303. package/dist/src/daemon/draw-child.js.map +1 -0
  304. package/dist/src/daemon/draw.js +458 -0
  305. package/dist/src/daemon/draw.js.map +1 -0
  306. package/dist/src/daemon/git-evidence.js +345 -0
  307. package/dist/src/daemon/git-evidence.js.map +1 -0
  308. package/dist/src/daemon/projection.js +233 -0
  309. package/dist/src/daemon/projection.js.map +1 -0
  310. package/dist/src/daemon/prune.js +376 -0
  311. package/dist/src/daemon/prune.js.map +1 -0
  312. package/dist/src/mcp/http.js +343 -0
  313. package/dist/src/mcp/http.js.map +1 -0
  314. package/dist/src/mcp/server.js +594 -0
  315. package/dist/src/mcp/server.js.map +1 -0
  316. package/docs/cli-reference.md +5363 -0
  317. package/package.json +43 -4
  318. package/schema/.gitkeep +0 -0
  319. package/schema/LICENSE +117 -0
  320. package/schema/envelope.schema.json +137 -0
  321. package/schema/event.schema.json +1810 -0
  322. package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
  323. package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
  324. package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
  325. package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
  326. package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
  327. package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
  328. package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
  329. package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
  330. package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
  331. package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
  332. package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
  333. package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
  334. package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
  335. package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
  336. package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
  337. package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
  338. package/schema/fixtures/envelope/valid/canonical.json +25 -0
  339. package/schema/fixtures/envelope/valid/minimal.json +7 -0
  340. package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
  341. package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
  342. package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
  343. package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
  344. package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
  345. package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
  346. package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
  347. package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
  348. package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
  349. package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
  350. package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
  351. package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
  352. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
  353. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
  354. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
  355. package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
  356. package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
  357. package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
  358. package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
  359. package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
  360. package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
  361. package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
  362. package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
  363. package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
  364. package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
  365. package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
  366. package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
  367. package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
  368. package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
  369. package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
  370. package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
  371. package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
  372. package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
  373. package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
  374. package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
  375. package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
  376. package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
  377. package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
  378. package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
  379. package/schema/fixtures/event/invalid/missing-alg.json +14 -0
  380. package/schema/fixtures/event/invalid/missing-hash.json +14 -0
  381. package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
  382. package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
  383. package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
  384. package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
  385. package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
  386. package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
  387. package/schema/fixtures/event/invalid/short-hash.json +15 -0
  388. package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
  389. package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
  390. package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
  391. package/schema/fixtures/event/valid/approval-expired.json +15 -0
  392. package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
  393. package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
  394. package/schema/fixtures/event/valid/approval-granted.json +15 -0
  395. package/schema/fixtures/event/valid/approval-rejected.json +15 -0
  396. package/schema/fixtures/event/valid/approval-requested.json +19 -0
  397. package/schema/fixtures/event/valid/approval-revoked.json +15 -0
  398. package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
  399. package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
  400. package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
  401. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
  402. package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
  403. package/schema/fixtures/event/valid/audit-sampled.json +14 -0
  404. package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
  405. package/schema/fixtures/event/valid/envelope-drift.json +16 -0
  406. package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
  407. package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
  408. package/schema/fixtures/event/valid/execution-completed.json +15 -0
  409. package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
  410. package/schema/fixtures/event/valid/execution-failed.json +15 -0
  411. package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
  412. package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
  413. package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
  414. package/schema/fixtures/event/valid/execution-started.json +14 -0
  415. package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
  416. package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
  417. package/schema/fixtures/event/valid/gate-closed.json +14 -0
  418. package/schema/fixtures/event/valid/gate-opened.json +16 -0
  419. package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
  420. package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
  421. package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
  422. package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
  423. package/schema/fixtures/event/valid/payload-pruned.json +17 -0
  424. package/schema/fixtures/event/valid/policy-declined.json +16 -0
  425. package/schema/fixtures/event/valid/policy-proposed.json +35 -0
  426. package/schema/fixtures/event/valid/policy-updated.json +14 -0
  427. package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
  428. package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
  429. package/schema/fixtures/event/valid/route-accepted.json +15 -0
  430. package/schema/fixtures/event/valid/route-proposed.json +16 -0
  431. package/schema/fixtures/event/valid/spec-example.json +15 -0
  432. package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
  433. package/schema/fixtures/event/valid/task-registered.json +14 -0
  434. package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
  435. package/schema/fixtures/hash/known-answer.json +74 -0
  436. package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
  437. package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
  438. package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
  439. package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
  440. package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
  441. package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
  442. package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
  443. package/schema/fixtures/policy/invalid/missing-version.json +8 -0
  444. package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
  445. package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
  446. package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
  447. package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
  448. package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
  449. package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
  450. package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
  451. package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
  452. package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
  453. package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
  454. package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
  455. package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
  456. package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
  457. package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
  458. package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
  459. package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
  460. package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
  461. package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
  462. package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
  463. package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
  464. package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
  465. package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
  466. package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
  467. package/schema/fixtures/policy/valid/canonical.json +47 -0
  468. package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
  469. package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
  470. package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
  471. package/schema/fixtures/policy/valid/global-budgets.json +19 -0
  472. package/schema/fixtures/policy/valid/human-only.json +9 -0
  473. package/schema/fixtures/policy/valid/minimal.json +6 -0
  474. package/schema/fixtures/policy/valid/protected-paths.json +10 -0
  475. package/schema/fixtures/policy/valid/record-namespace.json +13 -0
  476. package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
  477. package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
  478. package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
  479. package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
  480. package/schema/fixtures/policy/valid/wildcards.json +15 -0
  481. package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
  482. package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
  483. package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
  484. package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
  485. package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
  486. package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
  487. package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
  488. package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
  489. package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
  490. package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
  491. package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
  492. package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
  493. package/schema/fixtures/policy-md/valid/canonical.md +50 -0
  494. package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
  495. package/schema/fixtures/policy-md/valid/minimal.md +3 -0
  496. package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
  497. package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
  498. package/schema/fixtures/policy-md/valid/with-values.md +79 -0
  499. package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
  500. package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
  501. package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
  502. package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
  503. package/schema/fixtures/sample-record/valid/minimal.json +4 -0
  504. package/schema/fixtures/sample-record/valid/with-note.json +5 -0
  505. package/schema/fixtures/values/invalid/class-shaped.json +9 -0
  506. package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
  507. package/schema/fixtures/values/invalid/non-string-item.json +4 -0
  508. package/schema/fixtures/values/invalid/over-cap.json +26 -0
  509. package/schema/fixtures/values/invalid/unknown-key.json +5 -0
  510. package/schema/fixtures/values/invalid/version-string.json +1 -0
  511. package/schema/fixtures/values/valid/empty-lists.json +7 -0
  512. package/schema/fixtures/values/valid/full.json +20 -0
  513. package/schema/fixtures/values/valid/minimal.json +1 -0
  514. package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
  515. package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
  516. package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
  517. package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
  518. package/schema/fixtures/values-md/valid/absent.md +50 -0
  519. package/schema/fixtures/values-md/valid/with-values.md +79 -0
  520. package/schema/policy.schema.json +481 -0
  521. package/schema/sample-record.schema.json +26 -0
  522. package/schema/values.schema.json +55 -0
@@ -0,0 +1,2743 @@
1
+ /**
2
+ * `approval hook` — harness adapters that put the gate in front of the commands
3
+ * an agent's harness runs directly (APRV-82 Claude Code, APRV-133 Cursor).
4
+ *
5
+ * The problem it closes. Until this verb, the runtime gated what went through
6
+ * `approval run`. Everything the harness executed on its own — `git push`, `gh
7
+ * pr create`, `npm install`, `curl` — bypassed APPROVAL.md entirely, so the
8
+ * enforcement of those classes was the prose in CLAUDE.md and an agent's
9
+ * willingness to read it. That is exactly the AGENTS.md failure SPEC.md §2
10
+ * critiques, reproduced inside the repository that critiques it.
11
+ *
12
+ * As everywhere else in this CLI, **no logic lives here.** Classification is
13
+ * `core/command-class.ts` (pure, fixture-tested); registration, policy
14
+ * resolution and intake are `core/gate.ts`; the decision is derived from the
15
+ * verified log by `core/state.ts`. This file reads one JSON object from stdin,
16
+ * calls those, and prints one JSON object back.
17
+ *
18
+ * Four choices are load-bearing enough to state plainly.
19
+ *
20
+ * **It exits 0 with a verdict, or 2 with nothing.** Claude Code reads a hook's
21
+ * stdout as a decision only on exit 0; a hook that exits 2 is a *block* with the
22
+ * stderr text as the reason, and any other non-zero code is a non-blocking
23
+ * error. So every classified or decided outcome — allow and deny alike — is an
24
+ * exit 0 with `hookSpecificOutput` on stdout, and the only exit 2 is a
25
+ * misconfigured hook (an unknown flag, a bad identity), where blocking is the
26
+ * correct failure mode. No new exit code is added to the frozen table.
27
+ *
28
+ * **Never `ask`.** The permission decision vocabulary includes `ask`, which
29
+ * hands the question to the harness's own prompt. Using it would answer an
30
+ * approval question outside the log: no request, no record, no audit trail, and
31
+ * a human deciding in a UI the policy never named. The hook allows or denies,
32
+ * and every deny carries a machine-readable code.
33
+ *
34
+ * **Fail closed on every axis.** An unreadable policy, an unreachable log, a
35
+ * command the classifier cannot read, a wait that times out: all deny. A hook
36
+ * that fell back to allow when it could not reach the gate would be worst
37
+ * precisely when it mattered. Since APRV-139 that includes an unattested
38
+ * policy: a verdict nobody is asked about is checked against the verified log
39
+ * first, exactly as `core/execute.ts` checks one (see `unattendedGuard`).
40
+ *
41
+ * **The harness executes, not the runtime.** The hook decides *before* the tool
42
+ * runs and never spawns anything, so it never writes an `execution.completed`
43
+ * or `execution.failed`: the runtime does not run the command and never learns
44
+ * how it went. It does write one `execution.started`, and only where a verdict
45
+ * of `allow` rests on a human's grant — that record is the *consumption* of the
46
+ * grant (APRV-117), which a harness request needs because it mints no token
47
+ * that could be spent instead. `core/gate.ts`'s `consumeHarnessGrant` is where
48
+ * that lives and why. What the log records is otherwise the approval lifecycle:
49
+ * `task.registered`, `approval.requested`, and the human's decision.
50
+ *
51
+ * **A decision outlives the invocation that asked for it (APRV-117).** Requests
52
+ * are matched by the payload hash of `{command, cwd}`, so the answer to "may I
53
+ * run these bytes, here" belongs to the bytes rather than to one tool-use id.
54
+ * A retry while the question is pending adopts it instead of asking twice; a
55
+ * retry after a grant lands proceeds on it, once, inside the TTL. That is why
56
+ * the wait no longer ends in an immediate withdrawal: a late tap authorizes
57
+ * something. It ends in one once the RETRY GRACE has run out (APRV-287): past
58
+ * that window nothing is coming back to adopt the question, and a request left
59
+ * standing is one more dead message a restarted listener re-delivers.
60
+ *
61
+ * **An allow follows its record, and says which window it sits in (APRV-200).**
62
+ * The harness executes and never sees this process's return value, so what
63
+ * authorizes the tool call is the record and not the verdict. Every allow that
64
+ * rests on a grant therefore spends it, RE-READS the verified log to establish
65
+ * that the `execution.started` is in the chain, and only then prints — a
66
+ * `hook-grant-unverified` deny where it cannot. The record itself carries
67
+ * `grant_origin`: `direct` where the tool call that spent the grant is the tool
68
+ * call that asked for it, `carried` where a later one spent it under the
69
+ * carryover above. Only `direct` states an ordering this runtime observed;
70
+ * `carried` is the window in which a grant can be a ratification of a write the
71
+ * harness already applied, and naming it is what makes that visible to an
72
+ * auditor holding the log alone. See `docs/claude-code-hook.md`.
73
+ */
74
+ import { spawnSync } from "node:child_process";
75
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
76
+ import { randomBytes } from "node:crypto";
77
+ import { tmpdir } from "node:os";
78
+ import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
79
+ import { attestationRefusal, checkAttestation } from "../core/attest.js";
80
+ import { childEnvironment } from "../core/child-env.js";
81
+ import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
82
+ import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
83
+ import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
84
+ import { harnessProvenance, } from "../core/harness-version.js";
85
+ import { abandonedAfterMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
86
+ import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
87
+ import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
88
+ import { payloadHash } from "../core/payload.js";
89
+ import { loadPolicy, parseDuration } from "../core/policy-load.js";
90
+ import { humanOnlyRefusal, resolve as resolvePolicy } from "../core/policy-match.js";
91
+ import { payloadOf, readVerifiedRecords, requestState, useVerifiedSnapshots, } from "../core/state.js";
92
+ import { boolFlag, parseFlags, stringFlag } from "./args.js";
93
+ import { EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
94
+ import { primaryRoot as resolvePrimaryRoot } from "./git-scope.js";
95
+ import { HOOK_HELP } from "./help.js";
96
+ import { DEFAULT_LOG_PATH } from "./paths.js";
97
+ import { refusal as renderRefusal, style, table } from "./style.js";
98
+ import { usageErrorText } from "./usage.js";
99
+ /** Identity accepted for the proposing side: a person or an agent. */
100
+ const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
101
+ /**
102
+ * Default wait, chosen to sit inside Claude Code's own 60s hook default.
103
+ *
104
+ * Spelled in `core/harness-wait.ts` since APRV-287, where the Telegram
105
+ * listener reads the same duration to decide which pending requests nobody is
106
+ * waiting on any more.
107
+ */
108
+ const DEFAULT_TIMEOUT = HOOK_DEFAULT_WAIT;
109
+ /** Poll interval for the decision wait. */
110
+ const DEFAULT_INTERVAL_MS = 1_000;
111
+ /**
112
+ * How much of the command line goes in the (claimed) summary field.
113
+ *
114
+ * A HEADLINE, and only that (APRV-124). What the approver is bound to is the
115
+ * payload, which carries the whole command (or the whole change) and is never
116
+ * shortened; this is the one-line label above it. Exported because the tests
117
+ * pin the distinction.
118
+ */
119
+ export const SUMMARY_LIMIT = 160;
120
+ /**
121
+ * The closed set of hook denial codes, frozen in the sense
122
+ * `GATE_REFUSAL_CODES` is: the reason string a human reads and an agent
123
+ * branches on starts with one of these.
124
+ *
125
+ * `hook-gate-refused` is a family: the emitted code is
126
+ * `hook-gate-refused:<gate refusal code>`, so the gate's own frozen vocabulary
127
+ * reaches the caller unflattened.
128
+ */
129
+ export const HOOK_DENY_CODES = [
130
+ /** No rule covers some segment of the command line. */
131
+ "hook-unclassified",
132
+ /**
133
+ * Some class of the command resolves to `human-only` (APRV-185, amended
134
+ * SPEC.md §5.2): the policy reserves it to human hands, so the command is
135
+ * denied outright and no gate lifecycle is opened for it.
136
+ *
137
+ * This union's spelling of the gate's `class-human-only`, which the detail
138
+ * names in full. It wears the `hook-` prefix every other member wears rather
139
+ * than borrowing the gate's bare code, because a caller branching on this
140
+ * vocabulary branches on one shape; `hook-gate-refused:<c>` is the form
141
+ * reserved for a code the gate itself produced, and the gate is not asked
142
+ * here.
143
+ *
144
+ * Distinct from `hook-unclassified`, and the repairs are opposites. That one
145
+ * says the policy has nothing to say about this command, so the fix is to
146
+ * declare a class for it. This one says the policy has spoken as clearly as
147
+ * it can, and the fix is for a person to run the command themselves. Distinct
148
+ * from `hook-rejected` for the reason the gate's code is distinct from a
149
+ * rejection: nobody decided anything, so there is nothing to ask again.
150
+ */
151
+ "hook-class-human-only",
152
+ /** A construct whose effect cannot be read off the text (`bash -c`, `eval`). */
153
+ "hook-opaque",
154
+ /** The command line could not be tokenized at all. */
155
+ "hook-unparseable",
156
+ /** A human rejected the request. */
157
+ "hook-rejected",
158
+ /** A previously granted request was withdrawn. */
159
+ "hook-revoked",
160
+ /** The request's TTL lapsed before a decision. */
161
+ "hook-expired",
162
+ /**
163
+ * The request was withdrawn before a decision landed (APRV-106). Since
164
+ * APRV-117 the timeout no longer produces this: what does is a session that
165
+ * ended mid-wait (signal or failure) and an operator's `approval withdraw`.
166
+ * Terminal, and not a refusal by anyone.
167
+ */
168
+ "hook-withdrawn",
169
+ /**
170
+ * The wait elapsed with the request still undecided. The request stays open
171
+ * for the RETRY GRACE (APRV-117, bounded by APRV-287): a decision inside that
172
+ * window authorizes a retry of the identical command in the identical
173
+ * directory, once. Past the grace the hook withdraws it (reason `timeout`),
174
+ * because a question nothing will adopt is a message on a phone that decides
175
+ * nothing.
176
+ */
177
+ "hook-timeout",
178
+ /** The gate refused intake; the gate's own code follows a colon. */
179
+ "hook-gate-refused",
180
+ /**
181
+ * The grant was spent and the VERIFIED log does not show it (APRV-200).
182
+ *
183
+ * Distinct from `hook-gate-refused:append-failed`, which says the write was
184
+ * refused and nothing landed. This one says the write reported success and the
185
+ * chain cannot be seen to carry it, which is a different fact with a different
186
+ * repair: nothing here is retried, the log is checked (`approval log verify`).
187
+ *
188
+ * On this surface the record IS the authorization — the harness executes and
189
+ * never sees the gate's return value — so a verdict is not printed until the
190
+ * verified chain carries the execution the harness is about to perform. The
191
+ * grant is spent by the time this fires, which is the fail-closed direction:
192
+ * one more prompt on the retry, and nothing authorized meanwhile.
193
+ */
194
+ "hook-grant-unverified",
195
+ /**
196
+ * `APPROVAL_HOOK_REQUIRE_SANDBOX=1` is set and this command runs code the
197
+ * runtime did not author, unwrapped (APRV-193).
198
+ *
199
+ * The one deny in this union that names a spelling that would work rather
200
+ * than a decision or a fault: re-run it as `approval sandbox -- <cmd>` and it
201
+ * proceeds, classified exactly as it is now, with no way out to the network.
202
+ *
203
+ * It exists because the hook DECIDES and the harness EXECUTES. A verdict
204
+ * cannot rewrite a command into a wrapper, so the only way for this runtime
205
+ * to insist on the room is to refuse the spelling that does not ask for it.
206
+ * Off by default, and turning it on can only ever refuse more — which is why
207
+ * an environment variable is an acceptable home for it, and why nothing in
208
+ * the other direction is readable from one.
209
+ */
210
+ "hook-sandbox-required",
211
+ /** The policy could not be loaded, so no class can be resolved. */
212
+ "hook-policy-unavailable",
213
+ /**
214
+ * No log exists where the hook was pointed. The hook is a WRITER to an
215
+ * existing log, never an initializer: creating one where it happens to stand
216
+ * (an agent worktree, say) forks a chain off the real log's tail, and git
217
+ * merges do not reconcile hash chains (APRV-101).
218
+ */
219
+ "hook-log-unreachable",
220
+ /** Malformed hook input, or a log/filesystem fact that stopped the check. */
221
+ "hook-io",
222
+ ];
223
+ const COMMON_FLAGS = {
224
+ "--help": "boolean",
225
+ "-h": "boolean",
226
+ };
227
+ const POLICY_FLAGS = {
228
+ "--policy": "string",
229
+ "--dir": "string",
230
+ };
231
+ function absolute(value, cwd) {
232
+ return isAbsolute(value) ? value : resolvePathSegments(cwd, value);
233
+ }
234
+ function usageError(streams, message) {
235
+ streams.err(usageErrorText(message, HOOK_HELP));
236
+ return EXIT_USAGE;
237
+ }
238
+ /**
239
+ * The primary checkout containing `cwd`, or `null` when git cannot say.
240
+ *
241
+ * `git rev-parse --git-common-dir` names the SHARED git directory: in a linked
242
+ * worktree it is the primary checkout's `.git`, in a plain checkout it is this
243
+ * checkout's own (printed as bare `.git` at the top level, absolute from a
244
+ * subdirectory). Either way the primary root is its parent, so a plain checkout
245
+ * resolves to itself.
246
+ *
247
+ * Run exactly as `amend.ts` runs git: `spawnSync`, no shell, and every failure
248
+ * is a value. When git is absent, or `cwd` is not a repository at all, this
249
+ * returns `null` and the caller falls back to `cwd` — today's behaviour, which
250
+ * is what a non-git deployment of the hook has always relied on.
251
+ *
252
+ * APRV-125 gave the resolution two more callers (`log sync` and `log advance`,
253
+ * which refuse outside the primary rather than falling back), so the
254
+ * implementation moved to `cli/git-scope.ts`. This alias keeps the hook reading
255
+ * the same answer they read.
256
+ */
257
+ const primaryRoot = resolvePrimaryRoot;
258
+ /**
259
+ * Policy and log, resolved from the same root (APRV-101).
260
+ *
261
+ * Before this, `--dir` scoped only the policy and the log was resolved from the
262
+ * process cwd, so a hook invoked with `--dir <primary>` from an agent worktree
263
+ * read the primary's policy and wrote the worktree's copy of the log: a
264
+ * dead-end chain that forks from the real one. Explicit flags still win
265
+ * (`--policy` for the policy, `--log` for the log); otherwise both follow
266
+ * `--dir`, and with no flags at all both follow the primary checkout.
267
+ */
268
+ function hookScope(flags, cwd) {
269
+ const policyFlag = stringFlag(flags, "--policy");
270
+ const logFlag = stringFlag(flags, "--log");
271
+ const dirFlag = stringFlag(flags, "--dir");
272
+ const root = dirFlag !== null ? absolute(dirFlag, cwd) : (primaryRoot(cwd) ?? cwd);
273
+ const options = policyFlag === null
274
+ ? { policy: { dir: root } }
275
+ : { policy: { file: absolute(policyFlag, cwd) } };
276
+ const logPath = logFlag === null ? join(root, DEFAULT_LOG_PATH) : absolute(logFlag, cwd);
277
+ return { logPath, root, options };
278
+ }
279
+ const CLAUDE_ADAPTER = {
280
+ kind: "claude-code",
281
+ originApp: "claude-code-hook",
282
+ defaultActor: "agent:claude-code",
283
+ shellTool: "Bash",
284
+ fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
285
+ };
286
+ const CURSOR_ADAPTER = {
287
+ kind: "cursor",
288
+ originApp: "cursor-hook",
289
+ defaultActor: "agent:cursor",
290
+ shellTool: "Shell",
291
+ fileTools: ["Write", "Delete"],
292
+ };
293
+ /**
294
+ * The decision object the harness reads from stdout.
295
+ *
296
+ * Claude Code wants the nested PreToolUse envelope. Cursor native hooks want
297
+ * `{permission, user_message, agent_message}`. One construction site per
298
+ * harness, still never `ask`.
299
+ */
300
+ function decision(permission, reason, harness) {
301
+ if (harness === "cursor") {
302
+ return `${JSON.stringify({
303
+ permission,
304
+ user_message: reason,
305
+ agent_message: reason,
306
+ })}\n`;
307
+ }
308
+ return `${JSON.stringify({
309
+ hookSpecificOutput: {
310
+ hookEventName: "PreToolUse",
311
+ permissionDecision: permission,
312
+ permissionDecisionReason: reason,
313
+ },
314
+ })}\n`;
315
+ }
316
+ function allow(streams, reason, harness) {
317
+ streams.out(decision("allow", reason, harness));
318
+ return EXIT_OK;
319
+ }
320
+ function deny(streams, code, detail, harness) {
321
+ streams.out(decision("deny", `${code}: ${detail}`, harness));
322
+ return EXIT_OK;
323
+ }
324
+ function readString(source, key) {
325
+ const value = source[key];
326
+ return typeof value === "string" && value.length > 0 ? value : null;
327
+ }
328
+ /**
329
+ * Parse the PreToolUse JSON.
330
+ *
331
+ * Deliberately tolerant about fields the decision does not depend on and strict
332
+ * about the two it does (`tool_name`, and `tool_input.command` for Bash). The
333
+ * `description` field is NEVER read: it is authored by the agent being gated,
334
+ * and a gate that read the subject's own account of its intent would be letting
335
+ * a self-reported field reduce scrutiny (SPEC.md §11.1).
336
+ */
337
+ function parseHookInput(raw) {
338
+ if (raw.trim().length === 0)
339
+ return { ok: false, detail: "hook stdin was empty" };
340
+ let parsed;
341
+ try {
342
+ parsed = JSON.parse(raw);
343
+ }
344
+ catch (cause) {
345
+ return {
346
+ ok: false,
347
+ detail: `hook stdin is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
348
+ };
349
+ }
350
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
351
+ return { ok: false, detail: "hook stdin is not a JSON object" };
352
+ }
353
+ const fields = parsed;
354
+ const toolName = readString(fields, "tool_name");
355
+ if (toolName === null)
356
+ return { ok: false, detail: "hook input has no tool_name" };
357
+ const toolInputValue = fields["tool_input"];
358
+ const toolInput = typeof toolInputValue === "object" && toolInputValue !== null && !Array.isArray(toolInputValue)
359
+ ? toolInputValue
360
+ : {};
361
+ const responseValue = fields["tool_response"];
362
+ return {
363
+ ok: true,
364
+ input: {
365
+ // The ONE shared bucket for an unreadable session (`core/loop.ts`'s
366
+ // `UNKNOWN_SESSION`): absence accrues faster than a readable id and never
367
+ // slower, which is the fail-closed direction.
368
+ sessionId: readString(fields, "session_id") ?? UNKNOWN_SESSION,
369
+ cwd: readString(fields, "cwd") ?? "",
370
+ toolName,
371
+ toolInput,
372
+ toolUseId: readString(fields, "tool_use_id"),
373
+ hookEventName: readString(fields, "hook_event_name"),
374
+ harnessVersion: readString(fields, "version"),
375
+ interrupted: fields["is_interrupt"] === true,
376
+ toolResponse: typeof responseValue === "object" && responseValue !== null && !Array.isArray(responseValue)
377
+ ? responseValue
378
+ : null,
379
+ },
380
+ };
381
+ }
382
+ // ===========================================================================
383
+ // History-rewrite refinement (APRV-108)
384
+ // ===========================================================================
385
+ /*
386
+ * Rewriting history nobody else holds is a commit.
387
+ *
388
+ * `vcs.history.rewrite` exists to guard SHARED history: a force push, a rebase
389
+ * of a branch other people have pulled, an amend of a commit that is already on
390
+ * the remote. An agent amending its own unpublished worktree branch destroys
391
+ * nothing anyone can observe, and pricing that at a human's attention spends the
392
+ * audit budget SPEC.md §11 asks to protect on a non-event.
393
+ *
394
+ * The classifier cannot answer this, and deliberately does not try: it is pure,
395
+ * and "is this branch published" is a fact about a checkout, not about a string.
396
+ * So the refinement lives HERE, in the impure layer that already runs git
397
+ * (`primaryRoot`, APRV-101), and is applied to the classifier's output rather
398
+ * than folded into it. `classifyCommand` keeps returning `vcs.history.rewrite`
399
+ * for these verbs, its fixture table keeps meaning what it says, and everything
400
+ * environment-dependent is in one named step a reader can audit.
401
+ *
402
+ * What downgrades, and only this:
403
+ *
404
+ * - the branch has NO upstream at all — nothing was ever published from it, so
405
+ * no rewrite of it can reach anyone else; or
406
+ * - the command is `git commit --amend` and HEAD is not reachable from the
407
+ * upstream — the one commit an amend rewrites has not been pushed.
408
+ *
409
+ * What never downgrades: anything push-side (`git push --force` and friends),
410
+ * a detached HEAD, the repository's default branch, a rebase or reset whose
411
+ * target the text does not name (a `git reset --hard HEAD~5` on a branch with an
412
+ * upstream may well be rewriting published commits, and the text cannot say), and
413
+ * every case where git declines to answer. Fail closed on each: a wrong
414
+ * downgrade removes a human from a decision that needed one, and a wrong
415
+ * `rewrite` costs one approval prompt.
416
+ */
417
+ /**
418
+ * Classifier rules whose rewrite is LOCAL, and so can be refined.
419
+ *
420
+ * `git-push-force` is deliberately absent: a push is a rewrite of the remote by
421
+ * construction, whatever this checkout's branch state is.
422
+ */
423
+ const LOCAL_REWRITE_RULES = [
424
+ /** `git commit --amend`. */
425
+ "git-commit-amend",
426
+ /** `git reset --hard`. */
427
+ "git-reset-hard",
428
+ /** `git rebase` / `filter-branch` / `filter-repo` (the table row's own id). */
429
+ "git-rewrite",
430
+ ];
431
+ /** The one rule whose rewritten commit is exactly HEAD. */
432
+ const AMEND_RULE = "git-commit-amend";
433
+ /** The rule name a refined segment reports, in `hook classify` and in tests. */
434
+ const REWRITE_UNPUBLISHED_RULE = "rewrite-unpublished";
435
+ const REWRITE_CLASS = "vcs.history.rewrite";
436
+ const UNPUBLISHED_CLASS = "vcs.commit.branch";
437
+ /**
438
+ * The environment every git child of this verb receives (APRV-205).
439
+ *
440
+ * The hook spawns no granted command — it answers allow or deny and the harness
441
+ * runs the command itself — so nothing here is the task's load-bearing case.
442
+ * These git children are still children of a process holding the session's
443
+ * credentials, and `git rev-parse` has no use for a Telegram token. Built
444
+ * through the one helper so there is one list.
445
+ */
446
+ function gitEnvironment() {
447
+ return childEnvironment().env;
448
+ }
449
+ /** Trimmed stdout of a successful git command, or `null` for any failure. */
450
+ function gitOutput(cwd, args) {
451
+ const result = spawnSync("git", [...args], { cwd, encoding: "utf8", env: gitEnvironment() });
452
+ if (result.error !== undefined || result.status !== 0)
453
+ return null;
454
+ return result.stdout.trim();
455
+ }
456
+ /**
457
+ * `git merge-base --is-ancestor` as three values, not two.
458
+ *
459
+ * Exit 0 is yes and exit 1 is no; every other exit (a missing ref, a broken
460
+ * repository, no git at all) is `null`, which the caller reads as "stay a
461
+ * rewrite" rather than as "no".
462
+ */
463
+ function isAncestor(cwd, ancestor, descendant) {
464
+ const result = spawnSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], {
465
+ cwd,
466
+ encoding: "utf8",
467
+ env: gitEnvironment(),
468
+ });
469
+ if (result.error !== undefined)
470
+ return null;
471
+ if (result.status === 0)
472
+ return true;
473
+ if (result.status === 1)
474
+ return false;
475
+ return null;
476
+ }
477
+ /**
478
+ * Is this the branch a rewrite must never be quiet about?
479
+ *
480
+ * `main` and `master` always count, whatever the remote says, so a local-only
481
+ * repository (and a branch someone named `main` in a scratch checkout) is
482
+ * covered. `refs/remotes/origin/HEAD` adds the remote's own answer when it is
483
+ * set, which is how a repository whose trunk is `develop` or `trunk` is read.
484
+ */
485
+ function isDefaultBranch(cwd, branch) {
486
+ if (branch === "main" || branch === "master")
487
+ return true;
488
+ const head = gitOutput(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
489
+ if (head === null || head.length === 0)
490
+ return false;
491
+ return head.replace(/^refs\/remotes\/origin\//u, "") === branch;
492
+ }
493
+ /**
494
+ * Ask git how far the checkout at `cwd` has been published.
495
+ *
496
+ * Every step that cannot be answered returns `shared`, which refines nothing.
497
+ * `for-each-ref` rather than `@{u}` on purpose: `rev-parse @{u}` exits non-zero
498
+ * both when there is no upstream and when the repository cannot be read, and
499
+ * those two must not collapse — one downgrades, the other must not.
500
+ */
501
+ function rewriteReach(cwd) {
502
+ const branch = gitOutput(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
503
+ // No git, not a repository, or a detached HEAD (which prints `HEAD`): a
504
+ // detached rewrite has no branch whose publication could be checked.
505
+ if (branch === null || branch.length === 0 || branch === "HEAD")
506
+ return { kind: "shared" };
507
+ if (isDefaultBranch(cwd, branch))
508
+ return { kind: "shared" };
509
+ // Exits 0 and prints an empty line when the branch tracks nothing, so an
510
+ // empty result is a real answer and a failure is not.
511
+ const upstream = gitOutput(cwd, [
512
+ "for-each-ref",
513
+ "--format=%(upstream:short)",
514
+ `refs/heads/${branch}`,
515
+ ]);
516
+ if (upstream === null)
517
+ return { kind: "shared" };
518
+ if (upstream.length === 0)
519
+ return { kind: "no-upstream", branch };
520
+ // An upstream is configured. HEAD reachable from it (or unanswerable, e.g. a
521
+ // tracking ref that was never fetched) stays a rewrite.
522
+ return isAncestor(cwd, "HEAD", upstream) === false
523
+ ? { kind: "head-unpushed", branch, upstream }
524
+ : { kind: "shared" };
525
+ }
526
+ /**
527
+ * Downgrade local rewrites of unpublished history to `vcs.commit.branch`.
528
+ *
529
+ * IMPURE by design and by contract: it runs git in `cwd`. Both callers pass the
530
+ * same directory the hook itself resolves from, so what `hook classify` prints
531
+ * is what `hook claude-code` decides.
532
+ */
533
+ export function refineRewrite(result, cwd) {
534
+ if (!result.ok)
535
+ return { result, notes: [] };
536
+ const refinable = result.segments.some((segment) => segment.class === REWRITE_CLASS && LOCAL_REWRITE_RULES.includes(segment.rule));
537
+ if (!refinable)
538
+ return { result, notes: [] };
539
+ const reach = rewriteReach(cwd);
540
+ if (reach.kind === "shared")
541
+ return { result, notes: [] };
542
+ const notes = [];
543
+ const segments = result.segments.map((segment) => {
544
+ if (segment.class !== REWRITE_CLASS || !LOCAL_REWRITE_RULES.includes(segment.rule)) {
545
+ return segment;
546
+ }
547
+ // With an upstream, only an amend is narrow enough to be sure: it rewrites
548
+ // HEAD and nothing else. A rebase or reset names a base the text cannot
549
+ // resolve, so it may reach commits that ARE on the upstream.
550
+ if (reach.kind === "head-unpushed" && segment.rule !== AMEND_RULE)
551
+ return segment;
552
+ notes.push(reach.kind === "no-upstream"
553
+ ? `${REWRITE_UNPUBLISHED_RULE}: branch ${reach.branch} has no upstream, so \`${segment.text}\` rewrites only unpublished history`
554
+ : `${REWRITE_UNPUBLISHED_RULE}: HEAD is not yet on ${reach.upstream}, so \`${segment.text}\` amends only unpublished history`);
555
+ return { ...segment, class: UNPUBLISHED_CLASS, rule: REWRITE_UNPUBLISHED_RULE };
556
+ });
557
+ if (notes.length === 0)
558
+ return { result, notes };
559
+ const classes = [];
560
+ for (const segment of segments) {
561
+ if (!classes.includes(segment.class))
562
+ classes.push(segment.class);
563
+ }
564
+ return { result: { ok: true, segments, classes }, notes };
565
+ }
566
+ // ===========================================================================
567
+ // Scratch-delete refinement (APRV-267)
568
+ // ===========================================================================
569
+ /*
570
+ * Where the agent's own scratch space is, and whether a delete really stays
571
+ * inside it.
572
+ *
573
+ * The classifier cannot answer either question. It is pure over command text,
574
+ * and "is this path under the scratchpad this process was allotted" is a fact
575
+ * about a machine. So the work splits the way APRV-108's rewrite refinement
576
+ * split: `command-class.ts` compares path segments against roots it is HANDED
577
+ * (`ClassifierContext.scratchRoots`), and everything that needs a disk or an
578
+ * environment lives here, in the impure layer that already runs git.
579
+ *
580
+ * ## What the roots are read from
581
+ *
582
+ * No harness exports the session scratchpad as an environment variable today.
583
+ * Claude Code names it in the system prompt and nowhere else, and this process
584
+ * inherits no `CLAUDE_SCRATCHPAD*` and no `TMPDIR` from it. So the roots are
585
+ * built from what a process CAN observe:
586
+ *
587
+ * - `CLAUDE_SCRATCHPAD_DIR` and `CLAUDE_CODE_SCRATCHPAD_DIR`, read if a
588
+ * harness ever starts exporting them, so that the day it does the rule is
589
+ * already narrow enough to name one session's own directory;
590
+ * - `os.tmpdir()`, which is where every observed scratchpad actually lives
591
+ * (`/private/tmp/claude-501/<project>/<session>/scratchpad` on this Mac);
592
+ * - the fixed platform temp roots `/tmp` and `/var/tmp`, plus `/private/tmp`
593
+ * on macOS, where `/tmp` is a symlink to it.
594
+ *
595
+ * ## Why nothing an agent controls widens the class
596
+ *
597
+ * SPEC.md §11.1: self-reported fields never reduce scrutiny. `os.tmpdir()`
598
+ * reads `TMPDIR`, so a poisoned value could in principle nominate `/` and turn
599
+ * every absolute delete into a scratch delete. Three guards close that, and
600
+ * none of them trusts the value: a root must resolve to a real directory, must
601
+ * clear the depth floor, and must not contain the directory the hook was
602
+ * invoked in. A checkout is never inside its own scratch root.
603
+ *
604
+ * The depth floor is two path segments, so `/` and one-segment directories like
605
+ * `/etc` are out, with the three compiled-in temp roots (`/tmp`, `/private/tmp`,
606
+ * `/var/tmp`) exempt from it because on Linux `os.tmpdir()` IS `/tmp`, a single
607
+ * segment. See {@link scratchRootDepthAccepted} for why that exemption cannot
608
+ * be reached by a poisoned value.
609
+ *
610
+ * ## Why the second pass exists at all
611
+ *
612
+ * A path can be textually under a root and physically somewhere else (a symlink
613
+ * in the middle of it), and a git checkout can live inside the temp root
614
+ * (`/tmp/probe-clone`), where a delete destroys work rather than tidying up.
615
+ * Neither is visible in the argv. So this pass re-reads each target, resolves
616
+ * the nearest ancestor that exists, and TIGHTENS back to
617
+ * `files.delete.out_of_scope` on any doubt: a target it cannot resolve, a
618
+ * resolution that leaves the root, a `.git` at or above the target.
619
+ */
620
+ /** The class the classifier hands over, and the one this pass falls back to. */
621
+ const OUT_OF_SCOPE_CLASS = "files.delete.out_of_scope";
622
+ /** The classifier rule whose segments this pass re-reads. */
623
+ const SCRATCH_RULE = "rm-scratch";
624
+ /** The rule a tightened segment reports. */
625
+ const SCRATCH_REJECTED_RULE = "rm-scratch-rejected";
626
+ /**
627
+ * Environment variables a HARNESS may use to name the session scratchpad.
628
+ *
629
+ * None is set by any harness this runtime has seen; they are read so the rule
630
+ * narrows the day one starts exporting it, rather than staying pinned to the
631
+ * whole temp root forever. A value that fails any of the guards (absolute, a
632
+ * real directory, deep enough, clear of the cwd) is ignored like any other
633
+ * candidate.
634
+ */
635
+ const SCRATCHPAD_ENV_NAMES = [
636
+ "CLAUDE_SCRATCHPAD_DIR",
637
+ "CLAUDE_CODE_SCRATCHPAD_DIR",
638
+ ];
639
+ /**
640
+ * Fixed temp roots, beyond whatever `os.tmpdir()` reports.
641
+ *
642
+ * These are the well-known system temp directories, and the depth rule below
643
+ * exempts them: they are compiled-in constants, not anything a caller reports.
644
+ */
645
+ const FIXED_TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/tmp"];
646
+ /** Segments a root must have when it is not one of {@link FIXED_TEMP_ROOTS}. */
647
+ const MIN_ROOT_SEGMENTS = 2;
648
+ /** Non-empty path segments in `path`. */
649
+ function segmentDepth(path) {
650
+ return path.split(sep).filter((segment) => segment.length > 0).length;
651
+ }
652
+ /**
653
+ * Is a candidate deep enough, once resolved, to stand as a scratch root?
654
+ *
655
+ * The depth floor is the anti-poisoning guard (SPEC.md §11.1: self-reported
656
+ * fields never reduce scrutiny). A `TMPDIR` naming `/` resolves and exists, and
657
+ * a root of `/` would turn every absolute delete into a scratch delete, so a
658
+ * resolved root is refused below {@link MIN_ROOT_SEGMENTS}.
659
+ *
660
+ * The well-known system temp roots are the one exception, and they are one on
661
+ * every platform: on Linux `os.tmpdir()` is `/tmp`, a single segment, and
662
+ * refusing it would mean `files.delete.scratch` could never fire there, while
663
+ * on macOS the same directory resolves through the `/tmp` symlink to
664
+ * `/private/tmp` and clears the floor by accident of layout. The exemption is
665
+ * keyed on the RESOLVED value being one of the three compiled-in names, so
666
+ * nothing a caller reports widens it: a poisoned `TMPDIR` still has to resolve
667
+ * to `/tmp`, `/private/tmp` or `/var/tmp` to get in, and those are roots
668
+ * already. `/` is not among them, and every other one-segment directory
669
+ * (`/etc`, `/home`, `/usr`) stays refused.
670
+ */
671
+ export function scratchRootDepthAccepted(resolved) {
672
+ if (FIXED_TEMP_ROOTS.includes(resolved))
673
+ return true;
674
+ return segmentDepth(resolved) >= MIN_ROOT_SEGMENTS;
675
+ }
676
+ /** `realpathSync`, or `null` for anything that does not resolve. */
677
+ function resolvedPath(candidate) {
678
+ try {
679
+ return realpathSync(candidate);
680
+ }
681
+ catch {
682
+ return null;
683
+ }
684
+ }
685
+ /** Is `candidate` a strict descendant of `root`, by path segment? */
686
+ function isBelow(candidate, root) {
687
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
688
+ return candidate.startsWith(prefix) && candidate.length > prefix.length;
689
+ }
690
+ /**
691
+ * The scratch roots this process may vouch for, resolved and guarded.
692
+ *
693
+ * `cwd` is the directory the hook itself resolved from; a candidate containing
694
+ * it is discarded, because a root that swallowed the checkout would make every
695
+ * delete in the repository a scratch delete.
696
+ */
697
+ export function resolveScratchRoots(cwd, env = process.env) {
698
+ const resolvedCwd = resolvedPath(cwd) ?? cwd;
699
+ const candidates = [];
700
+ for (const name of SCRATCHPAD_ENV_NAMES) {
701
+ const value = env[name];
702
+ if (typeof value === "string" && value.length > 0)
703
+ candidates.push(value);
704
+ }
705
+ candidates.push(tmpdir(), ...FIXED_TEMP_ROOTS);
706
+ const roots = [];
707
+ for (const candidate of candidates) {
708
+ if (!isAbsolute(candidate))
709
+ continue;
710
+ const resolved = resolvedPath(candidate);
711
+ if (resolved === null)
712
+ continue;
713
+ if (!scratchRootDepthAccepted(resolved))
714
+ continue;
715
+ if (resolved === resolvedCwd || isBelow(resolvedCwd, resolved))
716
+ continue;
717
+ if (!roots.includes(resolved))
718
+ roots.push(resolved);
719
+ }
720
+ return roots;
721
+ }
722
+ /**
723
+ * Is there a `.git` at `target` or above it, stopping at `root`?
724
+ *
725
+ * `root` itself is checked too: a checkout whose top IS a scratch root would
726
+ * otherwise hide from the walk. Any filesystem error answers `true`, because an
727
+ * unreadable directory is not one this pass may vouch for.
728
+ */
729
+ function insideCheckout(target, root) {
730
+ let at = target;
731
+ for (let depth = 0; depth < 64; depth += 1) {
732
+ try {
733
+ if (existsSync(join(at, ".git")))
734
+ return true;
735
+ }
736
+ catch {
737
+ return true;
738
+ }
739
+ if (at === root)
740
+ return false;
741
+ const up = dirname(at);
742
+ if (up === at)
743
+ return true;
744
+ at = up;
745
+ }
746
+ return true;
747
+ }
748
+ /**
749
+ * Does this one target survive the physical checks?
750
+ *
751
+ * The target itself may or may not exist, so the nearest EXISTING ancestor is
752
+ * resolved and the unresolved tail re-appended. A symlink anywhere in that
753
+ * ancestor chain therefore cannot smuggle the path out of the root, which is
754
+ * the escape the pure half cannot see.
755
+ */
756
+ function targetStaysInScratch(target, roots) {
757
+ let existing = target;
758
+ const tail = [];
759
+ for (let depth = 0; depth < 64; depth += 1) {
760
+ if (existsSync(existing))
761
+ break;
762
+ const up = dirname(existing);
763
+ if (up === existing)
764
+ return false;
765
+ tail.unshift(basename(existing));
766
+ existing = up;
767
+ }
768
+ const resolved = resolvedPath(existing);
769
+ if (resolved === null)
770
+ return false;
771
+ const full = tail.length === 0 ? resolved : join(resolved, ...tail);
772
+ const root = roots.find((candidate) => isBelow(full, candidate));
773
+ if (root === undefined)
774
+ return false;
775
+ return !insideCheckout(full, root);
776
+ }
777
+ /**
778
+ * Tighten `files.delete.scratch` back to `files.delete.out_of_scope` wherever
779
+ * the disk disagrees with the text.
780
+ *
781
+ * IMPURE by design and by contract, exactly as {@link refineRewrite} is: it
782
+ * stats paths. It only ever moves a segment toward the stricter class, so a
783
+ * caller that skipped it would never be MORE permissive than one that runs it,
784
+ * which is what lets `hook classify` and `hook claude-code` share it without
785
+ * either becoming the authority.
786
+ */
787
+ export function refineScratchDelete(result, roots) {
788
+ if (!result.ok)
789
+ return { result, notes: [] };
790
+ if (!result.segments.some((segment) => segment.rule === SCRATCH_RULE)) {
791
+ return { result, notes: [] };
792
+ }
793
+ const notes = [];
794
+ const segments = result.segments.map((segment) => {
795
+ if (segment.rule !== SCRATCH_RULE)
796
+ return segment;
797
+ const reject = (detail) => {
798
+ notes.push(`${SCRATCH_REJECTED_RULE}: ${detail}`);
799
+ return { ...segment, class: OUT_OF_SCOPE_CLASS, rule: SCRATCH_REJECTED_RULE };
800
+ };
801
+ const words = commandSegmentWords(segment.text);
802
+ const parsed = words === null ? undefined : words[0];
803
+ // The classifier read this segment a moment ago, so a parse that disagrees
804
+ // here is two reads of the same bytes disagreeing. Fail closed.
805
+ if (parsed === undefined) {
806
+ return reject(`\`${segment.text}\` could not be re-read, so it stays ${OUT_OF_SCOPE_CLASS}`);
807
+ }
808
+ const targets = parsed.args.filter((arg) => !arg.startsWith("-") || arg === "-");
809
+ if (targets.length === 0) {
810
+ return reject(`\`${segment.text}\` names no target, so it stays ${OUT_OF_SCOPE_CLASS}`);
811
+ }
812
+ const escaped = targets.find((target) => !targetStaysInScratch(target, roots));
813
+ if (escaped === undefined)
814
+ return segment;
815
+ return reject(`${escaped} does not resolve to a path inside a scratch root clear of any git checkout, so \`${segment.text}\` stays ${OUT_OF_SCOPE_CLASS}`);
816
+ });
817
+ if (notes.length === 0)
818
+ return { result, notes };
819
+ const classes = [];
820
+ for (const segment of segments) {
821
+ if (!classes.includes(segment.class))
822
+ classes.push(segment.class);
823
+ }
824
+ return { result: { ok: true, segments, classes }, notes };
825
+ }
826
+ /**
827
+ * The classifier, its scratch context, and both impure refinements, in the one
828
+ * order every caller must use.
829
+ *
830
+ * `hook classify` printing a different class from the one `hook claude-code`
831
+ * decides would make the explainer a different program (APRV-108's note), and
832
+ * that stays true now there are two refinements in the chain.
833
+ */
834
+ export function classifyForHook(command, protectedPaths, cwd) {
835
+ const roots = resolveScratchRoots(cwd);
836
+ const classified = classifyCommand(command, protectedPaths, { scratchRoots: roots });
837
+ const rewritten = refineRewrite(classified, cwd);
838
+ const scratched = refineScratchDelete(rewritten.result, roots);
839
+ return {
840
+ result: scratched.result,
841
+ notes: [...rewritten.notes, ...scratched.notes],
842
+ };
843
+ }
844
+ // ===========================================================================
845
+ // hook classify
846
+ // ===========================================================================
847
+ /**
848
+ * What the classifier made of a command (APRV-91 #9).
849
+ *
850
+ * Human output is an aligned three-column table under a `key` header row; the
851
+ * command text and the rule name are copyable and stay undressed. `--json`
852
+ * emits the classification object unchanged, and asks for the style FIRST so
853
+ * that the `json` veto on colour is the answer this process memoizes.
854
+ */
855
+ export function renderClassification(result, json, st = style({ json })) {
856
+ if (json)
857
+ return `${JSON.stringify(result)}\n`;
858
+ if (!result.ok) {
859
+ // APRV-102: the shared refusal shape rather than a second copy of it. The
860
+ // segment is a copyable value on its own line, which is what `refusal`'s
861
+ // optional second line is for.
862
+ return `${renderRefusal(st, result.code, result.detail)}\n ${st.key("segment:")} ${result.segment}\n`;
863
+ }
864
+ const rows = result.segments.map((segment) => [segment.class, segment.rule, segment.text]);
865
+ return `${table(st, rows, { header: ["class", "rule", "command"] })}\n\n${st.key("classes:")} ${result.classes.join(", ")}\n`;
866
+ }
867
+ /**
868
+ * `approval hook classify <command…>` — what the classifier makes of a command.
869
+ *
870
+ * Everything after `--` is the command verbatim, which is how a command with
871
+ * its own flags is passed without this parser claiming them.
872
+ *
873
+ * It reads the policy for the same reason `hook claude-code` does (APRV-107):
874
+ * `policy.protected_paths` widens the protected surface, and an explainer
875
+ * that answered from the built-ins alone would tell an agent a gated file is
876
+ * ungated. `--dir` / `--policy` scope it exactly as they scope the hook. This
877
+ * verb decides nothing and writes nothing, so an unreadable policy is not a
878
+ * refusal here: it classifies against the built-ins and says on stderr that the
879
+ * answer is the narrow one.
880
+ */
881
+ function commandClassify(argv, streams, cwd) {
882
+ const separator = argv.indexOf("--");
883
+ const head = separator === -1 ? argv : argv.slice(0, separator);
884
+ const tail = separator === -1 ? [] : argv.slice(separator + 1);
885
+ const parsed = parseFlags(head, { ...COMMON_FLAGS, ...POLICY_FLAGS, "--json": "boolean" });
886
+ if (!parsed.ok) {
887
+ return usageError(streams, `${parsed.message}; flags belonging to the command being classified must follow \`--\``);
888
+ }
889
+ if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
890
+ streams.out(`${HOOK_HELP}\n`);
891
+ return EXIT_OK;
892
+ }
893
+ const command = [...parsed.positionals, ...tail].join(" ").trim();
894
+ if (command.length === 0) {
895
+ return usageError(streams, "missing <command> argument for `approval hook classify`");
896
+ }
897
+ const { options } = hookScope(parsed.flags, cwd);
898
+ const load = loadPolicy(options.policy?.file === undefined
899
+ ? { dir: options.policy?.dir ?? cwd }
900
+ : { file: options.policy.file });
901
+ if (!load.ok) {
902
+ streams.err(`note: no policy read (${load.code}: ${load.message}); classifying against the built-in protected paths only\n`);
903
+ }
904
+ const protectedPaths = load.ok ? (load.policy.protected_paths ?? []) : [];
905
+ // The same impure refinements `hook claude-code` applies (APRV-108,
906
+ // APRV-267), run against the same directory: an explainer that printed the
907
+ // pure class where the hook decides a refined one would be explaining a
908
+ // different program.
909
+ streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd).result, boolFlag(parsed.flags, "--json")));
910
+ return EXIT_OK;
911
+ }
912
+ function sleepSync(ms) {
913
+ if (ms <= 0)
914
+ return;
915
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
916
+ }
917
+ function truncate(text, limit) {
918
+ const collapsed = text.replace(/\s+/gu, " ").trim();
919
+ return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;
920
+ }
921
+ // ===========================================================================
922
+ // File tools: the change, and which checkout it lands in (APRV-124)
923
+ // ===========================================================================
924
+ /**
925
+ * The rule a protected-path file touch reports, on the SAME class.
926
+ *
927
+ * Three tiers, and the class is whichever protected class the path selects
928
+ * (APRV-198: `policy.edit`, `policy.core` or `log.mutate`); the tier never
929
+ * changes it. A protected touch inside an agent worktree is a branch
930
+ * PROPOSAL: the file it writes is a copy on a branch, and the merge that makes
931
+ * it real is separately gated (`vcs.push.main`, `gh pr merge`). The same touch
932
+ * in the live checkout is the file itself. A protected name that resolves
933
+ * OUTSIDE the gated checkout altogether (a scratchpad `APPROVAL.md`, a demo
934
+ * fixture) is neither: the match is on the name, and the name is all it shares
935
+ * with the live policy (APRV-161). The approver was being told the same thing
936
+ * about all three, which is the "truthful label" half of this task.
937
+ *
938
+ * The distinction is deliberately NOT a class and NOT an autonomy: policy
939
+ * semantics are untouched here, every tier resolves exactly as the path's own
940
+ * protected class resolves, and APRV-127 is where sampling may hang off it.
941
+ * What changes is what the human reads.
942
+ */
943
+ const PROTECTED_PATH_RULE = "protected-path";
944
+ const PROTECTED_PATH_PROPOSAL_RULE = "protected-path-proposal";
945
+ const PROTECTED_NAME_ELSEWHERE_RULE = "protected-name-elsewhere";
946
+ /** Where agent worktrees live, relative to the primary root. */
947
+ const WORKTREE_DIR = [".claude", "worktrees"];
948
+ /** `realpathSync`, as a value. */
949
+ function realOrNull(path) {
950
+ try {
951
+ return realpathSync(path);
952
+ }
953
+ catch {
954
+ return null;
955
+ }
956
+ }
957
+ /**
958
+ * `path` with every existing ancestor resolved through its symlinks.
959
+ *
960
+ * A `Write` names a file that need not exist yet, and a comparison of an
961
+ * unresolved path against a resolved root answers "no" for the wrong reason on
962
+ * any machine where the checkout sits under a symlink (`/tmp` on macOS, every
963
+ * home directory behind an automounter). So the deepest existing ancestor is
964
+ * resolved and the remainder is joined back on.
965
+ */
966
+ function resolveExisting(path) {
967
+ let current = path;
968
+ const tail = [];
969
+ for (;;) {
970
+ const real = realOrNull(current);
971
+ if (real !== null)
972
+ return tail.length === 0 ? real : join(real, ...tail);
973
+ const parent = dirname(current);
974
+ if (parent === current)
975
+ return null;
976
+ tail.unshift(basename(current));
977
+ current = parent;
978
+ }
979
+ }
980
+ /**
981
+ * The tier `target` sits in, resolved once, from the hook's own directory.
982
+ *
983
+ * FAIL CLOSED, on every axis: anything not *provably* inside
984
+ * `<primary>/.claude/worktrees/<name>/…` and not *provably* outside the primary
985
+ * root is live-tier. A wrong "proposal" tells a human their APPROVAL.md edit is
986
+ * a branch copy when it is the live file; a wrong "elsewhere" tells them a
987
+ * scratch file is being edited when the live policy is; a wrong "live" costs
988
+ * nothing but a sterner sentence.
989
+ *
990
+ * Order matters. The proposal test runs first and against the worktrees
991
+ * directory's own resolved path, so a worktrees directory reached through a
992
+ * symlink stays a proposal rather than falling out of the root comparison.
993
+ *
994
+ * The primary root comes from `primaryRoot`, i.e. from git run in the hook's
995
+ * OWN process directory (APRV-108's discipline). The harness-supplied `cwd`
996
+ * field is never consulted: it is authored by the party under oversight, and a
997
+ * tier that could be chosen by the subject of the gate is not a tier.
998
+ */
999
+ function tierOf(target, cwd) {
1000
+ const live = (root) => ({
1001
+ rule: PROTECTED_PATH_RULE,
1002
+ worktree: null,
1003
+ root,
1004
+ });
1005
+ const root = primaryRoot(cwd);
1006
+ if (root === null)
1007
+ return live(null);
1008
+ const file = resolveExisting(target);
1009
+ if (file === null)
1010
+ return live(root);
1011
+ const base = resolveExisting(join(root, ...WORKTREE_DIR));
1012
+ if (base !== null && file.startsWith(`${base}${sep}`)) {
1013
+ const rest = file.slice(base.length + 1).split(sep);
1014
+ // `rest[0]` is the worktree; a target that IS the worktrees directory or a
1015
+ // worktree root names no file inside one and stays live-tier.
1016
+ const name = rest[0];
1017
+ if (name !== undefined && name.length > 0 && rest.length >= 2) {
1018
+ return { rule: PROTECTED_PATH_PROPOSAL_RULE, worktree: name, root };
1019
+ }
1020
+ }
1021
+ const realRoot = resolveExisting(root);
1022
+ if (realRoot === null)
1023
+ return live(root);
1024
+ if (file === realRoot || file.startsWith(`${realRoot}${sep}`))
1025
+ return live(realRoot);
1026
+ return { rule: PROTECTED_NAME_ELSEWHERE_RULE, worktree: null, root: realRoot };
1027
+ }
1028
+ /**
1029
+ * The class an ordinary file edit is (APRV-303).
1030
+ *
1031
+ * The same string `core/command-class.ts` gives a shell redirect into the
1032
+ * workspace, and spelled here because the file tools reach the same class by a
1033
+ * different road. Until APRV-303 the file path produced no class at all for a
1034
+ * non-protected target, which is why the loop floor could not see an Edit.
1035
+ */
1036
+ const WORKSPACE_WRITE_CLASS = "files.write.workspace";
1037
+ /**
1038
+ * What a non-Bash tool call asks for, or `null` when it names no file at all.
1039
+ *
1040
+ * Only one thing about a file edit is a gate question at v0.1: whether the file
1041
+ * is one only a human may write. Everything else the harness edits is
1042
+ * `files.write.workspace`, which this repository's policy makes autonomous, and
1043
+ * routing every keystroke of ordinary editing through a gate check would spend
1044
+ * latency to reach a foregone conclusion.
1045
+ *
1046
+ * ## The ordinary edit still gets a class (APRV-303)
1047
+ *
1048
+ * It used to get none: an unprotected target returned `null` here, and
1049
+ * `describeToolCall` answered `allow` from a branch that sits ABOVE the loop
1050
+ * floor, above `recordUnattended`, and above everything that appends. So a
1051
+ * session three failed writes deep had its Bash calls routed to a human and its
1052
+ * Edit calls waved through, which is the disagreement APRV-303 was filed on:
1053
+ * eight edits to the same file, under a standing floor, none of them routed and
1054
+ * none of them counted.
1055
+ *
1056
+ * The foregone conclusion is still foregone, and is still answered without
1057
+ * asking anybody: `describeToolCall` marks the call {@link FileGate.protectedPath}
1058
+ * `false`, and `runHarnessHook` allows it outright the moment it establishes
1059
+ * that no floor is standing. What it can no longer do is skip that
1060
+ * establishment. The floor predicate is now one predicate over one class for
1061
+ * every tool kind, which is what amended SPEC.md §10.2 asks for.
1062
+ *
1063
+ * ## The payload is the change (APRV-124)
1064
+ *
1065
+ * It used to be `{command: "Edit <path>", cwd}` — the *touch*. A human reading
1066
+ * that was asked to approve "an edit to CI config", with no way to tell a typo
1067
+ * fix from a disabled test job; the observed complaint (2026-08-20) is exactly
1068
+ * "I don't know what the actual CI edit is". The PreToolUse event carries the
1069
+ * whole change, so the payload does too:
1070
+ *
1071
+ * - `Edit` → `{tool, rule, file, before, after}` (plus `replace_all` when the
1072
+ * call sets it, because "replace every occurrence" is part of what is being
1073
+ * approved and two calls differing only in it are two different questions);
1074
+ * - `Write` → `{tool, rule, file, content}`;
1075
+ * - every other file tool → the same head plus its `tool_input` verbatim under
1076
+ * `input`, which renders as JSON rather than as a diff but hides nothing.
1077
+ *
1078
+ * Those bytes are what `payload_hash` binds, so the grant binds to the edit.
1079
+ * They are also what APRV-117's carryover keys on: an identical retry of the
1080
+ * identical edit hashes identically and adopts or carries the same question, a
1081
+ * changed edit is a new question, and a proposal-tier grant cannot be spent on
1082
+ * the live file because the absolute `file` differs.
1083
+ *
1084
+ * `description` is dropped on the way in: it is the agent's account of its own
1085
+ * intent, and it has no business in the bytes a human is bound to.
1086
+ */
1087
+ function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
1088
+ const declared = readString(toolInput, "file_path") ??
1089
+ readString(toolInput, "notebook_path") ??
1090
+ readString(toolInput, "path");
1091
+ if (declared === null)
1092
+ return null;
1093
+ // The SAME split the shell classifier applies (APRV-198): a file tool aimed
1094
+ // at APPROVAL.md or the approval home is `policy.core`, one aimed at
1095
+ // `.approval/log/` is `log.mutate`, and only the prose-and-configuration
1096
+ // surface stays `policy.edit`. Editing through the Edit tool must not be a
1097
+ // cheaper way to touch the gate than editing through a shell redirect.
1098
+ const surface = protectedPathClass(declared, protectedPaths);
1099
+ const file = absolute(declared, cwd);
1100
+ const tier = tierOf(file, cwd);
1101
+ const rule = tier.rule;
1102
+ const head = { tool: toolName, rule, file };
1103
+ const before = toolInput["old_string"];
1104
+ const after = toolInput["new_string"];
1105
+ const content = toolInput["content"] ?? toolInput["contents"];
1106
+ const replaceAll = toolInput["replace_all"];
1107
+ let payload;
1108
+ if (typeof before === "string" && typeof after === "string") {
1109
+ payload =
1110
+ typeof replaceAll === "boolean"
1111
+ ? { ...head, replace_all: replaceAll, before, after }
1112
+ : { ...head, before, after };
1113
+ }
1114
+ else if (typeof content === "string") {
1115
+ payload = { ...head, content };
1116
+ }
1117
+ else {
1118
+ const input = { ...toolInput };
1119
+ delete input["description"];
1120
+ payload = { ...head, input };
1121
+ }
1122
+ return {
1123
+ cls: surface ?? WORKSPACE_WRITE_CLASS,
1124
+ protectedPath: surface !== null,
1125
+ rule,
1126
+ file,
1127
+ worktree: tier.worktree,
1128
+ root: tier.root,
1129
+ payload,
1130
+ // The tier leads the headline rather than trailing it: a summary is
1131
+ // truncated from the right, and the qualifier is the last thing that may
1132
+ // be ellipsized away (a long path is not — the payload carries it whole).
1133
+ summary: summaryFor(tier, toolName, file),
1134
+ };
1135
+ }
1136
+ /** The headline for a tier: the qualifier first, the touch after it. */
1137
+ function summaryFor(tier, toolName, file) {
1138
+ if (tier.worktree !== null) {
1139
+ return `branch proposal (worktree ${tier.worktree}): ${toolName} ${file}`;
1140
+ }
1141
+ if (tier.rule === PROTECTED_NAME_ELSEWHERE_RULE) {
1142
+ return `file named like a policy file, outside this gated checkout: ${toolName} ${file}`;
1143
+ }
1144
+ return `${toolName} ${file}`;
1145
+ }
1146
+ /**
1147
+ * The tier, in the verdict's note, so an `allow` says what it authorized.
1148
+ *
1149
+ * The elsewhere arm names the root it was decided against, because "outside the
1150
+ * gated checkout" is only readable next to which checkout that is.
1151
+ */
1152
+ function fileTierNote(gated) {
1153
+ if (gated.worktree !== null) {
1154
+ return `${gated.rule}: ${gated.file} is inside agent worktree ${gated.worktree}, so this is a branch proposal and the merge to the live checkout is gated separately`;
1155
+ }
1156
+ if (gated.rule === PROTECTED_NAME_ELSEWHERE_RULE) {
1157
+ return `${gated.rule}: ${gated.file} is NAMED like a policy file but sits outside the gated checkout ${gated.root ?? "(unresolved)"}, so it is not this repository's live policy; it is gated because a protected name is protected wherever it sits`;
1158
+ }
1159
+ return `${gated.rule}: ${gated.file} is the LIVE checkout's copy`;
1160
+ }
1161
+ /**
1162
+ * The provenance pair to stamp on a record this invocation is about to write,
1163
+ * or `null` when this process cannot establish one (APRV-227).
1164
+ *
1165
+ * Called at the write site and not before. `installedHarnessVersion` memoizes,
1166
+ * so a multi-class command that registers once and a session that reaches this
1167
+ * twice both pay at most one probe per process.
1168
+ */
1169
+ function registrationProvenance(run) {
1170
+ return harnessProvenance(run.harness, run.eventVersion);
1171
+ }
1172
+ /**
1173
+ * Withdraw every still-pending key this invocation OPENED (APRV-106, narrowed
1174
+ * by APRV-117).
1175
+ *
1176
+ * BEST EFFORT, always. The caller has already decided what verdict it is
1177
+ * printing; this only decides whether a human is still going to be asked about
1178
+ * it. A withdrawal that refuses is reported on stderr and changes nothing —
1179
+ * including the case that matters most, `already-decided`, which means a human
1180
+ * answered while this was running and their answer must not be touched.
1181
+ *
1182
+ * Two things narrowed under APRV-117, and both are load-bearing.
1183
+ *
1184
+ * **The timeout no longer calls this immediately.** A request keyed by payload
1185
+ * hash can be adopted by the retry, so an answer that lands after this process
1186
+ * gave up still authorizes something; retracting it at once would be throwing
1187
+ * away the very decision the human is about to make. What still calls this is
1188
+ * every path where nothing will retry: a signal, a thrown failure, an intake
1189
+ * refusal that dooms the whole command, and — since APRV-287 — a wait whose
1190
+ * retry grace has run out (see {@link withdrawAbandoned}).
1191
+ *
1192
+ * **Only keys this invocation opened.** An ADOPTED key was requested by another
1193
+ * process, and `withdraw` is requester-only by design (APRV-106 rule 1): taking
1194
+ * back a question somebody else asked is exactly the queue-clearing the gate
1195
+ * refuses. So adopted keys are never passed here.
1196
+ *
1197
+ * Returns the keys actually withdrawn, for the deny reason.
1198
+ */
1199
+ function withdrawPending(run, streams, keys, why, reason = "cancelled") {
1200
+ const withdrawn = [];
1201
+ for (const key of keys) {
1202
+ const result = withdraw(run.logPath, key, run.actor, {
1203
+ ...run.options,
1204
+ reason,
1205
+ note: why,
1206
+ });
1207
+ if (result.ok) {
1208
+ withdrawn.push(key);
1209
+ continue;
1210
+ }
1211
+ if (result.code === "already-decided" || result.code === "request-withdrawn")
1212
+ continue;
1213
+ streams.err(`approval: the hook could not withdraw ${key} (${result.code}): ${result.message}\n`);
1214
+ }
1215
+ return withdrawn;
1216
+ }
1217
+ /**
1218
+ * Pending harness requests this actor opened that nothing will ever adopt
1219
+ * (APRV-287).
1220
+ *
1221
+ * ## The state this names
1222
+ *
1223
+ * A wait that expires leaves its question open, because a decision inside the
1224
+ * policy's TTL still authorizes an identical retry (APRV-117). That is right
1225
+ * for as long as a retry is plausible and wrong afterwards: on 2026-09-06 three
1226
+ * waits expired behind a dead daemon, nothing retried them, and the requests sat
1227
+ * live until the TTL — so the daemon's restart re-delivered a dozen dead
1228
+ * questions to a phone, one message each. The grace window
1229
+ * (`core/harness-wait.ts`) is where the two readings meet: inside it the
1230
+ * question is live for the retry, past it the asker is gone.
1231
+ *
1232
+ * ## What it will not name
1233
+ *
1234
+ * - **A request another actor opened.** `withdraw` is requester-only by design
1235
+ * (APRV-106 rule 1), so the filter is the same fact stated before the call:
1236
+ * taking back somebody else's question is the queue-clearing the gate
1237
+ * refuses.
1238
+ * - **The bytes this invocation is asking about.** `keepHash` is this
1239
+ * invocation's payload hash, and a request carrying it is the question this
1240
+ * process is adopting or waiting on. Sweeping it would be a hook withdrawing
1241
+ * its own live question.
1242
+ * - **Anything but a live `approval.requested`.** The state is derived through
1243
+ * `requestState` from the verified records the caller already read, so a
1244
+ * decided, expired or already withdrawn request is never touched.
1245
+ * - **A request younger than the wait plus the grace**, measured from the
1246
+ * `approval.requested` record's own runtime-assigned timestamp.
1247
+ *
1248
+ * Nothing here appends: the caller decides what to do with the list, and the
1249
+ * append happens through {@link withdrawPending} like every other withdrawal on
1250
+ * this surface.
1251
+ */
1252
+ function abandonedRequests(run, records, now, keepHash) {
1253
+ const nowMs = Date.parse(now);
1254
+ if (Number.isNaN(nowMs))
1255
+ return [];
1256
+ const limit = abandonedAfterMs(run.timeoutMs, run.graceMs);
1257
+ const found = new Map();
1258
+ for (const record of records) {
1259
+ if (record.event !== "approval.requested")
1260
+ continue;
1261
+ if (record.actor !== run.actor)
1262
+ continue;
1263
+ const key = record.action_key;
1264
+ if (typeof key !== "string" || key.length === 0)
1265
+ continue;
1266
+ const payload = payloadOf(record);
1267
+ if (payload["execution"] !== "harness")
1268
+ continue;
1269
+ if (keepHash !== null && payload["payload_hash"] === keepHash)
1270
+ continue;
1271
+ const at = Date.parse(record.ts);
1272
+ if (Number.isNaN(at) || nowMs - at < limit)
1273
+ continue;
1274
+ if (requestState(records, key, now, run.ttlMs).state !== "requested")
1275
+ continue;
1276
+ const cls = payload["class"];
1277
+ found.set(key, {
1278
+ actionKey: key,
1279
+ cls: typeof cls === "string" ? cls : "(no class)",
1280
+ ageMs: nowMs - at,
1281
+ });
1282
+ }
1283
+ return [...found.values()];
1284
+ }
1285
+ /** Minutes, for a sentence a human reads. */
1286
+ function minutesText(ms) {
1287
+ const minutes = Math.round(ms / 60_000);
1288
+ if (minutes >= 1)
1289
+ return `${String(minutes)}m`;
1290
+ return `${String(Math.max(1, Math.round(ms / 1000)))}s`;
1291
+ }
1292
+ /**
1293
+ * Take back every question this actor opened that the grace window has run out
1294
+ * on (APRV-287).
1295
+ *
1296
+ * Best effort, exactly as {@link withdrawPending} is: a withdrawal that refuses
1297
+ * changes nothing, and `already-decided` — a human answering while this ran —
1298
+ * is passed over in silence there. Returns the keys actually withdrawn.
1299
+ */
1300
+ function withdrawAbandoned(run, streams, records, now, keepHash, only = null) {
1301
+ const abandoned = abandonedRequests(run, records, now, keepHash).filter((entry) => only === null || only.includes(entry.actionKey));
1302
+ if (abandoned.length === 0)
1303
+ return [];
1304
+ return withdrawPending(run, streams, abandoned.map((entry) => entry.actionKey), `no retry adopted this question within ${minutesText(abandonedAfterMs(run.timeoutMs, run.graceMs))} of the hook's wait opening it (APRV-287); the asking tool call is gone, so the request is taken back rather than left for a listener to re-deliver`, "timeout");
1305
+ }
1306
+ /**
1307
+ * Spend every grant this verdict rests on, once each (APRV-117).
1308
+ *
1309
+ * A harness grant mints no token, so the record that it was used has to be
1310
+ * written deliberately: `consumeHarnessGrant` appends one `execution.started`
1311
+ * per key, through compare-and-append, and refuses `already-executed` if
1312
+ * anything spent it first. Called ONLY immediately before an `allow`, so a
1313
+ * verdict of deny spends nothing.
1314
+ *
1315
+ * Returns `null` on success, or the refusal that stopped it. A multi-class
1316
+ * command can consume its first key and fail on its second; the result is a
1317
+ * DENY with the first grant spent, which costs one extra prompt on the retry
1318
+ * and authorizes nothing. The reverse ordering — allow first, record later —
1319
+ * would trade that for a grant the harness used and the log never saw, so the
1320
+ * cheap failure is the correct one.
1321
+ *
1322
+ * `hash` is the binding the caller already computed over the bytes this verdict
1323
+ * is about (APRV-146). The gate requires it and compares it against what the
1324
+ * human answered: the same value keyed the carryover that found these grants, so
1325
+ * presenting it states, at the spend, the fact the match was made on.
1326
+ */
1327
+ function consumeGrants(run, keys, hash,
1328
+ /**
1329
+ * This invocation's own task id (APRV-200). The gate compares it against the
1330
+ * task the request record carries and records `grant_origin: "direct"` only
1331
+ * when they are the same tool call; anything else records `carried`.
1332
+ */
1333
+ task) {
1334
+ for (const key of keys) {
1335
+ const spent = consumeHarnessGrant(run.logPath, key, run.actor, {
1336
+ ...run.options,
1337
+ presentedPayloadHash: hash,
1338
+ spendingTask: task,
1339
+ });
1340
+ if (!spent.ok)
1341
+ return { code: spent.code, message: `${key}: ${spent.message}` };
1342
+ }
1343
+ return null;
1344
+ }
1345
+ /**
1346
+ * Establish, from the VERIFIED log, that every grant this verdict rests on is
1347
+ * spent and recorded — before the allow is printed (APRV-200).
1348
+ *
1349
+ * ## Why a second read
1350
+ *
1351
+ * {@link consumeGrants} appends through compare-and-append and reports what the
1352
+ * gate returned, which is the write side of §11.1 invariant 8. This is the read
1353
+ * side, and on this surface it is not redundant. Everywhere else in the runtime
1354
+ * the process that appends `execution.started` is the process that then performs
1355
+ * the side effect, so an append that returned success is an append the same
1356
+ * process is about to act on. Here the executor is the HARNESS: the hook prints
1357
+ * `allow` and a different program does the thing. What that program is authorized
1358
+ * by is not the gate's return value, which it never sees; it is the record. So
1359
+ * the record is what the hook checks, through the same verified path every
1360
+ * enforcement read in this module uses (§11.1 invariant 1), and a verdict is
1361
+ * printed only once the chain carries it.
1362
+ *
1363
+ * A failure here denies with the grant already spent. That is the fail-closed
1364
+ * direction and the same trade `consumeGrants` documents: the retry costs one
1365
+ * more prompt and authorizes nothing, where the reverse ordering would hand the
1366
+ * harness a permission the log cannot show.
1367
+ */
1368
+ function verifySpent(run, keys) {
1369
+ const read = readVerifiedRecords(run.logPath);
1370
+ if (!read.ok) {
1371
+ return {
1372
+ code: "hook-grant-unverified",
1373
+ detail: `the grant(s) for ${keys.join(", ")} were spent, but the log could not be re-read verified afterwards (${read.message}), so this hook cannot show that the record authorizing the tool call is in the chain. Nothing is allowed on an authorization the log cannot be seen to carry.`,
1374
+ };
1375
+ }
1376
+ const missing = keys.filter((key) => !read.records.some((record) => record.event === "execution.started" && record.action_key === key));
1377
+ if (missing.length === 0)
1378
+ return null;
1379
+ return {
1380
+ code: "hook-grant-unverified",
1381
+ detail: `the spend of ${missing.join(", ")} reported success and the verified log does not carry its execution.started; the tool call is denied rather than allowed on a record that is not there.`,
1382
+ };
1383
+ }
1384
+ /**
1385
+ * What the runtime must establish from the VERIFIED log before the hook lets a
1386
+ * command run with no human anywhere in the loop (APRV-139).
1387
+ *
1388
+ * The hole this closes. Until this check the hook's autonomous verdict was
1389
+ * computed from the on-disk policy alone: `loadPolicy`, `resolve`, allow. No
1390
+ * attestation, no loop-escalation, nothing appended, nothing read. Because the
1391
+ * harness — not this runtime — executes the tool call on an allow,
1392
+ * `core/execute.ts`'s own guards were never reached either, so whoever could
1393
+ * write APPROVAL.md (a merge, a second agent, a branch checkout) could
1394
+ * reclassify any class to `autonomous` and the hook would let the command
1395
+ * through. SPEC.md §11.1 says the opposite: an edited policy is inoperative
1396
+ * until a human re-attests it, and the hook is an enforcement surface like any
1397
+ * other.
1398
+ *
1399
+ * The two facts checked here are exactly the two `core/execute.ts`'s
1400
+ * supervised/autonomous branch checks before it starts one, and they are
1401
+ * checked in the same order, against a log read the same way:
1402
+ *
1403
+ * 1. the live policy bytes match the latest attestation (`core/attest.ts`);
1404
+ * 2. the task is not loop-escalated (SPEC.md §10.2, `core/loop.ts`).
1405
+ *
1406
+ * **Where a failure lands.** Both refusals are the gate's own frozen codes,
1407
+ * emitted through the `hook-gate-refused:` family, and they are the verdict the
1408
+ * gated path would have printed for these classes anyway: `core/gate.ts`'s
1409
+ * `request` checks attestation before it resolves anything, and refuses a
1410
+ * non-manual class for an escalated task. Checking here rather than there means
1411
+ * the deny costs no `task.registered` — under an unattested policy every
1412
+ * autonomous command an agent runs would otherwise append one, which is a log
1413
+ * full of registrations written under rules nobody is enforcing.
1414
+ *
1415
+ * **Only where nobody is asked.** The caller runs this when EVERY class resolves
1416
+ * non-manual. A command with a manual class keeps its existing path: escalation
1417
+ * escalates *to* manual rather than closing the task (`core/loop.ts`), and
1418
+ * refusing the human's question too would leave an escalated task with no way
1419
+ * back.
1420
+ */
1421
+ function unattendedGuard(logPath, policyPath, task,
1422
+ /**
1423
+ * Records this invocation has ALREADY read and verified (APRV-214). The
1424
+ * window lookup near the top of `runHarnessHook` performs a verified read
1425
+ * before the policy is loaded, and handing its result down means the closed
1426
+ * path still costs one verified read rather than gaining a third (APRV-209).
1427
+ * `null` where that read did not happen or did not verify, and then this does
1428
+ * its own, exactly as it always did.
1429
+ */
1430
+ known = null) {
1431
+ // The VERIFIED log, as every enforcement path reads it (SPEC.md §11.1): an
1432
+ // attestation or a failure streak read off unverified bytes is whatever the
1433
+ // last writer of the file wanted it to be.
1434
+ const read = known === null ? readVerifiedRecords(logPath) : { ok: true, records: known };
1435
+ if (!read.ok)
1436
+ return { code: "hook-io", detail: read.message };
1437
+ const refusal = attestationRefusal(checkAttestation(read.records, policyPath));
1438
+ if (refusal !== null) {
1439
+ return {
1440
+ code: `hook-gate-refused:${refusal.code}`,
1441
+ detail: `${refusal.message}. Until then the hook decides nothing unattended: this command would have run with no human in the loop under rules no human has vouched for.`,
1442
+ };
1443
+ }
1444
+ if (isLoopEscalated(read.records, task)) {
1445
+ return {
1446
+ code: "hook-gate-refused:loop-escalated",
1447
+ detail: `loop-escalated: task ${task} has three consecutive failed side-effecting executions and is escalated to manual (amended SPEC.md §10.2), so its unattended classes may not run. ${loopClearance("task", task)}.`,
1448
+ };
1449
+ }
1450
+ return null;
1451
+ }
1452
+ /**
1453
+ * Is this tool call floored to manual by a harness streak (APRV-145)?
1454
+ *
1455
+ * The VERIFIED log, like every other enforcement read in this module: a failure
1456
+ * streak computed off unverified bytes is whatever the last writer of the file
1457
+ * wanted it to be (§11.1 invariant 1). A log this hook cannot read fails closed
1458
+ * as a deny at the call site rather than as "no floor", because "we could not
1459
+ * establish the streak" is not "there is no streak".
1460
+ *
1461
+ * Both scopes are derived inside `core/loop.ts` from the task id this runtime
1462
+ * minted and the identity this process runs as. Nothing on the event names the
1463
+ * bucket it is counted in, which is what stops an agent shedding a streak by
1464
+ * rotating a string.
1465
+ */
1466
+ function harnessFloor(logPath, task, actor,
1467
+ /** Already-verified records from this invocation's window lookup (APRV-214). */
1468
+ known = null) {
1469
+ if (known !== null)
1470
+ return { ok: true, floor: harnessLoopFloor(known, task, actor) };
1471
+ const read = readVerifiedRecords(logPath);
1472
+ if (!read.ok)
1473
+ return { ok: false, detail: read.message };
1474
+ return { ok: true, floor: harnessLoopFloor(read.records, task, actor) };
1475
+ }
1476
+ /**
1477
+ * Charge and record every class of an unattended allow (APRV-141).
1478
+ *
1479
+ * One `execution.started` per class, through `core/gate.ts`, before the allow
1480
+ * is printed. Until this, a supervised or autonomous harness verdict appended
1481
+ * nothing at all, so `core/budgets.ts` charged it nothing (`daily_actions`
1482
+ * included) and `core/audit.ts` could never sample it — under Claude Code, on
1483
+ * the path that carries most of the traffic. The comment that path used to
1484
+ * carry was right that a record per agent action fills the log; APRV-141's
1485
+ * recorded decision is that an uncharged, unsampleable majority is the worse
1486
+ * of the two, and the record is kept as small as the contract allows.
1487
+ *
1488
+ * **The order is record-then-allow, and the failure is a deny.** A verdict
1489
+ * printed before the charge landed is a command that ran outside every budget,
1490
+ * which is the hole this closes. A refusal here (a budget ceiling, a head that
1491
+ * moved) therefore denies, and reaches the caller as the gate's own code.
1492
+ *
1493
+ * The autonomous classes are recorded with no `task.registered` behind them,
1494
+ * deliberately: `core/audit.ts` samples supervised executions only, so a
1495
+ * declaration would buy no oversight and would double the volume of exactly the
1496
+ * traffic this is trying not to drown the log in. The supervised classes are
1497
+ * registered already, by the caller, which is what makes them sampleable.
1498
+ */
1499
+ function recordUnattended(run, task, classes, hash) {
1500
+ for (const cls of classes) {
1501
+ const started = startHarnessExecution(run.logPath, { task, actionKey: `${task}:${cls}`, cls, payload_hash: hash }, run.actor, run.options);
1502
+ if (!started.ok)
1503
+ return { code: started.code, message: `${cls}: ${started.message}` };
1504
+ }
1505
+ return null;
1506
+ }
1507
+ /**
1508
+ * Say, on STDERR, that a question is now on a human's queue and where it went
1509
+ * (APRV-281).
1510
+ *
1511
+ * The behaviour this replaces: a gated tool call appended its request and then
1512
+ * blocked for the whole wait in complete silence, ending in a `hook-timeout` the
1513
+ * agent read as a refusal and the operator never saw coming. Nine minutes of a
1514
+ * session's clock, with no way to tell "nobody has answered yet" from "nothing
1515
+ * is even delivering this".
1516
+ *
1517
+ * **STDERR, and never stdout.** Stdout carries the verdict object the harness
1518
+ * parses (see this file's header); a second object, or any prose at all, on that
1519
+ * stream is a hook the harness cannot read. Claude Code shows stderr to the
1520
+ * operator, which is exactly the audience for this.
1521
+ *
1522
+ * **It decides nothing.** No verdict, no timeout, no record, no refusal code
1523
+ * turns on any of it. Both lines are printed after the request is appended and
1524
+ * before the poll loop starts, so the state they describe is the state that
1525
+ * exists; a probe that reported nothing (an unreadable directory, a platform
1526
+ * with no euid) simply stays quiet rather than changing what this process does.
1527
+ *
1528
+ * **The listener line names a socket, and claims only what a socket can tell
1529
+ * you.** `drawSocketUsable` is the same predicate an asker consults, and this
1530
+ * connects to nothing: a usable-looking socket therefore prints NOTHING here,
1531
+ * because a `stat` cannot establish that the far side answers. What an absent
1532
+ * or untrustworthy socket does establish is that `approval up` is not running
1533
+ * against this log in this checkout, and `approval up` is the one process that
1534
+ * both serves the channels and consumes the taps. That is worth saying: on
1535
+ * 2026-09-05 taps piled up unconsumed while hooks waited out their windows.
1536
+ */
1537
+ function announceWait(streams, run, waiting) {
1538
+ const where = run.channels.length === 0
1539
+ ? "no channel (this policy configures none, so nothing is delivering the question)"
1540
+ : `channel ${run.channels.join(", ")}`;
1541
+ for (const action of waiting) {
1542
+ const adopted = action.origin === "adopted"
1543
+ ? " The question was already open for these exact bytes, so this tool call adopts it rather than asking a second time."
1544
+ : "";
1545
+ 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`);
1546
+ }
1547
+ const socket = drawSocketPathFor(run.logPath);
1548
+ const listener = drawSocketUsable(socket);
1549
+ if (listener.ok)
1550
+ return;
1551
+ 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`);
1552
+ }
1553
+ /**
1554
+ * The gated half: find what is already open for these bytes, request whatever
1555
+ * is not, wait for the decisions, spend the grants. Returns the exit code of
1556
+ * whatever verdict it printed.
1557
+ *
1558
+ * ## Requests are keyed by bytes, not by invocation (APRV-117)
1559
+ *
1560
+ * The action key is still `hook:<session>:<tool-use id>:<class>` and is still
1561
+ * unique per invocation — what changed is that intake LOOKS for an earlier
1562
+ * request about the same `{command, cwd}` before opening a new one, matching on
1563
+ * the `payload_hash` recorded on `approval.requested`. Three outcomes per class,
1564
+ * decided by `core/gate.ts`'s `findHarnessCarry`:
1565
+ *
1566
+ * - nothing to carry: register and request, exactly as before;
1567
+ * - a pending request: **adopt** it — wait out the remainder of this
1568
+ * invocation's window on somebody else's key, opening nothing. The approver's
1569
+ * phone never shows two prompts for one command, because there is only ever
1570
+ * one question;
1571
+ * - an unspent grant inside the TTL: **carry** it — no wait, no prompt, and
1572
+ * the grant is spent (once) before the allow is printed.
1573
+ *
1574
+ * ## Why the wait no longer ends in a withdrawal (APRV-106, revised)
1575
+ *
1576
+ * APRV-106 retracted the request when the wait elapsed, because a retried tool
1577
+ * call was a new request with a new key and a late tap therefore authorized
1578
+ * nothing: the human spent attention on a question whose asker had left. The
1579
+ * carryover above removes the premise. A late tap now authorizes the retry, so
1580
+ * the request stays open for the policy's TTL and the timeout says so.
1581
+ *
1582
+ * What still withdraws is every path where nothing can adopt the question: a
1583
+ * SIGTERM or SIGINT (the session is going away), a thrown failure, and an intake
1584
+ * refusal partway through a multi-class command (the command cannot proceed on
1585
+ * any retry, so the classes already opened are noise in a human's queue). The
1586
+ * signal handlers are installed for the duration of the wait ONLY, and removed
1587
+ * in `finally`: a hook process is short-lived and borrowing the harness's
1588
+ * signal disposition for longer than the loop would be a side effect nobody
1589
+ * asked for.
1590
+ */
1591
+ function gateAndWait(streams, run, classes,
1592
+ /**
1593
+ * The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
1594
+ * itself for a file tool (APRV-124). Whatever this is, it is what reaches the
1595
+ * approver's FULL PAYLOAD block, complete — the summary below is a headline
1596
+ * and is the only thing here that may be shortened.
1597
+ */
1598
+ payload, headline,
1599
+ /**
1600
+ * The task id this invocation acts under, minted once by the caller
1601
+ * (APRV-139) so the loop-escalation check and the registration it may lead to
1602
+ * name the same task. Deriving it twice would mint two ids whenever
1603
+ * `tool_use_id` is absent and the random fallback runs.
1604
+ */
1605
+ task,
1606
+ /** The history-rewrite refinement's own words, or `""` (APRV-108). */
1607
+ note = "",
1608
+ /**
1609
+ * The harness streak that floors the SIDE-EFFECTING classes of this
1610
+ * invocation to `manual` (APRV-145, narrowed by APRV-297), or `null` where
1611
+ * policy alone sent it here.
1612
+ *
1613
+ * Passed into `request` as a boolean rather than acted on here, so the floored
1614
+ * action takes the identical path a manual class takes — same records, same
1615
+ * order, same wait — and nothing below knows how it got there. What the STATE
1616
+ * adds (APRV-280) is the deny text: an agent whose commands are all suddenly
1617
+ * on the phone is owed the reason and the way out in the same breath, and
1618
+ * before APRV-280 the nine-minute wait ended in a bare `hook-timeout` that
1619
+ * said neither.
1620
+ *
1621
+ * Since APRV-297 the caller passes `null` for a command whose classes are all
1622
+ * reads, and {@link floorApplies} below carves the read classes out of a mixed
1623
+ * one, so a floor never puts a question about looking on a human's phone.
1624
+ */
1625
+ floor = null) {
1626
+ /**
1627
+ * Does the floor route THIS class to a human? (APRV-297.)
1628
+ *
1629
+ * Per class rather than per command, because a MIXED tool call is one question
1630
+ * about its side effects and no question at all about its looking. Under a
1631
+ * floor, `ls -la && mkdir build` raises the write and leaves the read to the
1632
+ * policy, so the approver sees one prompt for what the command DOES. Before
1633
+ * this the read class was raised too, and a floored session put two prompts on
1634
+ * a phone for one command, one of which nobody needed to answer.
1635
+ *
1636
+ * The command is still routed as a whole: the verdict waits on the classes
1637
+ * that were raised, and an allow covers the command.
1638
+ */
1639
+ const floorApplies = (cls) => floor !== null && isSideEffectingClass(cls);
1640
+ const hash = payloadHash(payload);
1641
+ const summary = truncate(headline, SUMMARY_LIMIT);
1642
+ const sayAllow = (reason) => allow(streams, reason, run.harness);
1643
+ /**
1644
+ * Every deny this function can print, with the floor's own sentence appended
1645
+ * when a floor is what routed the command here (APRV-280). One wrapper rather
1646
+ * than a sentence bolted onto the timeout alone: a floored invocation that
1647
+ * ends in a rejection, a lapse or an I/O fault leaves the agent in exactly the
1648
+ * same place, and the operator reading the harness's error stream needs the
1649
+ * scope key either way.
1650
+ */
1651
+ const sayDeny = (code, detail) => deny(streams, code, floor === null
1652
+ ? detail
1653
+ : `${detail} This tool call was routed to a human by loop safety rather than by policy — loop-escalated: ${floor.scope} ${floor.key} has ${String(floor.consecutiveFailures)} consecutive failed side-effecting harness tool calls (amended SPEC.md §10.2). ${loopClearance(floor.scope, floor.key)}`, run.harness);
1654
+ // Intake reads the VERIFIED log, once, before anything is written: an
1655
+ // enforcement path reads nothing else (SPEC.md §11.1), and a carry decided
1656
+ // from unverified bytes would be a grant invented by whoever could write the
1657
+ // file.
1658
+ const intake = readVerifiedRecords(run.logPath);
1659
+ if (!intake.ok)
1660
+ return sayDeny("hook-io", intake.message);
1661
+ const intakeTs = new Date().toISOString();
1662
+ // APRV-287. Before this invocation adds a question of its own, the questions
1663
+ // earlier invocations of this actor left behind are taken back — every one
1664
+ // whose grace window has run out, and never the bytes this one is about to
1665
+ // ask about. The hook is the only writer that can do this: `withdraw` is
1666
+ // requester-only, and the requester of a harness request is this actor.
1667
+ const swept = withdrawAbandoned(run, streams, intake.records, intakeTs, hash);
1668
+ if (swept.length > 0) {
1669
+ 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`);
1670
+ }
1671
+ const actions = classes.map((cls) => {
1672
+ const carry = findHarnessCarry(intake.records, hash, cls, intakeTs, run.ttlMs);
1673
+ if (carry === null)
1674
+ return { cls, actionKey: `${task}:${cls}`, origin: "new" };
1675
+ return {
1676
+ cls,
1677
+ actionKey: carry.actionKey,
1678
+ origin: carry.kind === "granted" ? "carried" : "adopted",
1679
+ };
1680
+ });
1681
+ const fresh = actions.filter((action) => action.origin === "new");
1682
+ const adopted = actions.filter((action) => action.origin === "adopted");
1683
+ const carried = actions.filter((action) => action.origin === "carried");
1684
+ // Only the classes that need a new question are registered. A retry whose
1685
+ // every class carries or adopts registers no task at all — the envelope it
1686
+ // would declare already exists, under the key it is about to wait on.
1687
+ if (fresh.length > 0) {
1688
+ const envelope = {
1689
+ origin: { app: run.originApp, created_by: run.actor },
1690
+ state: "proposed",
1691
+ actions: fresh.map((action) => ({
1692
+ class: action.cls,
1693
+ summary,
1694
+ idempotency_key: action.actionKey,
1695
+ payload_hash: hash,
1696
+ })),
1697
+ };
1698
+ // APRV-227: which harness binary wrote this registration. A CALL option,
1699
+ // never a field of the envelope above — an envelope is authored by the
1700
+ // party under oversight, and this is a statement about the binary doing
1701
+ // the overseeing.
1702
+ const provenance = registrationProvenance(run);
1703
+ const registered = register(run.logPath, { task, envelope }, run.actor, {
1704
+ ...run.options,
1705
+ ...(provenance === null ? {} : { harness: provenance }),
1706
+ });
1707
+ if (!registered.ok) {
1708
+ return sayDeny(`hook-gate-refused:${registered.code}`, registered.message);
1709
+ }
1710
+ }
1711
+ // `execution: "harness"` says a grant here mints no execution token. The hook
1712
+ // answers allow/deny and Claude Code runs the command; nothing ever calls
1713
+ // `approval run`, so a minted token would be a live credential with no
1714
+ // spender. It removes capability from the requester and grants none.
1715
+ //
1716
+ // APRV-106's companion field, `wait_until`, is deliberately NOT declared any
1717
+ // more. It rendered as "requester waits until 09:23 UTC" on the approver's
1718
+ // phone, and under carryover that sentence is false: an answer after this
1719
+ // invocation stops waiting authorizes the retry. With no `wait_until` the
1720
+ // channel's own line falls back to the deadline that does govern — "expires
1721
+ // HH:MM UTC", the policy's TTL — which is now exactly the truth.
1722
+ const ownKeys = [];
1723
+ for (const action of fresh) {
1724
+ const result = request(run.logPath, {
1725
+ task,
1726
+ actionKey: action.actionKey,
1727
+ cls: action.cls,
1728
+ summary,
1729
+ payload_hash: hash,
1730
+ payload: { value: payload },
1731
+ execution: "harness",
1732
+ ...(floorApplies(action.cls) ? { loopFloor: true } : {}),
1733
+ }, run.actor, run.options);
1734
+ if (!result.ok) {
1735
+ // Whatever this invocation opened is retracted before the deny: a refusal
1736
+ // on the third class dooms the command on every retry too, so the first
1737
+ // two must not stand in a queue that nothing will ever adopt.
1738
+ 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`);
1739
+ return sayDeny(`hook-gate-refused:${result.code}`, result.message);
1740
+ }
1741
+ if (result.record !== null)
1742
+ ownKeys.push(action.actionKey);
1743
+ }
1744
+ /** Every key that must be granted before this hook says yes. */
1745
+ const waitKeys = [...adopted.map((action) => action.actionKey), ...ownKeys];
1746
+ /** Every key whose grant this verdict would spend. */
1747
+ const spendKeys = [...carried.map((action) => action.actionKey), ...waitKeys];
1748
+ /** How the allow line describes where its authorization came from. */
1749
+ const provenance = carried.length === 0
1750
+ ? ""
1751
+ : ` (carried: ${carried.map((action) => action.actionKey).join(", ")})`;
1752
+ if (waitKeys.length === 0) {
1753
+ if (spendKeys.length === 0) {
1754
+ // Every class resolved supervised: intake recorded no request (amended
1755
+ // SPEC.md §6.3), so there is nothing to wait for and nothing to spend.
1756
+ // What there is, since APRV-141, is something to charge: the start event
1757
+ // is this execution's authorization, and the registration `fresh` just
1758
+ // wrote is what makes it a sampleable one.
1759
+ const charged = recordUnattended(run, task, classes, hash);
1760
+ if (charged !== null) {
1761
+ return sayDeny(`hook-gate-refused:${charged.code}`, charged.message);
1762
+ }
1763
+ return sayAllow(`granted: ${classes.join(", ")} needs no approval under this policy${note}`);
1764
+ }
1765
+ // Every gated class carried an unspent grant: a human already answered this
1766
+ // exact question about these exact bytes, and nobody is asked again.
1767
+ const failed = consumeGrants(run, spendKeys, hash, task);
1768
+ if (failed !== null) {
1769
+ return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
1770
+ }
1771
+ const unverified = verifySpent(run, spendKeys);
1772
+ if (unverified !== null)
1773
+ return sayDeny(unverified.code, unverified.detail);
1774
+ return sayAllow(`granted: ${classes.join(", ")}${provenance}${note}`);
1775
+ }
1776
+ // Past every early return, so this is reached only where this process is
1777
+ // genuinely about to block on a human (APRV-281). The set it names is the set
1778
+ // it waits on: the keys this invocation opened, plus the ones it adopted from
1779
+ // an earlier tool call, which wait in the same silence and were the case the
1780
+ // announce would most easily have missed. A carried grant is not here because
1781
+ // nothing is waiting on it.
1782
+ announceWait(streams, run, actions.filter((action) => waitKeys.includes(action.actionKey)));
1783
+ const deadline = Date.now() + run.timeoutMs;
1784
+ // A signal arriving mid-wait means the session is going away: nothing will
1785
+ // retry this command, so the question this invocation opened is retracted.
1786
+ // `process.exit` is deliberate and immediate: the default disposition for
1787
+ // these signals is to die, and a handler that only withdrew would leave the
1788
+ // hook wedged in its poll loop with the harness waiting on it.
1789
+ const onSignal = (signal) => {
1790
+ withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
1791
+ process.exit(EXIT_USAGE);
1792
+ };
1793
+ const onTerm = () => onSignal("SIGTERM");
1794
+ const onInt = () => onSignal("SIGINT");
1795
+ process.on("SIGTERM", onTerm);
1796
+ process.on("SIGINT", onInt);
1797
+ /**
1798
+ * Has this invocation already said, on stderr, that the verified view lags
1799
+ * the requests it is waiting on (APRV-294)? Said once per invocation: the
1800
+ * poll runs every second, and a line per poll would bury the one line that
1801
+ * matters under sixty copies of itself.
1802
+ */
1803
+ let saidLagging = false;
1804
+ try {
1805
+ for (;;) {
1806
+ const read = readVerifiedRecords(run.logPath);
1807
+ if (!read.ok) {
1808
+ withdrawPending(run, streams, ownKeys, `the hook could not read the log while waiting on ${task}`);
1809
+ return sayDeny("hook-io", read.message);
1810
+ }
1811
+ const ts = new Date().toISOString();
1812
+ // Only the keys this invocation is waiting on count. Deriving the set
1813
+ // from the log again would let an empty or foreign result read as
1814
+ // "nothing pending" and fall through to allow; the verified log must show
1815
+ // every one of these keys granted before the hook says yes.
1816
+ const derived = waitKeys.map((key) => ({
1817
+ key,
1818
+ state: requestState(read.records, key, ts, run.ttlMs).state,
1819
+ }));
1820
+ const states = derived.map((entry) => entry.state);
1821
+ /**
1822
+ * Keys this process ESTABLISHED exist, that this read does not carry
1823
+ * (APRV-294).
1824
+ *
1825
+ * Every key in `waitKeys` was seen in a verified read by this process:
1826
+ * `ownKeys` because `request` appended it and returned the record,
1827
+ * `adopted` because intake's verified read found the pending request it
1828
+ * is adopting. So `none` here is never the terminal fact "there is no
1829
+ * such request". A log is append-only; a request that existed does not
1830
+ * stop existing. What `none` says is that the view this read produced
1831
+ * does not yet carry a record this process holds, which is a fact about
1832
+ * the view and not about the request.
1833
+ *
1834
+ * On 2026-09-07 02:00Z, minutes after `approval log sync` replaced the
1835
+ * committed baseline and the daemon restarted, a hook read exactly this
1836
+ * and denied at once: `hook-io: the verified log does not show every
1837
+ * request as granted (states: none, none, none)`. The requests were real
1838
+ * and reached the approver's phone; the view had not caught up. Treating
1839
+ * that as terminal spends the human's answer on nothing and, since it is
1840
+ * a deny, hands the agent a refusal for a question still open.
1841
+ *
1842
+ * So a lagging key waits, exactly as `requested` waits, bounded by the
1843
+ * same timeout — and nothing here reads unverified bytes as verified,
1844
+ * which is the only response to a lag that §11.1 invariant 1 leaves open.
1845
+ * The APRV-287 withdrawal still applies at expiry, over the keys whose
1846
+ * requests the view does carry.
1847
+ */
1848
+ const lagging = derived
1849
+ .filter((entry) => entry.state === "none")
1850
+ .map((entry) => entry.key);
1851
+ if (lagging.length > 0 && !saidLagging) {
1852
+ saidLagging = true;
1853
+ 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`);
1854
+ }
1855
+ if (!states.includes("requested") && lagging.length === 0) {
1856
+ // Precedence, as `approval wait` fixes it: a human's "no" outranks a
1857
+ // lapse, and both outrank "everything was granted". A withdrawal sits
1858
+ // with the refusals: it is not a decision, but it is terminal, and it
1859
+ // means this key will never be granted.
1860
+ if (states.includes("rejected")) {
1861
+ return sayDeny("hook-rejected", `a human rejected ${task}`);
1862
+ }
1863
+ if (states.includes("revoked")) {
1864
+ return sayDeny("hook-revoked", `approval for ${task} was withdrawn`);
1865
+ }
1866
+ if (states.includes("withdrawn")) {
1867
+ return sayDeny("hook-withdrawn", `the request for ${task} was withdrawn before a decision; nothing is pending and nothing was authorized`);
1868
+ }
1869
+ if (states.includes("expired")) {
1870
+ return sayDeny("hook-expired", `the request for ${task} lapsed before a decision`);
1871
+ }
1872
+ if (states.every((state) => state === "granted")) {
1873
+ // The grants are spent before the allow is printed, so this exact
1874
+ // command cannot ride the same authorization twice.
1875
+ const failed = consumeGrants(run, spendKeys, hash, task);
1876
+ if (failed !== null) {
1877
+ return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
1878
+ }
1879
+ const unverified = verifySpent(run, spendKeys);
1880
+ if (unverified !== null)
1881
+ return sayDeny(unverified.code, unverified.detail);
1882
+ return sayAllow(`granted: ${task} (${classes.join(", ")})${provenance}${note}`);
1883
+ }
1884
+ // Not a wait outcome: the log disagrees with itself about keys this
1885
+ // process is waiting on. Nothing is retracted, because the state that
1886
+ // would justify retracting is the state that could not be established.
1887
+ //
1888
+ // A BACKSTOP since APRV-294, and deliberately kept. `none` no longer
1889
+ // reaches here (it waits, above) and every remaining state is either
1890
+ // terminal and answered above or `granted`, so this is unreachable
1891
+ // through today's `RequestState`. It stands for the state a later
1892
+ // member of that union would arrive as: an outcome this function has no
1893
+ // reading for denies rather than allows.
1894
+ return sayDeny("hook-io", `the verified log does not show every request for ${task} as granted (states: ${states.join(", ")})`);
1895
+ }
1896
+ if (Date.now() >= deadline) {
1897
+ // APRV-117, narrowed by APRV-287. The request stays open for the RETRY
1898
+ // GRACE: a decision inside that window authorizes the retry of this
1899
+ // exact command in this exact directory, once, and withdrawing at the
1900
+ // first expiry would discard the answer the human is about to give.
1901
+ // Past the grace nobody is coming back for it, and a question nothing
1902
+ // will adopt is taken back rather than left for a restarted listener to
1903
+ // re-deliver.
1904
+ const withdrawn = withdrawAbandoned(run, streams, read.records, ts, null, ownKeys);
1905
+ // APRV-294: a wait that ends with the view still short of its own
1906
+ // requests says so. The deny is the same deny — the wait ran out — and
1907
+ // the repair is different from a queue nobody answered: the log this
1908
+ // hook reads is behind the log it wrote to, and `approval log verify`
1909
+ // in the checkout that owns it is where that is established.
1910
+ const stillLagging = lagging.length === 0
1911
+ ? ""
1912
+ : ` 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.`;
1913
+ if (withdrawn.length > 0) {
1914
+ 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}`);
1915
+ }
1916
+ 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}`);
1917
+ }
1918
+ sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
1919
+ }
1920
+ }
1921
+ catch (cause) {
1922
+ // The thrown path. `commandHarnessHook` turns this into an ordinary
1923
+ // deny. Unlike the timeout, this process cannot say what state it left
1924
+ // behind, so the question it opened is retracted rather than left standing
1925
+ // on a failure nobody diagnosed.
1926
+ withdrawPending(run, streams, ownKeys, `the requesting hook process failed while waiting (${cause instanceof Error ? cause.message : String(cause)})`);
1927
+ throw cause;
1928
+ }
1929
+ finally {
1930
+ process.off("SIGTERM", onTerm);
1931
+ process.off("SIGINT", onInt);
1932
+ }
1933
+ }
1934
+ // ===========================================================================
1935
+ // The completion counterpart (APRV-145)
1936
+ // ===========================================================================
1937
+ /**
1938
+ * The hook event names that report a tool call's OUTCOME rather than ask about
1939
+ * it, from `docs/claude-code-hook.md`'s pinned contract.
1940
+ *
1941
+ * `PostToolUseFailure` is listed because Claude Code splits the report in two:
1942
+ * a tool call that failed outright fires it instead of `PostToolUse`, and a
1943
+ * counterpart that only knew the success event would record the completions and
1944
+ * silently drop every failure, which is the one direction §11.1 invariant 4
1945
+ * forbids.
1946
+ */
1947
+ const POST_TOOL_EVENTS = ["PostToolUse", "PostToolUseFailure"];
1948
+ /**
1949
+ * Every line the counterpart can print, closed and machine-readable (§11.1
1950
+ * invariant 7).
1951
+ *
1952
+ * A post-execution hook cannot deny anything — the tool has already run — so
1953
+ * none of these is a verdict, and every one of them prints an EMPTY STDOUT: a
1954
+ * decision object on that stream would be a second answer about a command the
1955
+ * harness already ran. The line goes to stderr instead.
1956
+ *
1957
+ * ## The exit code decides whether anybody reads that line (APRV-303)
1958
+ *
1959
+ * Claude Code's hooks reference states it plainly: stderr from a hook that
1960
+ * exits 0 "goes to the debug log only, never the transcript, and Claude never
1961
+ * sees it", and a post-execution hook that exits 2 has its stderr shown, since
1962
+ * there is nothing left to block. So a refusal reported at exit 0 is a refusal
1963
+ * nobody receives, which is how 22052 unreported starts accumulated on this
1964
+ * project's own log without a single visible complaint.
1965
+ *
1966
+ * Therefore: {@link POST_TOOL_REPORTED} exits 0, because a counterpart that
1967
+ * landed is not news; every other code exits {@link POST_TOOL_SURFACE_EXIT},
1968
+ * because every other code means the outcome of a tool call was not recorded
1969
+ * and somebody has to know. Neither exit is a verdict, and neither blocks
1970
+ * anything.
1971
+ */
1972
+ export const POST_TOOL_CODES = [
1973
+ /** One or more counterparts were appended. */
1974
+ "post-tool-reported",
1975
+ /** The event names no tool-use id, so no task id can be reconstructed. */
1976
+ "post-tool-unidentified",
1977
+ /** The tool is not one this hook gates, so no start exists to close. */
1978
+ "post-tool-not-gated",
1979
+ /**
1980
+ * The outcome could not be read from the event by the pinned set of readings,
1981
+ * so NOTHING was appended. Recording a failure nobody observed trips an
1982
+ * escalation on noise, and recording a completion nobody observed clears one
1983
+ * on nothing.
1984
+ */
1985
+ "post-tool-unreadable-outcome",
1986
+ /** No log where the hook was pointed; the hook is a writer, never an initializer. */
1987
+ "post-tool-log-unreachable",
1988
+ /** The gate refused the append; its own frozen code follows a colon. */
1989
+ "post-tool-gate-refused",
1990
+ /** Malformed input, or a filesystem fact that stopped the report. */
1991
+ "post-tool-io",
1992
+ ];
1993
+ /** The one code that means the counterpart landed, and the one that exits 0. */
1994
+ const POST_TOOL_REPORTED = "post-tool-reported";
1995
+ /**
1996
+ * The exit code that makes a post-execution hook's stderr visible (APRV-303).
1997
+ *
1998
+ * It is the number Claude Code's hook protocol reserves for "show this line",
1999
+ * and on this one path it means exactly that. It is NOT `EXIT_USAGE`, whose
2000
+ * meaning in `cli/exit-codes.ts` is a malformed invocation: the harness hooks
2001
+ * speak the harness's protocol on both streams already (stdout carries a
2002
+ * decision object no other verb prints), and the exit code is the third field
2003
+ * of that same protocol. Nothing branches on it inside this runtime.
2004
+ */
2005
+ const POST_TOOL_SURFACE_EXIT = 2;
2006
+ /**
2007
+ * One machine-readable line on stderr. Never a verdict, and never blocking.
2008
+ *
2009
+ * Exit 0 for the report that landed, {@link POST_TOOL_SURFACE_EXIT} for every
2010
+ * other code, so that a report which did NOT land is seen rather than written
2011
+ * to a debug log nobody opens (see {@link POST_TOOL_CODES}).
2012
+ */
2013
+ function report(streams, code, detail, extra = {}) {
2014
+ streams.err(`${JSON.stringify({ approval: { hook: "post-tool-use", code, detail, ...extra } })}\n`);
2015
+ return code === POST_TOOL_REPORTED ? EXIT_OK : POST_TOOL_SURFACE_EXIT;
2016
+ }
2017
+ /**
2018
+ * Read a tool call's outcome off the reporting event, by a CLOSED set of
2019
+ * readings.
2020
+ *
2021
+ * ## THE EVENT NAME IS THE OUTCOME (APRV-303)
2022
+ *
2023
+ * The reading this replaces was written against a payload Claude Code does not
2024
+ * send. It asked for `tool_response.type` and accepted `text`, `base64` or
2025
+ * `error`, which is the shape of an API content block. What the event actually
2026
+ * carries under `tool_response` is the TOOL'S OWN structured output, verbatim,
2027
+ * and the hooks reference says so in as many words. From the shipped
2028
+ * declarations in `@anthropic-ai/claude-code/sdk-tools.d.ts`:
2029
+ *
2030
+ * - `BashOutput` has `stdout`, `stderr`, `interrupted`, `isImage` and no `type`;
2031
+ * - `FileEditOutput` (Edit, MultiEdit) has `filePath`, `oldString`,
2032
+ * `newString`, `structuredPatch` and no `type`;
2033
+ * - `FileWriteOutput` (Write) does have `type`, whose values are `create` and
2034
+ * `update`;
2035
+ * - `NotebookEditOutput` has no `type` and an optional `error` string.
2036
+ *
2037
+ * So the old reading matched NOTHING a Claude Code session emits, and every
2038
+ * successful tool call was reported unreadable and appended nothing. Measured
2039
+ * on this project's own log on 2026-09-07: 22062 harness starts, 10 reports,
2040
+ * and of the reports the `agent:claude-code` actor filed, nine were failures
2041
+ * and none was a completion. The §10.2 streak became a ratchet that only ever
2042
+ * counts up, so every long session escalated itself to manual and stayed there.
2043
+ *
2044
+ * The contract that IS true is the one the reference states about the events
2045
+ * themselves. `PostToolUse` "runs immediately after a tool completes
2046
+ * successfully". `PostToolUseFailure` runs "when a tool that started executing
2047
+ * fails". Claude Code fires exactly one of the two, neither of them when a
2048
+ * permission decision stopped the call before it ran. The event name is
2049
+ * therefore the whole reading, and it is the reading with the best provenance
2050
+ * available here: it is the harness saying which of its own two code paths ran,
2051
+ * rather than this process inferring an outcome out of a body of text.
2052
+ *
2053
+ * ## The refinements, and their direction
2054
+ *
2055
+ * Two readings of `tool_response` sit on top, and BOTH of them only ever move
2056
+ * the answer away from "completed" (§11.1 invariant 4: a field the reporting
2057
+ * side authors may raise scrutiny and never lower it):
2058
+ *
2059
+ * - `interrupted: true` (`BashOutput`) is UNREADABLE. A command a person
2060
+ * interrupted neither completed nor failed on its own terms; counting it a
2061
+ * failure trips an escalation on somebody's ctrl-C, and counting it a
2062
+ * completion clears a streak on a command that never finished.
2063
+ * - `type: "error"`, or a non-empty `error` string (`NotebookEditOutput`, and
2064
+ * the MCP error result the reference names) is a FAILURE, whatever the event
2065
+ * name claimed.
2066
+ *
2067
+ * Unreadable means append nothing, and that is the safe answer in both
2068
+ * directions at once. A failure nobody observed would trip an escalation on
2069
+ * noise, and a control that trips on noise is one operators learn to silence
2070
+ * (§8 makes this argument about timestamp anomalies). A completion nobody
2071
+ * observed would clear a streak on nothing. Appending nothing leaves the path
2072
+ * exactly as vacuous as it was before this verb existed, for that tool, and
2073
+ * manufactures neither. Since APRV-303 the unreadable arm also SAYS SO on a
2074
+ * stream somebody reads (see {@link report}).
2075
+ *
2076
+ * NOTHING OF THE TOOL'S OUTPUT IS READ. Only the shape: the event name, and
2077
+ * whether two enumerated fields are present and what kind of value they hold.
2078
+ * No text from any of them reaches the log or this function's return.
2079
+ */
2080
+ function readReportedOutcome(input) {
2081
+ const event = input.hookEventName;
2082
+ if (event !== "PostToolUse" && event !== "PostToolUseFailure") {
2083
+ return {
2084
+ ok: false,
2085
+ detail: `hook_event_name is ${event === null ? "absent" : JSON.stringify(event)}, which is neither of the two events this adapter reports an outcome for (PostToolUse, PostToolUseFailure)`,
2086
+ };
2087
+ }
2088
+ const response = input.toolResponse;
2089
+ // The one thing that unreads an event of either name. `PostToolUseFailure`
2090
+ // carries `is_interrupt` for the same fact and no `tool_response` at all, so
2091
+ // both spellings are checked and neither is trusted to say anything else.
2092
+ if (response?.["interrupted"] === true || input.interrupted === true) {
2093
+ return {
2094
+ ok: false,
2095
+ 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",
2096
+ };
2097
+ }
2098
+ if (event === "PostToolUseFailure")
2099
+ return { ok: true, outcome: "failed" };
2100
+ const errorText = response?.["error"];
2101
+ if (response?.["type"] === "error" ||
2102
+ (typeof errorText === "string" && errorText.length > 0)) {
2103
+ return { ok: true, outcome: "failed" };
2104
+ }
2105
+ return { ok: true, outcome: "completed" };
2106
+ }
2107
+ /**
2108
+ * The post-execution half of `approval hook <harness>` (APRV-145).
2109
+ *
2110
+ * It closes the delegated `execution.started` records the pre-execution half
2111
+ * wrote for this same tool call, so that the harness scopes of amended
2112
+ * SPEC.md §10.2 have a failure signal to accrue at all. Everything that makes
2113
+ * that safe lives in `core/gate.ts`'s `finishHarnessExecution`; what lives here
2114
+ * is the reading of the event and nothing else.
2115
+ */
2116
+ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
2117
+ if (input.toolName !== adapter.shellTool && !adapter.fileTools.includes(input.toolName)) {
2118
+ return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
2119
+ }
2120
+ if (input.toolUseId === null) {
2121
+ // The pre-execution half falls back to random bytes when the harness names
2122
+ // no tool-use id, and those bytes are not recoverable from this event. The
2123
+ // start stands, unclosed, and is counted in the coverage row of
2124
+ // `approval status` rather than closed against a guess.
2125
+ return report(streams, "post-tool-unidentified", "the event carries no tool_use_id, so the task id the pre-execution hook minted cannot be reconstructed; nothing was appended");
2126
+ }
2127
+ const reading = readReportedOutcome(input);
2128
+ if (!reading.ok) {
2129
+ return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended`);
2130
+ }
2131
+ const { logPath, root } = hookScope(flags, cwd);
2132
+ if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
2133
+ return report(streams, "post-tool-log-unreachable", `no log at ${logPath}; the hook writes to an existing log and never creates one. Run \`approval init\` in ${root}`);
2134
+ }
2135
+ const finished = finishHarnessExecution(logPath, {
2136
+ sessionId: input.sessionId,
2137
+ toolUseId: input.toolUseId,
2138
+ outcome: reading.outcome,
2139
+ // The one member of the closed set at v0.1. It names the untrusted
2140
+ // reporter and reduces nothing.
2141
+ reportedBy: "post-tool-use",
2142
+ }, actor);
2143
+ if (!finished.ok) {
2144
+ return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message);
2145
+ }
2146
+ return report(streams, POST_TOOL_REPORTED, `recorded ${reading.outcome} for ${String(finished.records.length)} delegated execution(s) of ${finished.task}`, { task: finished.task, outcome: reading.outcome, appended: finished.records.length });
2147
+ }
2148
+ function describeToolCall(input, adapter, protectedPaths, cwd) {
2149
+ if (input.toolName === adapter.shellTool) {
2150
+ const raw = readString(input.toolInput, "command");
2151
+ if (raw === null) {
2152
+ return {
2153
+ kind: "deny",
2154
+ code: "hook-io",
2155
+ detail: `${adapter.shellTool} tool_input carries no command string`,
2156
+ };
2157
+ }
2158
+ // Unchanged since APRV-117, deliberately: the payload is the WHOLE command
2159
+ // and the directory it runs in, so the FULL PAYLOAD block on the phone
2160
+ // carries every byte the harness will execute. Only `summary` is shortened.
2161
+ const payload = { command: raw, cwd: input.cwd };
2162
+ // APRV-108: a local rewrite of history this checkout never published is a
2163
+ // commit. APRV-267: a delete confined to the agent's own scratch is not a
2164
+ // decision. Both run in the hook's own cwd, after classification and never
2165
+ // inside it, and neither claims anything it cannot establish from the disk.
2166
+ const refined = classifyForHook(raw, protectedPaths, cwd);
2167
+ const classified = refined.result;
2168
+ if (!classified.ok) {
2169
+ return {
2170
+ kind: "deny",
2171
+ code: `hook-${classified.code}`,
2172
+ detail: `${classified.detail} (segment: ${classified.segment}). Rewrite it as a command the classifier can read, or run the effect through \`approval run\` with a granted token.`,
2173
+ };
2174
+ }
2175
+ return {
2176
+ kind: "gated",
2177
+ classes: classified.classes.filter((cls) => cls !== GATE_SELF_CLASS),
2178
+ payload,
2179
+ headline: raw,
2180
+ notes: refined.notes,
2181
+ segments: classified.segments,
2182
+ };
2183
+ }
2184
+ const gated = fileToolGate(input.toolName, input.toolInput, protectedPaths, cwd);
2185
+ if (gated === null) {
2186
+ return { kind: "allow", reason: `${input.toolName} names no file, so there is nothing to gate` };
2187
+ }
2188
+ if (!gated.protectedPath) {
2189
+ return {
2190
+ kind: "gated",
2191
+ classes: [gated.cls],
2192
+ payload: gated.payload,
2193
+ headline: gated.summary,
2194
+ notes: [],
2195
+ passthrough: `${input.toolName} is not a gated edit`,
2196
+ };
2197
+ }
2198
+ return {
2199
+ kind: "gated",
2200
+ classes: [gated.cls],
2201
+ payload: gated.payload,
2202
+ headline: gated.summary,
2203
+ // The tier rides in the verdict's note as well as in the payload, so an
2204
+ // `allow` says which checkout it authorized (APRV-124).
2205
+ notes: [fileTierNote(gated)],
2206
+ };
2207
+ }
2208
+ /** The environment variable that turns the sandbox requirement on (APRV-193). */
2209
+ export const REQUIRE_SANDBOX_ENV = "APPROVAL_HOOK_REQUIRE_SANDBOX";
2210
+ /**
2211
+ * Must this command have been written `approval sandbox -- …`? (APRV-193.)
2212
+ *
2213
+ * Returns the deny detail, or `null` to proceed. Four conditions, and every one
2214
+ * of them is a narrowing, so the answer is `null` for everything the operator
2215
+ * did not deliberately ask about:
2216
+ *
2217
+ * 1. the operator set `APPROVAL_HOOK_REQUIRE_SANDBOX=1`;
2218
+ * 2. some segment runs code this runtime did not author
2219
+ * (`CODE_EXECUTING_RULES`: `npm test`, `node x.mjs`, `tsc`, `make`…);
2220
+ * 3. that segment is not already inside the runtime's own wrapper. A
2221
+ * hand-written `sandbox-exec -f mine.sb` does NOT satisfy it, because a
2222
+ * profile a caller wrote can allow everything, and a requirement met by
2223
+ * writing your own permission is not a requirement;
2224
+ * 4. no class of the command is manual. A manual command is going to a human,
2225
+ * and a human's grant over these exact bytes is the authority to reach the
2226
+ * world — the same line `approval run` draws at the token.
2227
+ *
2228
+ * The environment variable is read in the strict direction only: setting it can
2229
+ * refuse commands that would otherwise run, and nothing an agent can set makes
2230
+ * this function return `null` where it would otherwise deny (SPEC.md §11.1
2231
+ * invariant 4).
2232
+ */
2233
+ export function sandboxRequirement(segments, autonomies, env = process.env) {
2234
+ if (env[REQUIRE_SANDBOX_ENV] !== "1")
2235
+ return null;
2236
+ if (segments === undefined)
2237
+ return null;
2238
+ if (autonomies.some((autonomy) => autonomy === "manual"))
2239
+ return null;
2240
+ const unwrapped = segments.filter((segment) => CODE_EXECUTING_RULES.includes(segment.rule) && segment.sandbox !== "runtime");
2241
+ if (unwrapped.length === 0)
2242
+ return null;
2243
+ const first = unwrapped[0];
2244
+ const external = first.sandbox === "external";
2245
+ return `${REQUIRE_SANDBOX_ENV}=1, and this command runs code the runtime did not author: ${JSON.stringify(first.text)} (rule ${first.rule}), ${external ? "under a profile this runtime did not write, which is a permission you granted yourself" : "with the session's own network"}. A command like this executes whatever is in the files it names, so its class describes what was typed rather than what will happen. Re-run it as \`approval sandbox -- <command>\`: it classifies the same, it is allowed the same, and it runs with no way out to the network (docs/sandboxed-exec.md). Nothing was appended.`;
2246
+ }
2247
+ /**
2248
+ * Is a window open over this log?
2249
+ *
2250
+ * Fails closed on every axis and reports NOTHING when it does. An absent log,
2251
+ * an unreadable one, a torn tail, a chain that does not verify: each yields no
2252
+ * window, and the caller falls through to the path it has always taken, where
2253
+ * `hook-log-unreachable` and `hook-io` fire in the same words at the same
2254
+ * places. A bypass derived from bytes nobody verified would be a bypass anyone
2255
+ * able to write the file could grant themselves, which is the whole reason the
2256
+ * window's state lives in the log rather than beside it.
2257
+ *
2258
+ * The existence probe is the same one the gated path makes further down, and it
2259
+ * is made FIRST so that a hook pointed at a directory with no log does no
2260
+ * verification work before saying so.
2261
+ */
2262
+ function lookupWindow(logPath) {
2263
+ if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
2264
+ return { window: null, records: null, head: null };
2265
+ }
2266
+ const read = readVerifiedRecords(logPath);
2267
+ if (!read.ok)
2268
+ return { window: null, records: null, head: null };
2269
+ return { window: openGateWindow(read.records), records: read.records, head: read.head };
2270
+ }
2271
+ /**
2272
+ * The banner every bypassed call prints to STDERR.
2273
+ *
2274
+ * Loud, and on stderr rather than in the decision reason, because the two have
2275
+ * different readers: the reason is read by the harness and by the agent, and
2276
+ * this is read by the person who opened the window and may have forgotten it is
2277
+ * open. It names the seq of the record that authorized the bypass and the one
2278
+ * that recorded it, so both ends are greppable from the log alone.
2279
+ */
2280
+ function bypassBanner(window, classes, seq) {
2281
+ return [
2282
+ "!! APPROVAL GATE OPEN — this command was NOT approved !!",
2283
+ ` window seq ${String(window.seq)}, opened by ${window.openedBy}, expires ${window.expiresAt}`,
2284
+ ` reason: ${window.reason}`,
2285
+ ` classes: ${classes.join(", ")}; recorded as gate.bypassed seq ${String(seq)}`,
2286
+ " close it with `approval gate close`",
2287
+ "",
2288
+ ].join("\n");
2289
+ }
2290
+ /**
2291
+ * The bypass path: classify anyway, refuse the things the window never reaches,
2292
+ * record the call, and only then allow it.
2293
+ *
2294
+ * ### What the window does NOT reach, and why each one stays
2295
+ *
2296
+ * - **A command the classifier cannot read** (`hook-opaque`,
2297
+ * `hook-unclassified`, `hook-unparseable`). The window is a suspension of the
2298
+ * policy's ANSWER, and an opaque command has no question to suspend: nothing
2299
+ * here can establish that a `bash -c` string does not write into the log.
2300
+ * - **`log.mutate`.** The window suspends the policy; the log is what the
2301
+ * window itself is derived from, and a bypass that could rewrite the log
2302
+ * could rewrite its own authorization. Refused unconditionally, with no
2303
+ * policy consulted, because this rule is not the policy's to relax.
2304
+ * - **Any class the policy reserves to human hands** (§11.1 invariant 9). A
2305
+ * human-only class is inert to agents by construction, and a window opened by
2306
+ * a human does not lend an agent the human's hands.
2307
+ *
2308
+ * ### The policy is loaded, best-effort
2309
+ *
2310
+ * For the protected-path set the classifier needs, and for the human-only
2311
+ * check. A policy that will not load is exactly the failure a window is opened
2312
+ * to repair, so a load failure is a NOTE on the verdict rather than a refusal;
2313
+ * the protected-path set is then empty and the human-only check has nothing to
2314
+ * resolve against, which the note says in as many words.
2315
+ *
2316
+ * ### Record, then allow
2317
+ *
2318
+ * §11.1 invariant 8, and the same order `recordUnattended` uses: a bypassed
2319
+ * command that ran and left no record is the one state this feature must not be
2320
+ * able to reach, so an append failure is a deny.
2321
+ */
2322
+ function runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
2323
+ /**
2324
+ * The verified read `window` was derived from (APRV-294), handed on to the
2325
+ * append so the same records answer "is a window open" and "which head does
2326
+ * this record chain onto". `null` is not reachable from the caller — a window
2327
+ * implies a read that produced it — and is accepted so the seam has one
2328
+ * shape.
2329
+ */
2330
+ decidedOn) {
2331
+ const scope = hookScope(flags, cwd);
2332
+ const load = loadPolicy(scope.options.policy?.file === undefined
2333
+ ? { dir: scope.options.policy?.dir ?? cwd }
2334
+ : { file: scope.options.policy.file });
2335
+ const protectedPaths = load.ok ? (load.policy.protected_paths ?? []) : [];
2336
+ const policyNote = load.ok
2337
+ ? null
2338
+ : `the policy did not load (${load.code}: ${load.message}), so no protected path beyond the built-ins was known here and no class could be resolved to human-only`;
2339
+ const described = describeToolCall(input, adapter, protectedPaths, cwd);
2340
+ if (described.kind === "deny") {
2341
+ return deny(streams, described.code, `${described.detail} The open window does not reach this: a command the classifier cannot read is a command nothing here can establish is safe to run unapproved.`, adapter.kind);
2342
+ }
2343
+ if (described.kind === "allow")
2344
+ return allow(streams, described.reason, adapter.kind);
2345
+ if (described.passthrough !== undefined) {
2346
+ // APRV-303. An ordinary workspace edit is allowed by the policy on its own
2347
+ // merits, so there is nothing here for the window to suspend and nothing
2348
+ // for a `gate.bypassed` record to say. The only thing that would have made
2349
+ // this call a question is a §10.2 floor, and a window bypasses the floor
2350
+ // outright. Answered here rather than below so the bypass log stays a
2351
+ // record of calls the window actually let through.
2352
+ return allow(streams, described.passthrough, adapter.kind);
2353
+ }
2354
+ const classes = described.classes;
2355
+ if (classes.length === 0) {
2356
+ // The gate's own CLI, including `approval gate close`. Allowed with no
2357
+ // record for the reason it is allowed outside a window: gating the gate
2358
+ // with the gate recurses, and a window that recorded its own closing verb
2359
+ // would be recording the act that ends it.
2360
+ return allow(streams, "the approval CLI is the gate itself and is not gated by it", adapter.kind);
2361
+ }
2362
+ const mutation = classes.find((cls) => cls === "log.mutate");
2363
+ if (mutation !== undefined) {
2364
+ return deny(streams, "hook-class-human-only", `${mutation} is never reachable through the open window: the window suspends the POLICY, and the log is what the window itself is derived from. A bypass able to write the log could rewrite its own authorization. Nothing was appended; a human writes the log directory by hand or not at all.`, adapter.kind);
2365
+ }
2366
+ if (load.ok) {
2367
+ const reserved = classes.find((cls) => resolvePolicy(load, cls).autonomy === "human-only");
2368
+ if (reserved !== undefined) {
2369
+ return deny(streams, "hook-class-human-only", `${humanOnlyRefusal(reserved, "this command may not run under an agent")} An open window does not reach it: the window suspends what the policy DECIDES, and a human-only class is one the policy reserves to human hands, which a window opened by a human does not lend to an agent (SPEC.md §11.1 invariant 9).`, adapter.kind);
2370
+ }
2371
+ }
2372
+ // APRV-227, resolved here because here is where a record is written. The
2373
+ // window path is the one a human comes back to read, so the binary that
2374
+ // printed the allow is named on it.
2375
+ const provenance = harnessProvenance(adapter.kind, input.harnessVersion);
2376
+ const recorded = recordGateBypass(logPath, {
2377
+ tool: input.toolName,
2378
+ summary: truncate(described.headline, SUMMARY_LIMIT),
2379
+ classes,
2380
+ payloadHash: payloadHash(described.payload),
2381
+ ...(input.sessionId === UNKNOWN_SESSION ? {} : { sessionId: input.sessionId }),
2382
+ ...(input.toolUseId === null ? {} : { toolUseId: input.toolUseId }),
2383
+ ...(input.cwd.length === 0 ? {} : { cwd: input.cwd }),
2384
+ ...(provenance === null ? {} : { harness: provenance }),
2385
+ }, actor, {},
2386
+ // APRV-294: the window this verdict was decided under, and the read it was
2387
+ // decided on. The append uses both, so a window that ended in between is
2388
+ // reported as the thing that happened rather than as "no window is open".
2389
+ {
2390
+ openedSeq: window.seq,
2391
+ ...(decidedOn === null ? {} : { read: decidedOn }),
2392
+ });
2393
+ if (!recorded.ok) {
2394
+ // Invariant 8: the record lands before the allow, so a refusal here is a
2395
+ // deny even though a window is open. `append-failed` reaches the caller
2396
+ // through the family reserved for a code the writer produced, and so does
2397
+ // `gate-window-closed` (APRV-294), which says the window stood when this
2398
+ // process classified the command and does not stand now.
2399
+ return deny(streams, `hook-gate-refused:${recorded.code}`, `${recorded.message} A window being open does not let a call run unrecorded: the record is what makes the bypass reviewable, so nothing runs without it.`, adapter.kind);
2400
+ }
2401
+ streams.err(bypassBanner(window, classes, recorded.record.seq));
2402
+ const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
2403
+ 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);
2404
+ }
2405
+ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2406
+ const parsed = parseFlags(argv, {
2407
+ ...COMMON_FLAGS,
2408
+ ...POLICY_FLAGS,
2409
+ "--log": "string",
2410
+ "--as": "string",
2411
+ "--timeout": "string",
2412
+ "--interval": "string",
2413
+ "--retry-grace": "string",
2414
+ });
2415
+ if (!parsed.ok)
2416
+ return usageError(streams, parsed.message);
2417
+ if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
2418
+ streams.out(`${HOOK_HELP}\n`);
2419
+ return EXIT_OK;
2420
+ }
2421
+ const extra = parsed.positionals[0];
2422
+ if (extra !== undefined) {
2423
+ return usageError(streams, `unexpected argument ${JSON.stringify(extra)}`);
2424
+ }
2425
+ const asFlag = stringFlag(parsed.flags, "--as");
2426
+ const actor = asFlag ?? adapter.defaultActor;
2427
+ if (!PRINCIPAL_ACTOR.test(actor)) {
2428
+ return usageError(streams, `--as expects agent:<id> or human:<id>, got ${JSON.stringify(asFlag)}`);
2429
+ }
2430
+ const timeoutText = stringFlag(parsed.flags, "--timeout") ?? DEFAULT_TIMEOUT;
2431
+ const timeoutMs = parseDuration(timeoutText);
2432
+ if (timeoutMs === null) {
2433
+ return usageError(streams, `--timeout expects a duration like 30s, 9m, got ${JSON.stringify(timeoutText)}`);
2434
+ }
2435
+ const intervalText = stringFlag(parsed.flags, "--interval");
2436
+ const intervalMs = intervalText === null ? DEFAULT_INTERVAL_MS : parseDuration(intervalText);
2437
+ if (intervalMs === null) {
2438
+ return usageError(streams, `--interval expects a duration like 500ms, 2s, got ${JSON.stringify(intervalText)}`);
2439
+ }
2440
+ // APRV-287. How long the question outlives the wait, for the retry that
2441
+ // adopts it. The duration grammar has no zero, so the shortest window is
2442
+ // `1ms`, which withdraws as the wait expires.
2443
+ const graceText = stringFlag(parsed.flags, "--retry-grace");
2444
+ const graceMs = graceText === null ? HOOK_RETRY_GRACE_MS : parseDuration(graceText);
2445
+ if (graceMs === null) {
2446
+ return usageError(streams, `--retry-grace expects a duration like 5m, 30s, 1ms, got ${JSON.stringify(graceText)}`);
2447
+ }
2448
+ const parsedInput = parseHookInput(readStdin());
2449
+ if (!parsedInput.ok)
2450
+ return deny(streams, "hook-io", parsedInput.detail, adapter.kind);
2451
+ const input = parsedInput.input;
2452
+ // APRV-145: WHICH EVENT THIS IS, read first and read at all. One command is
2453
+ // registered for two events, and they do opposite things — one answers before
2454
+ // the tool runs, the other records how it went — so the dispatch is the first
2455
+ // decision the verb makes.
2456
+ //
2457
+ // Anything that is not a post-execution event takes the pre-execution path,
2458
+ // including an event carrying no name at all. That is the strict direction: a
2459
+ // harness whose event this runtime does not recognize is a harness about to
2460
+ // run a command, and treating an unknown name as a no-op would be an ungated
2461
+ // one.
2462
+ if (input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName)) {
2463
+ // APRV-303. `commandHarnessHook`'s catch turns a throw into a DENY, which is
2464
+ // the right answer for a call that has not run yet and exactly the wrong one
2465
+ // here: it would print a verdict object about a tool call the harness has
2466
+ // already finished, and the reason the counterpart did not land would be
2467
+ // dressed as a permission decision. A throw on this path is `post-tool-io`,
2468
+ // on stderr, at the exit code that makes the line visible.
2469
+ try {
2470
+ return runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter);
2471
+ }
2472
+ catch (cause) {
2473
+ return report(streams, "post-tool-io", `the counterpart failed: ${cause instanceof Error ? cause.message : String(cause)}; nothing was appended, so the start this event would have closed is still open`);
2474
+ }
2475
+ }
2476
+ if (input.toolName !== adapter.shellTool && !adapter.fileTools.includes(input.toolName)) {
2477
+ return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind);
2478
+ }
2479
+ // APRV-188. From here on this process may resume a verified read behind the
2480
+ // snapshot the daemon published, instead of walking the chain from genesis:
2481
+ // the one thing a fresh process per gated tool call cannot amortize, and the
2482
+ // only term in a hook's cost that grows with the log. Turned on HERE rather
2483
+ // than at the CLI's entry point, so it covers exactly the gated path and no
2484
+ // other verb — `approval log verify` and every audit read stay cold.
2485
+ //
2486
+ // It changes what a read COSTS and nothing about what a read PROVES: the
2487
+ // prefix is admitted only against a SHA-256 this process computes over the
2488
+ // bytes it read itself, the head and the line count are re-derived from its
2489
+ // own parse, and the appended tail is walked in full. A snapshot that is
2490
+ // absent, stale, foreign, or wrong in any of those is ignored, and the walk
2491
+ // happens exactly as it does today. See `core/verified-snapshot.ts`.
2492
+ useVerifiedSnapshots(true);
2493
+ const { logPath, root, options } = hookScope(parsed.flags, cwd);
2494
+ // APRV-214, amended SPEC.md §5.2: the open window, looked up HERE — after the
2495
+ // scope is resolved and before the policy is loaded — because the whole point
2496
+ // of it is to be reachable when the things below are broken. A window opened
2497
+ // by a human puts every gated tool call through `runBypass` instead, so an
2498
+ // unparseable policy, a drifted attestation, a loop floor, a dark channel and
2499
+ // a hung daemon are all bypassed.
2500
+ //
2501
+ // The lookup is SELF-GATING, which is what makes placing it this early safe:
2502
+ // it reads the same verified log every enforcement path reads, and an absent,
2503
+ // torn or unverifiable log yields no window at all. The hook then falls
2504
+ // through to the path it has always taken and refuses there, in the same
2505
+ // words. The window suspends the POLICY; it never suspends the log.
2506
+ const looked = lookupWindow(logPath);
2507
+ if (looked.window !== null) {
2508
+ return runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
2509
+ // APRV-294. The records this window was derived from travel with it: the
2510
+ // bypass record is appended against the head they ended at, so the
2511
+ // verdict and the record are one read of the log.
2512
+ looked.records === null ? null : { records: looked.records, head: looked.head });
2513
+ }
2514
+ // The policy is read BEFORE the command is classified (APRV-107): the
2515
+ // protected-path set is built-ins plus `policy.protected_paths`, so what
2516
+ // counts as a protected path is a policy question and the classifier cannot be
2517
+ // asked it without the answer in hand.
2518
+ //
2519
+ // An unloadable policy resolves everything to manual, and a manual request
2520
+ // needs a log this hook may not be pointed at. Fail closed and say so, rather
2521
+ // than opening a request nobody configured a channel for.
2522
+ const load = loadPolicy(options.policy?.file === undefined
2523
+ ? { dir: options.policy?.dir ?? cwd }
2524
+ : { file: options.policy.file });
2525
+ if (!load.ok) {
2526
+ return deny(streams, "hook-policy-unavailable", `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`, adapter.kind);
2527
+ }
2528
+ const protectedPaths = load.policy.protected_paths ?? [];
2529
+ // What is being asked for, as one or more classes. One description site for
2530
+ // both paths since APRV-214 (see `describeToolCall`): the open window
2531
+ // classifies exactly as the closed one does, and a second copy of this would
2532
+ // be a second answer to "what is this command".
2533
+ const described = describeToolCall(input, adapter, protectedPaths, cwd);
2534
+ if (described.kind === "deny") {
2535
+ return deny(streams, described.code, described.detail, adapter.kind);
2536
+ }
2537
+ if (described.kind === "allow")
2538
+ return allow(streams, described.reason, adapter.kind);
2539
+ const { classes, payload, headline } = described;
2540
+ /** What the history-rewrite refinement did, for the decision reason. */
2541
+ const notes = [...described.notes];
2542
+ if (classes.length === 0) {
2543
+ return allow(streams, "the approval CLI is the gate itself and is not gated by it", adapter.kind);
2544
+ }
2545
+ // Every path from here needs the log, the fast paths included (APRV-139):
2546
+ // attestation and loop-escalation are facts about the log, so the
2547
+ // log-unreachable deny now sits above the autonomous verdict rather than
2548
+ // below it. A hook that could not reach the log used to allow whatever the
2549
+ // on-disk policy called autonomous; it now denies, which is the same answer
2550
+ // it already gave every other class.
2551
+ if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
2552
+ return deny(streams, "hook-log-unreachable", `no log at ${logPath}; the hook writes to an existing log and never creates one. Run \`approval init\` (then \`approval policy attest\`) in ${root}, or pass --log <path> to point the hook at the log that already exists`, adapter.kind);
2553
+ }
2554
+ // Minted once, here, and carried into `gateAndWait`: the loop-escalation
2555
+ // check below and any registration that follows must name the same task.
2556
+ const task = `hook:${input.sessionId}:${input.toolUseId ?? randomBytes(8).toString("hex")}`;
2557
+ const run = {
2558
+ logPath,
2559
+ options,
2560
+ actor,
2561
+ timeoutMs,
2562
+ intervalMs,
2563
+ graceMs,
2564
+ ttlMs: load.durations.approvalTtlMs,
2565
+ harness: adapter.kind,
2566
+ originApp: adapter.originApp,
2567
+ eventVersion: input.harnessVersion,
2568
+ // Off the policy this function already loaded and validated, so the names
2569
+ // printed are the names a channel process would serve (APRV-281). Sorted
2570
+ // for a stable line; `Object.keys` order is the file's, and a line that
2571
+ // changed when an operator reordered their policy would read as a change of
2572
+ // state.
2573
+ channels: Object.keys(load.policy.channels ?? {}).sort(),
2574
+ };
2575
+ const autonomies = classes.map((cls) => resolvePolicy(load, cls).autonomy);
2576
+ // APRV-185, amended SPEC.md §5.2, and the first verdict this function reaches
2577
+ // once the classes have autonomies. A command touching a class the policy
2578
+ // reserves to human hands is denied outright: no request is opened, no task is
2579
+ // registered, nothing is appended, and no human is asked — because the policy
2580
+ // has already answered, and there is no decision anyone could make that would
2581
+ // let this process run the command.
2582
+ //
2583
+ // Above the loop floor and the unattended guard deliberately. Those two route
2584
+ // a command TO a human's gate, and this class has no gate to be routed to; a
2585
+ // floor applied first would open a request nobody may grant. A command whose
2586
+ // classes are mixed is denied on the strength of the one human-only class, per
2587
+ // the classifier's existing rule that the whole command is answered by the
2588
+ // strictest thing in it.
2589
+ const reserved = classes.find((_cls, index) => autonomies[index] === "human-only");
2590
+ if (reserved !== undefined) {
2591
+ return deny(streams, "hook-class-human-only", `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.`, adapter.kind);
2592
+ }
2593
+ // APRV-193, and BELOW the human-only deny for the same reason that one sits
2594
+ // above the floor: a class no agent may run is answered before a question
2595
+ // about which room it would run in. Above everything that appends, so a
2596
+ // refused command leaves the log exactly as it found it.
2597
+ const unsandboxed = sandboxRequirement(described.segments, autonomies);
2598
+ if (unsandboxed !== null) {
2599
+ return deny(streams, "hook-sandbox-required", unsandboxed, adapter.kind);
2600
+ }
2601
+ // APRV-145, amended SPEC.md §10.2: loop safety on a surface that mints a
2602
+ // fresh task id per tool call. The floor is applied AFTER class resolution and
2603
+ // never inside it, exactly as §7's irreversibility floor is: `resolve` is pure
2604
+ // over policy text, and a failure streak is a projection over the log.
2605
+ //
2606
+ // The remedy is a floor and not a deny. Escalation escalates TO manual (§10.2,
2607
+ // and `core/loop.ts`'s own header), and the only thing that clears a streak is
2608
+ // an execution that completes — so a deny would leave an escalated session
2609
+ // with no way back, and a class the policy calls autonomous has no manual
2610
+ // sibling to fall back on. Every SIDE-EFFECTING class that would otherwise
2611
+ // have proceeded is routed to the human gate for this invocation (APRV-297
2612
+ // narrowed it to those); a class that already resolves manual is untouched,
2613
+ // because it was already going there.
2614
+ const floored = harnessFloor(logPath, task, actor, looked.records);
2615
+ if (!floored.ok)
2616
+ return deny(streams, "hook-io", floored.detail, adapter.kind);
2617
+ /**
2618
+ * The streak the log shows, before the read carve-out (APRV-297).
2619
+ *
2620
+ * Kept separate from the floor that is APPLIED because the verdict has to be
2621
+ * able to say "a floor is standing and it was not applied here". Collapsing
2622
+ * the two would leave an agent reading an ordinary autonomous allow with no
2623
+ * way to tell that the session it is in is three failed writes deep.
2624
+ */
2625
+ const tripped = floored.floor;
2626
+ /**
2627
+ * Is every class of this command a read? (APRV-297, amended SPEC.md §10.2.)
2628
+ *
2629
+ * The predicate is `core/loop.ts`'s own, the same one that decides what
2630
+ * ACCRUES, so what the floor counts and what it routes cannot come apart. A
2631
+ * class this build has never heard of is side-effecting by construction, so an
2632
+ * unknown class is routed exactly as it is counted.
2633
+ */
2634
+ const readsOnly = classes.every((cls) => !isSideEffectingClass(cls));
2635
+ /**
2636
+ * The floor as this invocation applies it: `null` for a command that only
2637
+ * looks, whatever the streak says.
2638
+ *
2639
+ * A read cannot cause the harm the floor bounds. The floor exists to stop an
2640
+ * agent retrying a side effect that keeps failing, so routing a `grep` to a
2641
+ * phone buys no safety and spends the two things the floor is supposed to be
2642
+ * conserving: a human's attention, and the session's ability to find out what
2643
+ * went wrong. On 2026-09-06/07 a tripped floor sent every read to the gate and
2644
+ * a session that could not get an answer could not even search the repository.
2645
+ */
2646
+ const floor = readsOnly ? null : tripped;
2647
+ if (tripped !== null && floor === null) {
2648
+ notes.push(`loop-escalated (amended SPEC.md §10.2) NOT APPLIED to this call: ${tripped.scope} ${tripped.key} has ${String(tripped.consecutiveFailures)} consecutive failed side-effecting harness tool calls, and every class of this command is a read (${classes.join(", ")}). Escalation raises scrutiny on side effects only, so this command is answered by the policy; the floor still routes the session's side-effecting calls to a human. ${loopClearance(tripped.scope, tripped.key)}`);
2649
+ }
2650
+ if (floor !== null) {
2651
+ // The decision trace: the verdict this invocation prints says that a floor
2652
+ // rather than the matched rule decided it, and names the scope and the
2653
+ // count that tripped, the way `core/execute.ts` names the irreversibility
2654
+ // floor beside a resolution's provenance.
2655
+ notes.push(`loop-escalated (amended SPEC.md §10.2): ${floor.scope} ${floor.key} has ${String(floor.consecutiveFailures)} consecutive failed side-effecting harness tool calls, so every class of this command is routed to a human for this invocation regardless of policy`);
2656
+ }
2657
+ /**
2658
+ * Appended to every verdict this invocation prints: the history-rewrite
2659
+ * refinement's own words, and the loop floor's when one applied.
2660
+ */
2661
+ const note = notes.length === 0 ? "" : ` (${notes.join("; ")})`;
2662
+ // APRV-303, and the last thing that can answer without touching the log: an
2663
+ // ordinary workspace edit, which the policy allows on its own merits and which
2664
+ // is a question only while a floor stands.
2665
+ //
2666
+ // The order is the whole fix. Until APRV-303 this allow was printed from
2667
+ // `describeToolCall`'s own branch, several hundred lines above the floor
2668
+ // lookup, so a session whose Bash calls were all being routed to a human went
2669
+ // on editing files unrouted and uncounted. Now the same allow is printed, in
2670
+ // the same words, from BELOW the floor: the fast path is as fast as it was,
2671
+ // and the floored path routes an Edit exactly as it routes an `echo >`,
2672
+ // because both are `files.write.workspace` and one predicate decides.
2673
+ if (described.passthrough !== undefined && floor === null) {
2674
+ return allow(streams, `${described.passthrough}${note}`, adapter.kind);
2675
+ }
2676
+ /** No class here needs a human, so nothing downstream will ask for one. */
2677
+ const unattended = floor === null && autonomies.every((autonomy) => autonomy !== "manual");
2678
+ if (unattended) {
2679
+ const refused = unattendedGuard(logPath, load.source.path, task, looked.records);
2680
+ if (refused !== null)
2681
+ return deny(streams, refused.code, refused.detail, adapter.kind);
2682
+ }
2683
+ if (floor === null && autonomies.every((autonomy) => autonomy === "autonomous")) {
2684
+ // No approval lifecycle: an autonomous action has none (amended SPEC.md
2685
+ // §6.3), so nothing is requested, decided or granted here. What IS appended
2686
+ // since APRV-141 is the execution record itself — the moment the policy
2687
+ // authorized this command — because a budget the busiest path does not
2688
+ // charge is not a budget. See `recordUnattended`.
2689
+ const charged = recordUnattended(run, task, classes, payloadHash(payload));
2690
+ if (charged !== null) {
2691
+ return deny(streams, `hook-gate-refused:${charged.code}`, charged.message, adapter.kind);
2692
+ }
2693
+ return allow(streams, `autonomous: ${classes.join(", ")}${note}`, adapter.kind);
2694
+ }
2695
+ // Past here the hook appends. It writes to a log that already exists and
2696
+ // creates none: a log the hook scaffolded where it happened to be standing
2697
+ // would be a second chain, forked from the real one's tail, and hash chains
2698
+ // do not survive a merge. An initialized-but-empty `.approval/log/` counts as
2699
+ // reachable — an audit trail that has recorded nothing is an empty log, not a
2700
+ // missing one (see `preflightLog`) — and `register` appends the first line.
2701
+ return gateAndWait(streams, run, classes, payload, headline, task, note, floor);
2702
+ }
2703
+ function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
2704
+ try {
2705
+ return runHarnessHook(argv, streams, cwd, readStdin, adapter);
2706
+ }
2707
+ catch (cause) {
2708
+ // A hook that throws is a hook the harness treats as a non-blocking error,
2709
+ // which would let the command through. Every unexpected failure becomes an
2710
+ // ordinary deny instead. Cursor additionally needs failClosed on the
2711
+ // hooks.json entry so a crash of this process still blocks.
2712
+ return deny(streams, "hook-io", `the hook failed: ${cause instanceof Error ? cause.message : String(cause)}`, adapter.kind);
2713
+ }
2714
+ }
2715
+ // ===========================================================================
2716
+ // Dispatch
2717
+ // ===========================================================================
2718
+ /** Read the whole of stdin, synchronously. */
2719
+ function defaultStdin() {
2720
+ return readFileSync(0, "utf8");
2721
+ }
2722
+ export function commandHook(argv, streams, cwd, readStdin = defaultStdin) {
2723
+ const sub = argv[0];
2724
+ const rest = argv.slice(1);
2725
+ if (sub === undefined) {
2726
+ return usageError(streams, "missing subcommand for `approval hook`");
2727
+ }
2728
+ if (sub === "--help" || sub === "-h" || sub === "help") {
2729
+ streams.out(`${HOOK_HELP}\n`);
2730
+ return EXIT_OK;
2731
+ }
2732
+ switch (sub) {
2733
+ case "claude-code":
2734
+ return commandHarnessHook(rest, streams, cwd, readStdin, CLAUDE_ADAPTER);
2735
+ case "cursor":
2736
+ return commandHarnessHook(rest, streams, cwd, readStdin, CURSOR_ADAPTER);
2737
+ case "classify":
2738
+ return commandClassify(rest, streams, cwd);
2739
+ default:
2740
+ return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
2741
+ }
2742
+ }
2743
+ //# sourceMappingURL=hook.js.map