approval-md 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (378) hide show
  1. package/README.md +85 -877
  2. package/SPEC.md +150 -19
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +53 -1
  8. package/dist/src/channels/contract.js +304 -7
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +394 -12
  22. package/dist/src/channels/telegram.js +621 -68
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/channels/web.d.ts +9 -0
  25. package/dist/src/channels/web.js +17 -0
  26. package/dist/src/channels/web.js.map +1 -1
  27. package/dist/src/cli/amend.js +564 -34
  28. package/dist/src/cli/amend.js.map +1 -1
  29. package/dist/src/cli/attest.d.ts +9 -0
  30. package/dist/src/cli/attest.js +177 -13
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/channel-relay.d.ts +108 -0
  33. package/dist/src/cli/channel-relay.js +334 -0
  34. package/dist/src/cli/channel-relay.js.map +1 -0
  35. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  36. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  37. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  38. package/dist/src/cli/channel-telegram.d.ts +347 -28
  39. package/dist/src/cli/channel-telegram.js +763 -28
  40. package/dist/src/cli/channel-telegram.js.map +1 -1
  41. package/dist/src/cli/channel.d.ts +10 -1
  42. package/dist/src/cli/channel.js +14 -1
  43. package/dist/src/cli/channel.js.map +1 -1
  44. package/dist/src/cli/codex-bridge.d.ts +819 -0
  45. package/dist/src/cli/codex-bridge.js +1607 -0
  46. package/dist/src/cli/codex-bridge.js.map +1 -0
  47. package/dist/src/cli/codex.d.ts +1 -1
  48. package/dist/src/cli/codex.js +304 -7
  49. package/dist/src/cli/codex.js.map +1 -1
  50. package/dist/src/cli/daemon.js +41 -6
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +1322 -122
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +17 -1
  56. package/dist/src/cli/execute.js +366 -22
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +14 -2
  65. package/dist/src/cli/help.js +395 -82
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook-codex.d.ts +49 -1
  68. package/dist/src/cli/hook-codex.js +60 -1
  69. package/dist/src/cli/hook-codex.js.map +1 -1
  70. package/dist/src/cli/hook.d.ts +828 -4
  71. package/dist/src/cli/hook.js +2972 -142
  72. package/dist/src/cli/hook.js.map +1 -1
  73. package/dist/src/cli/import.js +1 -1
  74. package/dist/src/cli/import.js.map +1 -1
  75. package/dist/src/cli/init.js +16 -7
  76. package/dist/src/cli/init.js.map +1 -1
  77. package/dist/src/cli/instructions.js +24 -3
  78. package/dist/src/cli/instructions.js.map +1 -1
  79. package/dist/src/cli/log-anchor.d.ts +55 -0
  80. package/dist/src/cli/log-anchor.js +199 -60
  81. package/dist/src/cli/log-anchor.js.map +1 -1
  82. package/dist/src/cli/main.d.ts +0 -12
  83. package/dist/src/cli/main.js +56 -3
  84. package/dist/src/cli/main.js.map +1 -1
  85. package/dist/src/cli/muse.d.ts +2 -0
  86. package/dist/src/cli/muse.js +62 -0
  87. package/dist/src/cli/muse.js.map +1 -0
  88. package/dist/src/cli/payload.js +79 -1
  89. package/dist/src/cli/payload.js.map +1 -1
  90. package/dist/src/cli/policy-apply.d.ts +195 -0
  91. package/dist/src/cli/policy-apply.js +573 -0
  92. package/dist/src/cli/policy-apply.js.map +1 -0
  93. package/dist/src/cli/policy.js +14 -1
  94. package/dist/src/cli/policy.js.map +1 -1
  95. package/dist/src/cli/preflight.d.ts +151 -13
  96. package/dist/src/cli/preflight.js +398 -41
  97. package/dist/src/cli/preflight.js.map +1 -1
  98. package/dist/src/cli/sandbox.js +17 -1
  99. package/dist/src/cli/sandbox.js.map +1 -1
  100. package/dist/src/cli/scaffold.d.ts +1 -1
  101. package/dist/src/cli/scaffold.js +7 -1
  102. package/dist/src/cli/scaffold.js.map +1 -1
  103. package/dist/src/cli/serve.d.ts +53 -0
  104. package/dist/src/cli/serve.js +270 -0
  105. package/dist/src/cli/serve.js.map +1 -0
  106. package/dist/src/cli/setup-channel.d.ts +9 -0
  107. package/dist/src/cli/setup-channel.js +28 -1
  108. package/dist/src/cli/setup-channel.js.map +1 -1
  109. package/dist/src/cli/setup-common.d.ts +3 -1
  110. package/dist/src/cli/setup-common.js +3 -2
  111. package/dist/src/cli/setup-common.js.map +1 -1
  112. package/dist/src/cli/setup.d.ts +2 -0
  113. package/dist/src/cli/setup.js +94 -2
  114. package/dist/src/cli/setup.js.map +1 -1
  115. package/dist/src/cli/up.js +134 -51
  116. package/dist/src/cli/up.js.map +1 -1
  117. package/dist/src/cli/values.js +3 -4
  118. package/dist/src/cli/values.js.map +1 -1
  119. package/dist/src/cli/verb-registry.d.ts +2 -0
  120. package/dist/src/cli/verb-registry.js +510 -47
  121. package/dist/src/cli/verb-registry.js.map +1 -1
  122. package/dist/src/cli/wordmark.d.ts +2 -2
  123. package/dist/src/cli/wordmark.js +2 -2
  124. package/dist/src/codex/broker.d.ts +229 -0
  125. package/dist/src/codex/broker.js +548 -0
  126. package/dist/src/codex/broker.js.map +1 -0
  127. package/dist/src/codex/runner.d.ts +178 -0
  128. package/dist/src/codex/runner.js +231 -0
  129. package/dist/src/codex/runner.js.map +1 -0
  130. package/dist/src/codex/serve.d.ts +56 -0
  131. package/dist/src/codex/serve.js +98 -0
  132. package/dist/src/codex/serve.js.map +1 -0
  133. package/dist/src/codex/workspace-commit.d.ts +219 -0
  134. package/dist/src/codex/workspace-commit.js +549 -0
  135. package/dist/src/codex/workspace-commit.js.map +1 -0
  136. package/dist/src/core/advance-cycle.d.ts +51 -0
  137. package/dist/src/core/advance-cycle.js +66 -2
  138. package/dist/src/core/advance-cycle.js.map +1 -1
  139. package/dist/src/core/agents-md.d.ts +20 -18
  140. package/dist/src/core/agents-md.js +33 -31
  141. package/dist/src/core/agents-md.js.map +1 -1
  142. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  143. package/dist/src/core/attest-bootstrap.js +135 -0
  144. package/dist/src/core/attest-bootstrap.js.map +1 -0
  145. package/dist/src/core/attest.d.ts +234 -2
  146. package/dist/src/core/attest.js +342 -7
  147. package/dist/src/core/attest.js.map +1 -1
  148. package/dist/src/core/audit.d.ts +73 -3
  149. package/dist/src/core/audit.js +65 -5
  150. package/dist/src/core/audit.js.map +1 -1
  151. package/dist/src/core/channel-lease.d.ts +322 -0
  152. package/dist/src/core/channel-lease.js +663 -0
  153. package/dist/src/core/channel-lease.js.map +1 -0
  154. package/dist/src/core/channel-owner.d.ts +213 -0
  155. package/dist/src/core/channel-owner.js +358 -0
  156. package/dist/src/core/channel-owner.js.map +1 -0
  157. package/dist/src/core/command-class.d.ts +205 -0
  158. package/dist/src/core/command-class.js +1433 -28
  159. package/dist/src/core/command-class.js.map +1 -1
  160. package/dist/src/core/commit-guard.d.ts +272 -0
  161. package/dist/src/core/commit-guard.js +424 -0
  162. package/dist/src/core/commit-guard.js.map +1 -0
  163. package/dist/src/core/daemon-actor.d.ts +45 -0
  164. package/dist/src/core/daemon-actor.js +54 -0
  165. package/dist/src/core/daemon-actor.js.map +1 -0
  166. package/dist/src/core/daemon-host.d.ts +178 -0
  167. package/dist/src/core/daemon-host.js +228 -0
  168. package/dist/src/core/daemon-host.js.map +1 -0
  169. package/dist/src/core/daemon-identity.d.ts +238 -0
  170. package/dist/src/core/daemon-identity.js +252 -0
  171. package/dist/src/core/daemon-identity.js.map +1 -0
  172. package/dist/src/core/dark-session.d.ts +109 -8
  173. package/dist/src/core/dark-session.js +266 -82
  174. package/dist/src/core/dark-session.js.map +1 -1
  175. package/dist/src/core/decision-refusal.d.ts +23 -2
  176. package/dist/src/core/decision-refusal.js +24 -2
  177. package/dist/src/core/decision-refusal.js.map +1 -1
  178. package/dist/src/core/env-file.d.ts +5 -0
  179. package/dist/src/core/env-file.js +60 -1
  180. package/dist/src/core/env-file.js.map +1 -1
  181. package/dist/src/core/execute.d.ts +15 -2
  182. package/dist/src/core/execute.js +15 -2
  183. package/dist/src/core/execute.js.map +1 -1
  184. package/dist/src/core/gate.d.ts +392 -6
  185. package/dist/src/core/gate.js +852 -24
  186. package/dist/src/core/gate.js.map +1 -1
  187. package/dist/src/core/gesture-refusal.d.ts +166 -0
  188. package/dist/src/core/gesture-refusal.js +188 -0
  189. package/dist/src/core/gesture-refusal.js.map +1 -0
  190. package/dist/src/core/harness-version.d.ts +128 -4
  191. package/dist/src/core/harness-version.js +181 -12
  192. package/dist/src/core/harness-version.js.map +1 -1
  193. package/dist/src/core/harness-wait.d.ts +82 -3
  194. package/dist/src/core/harness-wait.js +89 -3
  195. package/dist/src/core/harness-wait.js.map +1 -1
  196. package/dist/src/core/instance.d.ts +59 -2
  197. package/dist/src/core/instance.js +113 -0
  198. package/dist/src/core/instance.js.map +1 -1
  199. package/dist/src/core/log-subscribe.d.ts +21 -0
  200. package/dist/src/core/log-subscribe.js +16 -6
  201. package/dist/src/core/log-subscribe.js.map +1 -1
  202. package/dist/src/core/log-write-layer.d.ts +55 -0
  203. package/dist/src/core/log-write-layer.js +91 -0
  204. package/dist/src/core/log-write-layer.js.map +1 -0
  205. package/dist/src/core/log.d.ts +89 -22
  206. package/dist/src/core/log.js +197 -19
  207. package/dist/src/core/log.js.map +1 -1
  208. package/dist/src/core/payload-census.d.ts +51 -0
  209. package/dist/src/core/payload-census.js +125 -2
  210. package/dist/src/core/payload-census.js.map +1 -1
  211. package/dist/src/core/payload-store.d.ts +31 -0
  212. package/dist/src/core/payload-store.js +113 -8
  213. package/dist/src/core/payload-store.js.map +1 -1
  214. package/dist/src/core/payload.d.ts +18 -3
  215. package/dist/src/core/payload.js +20 -4
  216. package/dist/src/core/payload.js.map +1 -1
  217. package/dist/src/core/policy-diff.d.ts +10 -0
  218. package/dist/src/core/policy-diff.js +10 -2
  219. package/dist/src/core/policy-diff.js.map +1 -1
  220. package/dist/src/core/policy-explain.d.ts +17 -1
  221. package/dist/src/core/policy-explain.js +48 -1
  222. package/dist/src/core/policy-explain.js.map +1 -1
  223. package/dist/src/core/policy-load.d.ts +65 -1
  224. package/dist/src/core/policy-load.js +21 -3
  225. package/dist/src/core/policy-load.js.map +1 -1
  226. package/dist/src/core/policy-match.d.ts +81 -0
  227. package/dist/src/core/policy-match.js +87 -0
  228. package/dist/src/core/policy-match.js.map +1 -1
  229. package/dist/src/core/policy-proposal.d.ts +75 -0
  230. package/dist/src/core/policy-proposal.js +110 -4
  231. package/dist/src/core/policy-proposal.js.map +1 -1
  232. package/dist/src/core/protected-path-guard.d.ts +117 -4
  233. package/dist/src/core/protected-path-guard.js +362 -48
  234. package/dist/src/core/protected-path-guard.js.map +1 -1
  235. package/dist/src/core/question-preempted.d.ts +141 -0
  236. package/dist/src/core/question-preempted.js +152 -0
  237. package/dist/src/core/question-preempted.js.map +1 -0
  238. package/dist/src/core/read-scope.d.ts +182 -0
  239. package/dist/src/core/read-scope.js +256 -0
  240. package/dist/src/core/read-scope.js.map +1 -0
  241. package/dist/src/core/run-payload.d.ts +160 -0
  242. package/dist/src/core/run-payload.js +304 -0
  243. package/dist/src/core/run-payload.js.map +1 -0
  244. package/dist/src/core/sandbox.d.ts +81 -0
  245. package/dist/src/core/sandbox.js +190 -1
  246. package/dist/src/core/sandbox.js.map +1 -1
  247. package/dist/src/core/sender-identity.d.ts +501 -0
  248. package/dist/src/core/sender-identity.js +609 -0
  249. package/dist/src/core/sender-identity.js.map +1 -0
  250. package/dist/src/core/shlex.d.ts +102 -0
  251. package/dist/src/core/shlex.js +159 -0
  252. package/dist/src/core/shlex.js.map +1 -0
  253. package/dist/src/core/state.d.ts +52 -1
  254. package/dist/src/core/state.js +52 -6
  255. package/dist/src/core/state.js.map +1 -1
  256. package/dist/src/core/telegram-config.d.ts +79 -0
  257. package/dist/src/core/telegram-config.js +108 -0
  258. package/dist/src/core/telegram-config.js.map +1 -1
  259. package/dist/src/core/values.d.ts +18 -8
  260. package/dist/src/core/values.js +36 -1
  261. package/dist/src/core/values.js.map +1 -1
  262. package/dist/src/core/verify.d.ts +23 -1
  263. package/dist/src/core/verify.js +20 -2
  264. package/dist/src/core/verify.js.map +1 -1
  265. package/dist/src/daemon/advance.d.ts +10 -0
  266. package/dist/src/daemon/advance.js +25 -4
  267. package/dist/src/daemon/advance.js.map +1 -1
  268. package/dist/src/daemon/audit.d.ts +80 -5
  269. package/dist/src/daemon/audit.js +126 -6
  270. package/dist/src/daemon/audit.js.map +1 -1
  271. package/dist/src/daemon/daemon.d.ts +174 -2
  272. package/dist/src/daemon/daemon.js +307 -7
  273. package/dist/src/daemon/daemon.js.map +1 -1
  274. package/dist/src/daemon/git-evidence.d.ts +2 -2
  275. package/dist/src/daemon/git-evidence.js +1 -1
  276. package/dist/src/daemon/projection.d.ts +13 -2
  277. package/dist/src/daemon/projection.js +39 -6
  278. package/dist/src/daemon/projection.js.map +1 -1
  279. package/dist/src/mcp/server.d.ts +39 -3
  280. package/dist/src/mcp/server.js +30 -3
  281. package/dist/src/mcp/server.js.map +1 -1
  282. package/dist/src/muse/server.d.ts +16 -0
  283. package/dist/src/muse/server.js +238 -0
  284. package/dist/src/muse/server.js.map +1 -0
  285. package/dist/src/serve/archive.d.ts +158 -0
  286. package/dist/src/serve/archive.js +354 -0
  287. package/dist/src/serve/archive.js.map +1 -0
  288. package/dist/src/serve/arguments.d.ts +93 -0
  289. package/dist/src/serve/arguments.js +228 -0
  290. package/dist/src/serve/arguments.js.map +1 -0
  291. package/dist/src/serve/credentials.d.ts +91 -0
  292. package/dist/src/serve/credentials.js +143 -0
  293. package/dist/src/serve/credentials.js.map +1 -0
  294. package/dist/src/serve/follow.d.ts +86 -0
  295. package/dist/src/serve/follow.js +101 -0
  296. package/dist/src/serve/follow.js.map +1 -0
  297. package/dist/src/serve/hook-thread.d.ts +134 -0
  298. package/dist/src/serve/hook-thread.js +333 -0
  299. package/dist/src/serve/hook-thread.js.map +1 -0
  300. package/dist/src/serve/hook-worker.d.ts +134 -0
  301. package/dist/src/serve/hook-worker.js +173 -0
  302. package/dist/src/serve/hook-worker.js.map +1 -0
  303. package/dist/src/serve/server.d.ts +412 -0
  304. package/dist/src/serve/server.js +1251 -0
  305. package/dist/src/serve/server.js.map +1 -0
  306. package/docs/README-extended.md +766 -0
  307. package/docs/cli-reference.md +2563 -73
  308. package/docs/codex-enforced-session.md +75 -2
  309. package/docs/codex-workspace-broker.md +118 -0
  310. package/package.json +6 -2
  311. package/schema/event.schema.json +595 -9
  312. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  313. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  314. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  315. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  316. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  317. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  318. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  319. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  320. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  321. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  322. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  323. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  324. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  325. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  326. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  327. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  328. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  329. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  330. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  331. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  332. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  333. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  334. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  335. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  336. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  337. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  338. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  339. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  340. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  341. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  342. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  343. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  344. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  345. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  346. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  347. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  348. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  349. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  350. package/schema/fixtures/policy/valid/canonical.json +1 -1
  351. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  352. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  353. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  354. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  355. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  356. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  357. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  358. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  359. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  360. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  361. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  362. package/schema/fixtures/values/invalid/version-float.json +1 -0
  363. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  364. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  365. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  366. package/schema/fixtures/values/valid/full.json +5 -7
  367. package/schema/fixtures/values/valid/minimal.json +1 -1
  368. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  369. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  370. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  371. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  372. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  373. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  374. package/schema/fixtures/values-md/valid/absent.md +1 -1
  375. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  376. package/schema/policy.schema.json +100 -4
  377. package/schema/values.schema.json +7 -11
  378. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -54,23 +54,28 @@
