approval-md 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +19 -0
  8. package/dist/src/channels/contract.js +105 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -58,6 +58,14 @@ import { READ_OUT_OF_SCOPE_CLASS, isUnreadableTarget, readTargetVerdict, readTar
58
58
  * is never written to any envelope and no policy rule should name it.
59
59
  */
60
60
  export const GATE_SELF_CLASS = "gate.self";
61
+ /**
62
+ * The class a write under `$HERMES_HOME/scripts/` takes (APRV-445): scheduling
63
+ * code that Hermes's cron runs later with no hook in front of it. Returned by
64
+ * {@link protectedPathClass} beside the organs, ranked with `policy.edit`, and
65
+ * deliberately not a {@link ProtectedPathClass}: it is a side effect an
66
+ * operator prices, not a surface of the gate itself.
67
+ */
68
+ export const HERMES_SCHEDULED_SCRIPT_CLASS = "cron.manage";
61
69
  /**
62
70
  * The `policy.edit` sub-class namespace a `protected_paths` entry may route to
63
71
  * (APRV-266).
@@ -286,6 +294,79 @@ export function protectedPathClass(candidate, extra = []) {
286
294
  return "policy.core";
287
295
  }
288
296
  }
297
+ // Hermes Agent's equivalent (APRV-398), and the one whose organ lives in
298
+ // the USER HOME by design rather than in the repository. Hermes reads its
299
+ // configuration from `$HERMES_HOME` (default `~/.hermes`) and documents no
300
+ // project-local directory, so the paths below are the ones a real install
301
+ // carries: `config.yaml`, which is where the `hooks:` entries are written;
302
+ // `agent-hooks/`, which holds the scripts those entries run; and
303
+ // `shell-hooks-allowlist.json`, which records the operator's one-time TTY
304
+ // consent to a hook command and would let an agent pre-consent itself to a
305
+ // command of its own. Same property as `.cursor/hooks.json`: an agent that
306
+ // could write any of these could write itself out of the gate.
307
+ //
308
+ // NO NEW PATH GRAMMAR IS NEEDED FOR THE HOME CASE, and that is worth
309
+ // stating because it is the one thing about this entry that differs from
310
+ // the four above it. This loop walks EVERY segment of the candidate, so
311
+ // `.hermes/config.yaml`, `/Users/x/.hermes/config.yaml` and
312
+ // `~/.hermes/config.yaml` all match at whichever position `.hermes` sits
313
+ // in; the existing matchers were already position-agnostic and nothing was
314
+ // extended for this. What a home path does change is the CLASS a mere READ
315
+ // of one takes: `cat ~/.hermes/config.yaml` is a read of a path outside the
316
+ // gate root, so the read jail answers it as `read.file.out_of_scope`
317
+ // (human-only in this repository's policy) before this tier is consulted,
318
+ // while a WRITE to it is `policy.core` here. Both refuse under an agent;
319
+ // they refuse for different reasons, and `docs/hermes-hook.md` says so.
320
+ //
321
+ // The bare `.hermes` directory itself lands here, exactly as bare
322
+ // `.approval` and bare `.codex` do: `rm -rf ~/.hermes` removes the gate's
323
+ // own installation. Anything else under it is deliberately NOT listed —
324
+ // session state and transcripts are ordinary bookkeeping, and pricing those
325
+ // at a human's attention is the failure mode §11 asks to avoid, the same
326
+ // reasoning that keeps `.muse/worktrees/` out of the entry above.
327
+ //
328
+ // APRV-445 adds three rows from the Agent Village DATA-234 survey of Hermes
329
+ // v2026.9.24. `shell-hooks-allowlist.json.lock` is the allowlist's flock
330
+ // sidecar: an agent that makes it unwritable makes the gateway's consent
331
+ // write raise at start, so it is the allowlist's organ as much as the file
332
+ // is (matched by prefix, so the sidecar and the file are one row).
333
+ // `approval/` is where a hosted install keeps the hook's own configuration
334
+ // beside the harness. And `scripts/` is `cron.manage`, not an organ: Hermes
335
+ // runs a scheduled job's script from `$HERMES_HOME/scripts/` with no
336
+ // `pre_tool_call` at all, so WRITING one is scheduling code that will run
337
+ // ungated later, which is the same act as `cronjob_manage create` and is
338
+ // priced by the same policy line.
339
+ //
340
+ // `.env` (and `.env.*`) and `auth.json` are organs too, for WRITES (APRV-445
341
+ // refutation, B2). `isCredentialPath` makes a READ of them
342
+ // `account.credential`, but that tier is consulted for reads only, so before
343
+ // this a `write_file`, a `patch`, a `>>`, a `sed -i` or a `tee -a` onto
344
+ // `$HERMES_HOME/.env` classified as an ordinary `files.write.workspace` and
345
+ // ran unattended: and Hermes loads that file with `override=True` into every
346
+ // process it starts, which makes it the switch for the gate itself
347
+ // (`HERMES_ACCEPT_HOOKS`) as well as a secret store. Reads stay
348
+ // `account.credential`.
349
+ if (segment === ".hermes") {
350
+ // Recheck 3 (lows): a profile home, `.hermes/profiles/<p>/`, is a home of
351
+ // its own with the same organs, so its paths are judged one level down.
352
+ const next = segments[index + 1] === "profiles" && segments[index + 2] !== undefined
353
+ ? segments[index + 3]
354
+ : segments[index + 1];
355
+ if (next === undefined ||
356
+ next === "config.yaml" ||
357
+ next === "config.yml" ||
358
+ next === "agent-hooks" ||
359
+ next === "approval" ||
360
+ next === ".env" ||
361
+ next.startsWith(".env.") ||
362
+ next === "auth.json" ||
363
+ next.startsWith("shell-hooks-allowlist.json") ||
364
+ next.startsWith("hooks")) {
365
+ return "policy.core";
366
+ }
367
+ if (next === "scripts")
368
+ return HERMES_SCHEDULED_SCRIPT_CLASS;
369
+ }
289
370
  // Codex installs its hook through these configuration and script paths.
290
371
  if (segment === ".codex") {
291
372
  const next = segments[index + 1];
@@ -429,12 +510,34 @@ const CREDENTIAL_CLASS = "account.credential";
429
510
  * kin), which is the environment map holding the Telegram token, the vault
430
511
  * passphrase and the sampling secret.
431
512
  */
432
- function isCredentialPath(candidate) {
513
+ export function isCredentialPath(candidate) {
433
514
  if (candidate.length === 0)
434
515
  return false;
435
516
  const segments = pathSegments(candidate);
436
517
  for (let index = 0; index < segments.length; index += 1) {
437
- if (segments[index] !== ".approval")
518
+ const segment = segments[index];
519
+ // APRV-398. Hermes Agent's own home holds two files of credential material
520
+ // beside its configuration: `.env`, which is where its secrets go, and
521
+ // `auth.json`, which holds an OAuth grant. `$HERMES_HOME/config.yaml` and
522
+ // the hook paths beside it are `policy.core` (see `protectedPathClass`);
523
+ // these two are a different class for the reason `.approval/env` is, which
524
+ // is that what leaves the machine is the secret rather than the rule.
525
+ //
526
+ // Added by the task that taught this runtime about Hermes at all. A harness
527
+ // whose organ this codebase now recognises, whose credential files it did
528
+ // not, would be a Never-list item the classifier does not enforce — which is
529
+ // exactly the hole APRV-194 was filed for, one harness along.
530
+ if (segment === ".hermes") {
531
+ const next = segments[index + 1] === "profiles" && segments[index + 2] !== undefined
532
+ ? segments[index + 3]
533
+ : segments[index + 1];
534
+ if (next === ".env" || next?.startsWith(".env.") === true)
535
+ return true;
536
+ if (next === "auth.json")
537
+ return true;
538
+ continue;
539
+ }
540
+ if (segment !== ".approval")
438
541
  continue;
439
542
  const next = segments[index + 1];
440
543
  if (next === undefined)
@@ -1181,6 +1284,11 @@ const HARNESS_BINS = {
1181
1284
  grok: "grok",
1182
1285
  claude: "claude",
1183
1286
  "cursor-agent": "cursor",
1287
+ // APRV-398. `hermes` is the console script Hermes Agent installs, and the
1288
+ // harness kind and the binary share one spelling, so the class reads
1289
+ // `harness.launch.hermes` beside the `hermes` adapter and the `.hermes/`
1290
+ // protected paths with no name mapping in between.
1291
+ hermes: "hermes",
1184
1292
  };
1185
1293
  /**
1186
1294
  * Package specs a package runner may name, EXACTLY, to the same harness names.
@@ -1198,6 +1306,13 @@ const HARNESS_PACKAGES = {
1198
1306
  "cursor-agent": "cursor",
1199
1307
  muse: "muse",
1200
1308
  grok: "grok",
1309
+ // APRV-398. Hermes Agent is a Python distribution, so a package runner is not
1310
+ // how it is launched; the entries exist for the same reason the `muse` and
1311
+ // `grok` ones do, which is that a spec this table does not know keeps
1312
+ // whatever class the runner already had, and a spec it does know cannot be
1313
+ // spelled around the direct row.
1314
+ hermes: "hermes",
1315
+ "hermes-agent": "hermes",
1201
1316
  };
1202
1317
  /**
1203
1318
  * Argv that starts nothing: a version or help probe.
@@ -1498,6 +1613,429 @@ function refineSed(ctx) {
1498
1613
  ? { class: "files.write.workspace", rule: "sed-in-place" }
1499
1614
  : { class: "read.shell", rule: "sed-read" };
1500
1615
  }
1616
+ // ===========================================================================
1617
+ // Packaging and archives (APRV-397)
1618
+ // ===========================================================================
1619
+ /**
1620
+ * The read-only packaging and archive tools, and the one rule they share.
1621
+ *
1622
+ * ## What was wrong
1623
+ *
1624
+ * A release verification is mostly reading: pack a tarball, list what is in it,
1625
+ * unpack it somewhere disposable, hash a file, decode a base64 blob, ask a
1626
+ * binary its version. Every one of those commands was `unclassified` until this
1627
+ * task, so every one of them was DENIED, and the verifier that met the wall on
1628
+ * 2026-09-20 did the work anyway by fetching the tarball with `curl` and
1629
+ * parsing it in a script: a chain of network and interpreter calls in place of
1630
+ * six readers. A gate that refuses the legible spelling and leaves the
1631
+ * illegible one open has made the session less inspectable rather than safer.
1632
+ *
1633
+ * ## The one rule
1634
+ *
1635
+ * Each of these commands either reads what it names or writes into a
1636
+ * destination it names, and the destination is in the text. So they are decided
1637
+ * by the arithmetic {@link refineRm} already uses, hoisted into
1638
+ * {@link scopedWrite}: a destination strictly under a scratch root the CALLER
1639
+ * resolved is the agent's own space, a relative destination is the workspace,
1640
+ * and an absolute path anywhere else, a `..` segment, or a value the text cannot
1641
+ * read is out of scope.
1642
+ *
1643
+ * ## Why the out-of-scope answer is a class that already exists
1644
+ *
1645
+ * {@link OUT_OF_SCOPE_WRITE_CLASS} is `files.delete.out_of_scope`, which is
1646
+ * what `rm` and `find -delete` already answer for a destructive file operation
1647
+ * the text places outside the workspace. Minting `files.write.out_of_scope`
1648
+ * instead would have been the one change here capable of loosening something: a
1649
+ * class no policy names resolves by `defaults.autonomy` (SPEC.md §7), so in
1650
+ * every deployment whose defaults are permissive a brand-new name arrives
1651
+ * autonomous, while the existing class is held at `manual` by the reference
1652
+ * policy and by this repository's. An extraction is also honestly described by
1653
+ * it: unpacking an archive over a directory overwrites whatever it finds there,
1654
+ * which is the destruction the class is about.
1655
+ *
1656
+ * The strictness this inherits is `rm`'s, and it is deliberate. An ABSOLUTE
1657
+ * destination is out of scope even when it happens to sit inside the checkout,
1658
+ * because this file holds no workspace root, and the read roots it does hold
1659
+ * are a read notion that must not become a write authorization.
1660
+ */
1661
+ const OUT_OF_SCOPE_WRITE_CLASS = "files.delete.out_of_scope";
1662
+ /** The one rule id every out-of-scope packaging destination reports. */
1663
+ const OUT_OF_SCOPE_WRITE_RULE = "packaging-write-out-of-scope";
1664
+ /**
1665
+ * A destination a flag names, as a list {@link scopedWrite} can read.
1666
+ *
1667
+ * Three answers, and the third is the one worth naming: `[]` when the flag is
1668
+ * absent (the command writes where it stands, which is the working directory),
1669
+ * `[value]` when the flag names something the text can read, and `[null]` when
1670
+ * the flag is PRESENT and its value is not in the words. The last is a refusal
1671
+ * rather than an absence, because a flag present with nothing readable after it
1672
+ * would otherwise be answered as "no destination named" and take the looser
1673
+ * branch.
1674
+ */
1675
+ function namedDestination(args, names) {
1676
+ if (!hasFlag(args, names))
1677
+ return [];
1678
+ for (let index = 0; index < args.length; index += 1) {
1679
+ const arg = args[index];
1680
+ const equals = arg.indexOf("=");
1681
+ const name = equals === -1 ? arg : arg.slice(0, equals);
1682
+ if (!names.includes(name))
1683
+ continue;
1684
+ if (equals !== -1) {
1685
+ const value = arg.slice(equals + 1);
1686
+ return [value.length === 0 ? null : value];
1687
+ }
1688
+ const value = args[index + 1];
1689
+ return [value === undefined || isFlag(value) ? null : value];
1690
+ }
1691
+ return [null];
1692
+ }
1693
+ /**
1694
+ * The class of a write into destinations the command names (APRV-397).
1695
+ *
1696
+ * The same fail-closed branches {@link refineRm} has, in the same order,
1697
+ * against the same roots: all-scratch loosens, and an unreadable destination, an
1698
+ * absolute one, or a `..` segment is out of scope with the destination BOUND so
1699
+ * an approver is told which path earned the class. An empty destination list is
1700
+ * the working directory, which this file cannot see and must not guess at, so it
1701
+ * takes the row's own workspace answer exactly as `rm` with no target does.
1702
+ */
1703
+ function scopedWrite(ctx, destinations, rule) {
1704
+ const named = destinations.filter((entry) => entry !== null);
1705
+ if (named.length < destinations.length) {
1706
+ return { class: OUT_OF_SCOPE_WRITE_CLASS, rule: OUT_OF_SCOPE_WRITE_RULE };
1707
+ }
1708
+ if (allTargetsAreScratch(named, ctx.context.scratchRoots ?? [])) {
1709
+ return { class: "files.write.workspace", rule };
1710
+ }
1711
+ for (const destination of named) {
1712
+ if (destination.startsWith("/") ||
1713
+ pathSegments(destination).includes("..") ||
1714
+ isUnknownValue(destination)) {
1715
+ return { class: OUT_OF_SCOPE_WRITE_CLASS, rule: OUT_OF_SCOPE_WRITE_RULE, path: destination };
1716
+ }
1717
+ }
1718
+ return { class: "files.write.workspace", rule };
1719
+ }
1720
+ /**
1721
+ * An argv that asks a binary about itself: a version or a usage string.
1722
+ *
1723
+ * The harness table has its own copy of this idea ({@link HARNESS_PROBE_FLAGS})
1724
+ * and keeps it, because a harness probe still EXECUTES a whole second agent's
1725
+ * binary and is named in {@link CODE_EXECUTING_RULES} for that reason. This one
1726
+ * is the package managers', where `npm --version` prints a string and starts
1727
+ * nothing at all. `-v` is here and absent there: it is npm's version flag and
1728
+ * nothing else's.
1729
+ */
1730
+ const VERSION_PROBE_FLAGS = ["--version", "-v", "-V", "--help", "-h"];
1731
+ /** Is this argv nothing but probe flags? One word at minimum, so a bare binary is not a probe. */
1732
+ function isVersionProbe(args) {
1733
+ return args.length > 0 && args.every((arg) => VERSION_PROBE_FLAGS.includes(arg));
1734
+ }
1735
+ /** Positionals, with the values of value-taking flags left out of the count. */
1736
+ function positionalsBesideValues(args, valueFlags) {
1737
+ const out = [];
1738
+ for (let index = 0; index < args.length; index += 1) {
1739
+ const arg = args[index];
1740
+ if (isFlag(arg)) {
1741
+ if (valueFlags.includes(arg))
1742
+ index += 1;
1743
+ continue;
1744
+ }
1745
+ out.push(arg);
1746
+ }
1747
+ return out;
1748
+ }
1749
+ /** `npm pack`'s flags whose value is a path or a workspace name, never a package spec. */
1750
+ const NPM_PACK_VALUE_FLAGS = ["--pack-destination", "--workspace", "-w"];
1751
+ /**
1752
+ * Does this `npm pack` argument name something on disk rather than a registry
1753
+ * package?
1754
+ *
1755
+ * A path, in the spellings npm accepts for one. Everything else is a package
1756
+ * spec, which `npm pack` resolves by FETCHING it, and a value the text cannot
1757
+ * read is a spec too: the looser reading of an unreadable argument is the one
1758
+ * that lets a download through as a local pack.
1759
+ */
1760
+ function isLocalPackSpec(spec) {
1761
+ if (isUnknownValue(spec))
1762
+ return false;
1763
+ if (spec.startsWith(".") || spec.startsWith("/"))
1764
+ return true;
1765
+ return spec.includes("/") && !spec.startsWith("@");
1766
+ }
1767
+ /**
1768
+ * `npm pack` — a tarball into the working directory, or into
1769
+ * `--pack-destination`.
1770
+ *
1771
+ * Two answers beside the scoped write. A positional naming a REGISTRY package
1772
+ * is `network.call`, because that spelling downloads the package before it packs
1773
+ * it and the tarball it then writes is beside the point. And the write itself
1774
+ * runs the package's own `prepack` and `prepare` scripts, which is why the rule
1775
+ * id is in {@link CODE_EXECUTING_RULES}.
1776
+ */
1777
+ function refineNpmPack(ctx) {
1778
+ const specs = positionalsBesideValues(ctx.args, NPM_PACK_VALUE_FLAGS).slice(1);
1779
+ if (specs.some((spec) => !isLocalPackSpec(spec))) {
1780
+ return { class: "network.call", rule: "npm-pack-remote" };
1781
+ }
1782
+ return scopedWrite(ctx, namedDestination(ctx.args, ["--pack-destination"]), "npm-pack");
1783
+ }
1784
+ /**
1785
+ * `npm init` — `package.json` into the working directory.
1786
+ *
1787
+ * No flag moves it, so there is no destination to scope and the answer is the
1788
+ * workspace write the row declares. `npm init <pkg>` is a different act, since
1789
+ * it downloads an initializer and runs it, which is `npm exec` wearing another
1790
+ * name; it takes its own rule id and its seat in {@link CODE_EXECUTING_RULES}
1791
+ * beside `npm-script`.
1792
+ */
1793
+ function refineNpmInit(ctx) {
1794
+ const initializer = positionalsBesideValues(ctx.args, ["--workspace", "-w"]).slice(1);
1795
+ return initializer.length === 0
1796
+ ? { class: "files.write.workspace", rule: "npm-init" }
1797
+ : { class: "files.write.workspace", rule: "npm-init-create" };
1798
+ }
1799
+ /** `tar`'s long spellings of each mode, beside the one-letter ones. */
1800
+ const TAR_LIST_FLAGS = ["--list"];
1801
+ const TAR_EXTRACT_FLAGS = ["--extract", "--get"];
1802
+ const TAR_CREATE_FLAGS = [
1803
+ "--create",
1804
+ "--append",
1805
+ "--update",
1806
+ "--delete",
1807
+ "--concatenate",
1808
+ "--catenate",
1809
+ ];
1810
+ /** The argv that ask `tar` about itself and open no archive at all. */
1811
+ const TAR_PROBE_FLAGS = ["--version", "--help", "--usage"];
1812
+ /**
1813
+ * The mode letters `tar` was given, from the short bundles and the old-style
1814
+ * first word.
1815
+ *
1816
+ * `tar xzf a.tgz` carries no dash at all, and it is the spelling most sessions
1817
+ * write, so a rule that read only `-x` would answer the common form with a
1818
+ * refusal. Only the FIRST word can be an old-style bundle, and a word is read as
1819
+ * a bundle only when it is letters and nothing else: `-fa.tgz` is a glued
1820
+ * filename, and a letter taken out of a filename is a mode the command never
1821
+ * named. Dropping such a word costs a refusal (the mode becomes unreadable) and
1822
+ * can never invent a read.
1823
+ */
1824
+ function tarModeLetters(args) {
1825
+ let letters = "";
1826
+ for (let index = 0; index < args.length; index += 1) {
1827
+ const arg = args[index];
1828
+ if (/^-[A-Za-z]+$/u.test(arg))
1829
+ letters += arg.slice(1);
1830
+ else if (index === 0 && /^[A-Za-z]+$/u.test(arg))
1831
+ letters += arg;
1832
+ }
1833
+ return letters;
1834
+ }
1835
+ /** The tar letters that consume the next word, which is what makes a bundle ambiguous. */
1836
+ const TAR_VALUE_LETTERS = ["f", "C"];
1837
+ /** The option letters of a tar word, or `null` when it is not a bundle of them. */
1838
+ function tarBundle(arg, index) {
1839
+ if (/^-[A-Za-z]+$/u.test(arg))
1840
+ return arg.slice(1);
1841
+ if (index === 0 && /^[A-Za-z]+$/u.test(arg))
1842
+ return arg;
1843
+ return null;
1844
+ }
1845
+ /**
1846
+ * The value a tar flag carries, in every spelling tar accepts for it.
1847
+ *
1848
+ * The glued bundle is the whole reason this exists rather than
1849
+ * {@link namedDestination}: `tar -czf out.tgz src` names its archive with an
1850
+ * `f` buried in `-czf`, which an exact-word flag match cannot see, and a rule
1851
+ * that missed it would answer the commonest spelling of a create with the
1852
+ * looser branch.
1853
+ *
1854
+ * One bundle carrying BOTH value-taking letters (`-xCf`) is `null`, which
1855
+ * {@link scopedWrite} refuses: which of the following words feeds which letter
1856
+ * depends on tar's own option order, so the destination is not in the text
1857
+ * (SPEC.md §11.1, ambiguity resolves to the stricter class).
1858
+ */
1859
+ function tarFlagValue(args, letter, longNames) {
1860
+ for (let index = 0; index < args.length; index += 1) {
1861
+ const arg = args[index];
1862
+ const equals = arg.indexOf("=");
1863
+ const name = equals === -1 ? arg : arg.slice(0, equals);
1864
+ if (longNames.includes(name)) {
1865
+ if (equals === -1) {
1866
+ const next = args[index + 1];
1867
+ return [next === undefined || isFlag(next) ? null : next];
1868
+ }
1869
+ const value = arg.slice(equals + 1);
1870
+ return [value.length === 0 ? null : value];
1871
+ }
1872
+ const bundle = tarBundle(arg, index);
1873
+ if (bundle === null || !bundle.includes(letter))
1874
+ continue;
1875
+ if (TAR_VALUE_LETTERS.filter((candidate) => bundle.includes(candidate)).length > 1) {
1876
+ return [null];
1877
+ }
1878
+ const next = args[index + 1];
1879
+ return [next === undefined || isFlag(next) ? null : next];
1880
+ }
1881
+ return [];
1882
+ }
1883
+ /**
1884
+ * `tar` — a listing reads, an extraction writes where `-C` points, a creation
1885
+ * writes the archive `-f` names.
1886
+ *
1887
+ * The write branch is checked FIRST and takes both destinations when one command
1888
+ * names both modes, because `tar` resolves a contradictory mode by its own
1889
+ * option order and the stricter reading is the one that assumes it writes.
1890
+ *
1891
+ * A mode the text does not carry is `opaque` rather than a guess, and that is
1892
+ * the honest code for it: a `tar` whose mode is not in its words is a command
1893
+ * whose effect is not in its words, which is what {@link OPAQUE_BINS} refuses
1894
+ * for the same reason.
1895
+ */
1896
+ function refineTar(ctx) {
1897
+ const letters = tarModeLetters(ctx.args);
1898
+ const extracting = letters.includes("x") || hasFlag(ctx.args, TAR_EXTRACT_FLAGS);
1899
+ const creating = ["c", "r", "u", "A"].some((letter) => letters.includes(letter)) ||
1900
+ hasFlag(ctx.args, TAR_CREATE_FLAGS);
1901
+ if (extracting || creating) {
1902
+ const destinations = [
1903
+ ...(extracting ? tarFlagValue(ctx.args, "C", ["--directory"]) : []),
1904
+ ...(creating ? tarFlagValue(ctx.args, "f", ["--file"]) : []),
1905
+ ];
1906
+ return scopedWrite(ctx, destinations, extracting ? "tar-extract" : "tar-create");
1907
+ }
1908
+ if (letters.includes("t") || hasFlag(ctx.args, TAR_LIST_FLAGS)) {
1909
+ return { class: "read.shell", rule: "tar-list" };
1910
+ }
1911
+ if (ctx.args.length > 0 && ctx.args.every((arg) => TAR_PROBE_FLAGS.includes(arg))) {
1912
+ return { class: "read.shell", rule: "tar-probe" };
1913
+ }
1914
+ return { opaque: "tar names no mode this classifier can read" };
1915
+ }
1916
+ /**
1917
+ * `gunzip` — the stdout, test and list forms read; every other form REPLACES a
1918
+ * file.
1919
+ *
1920
+ * The default is the trap: `gunzip pkg.tgz` removes `pkg.tgz` and leaves
1921
+ * `pkg.tar` in its place, so the plain spelling is a write of the path it names.
1922
+ * Three flags leave the disk alone: `-c`/`--stdout` decompresses to standard
1923
+ * output, `-t`/`--test` checks the integrity of the archive, and `-l`/`--list`
1924
+ * prints the compressed and uncompressed sizes, the ratio and the member name.
1925
+ * A listing is the same kind of act as `tar -t` beside it and belongs in the
1926
+ * same class. `-k` (`--keep`) is deliberately not a read: it spares the input
1927
+ * and still creates the output.
1928
+ */
1929
+ function refineGunzip(ctx) {
1930
+ const reading = hasShortFlag(ctx.args, ["c", "t", "l"]) ||
1931
+ hasFlag(ctx.args, ["--stdout", "--to-stdout", "--test", "--list"]);
1932
+ return reading
1933
+ ? { class: "read.shell", rule: "gunzip-read" }
1934
+ : scopedWrite(ctx, ctx.positionals, "gunzip-write");
1935
+ }
1936
+ /** `base64` — a read of what it names, unless `-o` names a file to write. */
1937
+ function refineBase64(ctx) {
1938
+ const destination = namedDestination(ctx.args, ["-o", "--output"]);
1939
+ return destination.length === 0
1940
+ ? { class: "read.shell", rule: "base64-read" }
1941
+ : scopedWrite(ctx, destination, "base64-write");
1942
+ }
1943
+ /**
1944
+ * `openssl`'s digest subcommands, and only those.
1945
+ *
1946
+ * The row names the digests rather than the binary, so `openssl enc`, `genrsa`,
1947
+ * `req`, `rand` and `s_client` stay exactly as unclassified as they were. Each
1948
+ * of those writes key material, encrypts, or opens a socket, and none of them is
1949
+ * what a release verification runs; a row for the binary would have swept all of
1950
+ * them into a read.
1951
+ */
1952
+ const OPENSSL_DIGEST_SUBS = [
1953
+ "dgst",
1954
+ "md5",
1955
+ "sha1",
1956
+ "sha256",
1957
+ "sha384",
1958
+ "sha512",
1959
+ ];
1960
+ /** `openssl dgst …` — a read of its named files, unless `-out` names one to write. */
1961
+ function refineOpensslDigest(ctx) {
1962
+ const destination = namedDestination(ctx.args, ["-out"]);
1963
+ return destination.length === 0
1964
+ ? { class: "read.shell", rule: "openssl-digest" }
1965
+ : scopedWrite(ctx, destination, "openssl-digest-out");
1966
+ }
1967
+ /**
1968
+ * `git tag` — the listing forms read (APRV-397).
1969
+ *
1970
+ * APRV-305 gave the whole verb `release.publish`, which is right for the half
1971
+ * of it that creates, moves, signs and deletes tags: a tag is the name a
1972
+ * release was published under. It is wrong for the other half. `git tag -l` is
1973
+ * a listing of local refs, and on 2026-09-20 it put a nine-minute question on a
1974
+ * phone during a state check. Nothing about a listing reaches a registry, a
1975
+ * remote or a consumer, and the class it takes is `read.shell`, the class every
1976
+ * other reader of local repository metadata takes (`git log`, `git branch`,
1977
+ * `git status`).
1978
+ *
1979
+ * The split is an ALLOWLIST of the flags that list, and that direction is the
1980
+ * whole safety argument: a flag this rule has never heard of is a creation, so a
1981
+ * future `git tag` option cannot arrive as a read by default. A positional is a
1982
+ * TAG NAME, which creates one, unless `-l` is present and makes it a pattern.
1983
+ * The shape mirrors {@link refineGitBranch}, which splits read and write halves
1984
+ * of one verb for the same reason.
1985
+ */
1986
+ const GIT_TAG_READ_FLAGS = [
1987
+ "-l",
1988
+ "--list",
1989
+ "-n",
1990
+ "-i",
1991
+ "--ignore-case",
1992
+ "--omit-empty",
1993
+ "--column",
1994
+ "--no-column",
1995
+ "--color",
1996
+ "--no-color",
1997
+ ];
1998
+ /** Listing flags whose next word is a value rather than a tag name. */
1999
+ const GIT_TAG_READ_VALUE_FLAGS = [
2000
+ "--contains",
2001
+ "--no-contains",
2002
+ "--points-at",
2003
+ "--merged",
2004
+ "--no-merged",
2005
+ "--sort",
2006
+ "--format",
2007
+ ];
2008
+ function refineGitTag(ctx) {
2009
+ const publish = { class: "release.publish", rule: "git-tag" };
2010
+ const args = ctx.args.slice(1);
2011
+ const names = [];
2012
+ let listing = false;
2013
+ for (let index = 0; index < args.length; index += 1) {
2014
+ const arg = args[index];
2015
+ if (!isFlag(arg)) {
2016
+ names.push(arg);
2017
+ continue;
2018
+ }
2019
+ const equals = arg.indexOf("=");
2020
+ const name = equals === -1 ? arg : arg.slice(0, equals);
2021
+ if (name === "-l" || name === "--list") {
2022
+ listing = true;
2023
+ continue;
2024
+ }
2025
+ if (GIT_TAG_READ_FLAGS.includes(name) || /^-n\d+$/u.test(arg))
2026
+ continue;
2027
+ if (GIT_TAG_READ_VALUE_FLAGS.includes(name)) {
2028
+ if (equals === -1)
2029
+ index += 1;
2030
+ continue;
2031
+ }
2032
+ // Everything the allowlist does not name: a creation, a deletion, a
2033
+ // signature, a force-move, and also a flag that did not exist when this rule
2034
+ // was written.
2035
+ return publish;
2036
+ }
2037
+ return names.length > 0 && !listing ? publish : { class: "read.shell", rule: "git-tag-read" };
2038
+ }
1501
2039
  /**
1502
2040
  * The methods a fetch may name and still be a read: GET, and HEAD, which is a
1503
2041
  * GET that discards the body. Everything else, including a method the
@@ -1865,6 +2403,19 @@ function refineApprovalVerb(positionals, args = []) {
1865
2403
  return { class: "policy.core", rule: "approval-log-checkpoint" };
1866
2404
  return null;
1867
2405
  }
2406
+ // APRV-455. `channel relay` records gestures attributed to an EdgeOS account
2407
+ // on the word of whoever holds a secret the LAUNCHER chose. An agent that
2408
+ // could start one with a secret of its own, and post to it, would write
2409
+ // records that read as transport-authenticated evidence from a person's
2410
+ // session, which a Telegram listener cannot be made to do without the bot
2411
+ // token. So starting it is the operator's ceremony: `policy.core`, held
2412
+ // human-only by the reference policy, and the hook denies it with
2413
+ // `hook-class-human-only`. A deployment starts it from its own launcher, as
2414
+ // the store's user, outside any agent's hook. It mints no new class (SPEC.md
2415
+ // §11.1 invariant 9).
2416
+ if (verb === "channel" && sub === "relay") {
2417
+ return { class: "policy.core", rule: "approval-channel-relay" };
2418
+ }
1868
2419
  if (verb === "gate") {
1869
2420
  if (sub === "open")
1870
2421
  return { class: "policy.core", rule: "approval-gate-open" };
@@ -1963,7 +2514,16 @@ export const COMMAND_RULES = [
1963
2514
  { id: "git-reset", bins: ["git"], subs: ["reset"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitReset },
1964
2515
  { id: "git-commit", bins: ["git"], subs: ["commit"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitCommit },
1965
2516
  { id: "git-branch", bins: ["git"], subs: ["branch"], class: "read.shell", emits: ["vcs.commit.branch"], refine: refineGitBranch },
1966
- { id: "git-tag", bins: ["git"], subs: ["tag"], class: "release.publish" },
2517
+ // APRV-397 split the verb: the listing flags read, everything else publishes.
2518
+ // See {@link refineGitTag} for why the split is an allowlist.
2519
+ {
2520
+ id: "git-tag",
2521
+ bins: ["git"],
2522
+ subs: ["tag"],
2523
+ class: "release.publish",
2524
+ emits: ["read.shell"],
2525
+ refine: refineGitTag,
2526
+ },
1967
2527
  { id: "git-clone", bins: ["git"], subs: ["clone"], class: "network.call" },
1968
2528
  {
1969
2529
  id: "git-write",
@@ -2045,7 +2605,30 @@ export const COMMAND_RULES = [
2045
2605
  refine: refineGh,
2046
2606
  },
2047
2607
  // -- package managers ----------------------------------------------------
2608
+ // APRV-397. The probe row matches ONLY an argv that is nothing but version or
2609
+ // help flags, which is what `probe` means and what keeps `npm doctor`,
2610
+ // `npm cache clean` and every other subcommand this table does not name at the
2611
+ // `unclassified` deny they have today. `npm version` is a different word and
2612
+ // keeps its `release.publish` row below.
2613
+ { id: "npm-version", bins: ["npm", "pnpm", "yarn", "bun"], class: "read.shell", probe: true },
2048
2614
  { id: "npm-publish", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["publish", "version", "deprecate", "dist-tag", "unpublish"], class: "release.publish" },
2615
+ // APRV-397: the two package-manager verbs that write into a directory they
2616
+ // name rather than touching the registry or the lockfile.
2617
+ {
2618
+ id: "npm-pack",
2619
+ bins: ["npm", "pnpm", "yarn", "bun"],
2620
+ subs: ["pack"],
2621
+ class: "files.write.workspace",
2622
+ emits: [OUT_OF_SCOPE_WRITE_CLASS, "network.call"],
2623
+ refine: refineNpmPack,
2624
+ },
2625
+ {
2626
+ id: "npm-init",
2627
+ bins: ["npm", "pnpm", "yarn", "bun"],
2628
+ subs: ["init"],
2629
+ class: "files.write.workspace",
2630
+ refine: refineNpmInit,
2631
+ },
2049
2632
  { id: "npm-install", bins: ["npm", "bun"], subs: ["install", "i", "add"], class: "deps.add", emits: ["deps.install"], refine: refineNpmInstall },
2050
2633
  { id: "yarn-add", bins: ["yarn", "pnpm"], subs: ["add"], class: "deps.add" },
2051
2634
  { id: "yarn-install", bins: ["yarn", "pnpm"], subs: ["install"], class: "deps.install" },
@@ -2142,6 +2725,40 @@ export const COMMAND_RULES = [
2142
2725
  emits: ["files.delete.out_of_scope", "files.write.workspace"],
2143
2726
  refine: refineFind,
2144
2727
  },
2728
+ // -- packaging and archives (APRV-397) -----------------------------------
2729
+ // Four binaries a release verification cannot do without, each of which either
2730
+ // reads what it names or writes into a destination it names. The section
2731
+ // comment above {@link OUT_OF_SCOPE_WRITE_CLASS} holds the reasoning; these
2732
+ // rows only say which binary reaches which refinement.
2733
+ {
2734
+ id: "tar",
2735
+ bins: ["tar"],
2736
+ class: "files.write.workspace",
2737
+ emits: ["read.shell", OUT_OF_SCOPE_WRITE_CLASS],
2738
+ refine: refineTar,
2739
+ },
2740
+ {
2741
+ id: "gunzip",
2742
+ bins: ["gunzip"],
2743
+ class: "files.write.workspace",
2744
+ emits: ["read.shell", OUT_OF_SCOPE_WRITE_CLASS],
2745
+ refine: refineGunzip,
2746
+ },
2747
+ {
2748
+ id: "base64",
2749
+ bins: ["base64"],
2750
+ class: "read.shell",
2751
+ emits: ["files.write.workspace", OUT_OF_SCOPE_WRITE_CLASS],
2752
+ refine: refineBase64,
2753
+ },
2754
+ {
2755
+ id: "openssl-digest",
2756
+ bins: ["openssl"],
2757
+ subs: OPENSSL_DIGEST_SUBS,
2758
+ class: "read.shell",
2759
+ emits: ["files.write.workspace", OUT_OF_SCOPE_WRITE_CLASS],
2760
+ refine: refineOpensslDigest,
2761
+ },
2145
2762
  // -- network -------------------------------------------------------------
2146
2763
  // The HTTP clients split on their flags; the transports do not. What `ssh`,
2147
2764
  // `rsync` or `nc` will do at the far end is not written in the argv, so there
@@ -2458,6 +3075,70 @@ function protectedRank(surface) {
2458
3075
  const index = PROTECTED_PRECEDENCE.indexOf(surface);
2459
3076
  return index === -1 ? PROTECTED_PRECEDENCE.indexOf("policy.edit") : index;
2460
3077
  }
3078
+ /**
3079
+ * Is this word PROSE that merely spells a protected path, rather than a path?
3080
+ * (APRV-409.)
3081
+ *
3082
+ * The incident: a `backlog task create` whose acceptance-criterion argument
3083
+ * began with the literal text of a workflow path and continued into a sentence
3084
+ * classified `policy.edit.ci`, sat the full hook wait on the gate and was
3085
+ * denied on timeout. The command writes one task file and touches no workflow.
3086
+ * {@link pathSegments} splits on slashes only, so a word whose leading segments
3087
+ * are a protected DIRECTORY run prefix-matches whatever follows those slashes,
3088
+ * sentence included. An exact-file entry does not, because it matches on the
3089
+ * FINAL segment, which is why the same sentence beginning `CLAUDE.md` always
3090
+ * classified as an ordinary workspace write.
3091
+ *
3092
+ * The test drawn here is the narrow one, and it is narrow in a stated way: the
3093
+ * word is prose only when the protected match came ENTIRELY from a
3094
+ * whitespace-free head. Concretely, all three hold:
3095
+ *
3096
+ * 1. the word contains whitespace at all;
3097
+ * 2. the first of its segments carrying whitespace is not the first segment,
3098
+ * so there is a head in front of it;
3099
+ * 3. that head, on its own, already classifies as the SAME surface the whole
3100
+ * word did.
3101
+ *
3102
+ * When (3) fails the word is kept, which is the fail-closed direction and is
3103
+ * what keeps the shapes below unmoved:
3104
+ *
3105
+ * - `my notes/CLAUDE.md` and any other exact-file match at any depth: the match
3106
+ * needs the final segment, the head classifies as nothing, and the word keeps
3107
+ * `policy.edit`. A real file named with a space therefore stays protected.
3108
+ * - `my notes/.github/workflows/x.yml`: whitespace BEFORE the protected run,
3109
+ * head `my notes` classifies as nothing, word unmoved.
3110
+ * - every word with no whitespace at all, which is the whole ordinary universe
3111
+ * of paths: condition (1) fails before anything is computed.
3112
+ *
3113
+ * What does move is the one shape prose and a path share exactly: a
3114
+ * directory-prefix match whose remainder carries whitespace. `cp x
3115
+ * ".approval/my file.txt"` is a real path of that shape and now takes the
3116
+ * command's own class. That is a deliberate trade and not an oversight —
3117
+ * `my file.txt` and `ci.yml should not gate this task` are the same string
3118
+ * shape, and no pure, disk-free rule tells them apart. A threshold on how much
3119
+ * whitespace the remainder carries would have rescued the first, and it is
3120
+ * rejected: a number here is folklore a second implementation has to guess at,
3121
+ * and the conformance suite would be pinning it. The write itself is not
3122
+ * unguarded: `core/protected-path-guard.ts` judges what a commit actually
3123
+ * changed, from the log rather than from a session, and a `.approval/` file
3124
+ * with no authorization record still fails the pull request.
3125
+ *
3126
+ * Pure and disk-free like the rest of this file: no resolution, no stat, no
3127
+ * question about whether anything exists.
3128
+ */
3129
+ function proseNamingProtectedPath(word, surface, protectedPaths) {
3130
+ if (!/\s/u.test(word))
3131
+ return false;
3132
+ const segments = pathSegments(word);
3133
+ const firstSpaced = segments.findIndex((segment) => /\s/u.test(segment));
3134
+ // `-1` is a word whose whitespace survived no segment (nothing produces it
3135
+ // today) and `0` is whitespace in or before the first segment: both leave no
3136
+ // whitespace-free head to have matched, so both keep the word.
3137
+ if (firstSpaced <= 0)
3138
+ return false;
3139
+ const head = segments.slice(0, firstSpaced).join("/");
3140
+ return protectedPathClass(head, protectedPaths) === surface;
3141
+ }
2461
3142
  /**
2462
3143
  * The strictest protected surface named by these words, with the word itself.
2463
3144
  *
@@ -2468,13 +3149,22 @@ function protectedRank(surface) {
2468
3149
  * did and is what keeps a bare-string policy byte-identical: two routed paths
2469
3150
  * in one segment are two equally consequential surfaces, and the segment's
2470
3151
  * class names one of them while `ClassifiedSegment.path` names the word.
3152
+ *
3153
+ * `skipProse` turns on {@link proseNamingProtectedPath} (APRV-409) and is
3154
+ * passed by the POSITIONAL scan alone. The other caller here is the
3155
+ * write-redirection targets, whose words are paths by construction — the shell
3156
+ * is about to create that file — and the same is true of the paths
3157
+ * `core/apply-patch.ts` and the hook's file-tool pass hand to
3158
+ * {@link protectedPathClass} directly. None of them is offered the skip.
2471
3159
  */
2472
- function strictestProtected(words, protectedPaths) {
3160
+ function strictestProtected(words, protectedPaths, skipProse = false) {
2473
3161
  let best = null;
2474
3162
  for (const word of words) {
2475
3163
  const surface = protectedPathClass(word, protectedPaths);
2476
3164
  if (surface === null)
2477
3165
  continue;
3166
+ if (skipProse && proseNamingProtectedPath(word, surface, protectedPaths))
3167
+ continue;
2478
3168
  if (best === null || protectedRank(surface) < protectedRank(best.surface)) {
2479
3169
  best = { surface, path: word };
2480
3170
  }
@@ -2504,6 +3194,18 @@ export const CODE_EXECUTING_RULES = [
2504
3194
  "node-script",
2505
3195
  /** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
2506
3196
  "workspace-tool",
3197
+ /**
3198
+ * `npm pack` and `npm init <initializer>` (APRV-397).
3199
+ *
3200
+ * Both run code the runtime did not author, for different reasons. `npm pack`
3201
+ * runs the package's own `prepack` and `prepare` scripts, which are files in
3202
+ * the workspace an agent may have written a minute ago, exactly as `npm test`
3203
+ * is. `npm init <pkg>` downloads an initializer and runs it, which is
3204
+ * `npm exec` wearing another name. A bare `npm init` writes `package.json` and
3205
+ * runs nothing, so it is absent.
3206
+ */
3207
+ "npm-pack",
3208
+ "npm-init-create",
2507
3209
  /**
2508
3210
  * Launching an agent harness, and probing one (APRV-354).
2509
3211
  *
@@ -2698,11 +3400,18 @@ function sandboxWrapper(words, start) {
2698
3400
  // can produce (`docs/sandboxed-exec.md`, and APRV-193's Linux follow-up).
2699
3401
  return null;
2700
3402
  }
2701
- /** Find the first table row matching this binary and subcommand. */
2702
- function matchRule(bin, sub) {
3403
+ /**
3404
+ * Find the first table row matching this binary, subcommand and argv.
3405
+ *
3406
+ * The argv is read for one purpose only (APRV-397): a `probe` row matches an
3407
+ * argv that is nothing but version or help flags, and no other row reads it.
3408
+ */
3409
+ function matchRule(bin, sub, args) {
2703
3410
  for (const rule of COMMAND_RULES) {
2704
3411
  if (!rule.bins.includes(bin))
2705
3412
  continue;
3413
+ if (rule.probe === true && !isVersionProbe(args))
3414
+ continue;
2706
3415
  if (rule.subs !== undefined) {
2707
3416
  if (sub === null || !rule.subs.includes(sub))
2708
3417
  continue;
@@ -2826,7 +3535,7 @@ function classifySegment(segment, protectedPaths, context) {
2826
3535
  if (credential !== null)
2827
3536
  return { ok: true, ...credential };
2828
3537
  const sub = positionals[0] ?? null;
2829
- const rule = matchRule(basename, sub);
3538
+ const rule = matchRule(basename, sub, args);
2830
3539
  if (rule === null) {
2831
3540
  return {
2832
3541
  ok: false,
@@ -2861,8 +3570,14 @@ function classifySegment(segment, protectedPaths, context) {
2861
3570
  // direction-blind — a copy OUT of the policy directory is as gated as a copy
2862
3571
  // into it, because the classifier cannot tell which argument the binary will
2863
3572
  // treat as the destination and guessing would be the ungated direction.
3573
+ //
3574
+ // This is the ONE scan that skips prose (APRV-409, `skipProse`): a positional
3575
+ // is whatever the author quoted, and an acceptance criterion that opens with
3576
+ // a directory path is a sentence rather than a file. Redirection targets
3577
+ // above, and the apply-patch and file-tool paths elsewhere, are paths by
3578
+ // construction and are not offered the skip.
2864
3579
  if (!cls.startsWith("read.") && cls !== GATE_SELF_CLASS) {
2865
- const named = strictestProtected(positionals, protectedPaths);
3580
+ const named = strictestProtected(positionals, protectedPaths, true);
2866
3581
  if (named !== null) {
2867
3582
  return { ok: true, class: named.surface, rule: "protected-path", path: named.path };
2868
3583
  }
@@ -2989,6 +3704,43 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
2989
3704
  * `classifyCommand` answers `unparseable` for. Segments carrying no binary (a
2990
3705
  * bare assignment, a lone redirection) are omitted: they have no verb to show.
2991
3706
  */
3707
+ /**
3708
+ * The paths each segment WRITES through a redirection, from the same parse
3709
+ * (APRV-445). `<` is a read and is left out, as are the discard devices.
3710
+ * `null` when the tokenizer refuses the string.
3711
+ */
3712
+ export function commandSegmentWriteTargets(command) {
3713
+ const lexed = lex(command);
3714
+ if (!lexed.ok)
3715
+ return null;
3716
+ return lexed.segments.map((segment) => segment.redirects
3717
+ .filter((redirect) => redirect.op !== "<")
3718
+ .map((redirect) => redirect.target.text)
3719
+ .filter((target) => !isDiscardTarget(target)));
3720
+ }
3721
+ export function commandSegmentShapes(command) {
3722
+ const lexed = lex(command);
3723
+ if (!lexed.ok)
3724
+ return null;
3725
+ return lexed.segments.map((segment) => {
3726
+ const words = segment.words.map((word) => word.text);
3727
+ let cursor = 0;
3728
+ while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
3729
+ cursor += 1;
3730
+ return {
3731
+ text: segment.text,
3732
+ bin: words[cursor] ?? null,
3733
+ args: words.slice(cursor + 1),
3734
+ writes: segment.redirects
3735
+ .filter((redirect) => redirect.op !== "<")
3736
+ .map((redirect) => redirect.target.text)
3737
+ .filter((target) => !isDiscardTarget(target)),
3738
+ reads: segment.redirects
3739
+ .filter((redirect) => redirect.op === "<")
3740
+ .map((redirect) => redirect.target.text),
3741
+ };
3742
+ });
3743
+ }
2992
3744
  export function commandSegmentWords(command) {
2993
3745
  const lexed = lex(command);
2994
3746
  if (!lexed.ok)