approval-md 0.2.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 (378) hide show
  1. package/README.md +85 -877
  2. package/SPEC.md +150 -19
  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 +53 -1
  8. package/dist/src/channels/contract.js +304 -7
  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 +394 -12
  22. package/dist/src/channels/telegram.js +621 -68
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/channels/web.d.ts +9 -0
  25. package/dist/src/channels/web.js +17 -0
  26. package/dist/src/channels/web.js.map +1 -1
  27. package/dist/src/cli/amend.js +564 -34
  28. package/dist/src/cli/amend.js.map +1 -1
  29. package/dist/src/cli/attest.d.ts +9 -0
  30. package/dist/src/cli/attest.js +177 -13
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/channel-relay.d.ts +108 -0
  33. package/dist/src/cli/channel-relay.js +334 -0
  34. package/dist/src/cli/channel-relay.js.map +1 -0
  35. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  36. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  37. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  38. package/dist/src/cli/channel-telegram.d.ts +347 -28
  39. package/dist/src/cli/channel-telegram.js +763 -28
  40. package/dist/src/cli/channel-telegram.js.map +1 -1
  41. package/dist/src/cli/channel.d.ts +10 -1
  42. package/dist/src/cli/channel.js +14 -1
  43. package/dist/src/cli/channel.js.map +1 -1
  44. package/dist/src/cli/codex-bridge.d.ts +819 -0
  45. package/dist/src/cli/codex-bridge.js +1607 -0
  46. package/dist/src/cli/codex-bridge.js.map +1 -0
  47. package/dist/src/cli/codex.d.ts +1 -1
  48. package/dist/src/cli/codex.js +304 -7
  49. package/dist/src/cli/codex.js.map +1 -1
  50. package/dist/src/cli/daemon.js +41 -6
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +1322 -122
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +17 -1
  56. package/dist/src/cli/execute.js +366 -22
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +14 -2
  65. package/dist/src/cli/help.js +395 -82
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook-codex.d.ts +49 -1
  68. package/dist/src/cli/hook-codex.js +60 -1
  69. package/dist/src/cli/hook-codex.js.map +1 -1
  70. package/dist/src/cli/hook.d.ts +828 -4
  71. package/dist/src/cli/hook.js +2972 -142
  72. package/dist/src/cli/hook.js.map +1 -1
  73. package/dist/src/cli/import.js +1 -1
  74. package/dist/src/cli/import.js.map +1 -1
  75. package/dist/src/cli/init.js +16 -7
  76. package/dist/src/cli/init.js.map +1 -1
  77. package/dist/src/cli/instructions.js +24 -3
  78. package/dist/src/cli/instructions.js.map +1 -1
  79. package/dist/src/cli/log-anchor.d.ts +55 -0
  80. package/dist/src/cli/log-anchor.js +199 -60
  81. package/dist/src/cli/log-anchor.js.map +1 -1
  82. package/dist/src/cli/main.d.ts +0 -12
  83. package/dist/src/cli/main.js +56 -3
  84. package/dist/src/cli/main.js.map +1 -1
  85. package/dist/src/cli/muse.d.ts +2 -0
  86. package/dist/src/cli/muse.js +62 -0
  87. package/dist/src/cli/muse.js.map +1 -0
  88. package/dist/src/cli/payload.js +79 -1
  89. package/dist/src/cli/payload.js.map +1 -1
  90. package/dist/src/cli/policy-apply.d.ts +195 -0
  91. package/dist/src/cli/policy-apply.js +573 -0
  92. package/dist/src/cli/policy-apply.js.map +1 -0
  93. package/dist/src/cli/policy.js +14 -1
  94. package/dist/src/cli/policy.js.map +1 -1
  95. package/dist/src/cli/preflight.d.ts +151 -13
  96. package/dist/src/cli/preflight.js +398 -41
  97. package/dist/src/cli/preflight.js.map +1 -1
  98. package/dist/src/cli/sandbox.js +17 -1
  99. package/dist/src/cli/sandbox.js.map +1 -1
  100. package/dist/src/cli/scaffold.d.ts +1 -1
  101. package/dist/src/cli/scaffold.js +7 -1
  102. package/dist/src/cli/scaffold.js.map +1 -1
  103. package/dist/src/cli/serve.d.ts +53 -0
  104. package/dist/src/cli/serve.js +270 -0
  105. package/dist/src/cli/serve.js.map +1 -0
  106. package/dist/src/cli/setup-channel.d.ts +9 -0
  107. package/dist/src/cli/setup-channel.js +28 -1
  108. package/dist/src/cli/setup-channel.js.map +1 -1
  109. package/dist/src/cli/setup-common.d.ts +3 -1
  110. package/dist/src/cli/setup-common.js +3 -2
  111. package/dist/src/cli/setup-common.js.map +1 -1
  112. package/dist/src/cli/setup.d.ts +2 -0
  113. package/dist/src/cli/setup.js +94 -2
  114. package/dist/src/cli/setup.js.map +1 -1
  115. package/dist/src/cli/up.js +134 -51
  116. package/dist/src/cli/up.js.map +1 -1
  117. package/dist/src/cli/values.js +3 -4
  118. package/dist/src/cli/values.js.map +1 -1
  119. package/dist/src/cli/verb-registry.d.ts +2 -0
  120. package/dist/src/cli/verb-registry.js +510 -47
  121. package/dist/src/cli/verb-registry.js.map +1 -1
  122. package/dist/src/cli/wordmark.d.ts +2 -2
  123. package/dist/src/cli/wordmark.js +2 -2
  124. package/dist/src/codex/broker.d.ts +229 -0
  125. package/dist/src/codex/broker.js +548 -0
  126. package/dist/src/codex/broker.js.map +1 -0
  127. package/dist/src/codex/runner.d.ts +178 -0
  128. package/dist/src/codex/runner.js +231 -0
  129. package/dist/src/codex/runner.js.map +1 -0
  130. package/dist/src/codex/serve.d.ts +56 -0
  131. package/dist/src/codex/serve.js +98 -0
  132. package/dist/src/codex/serve.js.map +1 -0
  133. package/dist/src/codex/workspace-commit.d.ts +219 -0
  134. package/dist/src/codex/workspace-commit.js +549 -0
  135. package/dist/src/codex/workspace-commit.js.map +1 -0
  136. package/dist/src/core/advance-cycle.d.ts +51 -0
  137. package/dist/src/core/advance-cycle.js +66 -2
  138. package/dist/src/core/advance-cycle.js.map +1 -1
  139. package/dist/src/core/agents-md.d.ts +20 -18
  140. package/dist/src/core/agents-md.js +33 -31
  141. package/dist/src/core/agents-md.js.map +1 -1
  142. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  143. package/dist/src/core/attest-bootstrap.js +135 -0
  144. package/dist/src/core/attest-bootstrap.js.map +1 -0
  145. package/dist/src/core/attest.d.ts +234 -2
  146. package/dist/src/core/attest.js +342 -7
  147. package/dist/src/core/attest.js.map +1 -1
  148. package/dist/src/core/audit.d.ts +73 -3
  149. package/dist/src/core/audit.js +65 -5
  150. package/dist/src/core/audit.js.map +1 -1
  151. package/dist/src/core/channel-lease.d.ts +322 -0
  152. package/dist/src/core/channel-lease.js +663 -0
  153. package/dist/src/core/channel-lease.js.map +1 -0
  154. package/dist/src/core/channel-owner.d.ts +213 -0
  155. package/dist/src/core/channel-owner.js +358 -0
  156. package/dist/src/core/channel-owner.js.map +1 -0
  157. package/dist/src/core/command-class.d.ts +205 -0
  158. package/dist/src/core/command-class.js +1433 -28
  159. package/dist/src/core/command-class.js.map +1 -1
  160. package/dist/src/core/commit-guard.d.ts +272 -0
  161. package/dist/src/core/commit-guard.js +424 -0
  162. package/dist/src/core/commit-guard.js.map +1 -0
  163. package/dist/src/core/daemon-actor.d.ts +45 -0
  164. package/dist/src/core/daemon-actor.js +54 -0
  165. package/dist/src/core/daemon-actor.js.map +1 -0
  166. package/dist/src/core/daemon-host.d.ts +178 -0
  167. package/dist/src/core/daemon-host.js +228 -0
  168. package/dist/src/core/daemon-host.js.map +1 -0
  169. package/dist/src/core/daemon-identity.d.ts +238 -0
  170. package/dist/src/core/daemon-identity.js +252 -0
  171. package/dist/src/core/daemon-identity.js.map +1 -0
  172. package/dist/src/core/dark-session.d.ts +109 -8
  173. package/dist/src/core/dark-session.js +266 -82
  174. package/dist/src/core/dark-session.js.map +1 -1
  175. package/dist/src/core/decision-refusal.d.ts +23 -2
  176. package/dist/src/core/decision-refusal.js +24 -2
  177. package/dist/src/core/decision-refusal.js.map +1 -1
  178. package/dist/src/core/env-file.d.ts +5 -0
  179. package/dist/src/core/env-file.js +60 -1
  180. package/dist/src/core/env-file.js.map +1 -1
  181. package/dist/src/core/execute.d.ts +15 -2
  182. package/dist/src/core/execute.js +15 -2
  183. package/dist/src/core/execute.js.map +1 -1
  184. package/dist/src/core/gate.d.ts +392 -6
  185. package/dist/src/core/gate.js +852 -24
  186. package/dist/src/core/gate.js.map +1 -1
  187. package/dist/src/core/gesture-refusal.d.ts +166 -0
  188. package/dist/src/core/gesture-refusal.js +188 -0
  189. package/dist/src/core/gesture-refusal.js.map +1 -0
  190. package/dist/src/core/harness-version.d.ts +128 -4
  191. package/dist/src/core/harness-version.js +181 -12
  192. package/dist/src/core/harness-version.js.map +1 -1
  193. package/dist/src/core/harness-wait.d.ts +82 -3
  194. package/dist/src/core/harness-wait.js +89 -3
  195. package/dist/src/core/harness-wait.js.map +1 -1
  196. package/dist/src/core/instance.d.ts +59 -2
  197. package/dist/src/core/instance.js +113 -0
  198. package/dist/src/core/instance.js.map +1 -1
  199. package/dist/src/core/log-subscribe.d.ts +21 -0
  200. package/dist/src/core/log-subscribe.js +16 -6
  201. package/dist/src/core/log-subscribe.js.map +1 -1
  202. package/dist/src/core/log-write-layer.d.ts +55 -0
  203. package/dist/src/core/log-write-layer.js +91 -0
  204. package/dist/src/core/log-write-layer.js.map +1 -0
  205. package/dist/src/core/log.d.ts +89 -22
  206. package/dist/src/core/log.js +197 -19
  207. package/dist/src/core/log.js.map +1 -1
  208. package/dist/src/core/payload-census.d.ts +51 -0
  209. package/dist/src/core/payload-census.js +125 -2
  210. package/dist/src/core/payload-census.js.map +1 -1
  211. package/dist/src/core/payload-store.d.ts +31 -0
  212. package/dist/src/core/payload-store.js +113 -8
  213. package/dist/src/core/payload-store.js.map +1 -1
  214. package/dist/src/core/payload.d.ts +18 -3
  215. package/dist/src/core/payload.js +20 -4
  216. package/dist/src/core/payload.js.map +1 -1
  217. package/dist/src/core/policy-diff.d.ts +10 -0
  218. package/dist/src/core/policy-diff.js +10 -2
  219. package/dist/src/core/policy-diff.js.map +1 -1
  220. package/dist/src/core/policy-explain.d.ts +17 -1
  221. package/dist/src/core/policy-explain.js +48 -1
  222. package/dist/src/core/policy-explain.js.map +1 -1
  223. package/dist/src/core/policy-load.d.ts +65 -1
  224. package/dist/src/core/policy-load.js +21 -3
  225. package/dist/src/core/policy-load.js.map +1 -1
  226. package/dist/src/core/policy-match.d.ts +81 -0
  227. package/dist/src/core/policy-match.js +87 -0
  228. package/dist/src/core/policy-match.js.map +1 -1
  229. package/dist/src/core/policy-proposal.d.ts +75 -0
  230. package/dist/src/core/policy-proposal.js +110 -4
  231. package/dist/src/core/policy-proposal.js.map +1 -1
  232. package/dist/src/core/protected-path-guard.d.ts +117 -4
  233. package/dist/src/core/protected-path-guard.js +362 -48
  234. package/dist/src/core/protected-path-guard.js.map +1 -1
  235. package/dist/src/core/question-preempted.d.ts +141 -0
  236. package/dist/src/core/question-preempted.js +152 -0
  237. package/dist/src/core/question-preempted.js.map +1 -0
  238. package/dist/src/core/read-scope.d.ts +182 -0
  239. package/dist/src/core/read-scope.js +256 -0
  240. package/dist/src/core/read-scope.js.map +1 -0
  241. package/dist/src/core/run-payload.d.ts +160 -0
  242. package/dist/src/core/run-payload.js +304 -0
  243. package/dist/src/core/run-payload.js.map +1 -0
  244. package/dist/src/core/sandbox.d.ts +81 -0
  245. package/dist/src/core/sandbox.js +190 -1
  246. package/dist/src/core/sandbox.js.map +1 -1
  247. package/dist/src/core/sender-identity.d.ts +501 -0
  248. package/dist/src/core/sender-identity.js +609 -0
  249. package/dist/src/core/sender-identity.js.map +1 -0
  250. package/dist/src/core/shlex.d.ts +102 -0
  251. package/dist/src/core/shlex.js +159 -0
  252. package/dist/src/core/shlex.js.map +1 -0
  253. package/dist/src/core/state.d.ts +52 -1
  254. package/dist/src/core/state.js +52 -6
  255. package/dist/src/core/state.js.map +1 -1
  256. package/dist/src/core/telegram-config.d.ts +79 -0
  257. package/dist/src/core/telegram-config.js +108 -0
  258. package/dist/src/core/telegram-config.js.map +1 -1
  259. package/dist/src/core/values.d.ts +18 -8
  260. package/dist/src/core/values.js +36 -1
  261. package/dist/src/core/values.js.map +1 -1
  262. package/dist/src/core/verify.d.ts +23 -1
  263. package/dist/src/core/verify.js +20 -2
  264. package/dist/src/core/verify.js.map +1 -1
  265. package/dist/src/daemon/advance.d.ts +10 -0
  266. package/dist/src/daemon/advance.js +25 -4
  267. package/dist/src/daemon/advance.js.map +1 -1
  268. package/dist/src/daemon/audit.d.ts +80 -5
  269. package/dist/src/daemon/audit.js +126 -6
  270. package/dist/src/daemon/audit.js.map +1 -1
  271. package/dist/src/daemon/daemon.d.ts +174 -2
  272. package/dist/src/daemon/daemon.js +307 -7
  273. package/dist/src/daemon/daemon.js.map +1 -1
  274. package/dist/src/daemon/git-evidence.d.ts +2 -2
  275. package/dist/src/daemon/git-evidence.js +1 -1
  276. package/dist/src/daemon/projection.d.ts +13 -2
  277. package/dist/src/daemon/projection.js +39 -6
  278. package/dist/src/daemon/projection.js.map +1 -1
  279. package/dist/src/mcp/server.d.ts +39 -3
  280. package/dist/src/mcp/server.js +30 -3
  281. package/dist/src/mcp/server.js.map +1 -1
  282. package/dist/src/muse/server.d.ts +16 -0
  283. package/dist/src/muse/server.js +238 -0
  284. package/dist/src/muse/server.js.map +1 -0
  285. package/dist/src/serve/archive.d.ts +158 -0
  286. package/dist/src/serve/archive.js +354 -0
  287. package/dist/src/serve/archive.js.map +1 -0
  288. package/dist/src/serve/arguments.d.ts +93 -0
  289. package/dist/src/serve/arguments.js +228 -0
  290. package/dist/src/serve/arguments.js.map +1 -0
  291. package/dist/src/serve/credentials.d.ts +91 -0
  292. package/dist/src/serve/credentials.js +143 -0
  293. package/dist/src/serve/credentials.js.map +1 -0
  294. package/dist/src/serve/follow.d.ts +86 -0
  295. package/dist/src/serve/follow.js +101 -0
  296. package/dist/src/serve/follow.js.map +1 -0
  297. package/dist/src/serve/hook-thread.d.ts +134 -0
  298. package/dist/src/serve/hook-thread.js +333 -0
  299. package/dist/src/serve/hook-thread.js.map +1 -0
  300. package/dist/src/serve/hook-worker.d.ts +134 -0
  301. package/dist/src/serve/hook-worker.js +173 -0
  302. package/dist/src/serve/hook-worker.js.map +1 -0
  303. package/dist/src/serve/server.d.ts +412 -0
  304. package/dist/src/serve/server.js +1251 -0
  305. package/dist/src/serve/server.js.map +1 -0
  306. package/docs/README-extended.md +766 -0
  307. package/docs/cli-reference.md +2563 -73
  308. package/docs/codex-enforced-session.md +75 -2
  309. package/docs/codex-workspace-broker.md +118 -0
  310. package/package.json +6 -2
  311. package/schema/event.schema.json +595 -9
  312. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  313. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  314. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  315. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  316. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  317. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  318. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  319. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  320. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  321. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  322. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  323. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  324. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  325. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  326. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  327. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  328. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  329. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  330. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  331. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  332. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  333. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  334. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  335. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  336. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  337. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  338. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  339. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  340. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  341. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  342. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  343. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  344. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  345. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  346. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  347. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  348. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  349. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  350. package/schema/fixtures/policy/valid/canonical.json +1 -1
  351. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  352. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  353. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  354. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  355. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  356. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  357. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  358. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  359. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  360. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  361. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  362. package/schema/fixtures/values/invalid/version-float.json +1 -0
  363. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  364. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  365. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  366. package/schema/fixtures/values/valid/full.json +5 -7
  367. package/schema/fixtures/values/valid/minimal.json +1 -1
  368. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  369. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  370. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  371. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  372. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  373. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  374. package/schema/fixtures/values-md/valid/absent.md +1 -1
  375. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  376. package/schema/policy.schema.json +100 -4
  377. package/schema/values.schema.json +7 -11
  378. 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
  *
