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