approval-md 0.2.0 → 0.3.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 (235) hide show
  1. package/README.md +63 -24
  2. package/SPEC.md +57 -11
  3. package/dist/src/channels/contract.d.ts +34 -1
  4. package/dist/src/channels/contract.js +200 -7
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/telegram.d.ts +123 -11
  7. package/dist/src/channels/telegram.js +218 -23
  8. package/dist/src/channels/telegram.js.map +1 -1
  9. package/dist/src/channels/web.d.ts +9 -0
  10. package/dist/src/channels/web.js +17 -0
  11. package/dist/src/channels/web.js.map +1 -1
  12. package/dist/src/cli/amend.js +214 -30
  13. package/dist/src/cli/amend.js.map +1 -1
  14. package/dist/src/cli/attest.d.ts +9 -0
  15. package/dist/src/cli/attest.js +134 -7
  16. package/dist/src/cli/attest.js.map +1 -1
  17. package/dist/src/cli/channel-telegram.d.ts +99 -26
  18. package/dist/src/cli/channel-telegram.js +311 -13
  19. package/dist/src/cli/channel-telegram.js.map +1 -1
  20. package/dist/src/cli/channel.d.ts +9 -0
  21. package/dist/src/cli/channel.js +9 -0
  22. package/dist/src/cli/channel.js.map +1 -1
  23. package/dist/src/cli/codex-bridge.d.ts +819 -0
  24. package/dist/src/cli/codex-bridge.js +1607 -0
  25. package/dist/src/cli/codex-bridge.js.map +1 -0
  26. package/dist/src/cli/codex.d.ts +1 -1
  27. package/dist/src/cli/codex.js +304 -7
  28. package/dist/src/cli/codex.js.map +1 -1
  29. package/dist/src/cli/daemon.js +4 -1
  30. package/dist/src/cli/daemon.js.map +1 -1
  31. package/dist/src/cli/doctor.js +467 -12
  32. package/dist/src/cli/doctor.js.map +1 -1
  33. package/dist/src/cli/execute.js +25 -2
  34. package/dist/src/cli/execute.js.map +1 -1
  35. package/dist/src/cli/help.d.ts +6 -2
  36. package/dist/src/cli/help.js +165 -60
  37. package/dist/src/cli/help.js.map +1 -1
  38. package/dist/src/cli/hook-codex.d.ts +49 -1
  39. package/dist/src/cli/hook-codex.js +60 -1
  40. package/dist/src/cli/hook-codex.js.map +1 -1
  41. package/dist/src/cli/hook.d.ts +459 -3
  42. package/dist/src/cli/hook.js +1062 -114
  43. package/dist/src/cli/hook.js.map +1 -1
  44. package/dist/src/cli/import.js +1 -1
  45. package/dist/src/cli/import.js.map +1 -1
  46. package/dist/src/cli/main.js +5 -3
  47. package/dist/src/cli/main.js.map +1 -1
  48. package/dist/src/cli/policy-apply.d.ts +195 -0
  49. package/dist/src/cli/policy-apply.js +573 -0
  50. package/dist/src/cli/policy-apply.js.map +1 -0
  51. package/dist/src/cli/policy.js +14 -1
  52. package/dist/src/cli/policy.js.map +1 -1
  53. package/dist/src/cli/preflight.d.ts +151 -13
  54. package/dist/src/cli/preflight.js +398 -41
  55. package/dist/src/cli/preflight.js.map +1 -1
  56. package/dist/src/cli/sandbox.js +17 -1
  57. package/dist/src/cli/sandbox.js.map +1 -1
  58. package/dist/src/cli/scaffold.d.ts +1 -1
  59. package/dist/src/cli/scaffold.js +1 -1
  60. package/dist/src/cli/setup-channel.d.ts +9 -0
  61. package/dist/src/cli/setup-channel.js +28 -1
  62. package/dist/src/cli/setup-channel.js.map +1 -1
  63. package/dist/src/cli/setup-common.d.ts +3 -1
  64. package/dist/src/cli/setup-common.js +3 -2
  65. package/dist/src/cli/setup-common.js.map +1 -1
  66. package/dist/src/cli/setup.d.ts +2 -0
  67. package/dist/src/cli/setup.js +94 -2
  68. package/dist/src/cli/setup.js.map +1 -1
  69. package/dist/src/cli/up.js +115 -51
  70. package/dist/src/cli/up.js.map +1 -1
  71. package/dist/src/cli/values.js +3 -4
  72. package/dist/src/cli/values.js.map +1 -1
  73. package/dist/src/cli/verb-registry.js +174 -9
  74. package/dist/src/cli/verb-registry.js.map +1 -1
  75. package/dist/src/cli/wordmark.d.ts +2 -2
  76. package/dist/src/cli/wordmark.js +2 -2
  77. package/dist/src/codex/broker.d.ts +229 -0
  78. package/dist/src/codex/broker.js +548 -0
  79. package/dist/src/codex/broker.js.map +1 -0
  80. package/dist/src/codex/runner.d.ts +178 -0
  81. package/dist/src/codex/runner.js +231 -0
  82. package/dist/src/codex/runner.js.map +1 -0
  83. package/dist/src/codex/serve.d.ts +56 -0
  84. package/dist/src/codex/serve.js +98 -0
  85. package/dist/src/codex/serve.js.map +1 -0
  86. package/dist/src/codex/workspace-commit.d.ts +219 -0
  87. package/dist/src/codex/workspace-commit.js +549 -0
  88. package/dist/src/codex/workspace-commit.js.map +1 -0
  89. package/dist/src/core/advance-cycle.d.ts +51 -0
  90. package/dist/src/core/advance-cycle.js +66 -2
  91. package/dist/src/core/advance-cycle.js.map +1 -1
  92. package/dist/src/core/agents-md.d.ts +20 -18
  93. package/dist/src/core/agents-md.js +33 -31
  94. package/dist/src/core/agents-md.js.map +1 -1
  95. package/dist/src/core/attest.d.ts +215 -0
  96. package/dist/src/core/attest.js +317 -7
  97. package/dist/src/core/attest.js.map +1 -1
  98. package/dist/src/core/audit.d.ts +18 -0
  99. package/dist/src/core/audit.js +13 -0
  100. package/dist/src/core/audit.js.map +1 -1
  101. package/dist/src/core/channel-owner.d.ts +213 -0
  102. package/dist/src/core/channel-owner.js +358 -0
  103. package/dist/src/core/channel-owner.js.map +1 -0
  104. package/dist/src/core/command-class.d.ts +154 -0
  105. package/dist/src/core/command-class.js +673 -20
  106. package/dist/src/core/command-class.js.map +1 -1
  107. package/dist/src/core/commit-guard.d.ts +272 -0
  108. package/dist/src/core/commit-guard.js +424 -0
  109. package/dist/src/core/commit-guard.js.map +1 -0
  110. package/dist/src/core/daemon-actor.d.ts +45 -0
  111. package/dist/src/core/daemon-actor.js +54 -0
  112. package/dist/src/core/daemon-actor.js.map +1 -0
  113. package/dist/src/core/dark-session.d.ts +109 -8
  114. package/dist/src/core/dark-session.js +266 -82
  115. package/dist/src/core/dark-session.js.map +1 -1
  116. package/dist/src/core/decision-refusal.d.ts +23 -2
  117. package/dist/src/core/decision-refusal.js +24 -2
  118. package/dist/src/core/decision-refusal.js.map +1 -1
  119. package/dist/src/core/env-file.d.ts +5 -0
  120. package/dist/src/core/env-file.js +60 -1
  121. package/dist/src/core/env-file.js.map +1 -1
  122. package/dist/src/core/execute.d.ts +15 -2
  123. package/dist/src/core/execute.js +15 -2
  124. package/dist/src/core/execute.js.map +1 -1
  125. package/dist/src/core/gate.d.ts +86 -1
  126. package/dist/src/core/gate.js +81 -1
  127. package/dist/src/core/gate.js.map +1 -1
  128. package/dist/src/core/gesture-refusal.d.ts +166 -0
  129. package/dist/src/core/gesture-refusal.js +188 -0
  130. package/dist/src/core/gesture-refusal.js.map +1 -0
  131. package/dist/src/core/harness-version.d.ts +1 -1
  132. package/dist/src/core/harness-version.js +3 -1
  133. package/dist/src/core/harness-version.js.map +1 -1
  134. package/dist/src/core/instance.d.ts +59 -2
  135. package/dist/src/core/instance.js +113 -0
  136. package/dist/src/core/instance.js.map +1 -1
  137. package/dist/src/core/log.d.ts +39 -1
  138. package/dist/src/core/log.js.map +1 -1
  139. package/dist/src/core/policy-explain.d.ts +10 -0
  140. package/dist/src/core/policy-explain.js +32 -0
  141. package/dist/src/core/policy-explain.js.map +1 -1
  142. package/dist/src/core/policy-load.d.ts +41 -1
  143. package/dist/src/core/policy-load.js +21 -3
  144. package/dist/src/core/policy-load.js.map +1 -1
  145. package/dist/src/core/policy-match.d.ts +43 -0
  146. package/dist/src/core/policy-match.js +52 -0
  147. package/dist/src/core/policy-match.js.map +1 -1
  148. package/dist/src/core/policy-proposal.d.ts +52 -0
  149. package/dist/src/core/policy-proposal.js +102 -2
  150. package/dist/src/core/policy-proposal.js.map +1 -1
  151. package/dist/src/core/protected-path-guard.d.ts +117 -4
  152. package/dist/src/core/protected-path-guard.js +362 -48
  153. package/dist/src/core/protected-path-guard.js.map +1 -1
  154. package/dist/src/core/question-preempted.d.ts +141 -0
  155. package/dist/src/core/question-preempted.js +152 -0
  156. package/dist/src/core/question-preempted.js.map +1 -0
  157. package/dist/src/core/read-scope.d.ts +172 -0
  158. package/dist/src/core/read-scope.js +252 -0
  159. package/dist/src/core/read-scope.js.map +1 -0
  160. package/dist/src/core/sandbox.d.ts +81 -0
  161. package/dist/src/core/sandbox.js +190 -1
  162. package/dist/src/core/sandbox.js.map +1 -1
  163. package/dist/src/core/sender-identity.d.ts +476 -0
  164. package/dist/src/core/sender-identity.js +572 -0
  165. package/dist/src/core/sender-identity.js.map +1 -0
  166. package/dist/src/core/shlex.d.ts +102 -0
  167. package/dist/src/core/shlex.js +159 -0
  168. package/dist/src/core/shlex.js.map +1 -0
  169. package/dist/src/core/values.d.ts +18 -8
  170. package/dist/src/core/values.js +36 -1
  171. package/dist/src/core/values.js.map +1 -1
  172. package/dist/src/daemon/advance.d.ts +10 -0
  173. package/dist/src/daemon/advance.js +25 -4
  174. package/dist/src/daemon/advance.js.map +1 -1
  175. package/dist/src/daemon/daemon.js +9 -0
  176. package/dist/src/daemon/daemon.js.map +1 -1
  177. package/dist/src/daemon/git-evidence.d.ts +2 -2
  178. package/dist/src/daemon/git-evidence.js +1 -1
  179. package/dist/src/mcp/server.js +8 -0
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/docs/cli-reference.md +932 -32
  182. package/docs/codex-enforced-session.md +75 -2
  183. package/docs/codex-workspace-broker.md +118 -0
  184. package/package.json +3 -1
  185. package/schema/event.schema.json +538 -9
  186. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  187. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  188. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  189. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  190. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  191. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  192. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  193. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  194. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  195. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  196. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  197. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  198. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  199. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  200. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  201. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  202. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  203. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  204. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  205. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  206. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  207. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  208. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  209. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  210. package/schema/fixtures/policy/valid/canonical.json +1 -1
  211. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  212. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  213. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  214. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  215. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  216. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  217. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  218. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  219. package/schema/fixtures/values/invalid/version-float.json +1 -0
  220. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  221. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  222. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  223. package/schema/fixtures/values/valid/full.json +5 -7
  224. package/schema/fixtures/values/valid/minimal.json +1 -1
  225. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  226. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  227. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  228. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  229. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  230. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  231. package/schema/fixtures/values-md/valid/absent.md +1 -1
  232. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  233. package/schema/policy.schema.json +54 -2
  234. package/schema/values.schema.json +7 -11
  235. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -78,26 +78,27 @@ import { tmpdir } from "node:os";
78
78
  import { basename, dirname, isAbsolute, join, resolve as resolvePathSegments, sep, } from "node:path";
79
79
  import { attestationRefusal, checkAttestation } from "../core/attest.js";
80
80
  import { childEnvironment } from "../core/child-env.js";
81
- import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
81
+ import { classifyCommand, commandSegmentWords, CODE_EXECUTING_RULES, CONTRIBUTOR_SUFFIX, GATE_SELF_CLASS, protectedPathClass, } from "../core/command-class.js";
82
82
  import { consumeHarnessGrant, findHarnessCarry, finishHarnessExecution, register, request, startHarnessExecution, withdraw, } from "../core/gate.js";
