approval-md 0.4.0 → 0.4.2

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 (190) hide show
  1. package/SPEC.md +11 -0
  2. package/cli.js +72 -4
  3. package/dist/src/channels/contract.d.ts +53 -0
  4. package/dist/src/channels/contract.js +56 -0
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/tagging.d.ts +25 -0
  7. package/dist/src/channels/tagging.js +57 -5
  8. package/dist/src/channels/tagging.js.map +1 -1
  9. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  10. package/dist/src/channels/telegram-minimal.js +618 -0
  11. package/dist/src/channels/telegram-minimal.js.map +1 -0
  12. package/dist/src/channels/telegram.d.ts +454 -19
  13. package/dist/src/channels/telegram.js +931 -80
  14. package/dist/src/channels/telegram.js.map +1 -1
  15. package/dist/src/cli/amend.js +8 -1
  16. package/dist/src/cli/amend.js.map +1 -1
  17. package/dist/src/cli/attest.js +2 -1
  18. package/dist/src/cli/attest.js.map +1 -1
  19. package/dist/src/cli/audit-card.d.ts +1 -1
  20. package/dist/src/cli/audit-card.js +40 -1
  21. package/dist/src/cli/audit-card.js.map +1 -1
  22. package/dist/src/cli/audit.d.ts +7 -5
  23. package/dist/src/cli/audit.js +25 -12
  24. package/dist/src/cli/audit.js.map +1 -1
  25. package/dist/src/cli/channel-telegram-webhook.d.ts +2 -0
  26. package/dist/src/cli/channel-telegram-webhook.js +7 -0
  27. package/dist/src/cli/channel-telegram-webhook.js.map +1 -1
  28. package/dist/src/cli/channel-telegram.d.ts +75 -2
  29. package/dist/src/cli/channel-telegram.js +172 -16
  30. package/dist/src/cli/channel-telegram.js.map +1 -1
  31. package/dist/src/cli/channel-web.js +2 -1
  32. package/dist/src/cli/channel-web.js.map +1 -1
  33. package/dist/src/cli/channel.js +2 -1
  34. package/dist/src/cli/channel.js.map +1 -1
  35. package/dist/src/cli/doctor.js +1 -1
  36. package/dist/src/cli/doctor.js.map +1 -1
  37. package/dist/src/cli/execute.d.ts +12 -0
  38. package/dist/src/cli/execute.js +50 -4
  39. package/dist/src/cli/execute.js.map +1 -1
  40. package/dist/src/cli/gate.js +2 -1
  41. package/dist/src/cli/gate.js.map +1 -1
  42. package/dist/src/cli/help.d.ts +1 -0
  43. package/dist/src/cli/help.js +39 -18
  44. package/dist/src/cli/help.js.map +1 -1
  45. package/dist/src/cli/hook.d.ts +121 -3
  46. package/dist/src/cli/hook.js +719 -79
  47. package/dist/src/cli/hook.js.map +1 -1
  48. package/dist/src/cli/log-checkpoint.js +2 -1
  49. package/dist/src/cli/log-checkpoint.js.map +1 -1
  50. package/dist/src/cli/log-unlock.d.ts +23 -0
  51. package/dist/src/cli/log-unlock.js +110 -0
  52. package/dist/src/cli/log-unlock.js.map +1 -0
  53. package/dist/src/cli/main.js +23 -6
  54. package/dist/src/cli/main.js.map +1 -1
  55. package/dist/src/cli/setup-common.js +2 -1
  56. package/dist/src/cli/setup-common.js.map +1 -1
  57. package/dist/src/cli/up.js +5 -0
  58. package/dist/src/cli/up.js.map +1 -1
  59. package/dist/src/cli/vault.js +2 -1
  60. package/dist/src/cli/vault.js.map +1 -1
  61. package/dist/src/cli/verb-registry.js +1 -0
  62. package/dist/src/cli/verb-registry.js.map +1 -1
  63. package/dist/src/cli/wordmark.d.ts +2 -2
  64. package/dist/src/cli/wordmark.js +2 -2
  65. package/dist/src/core/attest-bootstrap.js +2 -1
  66. package/dist/src/core/attest-bootstrap.js.map +1 -1
  67. package/dist/src/core/attest.js +5 -4
  68. package/dist/src/core/attest.js.map +1 -1
  69. package/dist/src/core/audit.d.ts +113 -9
  70. package/dist/src/core/audit.js +382 -18
  71. package/dist/src/core/audit.js.map +1 -1
  72. package/dist/src/core/budgets.d.ts +12 -0
  73. package/dist/src/core/budgets.js +54 -1
  74. package/dist/src/core/budgets.js.map +1 -1
  75. package/dist/src/core/checkpoint.js +3 -2
  76. package/dist/src/core/checkpoint.js.map +1 -1
  77. package/dist/src/core/command-class.js +6 -0
  78. package/dist/src/core/command-class.js.map +1 -1
  79. package/dist/src/core/decision-refusal.js +2 -1
  80. package/dist/src/core/decision-refusal.js.map +1 -1
  81. package/dist/src/core/delegation.d.ts +109 -0
  82. package/dist/src/core/delegation.js +244 -0
  83. package/dist/src/core/delegation.js.map +1 -0
  84. package/dist/src/core/execute.js +12 -3
  85. package/dist/src/core/execute.js.map +1 -1
  86. package/dist/src/core/gate-window.js +4 -3
  87. package/dist/src/core/gate-window.js.map +1 -1
  88. package/dist/src/core/gate.d.ts +84 -43
  89. package/dist/src/core/gate.js +114 -6
  90. package/dist/src/core/gate.js.map +1 -1
  91. package/dist/src/core/gesture-refusal.js +2 -1
  92. package/dist/src/core/gesture-refusal.js.map +1 -1
  93. package/dist/src/core/identity.d.ts +105 -0
  94. package/dist/src/core/identity.js +122 -0
  95. package/dist/src/core/identity.js.map +1 -0
  96. package/dist/src/core/log-lock.d.ts +435 -0
  97. package/dist/src/core/log-lock.js +1093 -0
  98. package/dist/src/core/log-lock.js.map +1 -0
  99. package/dist/src/core/log.d.ts +82 -3
  100. package/dist/src/core/log.js +285 -40
  101. package/dist/src/core/log.js.map +1 -1
  102. package/dist/src/core/loop.d.ts +11 -0
  103. package/dist/src/core/loop.js +63 -4
  104. package/dist/src/core/loop.js.map +1 -1
  105. package/dist/src/core/policy-diff.js +20 -1
  106. package/dist/src/core/policy-diff.js.map +1 -1
  107. package/dist/src/core/policy-explain.js +18 -2
  108. package/dist/src/core/policy-explain.js.map +1 -1
  109. package/dist/src/core/policy-load.d.ts +38 -1
  110. package/dist/src/core/policy-load.js +34 -2
  111. package/dist/src/core/policy-load.js.map +1 -1
  112. package/dist/src/core/policy-match.js +30 -5
  113. package/dist/src/core/policy-match.js.map +1 -1
  114. package/dist/src/core/policy-proposal.d.ts +17 -0
  115. package/dist/src/core/policy-proposal.js +32 -16
  116. package/dist/src/core/policy-proposal.js.map +1 -1
  117. package/dist/src/core/prompt-layout.d.ts +186 -1
  118. package/dist/src/core/prompt-layout.js +345 -3
  119. package/dist/src/core/prompt-layout.js.map +1 -1
  120. package/dist/src/core/sender-identity.d.ts +24 -1
  121. package/dist/src/core/sender-identity.js +31 -0
  122. package/dist/src/core/sender-identity.js.map +1 -1
  123. package/dist/src/core/token.js +11 -0
  124. package/dist/src/core/token.js.map +1 -1
  125. package/dist/src/core/tool-map.d.ts +125 -0
  126. package/dist/src/core/tool-map.js +179 -0
  127. package/dist/src/core/tool-map.js.map +1 -0
  128. package/dist/src/core/validate.d.ts +20 -6
  129. package/dist/src/core/validate.js +0 -0
  130. package/dist/src/core/validate.js.map +1 -1
  131. package/dist/src/daemon/git-evidence.d.ts +2 -2
  132. package/dist/src/daemon/git-evidence.js +1 -1
  133. package/dist/src/serve/archive.d.ts +8 -0
  134. package/dist/src/serve/archive.js +11 -2
  135. package/dist/src/serve/archive.js.map +1 -1
  136. package/docs/README-extended.md +15 -0
  137. package/docs/cli-reference.md +571 -28
  138. package/package.json +2 -2
  139. package/schema/event.schema.json +387 -29
  140. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  141. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  142. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  143. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  144. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  145. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  146. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  147. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  148. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  149. package/schema/fixtures/event/invalid/audit-reviewed-model-actor.json +20 -0
  150. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  151. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  152. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  153. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  154. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  155. package/schema/fixtures/event/invalid/audit-reviewed-verdict-source-model.json +20 -0
  156. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  157. package/schema/fixtures/event/invalid/execution-started-harness-tool-space.json +18 -0
  158. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  159. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  160. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  161. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  162. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  163. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  164. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  165. package/schema/fixtures/event/valid/daemon-identity.json +2 -1
  166. package/schema/fixtures/event/valid/execution-started-harness-tool.json +19 -0
  167. package/schema/fixtures/policy/invalid/delegation-advice-string.json +15 -0
  168. package/schema/fixtures/policy/invalid/delegation-class-duplicate.json +18 -0
  169. package/schema/fixtures/policy/invalid/delegation-class-wildcard.json +17 -0
  170. package/schema/fixtures/policy/invalid/delegation-daily-cap-fractional.json +15 -0
  171. package/schema/fixtures/policy/invalid/delegation-daily-cap-negative.json +15 -0
  172. package/schema/fixtures/policy/invalid/delegation-daily-cap-too-high.json +15 -0
  173. package/schema/fixtures/policy/invalid/delegation-escalate-duplicate.json +18 -0
  174. package/schema/fixtures/policy/invalid/delegation-escalate-unknown.json +17 -0
  175. package/schema/fixtures/policy/invalid/delegation-max-autonomy-alias.json +15 -0
  176. package/schema/fixtures/policy/invalid/delegation-max-autonomy-autonomous.json +15 -0
  177. package/schema/fixtures/policy/invalid/delegation-model-no-version.json +15 -0
  178. package/schema/fixtures/policy/invalid/delegation-model-provider-form.json +15 -0
  179. package/schema/fixtures/policy/invalid/delegation-null.json +7 -0
  180. package/schema/fixtures/policy/invalid/delegation-reviewer-agent.json +17 -0
  181. package/schema/fixtures/policy/invalid/delegation-reviewer-bare-id.json +17 -0
  182. package/schema/fixtures/policy/invalid/delegation-unknown-key.json +16 -0
  183. package/schema/fixtures/policy/invalid/tools-class-wildcard.json +9 -0
  184. package/schema/fixtures/policy/invalid/tools-match-double-star.json +9 -0
  185. package/schema/fixtures/policy/invalid/unmapped-tool-unknown.json +7 -0
  186. package/schema/fixtures/policy/valid/delegation-empty.json +7 -0
  187. package/schema/fixtures/policy/valid/delegation-grammar-full.json +41 -0
  188. package/schema/fixtures/policy/valid/delegation-off.json +15 -0
  189. package/schema/fixtures/policy/valid/tools-mapping.json +17 -0
  190. package/schema/policy.schema.json +178 -0
