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
@@ -32,7 +32,23 @@
32
32
  * Self-reported text is never read. The hook passes the command only; the
33
33
  * harness's `description` field is authored by the very agent being gated
34
34
  * (SPEC.md §11.1: self-reported fields never reduce scrutiny).
35
+ *
36
+ * Two imports, and both are pure in exactly the same way this file is: no disk,
37
+ * no clock, no environment, no dependencies.
38
+ *
39
+ * `core/read-scope.ts` (APRV-347) holds the read-side path arithmetic, so the
40
+ * hook and the policy explainer can ask the same questions this file asks, of
41
+ * the same table, rather than each growing a copy of it.
42
+ *
43
+ * `core/policy-match.ts` (APRV-354) is imported for ONE name, the
44
+ * `harness.launch.` prefix. The classifier emits the family and two enforcement
45
+ * paths refuse a member of it that no policy rule names, so the three have to
46
+ * agree on what the family is; a second spelling of the prefix would close one
47
+ * of those doors and leave the other open. The import is type-safe in the
48
+ * dependency sense as well: `policy-match.ts` itself imports only types.
35
49
  */
50
+ import { HARNESS_LAUNCH_PREFIX } from "./policy-match.js";
51
+ import { READ_OUT_OF_SCOPE_CLASS, isUnreadableTarget, readTargetVerdict, readTargetsOf, } from "./read-scope.js";
36
52
  /**
37
53
  * The pass-through pseudo-class for the gate's own CLI.
38
54
  *
@@ -240,6 +256,36 @@ export function protectedPathClass(candidate, extra = []) {
240
256
  if (next === "hooks.json" || next === "hooks" || next === "agents")
241
257
  return "policy.core";
242
258
  }
259
+ // Grok Build's equivalent: `.grok/hooks/*.json` is where its PreToolUse
260
+ // entries are installed, and the scripts beside them are what those
261
+ // entries run. Same property as `.cursor/hooks.json` above and for the
262
+ // same reason (APRV-243): an agent that could write those could write
263
+ // itself out of the gate. Grok also READS `.claude/settings.json` and
264
+ // `.cursor/hooks.json` for compatibility, and both are already here.
265
+ if (segment === ".grok") {
266
+ const next = segments[index + 1];
267
+ if (next === "hooks.json" || next === "hooks")
268
+ return "policy.core";
269
+ }
270
+ // Muse Code's equivalent, OBSERVED rather than guessed (APRV-350): the live
271
+ // probe on muse-bin-1.3.0-R3233.1 established that the installed build reads
272
+ // `.muse/hooks.json` in the project, and that it rejects a malformed one
273
+ // loudly at startup. `settings` is listed beside it because Meta documents
274
+ // user-level hooks inside a `settings.json` `hooks` block, so a
275
+ // project-level copy would be the same organ under a second name; it never
276
+ // fired in the probe, and an entry for a file Muse does not read is INERT,
277
+ // while a missing entry for one it does read would be the hole.
278
+ //
279
+ // `worktrees` is deliberately NOT here. Muse keeps its own worktree state
280
+ // under `.muse/worktrees/`, which is ordinary workspace content: making it
281
+ // `policy.core` would price routine session bookkeeping at a human's
282
+ // attention, which is the failure mode §11 asks to avoid.
283
+ if (segment === ".muse") {
284
+ const next = segments[index + 1];
285
+ if (next === "hooks.json" || next === "hooks" || next?.startsWith("settings")) {
286
+ return "policy.core";
287
+ }
288
+ }
243
289
  // Codex installs its hook through these configuration and script paths.
244
290
  if (segment === ".codex") {
245
291
  const next = segments[index + 1];
@@ -472,6 +518,19 @@ export const NON_SECRET_ENV_NAMES = [
472
518
  "APPROVAL_MD",
473
519
  "APPROVAL_HOME",
474
520
  "APPROVAL_DIR",
521
+ /**
522
+ * Where the per-machine bot-ownership registry lives (APRV-390).
523
+ *
524
+ * A DIRECTORY PATH, and the same kind of thing `APPROVAL_HOME` and
525
+ * `APPROVAL_DIR` already are. It holds no secret and opens nothing: the file
526
+ * it points at carries bot ids, usernames and instance directories, all of
527
+ * which `.approval/env` carries in the open, and nothing reads it to widen a
528
+ * permission. Passed through because a child `approval` verb must resolve the
529
+ * SAME registry as its parent — a child that silently fell back to the
530
+ * platform default would answer "which instance owns this bot?" from a
531
+ * different file than the process that asked it.
532
+ */
533
+ "APPROVAL_STATE_DIR",
475
534
  ];
476
535
  /**
477
536
  * Does this bare variable name name credential material?
@@ -552,6 +611,33 @@ const OPERATOR_CHARS = new Set(["&", "|", ";", "(", ")", "<", ">", "\n"]);
552
611
  * Not understood, on purpose: parameter expansion values. `$VAR` and `${VAR}`
553
612
  * are kept verbatim in the word text, and every rule that reads a path or a
554
613
  * refspec treats a word containing `$` as unknown, which resolves stricter.
614
+ *
615
+ * ## Quoted text is DATA, and that is a contract (APRV-353)
616
+ *
617
+ * The command boundary this tokenizer honours is the shell's own. A quoted
618
+ * argument is ONE word to the shell, so nothing inside it is an operator, a
619
+ * redirection, a segment separator or a command name here either:
620
+ *
621
+ * - single-quoted text is wholly inert, every byte of it, `$` and backtick
622
+ * included;
623
+ * - double-quoted text is inert too, with the two exceptions the shell itself
624
+ * makes — `$(…)` and backticks, which it expands before the command runs and
625
+ * which therefore keep the class they have anywhere else (a substitution is
626
+ * classified recursively, a backtick is `opaque`);
627
+ * - adjacent quoted and unquoted runs concatenate into one word (`'a'"b"c`),
628
+ * and a backslash escape is applied where the shell applies it;
629
+ * - UNQUOTED operators split exactly as they always did, and quoting that does
630
+ * not balance is {@link LexResult} `ok: false` — `unparseable`, a refusal —
631
+ * rather than a guess at what the writer meant.
632
+ *
633
+ * This is stated rather than merely true because the failure it prevents is
634
+ * silent and one-directional. A backlog note that says the word `bash`, carries
635
+ * an angle-bracketed placeholder, a pipe or a semicolon is prose about work; a
636
+ * tokenizer that read it as syntax would refuse an ordinary workspace write and
637
+ * push its author toward rewording the record of what they did, which is the
638
+ * audit cost SPEC.md §11 exists to protect. Every printable ASCII character is
639
+ * covered in both quote styles by `tests/command-class-quoting.test.ts`, so an
640
+ * edit that loses the property fails there rather than in someone's notes.
555
641
  */