@@ -42,6 +58,14 @@
42
58
  * is never written to any envelope and no policy rule should name it.
43
59
  */
44
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";
45
69
  /**
46
70
  * The `policy.edit` sub-class namespace a `protected_paths` entry may route to
47
71
  * (APRV-266).
@@ -240,6 +264,109 @@ export function protectedPathClass(candidate, extra = []) {
240
264
  if (next === "hooks.json" || next === "hooks" || next === "agents")
241
265
  return "policy.core";
242
266
  }
267
+ // Grok Build's equivalent: `.grok/hooks/*.json` is where its PreToolUse
268
+ // entries are installed, and the scripts beside them are what those
269
+ // entries run. Same property as `.cursor/hooks.json` above and for the
270
+ // same reason (APRV-243): an agent that could write those could write
271
+ // itself out of the gate. Grok also READS `.claude/settings.json` and
272
+ // `.cursor/hooks.json` for compatibility, and both are already here.
273
+ if (segment === ".grok") {
274
+ const next = segments[index + 1];
275
+ if (next === "hooks.json" || next === "hooks")
276
+ return "policy.core";
277
+ }
278
+ // Muse Code's equivalent, OBSERVED rather than guessed (APRV-350): the live
279
+ // probe on muse-bin-1.3.0-R3233.1 established that the installed build reads
280
+ // `.muse/hooks.json` in the project, and that it rejects a malformed one
281
+ // loudly at startup. `settings` is listed beside it because Meta documents
282
+ // user-level hooks inside a `settings.json` `hooks` block, so a
283
+ // project-level copy would be the same organ under a second name; it never
284
+ // fired in the probe, and an entry for a file Muse does not read is INERT,
285
+ // while a missing entry for one it does read would be the hole.
286
+ //
287
+ // `worktrees` is deliberately NOT here. Muse keeps its own worktree state
288
+ // under `.muse/worktrees/`, which is ordinary workspace content: making it
289
+ // `policy.core` would price routine session bookkeeping at a human's
290
+ // attention, which is the failure mode §11 asks to avoid.
291
+ if (segment === ".muse") {
292
+ const next = segments[index + 1];
293
+ if (next === "hooks.json" || next === "hooks" || next?.startsWith("settings")) {
294
+ return "policy.core";
295
+ }
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
+ }
243
370
  // Codex installs its hook through these configuration and script paths.
244
371
  if (segment === ".codex") {
245
372
  const next = segments[index + 1];
@@ -383,12 +510,34 @@ const CREDENTIAL_CLASS = "account.credential";
383
510
  * kin), which is the environment map holding the Telegram token, the vault
384
511
  * passphrase and the sampling secret.
385
512
  */
