approval-md 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +19 -0
  8. package/dist/src/channels/contract.js +105 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -54,20 +54,23 @@
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, findPathSignOff, latestOrganAttestation, latestPathSignOff, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
62
+ import { HUMAN_ACTOR_ENV, attesterAt, checkAttestation, findOrganAttestation, findPathSignOff, latestOrganAttestation, latestPathSignOff, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
62
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";
67
70
  import { describeOwners, otherOwnersOf, ownedBot } from "../core/channel-owner.js";
68
71
  import { checkLogAnchor } from "./log-anchor.js";
69
72
  import { checkLogCheckpoints, checkpointPolicyOf } from "../core/checkpoint.js";
70
- import { payloadStoreCensus } from "../core/payload-census.js";
73
+ import { payloadIntegrity, payloadStoreCensus } from "../core/payload-census.js";
71
74
  import { payloadStoreDirFor } from "../core/payload-store.js";
72
75
  import { DEFAULT_TASKS_DIR, latestRegistration } from "../core/registration.js";
73
76
  import { POLICY_FILENAMES, loadPolicy } from "../core/policy-load.js";
@@ -84,8 +87,12 @@ import { policyWebPort } from "./channel-web.js";
84
87
  import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
85
88
  import { RESOLVE_DANGLING_COMMAND, lastAdvance, proveDanglingAdvances, } from "../core/advance-cycle.js";
86
89
  import { DEFAULT_DARK_WINDOW_MS, reportDarkSessions, } from "../core/dark-session.js";
87
- import { HARNESS_BINARY, HARNESS_KINDS, installedHarnessVersion, isHarnessKind, readHarnessProvenance, } from "../core/harness-version.js";
88
- import { git, repoPath, repoRoot } from "./git-scope.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";
89
96
  import { ScanError, checkAttestedPolicyOnMain, checkBuildFreshness, checkMainBehindOrigin, installationRoot, } from "./preflight.js";
90
97
  import { publishedState } from "./log-advance.js";
91
98
  import { DOCTOR_HELP } from "./help.js";
@@ -248,12 +255,17 @@ function resolvePolicyPath(policyFlag, dir, cwd) {
248
255
  function checkAttestationHealth(records, policyPath) {
249
256
  const status = checkAttestation(records, policyPath);
250
257
  switch (status.status) {
251
- 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);
252
263
  return {
253
264
  check: "attestation",
254
265
  status: "pass",
255
- 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)}…)`,
256
267
  };
268
+ }
257
269
  case "not-attested":
258
270
  return {
259
271
  check: "attestation",
@@ -280,6 +292,14 @@ function checkAttestationHealth(records, policyPath) {
280
292
  // ---------------------------------------------------------------------------
281
293
  // 4. log
282
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
+ }
283
303
  /** The chain verdict, in doctor's vocabulary. Reads; never writes. */
284
304
  function checkLog(logPath, result) {
285
305
  switch (result.status) {
@@ -291,13 +311,31 @@ function checkLog(logPath, result) {
291
311
  ? `${logPath} is empty (an audit trail that has recorded nothing is clean, not missing)`
292
312
  : `${logPath} verifies: ${result.records} record(s), head seq ${result.head.seq} ${result.head.hash.slice(0, 12)}…`,
293
313
  };
294
- 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
+ }
295
326
  return {
296
327
  check: "log",
297
328
  status: "fail",
298
- detail: `${logPath} ends with an unterminated final line — the signature of a crashed write, not of tampering; records 1..${result.intactThroughSeq} verify clean`,
299
- 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`,
300
337
  };
338
+ }
301
339
  case "corrupt":