83
83
  import { openGateWindow, recordGateBypass, } from "../core/gate-window.js";
84
- import { harnessProvenance, } from "../core/harness-version.js";
84
+ import { harnessProvenance, isHarnessKind, } from "../core/harness-version.js";
85
85
  import { abandonedAfterMs, 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
88
  import { payloadHash } from "../core/payload.js";
89
89
  import { classifyApplyPatch, parseApplyPatch } from "../core/apply-patch.js";
90
90
  import { loadPolicy, parseDuration } from "../core/policy-load.js";
91
- import { humanOnlyRefusal, resolve as resolvePolicy } from "../core/policy-match.js";
91
+ import { READ_OUT_OF_SCOPE_CLASS, effectiveReadRoots, isInReadScope, readTargetsOf, renderReadRoots, } from "../core/read-scope.js";
92
+ import { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve as resolvePolicy, } from "../core/policy-match.js";
92
93
  import { payloadOf, readVerifiedRecords, requestState, useVerifiedSnapshots, } from "../core/state.js";
93
94
  import { boolFlag, parseFlags, stringFlag } from "./args.js";
94
95
  import { EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
95
96
  import { primaryRoot as resolvePrimaryRoot } from "./git-scope.js";
96
- import { HOOK_HELP } from "./help.js";
97
+ import { HOOK_GROK_HELP, HOOK_HELP, HOOK_MUSE_HELP } from "./help.js";
97
98
  import { DEFAULT_LOG_PATH } from "./paths.js";
98
99
  import { refusal as renderRefusal, style, table } from "./style.js";
99
100
  import { usageErrorText } from "./usage.js";
100
- import { checkCodexHookInput, codexBinding, CODEX_POST_TOOL_EVENT, readCodexReportedOutcome, } from "./hook-codex.js";
101
+ import { checkCodexHookInput, codexArgvDisagrees, codexBinding, CODEX_POST_TOOL_EVENT, readCodexReportedOutcome, } from "./hook-codex.js";
101
102
  /** Identity accepted for the proposing side: a person or an agent. */
102
103
  const PRINCIPAL_ACTOR = /^(human|agent):.+/u;
103
104
  /**
@@ -151,6 +152,29 @@ export const HOOK_DENY_CODES = [
151
152
  * rejection: nobody decided anything, so there is nothing to ask again.
152
153
  */
153
154
  "hook-class-human-only",
155
+ /**
156
+ * A `harness.launch.*` class that no rule of this policy names (APRV-354).
157
+ *
158
+ * SPEC.md §7 says the family is never inferred autonomous; this is the
159
+ * stronger reading the family needs, which is that it is never inferred at
160
+ * all. A launch resolves only under a rule an operator wrote, and a policy
161
+ * that names neither `harness.launch.*` nor the specific member refuses.
162
+ *
163
+ * It exists because of the window the softer reading opens. Before the family
164
+ * existed, `codex …` and `muse …` were `hook-unclassified`: refused outright.
165
+ * Letting the new class fall to `defaults.autonomy` would have made every
166
+ * harness launch grantable by one approval in every project whose defaults
167
+ * are manual, the moment they upgraded — a capability arriving by upgrade
168
+ * rather than by decision. What that approval would cover is a whole second
169
+ * agent whose own actions this gate never sees.
170
+ *
171
+ * Distinct from `hook-unclassified`, which says the CLASSIFIER has nothing to
172
+ * say about the command; here the classifier was clear and the POLICY is
173
+ * silent. Distinct from `hook-class-human-only`, which is a policy that has
174
+ * spoken and reserved the class: the repair there is for a person to run the
175
+ * command, and the repair here is to write a line.
176
+ */
177
+ "hook-harness-launch-unruled",
154
178
  /** A construct whose effect cannot be read off the text (`bash -c`, `eval`). */
155
179
  "hook-opaque",
156
180
  /** The command line could not be tokenized at all. */
@@ -219,6 +243,46 @@ export const HOOK_DENY_CODES = [
219
243
  * merges do not reconcile hash chains (APRV-101).
220
244
  */
221
245
  "hook-log-unreachable",
246
+ /**
247
+ * The harness does not tell this hook where the call will run, so no verdict
248
+ * over the visible bytes can bind the action (APRV-311, native evidence in
249
+ * APRV-310 v6/v7).
250
+ *
251
+ * Native Codex 0.152.1 honours a per-call Bash working directory that appears
252
+ * in no field of the event: `tool_input` carries `command` alone, and the
253
+ * event cwd and the hook process cwd both stay at the session root. A
254
+ * decision over `{command, session root}` would therefore authorize different
255
+ * bytes from the `{command, effective directory}` the harness executes, and a
256
+ * relative path in an approved command can name a protected organ in a
257
+ * directory the classifier never saw.
258
+ *
259
+ * Distinct from `hook-io`, which this used to borrow, and the distinction is
260
+ * the repair. `hook-io` says THIS event was malformed and a well-formed one
261
+ * would be answered; this says every event of this shape is refused on this
262
+ * harness version, and the fix is a harness contract that exposes the
263
+ * effective execution directory, not a retry, a policy edit, or an open
264
+ * window. Nothing appends on this path and no gate lifecycle opens.
265
+ */
266
+ "hook-unsupported-execution-context",
267
+ /**
268
+ * The session names a Contributor-tier model, so every tool call is refused
269
+ * (APRV-350).
270
+ *
271
+ * Meta sells a Contributor variant of the Muse Spark family that "trades a
272
+ * lower price for permission to train on your prompts and completions". A
273
+ * session on one discloses every byte it reads, so the refusal is above the
274
+ * policy: no class resolution and no grant widens it, and an absent or
275
+ * unrecognised `model` is refused for the same reason an unparseable event is.
276
+ *
277
+ * Distinct from `hook-class-human-only`, which says a HUMAN must do this
278
+ * action; this says nothing may do it in this session, and the repair is to
279
+ * change the model in Muse's picker rather than to ask anybody. Distinct from
280
+ * `hook-io` because the event was perfectly well formed.
281
+ *
282
+ * What it cannot do is stated wherever it is documented: it stops tool calls,
283
+ * and it cannot recall a prompt the model has already been sent.
284
+ */
285
+ "hook-muse-contributor-model",
222
286
  /** Malformed hook input, or a log/filesystem fact that stopped the check. */
223
287
  "hook-io",
224
288
  ];
@@ -267,7 +331,7 @@ const primaryRoot = resolvePrimaryRoot;
267
331
  * (`--policy` for the policy, `--log` for the log); otherwise both follow
268
332
  * `--dir`, and with no flags at all both follow the primary checkout.
269
333
  */
270
- function hookScope(flags, cwd) {
334
+ export function hookScope(flags, cwd) {
271
335
  const policyFlag = stringFlag(flags, "--policy");
272
336
  const logFlag = stringFlag(flags, "--log");
273
337
  const dirFlag = stringFlag(flags, "--dir");
@@ -278,12 +342,30 @@ function hookScope(flags, cwd) {
278
342
  const logPath = logFlag === null ? join(root, DEFAULT_LOG_PATH) : absolute(logFlag, cwd);
279
343
  return { logPath, root, options };
280
344
  }
345
+ /**
346
+ * The GATE ROOT: the directory holding the policy file that was actually read
347
+ * (APRV-347).
348
+ *
349
+ * This is the anchor of the read scope, and it is deliberately the loaded
350
+ * policy's own directory rather than the hook's cwd or the harness's `cwd`
351
+ * field. A muse session started in `~/dev/muse` with its own `APPROVAL.md` gets
352
+ * `~/dev/muse`, and every sibling under `~/dev` is outside the scope with no
353
+ * extra grammar anywhere. A policy that did not load falls back to the scope
354
+ * root, which is the same directory the loader was pointed at.
355
+ */
356
+ function policyRootOf(load, fallback) {
357
+ return load.ok ? dirname(load.source.path) : fallback;
358
+ }
281
359
  const CLAUDE_ADAPTER = {
282
360
  kind: "claude-code",
283
361
  originApp: "claude-code-hook",
284
362
  defaultActor: "agent:claude-code",
285
363
  shellTool: "Bash",
286
364
  fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
365
+ // Claude Code's three path-carrying readers. `Read` names the file in
366
+ // `file_path` (or `notebook_path`), `Glob` and `Grep` name the directory they
367
+ // search in `path` and default to the workspace when it is absent.
368
+ readTools: ["Read", "Glob", "Grep"],
287
369
  };
288
370
  const CURSOR_ADAPTER = {
289
371
  kind: "cursor",
@@ -291,6 +373,14 @@ const CURSOR_ADAPTER = {
291
373
  defaultActor: "agent:cursor",
292
374
  shellTool: "Shell",
293
375
  fileTools: ["Write", "Delete"],
376
+ // Cursor's own hook documentation names `Shell`, `Write` and `Delete` as the
377
+ // matchable tools, and no read tool among them; `Read` is the name its agent
378
+ // surface uses. It is listed here because an entry that Cursor never sends is
379
+ // INERT — an unmatched tool takes the path it took before this task — while
380
+ // an entry missing for a tool Cursor does send would be an unscoped read. If
381
+ // Cursor names it otherwise, `cat` through `Shell` is still scoped by the
382
+ // classifier, which is the floor. See docs/cursor-hook.md.
383
+ readTools: ["Read"],
294
384
  };
295
385
  const CODEX_ADAPTER = {
296
386
  kind: "codex",
@@ -298,14 +388,139 @@ const CODEX_ADAPTER = {
298
388
  defaultActor: "agent:codex",
299
389
  shellTool: "Bash",
300
390
  fileTools: ["apply_patch"],
391
+ // Empty, and not an oversight. The Codex native hook contract exposes exactly
392
+ // two tools, `Bash` and `apply_patch` (docs/codex-hook.md); it has no
393
+ // separate read tool to gate. Codex `Bash` is refused outright today because
394
+ // the contract does not expose the per-call working directory (APRV-310), so
395
+ // a Codex read arrives as a shell command and is scoped by the classifier or
396
+ // it does not arrive at all.
397
+ readTools: [],
301
398
  bindToolName: true,
302
399
  };
400
+ /**
401
+ * Grok Build (APRV-243).
402
+ *
403
+ * Its PreToolUse hook is modelled on Claude Code's, with three differences
404
+ * that matter here: the envelope keys are camelCase, the verdict is
405
+ * `{decision, reason}` rather than the nested `hookSpecificOutput`, and a deny
406
+ * is EXIT 2 rather than exit 0 with a JSON body. The tool names are Claude
407
+ * Code's, which is what its documentation says and what the compatibility read
408
+ * of `.claude/settings.json` implies; `docs/grok-hook.md` records that this
409
+ * half is unverified until the live probe runs.
410
+ *
411
+ * The reason this adapter exists at all is a hazard rather than a feature.
412
+ * Grok Build states that it reads `.claude/settings.json` for compatibility.
413
+ * If that read fires this repository's committed claude-code entry under a
414
+ * Grok session, the claude-code adapter answers a deny by printing the nested
415
+ * Claude envelope and exiting 0, and Grok reads exit 0 as ALLOW. Every command
416
+ * would look gated and none would be.
417
+ */
418
+ const GROK_ADAPTER = {
419
+ kind: "grok",
420
+ originApp: "grok-hook",
421
+ defaultActor: "agent:grok",
422
+ shellTool: "Bash",
423
+ fileTools: ["Edit", "Write", "MultiEdit", "NotebookEdit"],
424
+ // Claude Code's three path-carrying readers, for the same reason the file
425
+ // tools are Claude Code's: Grok Build's tool vocabulary follows Claude
426
+ // Code's, and it READS `.claude/settings.json` for compatibility, so the
427
+ // names a Grok session sends are the names that file matches on. The
428
+ // asymmetry with Cursor is deliberate — Cursor documents its own smaller set,
429
+ // Grok documents Claude's.
430
+ //
431
+ // Both directions of the guess are safe in the way APRV-347 makes them safe.
432
+ // A read tool listed here that Grok never sends is INERT: an unmatched tool
433
+ // takes the path it took before. A read tool Grok sends that is NOT listed
434
+ // would be an unscoped read, which is the direction that matters, so the
435
+ // wider Claude set is the fail-closed guess. And a Grok read that arrives as
436
+ // a shell command through `Bash` is scoped by the classifier regardless,
437
+ // which is the floor under all of this.
438
+ //
439
+ // UNVERIFIED, like the file-tool list beside it, and `docs/grok-hook.md`
440
+ // says so: the live probe of APRV-243 AC1 records the tool names an actual
441
+ // session sends, and both lists are corrected to match before the register
442
+ // entry moves from parked.
443
+ readTools: ["Read", "Glob", "Grep"],
444
+ camelCaseEnvelope: true,
445
+ };
446
+ /**
447
+ * Meta Muse Code (APRV-350).
448
+ *
449
+ * Every field here is OBSERVED, from a live run on `muse-bin-1.3.0-R3233.1`
450
+ * whose 139 captured envelopes are the evidence. Nothing in this entry is a
451
+ * guess, which is the difference between it and the Grok entry above.
452
+ *
453
+ * The envelope is snake_case, so no `camelCaseEnvelope`. It carries
454
+ * `hook_event_name`, `tool_name`, `tool_input`, `tool_use_id`, `session_id`,
455
+ * `turn_id`, `cwd`, `transcript_path`, `model`, `permission_mode` and
456
+ * `model_provider`; `PostToolUse` adds `tool_response`. So Muse sends BOTH
457
+ * facts Codex lacks: a per-call working directory and a real outcome.
458
+ *
459
+ * ## What it cannot do, and why this adapter still ships
460
+ *
461
+ * Muse FAILS OPEN. A hook that crashes, hangs past its timeout, or prints
462
+ * anything Muse cannot parse is a hook Muse ignores, and the tool call
463
+ * proceeds. Worse, an output mixing verdict dialects is itself unparseable, so
464
+ * the belt-and-braces answer that satisfies every harness at once satisfies
465
+ * this one not at all: the probe's `deny` trial printed every dialect AND
466
+ * exited 2, and the write completed in under 80 ms.
467
+ *
468
+ * Therefore this adapter emits EXACTLY ONE dialect and nothing else: the nested
469
+ * `hookSpecificOutput` form at exit 0, which the default arm of
470
+ * {@link decision} already produces. Three forms were measured to block
471
+ * (nested at exit 0; `{decision:"block"}` at exit 0; empty stdout at exit 2)
472
+ * and the nested one is chosen because it is the only one that carries a REASON
473
+ * the model is shown, so a refused agent learns why instead of retrying blind.
474
+ * The probe watched an agent respond to an unexplained deny by shelling out to
475
+ * inspect the hook's own state file, which is the behaviour a silent refusal
476
+ * buys. `docs/muse-hook.md` records the choice and the two rejected forms.
477
+ */
478
+ const MUSE_ADAPTER = {
479
+ kind: "muse",
480
+ originApp: "muse-hook",
481
+ defaultActor: "agent:muse",
482
+ shellTool: "bash",
483
+ fileTools: ["write_file"],
484
+ // `read_file` names one path (relative to the session cwd, or absolute);
485
+ // `search` names an ARRAY under `paths`, and the live capture caught it
486
+ // reaching outside the workspace entirely — Muse applies no workspace
487
+ // confinement in `permission_mode: "default"`, so this is the read jail's
488
+ // whole reason for existing here.
489
+ readTools: ["read_file", "search"],
490
+ passThroughTools: ["submit_reminder_decision"],
491
+ shellCwdKey: "workdir",
492
+ contributorModelGuard: true,
493
+ };
494
+ /**
495
+ * Every harness this runtime speaks a hook protocol for, by kind (APRV-358).
496
+ *
497
+ * The table is `Record<HarnessKind, HarnessAdapter>` rather than a list of
498
+ * consts and a switch, and the type is the point: a kind added to
499
+ * `HARNESS_KINDS` with no adapter beside it fails to compile, so the two lists
500
+ * cannot drift by forgetting. The subcommand dispatch below reads this map, so
501
+ * `approval hook <kind>` is answerable for exactly the kinds named here.
502
+ *
503
+ * The kinds that are enumerated OUTSIDE this module — the schema's
504
+ * `payload.harness` enum, the verb registry's `hook` subcommands, the MCP
505
+ * exclusions, the help — are pinned set-equal to `HARNESS_KINDS` by
506
+ * `tests/harness-enum.test.ts`, which exists because `grok` shipped an adapter
507
+ * in APRV-243 and reached none of them. A Grok session's manual-class
508
+ * registration was refused at the write boundary for eleven days and nothing
509
+ * failed.
510
+ */
511
+ export const HARNESS_ADAPTERS = {
512
+ "claude-code": CLAUDE_ADAPTER,
513
+ cursor: CURSOR_ADAPTER,
514
+ codex: CODEX_ADAPTER,
515
+ grok: GROK_ADAPTER,
516
+ muse: MUSE_ADAPTER,
517
+ };
303
518
  /**
304
519
  * The decision object the harness reads from stdout.
305
520
  *
306
521
  * Claude Code wants the nested PreToolUse envelope. Cursor native hooks want
307
- * `{permission, user_message, agent_message}`. One construction site per
308
- * harness, still never `ask`.
522
+ * `{permission, user_message, agent_message}`. Grok Build wants
523
+ * `{decision, reason}`. One construction site per harness, still never `ask`.
309
524
  */
310
525
  function decision(permission, reason, harness, codexCommand) {
311
526
  if (harness === "cursor") {
@@ -315,6 +530,9 @@ function decision(permission, reason, harness, codexCommand) {
315
530
  agent_message: reason,
316
531
  })}\n`;
317
532
  }
533
+ if (harness === "grok") {
534
+ return `${JSON.stringify({ decision: permission, reason })}\n`;
535
+ }
318
536
  const hookSpecificOutput = {
319
537
  hookEventName: "PreToolUse",
320
538
  permissionDecision: permission,
@@ -333,14 +551,88 @@ function allow(streams, reason, harness, codexCommand) {
333
551
  streams.out(decision("allow", reason, harness, codexCommand));
334
552
  return EXIT_OK;
335
553
  }
554
+ /**
555
+ * The exit code a DENY carries.
556
+ *
557
+ * Claude Code, Cursor and Codex read the verdict out of stdout and treat a
558
+ * non-zero exit as a broken hook, so a deny there exits 0 with a JSON body.
559
+ * Grok Build reads exit 2 as the deny (APRV-243), and reads exit 0 as ALLOW
560
+ * whatever stdout said. Printing the body AND exiting 2 satisfies both halves
561
+ * of its documented contract and leaves the reason where a person can read it.
562
+ */
563
+ const GROK_DENY_EXIT = 2;
336
564
  function deny(streams, code, detail, harness) {
337
565
  streams.out(decision("deny", `${code}: ${detail}`, harness));
338
- return EXIT_OK;
566
+ return harness === "grok" ? GROK_DENY_EXIT : EXIT_OK;
567
+ }
568
+ /** The machine-readable code a Contributor-tier session is refused with. */
569
+ export const MUSE_CONTRIBUTOR_REFUSAL = "hook-muse-contributor-model";
570
+ /**
571
+ * Refuse every tool call on a Contributor-tier model, whatever the policy says.
572
+ *
573
+ * Meta sells two tiers of the Muse Spark family and marks the difference in the
574
+ * model id: a Contributor variant "trades a lower price for permission to train
575
+ * on your prompts and completions", against a Standard variant documented as
576
+ * never trained on. So a Contributor session discloses every file it reads, and
577
+ * a policy that would autonomously allow a read of the workspace was not
578
+ * written with that in mind.
579
+ *
580
+ * ## Why this is not a policy line
581
+ *
582
+ * A policy grants and withholds authority over CLASSES. This is not about the
583
+ * class of the action; the same `read.file` is fine on one model and a
584
+ * disclosure on another. Encoding it as policy would mean every operator's
585
+ * `APPROVAL.md` had to name Meta's tiering to be safe, and one that did not
586
+ * would be silently unsafe. So the adapter refuses, above the policy, and no
587
+ * policy can widen it. That is a strictness increase only, which is the one
588
+ * direction a hook may move a verdict on its own (SPEC §11.1 invariant 4).
589
+ *
590
+ * ## The self-reported field
591
+ *
592
+ * `model` is the harness's claim about itself, and SPEC §11.1 says a
593
+ * self-reported field never REDUCES scrutiny. This only ever raises it: the
594
+ * string can refuse a call, never authorize one. An absent, empty or
595
+ * unparseable `model` is refused too, because a session that will not say what
596
+ * it is running is exactly the one not to trust with a read.
597
+ *
598
+ * ## What it cannot do
599
+ *
600
+ * It stops tool calls. It cannot recall the prompt that was already sent — a
601
+ * hook fires after the model has seen it — and it cannot know what the harness
602
+ * attached as context before the first tool call. `docs/muse-hook.md` opens
603
+ * with this, because a guard people over-trust is worse than no guard.
604
+ */
605
+ function contributorModelRefusal(model) {
606
+ if (model === null || model.trim() === "") {
607
+ return "the envelope names no model, and a session that will not say what it is running cannot be recognised as non-contributor";
608
+ }
609
+ if (model.trim().toLowerCase().includes(CONTRIBUTOR_SUFFIX.replace(/^-/u, ""))) {
610
+ return `${model.trim()} is a Contributor-tier model: Meta trains on this tier's prompts and completions, so every file this session reads is disclosed. Refused regardless of policy; no grant widens it.`;
611
+ }
612
+ return null;
339
613
  }
340
614
  function readString(source, key) {
341
615
  const value = source[key];
342
616
  return typeof value === "string" && value.length > 0 ? value : null;
343
617
  }
618
+ /**
619
+ * The snake_case key, or the camelCase one when the adapter speaks that
620
+ * dialect (APRV-243).
621
+ *
622
+ * snake_case is read FIRST in both dialects, so an envelope carrying both
623
+ * spellings resolves the same way for every harness and cannot be used to show
624
+ * one command to the classifier and another to the harness.
625
+ */
626
+ function readDialect(source, snake, camel, camelCase) {
627
+ const value = source[snake];
628
+ if (value !== undefined)
629
+ return value;
630
+ return camelCase ? source[camel] : undefined;
631
+ }
632
+ function readDialectString(source, snake, camel, camelCase) {
633
+ const value = readDialect(source, snake, camel, camelCase);
634
+ return typeof value === "string" && value.length > 0 ? value : null;
635
+ }
344
636
  /**
345
637
  * Parse the PreToolUse JSON.
346
638
  *
@@ -350,7 +642,7 @@ function readString(source, key) {
350
642
  * and a gate that read the subject's own account of its intent would be letting
351
643
  * a self-reported field reduce scrutiny (SPEC.md §11.1).
352
644
  */
353
- function parseHookInput(raw) {
645
+ function parseHookInput(raw, camelCase = false) {
354
646
  if (raw.trim().length === 0)
355
647
  return { ok: false, detail: "hook stdin was empty" };
356
648
  let parsed;
@@ -367,15 +659,19 @@ function parseHookInput(raw) {
367
659
  return { ok: false, detail: "hook stdin is not a JSON object" };
368
660
  }
369
661
  const fields = parsed;
370
- const toolName = readString(fields, "tool_name");
371
- if (toolName === null)
372
- return { ok: false, detail: "hook input has no tool_name" };
373
- const toolInputValue = fields["tool_input"];
662
+ const toolName = readDialectString(fields, "tool_name", "toolName", camelCase);
663
+ if (toolName === null) {
664
+ return {
665
+ ok: false,
666
+ detail: camelCase ? "hook input has no tool_name or toolName" : "hook input has no tool_name",
667
+ };
668
+ }
669
+ const toolInputValue = readDialect(fields, "tool_input", "toolInput", camelCase);
374
670
  const toolInput = typeof toolInputValue === "object" && toolInputValue !== null && !Array.isArray(toolInputValue)
375
671
  ? toolInputValue
376
672
  : {};
377
- const responseValue = fields["tool_response"];
378
- const sessionId = readString(fields, "session_id");
673
+ const responseValue = readDialect(fields, "tool_response", "toolResponse", camelCase);
674
+ const sessionId = readDialectString(fields, "session_id", "sessionId", camelCase);
379
675
  return {
380
676
  ok: true,
381
677
  input: {
@@ -384,13 +680,17 @@ function parseHookInput(raw) {
384
680
  // slower, which is the fail-closed direction.
385
681
  sessionId: sessionId ?? UNKNOWN_SESSION,
386
682
  sessionIdPresent: sessionId !== null,
683
+ // Grok Build sends `workspaceRoot` beside `cwd`. Only `cwd` is read:
684
+ // the classifier resolves paths against the directory the command will
685
+ // actually run in, and a workspace root is a different fact.
387
686
  cwd: readString(fields, "cwd") ?? "",
388
687
  toolName,
389
688
  toolInput,
390
- toolUseId: readString(fields, "tool_use_id"),
391
- hookEventName: readString(fields, "hook_event_name"),
689
+ toolUseId: readDialectString(fields, "tool_use_id", "toolUseId", camelCase),
690
+ hookEventName: readDialectString(fields, "hook_event_name", "hookEventName", camelCase),
691
+ model: readDialectString(fields, "model", "model", camelCase),
392
692
  harnessVersion: readString(fields, "version"),
393
- interrupted: fields["is_interrupt"] === true,
693
+ interrupted: fields["is_interrupt"] === true || (camelCase && fields["isInterrupt"] === true),
394
694
  toolResponse: typeof responseValue === "object" && responseValue !== null && !Array.isArray(responseValue)
395
695
  ? responseValue
396
696
  : null,
@@ -842,22 +1142,166 @@ export function refineScratchDelete(result, roots) {
842
1142
  }
843
1143
  return { result: { ok: true, segments, classes }, notes };
844
1144
  }
1145
+ // ===========================================================================
1146
+ // Read scope, the disk half (APRV-347)
1147
+ // ===========================================================================
1148
+ /**
1149
+ * ## Why reads need a second pass too
1150
+ *
1151
+ * The pure classifier can settle an ABSOLUTE read target against the roots and
1152
+ * nothing else. `cat ../other/secrets`, `grep -r needle ./link-to-elsewhere`
1153
+ * and a bare `ls` all mean something only once a working directory and the
1154
+ * filesystem's own links are in hand, and those are exactly the spellings an
1155
+ * agent reaches for. So the read rule has the same two-part shape the delete
1156
+ * rule has: the text decides what text can decide, and this pass — which
1157
+ * resolves against the hook's OWN directory, follows links, and answers `null`
1158
+ * for anything it cannot resolve — tightens `read.shell` back to
1159
+ * `read.file.out_of_scope`.
1160
+ *
1161
+ * It only ever moves a segment toward the stricter class. A caller that skipped
1162
+ * it would be no more permissive than one that runs it, which is what lets
1163
+ * `hook classify` and `hook <harness>` share it without either becoming the
1164
+ * authority.
1165
+ */
1166
+ /** The rule a read tightened by this pass reports. */
1167
+ const READ_SCOPE_REJECTED_RULE = "read-out-of-scope-resolved";
1168
+ /**
1169
+ * The read roots this process may vouch for, resolved.
1170
+ *
1171
+ * The gate root is the directory the hook resolved its POLICY from, never the
1172
+ * harness-supplied `cwd`: a scope the subject of the gate could choose is not a
1173
+ * scope (SPEC.md §11.1, self-reported fields never reduce scrutiny). The
1174
+ * scratchpad and temp roots are the ones `resolveScratchRoots` already computes
1175
+ * and already guards, so the two rules cannot disagree about where the agent's
1176
+ * own scratch is. `declared` is `read_scope.roots` out of the loaded policy,
1177
+ * which may only widen this set.
1178
+ */
1179
+ export function resolveReadRoots(cwd, gateRoot, declared) {
1180
+ const roots = effectiveReadRoots({
1181
+ gateRoot: resolvedPath(gateRoot) ?? gateRoot,
1182
+ systemRoots: resolveScratchRoots(cwd),
1183
+ ...(declared === undefined ? {} : { declared }),
1184
+ });
1185
+ const out = [];
1186
+ for (const root of roots) {
1187
+ const resolved = resolvedPath(root) ?? root;
1188
+ if (!out.includes(resolved))
1189
+ out.push(resolved);
1190
+ }
1191
+ return out;
1192
+ }
1193
+ /**
1194
+ * Where this target really is, or `null` when nothing can say.
1195
+ *
1196
+ * The same walk `targetStaysInScratch` does, and for the same reason: the file
1197
+ * may not exist yet (or at all), so the nearest EXISTING ancestor is resolved
1198
+ * and the unresolved tail re-appended. A symlink anywhere in that chain
1199
+ * therefore cannot smuggle a read out of the root, which is the escape the pure
1200
+ * half cannot see.
1201
+ */
1202
+ function resolvedReadTarget(target, cwd) {
1203
+ let existing = isAbsolute(target) ? target : resolvePathSegments(cwd, target);
1204
+ const tail = [];
1205
+ for (let depth = 0; depth < 64; depth += 1) {
1206
+ if (existsSync(existing))
1207
+ break;
1208
+ const up = dirname(existing);
1209
+ if (up === existing)
1210
+ return null;
1211
+ tail.unshift(basename(existing));
1212
+ existing = up;
1213
+ }
1214
+ const resolved = resolvedPath(existing);
1215
+ if (resolved === null)
1216
+ return null;
1217
+ return tail.length === 0 ? resolved : join(resolved, ...tail);
1218
+ }
1219
+ /**
1220
+ * Tighten a `read.shell` segment to `read.file.out_of_scope` wherever the disk
1221
+ * disagrees with the text.
1222
+ *
1223
+ * IMPURE by design and by contract. `roots` empty means the caller asked for no
1224
+ * read scoping at all, and every segment is returned untouched — the same
1225
+ * "absent yields today's answer" the classifier context promises.
1226
+ */
1227
+ export function refineReadScope(result, roots, cwd) {
1228
+ if (!result.ok)
1229
+ return { result, notes: [] };
1230
+ if (roots.length === 0)
1231
+ return { result, notes: [] };
1232
+ if (!result.segments.some((segment) => segment.class === "read.shell")) {
1233
+ return { result, notes: [] };
1234
+ }
1235
+ const notes = [];
1236
+ const segments = result.segments.map((segment) => {
1237
+ if (segment.class !== "read.shell")
1238
+ return segment;
1239
+ const words = commandSegmentWords(segment.text);
1240
+ const parsed = words === null ? undefined : words[0];
1241
+ if (parsed === undefined)
1242
+ return segment;
1243
+ const positionals = parsed.args.filter((arg) => !arg.startsWith("-") || arg === "-");
1244
+ const declaredTargets = readTargetsOf(parsed.bin, positionals, parsed.args);
1245
+ if (declaredTargets === null)
1246
+ return segment;
1247
+ // No operand is not "no read": `ls`, `find` and a piped `grep needle` all
1248
+ // read the working directory, so the working directory is what is checked.
1249
+ const targets = declaredTargets.length === 0 ? ["."] : declaredTargets;
1250
+ const reject = (path, detail) => {
1251
+ notes.push(`${READ_SCOPE_REJECTED_RULE}: ${detail}`);
1252
+ return {
1253
+ ...segment,
1254
+ class: READ_OUT_OF_SCOPE_CLASS,
1255
+ rule: READ_SCOPE_REJECTED_RULE,
1256
+ path,
1257
+ };
1258
+ };
1259
+ for (const target of targets) {
1260
+ const resolved = resolvedReadTarget(target, cwd);
1261
+ if (resolved === null) {
1262
+ return reject(target, `${target} does not resolve to any path this hook can see, so \`${segment.text}\` is ${READ_OUT_OF_SCOPE_CLASS}`);
1263
+ }
1264
+ if (!isInReadScope(resolved, roots)) {
1265
+ return reject(resolved, `${target} resolves to ${resolved}, which is outside the read scope (${renderReadRoots(roots)}), so \`${segment.text}\` is ${READ_OUT_OF_SCOPE_CLASS}`);
1266
+ }
1267
+ }
1268
+ return segment;
1269
+ });
1270
+ if (notes.length === 0)
1271
+ return { result, notes };
1272
+ const classes = [];
1273
+ for (const segment of segments) {
1274
+ if (!classes.includes(segment.class))
1275
+ classes.push(segment.class);
1276
+ }
1277
+ return { result: { ok: true, segments, classes }, notes };
1278
+ }
845
1279
  /**
846
- * The classifier, its scratch context, and both impure refinements, in the one
1280
+ * The classifier, its context, and all three impure refinements, in the one
847
1281
  * order every caller must use.
848
1282
  *
849
1283
  * `hook classify` printing a different class from the one `hook claude-code`
850
1284
  * decides would make the explainer a different program (APRV-108's note), and
851
- * that stays true now there are two refinements in the chain.
1285
+ * that stays true now there are three refinements in the chain.
1286
+ *
1287
+ * `readRoots` is the one argument whose ABSENCE is the loose answer rather than
1288
+ * the strict one (APRV-347), so it is passed explicitly at every call site: an
1289
+ * empty list means "do not scope reads", which is what every caller outside a
1290
+ * resolved gate scope wants and what this classifier did before the field
1291
+ * existed.
852
1292
  */
853
- export function classifyForHook(command, protectedPaths, cwd) {
1293
+ export function classifyForHook(command, protectedPaths, cwd, readRoots = []) {
854
1294
  const roots = resolveScratchRoots(cwd);
855
- const classified = classifyCommand(command, protectedPaths, { scratchRoots: roots });
1295
+ const classified = classifyCommand(command, protectedPaths, {
1296
+ scratchRoots: roots,
1297
+ ...(readRoots.length === 0 ? {} : { readRoots }),
1298
+ });
856
1299
  const rewritten = refineRewrite(classified, cwd);
857
1300
  const scratched = refineScratchDelete(rewritten.result, roots);
1301
+ const scoped = refineReadScope(scratched.result, readRoots, cwd);
858
1302
  return {
859
- result: scratched.result,
860
- notes: [...rewritten.notes, ...scratched.notes],
1303
+ result: scoped.result,
1304
+ notes: [...rewritten.notes, ...scratched.notes, ...scoped.notes],
861
1305
  };
862
1306
  }
863
1307
  // ===========================================================================
@@ -913,7 +1357,7 @@ function commandClassify(argv, streams, cwd) {
913
1357
  if (command.length === 0) {
914
1358
  return usageError(streams, "missing <command> argument for `approval hook classify`");
915
1359
  }
916
- const { options } = hookScope(parsed.flags, cwd);
1360
+ const { root, options } = hookScope(parsed.flags, cwd);
917
1361
  const load = loadPolicy(options.policy?.file === undefined
918
1362
  ? { dir: options.policy?.dir ?? cwd }
919
1363
  : { file: options.policy.file });
@@ -922,10 +1366,13 @@ function commandClassify(argv, streams, cwd) {
922
1366
  }
923
1367
  const protectedPaths = load.ok ? (load.policy.protected_paths ?? []) : [];
924
1368
  // The same impure refinements `hook claude-code` applies (APRV-108,
925
- // APRV-267), run against the same directory: an explainer that printed the
926
- // pure class where the hook decides a refined one would be explaining a
927
- // different program.
928
- streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd).result, boolFlag(parsed.flags, "--json")));
1369
+ // APRV-267, APRV-347), run against the same directory and the same roots: an
1370
+ // explainer that printed the pure class where the hook decides a refined one
1371
+ // would be explaining a different program. A policy that did not load
1372
+ // contributes no `read_scope`, so the scope is the built-in roots alone,
1373
+ // which is the narrower answer and is what the hook itself would refuse on.
1374
+ const readRoots = resolveReadRoots(cwd, policyRootOf(load, root), load.ok ? load.policy.read_scope?.roots : undefined);
1375
+ streams.out(renderClassification(classifyForHook(command, protectedPaths, cwd, readRoots).result, boolFlag(parsed.flags, "--json")));
929
1376
  return EXIT_OK;
930
1377
  }
931
1378
  function sleepSync(ms) {
@@ -1053,6 +1500,132 @@ function tierOf(target, cwd) {
1053
1500
  * non-protected target, which is why the loop floor could not see an Edit.
1054
1501
  */
1055
1502
  const WORKSPACE_WRITE_CLASS = "files.write.workspace";
1503
+ /**
1504
+ * The Codex app-server's change set, when a call carries one (APRV-363,
1505
+ * APRV-379).
1506
+ *
1507
+ * TWO SHAPES, because the protocol has two. The LEGACY `applyPatchApproval`
1508
+ * carries `fileChanges`, a map of path to change, inline on the request. The
1509
+ * ITEM-BASED API puts the same material on an earlier `item/started` frame as
1510
+ * an ARRAY of `{path, kind, diff}`, and the bridge correlates that frame to the
1511
+ * approval request by item id before handing it here (APRV-379). Both arrive as
1512
+ * the server sent them and neither is re-rendered into the other.
1513
+ *
1514
+ * `null` for every other `apply_patch` call, which keeps the envelope path
1515
+ * exactly as it was: the native hook's `apply_patch` tool sends a command
1516
+ * string and reaches this function's `null` on the first test.
1517
+ */
1518
+ function codexFileChanges(toolInput) {
1519
+ const value = toolInput["file_changes"] ?? toolInput["fileChanges"];
1520
+ if (value === null || typeof value !== "object")
1521
+ return null;
1522
+ if (Array.isArray(value))
1523
+ return value.length === 0 ? null : value;
1524
+ const map = value;
1525
+ return Object.keys(map).length === 0 ? null : map;
1526
+ }
1527
+ /**
1528
+ * The paths a change set names, in the order this runtime will classify them,
1529
+ * or `null` when an entry names none (APRV-379).
1530
+ *
1531
+ * The map's keys ARE its paths, sorted so one change set is one description
1532
+ * whatever key order the server used. The array's entries each carry a `path`
1533
+ * string, and the order is the server's own: an array is a sequence and
1534
+ * reordering it would be this runtime describing a change set nobody sent. An
1535
+ * entry that is not an object, or whose `path` is not a string, yields `null`
1536
+ * and the caller refuses the whole set, because a change set with one
1537
+ * unreadable member is a change set this runtime cannot say the extent of.
1538
+ */
1539
+ function codexChangePaths(changes) {
1540
+ if (!Array.isArray(changes))
1541
+ return Object.keys(changes).sort();
1542
+ const paths = [];
1543
+ for (const entry of changes) {
1544
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry))
1545
+ return null;
1546
+ const declared = entry["path"];
1547
+ if (typeof declared !== "string")
1548
+ return null;
1549
+ paths.push(declared);
1550
+ }
1551
+ return paths;
1552
+ }
1553
+ /**
1554
+ * What a change SET asks for: one class per path it names, and the change bound
1555
+ * whole (APRV-363, APRV-379).
1556
+ *
1557
+ * The class rule is the file tools' rule and not a second one: a protected
1558
+ * target takes its derived protected class, every other target is
1559
+ * `files.write.workspace`, and the policy decides the autonomy of either. The
1560
+ * payload is the change rather than the touch, for the reason
1561
+ * {@link fileToolGate} states at length, plus `content_sha256` over the set as
1562
+ * it ARRIVED, so a human's grant binds the bytes the server sent and a later
1563
+ * reader can check that it did. The set goes into the payload in the shape it
1564
+ * arrived in, map or array; nothing here reads `diff`, `kind` or any other
1565
+ * member, because one observed `add` is not a licence to parse Codex patch
1566
+ * semantics in this repository.
1567
+ *
1568
+ * Fails closed on a path this runtime cannot place: one that lands outside the
1569
+ * directory the server named, or one carrying a NUL. A change whose target
1570
+ * cannot be resolved cannot be classified, and a classification against the
1571
+ * wrong tree is the failure this whole file exists to avoid.
1572
+ *
1573
+ * An ABSOLUTE path is accepted when it resolves INSIDE that directory, which
1574
+ * APRV-379 changed: the observed item frame names absolute paths
1575
+ * (`docs/codex-app-server-bridge.md`, question 1), and an absolute path inside
1576
+ * the directory is exactly as placeable as a relative one. Containment is what
1577
+ * was ever doing the work here, and it still decides: a path outside is refused
1578
+ * whichever way it was spelled.
1579
+ */
1580
+ function describeCodexFileChanges(changes, protectedPaths, cwd) {
1581
+ const classes = [];
1582
+ const notes = [];
1583
+ const paths = [];
1584
+ const declaredPaths = codexChangePaths(changes);
1585
+ if (declaredPaths === null) {
1586
+ return {
1587
+ kind: "deny",
1588
+ code: "hook-io",
1589
+ detail: "the file change set carries an entry that names no path string, so the extent of the change cannot be stated; nothing was classified",
1590
+ };
1591
+ }
1592
+ for (const declared of declaredPaths) {
1593
+ if (declared.length === 0 || declared.includes("\0")) {
1594
+ return {
1595
+ kind: "deny",
1596
+ code: "hook-io",
1597
+ detail: `the file change names ${JSON.stringify(declared)}, which is not a path this runtime can place; a change whose target cannot be placed cannot be classified`,
1598
+ };
1599
+ }
1600
+ const resolved = resolvePathSegments(cwd, declared);
1601
+ if (resolved !== cwd && !resolved.startsWith(`${cwd}${sep}`)) {
1602
+ return {
1603
+ kind: "deny",
1604
+ code: "hook-io",
1605
+ detail: `the file change names ${JSON.stringify(declared)}, which resolves outside ${cwd}`,
1606
+ };
1607
+ }
1608
+ paths.push(declared);
1609
+ const cls = protectedPathClass(resolved, protectedPaths) ?? WORKSPACE_WRITE_CLASS;
1610
+ if (!classes.includes(cls))
1611
+ classes.push(cls);
1612
+ notes.push(`change ${declared} (${cls})`);
1613
+ }
1614
+ return {
1615
+ kind: "gated",
1616
+ classes,
1617
+ payload: {
1618
+ tool: "apply_patch",
1619
+ rule: "codex app-server file change",
1620
+ cwd,
1621
+ paths,
1622
+ content_sha256: payloadHash(changes),
1623
+ changes,
1624
+ },
1625
+ headline: `apply_patch ${String(paths.length)} file change(s)`,
1626
+ notes,
1627
+ };
1628
+ }
1056
1629
  /**
1057
1630
  * What a non-Bash tool call asks for, or `null` when it names no file at all.
1058
1631
  *
@@ -1149,6 +1722,68 @@ function fileToolGate(toolName, toolInput, protectedPaths, cwd) {
1149
1722
  summary: summaryFor(tier, toolName, file),
1150
1723
  };
1151
1724
  }
1725
+ /**
1726
+ * The first entry of a `paths` array that falls outside the read scope, or
1727
+ * `null` when every entry is inside it (APRV-350).
1728
+ *
1729
+ * Separate from the single-path arm because the ANSWER is different: one path
1730
+ * either is or is not in scope, while a list is out of scope if ANY member is.
1731
+ * Returning the offending member rather than a boolean keeps the gated question
1732
+ * specific — an approver is asked about the directory that actually left the
1733
+ * scope, not about the whole list.
1734
+ */
1735
+ function firstOutOfScope(toolInput, roots, cwd) {
1736
+ const paths = toolInput["paths"];
1737
+ if (!Array.isArray(paths))
1738
+ return null;
1739
+ for (const entry of paths) {
1740
+ if (typeof entry !== "string" || entry.trim() === "")
1741
+ continue;
1742
+ const resolved = resolvedReadTarget(entry, cwd);
1743
+ if (resolved === null || !isInReadScope(resolved, roots))
1744
+ return entry;
1745
+ }
1746
+ return null;
1747
+ }
1748
+ function readToolGate(toolName, toolInput, roots, cwd) {
1749
+ if (roots.length === 0)
1750
+ return null;
1751
+ const declared = readString(toolInput, "file_path") ??
1752
+ readString(toolInput, "notebook_path") ??
1753
+ readString(toolInput, "path") ??
1754
+ // APRV-350: Muse's `search` names an ARRAY under `paths`, and the live
1755
+ // capture caught one pointing clean out of the workspace. The FIRST entry
1756
+ // that resolves outside the scope is the one gated: a search over five
1757
+ // directories where one is out of scope is an out-of-scope read, and
1758
+ // gating the first in-scope entry instead would have let it through. An
1759
+ // unreadable entry counts as out of scope for the same fail-closed reason
1760
+ // the single-path arm below resolves that way.
1761
+ firstOutOfScope(toolInput, roots, cwd);
1762
+ if (declared === null)
1763
+ return null;
1764
+ // FAIL CLOSED on an unresolvable path (the task's AC1, and SPEC.md §11.1):
1765
+ // a target nothing on this disk can place is a target nothing can say is
1766
+ // inside the scope, so it is out of it.
1767
+ const resolved = resolvedReadTarget(declared, cwd);
1768
+ if (resolved !== null && isInReadScope(resolved, roots))
1769
+ return null;
1770
+ const file = resolved ?? absolute(declared, cwd);
1771
+ const input = { ...toolInput };
1772
+ delete input["description"];
1773
+ return {
1774
+ file,
1775
+ declared,
1776
+ // The binding bytes name the RESOLVED path as well as the tool's own input,
1777
+ // so an approver reads where the data actually comes from and a grant over
1778
+ // one spelling cannot be spent on another.
1779
+ payload: { tool: toolName, rule: "read-scope", file, input },
1780
+ summary: `${toolName} ${file} (outside the read scope)`,
1781
+ };
1782
+ }
1783
+ /** The verdict note for a gated read: what was asked for, and against what. */
1784
+ function readScopeNote(gated, roots) {
1785
+ return `read-scope: ${gated.declared} resolves to ${gated.file}, which is outside the read scope (${renderReadRoots(roots)})`;
1786
+ }
1152
1787
  /** The headline for a tier: the qualifier first, the touch after it. */
1153
1788
  function summaryFor(tier, toolName, file) {
1154
1789
  if (tier.worktree !== null) {
@@ -1567,44 +2202,19 @@ function announceWait(streams, run, waiting) {
1567
2202
  streams.err(`approval: no listener is running for this log (${listener.reason}: ${socket}), so the request above may sit undelivered and a decision may go unconsumed. Start the gate's ambient runtime in the checkout that owns this log: \`eval "$(approval env)" && approval up\`, which runs the daemon loop and every configured channel in one process.\n`);
1568
2203
  }
1569
2204
  /**
1570
- * The gated half: find what is already open for these bytes, request whatever
1571
- * is not, wait for the decisions, spend the grants. Returns the exit code of
1572
- * whatever verdict it printed.
1573
- *
1574
- * ## Requests are keyed by bytes, not by invocation (APRV-117)
1575
- *
1576
- * The action key is still `hook:<session>:<tool-use id>:<class>` and is still
1577
- * unique per invocation — what changed is that intake LOOKS for an earlier
1578
- * request about the same `{command, cwd}` before opening a new one, matching on
1579
- * the `payload_hash` recorded on `approval.requested`. Three outcomes per class,
1580
- * decided by `core/gate.ts`'s `findHarnessCarry`:
1581
- *
1582
- * - nothing to carry: register and request, exactly as before;
1583
- * - a pending request: **adopt** it — wait out the remainder of this
1584
- * invocation's window on somebody else's key, opening nothing. The approver's
1585
- * phone never shows two prompts for one command, because there is only ever
1586
- * one question;
1587
- * - an unspent grant inside the TTL: **carry** it — no wait, no prompt, and
1588
- * the grant is spent (once) before the allow is printed.
2205
+ * The hook's own rendering of a verdict: one decision object on stdout, in the
2206
+ * dialect of the harness that asked.
1589
2207
  *
1590
- * ## Why the wait no longer ends in a withdrawal (APRV-106, revised)
1591
- *
1592
- * APRV-106 retracted the request when the wait elapsed, because a retried tool
1593
- * call was a new request with a new key and a late tap therefore authorized
1594
- * nothing: the human spent attention on a question whose asker had left. The
1595
- * carryover above removes the premise. A late tap now authorizes the retry, so
1596
- * the request stays open for the policy's TTL and the timeout says so.
1597
- *
1598
- * What still withdraws is every path where nothing can adopt the question: a
1599
- * SIGTERM or SIGINT (the session is going away), a thrown failure, and an intake
1600
- * refusal partway through a multi-class command (the command cannot proceed on
1601
- * any retry, so the classes already opened are noise in a human's queue). The
1602
- * signal handlers are installed for the duration of the wait ONLY, and removed
1603
- * in `finally`: a hook process is short-lived and borrowing the harness's
1604
- * signal disposition for longer than the loop would be a side effect nobody
1605
- * asked for.
2208
+ * The one place a verdict becomes bytes, so the Codex guard inside
2209
+ * {@link allow} — an allow that lost its exact bound `tool_input.command` is an
2210
+ * I/O failure rather than a permission — still stands over every path.
1606
2211
  */
1607
- function gateAndWait(streams, run, classes,
2212
+ function renderVerdict(streams, adapter, codexCommand, verdict) {
2213
+ return verdict.permission === "allow"
2214
+ ? allow(streams, verdict.reason, adapter.kind, codexCommand)
2215
+ : deny(streams, verdict.code, verdict.detail, adapter.kind);
2216
+ }
2217
+ export function gateHarnessCall(streams, run, classes,
1608
2218
  /**
1609
2219
  * The bytes the grant binds to: `{command, cwd}` for a Bash call, the change
1610
2220
  * itself for a file tool (APRV-124). Whatever this is, it is what reaches the
@@ -1655,18 +2265,22 @@ floor = null) {
1655
2265
  const floorApplies = (cls) => floor !== null && isSideEffectingClass(cls);
1656
2266
  const hash = payloadHash(payload);
1657
2267
  const summary = truncate(headline, SUMMARY_LIMIT);
1658
- const sayAllow = (reason) => allow(streams, reason, run.harness, run.codexCommand);
2268
+ const sayAllow = (reason) => ({ permission: "allow", reason });
1659
2269
  /**
1660
- * Every deny this function can print, with the floor's own sentence appended
2270
+ * Every deny this function can reach, with the floor's own sentence appended
1661
2271
  * when a floor is what routed the command here (APRV-280). One wrapper rather
1662
2272
  * than a sentence bolted onto the timeout alone: a floored invocation that
1663
2273
  * ends in a rejection, a lapse or an I/O fault leaves the agent in exactly the
1664
2274
  * same place, and the operator reading the harness's error stream needs the
1665
2275
  * scope key either way.
1666
2276
  */
1667
- const sayDeny = (code, detail) => deny(streams, code, floor === null
1668
- ? detail
1669
- : `${detail} This tool call was routed to a human by loop safety rather than by policy — loop-escalated: ${floor.scope} ${floor.key} has ${String(floor.consecutiveFailures)} consecutive failed side-effecting harness tool calls (amended SPEC.md §10.2). ${loopClearance(floor.scope, floor.key)}`, run.harness);
2277
+ const sayDeny = (code, detail) => ({
2278
+ permission: "deny",
2279
+ code,
2280
+ detail: floor === null
2281
+ ? detail
2282
+ : `${detail} This tool call was routed to a human by loop safety rather than by policy — loop-escalated: ${floor.scope} ${floor.key} has ${String(floor.consecutiveFailures)} consecutive failed side-effecting harness tool calls (amended SPEC.md §10.2). ${loopClearance(floor.scope, floor.key)}`,
2283
+ });
1670
2284
  // Intake reads the VERIFIED log, once, before anything is written: an
1671
2285
  // enforcement path reads nothing else (SPEC.md §11.1), and a carry decided
1672
2286
  // from unverified bytes would be a grant invented by whoever could write the
@@ -2093,9 +2707,71 @@ function report(streams, code, detail, extra = {}) {
2093
2707
  * whether two enumerated fields are present and what kind of value they hold.
2094
2708
  * No text from any of them reaches the log or this function's return.
2095
2709
  */
2710
+ /**
2711
+ * Muse's outcome, which it actually sends (APRV-350).
2712
+ *
2713
+ * The shell tool's `tool_response` is a JSON STRING, not an object, carrying
2714
+ * `exit_code`, `terminal_status`, `output` and `truncated`. The generic reader
2715
+ * above would see a string, find neither `interrupted` nor `error` on it, and
2716
+ * call every command completed — including the ones that failed. So Muse gets
2717
+ * its own reading, and it is the richer one: this harness sends both facts
2718
+ * Codex lacks.
2719
+ *
2720
+ * `terminal_status` leads because it distinguishes a command that ran from one
2721
+ * that was killed or timed out; `exit_code` decides the rest. Anything this
2722
+ * cannot read is UNREADABLE rather than assumed complete, which appends nothing
2723
+ * and leaves the path as vacuous as it was — the same choice the generic reader
2724
+ * makes, for the same reason. Nothing of `output` is read.
2725
+ */
2726
+ function readMuseReportedOutcome(input) {
2727
+ const raw = input.toolResponseRaw;
2728
+ // The file tools answer with an object (`filePath`, `structuredPatch`) and
2729
+ // the read tools with a plain string of file content. Neither carries an
2730
+ // outcome, and the event name is the only fact available for them.
2731
+ if (typeof raw !== "string") {
2732
+ return { ok: true, outcome: "completed" };
2733
+ }
2734
+ let body;
2735
+ try {
2736
+ body = JSON.parse(raw);
2737
+ }
2738
+ catch {
2739
+ // A non-JSON string is a read tool's content. It says nothing about
2740
+ // success, and the event fired at all, so the event name stands.
2741
+ return { ok: true, outcome: "completed" };
2742
+ }
2743
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
2744
+ return { ok: true, outcome: "completed" };
2745
+ }
2746
+ const fields = body;
2747
+ const status = fields["terminal_status"];
2748
+ if (typeof status === "string" && status !== "completed") {
2749
+ // `aborted`, `timed_out`, and whatever else Meta adds: a command that did
2750
+ // not finish on its own terms neither completed nor failed, exactly as an
2751
+ // interrupt does not.
2752
+ return {
2753
+ ok: false,
2754
+ detail: `terminal_status is ${JSON.stringify(status)}, so the command neither completed nor failed on its own terms`,
2755
+ };
2756
+ }
2757
+ const exitCode = fields["exit_code"];
2758
+ if (typeof exitCode === "number") {
2759
+ return { ok: true, outcome: exitCode === 0 ? "completed" : "failed" };
2760
+ }
2761
+ return { ok: true, outcome: "completed" };
2762
+ }
2096
2763
  function readReportedOutcome(input, adapter) {
2097
2764
  if (adapter.kind === "codex")
2098
2765
  return readCodexReportedOutcome(input);
2766
+ if (adapter.kind === "muse") {
2767
+ if (input.hookEventName !== "PostToolUse") {
2768
+ return {
2769
+ ok: false,
2770
+ detail: `hook_event_name is ${input.hookEventName === null ? "absent" : JSON.stringify(input.hookEventName)}, which is not the event this adapter reports an outcome for (PostToolUse)`,
2771
+ };
2772
+ }
2773
+ return readMuseReportedOutcome(input);
2774
+ }
2099
2775
  const event = input.hookEventName;
2100
2776
  if (event !== "PostToolUse" && event !== "PostToolUseFailure") {
2101
2777
  return {
@@ -2132,7 +2808,12 @@ function readReportedOutcome(input, adapter) {
2132
2808
  * is the reading of the event and nothing else.
2133
2809
  */
2134
2810
  function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
2135
- if (input.toolName !== adapter.shellTool && !adapter.fileTools.includes(input.toolName)) {
2811
+ if (input.toolName !== adapter.shellTool &&
2812
+ !adapter.fileTools.includes(input.toolName) &&
2813
+ // APRV-347: a read tool MAY have had a start written for it (one outside
2814
+ // the scope), so it belongs in this set. A read inside the scope wrote
2815
+ // nothing, and the close below finds no start and says so in its own words.
2816
+ !adapter.readTools.includes(input.toolName)) {
2136
2817
  return report(streams, "post-tool-not-gated", `${input.toolName} is not a gated tool, so no execution.started was ever written for it`);
2137
2818
  }
2138
2819
  if (input.toolUseId === null) {
@@ -2142,9 +2823,19 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
2142
2823
  // `approval status` rather than closed against a guess.
2143
2824
  return report(streams, "post-tool-unidentified", "the event carries no tool_use_id, so the task id the pre-execution hook minted cannot be reconstructed; nothing was appended");
2144
2825
  }
2826
+ // APRV-311. The id the pre-execution half minted for this same call, derived
2827
+ // here from the same two native fields it derived it from. It rides the
2828
+ // unreadable arm so that the line naming a start nobody closed also names
2829
+ // WHICH start: on Codex that arm is the only arm, and a diagnostic that
2830
+ // cannot be joined to an `execution.started` leaves an operator grepping a
2831
+ // log for a record they cannot identify. It is the correlation and nothing
2832
+ // more — no outcome is read, inferred, or appended on this path.
2833
+ const task = adapter.kind === "codex"
2834
+ ? codexBinding(input, cwd).task
2835
+ : `hook:${input.sessionId}:${input.toolUseId}`;
2145
2836
  const reading = readReportedOutcome(input, adapter);
2146
2837
  if (!reading.ok) {
2147
- return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended`);
2838
+ return report(streams, "post-tool-unreadable-outcome", `${reading.detail}; nothing was appended`, { task });
2148
2839
  }
2149
2840
  const { logPath, root } = hookScope(flags, cwd);
2150
2841
  if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
@@ -2159,12 +2850,35 @@ function runPostToolUse(flags, streams, cwd, input, actor, adapter) {
2159
2850
  reportedBy: "post-tool-use",
2160
2851
  }, actor);
2161
2852
  if (!finished.ok) {
2162
- return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message);
2853
+ return report(streams, `post-tool-gate-refused:${finished.code}`, finished.message, { task });
2163
2854
  }
2164
2855
  return report(streams, POST_TOOL_REPORTED, `recorded ${reading.outcome} for ${String(finished.records.length)} delegated execution(s) of ${finished.task}`, { task: finished.task, outcome: reading.outcome, appended: finished.records.length });
2165
2856
  }
2166
- function describeToolCall(input, adapter, protectedPaths, cwd) {
2857
+ function describeToolCall(input, adapter, protectedPaths, cwd, readRoots = []) {
2167
2858
  if (adapter.kind === "codex" && input.toolName === "apply_patch") {
2859
+ // APRV-363. The app-server's LEGACY `applyPatchApproval` carries its change
2860
+ // as a MAP of path to change, inline on the request, and never as an
2861
+ // `apply_patch` envelope. Rendering the map into an envelope so the parser
2862
+ // below could read it would classify bytes this runtime wrote rather than
2863
+ // bytes the server sent, which is the hazard APRV-362 exists for on the
2864
+ // command side, so the map is classified as a map: by the paths it names,
2865
+ // through the same protected-path rules every other file tool uses, with
2866
+ // the change itself bound verbatim. It is described HERE rather than by the
2867
+ // caller because one describer answers "what is this call", and a caller
2868
+ // that could hand this module its own classes would be the party under
2869
+ // oversight choosing its own scrutiny (SPEC §11.1 invariant 4).
2870
+ //
2871
+ // APRV-379 widened the material this branch reads and changed nothing about
2872
+ // the reading. The ITEM-BASED API delivers the same change set as an ARRAY
2873
+ // on an earlier `item/started` frame, and the bridge correlates that frame
2874
+ // to the approval request by item id before calling in here. One describer
2875
+ // still answers the question for both APIs, which is the point: two
2876
+ // describers would be two answers to "what is this call", and the second
2877
+ // one would be the one nobody reviewed.
2878
+ const changes = codexFileChanges(input.toolInput);
2879
+ if (changes !== null) {
2880
+ return describeCodexFileChanges(changes, protectedPaths, cwd);
2881
+ }
2168
2882
  const raw = readString(input.toolInput, "command");
2169
2883
  if (raw === null) {
2170
2884
  return { kind: "deny", code: "hook-io", detail: "apply_patch tool_input carries no command string" };
@@ -2192,17 +2906,55 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
2192
2906
  detail: `${adapter.shellTool} tool_input carries no command string`,
2193
2907
  };
2194
2908
  }
2909
+ // APRV-362. A call may state the argv its command renders, and the app-server
2910
+ // bridge does, so the payload can name the words the kernel receives rather
2911
+ // than a string somebody still has to parse. Two accounts of one call that
2912
+ // disagree describe no call at all, so the disagreement is refused here
2913
+ // instead of being resolved in favour of either. `codexArgv` accepts an
2914
+ // argv only when the command splits to exactly it, which is why the only
2915
+ // reachable outcomes are "absent", "agrees" and this.
2916
+ if (adapter.kind === "codex" && codexArgvDisagrees(input.toolInput, raw)) {
2917
+ return {
2918
+ kind: "deny",
2919
+ code: "hook-io",
2920
+ detail: "the call states an argv that its own command string does not split to; a payload cannot bind two readings of one command, so nothing was classified",
2921
+ };
2922
+ }
2923
+ // APRV-350: the PER-CALL working directory, where the harness sends one.
2924
+ //
2925
+ // Muse's `bash` carries `workdir`, and it is the directory the command will
2926
+ // actually run in; the top-level `cwd` is the session root and the two can
2927
+ // differ. The classifier resolves relative paths against this, so binding
2928
+ // the session root instead would classify `rm -rf docs` against the wrong
2929
+ // tree. Only an ABSOLUTE value is taken: a relative `workdir` is the
2930
+ // harness describing a location this runtime cannot place, and a
2931
+ // self-reported field may not talk its way into a narrower answer
2932
+ // (SPEC §11.1), so the fallback is the value that was already trusted.
2933
+ // `null` for every adapter that declares no key, which leaves both the
2934
+ // payload and the classification EXACTLY as they were. That is not
2935
+ // defensiveness: `input.cwd` and the hook's own `cwd` are different values,
2936
+ // and a first version of this that used the envelope's `cwd` for every
2937
+ // adapter re-classified Grok's commands against a directory that does not
2938
+ // exist on disk and denied them all.
2939
+ const perCallCwd = adapter.shellCwdKey === undefined
2940
+ ? null
2941
+ : readString(input.toolInput, adapter.shellCwdKey);
2942
+ const shellCwd = perCallCwd !== null && isAbsolute(perCallCwd) ? perCallCwd : null;
2195
2943
  // Unchanged since APRV-117, deliberately: the payload is the WHOLE command
2196
2944
  // and the directory it runs in, so the FULL PAYLOAD block on the phone
2197
2945
  // carries every byte the harness will execute. Only `summary` is shortened.
2198
2946
  const payload = adapter.bindToolName
2199
2947
  ? codexBinding(input, cwd).payload
2200
- : { command: raw, cwd: input.cwd };
2948
+ : { command: raw, cwd: shellCwd ?? input.cwd };
2201
2949
  // APRV-108: a local rewrite of history this checkout never published is a
2202
2950
  // commit. APRV-267: a delete confined to the agent's own scratch is not a
2203
2951
  // decision. Both run in the hook's own cwd, after classification and never
2204
2952
  // inside it, and neither claims anything it cannot establish from the disk.
2205
- const refined = classifyForHook(raw, protectedPaths, cwd);
2953
+ // Classified against the directory the command will RUN in, not the
2954
+ // hook's own, so a relative path in the command resolves the way the shell
2955
+ // will resolve it. For every adapter without a per-call working directory
2956
+ // this is `input.cwd` or the hook's own cwd exactly as before.
2957
+ const refined = classifyForHook(raw, protectedPaths, shellCwd ?? cwd, readRoots);
2206
2958
  const classified = refined.result;
2207
2959
  if (!classified.ok) {
2208
2960
  return {
@@ -2293,6 +3045,25 @@ function describeToolCall(input, adapter, protectedPaths, cwd) {
2293
3045
  segments: classified.segments,
2294
3046
  };
2295
3047
  }
3048
+ // APRV-347, above the file-tool branch because the two lists are disjoint and
3049
+ // a read is the cheaper question to answer: a read inside the scope, or one
3050
+ // naming no path at all, returns `null` here and takes the allow below.
3051
+ if (adapter.readTools.includes(input.toolName)) {
3052
+ const read = readToolGate(input.toolName, input.toolInput, readRoots, cwd);
3053
+ if (read === null) {
3054
+ return {
3055
+ kind: "allow",
3056
+ reason: `${input.toolName} is not a gated tool`,
3057
+ };
3058
+ }
3059
+ return {
3060
+ kind: "gated",
3061
+ classes: [READ_OUT_OF_SCOPE_CLASS],
3062
+ payload: read.payload,
3063
+ headline: read.summary,
3064
+ notes: [readScopeNote(read, readRoots)],
3065
+ };
3066
+ }
2296
3067
  const gated = fileToolGate(input.toolName, input.toolInput, protectedPaths, cwd);
2297
3068
  if (gated === null) {
2298
3069
  return { kind: "allow", reason: `${input.toolName} names no file, so there is nothing to gate` };
@@ -2439,7 +3210,11 @@ decidedOn) {
2439
3210
  const policyNote = load.ok
2440
3211
  ? null
2441
3212
  : `the policy did not load (${load.code}: ${load.message}), so no protected path beyond the built-ins was known here and no class could be resolved to human-only`;
2442
- const described = describeToolCall(input, adapter, protectedPaths, cwd);
3213
+ const described = describeToolCall(input, adapter, protectedPaths, cwd,
3214
+ // APRV-347. The window suspends the POLICY, not the classification: a read
3215
+ // outside the scope is described as one here too, so the bypass RECORD says
3216
+ // what was actually authorized rather than calling it an ordinary read.
3217
+ resolveReadRoots(cwd, policyRootOf(load, scope.root), load.ok ? load.policy.read_scope?.roots : undefined));
2443
3218
  if (described.kind === "deny") {
2444
3219
  return deny(streams, described.code, `${described.detail} The open window does not reach this: a command the classifier cannot read is a command nothing here can establish is safe to run unapproved.`, adapter.kind);
2445
3220
  }
@@ -2459,6 +3234,15 @@ decidedOn) {
2459
3234
  return deny(streams, "hook-class-human-only", `${mutation} is never reachable through the open window: the window suspends the POLICY, and the log is what the window itself is derived from. A bypass able to write the log could rewrite its own authorization. Nothing was appended; a human writes the log directory by hand or not at all.`, adapter.kind);
2460
3235
  }
2461
3236
  if (load.ok) {
3237
+ // APRV-354, above the human-only check here for the same reason it sits
3238
+ // above it on the ordinary path. A window suspends what the policy DECIDES;
3239
+ // it cannot supply a decision the policy never made. A launch the policy
3240
+ // names no rule for is not a class the window is holding open — it is a
3241
+ // class nobody has opted into — so the window does not reach it either.
3242
+ const unruled = classes.find((cls) => harnessLaunchNeedsRule(cls, resolvePolicy(load, cls)));
3243
+ if (unruled !== undefined) {
3244
+ return deny(streams, "hook-harness-launch-unruled", `${harnessLaunchUnruledRefusal(unruled, "this command may not run under an agent")} An open window does not reach it: a window suspends what the policy decides, and this is a class the policy has not spoken about at all.`, adapter.kind);
3245
+ }
2462
3246
  const reserved = classes.find((cls) => resolvePolicy(load, cls).autonomy === "human-only");
2463
3247
  if (reserved !== undefined) {
2464
3248
  return deny(streams, "hook-class-human-only", `${humanOnlyRefusal(reserved, "this command may not run under an agent")} An open window does not reach it: the window suspends what the policy DECIDES, and a human-only class is one the policy reserves to human hands, which a window opened by a human does not lend to an agent (SPEC.md §11.1 invariant 9).`, adapter.kind);
@@ -2511,7 +3295,18 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2511
3295
  if (!parsed.ok)
2512
3296
  return configurationError(parsed.message);
2513
3297
  if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
2514
- streams.out(`${HOOK_HELP}\n`);
3298
+ // APRV-243. Grok's help carries the `.grok/hooks/*.json` the human commits,
3299
+ // because that file's `timeout` is load-bearing: it must exceed `--timeout`
3300
+ // or Grok abandons the hook mid-wait and, failing open, runs the command.
3301
+ // Muse gets its own for the same reason and one more: its `.muse/hooks.json`
3302
+ // shape is not Claude's file under a different name, and an operator who
3303
+ // guessed would get "Hooks: 0 runnable" and a session that looks gated.
3304
+ const harnessHelp = adapter.kind === "grok"
3305
+ ? HOOK_GROK_HELP
3306
+ : adapter.kind === "muse"
3307
+ ? HOOK_MUSE_HELP
3308
+ : HOOK_HELP;
3309
+ streams.out(`${harnessHelp}\n`);
2515
3310
  return EXIT_OK;
2516
3311
  }
2517
3312
  const extra = parsed.positionals[0];
@@ -2541,16 +3336,65 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2541
3336
  if (graceMs === null) {
2542
3337
  return configurationError(`--retry-grace expects a duration like 5m, 30s, 1ms, got ${JSON.stringify(graceText)}`);
2543
3338
  }
2544
- const parsedInput = parseHookInput(readStdin());
3339
+ const parsedInput = parseHookInput(readStdin(), adapter.camelCaseEnvelope === true);
2545
3340
  if (!parsedInput.ok)
2546
3341
  return deny(streams, "hook-io", parsedInput.detail, adapter.kind);
2547
3342
  const input = parsedInput.input;
2548
3343
  if (adapter.kind === "codex") {
2549
3344
  const checked = checkCodexHookInput(input, cwd);
2550
- if (!checked.ok)
2551
- return deny(streams, "hook-io", checked.detail, adapter.kind);
3345
+ if (!checked.ok) {
3346
+ // APRV-311. WHICH PHASE the malformed event belongs to decides which
3347
+ // vocabulary refuses it, and until now both took the pre-execution one.
3348
+ // A `PostToolUse` event naming an unsupported tool, or carrying an id
3349
+ // this adapter will not accept, printed
3350
+ // `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}`
3351
+ // at exit 0: a permission decision about a call that has already run, in
3352
+ // the same words the pre-execution refusal uses, so nothing downstream
3353
+ // could tell the two apart. Nothing was ever going to be authorized here.
3354
+ //
3355
+ // The strict answer on this side is the one the rest of the post path
3356
+ // already gives: say so on stderr at the exit code that makes the line
3357
+ // visible, append nothing, and print no verdict. The event name is read
3358
+ // off the raw input rather than off the check, because the check is what
3359
+ // just failed.
3360
+ return input.hookEventName === CODEX_POST_TOOL_EVENT
3361
+ ? report(streams, "post-tool-io", `${checked.detail}; nothing was appended, so the start this event would have closed is still open`)
3362
+ : deny(streams, "hook-io", checked.detail, adapter.kind);
3363
+ }
2552
3364
  }
2553
3365
  const codexCommand = adapter.kind === "codex" ? codexBinding(input, cwd).payload.command : undefined;
3366
+ // APRV-350. The contributor guard runs BEFORE the event dispatch, the policy
3367
+ // and everything else, because it is not a question about the action: a
3368
+ // Contributor-tier session discloses every byte it reads, so there is nothing
3369
+ // for a policy to authorize. Refusing first also means no unparseable payload,
3370
+ // no missing log and no classifier quirk can route around it.
3371
+ //
3372
+ // On a POST event it refuses in the post vocabulary and prints no verdict:
3373
+ // Muse rejects a permission field on a post event, and a rejected hook is a
3374
+ // FAILED hook, which fails open. There is nothing to block after the fact
3375
+ // anyway; the line exists so the disclosure is on a stream somebody reads.
3376
+ if (adapter.contributorModelGuard === true) {
3377
+ const refusal = contributorModelRefusal(input.model);
3378
+ if (refusal !== null) {
3379
+ const postEvent = input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
3380
+ return postEvent
3381
+ ? report(streams, MUSE_CONTRIBUTOR_REFUSAL, `${refusal} Nothing was appended.`)
3382
+ : deny(streams, MUSE_CONTRIBUTOR_REFUSAL, refusal, adapter.kind);
3383
+ }
3384
+ }
3385
+ // APRV-350. The harness's own bookkeeping tool, answered and never gated.
3386
+ // Muse fires both events for `submit_reminder_decision` continuously — 100 of
3387
+ // the 139 events in the live capture — and it records a self-assessment and
3388
+ // touches nothing. It is answered with an explicit allow rather than left to
3389
+ // fall through, so the verdict is one dialect and nothing else, which is the
3390
+ // only shape this harness parses.
3391
+ if (adapter.passThroughTools?.includes(input.toolName) === true) {
3392
+ const passPostEvent = input.hookEventName !== null && POST_TOOL_EVENTS.includes(input.hookEventName);
3393
+ if (passPostEvent) {
3394
+ return report(streams, "post-tool-not-gated", `${input.toolName} is the harness's own bookkeeping tool, so no execution.started was ever written for it`);
3395
+ }
3396
+ return allow(streams, `${input.toolName} is ${adapter.kind}'s own bookkeeping tool: it records the session's self-assessment and touches nothing outside the session`, adapter.kind);
3397
+ }
2554
3398
  // APRV-145: WHICH EVENT THIS IS, read first and read at all. One command is
2555
3399
  // registered for two events, and they do opposite things — one answers before
2556
3400
  // the tool runs, the other records how it went — so the dispatch is the first
@@ -2571,11 +3415,19 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2571
3415
  // already finished, and the reason the counterpart did not land would be
2572
3416
  // dressed as a permission decision. A throw on this path is `post-tool-io`,
2573
3417
  // on stderr, at the exit code that makes the line visible.
3418
+ //
3419
+ // APRV-243. Grok Build reads exit 2 as DENY, full stop, so the visibility
3420
+ // exit this path uses everywhere else would be a permission decision about
3421
+ // a tool call that has already run. On this one harness the post-execution
3422
+ // path therefore always exits 0. The machine-readable line still goes to
3423
+ // stderr; whether Grok shows it is Grok's business, and losing a debug line
3424
+ // is a smaller harm than emitting a verdict the protocol will act on.
3425
+ const settle = (code) => (adapter.kind === "grok" ? EXIT_OK : code);
2574
3426
  try {
2575
- return runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter);
3427
+ return settle(runPostToolUse(parsed.flags, streams, cwd, input, actor, adapter));
2576
3428
  }
2577
3429
  catch (cause) {
2578
- return report(streams, "post-tool-io", `the counterpart failed: ${cause instanceof Error ? cause.message : String(cause)}; nothing was appended, so the start this event would have closed is still open`);
3430
+ return settle(report(streams, "post-tool-io", `the counterpart failed: ${cause instanceof Error ? cause.message : String(cause)}; nothing was appended, so the start this event would have closed is still open`));
2579
3431
  }
2580
3432
  }
2581
3433
  // Codex 0.152.1 can execute Bash in a per-call working directory that is
@@ -2585,10 +3437,32 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2585
3437
  // harness executes. Refuse before the open-window, gate-self, carry, or
2586
3438
  // registration paths; none of those can supply the missing directory fact.
2587
3439
  if (adapter.kind === "codex" && input.toolName === "Bash") {
2588
- return deny(streams, "hook-io", "Codex Bash is disabled because the native hook contract does not expose the effective per-call working directory; no policy or open window can authorize bytes the hook cannot bind", adapter.kind);
3440
+ return deny(streams, "hook-unsupported-execution-context", "Codex Bash is disabled because the native hook contract does not expose the effective per-call working directory; no policy or open window can authorize bytes the hook cannot bind", adapter.kind);
2589
3441
  }
2590
3442
  if (input.toolName !== adapter.shellTool && !adapter.fileTools.includes(input.toolName)) {
2591
- return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
3443
+ if (!adapter.readTools.includes(input.toolName)) {
3444
+ return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
3445
+ }
3446
+ // APRV-347, and the placement is the whole of its cost. A read tool call is
3447
+ // the most frequent event a session produces, and before this task it was
3448
+ // answered here with no policy load, no verified read of the log and no
3449
+ // window lookup. Everything below this line is expensive, so the reads that
3450
+ // do not need it must not pay for it.
3451
+ //
3452
+ // The short-circuit is sound because `read_scope` is ADDITIVE: it can only
3453
+ // widen the roots, so a target already inside the BUILT-IN roots is inside
3454
+ // the effective ones whatever the policy says, and the policy need not be
3455
+ // read to know it. A target outside them falls through, the policy is
3456
+ // loaded below, and `describeToolCall` asks again with the widened set —
3457
+ // which is where a policy-declared root actually takes effect.
3458
+ //
3459
+ // The cost of the fast path is a handful of `realpath` calls and no process
3460
+ // spawn, provided the hook is configured with `--dir` as the docs show
3461
+ // (otherwise `hookScope` runs `git rev-parse` to find the primary).
3462
+ const early = hookScope(parsed.flags, cwd);
3463
+ if (readToolGate(input.toolName, input.toolInput, resolveReadRoots(cwd, early.root), cwd) === null) {
3464
+ return allow(streams, `${input.toolName} is not a gated tool`, adapter.kind, codexCommand);
3465
+ }
2592
3466
  }
2593
3467
  // APRV-188. From here on this process may resume a verified read behind the
2594
3468
  // snapshot the daemon published, instead of walking the chain from genesis:
@@ -2625,6 +3499,40 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2625
3499
  // verdict and the record are one read of the log.
2626
3500
  looked.records === null ? null : { records: looked.records, head: looked.head });
2627
3501
  }
3502
+ return renderVerdict(streams, adapter, codexCommand, decideHarnessCall({
3503
+ streams,
3504
+ input,
3505
+ adapter,
3506
+ cwd,
3507
+ logPath,
3508
+ root,
3509
+ options,
3510
+ actor,
3511
+ timeoutMs,
3512
+ intervalMs,
3513
+ graceMs,
3514
+ codexCommand,
3515
+ windowRecords: looked.records,
3516
+ }));
3517
+ }
3518
+ /**
3519
+ * Classify, resolve, gate and wait: one harness tool call, from the event to a
3520
+ * verdict (APRV-361).
3521
+ *
3522
+ * Extracted from the hook's own verb so a SECOND caller can reach a decision
3523
+ * through exactly this sequence. `cli/codex-bridge.ts` answers Codex's
3524
+ * app-server approval requests over JSON-RPC rather than on stdout, and the
3525
+ * thing it must not do is re-implement any of what is below: the human-only
3526
+ * refusal, the unruled `harness.launch.*` refusal, the sandbox requirement, the
3527
+ * loop floor, the unattended guard, the autonomous charge, and the register,
3528
+ * request and wait that follow. Two implementations of that sequence would be
3529
+ * two gates, and the second one would be the one nobody reviewed.
3530
+ *
3531
+ * It returns a verdict and prints none. `streams.err` still carries the
3532
+ * progress and withdrawal lines, which are a report rather than a decision.
3533
+ */
3534
+ export function decideHarnessCall(decide) {
3535
+ const { streams, input, adapter, cwd, logPath, root, options, actor, timeoutMs, intervalMs, graceMs, codexCommand, windowRecords, } = decide;
2628
3536
  // The policy is read BEFORE the command is classified (APRV-107): the
2629
3537
  // protected-path set is built-ins plus `policy.protected_paths`, so what
2630
3538
  // counts as a protected path is a policy question and the classifier cannot be
@@ -2637,25 +3545,36 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2637
3545
  ? { dir: options.policy?.dir ?? cwd }
2638
3546
  : { file: options.policy.file });
2639
3547
  if (!load.ok) {
2640
- return deny(streams, "hook-policy-unavailable", `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`, adapter.kind);
3548
+ return {
3549
+ permission: "deny",
3550
+ code: "hook-policy-unavailable",
3551
+ detail: `${load.code}: ${load.message}; every class resolves to manual and the hook cannot verify a decision`,
3552
+ };
2641
3553
  }
2642
3554
  const protectedPaths = load.policy.protected_paths ?? [];
3555
+ // APRV-347. Resolved from the LOADED policy's own directory, so the scope is
3556
+ // anchored to the gate a decision will be recorded in, and widened by
3557
+ // `read_scope.roots` if that policy declares any.
3558
+ const readRoots = resolveReadRoots(cwd, policyRootOf(load, root), load.policy.read_scope?.roots);
2643
3559
  // What is being asked for, as one or more classes. One description site for
2644
3560
  // both paths since APRV-214 (see `describeToolCall`): the open window
2645
3561
  // classifies exactly as the closed one does, and a second copy of this would
2646
3562
  // be a second answer to "what is this command".
2647
- const described = describeToolCall(input, adapter, protectedPaths, cwd);
3563
+ const described = describeToolCall(input, adapter, protectedPaths, cwd, readRoots);
2648
3564
  if (described.kind === "deny") {
2649
- return deny(streams, described.code, described.detail, adapter.kind);
3565
+ return { permission: "deny", code: described.code, detail: described.detail };
2650
3566
  }
2651
3567
  if (described.kind === "allow") {
2652
- return allow(streams, described.reason, adapter.kind, codexCommand);
3568
+ return { permission: "allow", reason: described.reason };
2653
3569
  }
2654
3570
  const { classes, payload, headline } = described;
2655
3571
  /** What the history-rewrite refinement did, for the decision reason. */
2656
3572
  const notes = [...described.notes];
2657
3573
  if (classes.length === 0) {
2658
- return allow(streams, "the approval CLI is the gate itself and is not gated by it", adapter.kind, codexCommand);
3574
+ return {
3575
+ permission: "allow",
3576
+ reason: "the approval CLI is the gate itself and is not gated by it",
3577
+ };
2659
3578
  }
2660
3579
  // Every path from here needs the log, the fast paths included (APRV-139):
2661
3580
  // attestation and loop-escalation are facts about the log, so the
@@ -2664,7 +3583,11 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2664
3583
  // on-disk policy called autonomous; it now denies, which is the same answer
2665
3584
  // it already gave every other class.
2666
3585
  if (!existsSync(logPath) && !existsSync(dirname(logPath))) {
2667
- return deny(streams, "hook-log-unreachable", `no log at ${logPath}; the hook writes to an existing log and never creates one. Run \`approval init\` (then \`approval policy attest\`) in ${root}, or pass --log <path> to point the hook at the log that already exists`, adapter.kind);
3586
+ return {
3587
+ permission: "deny",
3588
+ code: "hook-log-unreachable",
3589
+ detail: `no log at ${logPath}; the hook writes to an existing log and never creates one. Run \`approval init\` (then \`approval policy attest\`) in ${root}, or pass --log <path> to point the hook at the log that already exists`,
3590
+ };
2668
3591
  }
2669
3592
  // Minted once, here, and carried into `gateAndWait`: the loop-escalation
2670
3593
  // check below and any registration that follows must name the same task.
@@ -2690,7 +3613,26 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2690
3613
  // state.
2691
3614
  channels: Object.keys(load.policy.channels ?? {}).sort(),
2692
3615
  };
2693
- const autonomies = classes.map((cls) => resolvePolicy(load, cls).autonomy);
3616
+ const resolutions = classes.map((cls) => resolvePolicy(load, cls));
3617
+ const autonomies = resolutions.map((resolution) => resolution.autonomy);
3618
+ // APRV-354, and ABOVE the human-only deny because it is the narrower
3619
+ // statement: this class is not merely reserved, it is one the policy has not
3620
+ // spoken about at all, and the repair is a line rather than a person.
3621
+ //
3622
+ // A `harness.launch.*` class resolves only under a rule an operator wrote. A
3623
+ // policy naming neither the family nor the member refuses the launch instead
3624
+ // of falling to `defaults.autonomy`, which keeps arrival exactly as strict as
3625
+ // it was before the family existed (`hook-unclassified`, refused) until
3626
+ // somebody opts in. See `harnessLaunchNeedsRule` for why the family gets this
3627
+ // and no other class does.
3628
+ const unruled = classes.find((cls, index) => harnessLaunchNeedsRule(cls, resolutions[index]));
3629
+ if (unruled !== undefined) {
3630
+ return {
3631
+ permission: "deny",
3632
+ code: "hook-harness-launch-unruled",
3633
+ detail: harnessLaunchUnruledRefusal(unruled, "this command may not run under an agent"),
3634
+ };
3635
+ }
2694
3636
  // APRV-185, amended SPEC.md §5.2, and the first verdict this function reaches
2695
3637
  // once the classes have autonomies. A command touching a class the policy
2696
3638
  // reserves to human hands is denied outright: no request is opened, no task is
@@ -2706,7 +3648,11 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2706
3648
  // strictest thing in it.
2707
3649
  const reserved = classes.find((_cls, index) => autonomies[index] === "human-only");
2708
3650
  if (reserved !== undefined) {
2709
- return deny(streams, "hook-class-human-only", `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.`, adapter.kind);
3651
+ return {
3652
+ permission: "deny",
3653
+ code: "hook-class-human-only",
3654
+ detail: `${humanOnlyRefusal(reserved, "this command may not run under an agent")} The gate's own code for this fact is \`class-human-only\`.`,
3655
+ };
2710
3656
  }
2711
3657
  // APRV-193, and BELOW the human-only deny for the same reason that one sits
2712
3658
  // above the floor: a class no agent may run is answered before a question
@@ -2714,7 +3660,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2714
3660
  // refused command leaves the log exactly as it found it.
2715
3661
  const unsandboxed = sandboxRequirement(described.segments, autonomies);
2716
3662
  if (unsandboxed !== null) {
2717
- return deny(streams, "hook-sandbox-required", unsandboxed, adapter.kind);
3663
+ return { permission: "deny", code: "hook-sandbox-required", detail: unsandboxed };
2718
3664
  }
2719
3665
  // APRV-145, amended SPEC.md §10.2: loop safety on a surface that mints a
2720
3666
  // fresh task id per tool call. The floor is applied AFTER class resolution and
@@ -2729,9 +3675,9 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2729
3675
  // have proceeded is routed to the human gate for this invocation (APRV-297
2730
3676
  // narrowed it to those); a class that already resolves manual is untouched,
2731
3677
  // because it was already going there.
2732
- const floored = harnessFloor(logPath, task, actor, looked.records);
3678
+ const floored = harnessFloor(logPath, task, actor, windowRecords);
2733
3679
  if (!floored.ok)
2734
- return deny(streams, "hook-io", floored.detail, adapter.kind);
3680
+ return { permission: "deny", code: "hook-io", detail: floored.detail };
2735
3681
  /**
2736
3682
  * The streak the log shows, before the read carve-out (APRV-297).
2737
3683
  *
@@ -2780,9 +3726,10 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2780
3726
  /** No class here needs a human, so nothing downstream will ask for one. */
2781
3727
  const unattended = floor === null && autonomies.every((autonomy) => autonomy !== "manual");
2782
3728
  if (unattended) {
2783
- const refused = unattendedGuard(logPath, load.source.path, task, looked.records);
2784
- if (refused !== null)
2785
- return deny(streams, refused.code, refused.detail, adapter.kind);
3729
+ const refused = unattendedGuard(logPath, load.source.path, task, windowRecords);
3730
+ if (refused !== null) {
3731
+ return { permission: "deny", code: refused.code, detail: refused.detail };
3732
+ }
2786
3733
  }
2787
3734
  if (floor === null && autonomies.every((autonomy) => autonomy === "autonomous")) {
2788
3735
  // No approval lifecycle: an autonomous action has none (amended SPEC.md
@@ -2792,9 +3739,13 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2792
3739
  // charge is not a budget. See `recordUnattended`.
2793
3740
  const charged = recordUnattended(run, task, classes, payloadHash(payload));
2794
3741
  if (charged !== null) {
2795
- return deny(streams, `hook-gate-refused:${charged.code}`, charged.message, adapter.kind);
3742
+ return {
3743
+ permission: "deny",
3744
+ code: `hook-gate-refused:${charged.code}`,
3745
+ detail: charged.message,
3746
+ };
2796
3747
  }
2797
- return allow(streams, `autonomous: ${classes.join(", ")}${note}`, adapter.kind, codexCommand);
3748
+ return { permission: "allow", reason: `autonomous: ${classes.join(", ")}${note}` };
2798
3749
  }
2799
3750
  // Past here the hook appends. It writes to a log that already exists and
2800
3751
  // creates none: a log the hook scaffolded where it happened to be standing
@@ -2802,7 +3753,7 @@ function runHarnessHook(argv, streams, cwd, readStdin, adapter) {
2802
3753
  // do not survive a merge. An initialized-but-empty `.approval/log/` counts as
2803
3754
  // reachable — an audit trail that has recorded nothing is an empty log, not a
2804
3755
  // missing one (see `preflightLog`) — and `register` appends the first line.
2805
- return gateAndWait(streams, run, classes, payload, headline, task, note, floor);
3756
+ return gateHarnessCall(streams, run, classes, payload, headline, task, note, floor);
2806
3757
  }
2807
3758
  function commandHarnessHook(argv, streams, cwd, readStdin, adapter) {
2808
3759
  try {
@@ -2833,17 +3784,14 @@ export function commandHook(argv, streams, cwd, readStdin = defaultStdin) {
2833
3784
  streams.out(`${HOOK_HELP}\n`);
2834
3785
  return EXIT_OK;
2835
3786
  }
2836
- switch (sub) {
2837
- case "claude-code":
2838
- return commandHarnessHook(rest, streams, cwd, readStdin, CLAUDE_ADAPTER);
2839
- case "cursor":
2840
- return commandHarnessHook(rest, streams, cwd, readStdin, CURSOR_ADAPTER);
2841
- case "codex":
2842
- return commandHarnessHook(rest, streams, cwd, readStdin, CODEX_ADAPTER);
2843
- case "classify":
2844
- return commandClassify(rest, streams, cwd);
2845
- default:
2846
- return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
3787
+ // APRV-358: one list. `isHarnessKind` is the same predicate the write
3788
+ // boundary and doctor use, so a kind that can be recorded is a kind that can
3789
+ // be invoked, and neither can be added without the other.
3790
+ if (isHarnessKind(sub)) {
3791
+ return commandHarnessHook(rest, streams, cwd, readStdin, HARNESS_ADAPTERS[sub]);
2847
3792
  }
3793
+ if (sub === "classify")
3794
+ return commandClassify(rest, streams, cwd);
3795
+ return usageError(streams, `unknown subcommand ${JSON.stringify(sub)} for \`approval hook\``);
2848
3796
  }
2849
3797
  //# sourceMappingURL=hook.js.map