@@ -76,7 +76,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeSyn
76
76
  import { randomBytes } from "node:crypto";
77
77
  import { tmpdir } from "node:os";
78
78
  import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
79
- import { attestationRefusal, checkAttestation } from "../core/attest.js";
79
+ import { attestationRefusal, checkAttestation, checkAttestationOfBytes } from "../core/attest.js";
80
80
  import { childEnvironment } from "../core/child-env.js";
81
81
  import { classifyCommand, commandSegmentShapes, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, isCredentialPath, protectedPathClass, } from "../core/command-class.js";
82
82
  import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, isProposalTask, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
@@ -85,9 +85,12 @@ import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
85
85
  import { abandonedAfterMs, HARNESS_CAP_MARGIN_MS, harnessCapFitsMargin, harnessCappedTtlMs, HOOK_DEFAULT_WAIT, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
86
86
  import { harnessLoopFloor, isLoopEscalated, isSideEffectingClass, loopClearance, UNKNOWN_SESSION, } from "../core/loop.js";
87
87
  import { drawSocketPathFor, drawSocketUsable } from "../core/live-draw.js";
88
+ import { appendLockHeld, DEFAULT_LOCK_RETRY_MS, DEFAULT_LOCK_TIMEOUT_MS, } from "../core/log.js";
89
+ import { settleTerminationGuards } from "../core/log-lock.js";
88
90
  import { payloadHash } from "../core/payload.js";
89
91
  import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
90
- import { loadPolicy, parseDuration } from "../core/policy-load.js";
92
+ import { loadPolicy, loadPolicyText, parseDuration, POLICY_FILENAMES, } from "../core/policy-load.js";
93
+ import { isRecordableToolName, toolMapVerdict, UNMAPPED_TOOL_CLASS, } from "../core/tool-map.js";
91
94
  import { READ_OUT_OF_SCOPE_CLASS, effectiveReadRoots, isInReadScope, readTargetsOf, renderReadRoots, } from "../core/read-scope.js";
92
95
  import { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve as resolvePolicy, } from "../core/policy-match.js";
93
96
  import { payloadOf, readVerifiedRecords, requestExpiresAt, requestState, useVerifiedSnapshots, } from "../core/state.js";
@@ -810,6 +813,100 @@ function toolRuleOf(adapter, input) {
810
813
  }
811
814
  return adapter.toolRules(input.toolName, input.toolInput);
812
815
  }
816
+ /**
817
+ * Is this call one the adapter's own tables leave unclaimed? (APRV-499.)
818
+ *
819
+ * Unclaimed means: not the shell tool, not a file or read tool, not a
820
+ * pass-through tool, and not a tool the hard-coded rule table answers (gated OR
821
+ * read). Exactly these calls reach the policy's `tools` mapping and its
822
+ * unmapped-tool default, and before APRV-499 exactly these were answered "not a
823
+ * gated tool". The adapter's tables keep precedence because they read the
824
+ * call's arguments, and a `tools` entry reads only its name.
825
+ */
826
+ function unclaimedTool(adapter, input) {
827
+ if (input.toolName === adapter.shellTool)
828
+ return false;
829
+ if (adapter.fileTools.includes(input.toolName))
830
+ return false;
831
+ if (adapter.readTools.includes(input.toolName))
832
+ return false;
833
+ if (adapter.passThroughTools?.includes(input.toolName) === true)
834
+ return false;
835
+ return toolRuleOf(adapter, input) === null;
836
+ }
837
+ /**
838
+ * The policy file's bytes, found the way `loadPolicy` finds them: `--policy`
839
+ * when given, else the first of {@link POLICY_FILENAMES} in the policy
840
+ * directory, else in `cwd`. `null` when there is none to read.
841
+ *
842
+ * Read ONCE and handed to both the parse and the attestation hash (APRV-142's
843
+ * rule, applied to this hook's unclaimed-tool answer), so no file swap between
844
+ * the two can attest one policy and decide by another.
845
+ */
846
+ function readHookPolicyBytes(options, cwd) {
847
+ const candidates = options.policy?.file === undefined
848
+ ? POLICY_FILENAMES.map((filename) => join(options.policy?.dir ?? cwd, filename))
849
+ : [options.policy.file];
850
+ for (const candidate of candidates) {
851
+ try {
852
+ return { path: candidate, bytes: readFileSync(candidate) };
853
+ }
854
+ catch {
855
+ continue;
856
+ }
857
+ }
858
+ return null;
859
+ }
860
+ function unclaimedStanding(options, cwd, toolName, records) {
861
+ const read = readHookPolicyBytes(options, cwd);
862
+ if (read === null)
863
+ return { kind: "decide" };
864
+ const load = loadPolicyText(read.path, read.bytes.toString("utf8"));
865
+ if (!load.ok)
866
+ return { kind: "decide" };
867
+ if (toolMapVerdict(load.policy, toolName).kind !== "not-gated")
868
+ return { kind: "decide" };
869
+ if (records === null) {
870
+ return {
871
+ kind: "unattested",
872
+ code: "hook-io",
873
+ detail: `${toolName} is outside the gate only under an attested policy, and the log that would show the attestation could not be read and verified; nothing was appended`,
874
+ };
875
+ }
876
+ const refusal = attestationRefusal(checkAttestationOfBytes(records, read.bytes));
877
+ if (refusal === null)
878
+ return { kind: "ungated" };
879
+ return {
880
+ kind: "unattested",
881
+ code: `hook-gate-refused:${refusal.code}`,
882
+ detail: `${refusal.message}. ${toolName} is a tool this adapter does not classify, and whether the policy gates it (a \`tools\` entry, \`defaults.unmapped_tool\`) is a question only the attested policy answers, so the on-disk file is not read as "not a gated tool" until a human attests it. Nothing was appended.`,
883
+ };
884
+ }
885
+ /**
886
+ * {@link unclaimedStanding} for a caller that has not read the log: the fast
887
+ * path and the post-execution half. One verified read, resumed behind the
888
+ * daemon's snapshot (APRV-188) where there is one.
889
+ */
890
+ function unclaimedUngated(flags, cwd, toolName) {
891
+ const { logPath, options } = hookScope(flags, cwd);
892
+ useVerifiedSnapshots(true);
893
+ const read = readVerifiedRecords(logPath);
894
+ return unclaimedStanding(options, cwd, toolName, read.ok ? read.records : null).kind === "ungated";
895
+ }
896
+ /**
897
+ * The `hook-policy-unavailable` detail (APRV-499, refutation S2): the load's
898
+ * own code and message, the first per-entry errors it computed, and the
899
+ * repair, so the human reading the refusal is told what to change.
900
+ */
901
+ function policyUnavailableDetail(load) {
902
+ const errors = load.errors ?? [];
903
+ const shown = errors
904
+ .slice(0, 5)
905
+ .map((error) => `${error.path.length === 0 ? "(root)" : error.path}: ${error.message}`);
906
+ const more = errors.length > shown.length ? `; and ${String(errors.length - shown.length)} more` : "";
907
+ const listed = shown.length === 0 ? "" : ` (${shown.join("; ")}${more})`;
908
+ return `${load.code}: ${load.message}${listed}; every class resolves to manual and the hook cannot verify a decision. Repair: correct ${shown.length === 0 ? "the policy file" : "each entry named above"}, then have a human attest the result (\`approval policy attest\`); an edited policy is inoperative until then`;
909
+ }
813
910
  /**
814
911
  * Every harness this runtime speaks a hook protocol for, by kind (APRV-358).
815
912
  *
@@ -988,6 +1085,86 @@ export function harnessBlockDirective(code, detail, harness) {
988
1085
  const exitCode = deny({ out: (text) => out.push(text), err: () => undefined }, code, detail, harness);
989
1086
  return { stdout: out.join(""), exitCode };
990
1087
  }
1088
+ /**
1089
+ * Raised on `globalThis` while the runtime's own Hermes signal guards are
1090
+ * registered (APRV-466). The `approval` bin reads the same registry symbol and
1091
+ * steps aside while it is `true`, so the bin and the runtime never both print.
1092
+ * An explicit flag rather than a listener count: an unrelated listener that
1093
+ * does not exit must not switch the bin's guard off.
1094
+ */
1095
+ export const HERMES_SIGNAL_OWNER = Symbol.for("approval-md.hermes-signal-owner");
1096
+ /**
1097
+ * The block directive a Hermes hook prints when a signal arrives before it
1098
+ * reached a verdict (APRV-445, APRV-466), as bytes.
1099
+ *
1100
+ * Two places print it: `hermesFailClosed`'s guard below, and the `approval` bin
1101
+ * (`cli.js`), which has to answer a signal that lands while this module is
1102
+ * still loading and so cannot import it. The bin spells the same bytes by hand,
1103
+ * and `tests/hermes-bin-signal-guard.test.ts` pins the two equal, so the
1104
+ * early-signal answer is one code with one spelling (SPEC.md §11.1 invariant
1105
+ * 6). The wait's own handler words its message differently ("while waiting for
1106
+ * a decision") under the same code.
1107
+ *
1108
+ * On the CLI the runtime's guard prints it for a signal held through the
1109
+ * synchronous stretch before the first spend (the stdin read, the
1110
+ * classification), at the pause `BEFORE_SPEND` (APRV-473); the bin prints it
1111
+ * for a signal during the load (docs/hermes-hook.md, "Two layers against a
1112
+ * signal").
1113
+ */
1114
+ export function hermesInterruptedDirective(signal) {
1115
+ return harnessBlockDirective("hook-interrupted", `the hook received ${signal} before it reached a verdict; nothing authorizes this call`, "hermes").stdout;
1116
+ }
1117
+ /**
1118
+ * The block directive a harness wait answers when a SIGTERM or SIGINT ends it
1119
+ * (APRV-473 for Hermes, APRV-475 for every harness), as bytes and exit code.
1120
+ *
1121
+ * It is the harness's ORDINARY deny, built by {@link harnessBlockDirective}
1122
+ * and so by {@link deny}, under the `hook-interrupted` code: the nested
1123
+ * `hookSpecificOutput` deny at exit 0 for Claude Code, Codex and Muse,
1124
+ * `{permission:"deny"}` at exit 0 for Cursor, `{decision:"deny"}` at exit 2
1125
+ * for Grok, `{action:"block"}` at exit 2 for Hermes. One construction site per
1126
+ * harness, so the answer to an interruption is spelled exactly as every other
1127
+ * refusal that harness receives from this hook, and a harness that blocks on
1128
+ * those blocks on this. `tests/harness-wait-interrupt.test.ts` pins the bytes
1129
+ * per harness.
1130
+ */
1131
+ export function interruptedWaitDirective(signal, harness) {
1132
+ return harnessBlockDirective("hook-interrupted", `the hook received ${signal} while waiting for a decision; nothing authorizes this call`, harness);
1133
+ }
1134
+ /**
1135
+ * The exit code an interrupted wait leaves with when stdout would not take its
1136
+ * directive whole (APRV-475): a closed pipe, or a short write.
1137
+ *
1138
+ * 2, because with an empty or torn stdout the exit code is the whole verdict,
1139
+ * and 2 is the one code these harnesses read as a block on its own: Claude Code
1140
+ * documents it as a blocking error, Grok as its deny, Hermes blocks on it
1141
+ * unconditionally, Muse's live probe blocked on it with an empty stdout, and a
1142
+ * Cursor entry with `failClosed` blocks on any non-zero exit. Exiting 0 there
1143
+ * would be the one wrong answer on the harnesses whose deny is exit 0 with a
1144
+ * body: an exit 0 with no body is no verdict, and those harnesses run the call.
1145
+ */
1146
+ const INTERRUPTED_BARE_EXIT = 2;
1147
+ /**
1148
+ * Answer an interrupted wait (APRV-475), and return the code to exit with.
1149
+ *
1150
+ * Writes {@link interruptedWaitDirective} synchronously to fd 1, because the
1151
+ * `process.exit` that follows does not wait for a stream to drain. Returns the
1152
+ * directive's own exit code, or {@link INTERRUPTED_BARE_EXIT} when the write
1153
+ * threw or came up short. Called by the wait's signal handler only, BEFORE its
1154
+ * withdrawal, so the block is on stdout however long the withdrawal waits for
1155
+ * the log's lock.
1156
+ */
1157
+ function writeInterruptedWait(signal, harness) {
1158
+ const directive = interruptedWaitDirective(signal, harness);
1159
+ let whole = false;
1160
+ try {
1161
+ whole = writeSync(1, directive.stdout) === Buffer.byteLength(directive.stdout);
1162
+ }
1163
+ catch {
1164
+ // stdout is gone; the exit code is the whole verdict.
1165
+ }
1166
+ return whole ? directive.exitCode : INTERRUPTED_BARE_EXIT;
1167
+ }
991
1168
  /** The machine-readable code a Hermes `execute_code` call is refused with. */