556
642
  function lex(command) {
557
643
  const segments = [];
@@ -737,6 +823,19 @@ function lex(command) {
737
823
  flush(command.length);
738
824
  return { ok: true, segments };
739
825
  }
826
+ /**
827
+ * The refusal detail for a backtick the shell would expand inside a
828
+ * double-quoted argument (APRV-353).
829
+ *
830
+ * Same code (`opaque`), same verdict (deny), more use: double quotes are what
831
+ * an author reaches for when the text carries an apostrophe, and a note that
832
+ * quotes a command in backticks is then legal shell that really does run
833
+ * something. The refusal names the spelling that is inert, because a refusal a
834
+ * reader cannot act on costs the same attention as one they can (SPEC.md §11.1:
835
+ * refusals are machine-readable and distinct — the code stays the machine's
836
+ * half, this is the human's).
837
+ */
838
+ const QUOTED_BACKTICK_OPAQUE = "backtick command substitution inside a double-quoted argument, which the shell expands; single quotes make the same text literal";
740
839
  /** Scan a double-quoted string starting at the opening quote. */
741
840
  function readDoubleQuoted(command, start) {
742
841
  let text = "";
@@ -760,7 +859,7 @@ function readDoubleQuoted(command, start) {
760
859
  const close = command.indexOf("`", index + 1);
761
860
  if (close === -1)
762
861
  return null;
763
- opaque = "backtick command substitution";
862
+ opaque = QUOTED_BACKTICK_OPAQUE;
764
863
  index = close;
765
864
  continue;
766
865
  }
@@ -895,7 +994,45 @@ function isTagRefspec(refspec) {
895
994
  return isTagRef(refspec);
896
995
  return isTagRef(refspec.slice(0, colon)) || isTagRef(refspec.slice(colon + 1));
897
996
  }
898
- /** `git push` — force, release, trunk and branch classes turn on flags and refspecs. */
997
+ /**
998
+ * Deleting a remote ref: its own class, never the trunk-push one (APRV-352).
999
+ *
1000
+ * A `git push` that deletes refs was `vcs.push.main` until now, which reads as
1001
+ * "this reaches the trunk" and in a repository that samples trunk pushes
1002
+ * retrospectively means an irreversible removal proceeds unasked and is looked
1003
+ * at afterwards. It is not the same act. A push adds commits somebody can still
1004
+ * see; a deletion removes the only name an unmerged branch had, and the
1005
+ * reflog that could find it again lives on a server nobody in the session can
1006
+ * reach. The two belong on separate policy lines, and a policy that wants them
1007
+ * on one can still write `vcs.*`.
1008
+ *
1009
+ * Distinct from `vcs.history.rewrite` as well, which guards SHARED history: a
1010
+ * force push moves a ref other people have already built on. That class stays
1011
+ * exactly where it was, above this one, so a force push that also deletes is
1012
+ * still a rewrite.
1013
+ */
1014
+ const REF_DELETE_CLASS = "vcs.ref.delete";
1015
+ /**
1016
+ * The ref a deleting refspec names, or `null` when the refspec deletes nothing.
1017
+ *
1018
+ * `:dst` (empty source) is the deletion git documents; `src:` (empty
1019
+ * destination) is the spelling the classifier has always treated as one too,
1020
+ * and it keeps doing so rather than being narrowed here. The non-empty side is
1021
+ * the name worth showing an approver either way.
1022
+ */
1023
+ function deletedRef(refspec) {
1024
+ const colon = refspec.indexOf(":");
1025
+ if (colon === -1)
1026
+ return null;
1027
+ const source = refspec.slice(0, colon);
1028
+ const destination = refspec.slice(colon + 1);
1029
+ if (destination.length === 0)
1030
+ return source.length === 0 ? refspec : source;
1031
+ if (source.length === 0)
1032
+ return destination;
1033
+ return null;
1034
+ }
1035
+ /** `git push` — force, release, deletion, trunk and branch turn on flags and refspecs. */
899
1036
  function refineGitPush(ctx) {
900
1037
  const args = ctx.args.slice(1);
901
1038
  if (hasFlag(args, ["--force", "-f", "--force-with-lease", "--force-if-includes", "--mirror"])) {
@@ -906,29 +1043,45 @@ function refineGitPush(ctx) {
906
1043
  if (refspecs.some((refspec) => refspec.startsWith("+"))) {
907
1044
  return { class: "vcs.history.rewrite", rule: "git-push-force" };
908
1045
  }
1046
+ // The tag check stays ABOVE the deletion check, deliberately. A tag is the
1047
+ // name a release was published under, and this repository's policy prices
1048
+ // `release.publish` accordingly; deleting one is a release act whichever
1049
+ // spelling removes it. Moving the deletion check up would re-label
1050
+ // `git push origin :refs/tags/v1.2.3` and a bulk form that mixes a tag in,
1051
+ // and APRV-352 asks for a class for branch deletions, not a loosening of the
1052
+ // tag surface.
909
1053
  if (hasFlag(args, ["--tags", "--follow-tags"]) ||
910
1054
  refspecs.some(isTagRefspec) ||
911
1055
  refspecs.some((word, index) => word === "tag" && index + 1 < refspecs.length)) {
912
1056
  return { class: "release.publish", rule: "git-push-tag" };
913
1057
  }
914
- // A non-tag deletion, or a push with no refspec at all: the destination is
915
- // either the trunk or unknown, and unknown resolves to the stricter class.
1058
+ // `--delete` / `-d`: every refspec after the remote is a ref being removed.
1059
+ // A `--delete` naming no ref at all is a git error, and it stays in this
1060
+ // class with nothing bound rather than falling through to a push class: an
1061
+ // invocation whose targets cannot be read is the one that least deserves the
1062
+ // looser answer.
916
1063
  if (hasFlag(args, ["--delete", "-d"])) {
917
- return { class: "vcs.push.main", rule: "git-push-delete" };
1064
+ return {
1065
+ class: REF_DELETE_CLASS,
1066
+ rule: "git-ref-delete",
1067
+ ...(refspecs.length === 0 ? {} : { path: refspecs.join(" ") }),
1068
+ };
918
1069
  }
919
1070
  if (refspecs.length === 0) {
920
1071
  return { class: "vcs.push.main", rule: "git-push-implicit" };
921
1072
  }
1073
+ // The colon-refspec spellings, which need no flag: `:refs/heads/x`, `:x`, and
1074
+ // a bulk form mixing several. ONE deleting refspec makes the whole command a
1075
+ // deletion, because the command's effect is the union of its refspecs and the
1076
+ // destructive half is the half a person is being asked about.
1077
+ const deleted = refspecs.map(deletedRef).filter((ref) => ref !== null);
1078
+ if (deleted.length > 0) {
1079
+ return { class: REF_DELETE_CLASS, rule: "git-ref-delete", path: deleted.join(" ") };
1080
+ }
922
1081
  let sawMain = false;
923
1082
  for (const refspec of refspecs) {
924
1083
  const colon = refspec.indexOf(":");
925
1084
  const destination = colon === -1 ? refspec : refspec.slice(colon + 1);
926
- // `:branch` (empty source) and `src:` (empty destination) both delete a
927
- // remote ref. A deletion is destructive whatever it names, so it takes the
928
- // stricter class rather than the branch one.
929
- if (destination.length === 0 || (colon !== -1 && refspec.slice(0, colon).length === 0)) {
930
- return { class: "vcs.push.main", rule: "git-push-delete" };
931
- }
932
1085
  if (isUnknownValue(destination)) {
933
1086
  sawMain = true;
934
1087
  continue;
@@ -955,6 +1108,211 @@ function refineGitPush(ctx) {
955
1108
  * Everything that is not provably scratch keeps the old class.
956
1109
  */
957
1110
  const SCRATCH_DELETE_CLASS = "files.delete.scratch";
1111
+ // ---------------------------------------------------------------------------
1112
+ // Agent harnesses (APRV-354)
1113
+ // ---------------------------------------------------------------------------
1114
+ /**
1115
+ * Launching an agent harness is its own class family, `harness.launch.NAME`.
1116
+ *
1117
+ * Until this, a command whose first word was `codex`, `muse`, `grok`, `claude`
1118
+ * or `cursor-agent` was `unclassified`: denied, which is fail closed and also
1119
+ * blunt. It told an approver nothing, it gave a human no class to grant through
1120
+ * the ordinary manual path, and it meant a lane could not so much as read a
1121
+ * harness version without going around the gate. The family fixes the second
1122
+ * half without touching the first: an unknown class still falls to
1123
+ * `defaults.autonomy`, so a project that adopts nothing is exactly where it was.
1124
+ *
1125
+ * ## The laundering hazard, which is why this is a class and not a table row
1126
+ *
1127
+ * A launched harness runs its own tools OUTSIDE this gate unless that harness's
1128
+ * own adapter is installed and attested. So a grant of `harness.launch.codex`
1129
+ * authorizes the LAUNCH and nothing the launched session then does — every
1130
+ * file it writes, every command it runs and every request it makes happens
1131
+ * where this runtime cannot see it. A policy that made the family autonomous
1132
+ * would have written itself out of the gate in one line, which is why SPEC.md
1133
+ * §7 says the family is never inferred autonomous and why the reference
1134
+ * proposal holds it at `manual`. The blessed Codex entry point is the confined
1135
+ * `approval codex start` (APRV-325.3), which keeps its own class.
1136
+ *
1137
+ * ## Muse, which carries a second hazard
1138
+ *
1139
+ * A Muse Code model id ending `-contributor` trades price for permission to
1140
+ * train on prompts and completions, so running one over real code shares that
1141
+ * code. Carter has ruled that Muse must never run with a Contributor model
1142
+ * selected. The classifier cannot enforce that — the model is a self-reported
1143
+ * argument, and a session may select one in its own UI where no command line
1144
+ * shows it — so it does the one thing a classifier honestly can: it BINDS the
1145
+ * `--model` value where it appears and gives a contributor value its own rule
1146
+ * id, so a prompt and a policy can both see it. This may only ever RAISE
1147
+ * scrutiny. A `-standard` id, or no `--model` at all, returns the ordinary rule
1148
+ * id and changes nothing, because a self-reported field that could lower
1149
+ * scrutiny is a field an agent would learn to write (SPEC.md §11.1 invariant 4).
1150
+ *
1151
+ * ## Where the family's name lives
1152
+ *
1153
+ * {@link HARNESS_LAUNCH_PREFIX} is `core/policy-match.ts`'s, not this file's,
1154
+ * and is imported rather than repeated. Two enforcement paths refuse a launch
1155
+ * that no policy rule names (`harnessLaunchNeedsRule`), and a second spelling of
1156
+ * the prefix is the shape of bug that closes one of those doors and leaves the
1157
+ * other open.
1158
+ */
1159
+ /** The rule id prefix, so a reader can tell a launch row from a probe. */
1160
+ const HARNESS_LAUNCH_RULE_PREFIX = "harness-launch-";
1161
+ /**
1162
+ * Harness binaries, by BASENAME, to the name their class carries.
1163
+ *
1164
+ * Basenames, because that is what {@link classifySegment} derives before any
1165
+ * rule sees a command, and it is what makes `/opt/homebrew/bin/codex`,
1166
+ * `~/.local/bin/muse` and `$HOME/.local/bin/muse` all land here without this
1167
+ * table knowing anything about where a binary lives. A spelling the basename
1168
+ * derivation cannot see through — `$MUSE_BIN`, a wrapper script of another
1169
+ * name — is `unclassified`, which is the answer it had before and the answer it
1170
+ * should keep.
1171
+ *
1172
+ * `cursor-agent` carries the name `cursor` so the class reads
1173
+ * `harness.launch.cursor` beside the `cursor` adapter and the `.cursor/`
1174
+ * protected paths. `gemini` is deliberately absent: APRV-354 names five
1175
+ * harnesses, `gemini update` keeps its `deps.upgrade` row above this one, and a
1176
+ * bare `gemini` stays `unclassified` until somebody makes that its own decision.
1177
+ */
1178
+ const HARNESS_BINS = {
1179
+ codex: "codex",
1180
+ muse: "muse",
1181
+ grok: "grok",
1182
+ claude: "claude",
1183
+ "cursor-agent": "cursor",
1184
+ };
1185
+ /**
1186
+ * Package specs a package runner may name, EXACTLY, to the same harness names.
1187
+ *
1188
+ * Exact, and the exactness is the rule: `npx codex-helper` is not a codex
1189
+ * launch, and a substring match that said it was would let any package whose
1190
+ * name happens to contain a harness's take a class it did not earn. A spec this
1191
+ * table does not know keeps whatever class the runner already had.
1192
+ */
1193
+ const HARNESS_PACKAGES = {
1194
+ codex: "codex",
1195
+ "@openai/codex": "codex",
1196
+ claude: "claude",
1197
+ "@anthropic-ai/claude-code": "claude",
1198
+ "cursor-agent": "cursor",
1199
+ muse: "muse",
1200
+ grok: "grok",
1201
+ };
1202
+ /**
1203
+ * Argv that starts nothing: a version or help probe.
1204
+ *
1205
+ * A probe prints a string and exits, so it is a read, and reading a harness's
1206
+ * own version is exactly what a session needs to be able to do without a
1207
+ * prompt. `help` counts only as the WHOLE argv: `codex help` prints usage,
1208
+ * while `codex help me refactor this` is a session.
1209
+ */
1210
+ const HARNESS_PROBE_FLAGS = ["--version", "-V", "--help", "-h"];
1211
+ /** The probe's rule id and class. A probe starts no session, so it reads. */
1212
+ const HARNESS_PROBE_RULE = "harness-probe";
1213
+ const HARNESS_PROBE_CLASS = "read.shell";
1214
+ /** The rule id a Muse launch takes when its `--model` names a Contributor model. */
1215
+ const MUSE_CONTRIBUTOR_RULE = "harness-launch-muse-contributor";
1216
+ /**
1217
+ * The suffix that marks a Muse model as training on what it is shown.
1218
+ *
1219
+ * Exported since APRV-350 so the hook adapter's contributor guard and this
1220
+ * classifier's `harness.launch.muse` refinement test the SAME mark. Two
1221
+ * spellings of "which models are unsafe" would drift, and the direction they
1222
+ * drift in is the one where a launch is refused and a tool call is not.
1223
+ */
1224
+ export const CONTRIBUTOR_SUFFIX = "-contributor";
1225
+ /** The `--model` value in either spelling, or `null` when none is written. */
1226
+ function harnessModel(args) {
1227
+ for (let index = 0; index < args.length; index += 1) {
1228
+ const arg = args[index];
1229
+ if (arg === "--model") {
1230
+ const value = args[index + 1];
1231
+ return value === undefined || isFlag(value) ? null : value;
1232
+ }
1233
+ if (arg.startsWith("--model="))
1234
+ return arg.slice("--model=".length);
1235
+ }
1236
+ return null;
1237
+ }
1238
+ /**
1239
+ * The harness a package spec names, version suffix stripped, or `null`.
1240
+ *
1241
+ * `@openai/codex@0.152.1` is the scoped case the split has to get right: the
1242
+ * `@` that opens a scope is not the `@` that opens a version.
1243
+ */
1244
+ function harnessPackage(spec) {
1245
+ const at = spec.startsWith("@") ? spec.indexOf("@", 1) : spec.indexOf("@");
1246
+ const bare = at === -1 ? spec : spec.slice(0, at);
1247
+ return HARNESS_PACKAGES[bare] ?? null;
1248
+ }
1249
+ /**
1250
+ * One harness invocation, given its name and the argv that follows its identity.
1251
+ *
1252
+ * Shared by the direct rows and the package-runner refinement, so a launch
1253
+ * spelled `npx @openai/codex exec` answers exactly as `codex exec` does. The
1254
+ * argv is bound to `path`: the segment's own `text` carries the command as
1255
+ * written, and `path` carries the part the class is ABOUT, which is what lets a
1256
+ * channel name it without re-parsing the line.
1257
+ */
1258
+ function harnessRefinement(name, args) {
1259
+ if (args.length === 1 &&
1260
+ (HARNESS_PROBE_FLAGS.includes(args[0]) || args[0] === "help")) {
1261
+ return { class: HARNESS_PROBE_CLASS, rule: HARNESS_PROBE_RULE };
1262
+ }
1263
+ // Anything else is a session. A probe flag beside other arguments is NOT a
1264
+ // probe here: the classifier cannot know which of the two the binary will
1265
+ // honour, and the stricter reading of an ambiguous harness invocation is the
1266
+ // one that assumes a session started.
1267
+ const model = name === "muse" ? harnessModel(args) : null;
1268
+ const contributor = model !== null && model.toLowerCase().endsWith(CONTRIBUTOR_SUFFIX);
1269
+ return {
1270
+ class: `${HARNESS_LAUNCH_PREFIX}${name}`,
1271
+ rule: contributor ? MUSE_CONTRIBUTOR_RULE : `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
1272
+ ...(args.length === 0 ? {} : { path: args.join(" ") }),
1273
+ };
1274
+ }
1275
+ /** `codex …`, `muse …`, `grok …`, `claude …`, `cursor-agent …`. */
1276
+ function refineHarness(ctx) {
1277
+ const name = HARNESS_BINS[ctx.bin];
1278
+ // Unreachable through the table, which matches on these basenames; a defensive
1279
+ // arm rather than a silent wrong class if a row is ever edited apart from the
1280
+ // table it is generated from.
1281
+ if (name === undefined) {
1282
+ return { opaque: `${ctx.bin} is an agent harness this table cannot name` };
1283
+ }
1284
+ return harnessRefinement(name, ctx.args);
1285
+ }
1286
+ /**
1287
+ * `npx`, `tsx`, `tsc`, … — unchanged, except a package runner naming a harness.
1288
+ *
1289
+ * `npx @openai/codex` starts the same session `codex` starts, and before this
1290
+ * it was `files.write.workspace`: the looser of the two answers, reachable by
1291
+ * typing four extra characters. The refinement is deliberately narrow — only
1292
+ * `npx`, only an EXACT package spec, and everything else returns the row's own
1293
+ * answer byte for byte, which is what keeps this from being a widening of the
1294
+ * workspace-tool row.
1295
+ */
1296
+ function refineWorkspaceTool(ctx) {
1297
+ const unchanged = { class: "files.write.workspace", rule: "workspace-tool" };
1298
+ if (ctx.bin !== "npx")
1299
+ return unchanged;
1300
+ const index = ctx.args.findIndex((arg) => !isFlag(arg));
1301
+ if (index === -1)
1302
+ return unchanged;
1303
+ const name = harnessPackage(ctx.args[index]);
1304
+ if (name === null)
1305
+ return unchanged;
1306
+ return harnessRefinement(name, ctx.args.slice(index + 1));
1307
+ }
1308
+ /** The five generated harness rows, one per binary, sharing one refinement. */
1309
+ const HARNESS_RULES = Object.entries(HARNESS_BINS).map(([bin, name]) => ({
1310
+ id: `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
1311
+ bins: [bin],
1312
+ class: `${HARNESS_LAUNCH_PREFIX}${name}`,
1313
+ emits: [HARNESS_PROBE_CLASS],
1314
+ refine: refineHarness,
1315
+ }));
958
1316
  /**
959
1317
  * Is `candidate` a STRICT descendant of `root`? Both are compared by path
960
1318
  * segment, so `/private/tmpfoo` is not under `/private/tmp` and a root is never
@@ -998,6 +1356,43 @@ function allTargetsAreScratch(targets, roots) {
998
1356
  }
999
1357
  return true;
1000
1358
  }
1359
+ // ===========================================================================
1360
+ // Read scope (read.file.out_of_scope, APRV-347)
1361
+ // ===========================================================================
1362
+ /** The rule id for a read whose ABSOLUTE target sits outside every root. */
1363
+ export const READ_OUT_OF_SCOPE_RULE = "read-out-of-scope";
1364
+ /** The rule id for a read target whose expansion the text cannot show. */
1365
+ export const READ_UNREADABLE_TARGET_RULE = "read-unreadable-path";
1366
+ /**
1367
+ * The first target of this read command that the TEXT places outside every
1368
+ * root, or `null` when nothing here settles it.
1369
+ *
1370
+ * `null` covers three different situations on purpose, and all three are
1371
+ * handed on rather than decided: the binary is not a scoped reader, every
1372
+ * target is provably inside a root, or a target is relative (or carries `..`)
1373
+ * and therefore means nothing without a working directory. The last of those
1374
+ * is the common case, and it is why the hook's second pass exists.
1375
+ */
1376
+ function escapedReadTarget(bin, positionals, args, roots) {
1377
+ const targets = readTargetsOf(bin, positionals, args);
1378
+ if (targets === null)
1379
+ return null;
1380
+ for (const target of targets) {
1381
+ switch (readTargetVerdict(target, roots)) {
1382
+ case "out-of-scope":
1383
+ return {
1384
+ path: target,
1385
+ rule: isUnreadableTarget(target)
1386
+ ? READ_UNREADABLE_TARGET_RULE
1387
+ : READ_OUT_OF_SCOPE_RULE,
1388
+ };
1389
+ case "in-scope":
1390
+ case "needs-disk":
1391
+ break;
1392
+ }
1393
+ }
1394
+ return null;
1395
+ }
1001
1396
  /** `rm` — everything outside the workspace, and every unreadable path, is manual. */
1002
1397
  function refineRm(ctx) {
1003
1398
  const recursive = hasFlag(ctx.args, ["--recursive"]) || hasShortFlag(ctx.args, ["r", "R"]);
@@ -1427,12 +1822,28 @@ function isGateEntrypoint(path) {
1427
1822
  * ritual reached the approver's phone as `policy.edit` over a protected path —
1428
1823
  * true, and useless. Classified by name it arrives as what it is.
1429
1824
  *
1430
- * `positionals` is read rather than `args`, so a flag between the words cannot
1431
- * hide the verb: `approval --json log sync` is the same invocation.
1825
+ * `policy attest --path` (APRV-338) is the newest member and the one that reads
1826
+ * a FLAG, because the flag is what changes the act. Without it the verb attests
1827
+ * the policy file or a gate organ, which are records about the gate's own
1828
+ * configuration; with it the verb ratifies protected TEXT, and that record is
1829
+ * what resolves SPEC.md's pending-sign-off suffix. An agent able to write one
1830
+ * could ratify its own amendments, so it is classified where the rest of the
1831
+ * gate's ceremonies are: `policy.core`, which this repository's policy holds
1832
+ * human-only, so the hook denies it with `hook-class-human-only` before the
1833
+ * verb's own `actor-not-human` refusal is ever reached. It mints no new class
1834
+ * (§11.1 invariant 9): `policy.core` already exists and is already in the row's
1835
+ * `emits`.
1836
+ *
1837
+ * `positionals` is read rather than `args` for the verb words, so a flag between
1838
+ * them cannot hide the verb: `approval --json log sync` is the same invocation.
1839
+ * `args` is read only where a flag is the act, as it is above.
1432
1840
  */
1433
- function refineApprovalVerb(positionals) {
1841
+ function refineApprovalVerb(positionals, args = []) {
1434
1842
  const verb = positionals[0];
1435
1843
  const sub = positionals[1];
1844
+ if (verb === "policy" && sub === "attest" && hasFlag(args, ["--path"])) {
1845
+ return { class: "policy.core", rule: "approval-policy-signoff" };
1846
+ }
1436
1847
  if (verb === "quickstart") {
1437
1848
  return { class: "policy.core", rule: "approval-quickstart" };
1438
1849
  }
@@ -1461,6 +1872,22 @@ function refineApprovalVerb(positionals) {
1461
1872
  return { class: "policy.core", rule: "approval-gate-close" };
1462
1873
  return null;
1463
1874
  }
1875
+ // APRV-343. `policy apply` WRITES `APPROVAL.md`, which is the one file in this
1876
+ // repository nothing but a human's own hand may change: an agent that could
1877
+ // run it could widen the policy that governs it and then attest the result
1878
+ // through the amendment the verb goes on to run. Classified where the file
1879
+ // already is (`policy.core`, human-only in the reference policy), so the hook
1880
+ // denies it with `hook-class-human-only`, behind the verb's own
1881
+ // `apply-agent-actor` refusal. It mints no new class (SPEC.md §11.1 invariant
1882
+ // 9): `policy.core` already exists and already covers the policy's machinery.
1883
+ //
1884
+ // The other `policy` subcommands stay pass-through. `check` and `test` read,
1885
+ // `attest` and `amend` refuse a non-human actor in code and collect a human's
1886
+ // tap through a channel when an agent runs them, which is the widening
1887
+ // APRV-109 deliberately made — and neither of them writes the policy file.
1888
+ if (verb === "policy" && sub === "apply") {
1889
+ return { class: "policy.core", rule: "approval-policy-apply" };
1890
+ }
1464
1891
  // APRV-257. `setup checkpoint` MINTS the key `log checkpoint` signs with, so
1465
1892
  // an agent that could run it could mint a key, store it, and vouch for a
1466
1893
  // chain it had just written — the mechanism defeated at its source rather
@@ -1487,7 +1914,7 @@ function refineApprovalVerb(positionals) {
1487
1914
  * two log verbs keeps the pass-through class and the row's own rule id.
1488
1915
  */
1489
1916
  function refineApproval(ctx) {
1490
- return refineApprovalVerb(ctx.positionals) ?? { class: GATE_SELF_CLASS, rule: "approval" };
1917
+ return (refineApprovalVerb(ctx.positionals, ctx.args) ?? { class: GATE_SELF_CLASS, rule: "approval" });
1491
1918
  }
1492
1919
  /**
1493
1920
  * `node` — an inline script is opaque, the gate's own entry point is
@@ -1500,7 +1927,7 @@ function refineNode(ctx) {
1500
1927
  if (script !== undefined && isGateEntrypoint(script)) {
1501
1928
  // `node cli.js log sync` is `approval log sync` spelled the long way, and
1502
1929
  // it must classify identically or the classification is a spelling test.
1503
- return (refineApprovalVerb(ctx.positionals.slice(1)) ?? {
1930
+ return (refineApprovalVerb(ctx.positionals.slice(1), ctx.args) ?? {
1504
1931
  class: GATE_SELF_CLASS,
1505
1932
  rule: "node-approval-cli",
1506
1933
  });
@@ -1524,7 +1951,7 @@ export const COMMAND_RULES = [
1524
1951
  bins: ["git"],
1525
1952
  subs: ["push"],
1526
1953
  class: "vcs.push.main",
1527
- emits: ["vcs.push.branch", "vcs.push.main", "vcs.history.rewrite"],
1954
+ emits: ["vcs.push.branch", "vcs.push.main", "vcs.history.rewrite", "vcs.ref.delete"],
1528
1955
  refine: refineGitPush,
1529
1956
  },
1530
1957
  {
@@ -1649,8 +2076,20 @@ export const COMMAND_RULES = [
1649
2076
  // harness unattended. `uca` matches with ANY arguments, `--dry-run` included:
1650
2077
  // the classifier reads text, cannot know which flags the script honours, and
1651
2078
  // the strictest reading of an updater is that it updates.
2079
+ //
2080
+ // APRV-354 answers the other half of that sentence: the launch rows below now
2081
+ // name what a bare `claude` or `codex …` is. This row stays ABOVE them on
2082
+ // purpose, so `codex update` and `claude update` keep `deps.upgrade` — an
2083
+ // upgrade swaps the binary that hosts the hook, which is the stricter of the
2084
+ // two readings and the class they already had.
1652
2085
  { id: "harness-update", bins: ["claude", "codex", "gemini"], subs: ["update"], class: "deps.upgrade" },
1653
2086
  { id: "harness-updater", bins: ["uca"], class: "deps.upgrade" },
2087
+ // -- agent harness launch (APRV-354) --------------------------------------
2088
+ // Generated from {@link HARNESS_BINS}, one row per binary, all sharing
2089
+ // {@link refineHarness}. Below `harness-update` so an upgrade keeps its
2090
+ // class; above the workspace tools so a package-runner spelling is the only
2091
+ // one that has to be refined rather than matched.
2092
+ ...HARNESS_RULES,
1654
2093
  // -- workspace tools -----------------------------------------------------
1655
2094
  // APRV-193: three of the rules below hand control to code the runtime did not
1656
2095
  // author, and they are named in {@link CODE_EXECUTING_RULES}.
@@ -1672,6 +2111,14 @@ export const COMMAND_RULES = [
1672
2111
  id: "workspace-tool",
1673
2112
  bins: ["npx", "tsx", "ts-node", "tsc", "oxlint", "eslint", "prettier", "vitest", "jest", "backlog", "make"],
1674
2113
  class: "files.write.workspace",
2114
+ // APRV-354: only `npx` naming a harness package changes; see
2115
+ // {@link refineWorkspaceTool}, which returns this row's own answer for
2116
+ // every other binary and every other package.
2117
+ emits: [
2118
+ HARNESS_PROBE_CLASS,
2119
+ ...Object.values(HARNESS_PACKAGES).map((name) => `${HARNESS_LAUNCH_PREFIX}${name}`),
2120
+ ],
2121
+ refine: refineWorkspaceTool,
1675
2122
  },
1676
2123
  {
1677
2124
  id: "workspace-write",
@@ -1830,9 +2277,17 @@ function refineGh(ctx) {
1830
2277
  * Binaries whose effect lives in a string this classifier will not interpret.
1831
2278
  *
1832
2279
  * A second parser for the same text is a second answer waiting to disagree with
1833
- * the shell's, so these refuse instead. `bash -c "…"`, `eval`, `xargs` and the
1834
- * `-e` interpreters can express anything at all; `sudo` and `env` re-launch
2280
+ * the shell's, so these refuse instead. `eval`, `xargs` and the `-e`
2281
+ * interpreters can express anything at all; `sudo` and `env` re-launch
1835
2282
  * something else with different authority.
2283
+ *
2284
+ * ONE NARROW EXCEPTION since APRV-380, and it is a narrowing of this rule
2285
+ * rather than a hole in it: a segment that is EXACTLY a known shell, one
2286
+ * inline-script flag and one script word is classified by the script, through
2287
+ * this same classifier. No second parser is written and the text is not read a
2288
+ * second way. {@link loginShellScript} states the shape and the reasoning, and
2289
+ * everything outside it — including the same shell with a script file, or a
2290
+ * shell nested inside an unwrapped script — is refused here exactly as before.
1836
2291
  */
1837
2292
  const OPAQUE_BINS = {
1838
2293
  bash: "runs a shell script",
@@ -1854,6 +2309,93 @@ const OPAQUE_BINS = {
1854
2309
  timeout: "runs another command under a timer",
1855
2310
  time: "runs another command under a timer",
1856
2311
  };
2312
+ /**
2313
+ * The shells {@link loginShellScript} will unwrap: the six in
2314
+ * {@link OPAQUE_BINS} that run a script.
2315
+ *
2316
+ * The other opaque binaries stay opaque under every shape. `eval`, `xargs` and
2317
+ * the `-e` interpreters build their text from somewhere this file cannot see;
2318
+ * `sudo`, `doas`, `env`, `nohup`, `exec`, `source`, `.`, `watch`, `timeout` and
2319
+ * `time` re-launch something else, with different authority or under a timer,
2320
+ * and what they re-launch is an argv rather than a script.
2321
+ */
2322
+ const UNWRAPPABLE_SHELLS = new Set([
2323
+ "bash",
2324
+ "sh",
2325
+ "zsh",
2326
+ "dash",
2327
+ "ksh",
2328
+ "fish",
2329
+ ]);
2330
+ /**
2331
+ * The inline-script flags a wrapper may carry: `-c`, with any run of `l` and
2332
+ * `i` before it (APRV-380).
2333
+ *
2334
+ * `-lc` is the shape the 2026-09-18 probe recorded on every Codex exec request.
2335
+ * `c` must be LAST, because that is the letter that takes the next word as its
2336
+ * argument, and a combination where it is not last is one whose reading depends
2337
+ * on the shell's own option parser. That is a shape this rule declines to guess
2338
+ * at, so it stays opaque.
2339
+ */
2340
+ const INLINE_SCRIPT_FLAG = /^-[li]*c$/u;
2341
+ /**
2342
+ * The script a LOGIN-SHELL WRAPPER runs, when the segment is exactly one
2343
+ * (APRV-380), or `null`.
2344
+ *
2345
+ * ## Why this is not the second parser the table above refuses
2346
+ *
2347
+ * {@link OPAQUE_BINS} states the position this narrows: a second parser for the
2348
+ * same text is a second answer waiting to disagree with the shell's. The hazard
2349
+ * that names is reading shell text a SECOND WAY. This is not that. When the
2350
+ * argv is exactly `[shell, -lc, script]` and nothing else, the script is the
2351
+ * text a Claude Code `Bash` call hands this classifier directly, and what
2352
+ * happens to it here is what happens to that: the same lexer, the same segment
2353
+ * rules, the same table. No new parser is written, and the text is not read a
2354
+ * second way — it is read the first way, by the only reader there is.
2355
+ *
2356
+ * ## The line, and it is exact
2357
+ *
2358
+ * Three words, no more: a known shell, one inline-script flag, one script. Any
2359
+ * of these keeps the wrapper opaque, because each is a shape whose effect
2360
+ * depends on something the argv alone does not say:
2361
+ *
2362
+ * - extra words (a script FILE, `--`, an option this rule does not model);
2363
+ * - an assignment prefix, which changes the environment the script runs in;
2364
+ * - a redirection on the wrapper, which is the outer shell's and not the
2365
+ * script's;
2366
+ * - a substitution in any of the three words, whose effect happens before the
2367
+ * shell even starts;
2368
+ * - a nested shell inside the script, refused by {@link ClassifierContext} when
2369
+ * the recursion runs (the inner classification unwraps nothing).
2370
+ *
2371
+ * The BINDING does not move. `cli/hook.ts` binds the outer command and argv
2372
+ * exactly as APRV-362 built them; what this changes is only which text is
2373
+ * classified, and the classes are additional evidence about bytes that are
2374
+ * bound elsewhere and unchanged.
2375
+ */
2376
+ export function loginShellScript(segment) {
2377
+ if (segment.opaque !== null)
2378
+ return null;
2379
+ if (segment.redirects.length > 0)
2380
+ return null;
2381
+ if (segment.words.length !== 3)
2382
+ return null;
2383
+ const [shell, flag, script] = segment.words;
2384
+ if (shell.substitutions.length > 0)
2385
+ return null;
2386
+ if (flag.substitutions.length > 0)
2387
+ return null;
2388
+ if (script.substitutions.length > 0)
2389
+ return null;
2390
+ const base = pathSegments(shell.text).slice(-1)[0] ?? shell.text;
2391
+ if (!UNWRAPPABLE_SHELLS.has(base))
2392
+ return null;
2393
+ if (!INLINE_SCRIPT_FLAG.test(flag.text))
2394
+ return null;
2395
+ // An empty script runs nothing, and `classifyCommand` answers `unclassified`
2396
+ // for an empty command. Leaving it wrapped keeps that answer the wrapper's.
2397
+ return script.text.trim().length === 0 ? null : script.text;
2398
+ }
1857
2399
  /** Interpreters that are opaque only when handed inline source. */
1858
2400
  const INLINE_SOURCE_BINS = {
1859
2401
  python: ["-c"],
@@ -1962,6 +2504,20 @@ export const CODE_EXECUTING_RULES = [
1962
2504
  "node-script",
1963
2505
  /** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
1964
2506
  "workspace-tool",
2507
+ /**
2508
+ * Launching an agent harness, and probing one (APRV-354).
2509
+ *
2510
+ * Every spelling is here, the probe included. A launch hands control to a
2511
+ * whole second agent, which is the most complete form of "code the runtime
2512
+ * did not author"; a probe still executes the same binary. The list is
2513
+ * matched against a segment's RULE, so the generated launch ids and the two
2514
+ * ids a refinement can return on its own — the probe and the Muse
2515
+ * contributor id — all have to be named, or `npx @openai/codex` would have
2516
+ * quietly stopped requiring a sandbox by gaining a better class.
2517
+ */
2518
+ ...HARNESS_RULES.map((rule) => rule.id),
2519
+ HARNESS_PROBE_RULE,
2520
+ MUSE_CONTRIBUTOR_RULE,
1965
2521
  ];
1966
2522
  /**
1967
2523
  * Every class the table can emit, for docs and for the dogfood test.
@@ -1988,8 +2544,45 @@ export const CLASSIFIER_CLASSES = (() => {
1988
2544
  seen.add(CREDENTIAL_CLASS);
1989
2545
  seen.add("files.write.workspace");
1990
2546
  seen.add("read.shell");
2547
+ // APRV-347: emitted from `classifySegment`'s tail rather than from a row of
2548
+ // the table, because it is a refinement of `read.shell` against roots the
2549
+ // CALLER resolved and no binary implies it on its own.
2550
+ seen.add(READ_OUT_OF_SCOPE_CLASS);
1991
2551
  return [...seen].sort();
1992
2552
  })();
2553
+ /**
2554
+ * The class the DAEMON's own cadence advance is gated as (APRV-382).
2555
+ *
2556
+ * The sub-class exists because the policy grammar has no actor condition and
2557
+ * this repository wanted one: an advance publishes records the log already
2558
+ * holds, so the daemon may make it unattended, while the same act from a
2559
+ * session in a worktree or a human terminal stays supervised. Two classes are
2560
+ * how that is written down, and which of them a cycle asks under is decided by
2561
+ * `core/advance-cycle.ts` from the running process, never from an argument.
2562
+ *
2563
+ * NO COMMAND SPELLS IT, on purpose. `approval log advance` classifies
2564
+ * `log.advance` whoever types it, so the looser line is unreachable from a
2565
+ * shell an agent can drive: it is reached only from inside the daemon process,
2566
+ * which an agent cannot become without a `gate.self` command this policy holds
2567
+ * at the manual default.
2568
+ */
2569
+ export const ADVANCE_DAEMON_CLASS = "log.advance.daemon";
2570
+ /**
2571
+ * Classes this RUNTIME emits for its own gated actions, which no command spells.
2572
+ *
2573
+ * Separate from {@link CLASSIFIER_CLASSES}, which is the binary table's own set
2574
+ * and is what `docs/claude-code-hook.md` documents row by row. A class here is
2575
+ * emitted by a runtime cycle that registers and requests it directly — the
2576
+ * daemon's advance is the first — so a policy declaring it is declaring a line
2577
+ * that CAN fire, and the reachability check `core/policy-expectations.ts` runs
2578
+ * at the amendment ceremony must say so. Without this list that ceremony would
2579
+ * refuse the line `unreachable`, which is a true statement about the command
2580
+ * classifier and a false one about the runtime.
2581
+ *
2582
+ * Adding a name here is a claim that some path in this codebase asks the gate
2583
+ * for that class, and widening it is a reviewable diff.
2584
+ */
2585
+ export const RUNTIME_CLASSES = [ADVANCE_DAEMON_CLASS];
1993
2586
  /**
1994
2587
  * Can the classifier emit `actionClass` for a project whose policy carries
1995
2588
  * these `protected_paths`? (APRV-266.)
@@ -2005,10 +2598,17 @@ export const CLASSIFIER_CLASSES = (() => {
2005
2598
  * A routed name is reachable exactly when some entry routes to it. A
2006
2599
  * `policy.edit.spec` rule in a policy whose `protected_paths` routes nothing to
2007
2600
  * it is a line that will never fire, and saying so is the whole point.
2601
+ *
2602
+ * {@link RUNTIME_CLASSES} is the third answer (APRV-382): a class no command
2603
+ * spells and a runtime cycle asks for directly is reachable in every project,
2604
+ * with no policy entry needed, because the path that emits it is in this
2605
+ * codebase rather than in the operator's file.
2008
2606
  */
2009
2607
  export function emittableClass(actionClass, protectedPaths = []) {
2010
2608
  if (CLASSIFIER_CLASSES.includes(actionClass))
2011
2609
  return true;
2610
+ if (RUNTIME_CLASSES.includes(actionClass))
2611
+ return true;
2012
2612
  if (!POLICY_EDIT_SUBCLASS.test(actionClass))
2013
2613
  return false;
2014
2614
  return protectedPaths.some((entry) => parseProtectedEntry(entry)?.routed === actionClass);
@@ -2250,6 +2850,11 @@ function classifySegment(segment, protectedPaths, context) {
2250
2850
  }
2251
2851
  let cls = refined === null ? rule.class : refined.class;
2252
2852
  let ruleId = refined === null ? rule.id : refined.rule;
2853
+ // The value a refinement bound, when one did (APRV-352). Same field and same
2854
+ // meaning as the protected-path binding above: the words the classifier
2855
+ // matched, verbatim, so an approver is told WHICH refs a deletion names
2856
+ // rather than being handed a class and left to re-read the command.
2857
+ const boundPath = refined !== null && "path" in refined ? refined.path : undefined;
2253
2858
  // A protected path anywhere in an effectful segment takes that path's class:
2254
2859
  // the command is editing the gate, whatever else it is doing. Every
2255
2860
  // positional is scanned, source and destination alike, so `cp` stays
@@ -2268,7 +2873,31 @@ function classifySegment(segment, protectedPaths, context) {
2268
2873
  cls = "files.write.workspace";
2269
2874
  ruleId = "redirect-write";
2270
2875
  }
2271
- return { ok: true, class: cls, rule: ruleId, ...(sandbox === null ? {} : { sandbox }) };
2876
+ // APRV-347, last because it is the narrowest: a read whose target the TEXT
2877
+ // places outside every root the caller named. Only `read.shell` is scoped —
2878
+ // `read.web` reaches no file, and a segment that has already taken a
2879
+ // protected, credential or write class is not a read at all. The relative
2880
+ // and symlinked cases are deliberately NOT decided here; they are left at
2881
+ // `read.shell` for the hook's disk pass, which tightens and never loosens.
2882
+ if (cls === "read.shell" && (context.readRoots ?? []).length > 0) {
2883
+ const escaped = escapedReadTarget(basename, positionals, args, context.readRoots ?? []);
2884
+ if (escaped !== null) {
2885
+ return {
2886
+ ok: true,
2887
+ class: READ_OUT_OF_SCOPE_CLASS,
2888
+ rule: escaped.rule,
2889
+ path: escaped.path,
2890
+ ...(sandbox === null ? {} : { sandbox }),
2891
+ };
2892
+ }
2893
+ }
2894
+ return {
2895
+ ok: true,
2896
+ class: cls,
2897
+ rule: ruleId,
2898
+ ...(boundPath === undefined ? {} : { path: boundPath }),
2899
+ ...(sandbox === null ? {} : { sandbox }),
2900
+ };
2272
2901
  }
2273
2902
  /**
2274
2903
  * Classify a shell command line into the classes it would produce.
@@ -2305,6 +2934,30 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
2305
2934
  const segments = [];
2306
2935
  const classes = [];
2307
2936
  for (const segment of lexed.segments) {
2937
+ // APRV-380. A LOGIN-SHELL WRAPPER is classified by the script it runs.
2938
+ // `loginShellScript` states the exact shape and why this is not the second
2939
+ // parser `OPAQUE_BINS` refuses; the inner classification is run with
2940
+ // `unwrapShell: false`, so a shell nested inside the script stays opaque
2941
+ // and the recursion is one level deep by construction.
2942
+ const script = context.unwrapShell === false ? null : loginShellScript(segment);
2943
+ if (script !== null) {
2944
+ const inner = classifyCommand(script, protectedPaths, { ...context, unwrapShell: false });
2945
+ if (!inner.ok) {
2946
+ // The refusal is the INNER one, reported against the inner segment: an
2947
+ // operator told `hook-opaque` for `zsh` learns nothing, and one told
2948
+ // which part of their script could not be read can rewrite it.
2949
+ return inner;
2950
+ }
2951
+ // Spliced rather than collapsed into one segment: a script is a command
2952
+ // line and its parts have their own classes, which is the thing a
2953
+ // one-class answer would lose.
2954
+ for (const found of inner.segments) {
2955
+ segments.push(found);
2956
+ if (!classes.includes(found.class))
2957
+ classes.push(found.class);
2958
+ }
2959
+ continue;
2960
+ }
2308
2961
  const outcome = classifySegment(segment, protectedPaths, context);
2309
2962
  if (!outcome.ok) {
2310
2963
  return { ok: false, code: outcome.code, segment: segment.text, detail: outcome.detail };