302
340
  return {
303
341
  check: "log",
@@ -516,8 +554,12 @@ const PAYLOAD_STORE_WARNING = "the store holds the bytes approvals bind to, keye
516
554
  * The probe is a real create-and-remove in the store directory, not a `statSync`
517
555
  * mode test: mode bits do not answer the question on a read-only mount, under an
518
556
  * ACL, or in a container whose uid mapping differs from the one that made the
519
- * directory. Nothing is left behind, and no payload file is read, written or
520
- * 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.
521
563
  */
522
564
  function checkPayloadStore(logPath, records) {
523
565
  const storeDir = payloadStoreDirFor(logPath);
@@ -527,6 +569,14 @@ function checkPayloadStore(logPath, records) {
527
569
  }
528
570
  catch (cause) {
529
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);
530
580
  return {
531
581
  check: "payload-store",
532
582
  status: "pass",
@@ -589,12 +639,94 @@ function checkPayloadStore(logPath, records) {
589
639
  const residue = census.awaitingRemoval === 0
590
640
  ? ""
591
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);
592
647
  return {
593
648
  check: "payload-store",
594
649
  status: "pass",
595
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}`,
596
651
  };
597
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
+ }
598
730
  // ---------------------------------------------------------------------------
599
731
  // 7b. the verified-head snapshot (APRV-188)
600
732
  // ---------------------------------------------------------------------------
@@ -1698,6 +1830,80 @@ function checkKeychainScope(logPath, load) {
1698
1830
  };
1699
1831
  }
1700
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
+ // ---------------------------------------------------------------------------
1701
1907
  // 12. log-drift (APRV-125)
1702
1908
  // ---------------------------------------------------------------------------
1703
1909
  /**
@@ -1935,28 +2141,180 @@ function checkAdvanceCadence(logPath, records) {
1935
2141
  // ---------------------------------------------------------------------------
1936
2142
  /** Where Claude Code keeps the hook registration a human commits. */
1937
2143
  const CLAUDE_SETTINGS = join(".claude", "settings.json");
1938
- /** Does any `hooks.<event>` entry run this CLI's harness hook? */
1939
- 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) {
1940
2284
  if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
1941
- return false;
1942
- const matchers = hooks[event];
1943
- if (!Array.isArray(matchers))
1944
- return false;
1945
- for (const matcher of matchers) {
1946
- 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)
1947
2292
  continue;
1948
- const entries = matcher["hooks"];
2293
+ const entries = group["hooks"];
1949
2294
  if (!Array.isArray(entries))
1950
2295
  continue;
2296
+ const pattern = group["matcher"];
1951
2297
  for (const entry of entries) {
1952
2298
  if (typeof entry !== "object" || entry === null)
1953
2299
  continue;
1954
2300
  const command = entry["command"];
1955
- if (typeof command === "string" && /\bapproval hook\b/u.test(command))
1956
- 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
+ });
1957
2311
  }
1958
2312
  }
1959
- 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;
1960
2318
  }
1961
2319
  /**
1962
2320
  * Is the harness registered for the event that reports outcomes (APRV-145)?
@@ -1972,6 +2330,12 @@ function registersApprovalHook(hooks, event) {
1972
2330
  * `policy.core` in this taxonomy — a file that configures the gate is part of
1973
2331
  * the gate — so the repair is a line for a human to commit, printed by
1974
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.
1975
2339
  */
1976
2340
  function checkHarnessOutcomes(dir) {
1977
2341
  const check = "harness-hook-outcomes";
@@ -2004,53 +2368,138 @@ function checkHarnessOutcomes(dir) {
2004
2368
  return {
2005
2369
  check,
2006
2370
  status: "skip",
2007
- 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`,
2008
2372
  };
2009
2373
  }
2010
2374
  if (!post) {
2011
2375
  return {
2012
2376
  check,
2013
2377
  status: "fail",
2014
- 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`,
2015
2379
  fix: "approval hook claude-code --help — prints the PostToolUse entry to add, which a human commits (.claude/settings.json is policy.core)",
2016
2380
  };
2017
2381
  }
2018
2382
  return {
2019
2383
  check,
2020
2384
  status: "pass",
2021
- 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`,
2022
2386
  };
2023
2387
  }
2024
2388
  // ---------------------------------------------------------------------------
2025
2389
  // harness hook wiring in THIS worktree (APRV-151)
2026
2390
  // ---------------------------------------------------------------------------
2027
- /** The tool names a protected-path write can arrive as. */
2028
- const GATED_TOOLS = ["Edit", "Write", "Bash"];
2029
- /** The `matcher` strings of every `approval hook` entry registered for `event`. */
2030
- function approvalHookMatchers(hooks, event) {
2031
- if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks))
2032
- return [];
2033
- const matchers = hooks[event];
2034
- if (!Array.isArray(matchers))
2035
- return [];
2036
- const found = [];
2037
- for (const matcher of matchers) {
2038
- 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")
2039
2455
  continue;
2040
- const entries = matcher["hooks"];
2041
- 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
+ });
2042
2469
  continue;
2043
- for (const entry of entries) {
2044
- if (typeof entry !== "object" || entry === null)
2045
- continue;
2046
- const command = entry["command"];
2047
- if (typeof command !== "string" || !/\bapproval hook\b/u.test(command))
2048
- continue;
2049
- const pattern = matcher["matcher"];
2050
- found.push(typeof pattern === "string" ? pattern : "");
2051
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
+ });
2483
+ continue;
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
+ });
2052
2496
  }
2053
- 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(" ") };
2054
2503
  }
2055
2504
  /**
2056
2505
  * Does the settings file THIS worktree carries register the pre-execution hook
@@ -2074,11 +2523,25 @@ function approvalHookMatchers(hooks, event) {
2074
2523
  * CI-side, over the committed log, in `core/protected-path-guard.ts`: it does
2075
2524
  * not trust session wiring, and this row does not claim to establish it.
2076
2525
  *
2077
- * Advisory, so it never fails the run. Doctor reads and never writes; the file
2078
- * 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.
2079
2541
  */