992
1169
  export const HERMES_EXECUTE_CODE_REFUSAL = "hook-hermes-execute-code-unbound";
993
1170
  /** The tool that carries a program and no path, no argv and no directory. */
@@ -2111,10 +2288,221 @@ function commandClassify(argv, streams, cwd) {
2111
2288
  return EXIT_OK;
2112
2289
  }
2113
2290
  function sleepSync(ms) {
2291
+ // A synchronous wait never runs with termination signals held by the log
2292
+ // lock's guard (APRV-479 round 2, RS2: `approval codex bridge` waits for a
2293
+ // human here through `driveSync`).
2294
+ settleTerminationGuards();
2114
2295
  if (ms <= 0)
2115
2296
  return;
2116
2297
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2117
2298
  }
2299
+ /** The longest delay `setTimeout` honours (2^31 - 1 ms). */
2300
+ const MAX_TIMER_MS = 2_147_483_647;
2301
+ /** Run `steps` to its verdict, sleeping each pause synchronously. */
2302
+ function driveSync(steps) {
2303
+ for (;;) {
2304
+ const next = steps.next(false);
2305
+ if (next.done === true)
2306
+ return next.value;
2307
+ if (next.value > 0)
2308
+ sleepSync(next.value);
2309
+ }
2310
+ }
2311
+ /**
2312
+ * Run `steps` to its verdict, sleeping each pause on the event loop.
2313
+ *
2314
+ * A zero pause is TWO `setImmediate` hops, and the second is what guarantees a
2315
+ * poll phase in between. libuv hands a signal to its JS listeners only when the
2316
+ * poll phase reads the signal pipe. A single immediate scheduled from a
2317
+ * poll-phase callback runs in the same iteration's check phase, before any
2318
+ * further poll, so a signal that arrived during the synchronous stretch would
2319
+ * still be sitting in the pipe when the spend ran. That is the case whenever
2320
+ * the run is still inside the continuation of the bin's `import()` of `dist/`
2321
+ * on a Node whose module load turns the loop (24, and likely 22; APRV-473
2322
+ * refuter, reproduced on 24). A second immediate scheduled from the check phase
2323
+ * runs in the NEXT iteration's check phase, after that iteration's poll, from
2324
+ * whichever phase the first was scheduled.
2325
+ */
2326
+ async function driveYielding(steps) {
2327
+ for (;;) {
2328
+ const next = steps.next(true);
2329
+ if (next.done === true)
2330
+ return next.value;
2331
+ // Clamped to the largest delay a timer holds: past it Node fires the timer
2332
+ // at once, and a pause would become a busy poll. The wait re-reads its
2333
+ // deadline after every pause, so a shorter pause changes nothing it judges.
2334
+ const ms = Math.min(next.value, MAX_TIMER_MS);
2335
+ await new Promise((settle) => {
2336
+ if (ms <= 0)
2337
+ setImmediate(() => setImmediate(settle));
2338
+ else
2339
+ setTimeout(settle, ms);
2340
+ });
2341
+ }
2342
+ }
2343
+ /**
2344
+ * The pause before anything appends `execution.started` or `gate.bypassed`
2345
+ * (APRV-473): a poll phase of the event loop under {@link driveYielding},
2346
+ * nothing under {@link driveSync}.
2347
+ *
2348
+ * Everything between two pauses runs synchronously, so a signal that lands
2349
+ * during the stdin read, the classification, or the poll read that found the
2350
+ * grant is held until the next pause. Without this one, the next point it
2351
+ * could be dispatched is after the spend, and a hook interrupted before its
2352
+ * verdict would record the execution of a call its harness had already given
2353
+ * up on. With it, the listener in force runs first: the wait's handler
2354
+ * withdraws the question and blocks, the early guard (`hermesFailClosed`)
2355
+ * blocks, and either one exits the process before the spend is reached.
2356
+ *
2357
+ * Shared by every harness (APRV-475): it is a yield inside the one gate chain,
2358
+ * so there is no per-harness copy to drift. A signal is HELD only while some
2359
+ * JS listener for it is registered. Outside the wait, the harnesses other than
2360
+ * Hermes register none, so there a signal takes its default disposition the
2361
+ * moment it arrives (the process dies with nothing printed and nothing spent),
2362
+ * and this pause matters for them at the post-wait spend, where the wait's
2363
+ * handler is registered.
2364
+ *
2365
+ * Taken inside {@link spendUnderLock} since APRV-478, immediately before the
2366
+ * spend's one attempt and after any wait for the log's lock, so nothing that can
2367
+ * wait sits between the last pause and the append.
2368
+ */
2369
+ const BEFORE_SPEND = 0;
2370
+ /**
2371
+ * Did this attempt lose a race for the log's lock, writing nothing?
2372
+ *
2373
+ * Two shapes: the spend's own append refused `append-failed` with the writer's
2374
+ * `lock-timeout`, and a budget refusal whose `budget.exceeded` evidence lost the
2375
+ * race (`startHarnessExecution` carries the writer's error on that refusal for
2376
+ * this, APRV-478). In both nothing was appended, so re-running the whole
2377
+ * attempt is safe and gives the record the chance a synchronous lock wait gave
2378
+ * it.
2379
+ */
2380
+ function lockTimedOut(result) {
2381
+ return (!result.ok &&
2382
+ (result.code === "append-failed" || result.code === "budget-exceeded") &&
2383
+ result.append?.code === "lock-timeout");
2384
+ }
2385
+ /**
2386
+ * Run one spend (an append that authorizes a harness call) without ever waiting
2387
+ * for the log's lock synchronously (APRV-478).
2388
+ *
2389
+ * ## The hole
2390
+ *
2391
+ * Every writer waits for `<log>.lock` inside `core/log.ts`, retrying with an
2392
+ * `Atomics.wait` sleep for up to {@link DEFAULT_LOCK_TIMEOUT_MS}. On the gate
2393
+ * path that wait came AFTER the last pause ({@link BEFORE_SPEND}), so a signal
2394
+ * that arrived while another writer (the daemon, routinely) held the lock was
2395
+ * held by the wait's listener through the whole stretch, the spend appended
2396
+ * `execution.started` and the allow went out, and the wait's `finally` then
2397
+ * removed the listener and the held signal was discarded with it: a start
2398
+ * recorded after the harness had abandoned the call, on every harness.
2399
+ *
2400
+ * ## What this does instead
2401
+ *
2402
+ * The wait for the lock becomes yields of the same generator chain, under ONE
2403
+ * deadline for the whole spend (the writer's own `lockTimeoutMs`, default
2404
+ * {@link DEFAULT_LOCK_TIMEOUT_MS}, taken once when the spend begins):
2405
+ *
2406
+ * 1. While the lockfile is present ({@link appendLockHeld}, advisory) and the
2407
+ * deadline has not passed, yield the retry interval (`lockRetryMs`, default
2408
+ * {@link DEFAULT_LOCK_RETRY_MS}).
2409
+ * 2. {@link BEFORE_SPEND}: the zero pause, taken when the spend begins and again
2410
+ * after any wait, so it always comes immediately before the attempt (with at
2411
+ * most the probe in between). This
2412
+ * is the pause the guarantee rests on: two `setImmediate` hops cross a poll
2413
+ * phase from any phase, so a signal that arrived during the wait or the
2414
+ * probe is dispatched here at the latest (a retry timer usually dispatches
2415
+ * it sooner, but a timer scheduled outside a timer callback can fire before
2416
+ * the next poll). The listener in force then blocks, withdraws what it can
2417
+ * and exits, and nothing below runs.
2418
+ * 3. One attempt with `lockTimeoutMs: 0`: the whole spend (a fresh verified read,
2419
+ * every check, compare-and-append against the head that read saw), whose
2420
+ * append tries the lock exactly ONCE and never sleeps. The lock protocol is
2421
+ * `core/log.ts`'s, untouched: atomic create-or-fail, never stolen, the head
2422
+ * compared under it, so this changes when a writer tries and never what a
2423
+ * try may do.
2424
+ *
2425
+ * An attempt that lost the race (another writer took the lock between the probe
2426
+ * and the try, a window as long as the attempt's own read) yields the retry
2427
+ * interval and goes back to 1, until the deadline: the same number of tries
2428
+ * inside the same bound as `acquireLock`'s synchronous retry, each one a whole
2429
+ * fresh attempt. An attempt that loses at or after the deadline is final,
2430
+ * exactly as a writer's own `lock-timeout` is: the caller denies, and nothing
2431
+ * was appended (fail closed, §11.1 invariant 8). See {@link lockTimedOut} for
2432
+ * the two refusals that count as a lost race.
2433
+ *
2434
+ * What is left between the last pause and the append is the spend's own read
2435
+ * of the log (milliseconds on a small log, longer on a large one) and its
2436
+ * single try at the lock, never a wait on another process. A signal there is
2437
+ * answered by the verdict the spend reaches.
2438
+ *
2439
+ * **The trade, on the yielding path only.** The writer's synchronous wait read
2440
+ * the log first and then spun on the lock itself, so it caught a gap of a
2441
+ * millisecond. Here the spend's read sits between the probe that saw the lock
2442
+ * free and the try, so a lock that is only ever free for gaps shorter than that
2443
+ * read (a writer holding it at a duty cycle near 100%) can run the spend out of
2444
+ * its bound where the synchronous wait got through. That ends in the deny
2445
+ * above, the grant left standing for a retry: fail closed. Holding the lock
2446
+ * across a yield to close it is not an option, because a handler that exits at
2447
+ * that yield would leave a lockfile that is never stolen.
2448
+ *
2449
+ * **Under {@link driveSync} nothing changes.** `commandHook` in process and in
2450
+ * the serve worker, and `decideHarnessCall` for the Codex bridge, have no event
2451
+ * loop that a signal could be dispatched on, so a yielded wait buys them
2452
+ * nothing. The driver says so at the first pause (see {@link GateSteps}), and
2453
+ * the spend is then exactly what it was: that zero pause (no pause under this
2454
+ * driver) and one call with the caller's own options, whose writer waits for
2455
+ * the lock synchronously, per head-moved attempt, as it always did.
2456
+ *
2457
+ * `attempt` receives the append options to use (the caller's own, with
2458
+ * `lockTimeoutMs: 0` on the yielding path), and the attempt number (1-based),
2459
+ * for a caller whose first attempt may reuse a read it already holds and whose
2460
+ * later ones may not.
2461
+ */
2462
+ function* spendUnderLock(logPath, append, attempt) {
2463
+ // The shared pause, and the driver's answer to whether it turns the loop.
2464
+ const yielding = (yield BEFORE_SPEND) === true;
2465
+ if (!yielding)
2466
+ return attempt(append ?? {}, 1);
2467
+ const askedBudget = append?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
2468
+ const budgetMs = Number.isFinite(askedBudget) ? Math.max(0, askedBudget) : DEFAULT_LOCK_TIMEOUT_MS;
2469
+ // Never a zero (or non-finite) retry: under `driveSync` a zero pause is no
2470
+ // pause, and the probe loop would spin.
2471
+ const askedRetry = append?.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;
2472
+ const retryMs = Number.isFinite(askedRetry) ? Math.max(1, askedRetry) : DEFAULT_LOCK_RETRY_MS;
2473
+ const once = { ...append, lockTimeoutMs: 0 };
2474
+ const deadline = Date.now() + budgetMs;
2475
+ for (let tries = 1;; tries += 1) {
2476
+ let waited = tries > 1;
2477
+ for (;;) {
2478
+ if (!appendLockHeld(logPath))
2479
+ break;
2480
+ const left = deadline - Date.now();
2481
+ if (left <= 0)
2482
+ break;
2483
+ waited = true;
2484
+ yield Math.min(retryMs, left);
2485
+ }
2486
+ // Uncontended first try: the pause above was taken a probe ago, and the
2487
+ // probe is the only thing that ran since. Otherwise the pause again, now
2488
+ // immediately before the attempt.
2489
+ if (waited)
2490
+ yield BEFORE_SPEND;
2491
+ const result = attempt(once, tries);
2492
+ if (!lockTimedOut(result))
2493
+ return result;
2494
+ const left = deadline - Date.now();
2495
+ if (left > 0) {
2496
+ yield Math.min(retryMs, left);
2497
+ continue;
2498
+ }
2499
+ // A copy, as `core/head-retry.ts` makes its own: only the message changes.
2500
+ return {
2501
+ ...result,
2502
+ message: `${result.message ?? "lock-timeout"}; the spend waited for the log's lock on the event loop for its whole ${String(budgetMs)} ms bound, trying every ${String(retryMs)} ms, and another writer held it at each of its ${String(tries)} attempt${tries === 1 ? "" : "s"} (APRV-478). Nothing was appended.`,
2503
+ };
2504
+ }
2505
+ }
2118
2506
  function truncate(text, limit) {
2119
2507
  const collapsed = text.replace(/\s+/gu, " ").trim();
2120
2508
  return collapsed.length <= limit ? collapsed : `${collapsed.slice(0, limit - 1)}…`;
@@ -2742,8 +3130,25 @@ function withdrawAbandoned(run, streams, records, now, keepHash, only = null) {
2742
3130
  * is about (APRV-146). The gate requires it and compares it against what the
2743
3131
  * human answered: the same value keyed the carryover that found these grants, so
2744
3132
  * presenting it states, at the spend, the fact the match was made on.
3133
+ *
3134
+ * Steps since APRV-478: each key is spent through {@link spendUnderLock}, so a
3135
+ * wait for the log's lock is a yield and the shared pause before the spend
3136
+ * ({@link BEFORE_SPEND}) is taken there, immediately before each append.
3137
+ *
3138
+ * **A multi-key spend pauses before every key, deliberately.** A signal that
3139
+ * lands after the first key is spent and before the second is dispatched at the
3140
+ * second key's pause: the call blocks with the first key's `execution.started`
3141
+ * already recorded (before the signal) and the rest unspent. That is the same
3142
+ * outcome as a spend that fails on a later key, above: a recorded start for a
3143
+ * call the harness does not run, a grant that costs one more prompt, and
3144
+ * nothing authorized. The alternative, spending the later keys with no pause,
3145
+ * would make each later key's lock wait synchronous again, and a signal there
3146
+ * would be held through starts recorded AFTER it and answered with an allow.
3147
+ * Recording nothing after the signal and blocking is the stricter of the two.
3148
+ * An atomic spend of every key at once needs a gate operation that does not
3149
+ * exist; this is the residue the SPEC hunk names for a multi-class call.
2745
3150
  */
2746
- function consumeGrants(run, keys, hash,
3151
+ function* consumeGrants(run, keys, hash,
2747
3152
  /**
2748
3153
  * This invocation's own task id (APRV-200). The gate compares it against the
2749
3154
  * task the request record carries and records `grant_origin: "direct"` only
@@ -2751,11 +3156,14 @@ function consumeGrants(run, keys, hash,
2751
3156
  */
2752
3157
  task) {
2753
3158
  for (const key of keys) {
2754
- const spent = consumeHarnessGrant(run.logPath, key, run.actor, {
3159
+ const spent = yield* spendUnderLock(run.logPath, run.options.append, (append) => consumeHarnessGrant(run.logPath, key, run.actor, {
2755
3160
  ...run.options,
3161
+ append,
2756
3162
  presentedPayloadHash: hash,
2757
3163
  spendingTask: task,
2758
- });
3164
+ // APRV-499 (refutation S3): a mapped or unmapped spend names its tool.
3165
+ ...(run.harnessTool === undefined ? {} : { harnessTool: run.harnessTool }),
3166
+ }));
2759
3167
  if (!spent.ok)
2760
3168
  return { code: spent.code, message: `${key}: ${spent.message}` };
2761
3169
  }
@@ -2914,10 +3322,20 @@ known = null) {
2914
3322
  * declaration would buy no oversight and would double the volume of exactly the
2915
3323
  * traffic this is trying not to drown the log in. The supervised classes are
2916
3324
  * registered already, by the caller, which is what makes them sampleable.
3325
+ *
3326
+ * Steps since APRV-478, for the reason {@link consumeGrants} gives: each class
3327
+ * is recorded through {@link spendUnderLock}, which takes the shared pause.
2917
3328
  */
2918
- function recordUnattended(run, task, classes, hash) {
3329
+ function* recordUnattended(run, task, classes, hash) {
3330
+ const harnessTool = run.harnessTool;
2919
3331
  for (const cls of classes) {
2920
- const started = startHarnessExecution(run.logPath, { task, actionKey: `${task}:${cls}`, cls, payload_hash: hash }, run.actor, run.options);
3332
+ const started = yield* spendUnderLock(run.logPath, run.options.append, (append) => startHarnessExecution(run.logPath, {
3333
+ task,
3334
+ actionKey: `${task}:${cls}`,
3335
+ cls,
3336
+ payload_hash: hash,
3337
+ ...(harnessTool === undefined ? {} : { harness_tool: harnessTool }),
3338
+ }, run.actor, { ...run.options, append }));
2921
3339
  if (!started.ok)
2922
3340
  return { code: started.code, message: `${cls}: ${started.message}` };
2923
3341
  }
@@ -3045,7 +3463,11 @@ function renderVerdict(streams, adapter, codexCommand, verdict) {
3045
3463
  ? allow(streams, verdict.reason, adapter.kind, codexCommand)
3046
3464
  : deny(streams, verdict.code, verdict.detail, adapter.kind);
3047
3465
  }
3048
- export function gateHarnessCall(streams, run, classes,
3466
+ export function gateHarnessCall(...args) {
3467
+ return driveSync(gateHarnessSteps(...args));
3468
+ }
3469
+ /** {@link gateHarnessCall} as steps, for a driver that yields (APRV-473). */
3470
+ function* gateHarnessSteps(streams, run, classes,
3049
3471
  /**
3050
3472
  * The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
3051
3473
  * itself for a file tool (APRV-124). Whatever this is, it is what reaches the
@@ -3225,6 +3647,9 @@ floor = null) {
3225
3647
  const registered = register(run.logPath, { task, envelope }, run.actor, {
3226
3648
  ...run.options,
3227
3649
  ...(provenance === null ? {} : { harness: provenance }),
3650
+ // APRV-499: the hook is the one caller that may declare the reserved
3651
+ // harness.tool.unmapped class.
3652
+ toolMapHook: true,
3228
3653
  });
3229
3654
  if (!registered.ok) {
3230
3655
  return sayDeny(`hook-gate-refused:${registered.code}`, registered.message);
@@ -3306,8 +3731,9 @@ floor = null) {
3306
3731
  // SPEC.md §6.3), so there is nothing to wait for and nothing to spend.
3307
3732
  // What there is, since APRV-141, is something to charge: the start event
3308
3733
  // is this execution's authorization, and the registration `fresh` just
3309
- // wrote is what makes it a sampleable one.
3310
- const charged = recordUnattended(run, task, classes, hash);
3734
+ // wrote is what makes it a sampleable one. The shared pause before the
3735
+ // spend is taken inside (APRV-473, APRV-478).
3736
+ const charged = yield* recordUnattended(run, task, classes, hash);
3311
3737
  if (charged !== null) {
3312
3738
  return sayDeny(`hook-gate-refused:${charged.code}`, charged.message);
3313
3739
  }
@@ -3315,7 +3741,7 @@ floor = null) {
3315
3741
  }
3316
3742
  // Every gated class carried an unspent grant: a human already answered this
3317
3743
  // exact question about these exact bytes, and nobody is asked again.
3318
- const failed = consumeGrants(run, spendKeys, hash, task);
3744
+ const failed = yield* consumeGrants(run, spendKeys, hash, task);
3319
3745
  if (failed !== null) {
3320
3746
  return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
3321
3747
  }
@@ -3349,24 +3775,42 @@ floor = null) {
3349
3775
  // `process.exit` is deliberate and immediate: the default disposition for
3350
3776
  // these signals is to die, and a handler that only withdrew would leave the
3351
3777
  // hook wedged in its poll loop with the harness waiting on it.
3778
+ //
3779
+ // APRV-473, APRV-475. On every `approval hook <harness>` CLI route this
3780
+ // handler gets its turn: the wait yields to the event loop between polls
3781
+ // (`driveYielding`), so a signal is dispatched within one pause of its
3782
+ // arrival instead of being held until the wait returns. It exits, so nothing
3783
+ // after it in this function runs: no poll reads the grant a human gives
3784
+ // afterwards, and nothing spends it. The synchronous callers (`commandHook`
3785
+ // in process, the Codex bridge's `decideHarnessCall`, a serve worker thread)
3786
+ // drive these steps with `driveSync`, under which this listener is
3787
+ // registered and removed without the loop ever turning, so it never runs
3788
+ // there and never writes into a stdout that is not a hook's (the bridge's is
3789
+ // a JSON-RPC channel). A serve thread's caller departing is `callerGone`.
3352
3790
  const onSignal = (signal) => {
3353
3791
  leaveWait();
3354
- withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
3355
- // APRV-445. On Hermes an exit with nothing on stdout is an ALLOW unless the
3356
- // exit is 2, and a harness tearing down may still read the verdict. So the
3357
- // block directive goes out first, written synchronously because
3358
- // `process.exit` below does not wait for a stream to drain.
3359
- if (run.harness === "hermes") {
3360
- const directive = harnessBlockDirective("hook-interrupted", `the hook received ${signal} while waiting for a decision; nothing authorizes this call`, "hermes");
3361
- try {
3362
- writeSync(1, directive.stdout);
3363
- }
3364
- catch {
3365
- // stdout is gone; the exit code below is the whole verdict.
3366
- }
3367
- process.exit(HERMES_DENY_EXIT);
3792
+ // APRV-445, APRV-475. A harness tearing down may still read the verdict,
3793
+ // and on several harnesses an exit with nothing on stdout is not a block
3794
+ // (Hermes reads it as an ALLOW unless the exit is 2; Claude Code, Codex
3795
+ // and Muse read most such exits as a failed hook and run the call). So the
3796
+ // harness's own block directive goes out FIRST, in its own dialect,
3797
+ // written synchronously, before the withdrawal: the withdrawal is a
3798
+ // compare-and-append that can wait up to the lock timeout behind another
3799
+ // writer, and a harness that escalates SIGTERM to SIGKILL inside that wait
3800
+ // must already be holding the block (APRV-475 refuter). The directive does
3801
+ // not depend on the withdrawal's outcome, and a withdrawal that never lands
3802
+ // leaves the question open as a timed-out one is.
3803
+ const exitCode = writeInterruptedWait(signal, run.harness);
3804
+ try {
3805
+ withdrawPending(run, streams, ownKeys, `the requesting hook process received ${signal} while waiting; the session is ending, so no retry will adopt this request`);
3806
+ }
3807
+ catch (cause) {
3808
+ // The withdrawal failed outright (an I/O fault inside the append). The
3809
+ // block is already on stdout: a block with the question left standing
3810
+ // is the stricter of the two outcomes this handler can still reach.
3811
+ streams.err(`approval: the hook could not withdraw its request after ${signal}: ${cause instanceof Error ? cause.message : String(cause)}\n`);
3368
3812
  }
3369
- process.exit(EXIT_USAGE);
3813
+ process.exit(exitCode);
3370
3814
  };
3371
3815
  const onTerm = () => onSignal("SIGTERM");
3372
3816
  const onInt = () => onSignal("SIGINT");
@@ -3431,7 +3875,7 @@ floor = null) {
3431
3875
  streams.err(`approval: the log's last line was incomplete when this hook read it (another writer mid-append); reading again rather than giving up on ${task}\n`);
3432
3876
  }
3433
3877
  tornReads += 1;
3434
- sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
3878
+ yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
3435
3879
  continue;
3436
3880
  }
3437
3881
  // AT the deadline (APRV-427 review). A clean read at this tick would
@@ -3536,13 +3980,22 @@ floor = null) {
3536
3980
  if (states.every((state) => state === "granted")) {
3537
3981
  // The grants are spent before the allow is printed, so this exact
3538
3982
  // command cannot ride the same authorization twice.
3983
+ //
3984
+ // APRV-473, APRV-478. `consumeGrants` turns the event loop before it
3985
+ // spends: once per retry while another writer holds the log's lock,
3986
+ // and once more (`BEFORE_SPEND`) immediately before each append. A
3987
+ // signal that arrived while this read ran, or while the spend waited
3988
+ // for the lock, is dispatched there, to the handler above, which
3989
+ // blocks, withdraws (a no-op now the request is decided, refused
3990
+ // `already-decided`) and exits. The grant is then left unspent rather
3991
+ // than recorded as the start of a call the harness gave up on.
3539
3992
  leaveWait();
3540
3993
  // Looked at again now the lock is held: re-taking it may have waited,
3541
3994
  // and a caller that left meanwhile must not have its grant spent on a
3542
3995
  // verdict nobody will receive. The retry is who spends it.
3543
3996
  if (callerGone())
3544
3997
  return sayGone();
3545
- const failed = consumeGrants(run, spendKeys, hash, task);
3998
+ const failed = yield* consumeGrants(run, spendKeys, hash, task);
3546
3999
  if (failed !== null) {
3547
4000
  return sayDeny(`hook-gate-refused:${failed.code}`, failed.message);
3548
4001
  }
@@ -3586,7 +4039,7 @@ floor = null) {
3586
4039
  }
3587
4040
  return keptOpen(`no decision on ${waitKeys.join(", ")} within the hook's ${waitText(run)}.`, stillLagging);
3588
4041
  }
3589
- sleepSync(Math.min(run.intervalMs, Math.max(0, deadline - Date.now())));
4042
+ yield Math.min(run.intervalMs, Math.max(0, deadline - Date.now()));
3590
4043
  }
3591
4044
  }
3592
4045
  catch (cause) {
@@ -3930,7 +4383,14 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
3930
4383
  // nothing, and the close below finds no start and says so in its own words.
3931
4384
  !adapter.readTools.includes(input.toolName) &&
3932
4385
  // APRV-445: a tool the rule table gated wrote a start like any other.
3933
- toolRuleOf(adapter, input)?.kind !== "gated") {
4386
+ toolRuleOf(adapter, input)?.kind !== "gated" &&
4387
+ // APRV-499: an unclaimed tool the policy maps, or one it records or asks
4388
+ // about as unmapped, wrote a start too. So did any unclaimed tool whose
4389
+ // policy no longer loads or is not the attested one, as far as this half
4390
+ // can tell, so that one goes on to the close, which finds the start or says
4391
+ // there is none. Only an ATTESTED policy that loads and leaves the tool
4392
+ // outside the gate keeps this answer (fix round 1, refutation B1).
4393
+ (!unclaimedTool(adapter, input) || unclaimedUngated(flags, cwd, input.toolName))) {
3934
4394
  return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
3935
4395
  }
3936
4396
  if (input.toolUseId === null) {
@@ -3971,7 +4431,23 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
3971
4431
  }
3972
4432
  return report(streams, POST_TOOL_REPORTED, `recorded ${reading.outcome} for ${String(finished.records.length)} delegated execution(s) of ${finished.task}`, { task: finished.task, outcome: reading.outcome, appended: finished.records.length });
3973
4433
  }
3974
- function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
4434
+ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = [],
4435
+ /**
4436
+ * The loaded policy whose `tools` mapping and `defaults.unmapped_tool` an
4437
+ * unclaimed call is answered by (APRV-499), or `null` when there is none to
4438
+ * read (the open window over a policy that did not load).
4439
+ */
4440
+ toolPolicy = null,
4441
+ /**
4442
+ * May an unclaimed call the policy leaves outside the gate be described as
4443
+ * "not a gated tool"? (APRV-499 fix round 1, refutations B1 and S1.) The
4444
+ * closed path passes `true` and checks the attestation on the allow it gets
4445
+ * back. The open window passes `true` only for an attested policy that
4446
+ * loads; otherwise an unclaimed call is described under
4447
+ * `harness.tool.unmapped`, so the bypass RECORDS it rather than letting it
4448
+ * run unrecorded on the word of a file nobody vouched for.
4449
+ */
4450
+ ungatedAllowed = true) {
3975
4451
  if (adapter.kind === "codex" && input.toolName === "apply_patch") {
3976
4452
  // APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
3977
4453
  // as a MAP of path to change, inline on the request, and never as an
@@ -4190,6 +4666,42 @@ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
4190
4666
  notes: [],
4191
4667
  };
4192
4668
  }
4669
+ // APRV-499: a call the adapter's own tables leave unclaimed (every MCP tool
4670
+ // among them) is answered by the policy's `tools` mapping, first match wins,
4671
+ // then by `defaults.unmapped_tool`. Below the rule table and the shell, and
4672
+ // above the read and file branches only because those two never see an
4673
+ // unclaimed call: the predicate excludes them. The payload is the whole call,
4674
+ // as the rule table's is, so a grant binds every argument; only the NAME is
4675
+ // written to the log, on the start record.
4676
+ if (unclaimedTool(adapter, input)) {
4677
+ const verdict = toolPolicy === null ? { kind: "not-gated" } : toolMapVerdict(toolPolicy, input.toolName);
4678
+ if (verdict.kind === "not-gated" && ungatedAllowed) {
4679
+ return { kind: "allow", reason: `${input.toolName} is not a gated tool`, unclaimed: true };
4680
+ }
4681
+ // Refutation S4: a name the record could not carry is refused here, by
4682
+ // name, before anything is appended, rather than at the write boundary.
4683
+ if (!isRecordableToolName(input.toolName)) {
4684
+ return {
4685
+ kind: "deny",
4686
+ code: "hook-io",
4687
+ detail: `tool-name-invalid: ${JSON.stringify(input.toolName.slice(0, 80))}${input.toolName.length > 80 ? "…" : ""} is classified by the policy's tool mapping, and a tool so classified is recorded by name, which must be 1 to 256 of letters, digits, _, ., : and - (the characters a tools entry can match). Nothing was appended`,
4688
+ };
4689
+ }
4690
+ const cls = verdict.kind === "not-gated" ? UNMAPPED_TOOL_CLASS : verdict.cls;
4691
+ const note = verdict.kind === "mapped"
4692
+ ? `tools entry ${String(verdict.index)} (${JSON.stringify(verdict.match)}) maps ${input.toolName} to ${verdict.cls} (APRV-499)`
4693
+ : verdict.kind === "unmapped"
4694
+ ? `${input.toolName} matches no tools entry; defaults.unmapped_tool: ${verdict.mode} (APRV-499)`
4695
+ : `${input.toolName} is outside the gate only under an attested policy that loads, and this one ${toolPolicy === null ? "did not load" : "is not attested"}, so it is described as ${UNMAPPED_TOOL_CLASS} (APRV-499)`;
4696
+ return {
4697
+ kind: "gated",
4698
+ classes: [cls],
4699
+ payload: { tool: input.toolName, input: input.toolInput },
4700
+ headline: `${input.toolName} (${cls})`,
4701
+ notes: [note],
4702
+ harnessTool: input.toolName,
4703
+ };
4704
+ }
4193
4705
  // APRV-347, above the file-tool branch because the two lists are disjoint and
4194
4706
  // a read is the cheaper question to answer: a read inside the scope, or one
4195
4707
  // naming no path at all, returns `null` here and takes the allow below.
@@ -4809,7 +5321,7 @@ function bypassBanner(window, classes, seq) {
4809
5321
  * command that ran and left no record is the one state this feature must not be
4810
5322
  * able to reach, so an append failure is a deny.
4811
5323
  */
4812
- function runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
5324
+ function* runBypass(streams, input, adapter, cwd, logPath, flags, actor, window,
4813
5325
  /**
4814
5326
  * The verified read `window` was derived from (APRV-294), handed on to the
4815
5327
  * append so the same records answer "is a window open" and "which head does
@@ -4831,7 +5343,18 @@ decidedOn) {
4831
5343
  // APRV-347. The window suspends the POLICY, not the classification: a read
4832
5344
  // outside the scope is described as one here too, so the bypass RECORD says
4833
5345
  // what was actually authorized rather than calling it an ordinary read.
4834
- resolveReadRoots(cwd, policyRootOf(load, scope.root), load.ok ? load.policy.read_scope?.roots : undefined));
5346
+ resolveReadRoots(cwd, policyRootOf(load, scope.root), load.ok ? load.policy.read_scope?.roots : undefined),
5347
+ // APRV-499. The window suspends the policy's ANSWER, not its mapping: a
5348
+ // mapped or unmapped tool is described under its class here too, so the
5349
+ // bypass record names it.
5350
+ load.ok ? load.policy : null,
5351
+ // Fix round 1 (refutations S1 and B1): "not a gated tool" is believed only
5352
+ // from an attested policy that loads. A policy that did not load, or one
5353
+ // nobody attested, cannot say a tool is outside the gate, so the call is
5354
+ // described under harness.tool.unmapped and the bypass records it: a
5355
+ // window being open does not let a call run unrecorded.
5356
+ unclaimedStanding(scope.options, cwd, input.toolName, decidedOn?.records ?? null).kind ===
5357
+ "ungated");
4835
5358
  if (described.kind === "deny") {
4836
5359
  return deny(streams, described.code, `${described.detail} The open window does not reach this: a command the classifier cannot read is a command nothing here can establish is safe to run unapproved.`, adapter.kind);
4837
5360
  }
@@ -4869,7 +5392,12 @@ decidedOn) {
4869
5392
  // window path is the one a human comes back to read, so the binary that
4870
5393
  // printed the allow is named on it.
4871
5394
  const provenance = harnessProvenance(adapter.kind, input.harnessVersion);
4872
- const recorded = recordGateBypass(logPath, {
5395
+ // APRV-473 refuter: the bypass record is this path's authorization, exactly
5396
+ // as `execution.started` is the gated path's, so a signal held through the
5397
+ // stdin read is dispatched before it is written (`BEFORE_SPEND`, taken
5398
+ // inside `spendUnderLock`, after any wait for the log's lock, which yields:
5399
+ // APRV-478).
5400
+ const recorded = yield* spendUnderLock(logPath, undefined, (append, round) => recordGateBypass(logPath, {
4873
5401
  tool: input.toolName,
4874
5402
  summary: truncate(described.headline, SUMMARY_LIMIT),
4875
5403
  classes,
@@ -4878,14 +5406,17 @@ decidedOn) {
4878
5406
  ...(input.toolUseId === null ? {} : { toolUseId: input.toolUseId }),
4879
5407
  ...(input.cwd.length === 0 ? {} : { cwd: input.cwd }),
4880
5408
  ...(provenance === null ? {} : { harness: provenance }),
4881
- }, actor, {},
4882
- // APRV-294: the window this verdict was decided under, and the read it was
4883
- // decided on. The append uses both, so a window that ended in between is
4884
- // reported as the thing that happened rather than as "no window is open".
5409
+ }, actor, { append },
5410
+ // APRV-294: the window this verdict was decided under, and the read it
5411
+ // was decided on. The append uses both, so a window that ended in
5412
+ // between is reported as the thing that happened rather than as "no
5413
+ // window is open". The read seeds the FIRST round only: a later round
5414
+ // exists because another writer took the lock, so that read's head is
5415
+ // already known to be stale, and `recordGateBypass` re-reads.
4885
5416
  {
4886
5417
  openedSeq: window.seq,
4887
- ...(decidedOn === null ? {} : { read: decidedOn }),
4888
- });
5418
+ ...(decidedOn === null || round > 1 ? {} : { read: decidedOn }),
5419
+ }));
4889
5420
  if (!recorded.ok) {
4890
5421
  // Invariant 8: the record lands before the allow, so a refusal here is a
4891
5422
  // deny even though a window is open. `append-failed` reaches the caller
@@ -4898,7 +5429,7 @@ decidedOn) {
4898
5429
  const notes = [...described.notes, ...(policyNote === null ? [] : [policyNote])];
4899
5430
  return allow(streams, `gate-open: ${classes.join(", ")} bypassed by the window opened at seq ${String(window.seq)} by ${window.openedBy} (expires ${window.expiresAt}); recorded as gate.bypassed seq ${String(recorded.record.seq)}${notes.length === 0 ? "" : ` (${notes.join("; ")})`}`, adapter.kind, codexCommand);
4900
5431
  }
4901
- function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5432
+ function* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
4902
5433
  // Codex and (APRV-445) Hermes answer a misconfigured entry with a verdict:
4903
5434
  // on both a usage error's empty stdout is a harness that runs the call, and
4904
5435
  // on Hermes a non-zero exit with an empty stdout is an allow outright.
@@ -5146,28 +5677,41 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5146
5677
  input.toolName !== adapter.shellTool &&
5147
5678
  !adapter.fileTools.includes(input.toolName)) {
5148
5679
  if (!adapter.readTools.includes(input.toolName)) {
5149
- return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5680
+ // APRV-499: an unclaimed tool is answered here, with one policy load,
5681
+ // only when the policy loads and neither maps it nor declares
5682
+ // `defaults.unmapped_tool`. Otherwise it goes on to the path below, where
5683
+ // a mapped or unmapped call is classified and a policy that did not load
5684
+ // is refused.
5685
+ // Fix round 1 (refutation B1): and only when those bytes are the ATTESTED
5686
+ // policy, which costs one verified read of the log. Anything else goes on
5687
+ // to the path below, which refuses it in the gate's own words or, under
5688
+ // an open window, records the bypass.
5689
+ if (unclaimedUngated(parsed.flags, cwd, input.toolName)) {
5690
+ return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5691
+ }
5150
5692
  }
5151
- // APRV-347, and the placement is the whole of its cost. A read tool call is
5152
- // the most frequent event a session produces, and before this task it was
5153
- // answered here with no policy load, no verified read of the log and no
5154
- // window lookup. Everything below this line is expensive, so the reads that
5155
- // do not need it must not pay for it.
5156
- //
5157
- // The short-circuit is sound because `read_scope` is ADDITIVE: it can only
5158
- // widen the roots, so a target already inside the BUILT-IN roots is inside
5159
- // the effective ones whatever the policy says, and the policy need not be
5160
- // read to know it. A target outside them falls through, the policy is
5161
- // loaded below, and `describeToolCall` asks again with the widened set —
5162
- // which is where a policy-declared root actually takes effect.
5163
- //
5164
- // The cost of the fast path is a handful of `realpath` calls and no process
5165
- // spawn, provided the hook is configured with `--dir` as the docs show
5166
- // (otherwise `hookScope` runs `git rev-parse` to find the primary).
5167
- const early = hookScope(parsed.flags, cwd);
5168
- if (credentialReadGate(input.toolName, input.toolInput, cwd) === null &&
5169
- readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
5170
- return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5693
+ else {
5694
+ // APRV-347, and the placement is the whole of its cost. A read tool call is
5695
+ // the most frequent event a session produces, and before this task it was
5696
+ // answered here with no policy load, no verified read of the log and no
5697
+ // window lookup. Everything below this line is expensive, so the reads that
5698
+ // do not need it must not pay for it.
5699
+ //
5700
+ // The short-circuit is sound because `read_scope` is ADDITIVE: it can only
5701
+ // widen the roots, so a target already inside the BUILT-IN roots is inside
5702
+ // the effective ones whatever the policy says, and the policy need not be
5703
+ // read to know it. A target outside them falls through, the policy is
5704
+ // loaded below, and `describeToolCall` asks again with the widened set —
5705
+ // which is where a policy-declared root actually takes effect.
5706
+ //
5707
+ // The cost of the fast path is a handful of `realpath` calls and no process
5708
+ // spawn, provided the hook is configured with `--dir` as the docs show
5709
+ // (otherwise `hookScope` runs `git rev-parse` to find the primary).
5710
+ const early = hookScope(parsed.flags, cwd);
5711
+ if (credentialReadGate(input.toolName, input.toolInput, cwd) === null &&
5712
+ readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
5713
+ return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
5714
+ }
5171
5715
  }
5172
5716
  }
5173
5717
  // APRV-188. From here on this process may resume a verified read behind the
@@ -5199,13 +5743,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5199
5743
  // words. The window suspends the POLICY; it never suspends the log.
5200
5744
  const looked = lookupWindow(logPath);
5201
5745
  if (looked.window !== null) {
5202
- return runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
5746
+ return yield* runBypass(streams, input, adapter, cwd, logPath, parsed.flags, actor, looked.window,
5203
5747
  // APRV-294. The records this window was derived from travel with it: the
5204
5748
  // bypass record is appended against the head they ended at, so the
5205
5749
  // verdict and the record are one read of the log.
5206
5750
  looked.records === null ? null : { records: looked.records, head: looked.head });
5207
5751
  }
5208
- return renderVerdict(streams, adapter, codexCommand, decideHarnessCall({
5752
+ return renderVerdict(streams, adapter, codexCommand, yield* decideHarnessSteps({
5209
5753
  streams,
5210
5754
  input,
5211
5755
  adapter,
@@ -5244,6 +5788,10 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5244
5788
  * progress and withdrawal lines, which are a report rather than a decision.
5245
5789
  */
5246
5790
  export function decideHarnessCall(decide) {
5791
+ return driveSync(decideHarnessSteps(decide));
5792
+ }
5793
+ /** {@link decideHarnessCall} as steps, for a driver that yields (APRV-473). */
5794
+ function* decideHarnessSteps(decide) {
5247
5795
  const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
5248
5796
  const harnessCapMs = decide.harnessCapMs ?? null;
5249
5797
  const harnessCapStated = decide.harnessCapStated ?? false;
@@ -5263,7 +5811,7 @@ export function decideHarnessCall(decide) {
5263
5811
  return {
5264
5812
  permission: "deny",
5265
5813
  code: "hook-policy-unavailable",
5266
- detail: `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`,
5814
+ detail: policyUnavailableDetail(load),
5267
5815
  };
5268
5816
  }
5269
5817
  const protectedPaths = load.policy.protected_paths ?? [];
@@ -5275,12 +5823,43 @@ export function decideHarnessCall(decide) {
5275
5823
  // both paths since APRV-214 (see `describeToolCall`): the open window
5276
5824
  // classifies exactly as the closed one does, and a second copy of this would
5277
5825
  // be a second answer to "what is this command".
5278
- const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots);
5826
+ const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots, load.policy);
5279
5827
  if (described.kind === "deny") {
5280
5828
  return { permission: "deny", code: described.code, detail: described.detail };
5281
5829
  }
5282
5830
  if (described.kind === "allow") {
5283
- return { permission: "allow", reason: described.reason };
5831
+ if (described.unclaimed !== true)
5832
+ return { permission: "allow", reason: described.reason };
5833
+ // APRV-499 fix round 1 (refutation B1): the on-disk policy leaves this call
5834
+ // outside the gate, and that is believed only of the ATTESTED policy. The
5835
+ // log is required for that, exactly as it is for an unattended Bash call.
5836
+ if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
5837
+ return {
5838
+ permission: "deny",
5839
+ code: "hook-log-unreachable",
5840
+ detail: `no log at ${logPath}, so whether the policy that leaves ${input.toolName} ungated is the attested one cannot be established; the hook writes to an existing log and never creates one. Run \`approval init\` (then \`approval policy attest\`) in ${root}, or pass --log <path> to point the hook at the log that already exists`,
5841
+ };
5842
+ }
5843
+ let records = windowRecords;
5844
+ if (records === null) {
5845
+ const read = readVerifiedRecords(logPath);
5846
+ if (!read.ok)
5847
+ return { permission: "deny", code: "hook-io", detail: read.message };
5848
+ records = read.records;
5849
+ }
5850
+ const standing = unclaimedStanding(options, cwd, input.toolName, records);
5851
+ if (standing.kind === "ungated")
5852
+ return { permission: "allow", reason: described.reason };
5853
+ if (standing.kind === "unattested") {
5854
+ return { permission: "deny", code: standing.code, detail: standing.detail };
5855
+ }
5856
+ // The file changed between the two reads of this one decision: refuse,
5857
+ // and the next call decides against whatever is there then.
5858
+ return {
5859
+ permission: "deny",
5860
+ code: "hook-policy-unavailable",
5861
+ detail: `the policy file changed while ${input.toolName} was being decided; nothing was appended, and a retry is decided against the file as it then stands`,
5862
+ };
5284
5863
  }
5285
5864
  const { classes, payload, headline } = described;
5286
5865
  /** What the history-rewrite refinement did, for the decision reason. */
@@ -5335,6 +5914,7 @@ export function decideHarnessCall(decide) {
5335
5914
  // changed when an operator reordered their policy would read as a change of
5336
5915
  // state.
5337
5916
  channels: Object.keys(load.policy.channels ?? {}).sort(),
5917
+ ...(described.harnessTool === undefined ? {} : { harnessTool: described.harnessTool }),
5338
5918
  };
5339
5919
  const resolutions = classes.map((cls) => resolvePolicy(load, cls));
5340
5920
  const autonomies = resolutions.map((resolution) => resolution.autonomy);
@@ -5486,8 +6066,10 @@ export function decideHarnessCall(decide) {
5486
6066
  // §6.3), so nothing is requested, decided or granted here. What IS appended
5487
6067
  // since APRV-141 is the execution record itself — the moment the policy
5488
6068
  // authorized this command — because a budget the busiest path does not
5489
- // charge is not a budget. See `recordUnattended`.
5490
- const charged = recordUnattended(run, task, classes, payloadHash(payload));
6069
+ // charge is not a budget. See `recordUnattended`. A held signal is
6070
+ // dispatched first, at the pause it takes before the append (APRV-473,
6071
+ // `BEFORE_SPEND`), and a wait for the log's lock yields (APRV-478).
6072
+ const charged = yield* recordUnattended(run, task, classes, payloadHash(payload));
5491
6073
  if (charged !== null) {
5492
6074
  return {
5493
6075
  permission: "deny",
@@ -5503,14 +6085,14 @@ export function decideHarnessCall(decide) {
5503
6085
  // do not survive a merge. An initialized-but-empty `.approval/log/` counts as
5504
6086
  // reachable — an audit trail that has recorded nothing is an empty log, not a
5505
6087
  // missing one (see `preflightLog`) — and `register` appends the first line.
5506
- return gateHarnessCall(streams, run, classes, payload, headline, task, note, floor);
6088
+ return yield* gateHarnessSteps(streams, run, classes, payload, headline, task, note, floor);
5507
6089
  }
5508
- function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
6090
+ function* commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5509
6091
  if (adapter.kind === "hermes") {
5510
- return hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
6092
+ return yield* hermesFailClosed(argv, streams, cwd, readStdin, adapter, waitSeam);
5511
6093
  }
5512
6094
  try {
5513
- return runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
6095
+ return yield* runHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam);
5514
6096
  }
5515
6097
  catch (cause) {
5516
6098
  // A hook that throws is a hook the harness treats as a non-blocking error,
@@ -5541,7 +6123,7 @@ function commandHarnessHook(argv, streams, cwd, readStdin, adapter, waitSeam) {
5541
6123
  * nothing on stdout by design. A throw becomes the same block the other
5542
6124
  * adapters' catch produces.
5543
6125
  */
5544
- function hermesFailClosed(argv, streams, cwd, readStdin, adapter,
6126
+ function* hermesFailClosed(argv, streams, cwd, readStdin, adapter,
5545
6127
  /** APRV-427: serve's wait seam, passed through untouched (null on the CLI). */
5546
6128
  waitSeam) {
5547
6129
  let stdout = "";
@@ -5567,12 +6149,24 @@ waitSeam) {
5567
6149
  // handler is prepended, so inside the wait it answers first and exits. CLI
5568
6150
  // only: under `approval serve` (a wait seam) the process's signals belong to
5569
6151
  // the server.
6152
+ //
6153
+ // A JS listener runs only when the event loop turns (APRV-466). On the CLI
6154
+ // this run is driven by `driveYielding` (APRV-473), so the loop turns at
6155
+ // every pause: between the wait's polls, where the wait's handler answers,
6156
+ // and once before anything appends `execution.started` (`BEFORE_SPEND`),
6157
+ // where whichever of the two is registered answers a signal held through
6158
+ // the synchronous stretch before it (the stdin read, the classification, the
6159
+ // registration). A signal that lands after the last pause is dispatched
6160
+ // once the run has returned, to the `approval` bin's own guard, which exits
6161
+ // with the answer this run gave. While these guards are registered,
6162
+ // HERMES_SIGNAL_OWNER tells the bin's guard to step aside, so the two never
6163
+ // both print.
5570
6164
  const onEarlySignal = (signal) => {
5571
6165
  if (raw !== null && isHermesPostEvent(raw))
5572
6166
  process.exit(EXIT_OK);
5573
6167
  if (stdout.length === 0) {
5574
6168
  try {
5575
- writeSync(1, harnessBlockDirective("hook-interrupted", `the hook received ${signal} before it reached a verdict; nothing authorizes this call`, "hermes").stdout);
6169
+ writeSync(1, hermesInterruptedDirective(signal));
5576
6170
  }
5577
6171
  catch {
5578
6172
  // stdout is gone; the exit code below is the whole verdict.
@@ -5582,23 +6176,27 @@ waitSeam) {
5582
6176
  };
5583
6177
  const onEarlyTerm = () => onEarlySignal("SIGTERM");
5584
6178
  const onEarlyInt = () => onEarlySignal("SIGINT");
6179
+ const owner = globalThis;
6180
+ const ownerBefore = owner[HERMES_SIGNAL_OWNER];
5585
6181
  if (waitSeam === null) {
5586
6182
  process.on("SIGTERM", onEarlyTerm);
5587
6183
  process.on("SIGINT", onEarlyInt);
6184
+ owner[HERMES_SIGNAL_OWNER] = true;
5588
6185
  }
5589
6186
  try {
5590
- return hermesVerdict();
6187
+ return yield* hermesVerdict();
5591
6188
  }
5592
6189
  finally {
5593
6190
  if (waitSeam === null) {
6191
+ owner[HERMES_SIGNAL_OWNER] = ownerBefore;
5594
6192
  process.off("SIGTERM", onEarlyTerm);
5595
6193
  process.off("SIGINT", onEarlyInt);
5596
6194
  }
5597
6195
  }
5598
- function hermesVerdict() {
6196
+ function* hermesVerdict() {
5599
6197
  let code;
5600
6198
  try {
5601
- code = runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
6199
+ code = yield* runHarnessHook(argv, tracked, cwd, reading, adapter, waitSeam);
5602
6200
  }
5603
6201
  catch (cause) {
5604
6202
  // A post-event never blocks: the call already ran (see the post path).
@@ -5657,6 +6255,48 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin,
5657
6255
  * the call polls. Every CLI route omits it. See {@link HookWaitSeam}.
5658
6256
  */
5659
6257
  waitSeam = null) {
6258
+ return driveSync(hookSteps(argv, streams, cwd, readStdin, waitSeam));
6259
+ }
6260
+ /**
6261
+ * {@link commandHook}, with its pauses on the event loop (APRV-473).
6262
+ *
6263
+ * Every `approval hook` CLI route, from `main`: Hermes since APRV-473, the
6264
+ * other five harnesses (and `classify`, which never pauses) since APRV-475.
6265
+ * The same steps as the synchronous form; the difference is that a SIGTERM or
6266
+ * SIGINT reaches the listener in force within one pause of arriving, instead
6267
+ * of after the run returns. In the wait that listener withdraws the question
6268
+ * this invocation opened, prints the harness's own `hook-interrupted` block
6269
+ * directive ({@link interruptedWaitDirective}) and exits with that directive's
6270
+ * exit code, so a grant that lands after the harness abandoned the call is
6271
+ * never spent on it. See {@link GateSteps} and {@link BEFORE_SPEND}.
6272
+ */
6273
+ export function commandHookYielding(argv, streams, cwd, readStdin = defaultStdin) {
6274
+ // The loop now turns mid-wait, so an asynchronous stderr failure (EPIPE on a
6275
+ // closed pipe after `announceWait`, where pipes are asynchronous) would
6276
+ // surface as an uncaught exception in the middle of the wait and end it
6277
+ // without withdrawing. Stderr carries progress lines and never the verdict,
6278
+ // so its failure is swallowed (APRV-473 refuter).
6279
+ process.stderr.on("error", () => undefined);
6280
+ return driveYielding(hookSteps(argv, streams, cwd, readStdin, null));
6281
+ }
6282
+ /**
6283
+ * The steps {@link commandHook} and {@link commandHookYielding} drive, for a
6284
+ * test that drives them itself (APRV-475 refuter).
6285
+ *
6286
+ * A test seam and nothing more: it exists so a test can stop at each pause and
6287
+ * look at the log, which is the only deterministic way to pin that the pause
6288
+ * before a spend ({@link BEFORE_SPEND}) sits between the read that found a
6289
+ * grant and the append that spends it, on every harness. No CLI route and no
6290
+ * embedding caller uses it; the one entry for those is the two drivers above.
6291
+ *
6292
+ * Pass `true` to each `next()` to step as {@link commandHookYielding} does
6293
+ * (the spend then waits for a held log lock in yields, APRV-478); `false` or
6294
+ * nothing steps as {@link commandHook} does.
6295
+ */
6296
+ export function commandHookSteps(argv, streams, cwd, readStdin) {
6297
+ return hookSteps(argv, streams, cwd, readStdin, null);
6298
+ }
6299
+ function* hookSteps(argv, streams, cwd, readStdin, waitSeam) {
5660
6300
  const sub = argv[0];
5661
6301
  const rest = argv.slice(1);
5662
6302
  if (sub === undefined) {
@@ -5670,7 +6310,7 @@ waitSeam = null) {
5670
6310
  // boundary and doctor use, so a kind that can be recorded is a kind that can
5671
6311
  // be invoked, and neither can be added without the other.
5672
6312
  if (isHarnessKind(sub)) {
5673
- return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
6313
+ return yield* commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub], waitSeam);
5674
6314
  }
5675
6315
  if (sub === "classify")
5676
6316
  return commandClassify(rest, streams, cwd);