386
- function isCredentialPath(candidate) {
513
+ export function isCredentialPath(candidate) {
387
514
  if (candidate.length === 0)
388
515
  return false;
389
516
  const segments = pathSegments(candidate);
390
517
  for (let index = 0; index < segments.length; index += 1) {
391
- 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")
392
541
  continue;
393
542
  const next = segments[index + 1];
394
543
  if (next === undefined)
@@ -472,6 +621,19 @@ export const NON_SECRET_ENV_NAMES = [
472
621
  "APPROVAL_MD",
473
622
  "APPROVAL_HOME",
474
623
  "APPROVAL_DIR",
624
+ /**
625
+ * Where the per-machine bot-ownership registry lives (APRV-390).
626
+ *
627
+ * A DIRECTORY PATH, and the same kind of thing `APPROVAL_HOME` and
628
+ * `APPROVAL_DIR` already are. It holds no secret and opens nothing: the file
629
+ * it points at carries bot ids, usernames and instance directories, all of
630
+ * which `.approval/env` carries in the open, and nothing reads it to widen a
631
+ * permission. Passed through because a child `approval` verb must resolve the
632
+ * SAME registry as its parent — a child that silently fell back to the
633
+ * platform default would answer "which instance owns this bot?" from a
634
+ * different file than the process that asked it.
635
+ */
636
+ "APPROVAL_STATE_DIR",
475
637
  ];
476
638
  /**
477
639
  * Does this bare variable name name credential material?
@@ -552,6 +714,33 @@ const OPERATOR_CHARS = new Set(["&", "|", ";", "(", ")", "<", ">", "\n"]);
552
714
  * Not understood, on purpose: parameter expansion values. `$VAR` and `${VAR}`
553
715
  * are kept verbatim in the word text, and every rule that reads a path or a
554
716
  * refspec treats a word containing `$` as unknown, which resolves stricter.
717
+ *
718
+ * ## Quoted text is DATA, and that is a contract (APRV-353)
719
+ *
720
+ * The command boundary this tokenizer honours is the shell's own. A quoted
721
+ * argument is ONE word to the shell, so nothing inside it is an operator, a
722
+ * redirection, a segment separator or a command name here either:
723
+ *
724
+ * - single-quoted text is wholly inert, every byte of it, `$` and backtick
725
+ * included;
726
+ * - double-quoted text is inert too, with the two exceptions the shell itself
727
+ * makes — `$(…)` and backticks, which it expands before the command runs and
728
+ * which therefore keep the class they have anywhere else (a substitution is
729
+ * classified recursively, a backtick is `opaque`);
730
+ * - adjacent quoted and unquoted runs concatenate into one word (`'a'"b"c`),
731
+ * and a backslash escape is applied where the shell applies it;
732
+ * - UNQUOTED operators split exactly as they always did, and quoting that does
733
+ * not balance is {@link LexResult} `ok: false` — `unparseable`, a refusal —
734
+ * rather than a guess at what the writer meant.
735
+ *
736
+ * This is stated rather than merely true because the failure it prevents is
737
+ * silent and one-directional. A backlog note that says the word `bash`, carries
738
+ * an angle-bracketed placeholder, a pipe or a semicolon is prose about work; a
739
+ * tokenizer that read it as syntax would refuse an ordinary workspace write and
740
+ * push its author toward rewording the record of what they did, which is the
741
+ * audit cost SPEC.md §11 exists to protect. Every printable ASCII character is
742
+ * covered in both quote styles by `tests/command-class-quoting.test.ts`, so an
743
+ * edit that loses the property fails there rather than in someone's notes.
555
744
  */
556
745
  function lex(command) {
557
746
  const segments = [];
@@ -737,6 +926,19 @@ function lex(command) {
737
926
  flush(command.length);
738
927
  return { ok: true, segments };
739
928
  }
929
+ /**
930
+ * The refusal detail for a backtick the shell would expand inside a
931
+ * double-quoted argument (APRV-353).
932
+ *
933
+ * Same code (`opaque`), same verdict (deny), more use: double quotes are what
934
+ * an author reaches for when the text carries an apostrophe, and a note that
935
+ * quotes a command in backticks is then legal shell that really does run
936
+ * something. The refusal names the spelling that is inert, because a refusal a
937
+ * reader cannot act on costs the same attention as one they can (SPEC.md §11.1:
938
+ * refusals are machine-readable and distinct — the code stays the machine's
939
+ * half, this is the human's).
940
+ */
941
+ const QUOTED_BACKTICK_OPAQUE = "backtick command substitution inside a double-quoted argument, which the shell expands; single quotes make the same text literal";
740
942
  /** Scan a double-quoted string starting at the opening quote. */
741
943
  function readDoubleQuoted(command, start) {
742
944
  let text = "";
@@ -760,7 +962,7 @@ function readDoubleQuoted(command, start) {
760
962
  const close = command.indexOf("`", index + 1);
761
963
  if (close === -1)
762
964
  return null;
763
- opaque = "backtick command substitution";
965
+ opaque = QUOTED_BACKTICK_OPAQUE;
764
966
  index = close;
765
967
  continue;
766
968
  }
@@ -895,7 +1097,45 @@ function isTagRefspec(refspec) {
895
1097
  return isTagRef(refspec);
896
1098
  return isTagRef(refspec.slice(0, colon)) || isTagRef(refspec.slice(colon + 1));
897
1099
  }
898
- /** `git push` — force, release, trunk and branch classes turn on flags and refspecs. */
1100
+ /**
1101
+ * Deleting a remote ref: its own class, never the trunk-push one (APRV-352).
1102
+ *
1103
+ * A `git push` that deletes refs was `vcs.push.main` until now, which reads as
1104
+ * "this reaches the trunk" and in a repository that samples trunk pushes
1105
+ * retrospectively means an irreversible removal proceeds unasked and is looked
1106
+ * at afterwards. It is not the same act. A push adds commits somebody can still
1107
+ * see; a deletion removes the only name an unmerged branch had, and the
1108
+ * reflog that could find it again lives on a server nobody in the session can
1109
+ * reach. The two belong on separate policy lines, and a policy that wants them
1110
+ * on one can still write `vcs.*`.
1111
+ *
1112
+ * Distinct from `vcs.history.rewrite` as well, which guards SHARED history: a
1113
+ * force push moves a ref other people have already built on. That class stays
1114
+ * exactly where it was, above this one, so a force push that also deletes is
1115
+ * still a rewrite.
1116
+ */
1117
+ const REF_DELETE_CLASS = "vcs.ref.delete";
1118
+ /**
1119
+ * The ref a deleting refspec names, or `null` when the refspec deletes nothing.
1120
+ *
1121
+ * `:dst` (empty source) is the deletion git documents; `src:` (empty
1122
+ * destination) is the spelling the classifier has always treated as one too,
1123
+ * and it keeps doing so rather than being narrowed here. The non-empty side is
1124
+ * the name worth showing an approver either way.
1125
+ */
1126
+ function deletedRef(refspec) {
1127
+ const colon = refspec.indexOf(":");
1128
+ if (colon === -1)
1129
+ return null;
1130
+ const source = refspec.slice(0, colon);
1131
+ const destination = refspec.slice(colon + 1);
1132
+ if (destination.length === 0)
1133
+ return source.length === 0 ? refspec : source;
1134
+ if (source.length === 0)
1135
+ return destination;
1136
+ return null;
1137
+ }
1138
+ /** `git push` — force, release, deletion, trunk and branch turn on flags and refspecs. */
899
1139
  function refineGitPush(ctx) {
900
1140
  const args = ctx.args.slice(1);
901
1141
  if (hasFlag(args, ["--force", "-f", "--force-with-lease", "--force-if-includes", "--mirror"])) {
@@ -906,29 +1146,45 @@ function refineGitPush(ctx) {
906
1146
  if (refspecs.some((refspec) => refspec.startsWith("+"))) {
907
1147
  return { class: "vcs.history.rewrite", rule: "git-push-force" };
908
1148
  }
1149
+ // The tag check stays ABOVE the deletion check, deliberately. A tag is the
1150
+ // name a release was published under, and this repository's policy prices
1151
+ // `release.publish` accordingly; deleting one is a release act whichever
1152
+ // spelling removes it. Moving the deletion check up would re-label
1153
+ // `git push origin :refs/tags/v1.2.3` and a bulk form that mixes a tag in,
1154
+ // and APRV-352 asks for a class for branch deletions, not a loosening of the
1155
+ // tag surface.
909
1156
  if (hasFlag(args, ["--tags", "--follow-tags"]) ||
910
1157
  refspecs.some(isTagRefspec) ||
911
1158
  refspecs.some((word, index) => word === "tag" && index + 1 < refspecs.length)) {
912
1159
  return { class: "release.publish", rule: "git-push-tag" };
913
1160
  }
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.
1161
+ // `--delete` / `-d`: every refspec after the remote is a ref being removed.
1162
+ // A `--delete` naming no ref at all is a git error, and it stays in this
1163
+ // class with nothing bound rather than falling through to a push class: an
1164
+ // invocation whose targets cannot be read is the one that least deserves the
1165
+ // looser answer.
916
1166
  if (hasFlag(args, ["--delete", "-d"])) {
917
- return { class: "vcs.push.main", rule: "git-push-delete" };
1167
+ return {
1168
+ class: REF_DELETE_CLASS,
1169
+ rule: "git-ref-delete",
1170
+ ...(refspecs.length === 0 ? {} : { path: refspecs.join(" ") }),
1171
+ };
918
1172
  }
919
1173
  if (refspecs.length === 0) {
920
1174
  return { class: "vcs.push.main", rule: "git-push-implicit" };
921
1175
  }
1176
+ // The colon-refspec spellings, which need no flag: `:refs/heads/x`, `:x`, and
1177
+ // a bulk form mixing several. ONE deleting refspec makes the whole command a
1178
+ // deletion, because the command's effect is the union of its refspecs and the
1179
+ // destructive half is the half a person is being asked about.
1180
+ const deleted = refspecs.map(deletedRef).filter((ref) => ref !== null);
1181
+ if (deleted.length > 0) {
1182
+ return { class: REF_DELETE_CLASS, rule: "git-ref-delete", path: deleted.join(" ") };
1183
+ }
922
1184
  let sawMain = false;
923
1185
  for (const refspec of refspecs) {
924
1186
  const colon = refspec.indexOf(":");
925
1187
  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
1188
  if (isUnknownValue(destination)) {
933
1189
  sawMain = true;
934
1190
  continue;
@@ -955,6 +1211,223 @@ function refineGitPush(ctx) {
955
1211
  * Everything that is not provably scratch keeps the old class.
956
1212
  */
957
1213
  const SCRATCH_DELETE_CLASS = "files.delete.scratch";
1214
+ // ---------------------------------------------------------------------------
1215
+ // Agent harnesses (APRV-354)
1216
+ // ---------------------------------------------------------------------------
1217
+ /**
1218
+ * Launching an agent harness is its own class family, `harness.launch.NAME`.
1219
+ *
1220
+ * Until this, a command whose first word was `codex`, `muse`, `grok`, `claude`
1221
+ * or `cursor-agent` was `unclassified`: denied, which is fail closed and also
1222
+ * blunt. It told an approver nothing, it gave a human no class to grant through
1223
+ * the ordinary manual path, and it meant a lane could not so much as read a
1224
+ * harness version without going around the gate. The family fixes the second
1225
+ * half without touching the first: an unknown class still falls to
1226
+ * `defaults.autonomy`, so a project that adopts nothing is exactly where it was.
1227
+ *
1228
+ * ## The laundering hazard, which is why this is a class and not a table row
1229
+ *
1230
+ * A launched harness runs its own tools OUTSIDE this gate unless that harness's
1231
+ * own adapter is installed and attested. So a grant of `harness.launch.codex`
1232
+ * authorizes the LAUNCH and nothing the launched session then does — every
1233
+ * file it writes, every command it runs and every request it makes happens
1234
+ * where this runtime cannot see it. A policy that made the family autonomous
1235
+ * would have written itself out of the gate in one line, which is why SPEC.md
1236
+ * §7 says the family is never inferred autonomous and why the reference
1237
+ * proposal holds it at `manual`. The blessed Codex entry point is the confined
1238
+ * `approval codex start` (APRV-325.3), which keeps its own class.
1239
+ *
1240
+ * ## Muse, which carries a second hazard
1241
+ *
1242
+ * A Muse Code model id ending `-contributor` trades price for permission to
1243
+ * train on prompts and completions, so running one over real code shares that
1244
+ * code. Carter has ruled that Muse must never run with a Contributor model
1245
+ * selected. The classifier cannot enforce that — the model is a self-reported
1246
+ * argument, and a session may select one in its own UI where no command line
1247
+ * shows it — so it does the one thing a classifier honestly can: it BINDS the
1248
+ * `--model` value where it appears and gives a contributor value its own rule
1249
+ * id, so a prompt and a policy can both see it. This may only ever RAISE
1250
+ * scrutiny. A `-standard` id, or no `--model` at all, returns the ordinary rule
1251
+ * id and changes nothing, because a self-reported field that could lower
1252
+ * scrutiny is a field an agent would learn to write (SPEC.md §11.1 invariant 4).
1253
+ *
1254
+ * ## Where the family's name lives
1255
+ *
1256
+ * {@link HARNESS_LAUNCH_PREFIX} is `core/policy-match.ts`'s, not this file's,
1257
+ * and is imported rather than repeated. Two enforcement paths refuse a launch
1258
+ * that no policy rule names (`harnessLaunchNeedsRule`), and a second spelling of
1259
+ * the prefix is the shape of bug that closes one of those doors and leaves the
1260
+ * other open.
1261
+ */
1262
+ /** The rule id prefix, so a reader can tell a launch row from a probe. */
1263
+ const HARNESS_LAUNCH_RULE_PREFIX = "harness-launch-";
1264
+ /**
1265
+ * Harness binaries, by BASENAME, to the name their class carries.
1266
+ *
1267
+ * Basenames, because that is what {@link classifySegment} derives before any
1268
+ * rule sees a command, and it is what makes `/opt/homebrew/bin/codex`,
1269
+ * `~/.local/bin/muse` and `$HOME/.local/bin/muse` all land here without this
1270
+ * table knowing anything about where a binary lives. A spelling the basename
1271
+ * derivation cannot see through — `$MUSE_BIN`, a wrapper script of another
1272
+ * name — is `unclassified`, which is the answer it had before and the answer it
1273
+ * should keep.
1274
+ *
1275
+ * `cursor-agent` carries the name `cursor` so the class reads
1276
+ * `harness.launch.cursor` beside the `cursor` adapter and the `.cursor/`
1277
+ * protected paths. `gemini` is deliberately absent: APRV-354 names five
1278
+ * harnesses, `gemini update` keeps its `deps.upgrade` row above this one, and a
1279
+ * bare `gemini` stays `unclassified` until somebody makes that its own decision.
1280
+ */
1281
+ const HARNESS_BINS = {
1282
+ codex: "codex",
1283
+ muse: "muse",
1284
+ grok: "grok",
1285
+ claude: "claude",
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",
1292
+ };
1293
+ /**
1294
+ * Package specs a package runner may name, EXACTLY, to the same harness names.
1295
+ *
1296
+ * Exact, and the exactness is the rule: `npx codex-helper` is not a codex
1297
+ * launch, and a substring match that said it was would let any package whose
1298
+ * name happens to contain a harness's take a class it did not earn. A spec this
1299
+ * table does not know keeps whatever class the runner already had.
1300
+ */
1301
+ const HARNESS_PACKAGES = {
1302
+ codex: "codex",
1303
+ "@openai/codex": "codex",
1304
+ claude: "claude",
1305
+ "@anthropic-ai/claude-code": "claude",
1306
+ "cursor-agent": "cursor",
1307
+ muse: "muse",
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",
1316
+ };
1317
+ /**
1318
+ * Argv that starts nothing: a version or help probe.
1319
+ *
1320
+ * A probe prints a string and exits, so it is a read, and reading a harness's
1321
+ * own version is exactly what a session needs to be able to do without a
1322
+ * prompt. `help` counts only as the WHOLE argv: `codex help` prints usage,
1323
+ * while `codex help me refactor this` is a session.
1324
+ */
1325
+ const HARNESS_PROBE_FLAGS = ["--version", "-V", "--help", "-h"];
1326
+ /** The probe's rule id and class. A probe starts no session, so it reads. */
1327
+ const HARNESS_PROBE_RULE = "harness-probe";
1328
+ const HARNESS_PROBE_CLASS = "read.shell";
1329
+ /** The rule id a Muse launch takes when its `--model` names a Contributor model. */
1330
+ const MUSE_CONTRIBUTOR_RULE = "harness-launch-muse-contributor";
1331
+ /**
1332
+ * The suffix that marks a Muse model as training on what it is shown.
1333
+ *
1334
+ * Exported since APRV-350 so the hook adapter's contributor guard and this
1335
+ * classifier's `harness.launch.muse` refinement test the SAME mark. Two
1336
+ * spellings of "which models are unsafe" would drift, and the direction they
1337
+ * drift in is the one where a launch is refused and a tool call is not.
1338
+ */
1339
+ export const CONTRIBUTOR_SUFFIX = "-contributor";
1340
+ /** The `--model` value in either spelling, or `null` when none is written. */
1341
+ function harnessModel(args) {
1342
+ for (let index = 0; index < args.length; index += 1) {
1343
+ const arg = args[index];
1344
+ if (arg === "--model") {
1345
+ const value = args[index + 1];
1346
+ return value === undefined || isFlag(value) ? null : value;
1347
+ }
1348
+ if (arg.startsWith("--model="))
1349
+ return arg.slice("--model=".length);
1350
+ }
1351
+ return null;
1352
+ }
1353
+ /**
1354
+ * The harness a package spec names, version suffix stripped, or `null`.
1355
+ *
1356
+ * `@openai/codex@0.152.1` is the scoped case the split has to get right: the
1357
+ * `@` that opens a scope is not the `@` that opens a version.
1358
+ */
1359
+ function harnessPackage(spec) {
1360
+ const at = spec.startsWith("@") ? spec.indexOf("@", 1) : spec.indexOf("@");
1361
+ const bare = at === -1 ? spec : spec.slice(0, at);
1362
+ return HARNESS_PACKAGES[bare] ?? null;
1363
+ }
1364
+ /**
1365
+ * One harness invocation, given its name and the argv that follows its identity.
1366
+ *
1367
+ * Shared by the direct rows and the package-runner refinement, so a launch
1368
+ * spelled `npx @openai/codex exec` answers exactly as `codex exec` does. The
1369
+ * argv is bound to `path`: the segment's own `text` carries the command as
1370
+ * written, and `path` carries the part the class is ABOUT, which is what lets a
1371
+ * channel name it without re-parsing the line.
1372
+ */
1373
+ function harnessRefinement(name, args) {
1374
+ if (args.length === 1 &&
1375
+ (HARNESS_PROBE_FLAGS.includes(args[0]) || args[0] === "help")) {
1376
+ return { class: HARNESS_PROBE_CLASS, rule: HARNESS_PROBE_RULE };
1377
+ }
1378
+ // Anything else is a session. A probe flag beside other arguments is NOT a
1379
+ // probe here: the classifier cannot know which of the two the binary will
1380
+ // honour, and the stricter reading of an ambiguous harness invocation is the
1381
+ // one that assumes a session started.
1382
+ const model = name === "muse" ? harnessModel(args) : null;
1383
+ const contributor = model !== null && model.toLowerCase().endsWith(CONTRIBUTOR_SUFFIX);
1384
+ return {
1385
+ class: `${HARNESS_LAUNCH_PREFIX}${name}`,
1386
+ rule: contributor ? MUSE_CONTRIBUTOR_RULE : `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
1387
+ ...(args.length === 0 ? {} : { path: args.join(" ") }),
1388
+ };
1389
+ }
1390
+ /** `codex …`, `muse …`, `grok …`, `claude …`, `cursor-agent …`. */
1391
+ function refineHarness(ctx) {
1392
+ const name = HARNESS_BINS[ctx.bin];
1393
+ // Unreachable through the table, which matches on these basenames; a defensive
1394
+ // arm rather than a silent wrong class if a row is ever edited apart from the
1395
+ // table it is generated from.
1396
+ if (name === undefined) {
1397
+ return { opaque: `${ctx.bin} is an agent harness this table cannot name` };
1398
+ }
1399
+ return harnessRefinement(name, ctx.args);
1400
+ }
1401
+ /**
1402
+ * `npx`, `tsx`, `tsc`, … — unchanged, except a package runner naming a harness.
1403
+ *
1404
+ * `npx @openai/codex` starts the same session `codex` starts, and before this
1405
+ * it was `files.write.workspace`: the looser of the two answers, reachable by
1406
+ * typing four extra characters. The refinement is deliberately narrow — only
1407
+ * `npx`, only an EXACT package spec, and everything else returns the row's own
1408
+ * answer byte for byte, which is what keeps this from being a widening of the
1409
+ * workspace-tool row.
1410
+ */
1411
+ function refineWorkspaceTool(ctx) {
1412
+ const unchanged = { class: "files.write.workspace", rule: "workspace-tool" };
1413
+ if (ctx.bin !== "npx")
1414
+ return unchanged;
1415
+ const index = ctx.args.findIndex((arg) => !isFlag(arg));
1416
+ if (index === -1)
1417
+ return unchanged;
1418
+ const name = harnessPackage(ctx.args[index]);
1419
+ if (name === null)
1420
+ return unchanged;
1421
+ return harnessRefinement(name, ctx.args.slice(index + 1));
1422
+ }
1423
+ /** The five generated harness rows, one per binary, sharing one refinement. */
1424
+ const HARNESS_RULES = Object.entries(HARNESS_BINS).map(([bin, name]) => ({
1425
+ id: `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
1426
+ bins: [bin],
1427
+ class: `${HARNESS_LAUNCH_PREFIX}${name}`,
1428
+ emits: [HARNESS_PROBE_CLASS],
1429
+ refine: refineHarness,
1430
+ }));
958
1431
  /**
959
1432
  * Is `candidate` a STRICT descendant of `root`? Both are compared by path
960
1433
  * segment, so `/private/tmpfoo` is not under `/private/tmp` and a root is never
@@ -998,6 +1471,43 @@ function allTargetsAreScratch(targets, roots) {
998
1471
  }
999
1472
  return true;
1000
1473
  }
1474
+ // ===========================================================================
1475
+ // Read scope (read.file.out_of_scope, APRV-347)
1476
+ // ===========================================================================
1477
+ /** The rule id for a read whose ABSOLUTE target sits outside every root. */
1478
+ export const READ_OUT_OF_SCOPE_RULE = "read-out-of-scope";
1479
+ /** The rule id for a read target whose expansion the text cannot show. */
1480
+ export const READ_UNREADABLE_TARGET_RULE = "read-unreadable-path";
1481
+ /**
1482
+ * The first target of this read command that the TEXT places outside every
1483
+ * root, or `null` when nothing here settles it.
1484
+ *
1485
+ * `null` covers three different situations on purpose, and all three are
1486
+ * handed on rather than decided: the binary is not a scoped reader, every
1487
+ * target is provably inside a root, or a target is relative (or carries `..`)
1488
+ * and therefore means nothing without a working directory. The last of those
1489
+ * is the common case, and it is why the hook's second pass exists.
1490
+ */
1491
+ function escapedReadTarget(bin, positionals, args, roots) {
1492
+ const targets = readTargetsOf(bin, positionals, args);
1493
+ if (targets === null)
1494
+ return null;
1495
+ for (const target of targets) {
1496
+ switch (readTargetVerdict(target, roots)) {
1497
+ case "out-of-scope":
1498
+ return {
1499
+ path: target,
1500
+ rule: isUnreadableTarget(target)
1501
+ ? READ_UNREADABLE_TARGET_RULE
1502
+ : READ_OUT_OF_SCOPE_RULE,
1503
+ };
1504
+ case "in-scope":
1505
+ case "needs-disk":
1506
+ break;
1507
+ }
1508
+ }
1509
+ return null;
1510
+ }
1001
1511
  /** `rm` — everything outside the workspace, and every unreadable path, is manual. */
1002
1512
  function refineRm(ctx) {
1003
1513
  const recursive = hasFlag(ctx.args, ["--recursive"]) || hasShortFlag(ctx.args, ["r", "R"]);
@@ -1103,6 +1613,429 @@ function refineSed(ctx) {
1103
1613
  ? { class: "files.write.workspace", rule: "sed-in-place" }
1104
1614
  : { class: "read.shell", rule: "sed-read" };
1105
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
+ }
1106
2039
  /**
1107
2040
  * The methods a fetch may name and still be a read: GET, and HEAD, which is a
1108
2041
  * GET that discards the body. Everything else, including a method the
@@ -1427,12 +2360,28 @@ function isGateEntrypoint(path) {
1427
2360
  * ritual reached the approver's phone as `policy.edit` over a protected path —
1428
2361
  * true, and useless. Classified by name it arrives as what it is.
1429
2362
  *
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.
2363
+ * `policy attest --path` (APRV-338) is the newest member and the one that reads
2364
+ * a FLAG, because the flag is what changes the act. Without it the verb attests
2365
+ * the policy file or a gate organ, which are records about the gate's own
2366
+ * configuration; with it the verb ratifies protected TEXT, and that record is
2367
+ * what resolves SPEC.md's pending-sign-off suffix. An agent able to write one
2368
+ * could ratify its own amendments, so it is classified where the rest of the
2369
+ * gate's ceremonies are: `policy.core`, which this repository's policy holds
2370
+ * human-only, so the hook denies it with `hook-class-human-only` before the
2371
+ * verb's own `actor-not-human` refusal is ever reached. It mints no new class
2372
+ * (§11.1 invariant 9): `policy.core` already exists and is already in the row's
2373
+ * `emits`.
2374
+ *
2375
+ * `positionals` is read rather than `args` for the verb words, so a flag between
2376
+ * them cannot hide the verb: `approval --json log sync` is the same invocation.
2377
+ * `args` is read only where a flag is the act, as it is above.
1432
2378
  */
1433
- function refineApprovalVerb(positionals) {
2379
+ function refineApprovalVerb(positionals, args = []) {
1434
2380
  const verb = positionals[0];
1435
2381
  const sub = positionals[1];
2382
+ if (verb === "policy" && sub === "attest" && hasFlag(args, ["--path"])) {
2383
+ return { class: "policy.core", rule: "approval-policy-signoff" };
2384
+ }
1436
2385
  if (verb === "quickstart") {
1437
2386
  return { class: "policy.core", rule: "approval-quickstart" };
1438
2387
  }
@@ -1454,6 +2403,19 @@ function refineApprovalVerb(positionals) {
1454
2403
  return { class: "policy.core", rule: "approval-log-checkpoint" };
1455
2404
  return null;
1456
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
+ }
1457
2419
  if (verb === "gate") {
1458
2420
  if (sub === "open")
1459
2421
  return { class: "policy.core", rule: "approval-gate-open" };
@@ -1461,6 +2423,22 @@ function refineApprovalVerb(positionals) {
1461
2423
  return { class: "policy.core", rule: "approval-gate-close" };
1462
2424
  return null;
1463
2425
  }
2426
+ // APRV-343. `policy apply` WRITES `APPROVAL.md`, which is the one file in this
2427
+ // repository nothing but a human's own hand may change: an agent that could
2428
+ // run it could widen the policy that governs it and then attest the result
2429
+ // through the amendment the verb goes on to run. Classified where the file
2430
+ // already is (`policy.core`, human-only in the reference policy), so the hook
2431
+ // denies it with `hook-class-human-only`, behind the verb's own
2432
+ // `apply-agent-actor` refusal. It mints no new class (SPEC.md §11.1 invariant
2433
+ // 9): `policy.core` already exists and already covers the policy's machinery.
2434
+ //
2435
+ // The other `policy` subcommands stay pass-through. `check` and `test` read,
2436
+ // `attest` and `amend` refuse a non-human actor in code and collect a human's
2437
+ // tap through a channel when an agent runs them, which is the widening
2438
+ // APRV-109 deliberately made — and neither of them writes the policy file.
2439
+ if (verb === "policy" && sub === "apply") {
2440
+ return { class: "policy.core", rule: "approval-policy-apply" };
2441
+ }
1464
2442
  // APRV-257. `setup checkpoint` MINTS the key `log checkpoint` signs with, so
1465
2443
  // an agent that could run it could mint a key, store it, and vouch for a
1466
2444
  // chain it had just written — the mechanism defeated at its source rather
@@ -1487,7 +2465,7 @@ function refineApprovalVerb(positionals) {
1487
2465
  * two log verbs keeps the pass-through class and the row's own rule id.
1488
2466
  */
1489
2467
  function refineApproval(ctx) {
1490
- return refineApprovalVerb(ctx.positionals) ?? { class: GATE_SELF_CLASS, rule: "approval" };
2468
+ return (refineApprovalVerb(ctx.positionals, ctx.args) ?? { class: GATE_SELF_CLASS, rule: "approval" });
1491
2469
  }
1492
2470
  /**
1493
2471
  * `node` — an inline script is opaque, the gate's own entry point is
@@ -1500,7 +2478,7 @@ function refineNode(ctx) {
1500
2478
  if (script !== undefined && isGateEntrypoint(script)) {
1501
2479
  // `node cli.js log sync` is `approval log sync` spelled the long way, and
1502
2480
  // it must classify identically or the classification is a spelling test.
1503
- return (refineApprovalVerb(ctx.positionals.slice(1)) ?? {
2481
+ return (refineApprovalVerb(ctx.positionals.slice(1), ctx.args) ?? {
1504
2482
  class: GATE_SELF_CLASS,
1505
2483
  rule: "node-approval-cli",
1506
2484
  });
@@ -1524,7 +2502,7 @@ export const COMMAND_RULES = [
1524
2502
  bins: ["git"],
1525
2503
  subs: ["push"],
1526
2504
  class: "vcs.push.main",
1527
- emits: ["vcs.push.branch", "vcs.push.main", "vcs.history.rewrite"],
2505
+ emits: ["vcs.push.branch", "vcs.push.main", "vcs.history.rewrite", "vcs.ref.delete"],
1528
2506
  refine: refineGitPush,
1529
2507
  },
1530
2508
  {
@@ -1536,7 +2514,16 @@ export const COMMAND_RULES = [
1536
2514
  { id: "git-reset", bins: ["git"], subs: ["reset"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitReset },
1537
2515
  { id: "git-commit", bins: ["git"], subs: ["commit"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitCommit },
1538
2516
  { id: "git-branch", bins: ["git"], subs: ["branch"], class: "read.shell", emits: ["vcs.commit.branch"], refine: refineGitBranch },
1539
- { 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
+ },
1540
2527
  { id: "git-clone", bins: ["git"], subs: ["clone"], class: "network.call" },
1541
2528
  {
1542
2529
  id: "git-write",
@@ -1618,7 +2605,30 @@ export const COMMAND_RULES = [
1618
2605
  refine: refineGh,
1619
2606
  },
1620
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 },
1621
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
+ },
1622
2632
  { id: "npm-install", bins: ["npm", "bun"], subs: ["install", "i", "add"], class: "deps.add", emits: ["deps.install"], refine: refineNpmInstall },
1623
2633
  { id: "yarn-add", bins: ["yarn", "pnpm"], subs: ["add"], class: "deps.add" },
1624
2634
  { id: "yarn-install", bins: ["yarn", "pnpm"], subs: ["install"], class: "deps.install" },
@@ -1649,8 +2659,20 @@ export const COMMAND_RULES = [
1649
2659
  // harness unattended. `uca` matches with ANY arguments, `--dry-run` included:
1650
2660
  // the classifier reads text, cannot know which flags the script honours, and
1651
2661
  // the strictest reading of an updater is that it updates.
2662
+ //
2663
+ // APRV-354 answers the other half of that sentence: the launch rows below now
2664
+ // name what a bare `claude` or `codex …` is. This row stays ABOVE them on
2665
+ // purpose, so `codex update` and `claude update` keep `deps.upgrade` — an
2666
+ // upgrade swaps the binary that hosts the hook, which is the stricter of the
2667
+ // two readings and the class they already had.
1652
2668
  { id: "harness-update", bins: ["claude", "codex", "gemini"], subs: ["update"], class: "deps.upgrade" },
1653
2669
  { id: "harness-updater", bins: ["uca"], class: "deps.upgrade" },
2670
+ // -- agent harness launch (APRV-354) --------------------------------------
2671
+ // Generated from {@link HARNESS_BINS}, one row per binary, all sharing
2672
+ // {@link refineHarness}. Below `harness-update` so an upgrade keeps its
2673
+ // class; above the workspace tools so a package-runner spelling is the only
2674
+ // one that has to be refined rather than matched.
2675
+ ...HARNESS_RULES,
1654
2676
  // -- workspace tools -----------------------------------------------------
1655
2677
  // APRV-193: three of the rules below hand control to code the runtime did not
1656
2678
  // author, and they are named in {@link CODE_EXECUTING_RULES}.
@@ -1672,6 +2694,14 @@ export const COMMAND_RULES = [
1672
2694
  id: "workspace-tool",
1673
2695
  bins: ["npx", "tsx", "ts-node", "tsc", "oxlint", "eslint", "prettier", "vitest", "jest", "backlog", "make"],
1674
2696
  class: "files.write.workspace",
2697
+ // APRV-354: only `npx` naming a harness package changes; see
2698
+ // {@link refineWorkspaceTool}, which returns this row's own answer for
2699
+ // every other binary and every other package.
2700
+ emits: [
2701
+ HARNESS_PROBE_CLASS,
2702
+ ...Object.values(HARNESS_PACKAGES).map((name) => `${HARNESS_LAUNCH_PREFIX}${name}`),
2703
+ ],
2704
+ refine: refineWorkspaceTool,
1675
2705
  },
1676
2706
  {
1677
2707
  id: "workspace-write",
@@ -1695,6 +2725,40 @@ export const COMMAND_RULES = [
1695
2725
  emits: ["files.delete.out_of_scope", "files.write.workspace"],
1696
2726
  refine: refineFind,
1697
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
+ },
1698
2762
  // -- network -------------------------------------------------------------
1699
2763
  // The HTTP clients split on their flags; the transports do not. What `ssh`,
1700
2764
  // `rsync` or `nc` will do at the far end is not written in the argv, so there
@@ -1830,9 +2894,17 @@ function refineGh(ctx) {
1830
2894
  * Binaries whose effect lives in a string this classifier will not interpret.
1831
2895
  *
1832
2896
  * 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
2897
+ * the shell's, so these refuse instead. `eval`, `xargs` and the `-e`
2898
+ * interpreters can express anything at all; `sudo` and `env` re-launch
1835
2899
  * something else with different authority.
2900
+ *
2901
+ * ONE NARROW EXCEPTION since APRV-380, and it is a narrowing of this rule
2902
+ * rather than a hole in it: a segment that is EXACTLY a known shell, one
2903
+ * inline-script flag and one script word is classified by the script, through
2904
+ * this same classifier. No second parser is written and the text is not read a
2905
+ * second way. {@link loginShellScript} states the shape and the reasoning, and
2906
+ * everything outside it — including the same shell with a script file, or a
2907
+ * shell nested inside an unwrapped script — is refused here exactly as before.
1836
2908
  */
1837
2909
  const OPAQUE_BINS = {
1838
2910
  bash: "runs a shell script",
@@ -1854,6 +2926,93 @@ const OPAQUE_BINS = {
1854
2926
  timeout: "runs another command under a timer",
1855
2927
  time: "runs another command under a timer",
1856
2928
  };
2929
+ /**
2930
+ * The shells {@link loginShellScript} will unwrap: the six in
2931
+ * {@link OPAQUE_BINS} that run a script.
2932
+ *
2933
+ * The other opaque binaries stay opaque under every shape. `eval`, `xargs` and
2934
+ * the `-e` interpreters build their text from somewhere this file cannot see;
2935
+ * `sudo`, `doas`, `env`, `nohup`, `exec`, `source`, `.`, `watch`, `timeout` and
2936
+ * `time` re-launch something else, with different authority or under a timer,
2937
+ * and what they re-launch is an argv rather than a script.
2938
+ */
2939
+ const UNWRAPPABLE_SHELLS = new Set([
2940
+ "bash",
2941
+ "sh",
2942
+ "zsh",
2943
+ "dash",
2944
+ "ksh",
2945
+ "fish",
2946
+ ]);
2947
+ /**
2948
+ * The inline-script flags a wrapper may carry: `-c`, with any run of `l` and
2949
+ * `i` before it (APRV-380).
2950
+ *
2951
+ * `-lc` is the shape the 2026-09-18 probe recorded on every Codex exec request.
2952
+ * `c` must be LAST, because that is the letter that takes the next word as its
2953
+ * argument, and a combination where it is not last is one whose reading depends
2954
+ * on the shell's own option parser. That is a shape this rule declines to guess
2955
+ * at, so it stays opaque.
2956
+ */
2957
+ const INLINE_SCRIPT_FLAG = /^-[li]*c$/u;
2958
+ /**
2959
+ * The script a LOGIN-SHELL WRAPPER runs, when the segment is exactly one
2960
+ * (APRV-380), or `null`.
2961
+ *
2962
+ * ## Why this is not the second parser the table above refuses
2963
+ *
2964
+ * {@link OPAQUE_BINS} states the position this narrows: a second parser for the
2965
+ * same text is a second answer waiting to disagree with the shell's. The hazard
2966
+ * that names is reading shell text a SECOND WAY. This is not that. When the
2967
+ * argv is exactly `[shell, -lc, script]` and nothing else, the script is the
2968
+ * text a Claude Code `Bash` call hands this classifier directly, and what
2969
+ * happens to it here is what happens to that: the same lexer, the same segment
2970
+ * rules, the same table. No new parser is written, and the text is not read a
2971
+ * second way — it is read the first way, by the only reader there is.
2972
+ *
2973
+ * ## The line, and it is exact
2974
+ *
2975
+ * Three words, no more: a known shell, one inline-script flag, one script. Any
2976
+ * of these keeps the wrapper opaque, because each is a shape whose effect
2977
+ * depends on something the argv alone does not say:
2978
+ *
2979
+ * - extra words (a script FILE, `--`, an option this rule does not model);
2980
+ * - an assignment prefix, which changes the environment the script runs in;
2981
+ * - a redirection on the wrapper, which is the outer shell's and not the
2982
+ * script's;
2983
+ * - a substitution in any of the three words, whose effect happens before the
2984
+ * shell even starts;
2985
+ * - a nested shell inside the script, refused by {@link ClassifierContext} when
2986
+ * the recursion runs (the inner classification unwraps nothing).
2987
+ *
2988
+ * The BINDING does not move. `cli/hook.ts` binds the outer command and argv
2989
+ * exactly as APRV-362 built them; what this changes is only which text is
2990
+ * classified, and the classes are additional evidence about bytes that are
2991
+ * bound elsewhere and unchanged.
2992
+ */
2993
+ export function loginShellScript(segment) {
2994
+ if (segment.opaque !== null)
2995
+ return null;
2996
+ if (segment.redirects.length > 0)
2997
+ return null;
2998
+ if (segment.words.length !== 3)
2999
+ return null;
3000
+ const [shell, flag, script] = segment.words;
3001
+ if (shell.substitutions.length > 0)
3002
+ return null;
3003
+ if (flag.substitutions.length > 0)
3004
+ return null;
3005
+ if (script.substitutions.length > 0)
3006
+ return null;
3007
+ const base = pathSegments(shell.text).slice(-1)[0] ?? shell.text;
3008
+ if (!UNWRAPPABLE_SHELLS.has(base))
3009
+ return null;
3010
+ if (!INLINE_SCRIPT_FLAG.test(flag.text))
3011
+ return null;
3012
+ // An empty script runs nothing, and `classifyCommand` answers `unclassified`
3013
+ // for an empty command. Leaving it wrapped keeps that answer the wrapper's.
3014
+ return script.text.trim().length === 0 ? null : script.text;
3015
+ }
1857
3016
  /** Interpreters that are opaque only when handed inline source. */
1858
3017
  const INLINE_SOURCE_BINS = {
1859
3018
  python: ["-c"],
@@ -1916,6 +3075,70 @@ function protectedRank(surface) {
1916
3075
  const index = PROTECTED_PRECEDENCE.indexOf(surface);
1917
3076
  return index === -1 ? PROTECTED_PRECEDENCE.indexOf("policy.edit") : index;
1918
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
+ }
1919
3142
  /**
1920
3143
  * The strictest protected surface named by these words, with the word itself.
1921
3144
  *
@@ -1926,13 +3149,22 @@ function protectedRank(surface) {
1926
3149
  * did and is what keeps a bare-string policy byte-identical: two routed paths
1927
3150
  * in one segment are two equally consequential surfaces, and the segment's
1928
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.
1929
3159
  */
1930
- function strictestProtected(words, protectedPaths) {
3160
+ function strictestProtected(words, protectedPaths, skipProse = false) {
1931
3161
  let best = null;
1932
3162
  for (const word of words) {
1933
3163
  const surface = protectedPathClass(word, protectedPaths);
1934
3164
  if (surface === null)
1935
3165
  continue;
3166
+ if (skipProse && proseNamingProtectedPath(word, surface, protectedPaths))
3167
+ continue;
1936
3168
  if (best === null || protectedRank(surface) < protectedRank(best.surface)) {
1937
3169
  best = { surface, path: word };
1938
3170
  }
@@ -1962,6 +3194,32 @@ export const CODE_EXECUTING_RULES = [
1962
3194
  "node-script",
1963
3195
  /** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
1964
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",
3209
+ /**
3210
+ * Launching an agent harness, and probing one (APRV-354).
3211
+ *
3212
+ * Every spelling is here, the probe included. A launch hands control to a
3213
+ * whole second agent, which is the most complete form of "code the runtime
3214
+ * did not author"; a probe still executes the same binary. The list is
3215
+ * matched against a segment's RULE, so the generated launch ids and the two
3216
+ * ids a refinement can return on its own — the probe and the Muse
3217
+ * contributor id — all have to be named, or `npx @openai/codex` would have
3218
+ * quietly stopped requiring a sandbox by gaining a better class.
3219
+ */
3220
+ ...HARNESS_RULES.map((rule) => rule.id),
3221
+ HARNESS_PROBE_RULE,
3222
+ MUSE_CONTRIBUTOR_RULE,
1965
3223
  ];
1966
3224
  /**
1967
3225
  * Every class the table can emit, for docs and for the dogfood test.
@@ -1988,8 +3246,45 @@ export const CLASSIFIER_CLASSES = (() => {
1988
3246
  seen.add(CREDENTIAL_CLASS);
1989
3247
  seen.add("files.write.workspace");
1990
3248
  seen.add("read.shell");
3249
+ // APRV-347: emitted from `classifySegment`'s tail rather than from a row of
3250
+ // the table, because it is a refinement of `read.shell` against roots the
3251
+ // CALLER resolved and no binary implies it on its own.
3252
+ seen.add(READ_OUT_OF_SCOPE_CLASS);
1991
3253
  return [...seen].sort();
1992
3254
  })();
3255
+ /**
3256
+ * The class the DAEMON's own cadence advance is gated as (APRV-382).
3257
+ *
3258
+ * The sub-class exists because the policy grammar has no actor condition and
3259
+ * this repository wanted one: an advance publishes records the log already
3260
+ * holds, so the daemon may make it unattended, while the same act from a
3261
+ * session in a worktree or a human terminal stays supervised. Two classes are
3262
+ * how that is written down, and which of them a cycle asks under is decided by
3263
+ * `core/advance-cycle.ts` from the running process, never from an argument.
3264
+ *
3265
+ * NO COMMAND SPELLS IT, on purpose. `approval log advance` classifies
3266
+ * `log.advance` whoever types it, so the looser line is unreachable from a
3267
+ * shell an agent can drive: it is reached only from inside the daemon process,
3268
+ * which an agent cannot become without a `gate.self` command this policy holds
3269
+ * at the manual default.
3270
+ */
3271
+ export const ADVANCE_DAEMON_CLASS = "log.advance.daemon";
3272
+ /**
3273
+ * Classes this RUNTIME emits for its own gated actions, which no command spells.
3274
+ *
3275
+ * Separate from {@link CLASSIFIER_CLASSES}, which is the binary table's own set
3276
+ * and is what `docs/claude-code-hook.md` documents row by row. A class here is
3277
+ * emitted by a runtime cycle that registers and requests it directly — the
3278
+ * daemon's advance is the first — so a policy declaring it is declaring a line
3279
+ * that CAN fire, and the reachability check `core/policy-expectations.ts` runs
3280
+ * at the amendment ceremony must say so. Without this list that ceremony would
3281
+ * refuse the line `unreachable`, which is a true statement about the command
3282
+ * classifier and a false one about the runtime.
3283
+ *
3284
+ * Adding a name here is a claim that some path in this codebase asks the gate
3285
+ * for that class, and widening it is a reviewable diff.
3286
+ */
3287
+ export const RUNTIME_CLASSES = [ADVANCE_DAEMON_CLASS];
1993
3288
  /**
1994
3289
  * Can the classifier emit `actionClass` for a project whose policy carries
1995
3290
  * these `protected_paths`? (APRV-266.)
@@ -2005,10 +3300,17 @@ export const CLASSIFIER_CLASSES = (() => {
2005
3300
  * A routed name is reachable exactly when some entry routes to it. A
2006
3301
  * `policy.edit.spec` rule in a policy whose `protected_paths` routes nothing to
2007
3302
  * it is a line that will never fire, and saying so is the whole point.
3303
+ *
3304
+ * {@link RUNTIME_CLASSES} is the third answer (APRV-382): a class no command
3305
+ * spells and a runtime cycle asks for directly is reachable in every project,
3306
+ * with no policy entry needed, because the path that emits it is in this
3307
+ * codebase rather than in the operator's file.
2008
3308
  */
2009
3309
  export function emittableClass(actionClass, protectedPaths = []) {
2010
3310
  if (CLASSIFIER_CLASSES.includes(actionClass))
2011
3311
  return true;
3312
+ if (RUNTIME_CLASSES.includes(actionClass))
3313
+ return true;
2012
3314
  if (!POLICY_EDIT_SUBCLASS.test(actionClass))
2013
3315
  return false;
2014
3316
  return protectedPaths.some((entry) => parseProtectedEntry(entry)?.routed === actionClass);
@@ -2098,11 +3400,18 @@ function sandboxWrapper(words, start) {
2098
3400
  // can produce (`docs/sandboxed-exec.md`, and APRV-193's Linux follow-up).
2099
3401
  return null;
2100
3402
  }
2101
- /** Find the first table row matching this binary and subcommand. */
2102
- 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) {
2103
3410
  for (const rule of COMMAND_RULES) {
2104
3411
  if (!rule.bins.includes(bin))
2105
3412
  continue;
3413
+ if (rule.probe === true && !isVersionProbe(args))
3414
+ continue;
2106
3415
  if (rule.subs !== undefined) {
2107
3416
  if (sub === null || !rule.subs.includes(sub))
2108
3417
  continue;
@@ -2226,7 +3535,7 @@ function classifySegment(segment, protectedPaths, context) {
2226
3535
  if (credential !== null)
2227
3536
  return { ok: true, ...credential };
2228
3537
  const sub = positionals[0] ?? null;
2229
- const rule = matchRule(basename, sub);
3538
+ const rule = matchRule(basename, sub, args);
2230
3539
  if (rule === null) {
2231
3540
  return {
2232
3541
  ok: false,
@@ -2250,14 +3559,25 @@ function classifySegment(segment, protectedPaths, context) {
2250
3559
  }
2251
3560
  let cls = refined === null ? rule.class : refined.class;
2252
3561
  let ruleId = refined === null ? rule.id : refined.rule;
3562
+ // The value a refinement bound, when one did (APRV-352). Same field and same
3563
+ // meaning as the protected-path binding above: the words the classifier
3564
+ // matched, verbatim, so an approver is told WHICH refs a deletion names
3565
+ // rather than being handed a class and left to re-read the command.
3566
+ const boundPath = refined !== null && "path" in refined ? refined.path : undefined;
2253
3567
  // A protected path anywhere in an effectful segment takes that path's class:
2254
3568
  // the command is editing the gate, whatever else it is doing. Every
2255
3569
  // positional is scanned, source and destination alike, so `cp` stays
2256
3570
  // direction-blind — a copy OUT of the policy directory is as gated as a copy
2257
3571
  // into it, because the classifier cannot tell which argument the binary will
2258
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.
2259
3579
  if (!cls.startsWith("read.") && cls !== GATE_SELF_CLASS) {
2260
- const named = strictestProtected(positionals, protectedPaths);
3580
+ const named = strictestProtected(positionals, protectedPaths, true);
2261
3581
  if (named !== null) {
2262
3582
  return { ok: true, class: named.surface, rule: "protected-path", path: named.path };
2263
3583
  }
@@ -2268,7 +3588,31 @@ function classifySegment(segment, protectedPaths, context) {
2268
3588
  cls = "files.write.workspace";
2269
3589
  ruleId = "redirect-write";
2270
3590
  }
2271
- return { ok: true, class: cls, rule: ruleId, ...(sandbox === null ? {} : { sandbox }) };
3591
+ // APRV-347, last because it is the narrowest: a read whose target the TEXT
3592
+ // places outside every root the caller named. Only `read.shell` is scoped —
3593
+ // `read.web` reaches no file, and a segment that has already taken a
3594
+ // protected, credential or write class is not a read at all. The relative
3595
+ // and symlinked cases are deliberately NOT decided here; they are left at
3596
+ // `read.shell` for the hook's disk pass, which tightens and never loosens.
3597
+ if (cls === "read.shell" && (context.readRoots ?? []).length > 0) {
3598
+ const escaped = escapedReadTarget(basename, positionals, args, context.readRoots ?? []);
3599
+ if (escaped !== null) {
3600
+ return {
3601
+ ok: true,
3602
+ class: READ_OUT_OF_SCOPE_CLASS,
3603
+ rule: escaped.rule,
3604
+ path: escaped.path,
3605
+ ...(sandbox === null ? {} : { sandbox }),
3606
+ };
3607
+ }
3608
+ }
3609
+ return {
3610
+ ok: true,
3611
+ class: cls,
3612
+ rule: ruleId,
3613
+ ...(boundPath === undefined ? {} : { path: boundPath }),
3614
+ ...(sandbox === null ? {} : { sandbox }),
3615
+ };
2272
3616
  }
2273
3617
  /**
2274
3618
  * Classify a shell command line into the classes it would produce.
@@ -2305,6 +3649,30 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
2305
3649
  const segments = [];
2306
3650
  const classes = [];
2307
3651
  for (const segment of lexed.segments) {
3652
+ // APRV-380. A LOGIN-SHELL WRAPPER is classified by the script it runs.
3653
+ // `loginShellScript` states the exact shape and why this is not the second
3654
+ // parser `OPAQUE_BINS` refuses; the inner classification is run with
3655
+ // `unwrapShell: false`, so a shell nested inside the script stays opaque
3656
+ // and the recursion is one level deep by construction.
3657
+ const script = context.unwrapShell === false ? null : loginShellScript(segment);
3658
+ if (script !== null) {
3659
+ const inner = classifyCommand(script, protectedPaths, { ...context, unwrapShell: false });
3660
+ if (!inner.ok) {
3661
+ // The refusal is the INNER one, reported against the inner segment: an
3662
+ // operator told `hook-opaque` for `zsh` learns nothing, and one told
3663
+ // which part of their script could not be read can rewrite it.
3664
+ return inner;
3665
+ }
3666
+ // Spliced rather than collapsed into one segment: a script is a command
3667
+ // line and its parts have their own classes, which is the thing a
3668
+ // one-class answer would lose.
3669
+ for (const found of inner.segments) {
3670
+ segments.push(found);
3671
+ if (!classes.includes(found.class))
3672
+ classes.push(found.class);
3673
+ }
3674
+ continue;
3675
+ }
2308
3676
  const outcome = classifySegment(segment, protectedPaths, context);
2309
3677
  if (!outcome.ok) {
2310
3678
  return { ok: false, code: outcome.code, segment: segment.text, detail: outcome.detail };
@@ -2336,6 +3704,43 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
2336
3704
  * `classifyCommand` answers `unparseable` for. Segments carrying no binary (a
2337
3705
  * bare assignment, a lone redirection) are omitted: they have no verb to show.
2338
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
+ }
2339
3744
  export function commandSegmentWords(command) {
2340
3745
  const lexed = lex(command);
2341
3746
  if (!lexed.ok)