2080
2542
  function checkHarnessWiring(dir) {
2081
2543
  const check = "harness-hook-wiring";
2544
+ const gated = gatedClaudeTools();
2082
2545
  const root = repoRoot(dir);
2083
2546
  const where = root === null ? dir : root;
2084
2547
  const path = join(where, CLAUDE_SETTINGS);
@@ -2112,29 +2575,72 @@ function checkHarnessWiring(dir) {
2112
2575
  const hooks = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
2113
2576
  ? parsed["hooks"]
2114
2577
  : null;
2115
- const matchers = approvalHookMatchers(hooks, "PreToolUse");
2116
- if (matchers.length === 0) {
2578
+ const entries = claudeHookEntries(hooks, "PreToolUse");
2579
+ if (entries.length === 0) {
2117
2580
  return {
2118
2581
  check,
2119
2582
  status: "skip",
2120
- 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.`,
2121
- 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,
2122
2585
  };
2123
2586
  }
2124
- const covered = GATED_TOOLS.filter((tool) => matchers.some((pattern) => pattern.length === 0 || pattern.split("|").includes(tool)));
2125
- 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));
2126
2617
  if (missing.length > 0) {
2127
2618
  return {
2128
2619
  check,
2129
- status: "skip",
2130
- 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.`,
2131
- 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,
2132
2623
  };
2133
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.`;
2134
2639
  return {
2135
2640
  check,
2136
- status: "pass",
2137
- 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 }),
2138
2644
  };
2139
2645
  }
2140
2646
  /** The project-local Codex hook files doctor can observe without asking Codex. */
@@ -2250,10 +2756,138 @@ export function checkCodexHookWiring(dir) {
2250
2756
  // ---------------------------------------------------------------------------
2251
2757
  /** The Cursor counterpart of {@link CLAUDE_SETTINGS}. */
2252
2758
  const CURSOR_HOOKS = join(".cursor", "hooks.json");
2253
- /** Where a harness hook registration can be written, one file per harness. */
2254
- const HARNESS_SETTINGS = [CLAUDE_SETTINGS, CURSOR_HOOKS, CODEX_HOOKS];
2255
- /** `approval hook <kind>` inside a command string, whichever file shape holds it. */
2256
- 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
+ }
2257
2891
  /**
2258
2892
  * Every harness this checkout registers an `approval hook` command for.
2259
2893
  *
@@ -2268,35 +2902,52 @@ const HOOK_COMMAND = /\bapproval["']?\s+hook\s+(claude-code|cursor|codex)\b/u;
2268
2902
  */
2269
2903
  export function registeredHarnesses(dir) {
2270
2904
  const found = new Set();
2271
- for (const relative of HARNESS_SETTINGS) {
2272
- const path = join(dir, relative);
2273
- if (!existsSync(path))
2274
- continue;
2275
- let parsed;
2276
- try {
2277
- parsed = JSON.parse(readFileSync(path, "utf8"));
2278
- }
2279
- catch {
2280
- continue;
2281
- }
2282
- const stack = [parsed];
2283
- while (stack.length > 0) {
2284
- const node = stack.pop();
2285
- if (typeof node === "string") {
2286
- const match = HOOK_COMMAND.exec(node);
2287
- if (match !== null &&
2288
- isHarnessKind(match[1]) &&
2289
- (match[1] !== "codex" || isDirectCodexHookCommand(node))) {
2290
- 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;
2291
2920
  }
2921
+ const match = HOOK_COMMAND.exec(text);
2922
+ if (match !== null && isHarnessKind(match[1]))
2923
+ found.add(match[1]);
2292
2924
  continue;
2293
2925
  }
2294
- if (Array.isArray(node)) {
2295
- stack.push(...node);
2926
+ try {
2927
+ parsed = JSON.parse(readFileSync(path, "utf8"));
2928
+ }
2929
+ catch {
2296
2930
  continue;
2297
2931
  }
2298
- if (typeof node === "object" && node !== null) {
2299
- 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
+ }
2300
2951
  }
2301
2952
  }
2302
2953
  }
@@ -2368,6 +3019,20 @@ function recordedHarnessVersions(records) {
2368
3019
  * has nothing to compare against, and inventing a baseline would be inventing
2369
3020
  * the fact); and no such binary on PATH, since doctor may be running somewhere
2370
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.
2371
3036
  */
2372
3037
  function checkHarnessVersion(dir, records) {
2373
3038
  const check = "harness-version-unverified";
@@ -2378,13 +3043,41 @@ function checkHarnessVersion(dir, records) {
2378
3043
  return {
2379
3044
  check,
2380
3045
  status: "skip",
2381
- 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`,
2382
3047
  };
2383
3048
  }
2384
3049
  const recorded = recordedHarnessVersions(records);
2385
3050
  const mismatched = [];
2386
3051
  const matched = [];
2387
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
+ }
2388
3081
  for (const kind of kinds) {
2389
3082
  const last = recorded.get(kind);
2390
3083
  if (last === undefined) {
@@ -2402,12 +3095,26 @@ function checkHarnessVersion(dir, records) {
2402
3095
  }
2403
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)}`);
2404
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
+ }
2405
3112
  if (mismatched.length > 0) {
2406
3113
  const first = kinds[0];
2407
3114
  return {
2408
3115
  check,
2409
3116
  status: "fail",
2410
- 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("; ")}.`}`,
2411
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.`,
2412
3119
  };