54
54
  * precisely what this command reports, so they are check failures (exit 1).
55
55
  */
56
56
  import { createServer } from "node:net";
57
- import { closeSync, existsSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, } from "node:fs";
57
+ import { closeSync, existsSync, openSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, } from "node:fs";
58
+ import { homedir } from "node:os";
58
59
  import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments } from "node:path";
59
60
  import { WEB_DEFAULT_PORT } from "../channels/web.js";
60
61
  import { TELEGRAM_DEFAULT_API_BASE, telegramChatEnvFor, telegramTokenEnvFor, } from "../channels/telegram.js";
61
- import { HUMAN_ACTOR_ENV, checkAttestation, findOrganAttestation, latestOrganAttestation, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
62
- import { isGateOrganPath } from "../core/command-class.js";
62
+ import { HUMAN_ACTOR_ENV, attesterAt, checkAttestation, findOrganAttestation, findPathSignOff, latestOrganAttestation, latestPathSignOff, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
63
+ import { isGateOrganPath, POLICY_EDIT_SUBCLASS, protectedPathClass, } from "../core/command-class.js";
63
64
  import { VALUES_INFO_STRING, loadValues } from "../core/values.js";
64
65
  import { KEYSTORE_DEFERRED, NON_RESOLVING_RUNNER, envFilePathFor, resolveEnvironment, } from "../core/env-file.js";
65
66
  import { readTaskFile } from "../core/frontmatter.js";
67
+ import { derivedDaemonId, resolveDaemonAllowlist, resolveDaemonId, } from "../core/daemon-host.js";
68
+ import { DAEMON_ID_ENV } from "../core/daemon-identity.js";
66
69
  import { instanceFindings, instanceHomeFor, instanceIdFor } from "../core/instance.js";
70
+ import { describeOwners, otherOwnersOf, ownedBot } from "../core/channel-owner.js";
67
71
  import { checkLogAnchor } from "./log-anchor.js";
68
72
  import { checkLogCheckpoints, checkpointPolicyOf } from "../core/checkpoint.js";
69
- import { payloadStoreCensus } from "../core/payload-census.js";
73
+ import { payloadIntegrity, payloadStoreCensus } from "../core/payload-census.js";
70
74
  import { payloadStoreDirFor } from "../core/payload-store.js";
71
75
  import { DEFAULT_TASKS_DIR, latestRegistration } from "../core/registration.js";
72
76
  import { POLICY_FILENAMES, loadPolicy } from "../core/policy-load.js";
73
77
  import { openObligations } from "../core/audit.js";
78
+ import { SENDER_CHANNELS, SENDER_KEY_ENV, mapsSendersFor, senderKeyFrom, senderMappingForms, } from "../core/sender-identity.js";
74
79
  import { classSampling, resolveSampler } from "../core/sampler.js";
75
80
  import { checkVault, passphraseEnvFor, passphraseFrom, vaultExists, vaultPathFor, } from "../core/vault.js";
76
81
  import { admitSnapshot, logBytes, snapshotPathFor, snapshotSummary, } from "../core/verified-snapshot.js";
@@ -82,9 +87,13 @@ import { policyWebPort } from "./channel-web.js";
82
87
  import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
83
88
  import { RESOLVE_DANGLING_COMMAND, lastAdvance, proveDanglingAdvances, } from "../core/advance-cycle.js";
84
89
  import { DEFAULT_DARK_WINDOW_MS, reportDarkSessions, } from "../core/dark-session.js";
85
- import { HARNESS_BINARY, HARNESS_KINDS, installedHarnessVersion, isHarnessKind, readHarnessProvenance, } from "../core/harness-version.js";
86
- import { git, repoPath, repoRoot } from "./git-scope.js";
87
- import { ScanError, checkBuildFreshness, checkMainBehindOrigin, installationRoot, } from "./preflight.js";
90
+ import { HARNESS_BINARY, HARNESS_KINDS, HERMES_FAIL_CLOSED_FLOOR, hermesFailClosedSupport, installedHarnessVersion, installedHarnessVersionRaw, isHarnessKind, readHarnessProvenance, } from "../core/harness-version.js";
91
+ import { git, primaryRoot, repoPath, repoRoot } from "./git-scope.js";
92
+ // APRV-408: the gated tool roster of the `harness-hook-wiring` row is READ from
93
+ // the adapter that answers the calls, never restated here. No new export; the
94
+ // map has been exported since APRV-398.
95
+ import { HARNESS_ADAPTERS } from "./hook.js";
96
+ import { ScanError, checkAttestedPolicyOnMain, checkBuildFreshness, checkMainBehindOrigin, installationRoot, } from "./preflight.js";
88
97
  import { publishedState } from "./log-advance.js";
89
98
  import { DOCTOR_HELP } from "./help.js";
90
99
  import { DEFAULT_LOG_PATH, resolvePath } from "./paths.js";
@@ -246,12 +255,17 @@ function resolvePolicyPath(policyFlag, dir, cwd) {
246
255
  function checkAttestationHealth(records, policyPath) {
247
256
  const status = checkAttestation(records, policyPath);
248
257
  switch (status.status) {
249
- case "attested":
258
+ case "attested": {
259
+ // APRV-449: WHO set the policy in force, from the verified record at that
260
+ // seq and never from the file. A hosted tenant reads this row to learn
261
+ // whether their starting policy is the operator's or their own.
262
+ const by = attesterAt(records, status.seq);
250
263
  return {
251
264
  check: "attestation",
252
265
  status: "pass",
253
- detail: `${policyPath} is attested at seq ${status.seq} (sha256 ${status.sha256.slice(0, 12)}…)`,
266
+ detail: `${policyPath} is attested at seq ${status.seq}${by === null ? "" : ` by ${by}`} (sha256 ${status.sha256.slice(0, 12)}…)`,
254
267
  };
268
+ }
255
269
  case "not-attested":
256
270
  return {
257
271
  check: "attestation",
@@ -278,6 +292,14 @@ function checkAttestationHealth(records, policyPath) {
278
292
  // ---------------------------------------------------------------------------
279
293
  // 4. log
280
294
  // ---------------------------------------------------------------------------
295
+ /**
296
+ * A path as one POSIX shell word: bare when it holds only characters no shell
297
+ * interprets, single-quoted otherwise (`'` closed, escaped and reopened), so a
298
+ * fix command pasted from a row names the file the row is about.
299
+ */
300
+ function shellWord(path) {
301
+ return /^[A-Za-z0-9_./@:+-]+$/.test(path) ? path : `'${path.split("'").join(`'\\''`)}'`;
302
+ }
281
303
  /** The chain verdict, in doctor's vocabulary. Reads; never writes. */
282
304
  function checkLog(logPath, result) {
283
305
  switch (result.status) {
@@ -289,13 +311,31 @@ function checkLog(logPath, result) {
289
311
  ? `${logPath} is empty (an audit trail that has recorded nothing is clean, not missing)`
290
312
  : `${logPath} verifies: ${result.records} record(s), head seq ${result.head.seq} ${result.head.hash.slice(0, 12)}…`,
291
313
  };
292
- case "torn-tail":
314
+ case "torn-tail": {
315
+ const file = shellWord(logPath);
316
+ const keep = String(result.intactBytes);
317
+ // What the truncation removes, from the file's own size: verify counts
318
+ // the tail's DECODED bytes, which overstates a tail holding invalid UTF-8.
319
+ let drop = result.tornBytes;
320
+ try {
321
+ drop = statSync(logPath).size - result.intactBytes;
322
+ }
323
+ catch {
324
+ // Unreadable now: the verify count stands.
325
+ }
293
326
  return {
294
327
  check: "log",
295
328
  status: "fail",
296
- detail: `${logPath} ends with an unterminated final line — the signature of a crashed write, not of tampering; records 1..${result.intactThroughSeq} verify clean`,
297
- fix: "approval log verify — the full report; nothing here truncates the torn line, because that is a human decision",
329
+ detail: result.tear === "nul-filled"
330
+ ? `${logPath} ends with ${String(result.tornBytes)} NUL byte(s) after record ${String(result.intactThroughSeq)}, the crash-before-writeback signature: the file grew but its data never reached the disk before the machine stopped, so nothing was tampered and whatever was appended there is lost; records 1..${String(result.intactThroughSeq)} verify clean`
331
+ : `${logPath} ends with an unterminated line of ${String(result.tornBytes)} byte(s) after record ${String(result.intactThroughSeq)}, the signature of a crashed write (a writer that died mid-line), not of tampering; records 1..${String(result.intactThroughSeq)} verify clean`,
332
+ // APRV-440: which bytes, exactly. The line still opens with the read,
333
+ // because truncating is a human decision taken after it, and the
334
+ // truncation is spelled in `node` because `truncate(1)` is not on
335
+ // macOS and node is wherever this CLI runs. Doctor truncates nothing.
336
+ fix: `approval log verify — read the full report; then, if you decide to repair, keep the first ${keep} byte(s) of ${logPath} and drop the final ${String(drop)}: \`node -e "require('fs').truncateSync(process.argv[1], ${keep})" ${file}\`. Nothing here truncates, because that is a human decision`,
298
337
  };
338
+ }
299
339
  case "corrupt":
300
340
  return {
301
341
  check: "log",
@@ -332,7 +372,27 @@ function redact(text, token) {
332
372
  * operator to set a variable their policy never mentions is the failure mode
333
373
  * this parameter removes.
334
374
  */
335
- async function checkTelegram(apiBase, load) {
375
+ /**
376
+ * Who owns the bot this row is about (APRV-390), as one clause.
377
+ *
378
+ * Read from the two ownership records and never from the network: doctor's
379
+ * `getMe` answers WHICH bot, and this answers WHOSE. An instance that has
380
+ * never started a listener has no record and gets no clause, because "nothing
381
+ * has claimed it yet" is a state and printing it on every row would make the
382
+ * real finding — two instances on one bot — harder to see.
383
+ */
384
+ function ownershipClause(logPath, botId, apiBase) {
385
+ const mine = ownedBot(logPath, "telegram");
386
+ const others = otherOwnersOf(logPath, botId, apiBase);
387
+ if (others.length > 0) {
388
+ return `; ALSO claimed on this machine by ${describeOwners(others)}, which is two gates on one bot`;
389
+ }
390
+ if (mine !== null && mine.botId === botId) {
391
+ return `; owned by this instance (${mine.instanceHome}, instance ${mine.instanceId})`;
392
+ }
393
+ return "";
394
+ }
395
+ async function checkTelegram(apiBase, load, logPath) {
336
396
  const tokenEnv = telegramTokenEnvFor(load);
337
397
  const chatEnv = telegramChatEnvFor(load);
338
398
  const token = process.env[tokenEnv] ?? "";
@@ -385,11 +445,23 @@ async function checkTelegram(apiBase, load) {
385
445
  }
386
446
  const result = (envelope["result"] ?? {});
387
447
  const username = typeof result["username"] === "string" ? `@${result["username"]}` : "unnamed";
388
- const id = result["id"] === undefined ? "unknown id" : `id ${String(result["id"])}`;
448
+ const botId = result["id"] === undefined ? "" : String(result["id"]);
449
+ const id = botId.length === 0 ? "unknown id" : `id ${botId}`;
450
+ // APRV-390. Two gates on one bot is a FAIL, not a note: it is the state in
451
+ // which an approval tap is answered by whichever listener asked first.
452
+ const owners = botId.length === 0 ? [] : otherOwnersOf(logPath, botId, base);
453
+ if (owners.length > 0) {
454
+ return {
455
+ check: "telegram",
456
+ status: "fail",
457
+ detail: `token valid: ${username} (${id}) via ${base}, chat ${chat} — but this bot is also claimed on this machine by ${describeOwners(owners)}. Both gates long-poll it, their getUpdates offsets acknowledge each other's updates, and an approval tap is answered by whichever listener asked first`,
458
+ fix: "approval setup channel telegram — give this instance its own bot from @BotFather; `approval channel telegram health` names the current owner of each",
459
+ };
460
+ }
389
461
  return {
390
462
  check: "telegram",
391
463
  status: "pass",
392
- detail: `token valid: ${username} (${id}) via ${base}, chat ${chat}; no message was sent and no update was consumed`,
464
+ detail: `token valid: ${username} (${id}) via ${base}, chat ${chat}${botId.length === 0 ? "" : ownershipClause(logPath, botId, base)}; no message was sent and no update was consumed`,
393
465
  };
394
466
  }
395
467
  catch (cause) {
@@ -482,8 +554,12 @@ const PAYLOAD_STORE_WARNING = "the store holds the bytes approvals bind to, keye
482
554
  * The probe is a real create-and-remove in the store directory, not a `statSync`
483
555
  * mode test: mode bits do not answer the question on a read-only mount, under an
484
556
  * ACL, or in a container whose uid mapping differs from the one that made the
485
- * directory. Nothing is left behind, and no payload file is read, written or
486
- * verified here.
557
+ * directory. Nothing is left behind, and no payload file is written here.
558
+ *
559
+ * Since APRV-457 the bound payloads are read back and verified (see
560
+ * {@link payloadDamageCheck}): a store that is writable but holds a torn,
561
+ * missing or tampered payload a verified record binds fails, each reading with
562
+ * its own text.
487
563
  */
488
564
  function checkPayloadStore(logPath, records) {
489
565
  const storeDir = payloadStoreDirFor(logPath);
@@ -493,6 +569,14 @@ function checkPayloadStore(logPath, records) {
493
569
  }
494
570
  catch (cause) {
495
571
  if (cause.code === "ENOENT") {
572
+ // APRV-457: an absent store is only "not created yet" when no verified
573
+ // record proves the store already held bytes. When one does, the whole
574
+ // directory is missing under it: the crash signature of a first store
575
+ // whose directory entry never reached the disk, or a store not carried
576
+ // with the log. Every such binding reads as lost.
577
+ const damage = payloadIntegrity(records, storeDir);
578
+ if (damage.length > 0)
579
+ return payloadDamageCheck(damage);
496
580
  return {
497
581
  check: "payload-store",
498
582
  status: "pass",
@@ -555,12 +639,94 @@ function checkPayloadStore(logPath, records) {
555
639
  const residue = census.awaitingRemoval === 0
556
640
  ? ""
557
641
  : `, ${census.awaitingRemoval} already recorded as pruned and awaiting removal by the daemon`;
642
+ // APRV-457: the bytes verified records bind, read back. Only a damaged store
643
+ // changes the row; an intact one reads exactly as it did before.
644
+ const damage = payloadIntegrity(records, storeDir);
645
+ if (damage.length > 0)
646
+ return payloadDamageCheck(damage);
558
647
  return {
559
648
  check: "payload-store",
560
649
  status: "pass",
561
650
  detail: `${storeDir} is writable and holds ${files} payload file(s), ${census.pruned} pruned by the log, ${census.orphans} bound to no record${residue}; ${PAYLOAD_STORE_WARNING}`,
562
651
  };
563
652
  }
653
+ /** At most this many payloads are named per kind; the count covers the rest. */
654
+ const DAMAGE_NAMED = 5;
655
+ function namedDamage(items, describe) {
656
+ const named = items.slice(0, DAMAGE_NAMED).map(describe).join("; ");
657
+ const more = items.length > DAMAGE_NAMED ? `; and ${String(items.length - DAMAGE_NAMED)} more` : "";
658
+ return `${named}${more}`;
659
+ }
660
+ /**
661
+ * The payload-store row when bound payloads are not intact (APRV-457).
662
+ *
663
+ * Two readings, kept apart because they call for opposite reactions:
664
+ *
665
+ * - **The crash-before-writeback signature.** A file that is empty or all NUL
666
+ * (`torn`), or a file that is absent although its record proves the store
667
+ * held it (`lost`). A crash leaves exactly these and nothing else: it can drop
668
+ * bytes and names, never write bytes nobody wrote. Before APRV-457 the store
669
+ * did not fsync, so a platform kill could keep the (fsynced) record and drop
670
+ * its payload. Nothing was tampered; the bytes are gone from this store.
671
+ * - **Tampering or corruption** (`mismatch`). The file holds at least one
672
+ * non-NUL byte and does not verify. That is not the pure crash shape, so it is never
673
+ * read as one (a partial writeback, valid prefix and NUL tail, lands here too: the stricter reading), and its fix moves the file aside for investigation.
674
+ *
675
+ * The torn reading cannot swallow a tampered file that still carries content:
676
+ * it requires every byte to be NUL. A file someone zeroed by hand reads as torn,
677
+ * which is honest, because its bytes say nothing either way and the material is
678
+ * lost either way. A partially written payload (valid prefix, NUL tail) is
679
+ * reported as a mismatch, the stricter of the two readings.
680
+ *
681
+ * Doctor moves, restores and deletes nothing; every fix is the operator's.
682
+ */
683
+ function payloadDamageCheck(damage) {
684
+ const torn = damage.filter((item) => item.kind === "torn");
685
+ const lost = damage.filter((item) => item.kind === "lost");
686
+ const mismatch = damage.filter((item) => item.kind === "mismatch");
687
+ const unreadable = damage.filter((item) => item.kind === "unreadable");
688
+ const short = (hash) => `${hash.slice(0, 12)}…`;
689
+ const parts = [];
690
+ if (mismatch.length > 0) {
691
+ parts.push(`${String(mismatch.length)} payload(s) a verified record binds hold bytes that do not hash to their name and are NOT the crash signature (that is an empty or all-NUL file; a partial writeback can also leave a valid prefix with a NUL tail, so the stricter reading applies until shown otherwise): treat them as tampered or corrupted (${namedDamage(mismatch, (item) => `${short(item.hash)} bound at seq ${String(item.seq)}`)}); every read re-verifies, so no channel renders them`);
692
+ }
693
+ if (torn.length > 0) {
694
+ parts.push(`${String(torn.length)} payload(s) a verified record binds are empty or NUL-filled, the crash-before-writeback signature: the name reached the disk and the bytes did not, so nothing was tampered and the material is lost from this store (${namedDamage(torn, (item) => `${short(item.hash)} bound at seq ${String(item.seq)}, ${String(item.bytes)} NUL byte(s)`)})`);
695
+ }
696
+ if (lost.length > 0) {
697
+ parts.push(`${String(lost.length)} payload(s) are missing although the record that bound them proves the store held them (${namedDamage(lost, (item) => `${short(item.hash)}, ${item.event} at seq ${String(item.seq)}`)}): the crash-before-writeback signature when this store has always lived beside this log (the name never reached the disk), or a store that was not carried with the log`);
698
+ }
699
+ if (unreadable.length > 0) {
700
+ parts.push(`${String(unreadable.length)} payload(s) a verified record binds could not be read (${namedDamage(unreadable, (item) => `${short(item.hash)} bound at seq ${String(item.seq)}: ${item.detail}`)})`);
701
+ }
702
+ // One command leads the fix, for the reading that matters most: suspected
703
+ // tampering first, then a torn file (which must be moved before a copy can go
704
+ // back), then a lost one, then an unreadable one.
705
+ const restore = (hash, path) => `restore ${hash}.json from wherever else it lives (another checkout, a backup, the git history of the store) once \`approval payload hash\` prints ${hash} for the copy, and put it at ${path}; the log records the binding and never the material, so nothing rebuilds it`;
706
+ let fix;
707
+ const firstMismatch = mismatch[0];
708
+ const firstTorn = torn[0];
709
+ const firstLost = lost[0];
710
+ if (firstMismatch !== undefined) {
711
+ fix = `mv ${shellWord(firstMismatch.path)} ${shellWord(`${firstMismatch.path}.suspect`)} — move each payload that does not verify aside (do not delete it), find out what wrote it, and ${restore(firstMismatch.hash, firstMismatch.path)}`;
712
+ }
713
+ else if (firstTorn !== undefined) {
714
+ fix = `mv ${shellWord(firstTorn.path)} ${shellWord(`${firstTorn.path}.torn`)} — move each torn payload aside (the nonmanual intake refuses to replace a file that does not verify), then ${restore(firstTorn.hash, firstTorn.path)}`;
715
+ }
716
+ else if (firstLost !== undefined) {
717
+ fix = `approval payload hash <copy> — ${restore(firstLost.hash, firstLost.path)}`;
718
+ }
719
+ else {
720
+ const first = unreadable[0];
721
+ fix = `chmod u+r ${shellWord(first.path)} — make each payload readable by the user running approval, check its ownership and the disk, then run approval doctor again`;
722
+ }
723
+ return {
724
+ check: "payload-store",
725
+ status: "fail",
726
+ detail: `${parts.join("; ")}; ${PAYLOAD_STORE_WARNING}`,
727
+ fix: `${fix}. Doctor moves, restores and deletes nothing`,
728
+ };
729
+ }
564
730
  // ---------------------------------------------------------------------------
565
731
  // 7b. the verified-head snapshot (APRV-188)
566
732
  // ---------------------------------------------------------------------------
@@ -744,11 +910,19 @@ function checkValuesBlock(policyPath, policyFlagged, dir) {
744
910
  detail: oneLine(`${result.message}, so there is no values block to read. The policy file's own absence is reported by the attestation row above.`),
745
911
  };
746
912
  }
913
+ // A block of the format APRV-336 replaced is a fixable block rather than a
914
+ // broken one, so its fix names the two edits instead of the load code.
915
+ const fix = result.code === "version-unsupported"
916
+ ? `edit the block in ${result.source?.filename ?? "the policy file"}: fold \`wants:\` into \`like:\`, rename \`responds:\` to \`communication:\`, and set \`version: "0.2"\` with the quotes; then re-attest, since the attestation digests the whole file`
917
+ : `approval values --json — prints the same failure with its code (${result.code}); fix the block in ${result.source?.filename ?? "the policy file"} and re-attest, since the attestation digests the whole file`;
747
918
  return {
748
919
  check: "values-block",
749
920
  status: "fail",
750
- detail: oneLine(`a \`\`\`${VALUES_INFO_STRING} block is present and could not be read (${result.code}): ${result.message}. Nothing about the policy changed — guidance is not enforcement — but the operator's stated values reach no agent until this parses.`),
751
- fix: `approval values --json — prints the same failure with its code (${result.code}); fix the block in ${result.source?.filename ?? "the policy file"} and re-attest, since the attestation digests the whole file`,
921
+ detail: oneLine(
922
+ // The loader's own message may end in a full stop (the version refusals
923
+ // are whole sentences), and two of them in a row reads as a typo.
924
+ `a \`\`\`${VALUES_INFO_STRING} block is present and could not be read (${result.code}): ${result.message.replace(/\.$/u, "")}. Nothing about the policy changed — guidance is not enforcement — but the operator's stated values reach no agent until this parses.`),
925
+ fix,
752
926
  };
753
927
  }
754
928
  if (!result.present) {
@@ -758,12 +932,160 @@ function checkValuesBlock(policyPath, policyFlagged, dir) {
758
932
  detail: `${result.source.filename}: no approval-values block; the operator has declared no values here. That is a declaration rather than a gap, and \`approval values\` says so in those words.`,
759
933
  };
760
934
  }
761
- const declared = ["love", "like", "dislike", "wants"].filter((key) => result.values[key] !== undefined);
762
- const responds = result.values.responds === undefined ? "" : ", responds";
935
+ const declared = ["love", "like", "dislike"].filter((key) => result.values[key] !== undefined);
936
+ const communication = result.values.communication === undefined ? "" : ", communication";
763
937
  return {
764
938
  check: "values-block",
765
939
  status: "pass",
766
- detail: `${result.source.filename}: the values block parses and validates (version ${String(result.values.version)}; ${declared.length === 0 ? "no list" : declared.join(", ")}${responds}). It is guidance, so nothing here is enforced; read it with \`approval values\`.`,
940
+ detail: `${result.source.filename}: the values block parses and validates (version ${JSON.stringify(result.values.version)}; ${declared.length === 0 ? "no list" : declared.join(", ")}${communication}). It is guidance, so nothing here is enforced; read it with \`approval values\`.`,
941
+ };
942
+ }
943
+ // ---------------------------------------------------------------------------
944
+ // 7e. the deprecated bare `supervised` (APRV-335)
945
+ // ---------------------------------------------------------------------------
946
+ /**
947
+ * Which rules of this policy still write the deprecated bare `supervised`.
948
+ *
949
+ * SPEC.md §5.2 keeps the spelling parsing as `supervised-retro` so that no
950
+ * published policy breaks, and the loader records a note wherever it appears.
951
+ * A note is read by whoever happens to run a verb that prints notes; the
952
+ * deprecation needs a place an operator looks at the state of their repository
953
+ * on purpose, which is this report. SPEC.md §5.2 asks implementations for
954
+ * exactly that.
955
+ *
956
+ * It is never a `fail` and never a `warn`. The policy says what its author
957
+ * meant, every gate resolves it identically, and nothing on this machine is
958
+ * broken by a spelling: a red row here would be doctor going red over prose. A
959
+ * `skip` is the unreadable policy, whose own failure the attestation and
960
+ * `policy check` rows report; everything else is a `pass` that either names the
961
+ * rules to rewrite or says plainly that there are none.
962
+ */
963
+ function checkAutonomyAlias(policyLoad) {
964
+ if (!policyLoad.ok) {
965
+ return {
966
+ check: "autonomy-alias",
967
+ status: "skip",
968
+ detail: `the policy did not load (${policyLoad.code}), so no rule could be read for the deprecated bare \`supervised\`. A failed load resolves every class to \`manual\` and names no level at all; the failure itself is reported by \`approval policy check\`.`,
969
+ };
970
+ }
971
+ // The same walk `aliasNotes` makes in the loader, and deliberately the same
972
+ // order: `defaults` first, then class patterns sorted, so the row and the
973
+ // load-time notes name the same places in the same sequence.
974
+ const where = [];
975
+ if (policyLoad.policy.defaults?.autonomy === "supervised")
976
+ where.push("defaults.autonomy");
977
+ for (const pattern of Object.keys(policyLoad.policy.classes ?? {}).sort()) {
978
+ if (policyLoad.policy.classes?.[pattern]?.autonomy === "supervised")
979
+ where.push(pattern);
980
+ }
981
+ if (where.length === 0) {
982
+ return {
983
+ check: "autonomy-alias",
984
+ status: "pass",
985
+ detail: `${policyLoad.source.filename}: no rule uses the deprecated bare \`supervised\`. Every supervised class says which bargain it means, \`supervised-retro\` or \`supervised-live\`, so nothing here changes when a future schema version drops the alias.`,
986
+ };
987
+ }
988
+ return {
989
+ check: "autonomy-alias",
990
+ status: "pass",
991
+ detail: oneLine(`deprecated bare \`supervised\` on ${String(where.length)} rule(s): ${where.join(", ")}. Each parses as \`supervised-retro\` and is enforced as one, so the policy means today what its author meant; the spelling is deprecated and a future version of the policy schema removes it (SPEC.md §5.2, APRV-335).`),
992
+ // FIX_COMMAND_PREFIXES: the repair is a hand edit of the policy file plus
993
+ // the re-attestation that edit costs, and `policy amend` is the one verb
994
+ // that owns both. The prose after it is the edit itself: write
995
+ // supervised-retro.
996
+ fix: "approval policy amend — write supervised-retro in place of the bare supervised on the rules above, and re-attest in the same step; the paste-ready draft for this repo is docs/proposals/approval-md-2026-09.md",
997
+ };
998
+ }
999
+ /**
1000
+ * Which approvers can still be recognized by the channels they are listed for
1001
+ * (APRV-324, `design/channel-sender-identity.md` §5 item 13).
1002
+ *
1003
+ * The row exists for one transition and says which side of it this deployment
1004
+ * is on. Declaring the first `senders` entry for a channel turns enforcement on
1005
+ * for that whole channel: from then on a decision arriving with an account the
1006
+ * policy does not name is REFUSED rather than recorded under the listener's own
1007
+ * identity. An operator who maps themselves and forgets a colleague has built a
1008
+ * gate that will refuse that colleague's next tap, and the file reads as though
1009
+ * both are approvers.
1010
+ *
1011
+ * Three answers, and the verdicts are chosen for what each one actually is:
1012
+ *
1013
+ * - **SKIP** when no approver declares a mapping. That is every installation
1014
+ * before this key existed and most of them after, it is fully correct, and a
1015
+ * red row for it would be a red row people learn to skip past.
1016
+ * - **FAIL** for an approver listed for a mapped channel with no id of their
1017
+ * own on it. Not a state: the policy says that person decides on that
1018
+ * channel, and the runtime will not honour it.
1019
+ * - **PASS** when every approver the policy can reach on a mapped channel is
1020
+ * recognizable there.
1021
+ *
1022
+ * It reads the policy and nothing else — no log, no network, no credential —
1023
+ * and it prints the id of nobody: a doctor row is read over a shoulder, the
1024
+ * mapping is in a file the operator can open, and the row's job is to name who
1025
+ * is missing rather than to recite everyone who is not.
1026
+ */
1027
+ function checkSenderMapping(policyLoad, env = process.env) {
1028
+ if (!policyLoad.ok) {
1029
+ return {
1030
+ check: "sender-mapping",
1031
+ status: "skip",
1032
+ detail: `the policy did not load (${policyLoad.code}), so no approver roster could be read. A failed load carries no sender mapping either, and a decision arriving with an authenticated sender is refused \`sender-unmapped\` until the file parses again — the \`attestation\` row above and \`approval policy check\` say what is wrong with it.`,
1033
+ };
1034
+ }
1035
+ const approvers = policyLoad.policy.approvers;
1036
+ const mapped = SENDER_CHANNELS.filter((channel) => mapsSendersFor(approvers, channel));
1037
+ if (mapped.length === 0) {
1038
+ return {
1039
+ check: "sender-mapping",
1040
+ status: "skip",
1041
+ detail: `${policyLoad.source.filename}: no approver declares a \`senders\` block, so every decision is recorded against the identity the deciding process was launched with (\`--as\` / APPROVAL_HUMAN), exactly as it was before the mapping existed. Anyone who can reach a configured channel decides as that identity; map an approver's account id to record who actually tapped (SPEC.md §5.2, APRV-324).`,
1042
+ };
1043
+ }
1044
+ // APRV-370. Which FORM each mapped channel is written in, and for a keyed one
1045
+ // whether the key this process would compare with resolves at all. The row
1046
+ // reports the form because an operator reading it cannot otherwise tell a
1047
+ // gate that publishes its accounts from one that does not, and it reports the
1048
+ // key because a keyed channel without one refuses every tap on it.
1049
+ const key = senderKeyFrom(env);
1050
+ const keyed = mapped.filter((channel) => senderMappingForms(approvers, channel).keyed);
1051
+ const mixed = keyed.filter((channel) => senderMappingForms(approvers, channel).raw);
1052
+ const forms = keyed.length === 0
1053
+ ? "raw account ids"
1054
+ : mixed.length > 0
1055
+ ? `both forms (${mixed.join(", ")} carries raw ids beside keyed ones)`
1056
+ : `the keyed form (${SENDER_KEY_ENV})`;
1057
+ if (keyed.length > 0 && key === null) {
1058
+ return {
1059
+ check: "sender-mapping",
1060
+ status: "fail",
1061
+ detail: oneLine(`${policyLoad.source.filename} maps ${keyed.join(", ")} senders in the keyed form and ${SENDER_KEY_ENV} is unset or empty in this process, so no account on those channels can be resolved at all and EVERY decision there is refused \`sender-key-unavailable\`. Without the key the runtime can compute no digest, so it cannot tell a mapped account from an unmapped one and will not guess; there is no fallback to a raw comparison, by design.`),
1062
+ fix: `approval setup sender-key mints and stores the key; \`eval "$(approval env)"\` establishes it in the shell that starts the listener, and the listener has to be restarted to pick it up. \`approval env --check\` says where the value lives and prints no values`,
1063
+ };
1064
+ }
1065
+ const gaps = [];
1066
+ for (const channel of mapped) {
1067
+ for (const id of Object.keys(approvers ?? {}).sort()) {
1068
+ const entry = approvers?.[id];
1069
+ if (entry === undefined)
1070
+ continue;
1071
+ if (!entry.channels.includes(channel))
1072
+ continue;
1073
+ if (entry.senders?.[channel] === undefined)
1074
+ gaps.push(`${id} on ${channel}`);
1075
+ }
1076
+ }
1077
+ if (gaps.length > 0) {
1078
+ return {
1079
+ check: "sender-mapping",
1080
+ status: "fail",
1081
+ detail: oneLine(`${policyLoad.source.filename} maps ${mapped.join(", ")} senders in ${forms}, and ${String(gaps.length)} approver(s) listed for a mapped channel carry no id there: ${gaps.join(", ")}. A decision from an account this file does not name is refused \`sender-unmapped\` and nothing is recorded, so the policy currently says those people may decide on a channel where their taps will not be honoured.`),
1082
+ fix: "approval policy amend — give each approver above a `senders` entry for that channel, or drop the channel from their `channels` list if they are not meant to decide there; the mapping is attested policy, so the edit is inoperative until it is re-attested",
1083
+ };
1084
+ }
1085
+ return {
1086
+ check: "sender-mapping",
1087
+ status: "pass",
1088
+ detail: oneLine(`${policyLoad.source.filename} maps ${mapped.join(", ")} senders in ${forms}, and every approver listed for a mapped channel carries an id there.${keyed.length === 0 ? "" : ` ${SENDER_KEY_ENV} resolves, so the keyed ids can be compared.`} Each decision on those channels is recorded against the person the operator attested that account to; an account this file does not name is refused rather than attributed to the listener (SPEC.md §10.3, APRV-324, APRV-370).`),
767
1089
  };
768
1090
  }
769
1091
  // ---------------------------------------------------------------------------
@@ -1508,6 +1830,80 @@ function checkKeychainScope(logPath, load) {
1508
1830
  };
1509
1831
  }
1510
1832
  // ---------------------------------------------------------------------------
1833
+ // 32. daemon-identity (APRV-383)
1834
+ // ---------------------------------------------------------------------------
1835
+ /**
1836
+ * Which daemon would write here, and whether the attested policy admits it
1837
+ * (APRV-383).
1838
+ *
1839
+ * The sibling of `keychain-scope` above, one layer along: that row answers
1840
+ * "whose CREDENTIALS is this instance using", this one answers "whose NAME would
1841
+ * this instance's daemon write onto every record it appends". Both derive from the
1842
+ * same instance id, which is the point — an operator comparing `daemon-3f2a9c11`
1843
+ * in a record against `approval-tg-token-3f2a9c11` in `.approval/env` is looking
1844
+ * at one gate.
1845
+ *
1846
+ * ## The verdict rule
1847
+ *
1848
+ * - **FAIL** for an `APPROVAL_DAEMON_ID` in this shell that is not a usable id.
1849
+ * That is a broken launch environment wherever it is read from: a daemon started
1850
+ * from this shell would refuse every append, and the repair is one variable.
1851
+ * - **SKIP**, named and loud, for an id the policy's `daemons` list does not
1852
+ * admit. Deliberately not a fail, and the reason is the one `keychain-scope`
1853
+ * states for the legacy item: under the deployment this key exists FOR, the
1854
+ * tenant's policy names a daemon id belonging to somebody else's machine, so a
1855
+ * red row here would be red on every tenant's laptop forever, and a red row on
1856
+ * every installation is a red row people learn to skip past. The line says
1857
+ * plainly that a daemon started HERE would write nothing.
1858
+ * - **PASS** when the list names this id, and when there is no list at all, which
1859
+ * is every installation that never adopts the key.
1860
+ *
1861
+ * It reads a policy and an environment variable's SHAPE. No value is printed on
1862
+ * any path, because the only value involved is an id, which the policy and the log
1863
+ * both carry in the open.
1864
+ */
1865
+ function checkDaemonIdentity(logPath, records, load) {
1866
+ const resolved = resolveDaemonId(logPath);
1867
+ if (!resolved.ok) {
1868
+ return {
1869
+ check: "daemon-identity",
1870
+ status: "fail",
1871
+ detail: `${resolved.message}. A daemon started from this shell would be refused \`daemon-id-invalid\` on every append, so it would read, render and report while writing nothing`,
1872
+ fix: `unset ${DAEMON_ID_ENV} to use the derived id ${derivedDaemonId(logPath)}, or set it to a usable one: lowercase letters, digits, \`.\`, \`-\` and \`_\`, starting with a letter or digit`,
1873
+ };
1874
+ }
1875
+ const head = `a daemon run against ${instanceHomeFor(logPath)} writes \`daemon: ${resolved.id}\` onto every record it appends (${resolved.source === "environment" ? `declared in ${DAEMON_ID_ENV}` : "derived from this instance, the same id the keychain-scope row names"})`;
1876
+ const allowlist = resolveDaemonAllowlist(records, load);
1877
+ if (!allowlist.ok) {
1878
+ return {
1879
+ check: "daemon-identity",
1880
+ status: "pass",
1881
+ detail: `${head}. No \`daemons\` allowlist is in force (${allowlist.detail}), so any daemon may write, exactly as before the key existed`,
1882
+ };
1883
+ }
1884
+ const allowed = allowlist.allowed;
1885
+ if (allowed === null) {
1886
+ return {
1887
+ check: "daemon-identity",
1888
+ status: "pass",
1889
+ detail: `${head}. The attested policy lists no allowed daemon ids, so any daemon may write — which is what every policy written before this key looked like`,
1890
+ };
1891
+ }
1892
+ if (!allowed.includes(resolved.id)) {
1893
+ return {
1894
+ check: "daemon-identity",
1895
+ status: "skip",
1896
+ detail: `${head}. The attested policy's \`daemons\` list admits ${allowed.length === 0 ? "no daemon at all" : allowed.join(", ")}, so a daemon started HERE would be refused \`daemon-not-allowed\` on every append and would write nothing. Expected where the daemon for this log runs on another machine; wrong if that daemon is meant to be this one`,
1897
+ fix: `add ${resolved.id} to \`daemons\` and re-attest the policy, or run the daemon whose id the list already names`,
1898
+ };
1899
+ }
1900
+ return {
1901
+ check: "daemon-identity",
1902
+ status: "pass",
1903
+ detail: `${head}, and the attested policy's \`daemons\` list admits it (${allowed.join(", ")}). Being listed grants nothing beyond the ability to write: no verdict, budget, floor, draw or token reads this id`,
1904
+ };
1905
+ }
1906
+ // ---------------------------------------------------------------------------
1511
1907
  // 12. log-drift (APRV-125)
1512
1908
  // ---------------------------------------------------------------------------
1513
1909
  /**
@@ -1745,28 +2141,180 @@ function checkAdvanceCadence(logPath, records) {
1745
2141
  // ---------------------------------------------------------------------------
1746
2142
  /** Where Claude Code keeps the hook registration a human commits. */
1747
2143
  const CLAUDE_SETTINGS = join(".claude", "settings.json");
1748
- /** Does any `hooks.<event>` entry run this CLI's harness hook? */
1749
- function registersApprovalHook(hooks, event) {
2144
+ /** This runtime's own Claude Code hook adapter, the roster both rows read. */
2145
+ const CLAUDE_HOOK_ADAPTER = HARNESS_ADAPTERS["claude-code"];
2146
+ /**
2147
+ * The tools a protected-path write can arrive as, DERIVED (APRV-408).
2148
+ *
2149
+ * Until APRV-408 this was a hand list, `["Edit", "Write", "Bash"]`, and it had
2150
+ * drifted: the adapter gates `Bash` plus `Edit`, `Write`, `MultiEdit` and
2151
+ * `NotebookEdit`, so a matcher of `Bash|Edit|Write` passed the wiring row while
2152
+ * a `MultiEdit` to a protected path reached the file system unclassified. Two
2153
+ * lists of the same fact is one list too many, so there is now one: the
2154
+ * adapter's, because the adapter is what actually answers the call.
2155
+ */
2156
+ function gatedClaudeTools() {
2157
+ return [CLAUDE_HOOK_ADAPTER.shellTool, ...CLAUDE_HOOK_ADAPTER.fileTools];
2158
+ }
2159
+ /**
2160
+ * Anything that makes a command line mean something other than its own words.
2161
+ *
2162
+ * Deliberately generous. A command carrying any of these is reported as
2163
+ * unresolved rather than parsed on a best guess, because the question this
2164
+ * parse answers — which checkout does the gate resolve its policy and log from
2165
+ * — has no safe approximation. Fail closed: SPEC §11.
2166
+ */
2167
+ const SHELL_OPAQUE = /[$`\\;&|<>(){}\r\n*?[\]]/u;
2168
+ /**
2169
+ * The `hook claude-code` verb named anywhere in a command, however wrapped.
2170
+ *
2171
+ * The VERB and not the executable, because the executable is exactly what a
2172
+ * wrapper hides: `$(which approval) hook claude-code` and `bash -lc "approval
2173
+ * hook claude-code …"` both name this handler and neither carries the word
2174
+ * `approval` in a position any parse could trust. Naming the verb is what makes
2175
+ * such an entry `unresolved` (reported, never a pass) instead of `foreign`
2176
+ * (invisible, which is how a wrapped handler would read as "no hook
2177
+ * registered"). Another harness's entry parked in this file — `approval hook
2178
+ * cursor` — does not name it, and stays foreign.
2179
+ */
2180
+ const CLAUDE_HOOK_MENTION = /\bhook\s+claude-code\b/u;
2181
+ /**
2182
+ * Split a command line into words, honouring simple quotes.
2183
+ *
2184
+ * Only ever reached for a command {@link SHELL_OPAQUE} has already cleared, so
2185
+ * there is no expansion, no escape and no operator left to get wrong; `null` is
2186
+ * an unclosed quote.
2187
+ */
2188
+ function shellWords(command) {
2189
+ const tokens = [];
2190
+ let current = "";
2191
+ let quote = null;
2192
+ let started = false;
2193
+ for (const character of command) {
2194
+ if (quote !== null) {
2195
+ if (character === quote)
2196
+ quote = null;
2197
+ else
2198
+ current += character;
2199
+ continue;
2200
+ }
2201
+ if (character === '"' || character === "'") {
2202
+ quote = character;
2203
+ started = true;
2204
+ continue;
2205
+ }
2206
+ if (/\s/u.test(character)) {
2207
+ if (started)
2208
+ tokens.push(current);
2209
+ current = "";
2210
+ started = false;
2211
+ continue;
2212
+ }
2213
+ current += character;
2214
+ started = true;
2215
+ }
2216
+ if (quote !== null)
2217
+ return null;
2218
+ if (started)
2219
+ tokens.push(current);
2220
+ return tokens;
2221
+ }
2222
+ /**
2223
+ * Read a registered command as an `approval hook claude-code` invocation
2224
+ * (APRV-408).
2225
+ *
2226
+ * Modelled on {@link isDirectCodexHookCommand}, which the Codex rows have had
2227
+ * since APRV-313, and which the Claude Code rows had no counterpart of: they
2228
+ * matched the substring `approval hook`, so a wrapper script, a different
2229
+ * harness's entry and a handler bound to a checkout that does not exist on this
2230
+ * machine all read the same. Tolerant of flag order and of `--as`/`--timeout`
2231
+ * being absent, because those do not decide anything this row reports; strict
2232
+ * about the three facts that do — the executable is `approval`, the verb is
2233
+ * `hook claude-code`, and the `--dir` is whatever it is.
2234
+ */
2235
+ function parseClaudeHookCommand(command) {
2236
+ const mentioned = CLAUDE_HOOK_MENTION.test(command);
2237
+ const unreadable = (reason) => mentioned ? { kind: "unresolved", reason } : { kind: "foreign" };
2238
+ if (SHELL_OPAQUE.test(command)) {
2239
+ return unreadable("it carries shell metacharacters, so what it runs depends on a shell rather than on these words");
2240
+ }
2241
+ const tokens = shellWords(command);
2242
+ if (tokens === null)
2243
+ return unreadable("its quoting does not close");
2244
+ const executable = tokens[0] ?? "";
2245
+ if (tokens[1] !== "hook" || tokens[2] !== "claude-code") {
2246
+ // `approval hook cursor` in this file is another harness's entry, and a
2247
+ // wrapper name with the verb buried inside it is ours and unreadable.
2248
+ return unreadable("its first words are not `approval hook claude-code`");
2249
+ }
2250
+ if (basename(executable) !== "approval") {
2251
+ return unreadable(`its executable ${JSON.stringify(executable)} is not \`approval\``);
2252
+ }
2253
+ if (executable.includes("/") && !isAbsolute(executable)) {
2254
+ return unreadable(`its executable ${JSON.stringify(executable)} is a relative path, so which binary runs depends on the working directory`);
2255
+ }
2256
+ let dir = null;
2257
+ for (let index = 3; index < tokens.length; index += 1) {
2258
+ const token = tokens[index] ?? "";
2259
+ if (token.startsWith("--dir=")) {
2260
+ dir = token.slice("--dir=".length);
2261
+ continue;
2262
+ }
2263
+ if (token !== "--dir")
2264
+ continue;
2265
+ const value = tokens[index + 1];
2266
+ if (value === undefined || value.startsWith("--")) {
2267
+ return unreadable("its `--dir` flag names no value");
2268
+ }
2269
+ dir = value;
2270
+ index += 1;
2271
+ }
2272
+ return { kind: "handler", dir };
2273
+ }
2274
+ /**
2275
+ * Every `hooks.<event>` entry of this file that is OURS (APRV-408).
2276
+ *
2277
+ * One reading for both harness rows, so `harness-hook-outcomes` and
2278
+ * `harness-hook-wiring` cannot disagree about which entries they are talking
2279
+ * about. An unresolved entry counts as ours here — it names the verb, and a
2280
+ * command this file cannot read is a reason to report less confidently rather
2281
+ * than to report that no hook is registered.
2282
+ */
2283
+ function claudeHookEntries(hooks, event) {
1750
2284
  if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
1751
- return false;
1752
- const matchers = hooks[event];
1753
- if (!Array.isArray(matchers))
1754
- return false;
1755
- for (const matcher of matchers) {
1756
- if (typeof matcher !== "object" || matcher === null)
2285
+ return [];
2286
+ const groups = hooks[event];
2287
+ if (!Array.isArray(groups))
2288
+ return [];
2289
+ const found = [];
2290
+ for (const group of groups) {
2291
+ if (typeof group !== "object" || group === null)
1757
2292
  continue;
1758
- const entries = matcher["hooks"];
2293
+ const entries = group["hooks"];
1759
2294
  if (!Array.isArray(entries))
1760
2295
  continue;
2296
+ const pattern = group["matcher"];
1761
2297
  for (const entry of entries) {
1762
2298
  if (typeof entry !== "object" || entry === null)
1763
2299
  continue;
1764
2300
  const command = entry["command"];
1765
- if (typeof command === "string" && /\bapproval hook\b/u.test(command))
1766
- return true;
2301
+ if (typeof command !== "string")
2302
+ continue;
2303
+ const parse = parseClaudeHookCommand(command);
2304
+ if (parse.kind === "foreign")
2305
+ continue;
2306
+ found.push({
2307
+ matcher: typeof pattern === "string" ? pattern : "",
2308
+ command,
2309
+ parse,
2310
+ });
1767
2311
  }
1768
2312
  }
1769
- return false;
2313
+ return found;
2314
+ }
2315
+ /** Does any `hooks.<event>` entry run this CLI's Claude Code hook? */
2316
+ function registersApprovalHook(hooks, event) {
2317
+ return claudeHookEntries(hooks, event).length > 0;
1770
2318
  }
1771
2319
  /**
1772
2320
  * Is the harness registered for the event that reports outcomes (APRV-145)?
@@ -1782,6 +2330,12 @@ function registersApprovalHook(hooks, event) {
1782
2330
  * `policy.core` in this taxonomy — a file that configures the gate is part of
1783
2331
  * the gate — so the repair is a line for a human to commit, printed by
1784
2332
  * `approval instructions hook`.
2333
+ *
2334
+ * Which entries are OURS is read by {@link claudeHookEntries} since APRV-408,
2335
+ * the same reading `harness-hook-wiring` uses, so the two rows cannot come
2336
+ * apart on the question. Before that this row matched the substring `approval
2337
+ * hook`, which counted another harness's entry parked in Claude Code's own
2338
+ * settings file as a post-execution reporter for Claude Code.
1785
2339
  */
1786
2340
  function checkHarnessOutcomes(dir) {
1787
2341
  const check = "harness-hook-outcomes";
@@ -1814,53 +2368,138 @@ function checkHarnessOutcomes(dir) {
1814
2368
  return {
1815
2369
  check,
1816
2370
  status: "skip",
1817
- detail: `${path} registers no \`approval hook\` entry, so this checkout is not gated by the harness hook at all`,
2371
+ detail: `${path} registers no \`approval hook claude-code\` entry, so this checkout is not gated by the harness hook at all`,
1818
2372
  };
1819
2373
  }
1820
2374
  if (!post) {
1821
2375
  return {
1822
2376
  check,
1823
2377
  status: "fail",
1824
- detail: `${path} registers \`approval hook\` for PreToolUse and not for PostToolUse, so no tool call ever reports an outcome: every harness execution.started stays delegated, and the loop escalation of SPEC.md §10.2 cannot accrue on this path`,
2378
+ detail: `${path} registers \`approval hook claude-code\` for PreToolUse and not for PostToolUse, so no tool call ever reports an outcome: every harness execution.started stays delegated, and the loop escalation of SPEC.md §10.2 cannot accrue on this path`,
1825
2379
  fix: "approval hook claude-code --help — prints the PostToolUse entry to add, which a human commits (.claude/settings.json is policy.core)",
1826
2380
  };
1827
2381
  }
1828
2382
  return {
1829
2383
  check,
1830
2384
  status: "pass",
1831
- detail: `${path} registers \`approval hook\` for the ${pre ? "pre-execution and " : ""}post-execution event, so tool call outcomes reach the log and loop escalation can accrue`,
2385
+ detail: `${path} registers \`approval hook claude-code\` for the ${pre ? "pre-execution and " : ""}post-execution event, so tool call outcomes reach the log and loop escalation can accrue`,
1832
2386
  };
1833
2387
  }
1834
2388
  // ---------------------------------------------------------------------------
1835
2389
  // harness hook wiring in THIS worktree (APRV-151)
1836
2390
  // ---------------------------------------------------------------------------
1837
- /** The tool names a protected-path write can arrive as. */
1838
- const GATED_TOOLS = ["Edit", "Write", "Bash"];
1839
- /** The `matcher` strings of every `approval hook` entry registered for `event`. */
1840
- function approvalHookMatchers(hooks, event) {
1841
- if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
1842
- return [];
1843
- const matchers = hooks[event];
1844
- if (!Array.isArray(matchers))
1845
- return [];
1846
- const found = [];
1847
- for (const matcher of matchers) {
1848
- if (typeof matcher !== "object" || matcher === null)
2391
+ /** The repair every wiring verdict but the two absences points at. */
2392
+ const HOOK_ENTRY_FIX = "approval instructions hook — prints the PreToolUse entry a human commits";
2393
+ /** A tool name as a `matcher` may spell it: no regex syntax anywhere in it. */
2394
+ const MATCHER_TOOL_NAME = /^[A-Za-z0-9_-]+$/u;
2395
+ /**
2396
+ * The tools a `matcher` names, or `"pattern"` when it is not a plain list
2397
+ * (APRV-408).
2398
+ *
2399
+ * Claude Code treats `matcher` as a regular expression, and this file will not
2400
+ * evaluate one: a regex is answered by matching it against candidate strings,
2401
+ * and the candidates here are "every tool name that exists", which is not a set
2402
+ * this runtime holds. So the only shape read is the documented one — tool names
2403
+ * joined by `|` — and everything else is reported as unresolved coverage rather
2404
+ * than guessed at. An empty matcher is `"pattern"` too: it selects every tool
2405
+ * in Claude Code, which is a coverage claim this row would rather see written
2406
+ * down than infer.
2407
+ *
2408
+ * Nothing here compiles the pattern, so a hostile one (`(a+)+$` and its kin)
2409
+ * costs one anchored character-class test per alternative and cannot make
2410
+ * `doctor` hang.
2411
+ */
2412
+ function matcherTools(pattern) {
2413
+ if (pattern.trim().length === 0)
2414
+ return "pattern";
2415
+ const parts = pattern.split("|");
2416
+ if (parts.some((part) => !MATCHER_TOOL_NAME.test(part)))
2417
+ return "pattern";
2418
+ return parts;
2419
+ }
2420
+ /** `realpathSync` that answers the path itself when it cannot be resolved. */
2421
+ function realOrSelf(path) {
2422
+ try {
2423
+ return realpathSync(path);
2424
+ }
2425
+ catch {
2426
+ return path;
2427
+ }
2428
+ }
2429
+ /**
2430
+ * Which checkout the registered handler resolves policy and log from
2431
+ * (APRV-408).
2432
+ *
2433
+ * The committed entry in this repository hardcodes one absolute `--dir`, which
2434
+ * is correct on the machine it was written on and names a directory that does
2435
+ * not exist in a clone anywhere else; nothing checked. A handler bound to
2436
+ * another checkout is not a weaker gate, it is a DIFFERENT gate: another
2437
+ * policy, another log, another open window. That is the one thing this row
2438
+ * fails on.
2439
+ *
2440
+ * `primaryRoot` first and `repoRoot` as the fallback, because the documented
2441
+ * `--dir` names the PRIMARY checkout: in a linked worktree the two differ, and
2442
+ * the primary is the one the gate must answer from.
2443
+ */
2444
+ function describeBinding(entries, dir, fallbackRoot) {
2445
+ const primary = primaryRoot(dir);
2446
+ const expected = primary ?? fallbackRoot;
2447
+ const expectedName = primary === null ? "repository root" : "primary root";
2448
+ const rank = { pass: 0, skip: 1, fail: 2 };
2449
+ const reports = [];
2450
+ for (const entry of entries) {
2451
+ const parse = entry.parse;
2452
+ // `claudeHookEntries` keeps no foreign entry, and the narrowing is written
2453
+ // out anyway: a reader of this loop should not have to hold that invariant.
2454
+ if (parse.kind === "foreign")
1849
2455
  continue;
1850
- const entries = matcher["hooks"];
1851
- if (!Array.isArray(entries))
2456
+ if (parse.kind === "unresolved") {
2457
+ reports.push({
2458
+ status: "skip",
2459
+ clause: `BINDING UNRESOLVED: the PreToolUse command ${JSON.stringify(entry.command)} cannot be read as an \`approval hook claude-code\` invocation (${parse.reason}), so which checkout it would gate is not established here — and unresolved is never a pass.`,
2460
+ });
2461
+ continue;
2462
+ }
2463
+ const declared = parse.dir;
2464
+ if (declared === null) {
2465
+ reports.push({
2466
+ status: "skip",
2467
+ clause: `BINDING UNSTATED: the PreToolUse command names no \`--dir\`, so the hook resolves its policy and log from wherever the harness happens to run it (APRV-101) rather than from a directory this row can read. The ${expectedName} here is ${expected ?? "unknown to git"}.`,
2468
+ });
2469
+ continue;
2470
+ }
2471
+ if (!isAbsolute(declared)) {
2472
+ reports.push({
2473
+ status: "skip",
2474
+ clause: `BINDING RELATIVE: the PreToolUse command binds \`--dir ${declared}\`, which names a different directory from every working directory it could be launched in. The ${expectedName} here is ${expected ?? "unknown to git"}.`,
2475
+ });
2476
+ continue;
2477
+ }
2478
+ if (expected === null) {
2479
+ reports.push({
2480
+ status: "skip",
2481
+ clause: `BINDING UNCHECKED: the PreToolUse command binds \`--dir ${declared}\`, and git could not say what checkout ${dir} belongs to, so the two cannot be compared here.`,
2482
+ });
1852
2483
  continue;
1853
- for (const entry of entries) {
1854
- if (typeof entry !== "object" || entry === null)
1855
- continue;
1856
- const command = entry["command"];
1857
- if (typeof command !== "string" || !/\bapproval hook\b/u.test(command))
1858
- continue;
1859
- const pattern = matcher["matcher"];
1860
- found.push(typeof pattern === "string" ? pattern : "");
1861
2484
  }
2485
+ if (realOrSelf(declared) !== realOrSelf(expected)) {
2486
+ reports.push({
2487
+ status: "fail",
2488
+ clause: `MISBOUND: the PreToolUse command binds \`--dir ${declared}\`, and this checkout's ${expectedName} is ${expected}. A hook answering from another checkout answers another checkout's policy, log and open window, so a call gated here is not gated by the gate a reader of this repository is looking at.`,
2489
+ });
2490
+ continue;
2491
+ }
2492
+ reports.push({
2493
+ status: "pass",
2494
+ clause: `BOUND to ${expected}, this checkout's ${expectedName}, by the \`--dir\` the PreToolUse command carries.`,
2495
+ });
1862
2496
  }
1863
- return found;
2497
+ if (reports.length === 0) {
2498
+ return { status: "skip", clause: "BINDING UNRESOLVED: no PreToolUse command to read." };
2499
+ }
2500
+ const status = reports.reduce((worst, report) => (rank[report.status] > rank[worst] ? report.status : worst), "pass");
2501
+ const clauses = [...new Set(reports.map((report) => report.clause))];
2502
+ return { status, clause: clauses.join(" ") };
1864
2503
  }
1865
2504
  /**
1866
2505
  * Does the settings file THIS worktree carries register the pre-execution hook
@@ -1884,11 +2523,25 @@ function approvalHookMatchers(hooks, event) {
1884
2523
  * CI-side, over the committed log, in `core/protected-path-guard.ts`: it does
1885
2524
  * not trust session wiring, and this row does not claim to establish it.
1886
2525
  *
1887
- * Advisory, so it never fails the run. Doctor reads and never writes; the file
1888
- * is `policy.edit` and its repair is a line for a human to commit.
2526
+ * Advisory, so it never fails the run — with the single exception APRV-408
2527
+ * added: a handler POSITIVELY bound to a different checkout. That is not a
2528
+ * weaker report of the same gate, it is a statement that the entry on disk
2529
+ * answers from somewhere else, which this row can establish from the file
2530
+ * alone. Everything else it learned in APRV-408 only ADDS lines from disk
2531
+ * facts: the gated roster comes from the adapter rather than a hand list, the
2532
+ * read tools the adapter declares and the matcher leaves out are named as the
2533
+ * documented default they are, and a matcher tool the adapter does not handle
2534
+ * is named as the `allow` it will actually receive. None of those feeds a
2535
+ * verdict, and none of them asks the session anything about itself (SPEC §11:
2536
+ * a self-reported field never reduces scrutiny, and this row reads no
2537
+ * self-report at all).
2538
+ *
2539
+ * Doctor reads and never writes; the file is `policy.core` and its repair is a
2540
+ * line for a human to commit.
1889
2541
  */
1890
2542
  function checkHarnessWiring(dir) {
1891
2543
  const check = "harness-hook-wiring";
2544
+ const gated = gatedClaudeTools();
1892
2545
  const root = repoRoot(dir);
1893
2546
  const where = root === null ? dir : root;
1894
2547
  const path = join(where, CLAUDE_SETTINGS);
@@ -1922,29 +2575,72 @@ function checkHarnessWiring(dir) {
1922
2575
  const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
1923
2576
  ? parsed["hooks"]
1924
2577
  : null;
1925
- const matchers = approvalHookMatchers(hooks, "PreToolUse");
1926
- if (matchers.length === 0) {
2578
+ const entries = claudeHookEntries(hooks, "PreToolUse");
2579
+ if (entries.length === 0) {
1927
2580
  return {
1928
2581
  check,
1929
2582
  status: "skip",
1930
- detail: `NOT WIRED: ${path} registers no \`approval hook\` entry for PreToolUse, so a protected-path Edit, Write or Bash call in this checkout reaches the file system unclassified.`,
1931
- fix: "approval instructions hook — prints the PreToolUse entry a human commits",
2583
+ detail: `NOT WIRED: ${path} registers no \`approval hook claude-code\` entry for PreToolUse, so a protected-path write arriving through ${gated.join(", ")} in this checkout reaches the file system unclassified.`,
2584
+ fix: HOOK_ENTRY_FIX,
1932
2585
  };
1933
2586
  }
1934
- const covered = GATED_TOOLS.filter((tool) => matchers.some((pattern) => pattern.length === 0 || pattern.split("|").includes(tool)));
1935
- const missing = GATED_TOOLS.filter((tool) => !covered.includes(tool));
2587
+ const matchers = entries.map((entry) => entry.matcher);
2588
+ const named = new Set();
2589
+ const patterns = [];
2590
+ for (const matcher of matchers) {
2591
+ const tools = matcherTools(matcher);
2592
+ if (tools === "pattern") {
2593
+ patterns.push(matcher);
2594
+ continue;
2595
+ }
2596
+ for (const tool of tools)
2597
+ named.add(tool);
2598
+ }
2599
+ const binding = describeBinding(entries, dir, root);
2600
+ const wired = `${path} registers \`approval hook claude-code\` for PreToolUse with matcher ${JSON.stringify(matchers.join(", "))}`;
2601
+ const notProof = `This is the file being present, NOT proof this session loaded it — the APRV-151 bypasses happened in worktrees carrying exactly this entry. The check that does not trust session wiring is the CI-side grant cross-check over the committed log, which asks whether the CHANGE was granted rather than whether the path ever was (APRV-202).`;
2602
+ // A matcher this file will not read leaves every coverage question open, so
2603
+ // the roster, read-tool and passthrough lines are withheld rather than
2604
+ // computed against a set that may be incomplete. The binding is independent
2605
+ // of the matcher and is still reported (and can still fail).
2606
+ if (patterns.length > 0) {
2607
+ return {
2608
+ check,
2609
+ status: binding.status === "fail" ? "fail" : "skip",
2610
+ detail: `MATCHED BY PATTERN: ${wired}. ${patterns
2611
+ .map((pattern) => JSON.stringify(pattern))
2612
+ .join(" and ")} is not a plain \`|\`-joined list of tool names, and this row does not evaluate a regular expression, so which of ${gated.join(", ")} it covers is not resolved here. ${binding.clause} ${notProof}`,
2613
+ fix: HOOK_ENTRY_FIX,
2614
+ };
2615
+ }
2616
+ const missing = gated.filter((tool) => !named.has(tool));
1936
2617
  if (missing.length > 0) {
1937
2618
  return {
1938
2619
  check,
1939
- status: "skip",
1940
- detail: `NOT WIRED for every tool: ${path} registers \`approval hook\` for PreToolUse with matcher ${JSON.stringify(matchers.join(", "))}, which does not cover ${missing.join(", ")}. A protected-path write arriving through ${missing[0]} is never classified.`,
1941
- fix: "approval instructions hook — prints the PreToolUse entry a human commits",
2620
+ status: binding.status === "fail" ? "fail" : "skip",
2621
+ detail: `NOT WIRED for every tool: ${wired}, which does not cover ${missing.join(", ")}. This runtime's Claude Code adapter gates ${gated.join(", ")}, so a protected-path write arriving through ${missing[0]} is never classified. ${binding.clause}`,
2622
+ fix: HOOK_ENTRY_FIX,
1942
2623
  };
1943
2624
  }
2625
+ // Informational, both of them, and neither is ever a verdict.
2626
+ const held = CLAUDE_HOOK_ADAPTER.readTools.filter((tool) => !named.has(tool));
2627
+ const readLine = held.length === 0
2628
+ ? ""
2629
+ : ` READ TOOLS HELD BY DESIGN: the adapter also knows ${held.join(", ")}, and the matcher leaves ${held.length === 1 ? "it" : "them"} out, so a read is answered without reaching the gate. That is the documented default rather than a gap — see "The read scope" in docs/claude-code-hook.md, where the \`Bash|Edit|Write|MultiEdit|NotebookEdit|Read|Glob|Grep\` matcher is printed and deliberately left out of the installed one, because a read is the most frequent tool call a session makes and every matched call is a Node process start. This line never fails this row.`;
2630
+ const handled = new Set([
2631
+ ...gated,
2632
+ ...CLAUDE_HOOK_ADAPTER.readTools,
2633
+ ...(CLAUDE_HOOK_ADAPTER.passThroughTools ?? []),
2634
+ ]);
2635
+ const unhandled = [...named].filter((tool) => !handled.has(tool));
2636
+ const passthroughLine = unhandled.length === 0
2637
+ ? ""
2638
+ : ` MATCHED AND NOT CLASSIFIED: ${unhandled.join(", ")} ${unhandled.length === 1 ? "is" : "are"} in the matcher and this runtime's Claude Code adapter handles ${unhandled.length === 1 ? "it" : "them"} as neither a shell, file nor read tool, so every such call spawns the hook and is answered \`allow\` with "is not a gated tool". The cost is real and the coverage is not.`;
1944
2639
  return {
1945
2640
  check,
1946
- status: "pass",
1947
- detail: `WIRED on disk: ${path} registers \`approval hook\` for PreToolUse over ${GATED_TOOLS.join(", ")}. This is the file being present, NOT proof this session loaded it — the APRV-151 bypasses happened in worktrees carrying exactly this entry. The check that does not trust session wiring is the CI-side grant cross-check over the committed log, which asks whether the CHANGE was granted rather than whether the path ever was (APRV-202).`,
2641
+ status: binding.status,
2642
+ detail: `WIRED on disk: ${path} registers \`approval hook claude-code\` for PreToolUse over ${gated.join(", ")}, the tools this runtime's own adapter gates. ${binding.clause}${readLine}${passthroughLine} ${notProof}`,
2643
+ ...(binding.status === "pass" ? {} : { fix: HOOK_ENTRY_FIX }),
1948
2644
  };
1949
2645
  }
1950
2646
  /** The project-local Codex hook files doctor can observe without asking Codex. */
@@ -2060,10 +2756,138 @@ export function checkCodexHookWiring(dir) {
2060
2756
  // ---------------------------------------------------------------------------
2061
2757
  /** The Cursor counterpart of {@link CLAUDE_SETTINGS}. */
2062
2758
  const CURSOR_HOOKS = join(".cursor", "hooks.json");
2063
- /** Where a harness hook registration can be written, one file per harness. */
2064
- const HARNESS_SETTINGS = [CLAUDE_SETTINGS, CURSOR_HOOKS, CODEX_HOOKS];
2065
- /** `approval hook <kind>` inside a command string, whichever file shape holds it. */
2066
- const HOOK_COMMAND = /\bapproval["']?\s+hook\s+(claude-code|cursor|codex)\b/u;
2759
+ /**
2760
+ * The environment variable Hermes Agent resolves its own configuration root
2761
+ * from, and the default it falls back to (APRV-398).
2762
+ *
2763
+ * Read here for ONE purpose: to know where to look for a registration this
2764
+ * checkout cannot hold. Hermes documents no project-local configuration
2765
+ * directory, so a repository-relative search for a Hermes hook would be empty on
2766
+ * every machine and the row would never be able to say anything true.
2767
+ *
2768
+ * Reading an environment variable is acceptable here in the narrow way it is
2769
+ * acceptable in `sandboxRequirement`: it moves where a DIAGNOSTIC looks, and the
2770
+ * row it feeds can only SKIP when it finds nothing and only ADD a line when it
2771
+ * finds something. Nothing an agent can set makes a red line go away, which is
2772
+ * the only property §11.1 invariant 4 asks of a self-reported input.
2773
+ */
2774
+ const HERMES_HOME_ENV = "HERMES_HOME";
2775
+ const HERMES_HOME_DEFAULT = ".hermes";
2776
+ /**
2777
+ * Where each harness's `approval hook` registration can be written.
2778
+ *
2779
+ * A `Record<HarnessKind, …>` since APRV-398, and the type is the point, exactly
2780
+ * as it is for `HARNESS_ADAPTERS`: a kind added to {@link HARNESS_KINDS} with no
2781
+ * entry here fails to compile, and `tests/harness-enum.test.ts` pins the keys
2782
+ * set-equal to that list. Before this it was a flat array of three paths and a
2783
+ * regex naming three kinds, so `grok` and `muse` shipped adapters that this row
2784
+ * could not see — the same class of miss APRV-358 was filed for, in the one
2785
+ * place APRV-358 did not reach.
2786
+ *
2787
+ * An empty list is a statement rather than a gap, and `codex` has one on
2788
+ * purpose: `.codex/hooks.json` is already read by the Codex-specific rows above
2789
+ * ({@link CODEX_HOOKS}), and this list is consulted by `registeredHarnesses`,
2790
+ * whose behaviour for the three harnesses that were already in it must not
2791
+ * change by a byte in this task. Codex is therefore listed with the file it
2792
+ * always had and nothing new.
2793
+ */
2794
+ export const HARNESS_SETTINGS = {
2795
+ "claude-code": [{ file: CLAUDE_SETTINGS }],
2796
+ cursor: [{ file: CURSOR_HOOKS }],
2797
+ codex: [{ file: CODEX_HOOKS }],
2798
+ // APRV-243: Grok Build installs a PreToolUse entry per file under
2799
+ // `.grok/hooks/`, so the directory is scanned rather than one name guessed.
2800
+ // It also READS `.claude/settings.json` and `.cursor/hooks.json` for
2801
+ // compatibility, and both of those are already scanned above; a Grok session
2802
+ // hosted by this repository's claude-code entry is the hazard
2803
+ // `docs/grok-hook.md` opens with, not a registration this row should claim.
2804
+ grok: [{ dir: join(".grok", "hooks") }],
2805
+ // APRV-350, observed: the installed build reads `.muse/hooks.json`.
2806
+ // `.muse/settings.json` is listed for the reason the classifier lists it —
2807
+ // Meta documents a user-level `settings.json` `hooks` block, so a
2808
+ // project-level copy would be the same organ under a second name — and an
2809
+ // entry for a file Muse does not read costs a miss rather than a false row.
2810
+ muse: [{ file: join(".muse", "hooks.json") }, { file: join(".muse", "settings.json") }],
2811
+ // APRV-398: not in the checkout at all. See {@link HERMES_HOME_ENV}.
2812
+ hermes: [
2813
+ { home: "hermes", file: "config.yaml", text: true },
2814
+ { home: "hermes", file: "config.yml", text: true },
2815
+ ],
2816
+ };
2817
+ /**
2818
+ * `approval hook <kind>` inside a command string, whichever file shape holds it.
2819
+ *
2820
+ * DERIVED from {@link HARNESS_KINDS} since APRV-398 rather than spelled, so a
2821
+ * new adapter cannot ship with the pattern left behind — which is what happened
2822
+ * to `grok` and `muse`. Longest alternative first: the alternation is tried left
2823
+ * to right, and a kind that is a prefix of another kind would otherwise match
2824
+ * the shorter spelling and leave the rest of the name outside the capture.
2825
+ */
2826
+ const HOOK_COMMAND = new RegExp(`\\bapproval["']?\\s+hook\\s+(${[...HARNESS_KINDS]
2827
+ .sort((left, right) => right.length - left.length)
2828
+ .join("|")})\\b`, "u");
2829
+ /**
2830
+ * The places {@link registeredHarnesses} looked, for the row that found nothing.
2831
+ *
2832
+ * Every location of every kind, in kind order, so the line a reader gets names
2833
+ * the file they have to write rather than three of the six. A home-resolved
2834
+ * location is printed with its `$HERMES_HOME/` prefix rather than expanded: the
2835
+ * expansion is this machine's answer, and the instruction is the general one.
2836
+ */
2837
+ function harnessSettingNames() {
2838
+ return HARNESS_KINDS.flatMap((kind) => HARNESS_SETTINGS[kind].map((location) => {
2839
+ const name = location.file ?? `${location.dir ?? "?"}/`;
2840
+ return location.home === "hermes" ? `$HERMES_HOME/${name}` : name;
2841
+ })).join(" or ");
2842
+ }
2843
+ /**
2844
+ * A fresh copy of {@link HOOK_COMMAND}, for the suite that pins it (APRV-398).
2845
+ *
2846
+ * A function rather than the constant, so a caller cannot carry state into this
2847
+ * module's own matching: the pattern is not global today, and a test that shared
2848
+ * the object would silently break on the day somebody adds a flag to it.
2849
+ */
2850
+ export function hookCommandPattern() {
2851
+ return new RegExp(HOOK_COMMAND.source, HOOK_COMMAND.flags);
2852
+ }
2853
+ /** Hermes's configuration root, from the environment or its documented default. */
2854
+ function hermesHome(env = process.env) {
2855
+ const declared = env[HERMES_HOME_ENV];
2856
+ if (typeof declared === "string" && declared.trim() !== "")
2857
+ return declared;
2858
+ return join(homedir(), HERMES_HOME_DEFAULT);
2859
+ }
2860
+ /** Every file a location names that exists on disk, absolute. */
2861
+ function settingFiles(dir, location) {
2862
+ const base = location.home === "hermes" ? hermesHome() : dir;
2863
+ if (location.file !== undefined) {
2864
+ const path = join(base, location.file);
2865
+ return existsSync(path) ? [path] : [];
2866
+ }
2867
+ if (location.dir === undefined)
2868
+ return [];
2869
+ let names;
2870
+ try {
2871
+ names = readdirSync(join(base, location.dir));
2872
+ }
2873
+ catch {
2874
+ return [];
2875
+ }
2876
+ const found = [];
2877
+ for (const name of names.sort()) {
2878
+ if (location.prefix !== undefined && !name.startsWith(location.prefix))
2879
+ continue;
2880
+ const path = join(base, location.dir, name);
2881
+ try {
2882
+ if (statSync(path).isFile())
2883
+ found.push(path);
2884
+ }
2885
+ catch {
2886
+ continue;
2887
+ }
2888
+ }
2889
+ return found;
2890
+ }
2067
2891
  /**
2068
2892
  * Every harness this checkout registers an `approval hook` command for.
2069
2893
  *
@@ -2078,35 +2902,52 @@ const HOOK_COMMAND = /\bapproval["']?\s+hook\s+(claude-code|cursor|codex)\b/u;
2078
2902
  */
2079
2903
  export function registeredHarnesses(dir) {
2080
2904
  const found = new Set();
2081
- for (const relative of HARNESS_SETTINGS) {
2082
- const path = join(dir, relative);
2083
- if (!existsSync(path))
2084
- continue;
2085
- let parsed;
2086
- try {
2087
- parsed = JSON.parse(readFileSync(path, "utf8"));
2088
- }
2089
- catch {
2090
- continue;
2091
- }
2092
- const stack = [parsed];
2093
- while (stack.length > 0) {
2094
- const node = stack.pop();
2095
- if (typeof node === "string") {
2096
- const match = HOOK_COMMAND.exec(node);
2097
- if (match !== null &&
2098
- isHarnessKind(match[1]) &&
2099
- (match[1] !== "codex" || isDirectCodexHookCommand(node))) {
2100
- found.add(match[1]);
2905
+ const locations = HARNESS_KINDS.flatMap((kind) => HARNESS_SETTINGS[kind]);
2906
+ for (const location of locations) {
2907
+ for (const path of settingFiles(dir, location)) {
2908
+ let parsed;
2909
+ if (location.text === true) {
2910
+ // A YAML document has no JSON leaves to walk, so the whole text is the
2911
+ // haystack. Only the hermes location asks for this; the three that were
2912
+ // here before this task keep the JSON walk they have always had, which
2913
+ // is what keeps their behaviour byte-identical.
2914
+ let text;
2915
+ try {
2916
+ text = readFileSync(path, "utf8");
2917
+ }
2918
+ catch {
2919
+ continue;
2101
2920
  }
2921
+ const match = HOOK_COMMAND.exec(text);
2922
+ if (match !== null && isHarnessKind(match[1]))
2923
+ found.add(match[1]);
2102
2924
  continue;
2103
2925
  }
2104
- if (Array.isArray(node)) {
2105
- stack.push(...node);
2926
+ try {
2927
+ parsed = JSON.parse(readFileSync(path, "utf8"));
2928
+ }
2929
+ catch {
2106
2930
  continue;
2107
2931
  }
2108
- if (typeof node === "object" && node !== null) {
2109
- stack.push(...Object.values(node));
2932
+ const stack = [parsed];
2933
+ while (stack.length > 0) {
2934
+ const node = stack.pop();
2935
+ if (typeof node === "string") {
2936
+ const match = HOOK_COMMAND.exec(node);
2937
+ if (match !== null &&
2938
+ isHarnessKind(match[1]) &&
2939
+ (match[1] !== "codex" || isDirectCodexHookCommand(node))) {
2940
+ found.add(match[1]);
2941
+ }
2942
+ continue;
2943
+ }
2944
+ if (Array.isArray(node)) {
2945
+ stack.push(...node);
2946
+ continue;
2947
+ }
2948
+ if (typeof node === "object" && node !== null) {
2949
+ stack.push(...Object.values(node));
2950
+ }
2110
2951
  }
2111
2952
  }
2112
2953
  }
@@ -2178,6 +3019,20 @@ function recordedHarnessVersions(records) {
2178
3019
  * has nothing to compare against, and inventing a baseline would be inventing
2179
3020
  * the fact); and no such binary on PATH, since doctor may be running somewhere
2180
3021
  * the harness is not installed, which is a state and not a fault.
3022
+ *
3023
+ * ## The Hermes fail-closed floor rides in this row (APRV-415)
3024
+ *
3025
+ * One harness has a second version question, and it is graver than the first. A
3026
+ * Hermes below `HERMES_FAIL_CLOSED_FLOOR` does not know `fail_closed` and ignores
3027
+ * it silently, so every broken hook PROCEEDS and the session looks gated while it
3028
+ * is not. That belongs here rather than in a row of its own because both findings
3029
+ * answer one question — is the binary hosting this hook in a state where the gate
3030
+ * is known to fire — and a reader who sees this row green has been told both. It
3031
+ * is read from `<binary> --version` rather than from a record, because Hermes's
3032
+ * build stamp sits behind a non-ASCII separator that the write boundary refuses,
3033
+ * so no record can ever carry it. The comparison is on the BUILD DATE and the
3034
+ * upstream commit: the two builds that differ on this behaviour report the same
3035
+ * semver.
2181
3036
  */
2182
3037
  function checkHarnessVersion(dir, records) {
2183
3038
  const check = "harness-version-unverified";
@@ -2188,13 +3043,41 @@ function checkHarnessVersion(dir, records) {
2188
3043
  return {
2189
3044
  check,
2190
3045
  status: "skip",
2191
- detail: `${where} registers no \`approval hook\` command in ${HARNESS_SETTINGS.join(" or ")}, so no harness hosts the hook here and there is no installed version for the log to be behind`,
3046
+ detail: `${where} registers no \`approval hook\` command in ${harnessSettingNames()}, so no harness hosts the hook here and there is no installed version for the log to be behind`,
2192
3047
  };
2193
3048
  }
2194
3049
  const recorded = recordedHarnessVersions(records);
2195
3050
  const mismatched = [];
2196
3051
  const matched = [];
2197
3052
  const unknown = [];
3053
+ // APRV-415. The VERSION FLOOR, which is a different question from the rest of
3054
+ // this row and belongs to it for one reason: both ask whether the binary
3055
+ // hosting this hook is in a state where the gate is known to fire. The row
3056
+ // above asks whether the binary CHANGED without the gate being exercised
3057
+ // since; this asks whether the binary is old enough that a broken hook would
3058
+ // not have stopped anything in the first place.
3059
+ //
3060
+ // It is read off the INSTALLED build rather than off a record, and that is not
3061
+ // a shortcut: Hermes prints its build stamp behind a non-ASCII separator, so
3062
+ // the write boundary's printable-ASCII rule (SPEC.md §11.1 invariant 3) refuses
3063
+ // the whole line and no record ever carries a Hermes version. The floor would be
3064
+ // permanently unknown if it waited for one.
3065
+ const floor = [];
3066
+ const belowFloor = [];
3067
+ if (kinds.includes("hermes")) {
3068
+ const raw = installedHarnessVersionRaw("hermes");
3069
+ const support = hermesFailClosedSupport(raw);
3070
+ const quoted = raw === null ? "(no answer)" : JSON.stringify(raw);
3071
+ if (support === "ignores") {
3072
+ belowFloor.push(`the installed Hermes is BELOW the fail_closed floor: \`${HARNESS_BINARY["hermes"]} --version\` says ${quoted}, and the floor is ${HERMES_FAIL_CLOSED_FLOOR.statement}. On a build that does not know the key, a hook crash, a hook timeout and unparseable hook output all PROCEED, silently, so every gated tool call in this checkout is a backstop rather than a gate and nothing in the session says so`);
3073
+ }
3074
+ else if (support === "honours") {
3075
+ floor.push(`hermes ${quoted} is at or above the fail_closed floor (${HERMES_FAIL_CLOSED_FLOOR.statement}), so a broken hook blocks rather than proceeding`);
3076
+ }
3077
+ else {
3078
+ floor.push(`hermes: the fail_closed floor could not be established from ${quoted} (no build date and not the floor's own commit; two commits cannot be ordered without a repository), so check it by hand — the floor is ${HERMES_FAIL_CLOSED_FLOOR.statement}`);
3079
+ }
3080
+ }
2198
3081
  for (const kind of kinds) {
2199
3082
  const last = recorded.get(kind);
2200
3083
  if (last === undefined) {
@@ -2212,12 +3095,26 @@ function checkHarnessVersion(dir, records) {
2212
3095
  }
2213
3096
  mismatched.push(`${kind} is installed at ${JSON.stringify(installed)} and the last hook-written record (seq ${String(last.seq)}) was issued by ${JSON.stringify(last.version)}`);
2214
3097
  }
3098
+ // APRV-415: the floor is reported FIRST when it is breached, because it is the
3099
+ // graver of the two findings. An unverified change means nobody has checked
3100
+ // whether the gate still fires; a build below the floor means a broken hook does
3101
+ // not stop anything, which is established rather than unchecked.
3102
+ if (belowFloor.length > 0) {
3103
+ return {
3104
+ check,
3105
+ status: "fail",
3106
+ detail: `${belowFloor.join("; ")}${mismatched.length === 0
3107
+ ? ""
3108
+ : `. And the harness binary changed with no gate exercised since: ${mismatched.join("; ")}`}`,
3109
+ fix: `hermes update (or reinstall from main), then \`approval doctor\` again. The installed version is self-reported, so this row can only ask for a look: it never widens a verdict, and the adapter's own refusals are the same on every build.`,
3110
+ };
3111
+ }
2215
3112
  if (mismatched.length > 0) {
2216
3113
  const first = kinds[0];
2217
3114
  return {
2218
3115
  check,
2219
3116
  status: "fail",
2220
- detail: `the harness binary changed and the gate has not been exercised since: ${mismatched.join("; ")}. A release can change the hook envelope semantics, so until one record is written under the new binary nothing here shows the hook still fires. The recorded version is self-reported and reduces nothing: a match would not have proved the hook fired either, and what a mismatch says is that nobody has looked.`,
3117
+ detail: `the harness binary changed and the gate has not been exercised since: ${mismatched.join("; ")}. A release can change the hook envelope semantics, so until one record is written under the new binary nothing here shows the hook still fires. The recorded version is self-reported and reduces nothing: a match would not have proved the hook fired either, and what a mismatch says is that nobody has looked.${floor.length === 0 ? "" : ` Separately: ${floor.join("; ")}.`}`,
2221
3118
  fix: `approval hook ${first} --dir ${where} < one PreToolUse event for a supervised-class command — the self-test in docs/${first === "cursor" ? "cursor" : "claude-code"}-hook.md. It prompts nobody and writes one task.registered carrying the installed version.`,
2222
3119
  };
2223
3120
  }
@@ -2225,13 +3122,13 @@ function checkHarnessVersion(dir, records) {
2225
3122
  return {
2226
3123
  check,
2227
3124
  status: "pass",
2228
- detail: `${matched.join("; ")}${unknown.length === 0 ? "" : `; ${unknown.join("; ")}`}. A match is not proof the hook fired; it is the absence of the one thing this row can see, an unverified change of the binary hosting it.`,
3125
+ detail: `${matched.join("; ")}${unknown.length === 0 ? "" : `; ${unknown.join("; ")}`}. A match is not proof the hook fired; it is the absence of the one thing this row can see, an unverified change of the binary hosting it.${floor.length === 0 ? "" : ` ${floor.join("; ")}.`}`,
2229
3126
  };
2230
3127
  }
2231
3128
  return {
2232
3129
  check,
2233
3130
  status: "skip",
2234
- detail: `${where} registers ${kinds.join(", ")} and no comparison could be made: ${unknown.join("; ")}`,
3131
+ detail: `${where} registers ${kinds.join(", ")} and no comparison could be made: ${unknown.join("; ")}${floor.length === 0 ? "" : `. ${floor.join("; ")}`}`,
2235
3132
  };
2236
3133
  }
2237
3134
  // ---------------------------------------------------------------------------
@@ -2325,16 +3222,46 @@ function checkDarkSessions(logPath, dir, policyPath, records, verified) {
2325
3222
  * is reported); this list only says where to look, so a directory nobody uses
2326
3223
  * costs nothing and a file nobody named is not invented.
2327
3224
  */
2328
- const ORGAN_SEARCH = [
2329
- { dir: ".claude", prefix: "settings" },
2330
- { dir: ".cursor", prefix: "hooks.json" },
2331
- { dir: join(".cursor", "hooks") },
2332
- { dir: join(".cursor", "agents") },
2333
- ];
3225
+ export const ORGAN_SEARCH = {
3226
+ "claude-code": [{ dir: ".claude", prefix: "settings" }],
3227
+ cursor: [
3228
+ { dir: ".cursor", prefix: "hooks.json" },
3229
+ { dir: join(".cursor", "hooks") },
3230
+ { dir: join(".cursor", "agents") },
3231
+ ],
3232
+ // EMPTY ON PURPOSE, and this is the one entry in the record that is a decision
3233
+ // rather than a list. `.codex/config.toml` and `.codex/hooks.json` are organs
3234
+ // by `core/command-class.ts`'s own word, and adding them here would change
3235
+ // what the `gate-organs` row reports in every Codex checkout — a change
3236
+ // APRV-398 has no business making while adding a different harness. The row
3237
+ // they belong in is their own task. What this record buys meanwhile is that
3238
+ // the omission is now VISIBLE and typed rather than an absence nobody could
3239
+ // see: a reader of this table can tell that codex was considered.
3240
+ codex: [],
3241
+ // APRV-243 and APRV-350: the organs the classifier has recognised since those
3242
+ // tasks, which this list never learned about. `.grok/hooks/` and
3243
+ // `.muse/hooks.json` are where each harness's hook is installed, so a
3244
+ // hand-edited one nobody attested is exactly what this row exists to report.
3245
+ grok: [
3246
+ { dir: ".grok", prefix: "hooks.json" },
3247
+ { dir: join(".grok", "hooks") },
3248
+ ],
3249
+ muse: [{ dir: ".muse", prefix: "hooks" }, { dir: ".muse", prefix: "settings" }],
3250
+ // APRV-398. Hermes's real organ is `$HERMES_HOME/config.yaml` in the user
3251
+ // home, and this row is about THIS CHECKOUT, so what is listed is the
3252
+ // repository-relative directory the classifier would call an organ if a
3253
+ // checkout carried one. A directory nobody uses costs nothing (see the header),
3254
+ // and every candidate found here is put to the classifier before it is
3255
+ // reported, so nothing is invented. The home file is reached by the
3256
+ // harness-version row above, through {@link HARNESS_SETTINGS}.
3257
+ hermes: [{ dir: ".hermes", prefix: "config" }, { dir: join(".hermes", "agent-hooks") }],
3258
+ };
3259
+ /** Every organ directory to look in, across harnesses, in kind order. */
3260
+ const ORGAN_SEARCH_ENTRIES = HARNESS_KINDS.flatMap((kind) => ORGAN_SEARCH[kind]);
2334
3261
  /** The organ files this checkout actually carries, repository-relative, sorted. */
2335
3262
  function listGateOrgans(root) {
2336
3263
  const found = [];
2337
- for (const entry of ORGAN_SEARCH) {
3264
+ for (const entry of ORGAN_SEARCH_ENTRIES) {
2338
3265
  let names;
2339
3266
  try {
2340
3267
  names = readdirSync(join(root, entry.dir));
@@ -2379,6 +3306,78 @@ function listGateOrgans(root) {
2379
3306
  * configuration here, which is a state and not a fault, exactly as
2380
3307
  * `harness-hook-wiring` treats the same absence.
2381
3308
  */
3309
+ // ---------------------------------------------------------------------------
3310
+ // 30. codex-auto-reviewer (APRV-378)
3311
+ // ---------------------------------------------------------------------------
3312
+ /**
3313
+ * How recent a pre-empted question has to be for this row to FAIL.
3314
+ *
3315
+ * The same twenty-four hours `dark-sessions` watches, and for the same reason:
3316
+ * a row that failed forever over a fact an operator has already dealt with is a
3317
+ * row nobody reads. An older record is not forgotten, it is reported in the
3318
+ * detail of a passing row, which is where a durable fact belongs once the thing
3319
+ * it asks for has been done.
3320
+ */
3321
+ const AUTO_REVIEWER_WINDOW_MS = DEFAULT_DARK_WINDOW_MS;
3322
+ /**
3323
+ * Has something other than this gate answered a question this gate exists to
3324
+ * ask (APRV-378)?
3325
+ *
3326
+ * It reads `audit.question_preempted` and nothing else. There is no
3327
+ * configuration here to inspect: whether Codex's auto-reviewer runs is gated in
3328
+ * the harness's own configuration under a key this repository has no record of,
3329
+ * and a config read written against a guessed key finds nothing and reports a
3330
+ * GREEN row, which is the worst failure direction a health check has. So this
3331
+ * row reports what was OBSERVED, and the only durable record of an observation
3332
+ * is the one `approval codex bridge` appends when it stops.
3333
+ *
3334
+ * Reading this record is a diagnosis and not enforcement: the row authorizes
3335
+ * nothing and refuses nothing, and `tests/question-preempted.test.ts` pins that
3336
+ * no enforcement module reads the type.
3337
+ *
3338
+ * A row that says `pass` is saying the log holds no such record. It is NOT
3339
+ * saying the auto-reviewer is off: nothing on this machine can say that, which
3340
+ * is why the bridge probes rather than reads (APRV-364).
3341
+ */
3342
+ function checkCodexAutoReviewer(records, verified) {
3343
+ const check = "codex-auto-reviewer";
3344
+ if (!verified) {
3345
+ return {
3346
+ check,
3347
+ status: "skip",
3348
+ detail: "the chain did not verify, so the records this row reads cannot be trusted; see the log check above",
3349
+ };
3350
+ }
3351
+ const seen = records.filter((record) => record.event === "audit.question_preempted");
3352
+ if (seen.length === 0) {
3353
+ return {
3354
+ check,
3355
+ status: "pass",
3356
+ detail: "no record of anything other than this gate answering a question it exists to ask. That is what the log holds, and it is not a claim that a harness auto-reviewer is off: only a bridge session's own probe can establish that, and only for the question it probed",
3357
+ };
3358
+ }
3359
+ const newest = seen[seen.length - 1];
3360
+ const payload = (newest.payload ?? {});
3361
+ const question = (payload["question"] ?? {});
3362
+ const source = typeof payload["source"] === "string" ? payload["source"] : "an unnamed source";
3363
+ const id = typeof question["id"] === "string" ? question["id"] : "(no id)";
3364
+ const verdict = typeof payload["verdict"] === "string" ? payload["verdict"] : "none stated";
3365
+ const recent = seen.filter((record) => Date.parse(record.ts) >= Date.now() - AUTO_REVIEWER_WINDOW_MS);
3366
+ const where = `${source} answered question ${id} (verdict: ${verdict}) at seq ${String(newest.seq)}`;
3367
+ if (recent.length === 0) {
3368
+ return {
3369
+ check,
3370
+ status: "pass",
3371
+ detail: `${String(seen.length)} record(s), none in the last ${String(Math.round(AUTO_REVIEWER_WINDOW_MS / 3_600_000))}h. The most recent: ${where}`,
3372
+ };
3373
+ }
3374
+ return {
3375
+ check,
3376
+ status: "fail",
3377
+ detail: `${String(recent.length)} of ${String(seen.length)} record(s) in the last ${String(Math.round(AUTO_REVIEWER_WINDOW_MS / 3_600_000))}h: ${where}. A session whose questions something else can answer is a session whose silence means nothing, and the bridge stopped on it`,
3378
+ fix: "approval doctor --verbose, then turn the harness's own auto-reviewer off in its configuration before running `approval codex bridge` again; this runtime cannot turn it off and cannot read whether it is on",
3379
+ };
3380
+ }
2382
3381
  function checkGateOrgans(dir, records) {
2383
3382
  const check = "gate-organs";
2384
3383
  const root = repoRoot(dir) ?? dir;
@@ -2387,7 +3386,7 @@ function checkGateOrgans(dir, records) {
2387
3386
  return {
2388
3387
  check,
2389
3388
  status: "skip",
2390
- detail: `${root} carries no gate organ files (${ORGAN_SEARCH.map((entry) => entry.dir).join(", ")}), so there is nothing here for a human to have attested`,
3389
+ detail: `${root} carries no gate organ files (${ORGAN_SEARCH_ENTRIES.map((entry) => entry.dir).join(", ")}), so there is nothing here for a human to have attested`,
2391
3390
  };
2392
3391
  }
2393
3392
  const unattested = [];
@@ -2432,6 +3431,167 @@ function checkGateOrgans(dir, records) {
2432
3431
  };
2433
3432
  }
2434
3433
  // ---------------------------------------------------------------------------
3434
+ // 31. pending-sign-off (APRV-338)
3435
+ // ---------------------------------------------------------------------------
3436
+ /**
3437
+ * The marker SPEC.md's amendment-provenance rule puts on unratified text.
3438
+ *
3439
+ * Matched case-insensitively on the words alone rather than on the whole
3440
+ * `(Amended APRV-n, pending sign-off.)` form, because the form is prose a human
3441
+ * writes and a row that missed a hand-typed variant would report a clean file
3442
+ * that is not one. Over-reporting here costs a line of output; under-reporting
3443
+ * costs the thing the marker exists for.
3444
+ */
3445
+ const PENDING_SIGN_OFF_MARKER = /pending sign-off/giu;
3446
+ /**
3447
+ * Where to look for protected files carrying the marker.
3448
+ *
3449
+ * One level deep and enumerated, exactly as {@link ORGAN_SEARCH} is, and for
3450
+ * the same reason: `core/command-class.ts` decides what IS a protected path
3451
+ * (every candidate found here is put to it), so this list only says where to
3452
+ * look. A repository-wide walk would be a health check that reads the whole
3453
+ * working tree on every run, and the marker lives in governing documents, which
3454
+ * sit at the root or in a directory a policy named.
3455
+ */
3456
+ const SIGN_OFF_SEARCH_DIRS = [".", join(".github", "workflows"), "docs", "design"];
3457
+ /** How many bytes of a candidate are read before it is skipped as not-prose. */
3458
+ const SIGN_OFF_MAX_BYTES = 4 * 1024 * 1024;
3459
+ /**
3460
+ * The protected files this checkout carries that doctor will read,
3461
+ * repository-relative and sorted.
3462
+ *
3463
+ * The enumerated directories above, plus every exact path the policy's
3464
+ * `protected_paths` names (a directory entry is enumerated one level deep like
3465
+ * the rest). A candidate is kept only when the classifier calls it `policy.edit`
3466
+ * or a `policy.edit.*` sub-class, which is the same set `approval policy attest
3467
+ * --path` will sign off: a row that listed a file the verb refuses would be
3468
+ * advice nobody can take.
3469
+ */
3470
+ function listSignableFiles(root, extra) {
3471
+ const dirs = new Set(SIGN_OFF_SEARCH_DIRS);
3472
+ const exact = new Set();
3473
+ for (const raw of extra) {
3474
+ const entry = typeof raw === "string" ? raw : raw.path;
3475
+ if (typeof entry !== "string" || entry.length === 0)
3476
+ continue;
3477
+ if (entry.includes("..") || isAbsolute(entry))
3478
+ continue;
3479
+ if (/[/\\]$/u.test(entry))
3480
+ dirs.add(entry.replace(/[/\\]+$/u, ""));
3481
+ else
3482
+ exact.add(entry.split(/[/\\]+/u).join("/"));
3483
+ }
3484
+ const found = new Set();
3485
+ const keep = (relative) => {
3486
+ let isFile;
3487
+ try {
3488
+ const stats = statSync(join(root, relative));
3489
+ isFile = stats.isFile() && stats.size <= SIGN_OFF_MAX_BYTES;
3490
+ }
3491
+ catch {
3492
+ return;
3493
+ }
3494
+ if (!isFile)
3495
+ return;
3496
+ const routed = protectedPathClass(relative, extra);
3497
+ if (routed === "policy.edit" || (routed !== null && POLICY_EDIT_SUBCLASS.test(routed))) {
3498
+ found.add(relative);
3499
+ }
3500
+ };
3501
+ for (const dir of dirs) {
3502
+ let names;
3503
+ try {
3504
+ names = readdirSync(join(root, dir));
3505
+ }
3506
+ catch {
3507
+ continue;
3508
+ }
3509
+ const prefix = dir === "." ? "" : `${dir.split(/[/\\]+/u).join("/")}/`;
3510
+ for (const name of names.sort())
3511
+ keep(`${prefix}${name}`);
3512
+ }
3513
+ for (const path of exact)
3514
+ keep(path);
3515
+ return [...found].sort();
3516
+ }
3517
+ /**
3518
+ * Which protected files carry a pending-sign-off marker that no record
3519
+ * resolves (APRV-338)?
3520
+ *
3521
+ * SPEC.md's amendment-provenance rule says text that reached a protected file
3522
+ * without a grant carries `(Amended APRV-n, pending sign-off.)` and holds no
3523
+ * more authority than a proposal until a human ratifies it. Since APRV-338 the
3524
+ * ratification is a record, `gate.path.signed_off`, so the state is finally
3525
+ * answerable: a file whose CURRENT bytes carry a sign-off is ratified as it
3526
+ * stands, and one that carries the marker with no such record is a debt
3527
+ * somebody owes.
3528
+ *
3529
+ * **This row never moves the exit code**, for the reasons `gate-organs` does
3530
+ * not. Nothing on this machine is broken by unratified prose; what the row buys
3531
+ * is that the debt is visible at the terminal rather than discovered when a
3532
+ * pull request fails. And a marker is a human's own sentence about their own
3533
+ * text: a health check that failed on one would be the runtime grading prose.
3534
+ */
3535
+ function checkPendingSignOff(dir, records, policyLoad) {
3536
+ const check = "pending-sign-off";
3537
+ const root = repoRoot(dir) ?? dir;
3538
+ // A policy that did not load contributes no entries, which narrows the set
3539
+ // doctor reads rather than widening it — the same direction every other
3540
+ // reader of `protected_paths` takes when it cannot have the list.
3541
+ const extra = policyLoad.ok ? (policyLoad.policy.protected_paths ?? []) : [];
3542
+ const files = listSignableFiles(root, extra);
3543
+ const pending = [];
3544
+ const ratified = [];
3545
+ const unreadable = [];
3546
+ for (const file of files) {
3547
+ let text;
3548
+ let sha256;
3549
+ try {
3550
+ const bytes = readFileSync(join(root, file));
3551
+ text = bytes.toString("utf8");
3552
+ sha256 = policyBytesHash(bytes);
3553
+ }
3554
+ catch (cause) {
3555
+ unreadable.push(`${file} (${detailOf(cause)})`);
3556
+ continue;
3557
+ }
3558
+ const markers = text.match(PENDING_SIGN_OFF_MARKER)?.length ?? 0;
3559
+ if (markers === 0)
3560
+ continue;
3561
+ const plural = markers === 1 ? "" : "s";
3562
+ if (findPathSignOff(records, file, sha256) !== null) {
3563
+ ratified.push(`${file} (${String(markers)} marker${plural}, signed off at these bytes)`);
3564
+ continue;
3565
+ }
3566
+ const previous = latestPathSignOff(records, file);
3567
+ pending.push(previous === null
3568
+ ? `${file} (${String(markers)} marker${plural}, never signed off, live ${sha256.slice(0, 12)}…)`
3569
+ : `${file} (${String(markers)} marker${plural}, edited since seq ${previous.record.seq}: signed ${previous.sha256.slice(0, 12)}…, live ${sha256.slice(0, 12)}…)`);
3570
+ }
3571
+ if (pending.length === 0 && unreadable.length === 0) {
3572
+ return {
3573
+ check,
3574
+ status: "pass",
3575
+ detail: ratified.length === 0
3576
+ ? `no protected file in ${root} carries a pending-sign-off marker (${String(files.length)} file(s) read)`
3577
+ : `every pending-sign-off marker in ${root} sits in a file a human has signed off at its current bytes: ${ratified.join("; ")}`,
3578
+ };
3579
+ }
3580
+ const parts = [];
3581
+ if (pending.length > 0)
3582
+ parts.push(`AWAITING SIGN-OFF: ${pending.join("; ")}`);
3583
+ if (unreadable.length > 0)
3584
+ parts.push(`unreadable: ${unreadable.join("; ")}`);
3585
+ const first = (pending[0] ?? "<path>").split(" ")[0] ?? "<path>";
3586
+ return {
3587
+ check,
3588
+ // Never a fail: see the note above.
3589
+ status: "skip",
3590
+ detail: `${parts.join(". ")}. Text carrying that marker holds no more authority than a proposal until a human ratifies it (SPEC.md §5.2), and the ratification is a gate.path.signed_off record over the file's exact bytes. A marker whose text was later granted through the gate should lose the suffix instead; signing off is for text that reached the file without a grant`,
3591
+ fix: `approval policy attest --path ${first} --as human:<id> — after READING the file as it now stands`,
3592
+ };
3593
+ }
3594
+ // ---------------------------------------------------------------------------
2435
3595
  // 28. sealed-keys (APRV-285)
2436
3596
  // ---------------------------------------------------------------------------
2437
3597
  /** The exact line doctor tells an operator to add for the sealed-token key store. */
@@ -2478,10 +3638,11 @@ function trackedPrivateKeys(root, keyDir) {
2478
3638
  * `token_sealed` for as long as the token is unspent and inside its TTL, so the
2479
3639
  * whole design of sealed delivery rests on the key never being committed.
2480
3640
  *
2481
- * Nothing enforced that. `.approval/payloads/` is deliberately TRACKED (evidence
2482
- * belongs in the history), so `.approval/` is a directory an operator adds from
2483
- * during a records or ceremony commit, and a key store with no ignore line is
2484
- * swept in by the same `git add` that carries the payloads.
3641
+ * Nothing enforced that. `.approval/payloads/` was TRACKED by default until
3642
+ * APRV-445 (and still is wherever an operator keeps it in history, as this
3643
+ * repository does), so `.approval/` is a directory an operator adds from during
3644
+ * a records or ceremony commit, and a key store with no ignore line is swept in
3645
+ * by the same `git add` that carries the payloads.
2485
3646
  *
2486
3647
  * Two questions, in the order of what stays wrong the longest, the same reading
2487
3648
  * the vault and environment rows use:
@@ -2530,7 +3691,7 @@ function checkSealedKeys(logPath, dir) {
2530
3691
  return {
2531
3692
  check,
2532
3693
  status: "fail",
2533
- detail: `${String(present.length)} sealed-delivery private key(s) in ${keyDir} are NOT gitignored in ${dir}: one \`git add .approval/\` — the command a records or ceremony commit uses, because \`.approval/payloads/\` is deliberately tracked — publishes a live key beside the ciphertext it opens`,
3694
+ detail: `${String(present.length)} sealed-delivery private key(s) in ${keyDir} are NOT gitignored in ${dir}: one \`git add .approval/\` — the command a records or ceremony commit uses, wherever \`.approval/payloads/\` is kept in history — publishes a live key beside the ciphertext it opens`,
2534
3695
  fix: `echo '${KEYS_IGNORE_LINE}' >> ${join(dir, ".gitignore")} — the line \`approval init\` writes; and treat every action whose token is still unspent inside its TTL as disclosed`,
2535
3696
  };
2536
3697
  }
@@ -2667,7 +3828,7 @@ export function commandDoctor(argv, streams, cwd) {
2667
3828
  checkIdentity(),
2668
3829
  checkAttestationHealth(verified.records, policyPath),
2669
3830
  checkLog(logPath, verified.result),
2670
- await checkTelegram(apiBase, policyLoad),
3831
+ await checkTelegram(apiBase, policyLoad, logPath),
2671
3832
  await checkWebPort(port ?? WEB_DEFAULT_PORT),
2672
3833
  checkPayloadStore(logPath, verified.records),
2673
3834
  // APRV-271: asks the running daemon for the one half of this answer that
@@ -2710,6 +3871,15 @@ export function commandDoctor(argv, streams, cwd) {
2710
3871
  // network to be more accurate would be acting on its own account, so the
2711
3872
  // answer is as fresh as the operator's last fetch and says so.
2712
3873
  checkMainBehindOrigin(logPath, queuePath, root),
3874
+ // APRV-342: the interregnum between an attestation and its pull request
3875
+ // merging. `attestation` above asks whether the LOCAL policy is attested;
3876
+ // this asks whether the attested policy is on the remote, which is the
3877
+ // question a fresh checkout of main answers with `policy-not-attested`.
3878
+ checkAttestedPolicyOnMain({
3879
+ policyPath,
3880
+ records: verified.records,
3881
+ root: repoRoot(dirname(logPath)),
3882
+ }),
2713
3883
  // APRV-227: appended, fourteenth time, same reason. The only row that
2714
3884
  // asks a question about a binary OUTSIDE this repository, and it asks it
2715
3885
  // the one way a log can: what the last record said the harness was,
@@ -2738,13 +3908,43 @@ export function commandDoctor(argv, streams, cwd) {
2738
3908
  checkGateOrgans(dir, verified.records),
2739
3909
  // APRV-285: appended, eighteenth time, same reason. The sibling of the
2740
3910
  // vault and environment rows for the one file under `.approval/` that is
2741
- // a raw private key: `.approval/payloads/` is tracked on purpose, so
2742
- // `.approval/` is a directory people `git add` from, and the key store had
2743
- // nothing telling them it must not come along.
3911
+ // a raw private key: wherever `.approval/payloads/` is tracked (the
3912
+ // default before APRV-445, and this repository's choice), `.approval/` is
3913
+ // a directory people `git add` from, and the key store had nothing
3914
+ // telling them it must not come along.
2744
3915
  checkSealedKeys(logPath, dir),
2745
3916
  // APRV-313: appended, nineteenth time, same reason. Configuration on
2746
3917
  // disk is distinct from Codex trust, loading and observed execution.
2747
3918
  checkCodexHookWiring(dir),
3919
+ // APRV-335: appended, twentieth time, same reason. The health-report half
3920
+ // of the alias deprecation SPEC.md §5.2 now states: the loader's note is
3921
+ // seen by whoever runs a verb that prints notes, and this is the surface
3922
+ // an operator opens to read the state of their own repository.
3923
+ checkAutonomyAlias(policyLoad),
3924
+ // APRV-338: appended, twenty-first time, same reason. The terminal half
3925
+ // of the sign-off record: which protected files still carry SPEC.md's
3926
+ // pending-sign-off marker with no record ratifying their current bytes.
3927
+ // Informational and never a fail, exactly as gate-organs is — nothing on
3928
+ // this machine is broken by unratified prose, and a health check that
3929
+ // graded prose would be the runtime marking a human's homework.
3930
+ checkPendingSignOff(dir, verified.records, policyLoad),
3931
+ // APRV-324: appended, twenty-second time, same reason. The row design
3932
+ // §5 item 13 asks for, and the one an operator needs BEFORE the first
3933
+ // tap is refused: declaring a `senders` entry turns enforcement on for
3934
+ // that whole channel, so an approver left out of it is a person the
3935
+ // policy still lists and the gate will no longer honour.
3936
+ checkSenderMapping(policyLoad),
3937
+ // APRV-378: appended, twenty-third time, same reason. The terminal half
3938
+ // of `audit.question_preempted`: the bridge stops the session that saw
3939
+ // one, and this is where an operator finds out it happened without
3940
+ // reading the log by hand.
3941
+ checkCodexAutoReviewer(verified.records, verified.result.status === "clean"),
3942
+ // APRV-383: appended, twenty-fourth time, same reason. The sibling of
3943
+ // `keychain-scope` one layer along — that row says whose credentials this
3944
+ // instance uses, this one says whose name its daemon writes onto every
3945
+ // record — and the row an operator wiring up a hosted daemon needs BEFORE
3946
+ // anything is running.
3947
+ checkDaemonIdentity(logPath, verified.records, policyLoad),
2748
3948
  ];
2749
3949
  const ok = checks.every((entry) => entry.status !== "fail");
2750
3950
  if (json)