2413
3120
  }
@@ -2415,13 +3122,13 @@ function checkHarnessVersion(dir, records) {
2415
3122
  return {
2416
3123
  check,
2417
3124
  status: "pass",
2418
- 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("; ")}.`}`,
2419
3126
  };
2420
3127
  }
2421
3128
  return {
2422
3129
  check,
2423
3130
  status: "skip",
2424
- 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("; ")}`}`,
2425
3132
  };
2426
3133
  }
2427
3134
  // ---------------------------------------------------------------------------
@@ -2515,16 +3222,46 @@ function checkDarkSessions(logPath, dir, policyPath, records, verified) {
2515
3222
  * is reported); this list only says where to look, so a directory nobody uses
2516
3223
  * costs nothing and a file nobody named is not invented.
2517
3224
  */
2518
- const ORGAN_SEARCH = [
2519
- { dir: ".claude", prefix: "settings" },
2520
- { dir: ".cursor", prefix: "hooks.json" },
2521
- { dir: join(".cursor", "hooks") },
2522
- { dir: join(".cursor", "agents") },
2523
- ];
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]);
2524
3261
  /** The organ files this checkout actually carries, repository-relative, sorted. */
2525
3262
  function listGateOrgans(root) {
2526
3263
  const found = [];
2527
- for (const entry of ORGAN_SEARCH) {
3264
+ for (const entry of ORGAN_SEARCH_ENTRIES) {
2528
3265
  let names;
2529
3266
  try {
2530
3267
  names = readdirSync(join(root, entry.dir));
@@ -2649,7 +3386,7 @@ function checkGateOrgans(dir, records) {
2649
3386
  return {
2650
3387
  check,
2651
3388
  status: "skip",
2652
- 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`,
2653
3390
  };
2654
3391
  }
2655
3392
  const unattested = [];
@@ -2901,10 +3638,11 @@ function trackedPrivateKeys(root, keyDir) {
2901
3638
  * `token_sealed` for as long as the token is unspent and inside its TTL, so the
2902
3639
  * whole design of sealed delivery rests on the key never being committed.
2903
3640
  *
2904
- * Nothing enforced that. `.approval/payloads/` is deliberately TRACKED (evidence
2905
- * belongs in the history), so `.approval/` is a directory an operator adds from
2906
- * during a records or ceremony commit, and a key store with no ignore line is
2907
- * 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.
2908
3646
  *
2909
3647
  * Two questions, in the order of what stays wrong the longest, the same reading
2910
3648
  * the vault and environment rows use:
@@ -2953,7 +3691,7 @@ function checkSealedKeys(logPath, dir) {
2953
3691
  return {
2954
3692
  check,
2955
3693
  status: "fail",
2956
- 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`,
2957
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`,
2958
3696
  };
2959
3697
  }
@@ -3170,9 +3908,10 @@ export function commandDoctor(argv, streams, cwd) {
3170
3908
  checkGateOrgans(dir, verified.records),
3171
3909
  // APRV-285: appended, eighteenth time, same reason. The sibling of the
3172
3910
  // vault and environment rows for the one file under `.approval/` that is
3173
- // a raw private key: `.approval/payloads/` is tracked on purpose, so
3174
- // `.approval/` is a directory people `git add` from, and the key store had
3175
- // 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.
3176
3915
  checkSealedKeys(logPath, dir),
3177
3916
  // APRV-313: appended, nineteenth time, same reason. Configuration on
3178
3917
  // disk is distinct from Codex trust, loading and observed execution.
@@ -3200,6 +3939,12 @@ export function commandDoctor(argv, streams, cwd) {
3200
3939
  // one, and this is where an operator finds out it happened without
3201
3940
  // reading the log by hand.
3202
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),
3203
3948
  ];
3204
3949
  const ok = checks.every((entry) => entry.status !== "fail");
3205
3950
  if (json)