approval-md 0.0.1 → 0.1.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 (522) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +5 -0
  3. package/README.md +909 -4
  4. package/SPEC.md +445 -32
  5. package/cli.js +29 -3
  6. package/dist/src/adapters/agentmail.js +1200 -0
  7. package/dist/src/adapters/agentmail.js.map +1 -0
  8. package/dist/src/adapters/conformance.js +461 -0
  9. package/dist/src/adapters/conformance.js.map +1 -0
  10. package/dist/src/adapters/contract.js +941 -0
  11. package/dist/src/adapters/contract.js.map +1 -0
  12. package/dist/src/adapters/email.js +749 -0
  13. package/dist/src/adapters/email.js.map +1 -0
  14. package/dist/src/adapters/env-passphrase.js +132 -0
  15. package/dist/src/adapters/env-passphrase.js.map +1 -0
  16. package/dist/src/adapters/registry.js +76 -0
  17. package/dist/src/adapters/registry.js.map +1 -0
  18. package/dist/src/adapters/smtp.js +499 -0
  19. package/dist/src/adapters/smtp.js.map +1 -0
  20. package/dist/src/adapters/vault-provider.js +161 -0
  21. package/dist/src/adapters/vault-provider.js.map +1 -0
  22. package/dist/src/channels/batch.js +121 -0
  23. package/dist/src/channels/batch.js.map +1 -0
  24. package/dist/src/channels/cli.js +468 -0
  25. package/dist/src/channels/cli.js.map +1 -0
  26. package/dist/src/channels/conformance.js +445 -0
  27. package/dist/src/channels/conformance.js.map +1 -0
  28. package/dist/src/channels/contract.js +494 -0
  29. package/dist/src/channels/contract.js.map +1 -0
  30. package/dist/src/channels/payload-view.js +43 -0
  31. package/dist/src/channels/payload-view.js.map +1 -0
  32. package/dist/src/channels/render-queue.js +564 -0
  33. package/dist/src/channels/render-queue.js.map +1 -0
  34. package/dist/src/channels/tagging.js +723 -0
  35. package/dist/src/channels/tagging.js.map +1 -0
  36. package/dist/src/channels/telegram.js +3190 -0
  37. package/dist/src/channels/telegram.js.map +1 -0
  38. package/dist/src/channels/web.js +903 -0
  39. package/dist/src/channels/web.js.map +1 -0
  40. package/dist/src/cli/adapter.js +278 -0
  41. package/dist/src/cli/adapter.js.map +1 -0
  42. package/dist/src/cli/amend.js +2171 -0
  43. package/dist/src/cli/amend.js.map +1 -0
  44. package/dist/src/cli/args.js +86 -0
  45. package/dist/src/cli/args.js.map +1 -0
  46. package/dist/src/cli/attest.js +307 -0
  47. package/dist/src/cli/attest.js.map +1 -0
  48. package/dist/src/cli/audit-card.js +201 -0
  49. package/dist/src/cli/audit-card.js.map +1 -0
  50. package/dist/src/cli/audit.js +460 -0
  51. package/dist/src/cli/audit.js.map +1 -0
  52. package/dist/src/cli/channel-telegram.js +2063 -0
  53. package/dist/src/cli/channel-telegram.js.map +1 -0
  54. package/dist/src/cli/channel-web.js +357 -0
  55. package/dist/src/cli/channel-web.js.map +1 -0
  56. package/dist/src/cli/channel.js +438 -0
  57. package/dist/src/cli/channel.js.map +1 -0
  58. package/dist/src/cli/checkpoint-tap.js +238 -0
  59. package/dist/src/cli/checkpoint-tap.js.map +1 -0
  60. package/dist/src/cli/coverage.js +343 -0
  61. package/dist/src/cli/coverage.js.map +1 -0
  62. package/dist/src/cli/daemon.js +631 -0
  63. package/dist/src/cli/daemon.js.map +1 -0
  64. package/dist/src/cli/doctor.js +2648 -0
  65. package/dist/src/cli/doctor.js.map +1 -0
  66. package/dist/src/cli/env.js +302 -0
  67. package/dist/src/cli/env.js.map +1 -0
  68. package/dist/src/cli/execute.js +1682 -0
  69. package/dist/src/cli/execute.js.map +1 -0
  70. package/dist/src/cli/exit-codes.js +82 -0
  71. package/dist/src/cli/exit-codes.js.map +1 -0
  72. package/dist/src/cli/feedback.js +205 -0
  73. package/dist/src/cli/feedback.js.map +1 -0
  74. package/dist/src/cli/gate-window.js +294 -0
  75. package/dist/src/cli/gate-window.js.map +1 -0
  76. package/dist/src/cli/gate.js +557 -0
  77. package/dist/src/cli/gate.js.map +1 -0
  78. package/dist/src/cli/git-scope.js +295 -0
  79. package/dist/src/cli/git-scope.js.map +1 -0
  80. package/dist/src/cli/gloss-attach.js +107 -0
  81. package/dist/src/cli/gloss-attach.js.map +1 -0
  82. package/dist/src/cli/gloss-codex-child.js +149 -0
  83. package/dist/src/cli/gloss-codex-child.js.map +1 -0
  84. package/dist/src/cli/gloss-codex.js +255 -0
  85. package/dist/src/cli/gloss-codex.js.map +1 -0
  86. package/dist/src/cli/gloss-options.js +79 -0
  87. package/dist/src/cli/gloss-options.js.map +1 -0
  88. package/dist/src/cli/gloss.js +362 -0
  89. package/dist/src/cli/gloss.js.map +1 -0
  90. package/dist/src/cli/help.js +2217 -0
  91. package/dist/src/cli/help.js.map +1 -0
  92. package/dist/src/cli/hook.js +2743 -0
  93. package/dist/src/cli/hook.js.map +1 -0
  94. package/dist/src/cli/import.js +175 -0
  95. package/dist/src/cli/import.js.map +1 -0
  96. package/dist/src/cli/init.js +336 -0
  97. package/dist/src/cli/init.js.map +1 -0
  98. package/dist/src/cli/instructions.js +262 -0
  99. package/dist/src/cli/instructions.js.map +1 -0
  100. package/dist/src/cli/journal.js +238 -0
  101. package/dist/src/cli/journal.js.map +1 -0
  102. package/dist/src/cli/log-advance.js +749 -0
  103. package/dist/src/cli/log-advance.js.map +1 -0
  104. package/dist/src/cli/log-anchor.js +387 -0
  105. package/dist/src/cli/log-anchor.js.map +1 -0
  106. package/dist/src/cli/log-checkpoint.js +128 -0
  107. package/dist/src/cli/log-checkpoint.js.map +1 -0
  108. package/dist/src/cli/log-sync.js +849 -0
  109. package/dist/src/cli/log-sync.js.map +1 -0
  110. package/dist/src/cli/log-verbs.js +354 -0
  111. package/dist/src/cli/log-verbs.js.map +1 -0
  112. package/dist/src/cli/long-help.js +148 -0
  113. package/dist/src/cli/long-help.js.map +1 -0
  114. package/dist/src/cli/main.js +1056 -0
  115. package/dist/src/cli/main.js.map +1 -0
  116. package/dist/src/cli/mcp.js +306 -0
  117. package/dist/src/cli/mcp.js.map +1 -0
  118. package/dist/src/cli/paths.js +79 -0
  119. package/dist/src/cli/paths.js.map +1 -0
  120. package/dist/src/cli/payload.js +253 -0
  121. package/dist/src/cli/payload.js.map +1 -0
  122. package/dist/src/cli/policy.js +229 -0
  123. package/dist/src/cli/policy.js.map +1 -0
  124. package/dist/src/cli/preflight.js +888 -0
  125. package/dist/src/cli/preflight.js.map +1 -0
  126. package/dist/src/cli/progress.js +112 -0
  127. package/dist/src/cli/progress.js.map +1 -0
  128. package/dist/src/cli/prompt.js +312 -0
  129. package/dist/src/cli/prompt.js.map +1 -0
  130. package/dist/src/cli/records.js +66 -0
  131. package/dist/src/cli/records.js.map +1 -0
  132. package/dist/src/cli/render.js +132 -0
  133. package/dist/src/cli/render.js.map +1 -0
  134. package/dist/src/cli/sandbox.js +150 -0
  135. package/dist/src/cli/sandbox.js.map +1 -0
  136. package/dist/src/cli/scaffold.js +137 -0
  137. package/dist/src/cli/scaffold.js.map +1 -0
  138. package/dist/src/cli/setup-adapter.js +475 -0
  139. package/dist/src/cli/setup-adapter.js.map +1 -0
  140. package/dist/src/cli/setup-channel.js +635 -0
  141. package/dist/src/cli/setup-channel.js.map +1 -0
  142. package/dist/src/cli/setup-checkpoint.js +196 -0
  143. package/dist/src/cli/setup-checkpoint.js.map +1 -0
  144. package/dist/src/cli/setup-common.js +376 -0
  145. package/dist/src/cli/setup-common.js.map +1 -0
  146. package/dist/src/cli/setup-flow.js +476 -0
  147. package/dist/src/cli/setup-flow.js.map +1 -0
  148. package/dist/src/cli/setup-service.js +308 -0
  149. package/dist/src/cli/setup-service.js.map +1 -0
  150. package/dist/src/cli/setup.js +473 -0
  151. package/dist/src/cli/setup.js.map +1 -0
  152. package/dist/src/cli/style.js +469 -0
  153. package/dist/src/cli/style.js.map +1 -0
  154. package/dist/src/cli/token.js +274 -0
  155. package/dist/src/cli/token.js.map +1 -0
  156. package/dist/src/cli/up.js +847 -0
  157. package/dist/src/cli/up.js.map +1 -0
  158. package/dist/src/cli/usage.js +91 -0
  159. package/dist/src/cli/usage.js.map +1 -0
  160. package/dist/src/cli/values.js +189 -0
  161. package/dist/src/cli/values.js.map +1 -0
  162. package/dist/src/cli/vault.js +362 -0
  163. package/dist/src/cli/vault.js.map +1 -0
  164. package/dist/src/cli/verb-registry.js +2173 -0
  165. package/dist/src/cli/verb-registry.js.map +1 -0
  166. package/dist/src/cli/wordmark.js +52 -0
  167. package/dist/src/cli/wordmark.js.map +1 -0
  168. package/dist/src/core/advance-cycle.js +200 -0
  169. package/dist/src/core/advance-cycle.js.map +1 -0
  170. package/dist/src/core/agents-md.js +747 -0
  171. package/dist/src/core/agents-md.js.map +1 -0
  172. package/dist/src/core/attest.js +577 -0
  173. package/dist/src/core/attest.js.map +1 -0
  174. package/dist/src/core/audit.js +882 -0
  175. package/dist/src/core/audit.js.map +1 -0
  176. package/dist/src/core/budgets.js +449 -0
  177. package/dist/src/core/budgets.js.map +1 -0
  178. package/dist/src/core/checkpoint.js +738 -0
  179. package/dist/src/core/checkpoint.js.map +1 -0
  180. package/dist/src/core/child-env.js +86 -0
  181. package/dist/src/core/child-env.js.map +1 -0
  182. package/dist/src/core/clock.js +43 -0
  183. package/dist/src/core/clock.js.map +1 -0
  184. package/dist/src/core/command-class.js +2321 -0
  185. package/dist/src/core/command-class.js.map +1 -0
  186. package/dist/src/core/coverage-sources/adapter.js +71 -0
  187. package/dist/src/core/coverage-sources/adapter.js.map +1 -0
  188. package/dist/src/core/coverage-sources/gh.js +136 -0
  189. package/dist/src/core/coverage-sources/gh.js.map +1 -0
  190. package/dist/src/core/coverage-sources/git.js +269 -0
  191. package/dist/src/core/coverage-sources/git.js.map +1 -0
  192. package/dist/src/core/coverage.js +337 -0
  193. package/dist/src/core/coverage.js.map +1 -0
  194. package/dist/src/core/credential-spec.js +23 -0
  195. package/dist/src/core/credential-spec.js.map +1 -0
  196. package/dist/src/core/dark-session.js +714 -0
  197. package/dist/src/core/dark-session.js.map +1 -0
  198. package/dist/src/core/decision-refusal.js +265 -0
  199. package/dist/src/core/decision-refusal.js.map +1 -0
  200. package/dist/src/core/env-file.js +837 -0
  201. package/dist/src/core/env-file.js.map +1 -0
  202. package/dist/src/core/execute.js +1233 -0
  203. package/dist/src/core/execute.js.map +1 -0
  204. package/dist/src/core/frontmatter.js +100 -0
  205. package/dist/src/core/frontmatter.js.map +1 -0
  206. package/dist/src/core/gate-window.js +506 -0
  207. package/dist/src/core/gate-window.js.map +1 -0
  208. package/dist/src/core/gate.js +2947 -0
  209. package/dist/src/core/gate.js.map +1 -0
  210. package/dist/src/core/git-run.js +93 -0
  211. package/dist/src/core/git-run.js.map +1 -0
  212. package/dist/src/core/harness-version.js +210 -0
  213. package/dist/src/core/harness-version.js.map +1 -0
  214. package/dist/src/core/harness-wait.js +58 -0
  215. package/dist/src/core/harness-wait.js.map +1 -0
  216. package/dist/src/core/head-retry.js +121 -0
  217. package/dist/src/core/head-retry.js.map +1 -0
  218. package/dist/src/core/instance.js +319 -0
  219. package/dist/src/core/instance.js.map +1 -0
  220. package/dist/src/core/intake-limits.js +350 -0
  221. package/dist/src/core/intake-limits.js.map +1 -0
  222. package/dist/src/core/jcs.js +132 -0
  223. package/dist/src/core/jcs.js.map +1 -0
  224. package/dist/src/core/journal.js +200 -0
  225. package/dist/src/core/journal.js.map +1 -0
  226. package/dist/src/core/live-draw.js +703 -0
  227. package/dist/src/core/live-draw.js.map +1 -0
  228. package/dist/src/core/log-reconcile.js +136 -0
  229. package/dist/src/core/log-reconcile.js.map +1 -0
  230. package/dist/src/core/log.js +546 -0
  231. package/dist/src/core/log.js.map +1 -0
  232. package/dist/src/core/loop.js +476 -0
  233. package/dist/src/core/loop.js.map +1 -0
  234. package/dist/src/core/md-fence.js +74 -0
  235. package/dist/src/core/md-fence.js.map +1 -0
  236. package/dist/src/core/money.js +195 -0
  237. package/dist/src/core/money.js.map +1 -0
  238. package/dist/src/core/payload-census.js +146 -0
  239. package/dist/src/core/payload-census.js.map +1 -0
  240. package/dist/src/core/payload-store.js +340 -0
  241. package/dist/src/core/payload-store.js.map +1 -0
  242. package/dist/src/core/payload.js +80 -0
  243. package/dist/src/core/payload.js.map +1 -0
  244. package/dist/src/core/policy-diff.js +565 -0
  245. package/dist/src/core/policy-diff.js.map +1 -0
  246. package/dist/src/core/policy-expectations.js +394 -0
  247. package/dist/src/core/policy-expectations.js.map +1 -0
  248. package/dist/src/core/policy-explain.js +230 -0
  249. package/dist/src/core/policy-explain.js.map +1 -0
  250. package/dist/src/core/policy-load.js +524 -0
  251. package/dist/src/core/policy-load.js.map +1 -0
  252. package/dist/src/core/policy-match.js +467 -0
  253. package/dist/src/core/policy-match.js.map +1 -0
  254. package/dist/src/core/policy-proposal.js +458 -0
  255. package/dist/src/core/policy-proposal.js.map +1 -0
  256. package/dist/src/core/prompt-layout.js +422 -0
  257. package/dist/src/core/prompt-layout.js.map +1 -0
  258. package/dist/src/core/protected-path-guard.js +1087 -0
  259. package/dist/src/core/protected-path-guard.js.map +1 -0
  260. package/dist/src/core/registration.js +39 -0
  261. package/dist/src/core/registration.js.map +1 -0
  262. package/dist/src/core/reindex.js +336 -0
  263. package/dist/src/core/reindex.js.map +1 -0
  264. package/dist/src/core/sampler.js +388 -0
  265. package/dist/src/core/sampler.js.map +1 -0
  266. package/dist/src/core/sandbox.js +424 -0
  267. package/dist/src/core/sandbox.js.map +1 -0
  268. package/dist/src/core/seal.js +290 -0
  269. package/dist/src/core/seal.js.map +1 -0
  270. package/dist/src/core/state.js +1009 -0
  271. package/dist/src/core/state.js.map +1 -0
  272. package/dist/src/core/task-file.js +464 -0
  273. package/dist/src/core/task-file.js.map +1 -0
  274. package/dist/src/core/telegram-config.js +114 -0
  275. package/dist/src/core/telegram-config.js.map +1 -0
  276. package/dist/src/core/token.js +578 -0
  277. package/dist/src/core/token.js.map +1 -0
  278. package/dist/src/core/validate.js +0 -0
  279. package/dist/src/core/validate.js.map +1 -0
  280. package/dist/src/core/values.js +153 -0
  281. package/dist/src/core/values.js.map +1 -0
  282. package/dist/src/core/vault.js +612 -0
  283. package/dist/src/core/vault.js.map +1 -0
  284. package/dist/src/core/verified-snapshot.js +506 -0
  285. package/dist/src/core/verified-snapshot.js.map +1 -0
  286. package/dist/src/core/verify.js +549 -0
  287. package/dist/src/core/verify.js.map +1 -0
  288. package/dist/src/core/version.js +9 -0
  289. package/dist/src/core/version.js.map +1 -0
  290. package/dist/src/core/wysiwys.js +728 -0
  291. package/dist/src/core/wysiwys.js.map +1 -0
  292. package/dist/src/daemon/advance-child.js +78 -0
  293. package/dist/src/daemon/advance-child.js.map +1 -0
  294. package/dist/src/daemon/advance.js +849 -0
  295. package/dist/src/daemon/advance.js.map +1 -0
  296. package/dist/src/daemon/audit.js +90 -0
  297. package/dist/src/daemon/audit.js.map +1 -0
  298. package/dist/src/daemon/daemon.js +1988 -0
  299. package/dist/src/daemon/daemon.js.map +1 -0
  300. package/dist/src/daemon/dark-session.js +119 -0
  301. package/dist/src/daemon/dark-session.js.map +1 -0
  302. package/dist/src/daemon/draw-child.js +132 -0
  303. package/dist/src/daemon/draw-child.js.map +1 -0
  304. package/dist/src/daemon/draw.js +458 -0
  305. package/dist/src/daemon/draw.js.map +1 -0
  306. package/dist/src/daemon/git-evidence.js +345 -0
  307. package/dist/src/daemon/git-evidence.js.map +1 -0
  308. package/dist/src/daemon/projection.js +233 -0
  309. package/dist/src/daemon/projection.js.map +1 -0
  310. package/dist/src/daemon/prune.js +376 -0
  311. package/dist/src/daemon/prune.js.map +1 -0
  312. package/dist/src/mcp/http.js +343 -0
  313. package/dist/src/mcp/http.js.map +1 -0
  314. package/dist/src/mcp/server.js +594 -0
  315. package/dist/src/mcp/server.js.map +1 -0
  316. package/docs/cli-reference.md +5363 -0
  317. package/package.json +43 -4
  318. package/schema/.gitkeep +0 -0
  319. package/schema/LICENSE +117 -0
  320. package/schema/envelope.schema.json +137 -0
  321. package/schema/event.schema.json +1810 -0
  322. package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
  323. package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
  324. package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
  325. package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
  326. package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
  327. package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
  328. package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
  329. package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
  330. package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
  331. package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
  332. package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
  333. package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
  334. package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
  335. package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
  336. package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
  337. package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
  338. package/schema/fixtures/envelope/valid/canonical.json +25 -0
  339. package/schema/fixtures/envelope/valid/minimal.json +7 -0
  340. package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
  341. package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
  342. package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
  343. package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
  344. package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
  345. package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
  346. package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
  347. package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
  348. package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
  349. package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
  350. package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
  351. package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
  352. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
  353. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
  354. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
  355. package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
  356. package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
  357. package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
  358. package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
  359. package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
  360. package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
  361. package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
  362. package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
  363. package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
  364. package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
  365. package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
  366. package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
  367. package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
  368. package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
  369. package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
  370. package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
  371. package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
  372. package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
  373. package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
  374. package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
  375. package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
  376. package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
  377. package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
  378. package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
  379. package/schema/fixtures/event/invalid/missing-alg.json +14 -0
  380. package/schema/fixtures/event/invalid/missing-hash.json +14 -0
  381. package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
  382. package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
  383. package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
  384. package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
  385. package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
  386. package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
  387. package/schema/fixtures/event/invalid/short-hash.json +15 -0
  388. package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
  389. package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
  390. package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
  391. package/schema/fixtures/event/valid/approval-expired.json +15 -0
  392. package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
  393. package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
  394. package/schema/fixtures/event/valid/approval-granted.json +15 -0
  395. package/schema/fixtures/event/valid/approval-rejected.json +15 -0
  396. package/schema/fixtures/event/valid/approval-requested.json +19 -0
  397. package/schema/fixtures/event/valid/approval-revoked.json +15 -0
  398. package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
  399. package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
  400. package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
  401. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
  402. package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
  403. package/schema/fixtures/event/valid/audit-sampled.json +14 -0
  404. package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
  405. package/schema/fixtures/event/valid/envelope-drift.json +16 -0
  406. package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
  407. package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
  408. package/schema/fixtures/event/valid/execution-completed.json +15 -0
  409. package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
  410. package/schema/fixtures/event/valid/execution-failed.json +15 -0
  411. package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
  412. package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
  413. package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
  414. package/schema/fixtures/event/valid/execution-started.json +14 -0
  415. package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
  416. package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
  417. package/schema/fixtures/event/valid/gate-closed.json +14 -0
  418. package/schema/fixtures/event/valid/gate-opened.json +16 -0
  419. package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
  420. package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
  421. package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
  422. package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
  423. package/schema/fixtures/event/valid/payload-pruned.json +17 -0
  424. package/schema/fixtures/event/valid/policy-declined.json +16 -0
  425. package/schema/fixtures/event/valid/policy-proposed.json +35 -0
  426. package/schema/fixtures/event/valid/policy-updated.json +14 -0
  427. package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
  428. package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
  429. package/schema/fixtures/event/valid/route-accepted.json +15 -0
  430. package/schema/fixtures/event/valid/route-proposed.json +16 -0
  431. package/schema/fixtures/event/valid/spec-example.json +15 -0
  432. package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
  433. package/schema/fixtures/event/valid/task-registered.json +14 -0
  434. package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
  435. package/schema/fixtures/hash/known-answer.json +74 -0
  436. package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
  437. package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
  438. package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
  439. package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
  440. package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
  441. package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
  442. package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
  443. package/schema/fixtures/policy/invalid/missing-version.json +8 -0
  444. package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
  445. package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
  446. package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
  447. package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
  448. package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
  449. package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
  450. package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
  451. package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
  452. package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
  453. package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
  454. package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
  455. package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
  456. package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
  457. package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
  458. package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
  459. package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
  460. package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
  461. package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
  462. package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
  463. package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
  464. package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
  465. package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
  466. package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
  467. package/schema/fixtures/policy/valid/canonical.json +47 -0
  468. package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
  469. package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
  470. package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
  471. package/schema/fixtures/policy/valid/global-budgets.json +19 -0
  472. package/schema/fixtures/policy/valid/human-only.json +9 -0
  473. package/schema/fixtures/policy/valid/minimal.json +6 -0
  474. package/schema/fixtures/policy/valid/protected-paths.json +10 -0
  475. package/schema/fixtures/policy/valid/record-namespace.json +13 -0
  476. package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
  477. package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
  478. package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
  479. package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
  480. package/schema/fixtures/policy/valid/wildcards.json +15 -0
  481. package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
  482. package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
  483. package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
  484. package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
  485. package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
  486. package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
  487. package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
  488. package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
  489. package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
  490. package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
  491. package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
  492. package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
  493. package/schema/fixtures/policy-md/valid/canonical.md +50 -0
  494. package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
  495. package/schema/fixtures/policy-md/valid/minimal.md +3 -0
  496. package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
  497. package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
  498. package/schema/fixtures/policy-md/valid/with-values.md +79 -0
  499. package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
  500. package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
  501. package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
  502. package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
  503. package/schema/fixtures/sample-record/valid/minimal.json +4 -0
  504. package/schema/fixtures/sample-record/valid/with-note.json +5 -0
  505. package/schema/fixtures/values/invalid/class-shaped.json +9 -0
  506. package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
  507. package/schema/fixtures/values/invalid/non-string-item.json +4 -0
  508. package/schema/fixtures/values/invalid/over-cap.json +26 -0
  509. package/schema/fixtures/values/invalid/unknown-key.json +5 -0
  510. package/schema/fixtures/values/invalid/version-string.json +1 -0
  511. package/schema/fixtures/values/valid/empty-lists.json +7 -0
  512. package/schema/fixtures/values/valid/full.json +20 -0
  513. package/schema/fixtures/values/valid/minimal.json +1 -0
  514. package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
  515. package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
  516. package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
  517. package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
  518. package/schema/fixtures/values-md/valid/absent.md +50 -0
  519. package/schema/fixtures/values-md/valid/with-values.md +79 -0
  520. package/schema/policy.schema.json +481 -0
  521. package/schema/sample-record.schema.json +26 -0
  522. package/schema/values.schema.json +55 -0
@@ -0,0 +1,2321 @@
1
+ /**
2
+ * Shell command classification: a pure, deterministic map from a command line
3
+ * to the SPEC.md §7 side-effect classes it would produce (APRV-82).
4
+ *
5
+ * This is the input half of the Claude Code PreToolUse hook. The harness hands
6
+ * us a command string it is about to run; policy speaks in classes; something
7
+ * has to translate. That translation is the reviewable artifact of this task, so
8
+ * it lives in one file, is data-driven, and is exhaustively fixture-tested.
9
+ *
10
+ * Three properties are load-bearing.
11
+ *
12
+ * **Pure.** No filesystem, no clock, no environment, no network. The same string
13
+ * always yields the same answer, which is what makes the fixture table a real
14
+ * specification rather than a sample of observed behaviour.
15
+ *
16
+ * **Fail closed, in three named ways.** A construct whose effect cannot be read
17
+ * off the text is `opaque` (`bash -c`, `eval`, backticks, a tainting command
18
+ * substitution). A binary the table does not know is `unclassified`. A string
19
+ * the tokenizer cannot even split is `unparseable`. All three are refusals, and
20
+ * the hook denies on each: the alternative, guessing a class for a command we do
21
+ * not understand, would let the least legible command lines through the widest
22
+ * gap. Ambiguity inside a construct we DO understand resolves to the stricter
23
+ * class (`git push` with no refspec is `vcs.push.main`, an `rm` path holding an
24
+ * unexpanded `$VAR` is `files.delete.out_of_scope`).
25
+ *
26
+ * **Best effort, and honest about it.** This is a classifier over shell text,
27
+ * not a shell. It is a speed bump against an agent doing the wrong thing by
28
+ * mistake, in the SPEC.md §11 sense; it is not scheming-robust, and nothing here
29
+ * claims otherwise. The hard boundary stays where SPEC.md §10.4 puts it: the
30
+ * credentials in the vault and the token an adapter demands.
31
+ *
32
+ * Self-reported text is never read. The hook passes the command only; the
33
+ * harness's `description` field is authored by the very agent being gated
34
+ * (SPEC.md §11.1: self-reported fields never reduce scrutiny).
35
+ */
36
+ /**
37
+ * The pass-through pseudo-class for the gate's own CLI.
38
+ *
39
+ * `approval …` is already the enforcement path — gating it with itself would
40
+ * either deadlock (the hook waiting on a decision that `approval grant` cannot
41
+ * deliver) or recurse. The hook allows this class without touching the log; it
42
+ * is never written to any envelope and no policy rule should name it.
43
+ */
44
+ export const GATE_SELF_CLASS = "gate.self";
45
+ /**
46
+ * The `policy.edit` sub-class namespace a `protected_paths` entry may route to
47
+ * (APRV-266).
48
+ *
49
+ * One protected surface with one autonomy was the shape until now, so a policy
50
+ * that wanted its specification sampled at one tenth and its release workflows
51
+ * gated every time had to choose one of those numbers for both. A routed entry
52
+ * says which sub-class a path family takes, and the sub-class is an ordinary
53
+ * §7 class with an ordinary policy line, so each family gets its own autonomy
54
+ * and its own live rate without any new grammar in `classes`.
55
+ *
56
+ * The namespace is closed to ONE extra segment under `policy.edit` and nothing
57
+ * else. A policy may not route a path to `policy.core`, to `log.mutate`, or to
58
+ * any class outside this namespace: those are the gate's own organs and the
59
+ * record of what happened, and §11.1 invariant 9 reserves them — a policy
60
+ * widening its own protected surface is naming prose and configuration, and
61
+ * mints no authority over anything else. `policy.schema.json` enforces the
62
+ * shape; this pattern is the same rule where the matcher can see it.
63
+ */
64
+ export const POLICY_EDIT_SUBCLASS = /^policy\.edit\.[a-z][a-z0-9-]*$/u;
65
+ /**
66
+ * Sub-class names this spec reserves, with the meaning an implementation must
67
+ * give them (APRV-266).
68
+ *
69
+ * Reserved so that two policies written by two people mean the same thing by
70
+ * `policy.edit.ci`, and so a reader of somebody else's policy does not have to
71
+ * infer it. An author is free to mint their own word beside these — the pattern
72
+ * above admits any lowercase word — and a minted name carries only the meaning
73
+ * its own policy line gives it.
74
+ */
75
+ export const RESERVED_POLICY_EDIT_SUBCLASSES = {
76
+ "policy.edit.spec": "the project's governing specification and its amendments",
77
+ "policy.edit.harness": "agent instruction files and harness configuration that is not the hook itself",
78
+ "policy.edit.ci": "continuous-integration and release configuration",
79
+ "policy.edit.design": "design documents and decision records",
80
+ };
81
+ /**
82
+ * Files whose edit is `policy.core` wherever they sit: the policy itself,
83
+ * under either spelling.
84
+ */
85
+ const CORE_FILENAMES = ["APPROVAL.md", "APPROVALS.md"];
86
+ /**
87
+ * Files whose edit is `policy.edit` wherever they sit: the agent instructions
88
+ * that carry the policy's authority in prose, and the release configuration.
89
+ */
90
+ const PROTECTED_FILENAMES = ["CLAUDE.md", "AGENTS.md", ".npmrc"];
91
+ /** Split a path into segments, dropping `./` noise. Never touches the disk. */
92
+ function pathSegments(candidate) {
93
+ return candidate
94
+ .split(/[/\\]+/u)
95
+ .filter((segment) => segment.length > 0 && segment !== ".");
96
+ }
97
+ /**
98
+ * Read one `policy.protected_paths` entry, or `null` when it names nothing.
99
+ *
100
+ * The schema already rejects globs, absolute paths and `..` segments; this
101
+ * repeats the structural half of that check so a caller that skipped
102
+ * validation gets an entry that matches nothing rather than an entry that
103
+ * matches surprisingly. Pure, like everything else here: no resolution against
104
+ * a checkout, no disk.
105
+ *
106
+ * The same defensiveness covers the routed form (APRV-266): an object whose
107
+ * `class` is not a well-formed `policy.edit` sub-class matches NOTHING at all
108
+ * rather than falling back to `policy.edit`. A silent fallback would be the
109
+ * worst of the three available answers — the author would read their file and
110
+ * see a rate that is not the rate in force — and the loader refuses such a
111
+ * policy outright, so this branch is only ever reached by a caller that
112
+ * skipped validation.
113
+ */
114
+ export function parseProtectedEntry(entry) {
115
+ const raw = typeof entry === "string" ? entry : entry.path;
116
+ const routed = typeof entry === "string" ? null : entry.class;
117
+ if (typeof raw !== "string")
118
+ return null;
119
+ if (routed !== null && (typeof routed !== "string" || !POLICY_EDIT_SUBCLASS.test(routed))) {
120
+ return null;
121
+ }
122
+ const trimmed = raw.trim();
123
+ if (trimmed.length === 0)
124
+ return null;
125
+ const directory = /[/\\]$/u.test(trimmed);
126
+ const segments = pathSegments(trimmed);
127
+ if (segments.length === 0)
128
+ return null;
129
+ if (segments.some((segment) => segment === ".."))
130
+ return null;
131
+ return { segments, directory, routed };
132
+ }
133
+ /** Does `segments` match one parsed entry? */
134
+ function matchesEntry(segments, entry) {
135
+ const want = entry.segments;
136
+ if (want.length > segments.length)
137
+ return false;
138
+ if (entry.directory) {
139
+ // A directory prefix matches wherever its segments appear as a contiguous
140
+ // run, exactly as the built-in `.approval/` and `.github/workflows/` do.
141
+ for (let start = 0; start + want.length <= segments.length; start += 1) {
142
+ if (want.every((segment, offset) => segment === segments[start + offset]))
143
+ return true;
144
+ }
145
+ return false;
146
+ }
147
+ // An exact path matches when the candidate ENDS with it: `docs/x.md` matches
148
+ // `/repo/docs/x.md` and `./docs/x.md`, and a bare filename (a one-segment
149
+ // entry) matches that filename in any directory, which is how the built-in
150
+ // filenames have always behaved.
151
+ const offset = segments.length - want.length;
152
+ return want.every((segment, index) => segment === segments[offset + index]);
153
+ }
154
+ /**
155
+ * Which protected class does this path name, if any? (APRV-198.)
156
+ *
157
+ * Deliberately name-based rather than location-based: the hook runs in whatever
158
+ * directory the harness is in, and a classifier that resolved paths against a
159
+ * checkout would answer differently in a worktree than in the primary. A
160
+ * false positive here costs one approval prompt; a false negative costs the
161
+ * property the whole file exists to defend.
162
+ *
163
+ * **The check order IS the precedence.** A path is answered by the strictest
164
+ * surface it names, `log.mutate` first, then `policy.core`, then
165
+ * `policy.edit`: `.approval/log/events.jsonl` is a log write and not merely an
166
+ * approval-home write, and a `policy.protected_paths` entry that happens to
167
+ * name a built-in surface cannot demote it, because the built-ins are matched
168
+ * before the policy's own list is read.
169
+ *
170
+ * `extra` carries `policy.protected_paths` (APRV-107). It is strictly
171
+ * ADDITIVE: the built-in set above is protected whatever a policy says, so a
172
+ * policy can widen the protected surface and can never narrow it, and every
173
+ * path it adds in the APRV-107 bare-string spelling lands on `policy.edit` —
174
+ * the reviewable class — because a policy widening its own surface is naming
175
+ * prose and configuration, not minting authority over the gate's organs. Still
176
+ * pure: the caller loads the policy, this function only matches segments.
177
+ *
178
+ * ## Routed entries (APRV-266)
179
+ *
180
+ * An entry written as `{path, class}` answers with the class it names, and the
181
+ * routed tier sits between the built-in `policy.core` tier and the built-in
182
+ * `policy.edit` tier. That position is the whole of the routing rule:
183
+ *
184
+ * - It is BELOW `log.mutate` and `policy.core`, so a routing can never reach
185
+ * the log or the gate's own organs. `{path: .approval/, class:
186
+ * policy.edit.home}` matches nothing, because tier 2 answered first — which
187
+ * is invariant 9's "no verb minting authority" holding at the one place a
188
+ * policy could otherwise have reached past it. The loader refuses such an
189
+ * entry outright rather than letting it sit inert.
190
+ * - It is ABOVE the built-in `policy.edit` set, so a routing CAN re-label a
191
+ * built-in `policy.edit` path: `{path: .github/workflows/, class:
192
+ * policy.edit.ci}` is exactly the sentence a project wants to write. What
193
+ * stops that from being a demotion is not this function but the load-time
194
+ * floor in `policy-load.ts`, which refuses a policy whose routing would
195
+ * resolve a built-in path below what the `policy.edit` line itself resolves
196
+ * to. The classifier stays pure: it reports the class the policy named and
197
+ * judges no autonomy.
198
+ *
199
+ * Among several routed entries matching one path, the MOST SPECIFIC wins (most
200
+ * segments), and declaration order breaks a tie. Most-specific is what a
201
+ * carve-out means — `design/` routed one way and `design/frozen/` another — and
202
+ * a tie is two entries of equal depth both claiming one path, which is the
203
+ * author's own ambiguity and is resolved the only way a pure function can:
204
+ * by the order they wrote them in.
205
+ *
206
+ * A string-only `extra` cannot reach the routed tier at all, so a policy that
207
+ * has not adopted the object form classifies byte for byte as it did before.
208
+ */
209
+ export function protectedPathClass(candidate, extra = []) {
210
+ if (candidate.length === 0)
211
+ return null;
212
+ const segments = pathSegments(candidate);
213
+ // 1. The log directory, before anything else that would call it a policy file.
214
+ for (let index = 0; index < segments.length; index += 1) {
215
+ if (segments[index] === ".approval" && segments[index + 1] === "log")
216
+ return "log.mutate";
217
+ }
218
+ // 2. The gate's own organs.
219
+ const last = segments[segments.length - 1];
220
+ if (last !== undefined && CORE_FILENAMES.includes(last))
221
+ return "policy.core";
222
+ for (let index = 0; index < segments.length; index += 1) {
223
+ const segment = segments[index];
224
+ // The rest of the approval home: payload store, vault, keys, environment
225
+ // map, queue. The bare `.approval` directory itself lands here too.
226
+ if (segment === ".approval")
227
+ return "policy.core";
228
+ // The harness's own settings, which is where a hook is installed or removed.
229
+ if (segment === ".claude") {
230
+ const next = segments[index + 1];
231
+ if (next !== undefined && next.startsWith("settings"))
232
+ return "policy.core";
233
+ }
234
+ // Cursor's equivalent surface: the hook install file, hook scripts, and
235
+ // custom-agent prompts. An agent that could write those could write itself
236
+ // out of the gate (APRV-133), which is the `policy.core` property and not
237
+ // the prose one.
238
+ if (segment === ".cursor") {
239
+ const next = segments[index + 1];
240
+ if (next === "hooks.json" || next === "hooks" || next === "agents")
241
+ return "policy.core";
242
+ }
243
+ }
244
+ // 3. The policy's own routed entries (APRV-266), above the built-in
245
+ // `policy.edit` set so a routing can re-label one of those paths, and
246
+ // below tiers 1 and 2 so it can never reach the log or the gate's organs.
247
+ // Most segments win; declaration order breaks a tie.
248
+ let routed = null;
249
+ for (const raw of extra) {
250
+ const entry = parseProtectedEntry(raw);
251
+ if (entry === null || entry.routed === null)
252
+ continue;
253
+ if (!matchesEntry(segments, entry))
254
+ continue;
255
+ const depth = entry.segments.length;
256
+ if (routed === null || depth > routed.depth)
257
+ routed = { entry, depth };
258
+ }
259
+ if (routed !== null)
260
+ return routed.entry.routed;
261
+ // 4. The prose and configuration about the gate.
262
+ if (last !== undefined && PROTECTED_FILENAMES.includes(last))
263
+ return "policy.edit";
264
+ for (let index = 0; index < segments.length; index += 1) {
265
+ // CI configuration.
266
+ if (segments[index] === ".github" && segments[index + 1] === "workflows")
267
+ return "policy.edit";
268
+ }
269
+ // 5. The policy's own bare-string entries, which mean `policy.edit` (APRV-107).
270
+ for (const raw of extra) {
271
+ const entry = parseProtectedEntry(raw);
272
+ if (entry !== null && entry.routed === null && matchesEntry(segments, entry)) {
273
+ return "policy.edit";
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+ /**
279
+ * The class a path takes from the BUILT-IN set alone, ignoring every policy
280
+ * entry (APRV-266).
281
+ *
282
+ * The load-time routing floor needs this and nothing else: "is the path this
283
+ * entry routes one the runtime protects on its own?" decides whether the floor
284
+ * applies to it, and asking {@link protectedPathClass} with the policy's own
285
+ * entries in hand would answer with the routing under test.
286
+ */
287
+ export function builtinProtectedPathClass(candidate) {
288
+ return protectedPathClass(candidate);
289
+ }
290
+ /**
291
+ * Does this path name something only a human may write?
292
+ *
293
+ * The boolean face of {@link protectedPathClass}, kept because two callers
294
+ * (`core/wysiwys.ts`'s protected-path view and the hook's file-tool gate) ask
295
+ * whether a path is protected at all before they ask which surface it is.
296
+ */
297
+ export function isProtectedPath(candidate, extra = []) {
298
+ return protectedPathClass(candidate, extra) !== null;
299
+ }
300
+ /**
301
+ * One path, in the spelling an organ attestation records (APRV-272).
302
+ *
303
+ * Segment-wise: separators collapse, `./` noise disappears, a trailing slash
304
+ * goes, and the result is joined with `/` whatever the caller's platform uses.
305
+ * Two spellings of one file therefore attest and match as one file, which is
306
+ * the property the guard needs, since git reports `.claude/settings.json` and a
307
+ * human at a terminal may type `./.claude/settings.json`.
308
+ *
309
+ * Pure and disk-free, like everything else in this file: it never resolves,
310
+ * never follows a link, and never asks whether the path exists.
311
+ */
312
+ export function normalizePathSpelling(candidate) {
313
+ return pathSegments(candidate).join("/");
314
+ }
315
+ /**
316
+ * Is this path one of the gate's ORGANS — a `policy.core` surface a human can
317
+ * attest by content (APRV-272)?
318
+ *
319
+ * The organs are the harness files that install the hook: `.claude/settings*`
320
+ * and Cursor's `hooks.json`, `hooks/` and `agents/`. They are `policy.core`
321
+ * because an agent that could write them could write itself out of the gate,
322
+ * and `policy.core` is human-only, so the gate mints no record for them at all
323
+ * — which is exactly why the protected-path guard could never pass a hand-made
324
+ * edit to one, and why {@link normalizePathSpelling}-keyed attestation is the
325
+ * evidence for them.
326
+ *
327
+ * Two `policy.core` surfaces are deliberately NOT organs, and both keep their
328
+ * own rules:
329
+ *
330
+ * - The policy file, which has had content attestation since APRV-15 and whose
331
+ * attestation the gate reads on every operation. An organ record must never
332
+ * be able to stand in for it.
333
+ * - Everything under the approval home (`.approval/`): the payload store, the
334
+ * vault, the keys, the environment map, the queue. Those are the human's own
335
+ * ceremony surface, and the log directory under them is `log.mutate`, which
336
+ * is stricter still.
337
+ *
338
+ * The question is asked of the BUILT-IN set alone. A policy may not route any
339
+ * path to `policy.core` (§11.1 invariant 9 and the `policy.edit.*` namespace
340
+ * close that), so consulting the policy's entries here could only ever widen
341
+ * the set of files a human may attest by a routing the classifier already
342
+ * refuses to honor.
343
+ */
344
+ export function isGateOrganPath(candidate) {
345
+ if (builtinProtectedPathClass(candidate) !== "policy.core")
346
+ return false;
347
+ const segments = pathSegments(candidate);
348
+ const last = segments[segments.length - 1];
349
+ if (last !== undefined && CORE_FILENAMES.includes(last))
350
+ return false;
351
+ return !segments.includes(".approval");
352
+ }
353
+ // ===========================================================================
354
+ // Credential material (account.credential, APRV-194)
355
+ // ===========================================================================
356
+ /**
357
+ * The class a credential touch takes.
358
+ *
359
+ * SPEC.md §7 has declared `account.credential` since v0.1 and no rule emitted
360
+ * it, so a policy line on the class was inert: `security find-generic-password`
361
+ * fell to `unclassified` (a deny, but undiagnostic) and `cat .approval/vault.enc`
362
+ * fell to `read.shell`, which this repository's own policy makes AUTONOMOUS.
363
+ * The vault is sealed, so that was not an exploit; it was the Never list
364
+ * believing something the classifier did not enforce.
365
+ */
366
+ const CREDENTIAL_CLASS = "account.credential";
367
+ /**
368
+ * Files under the approval home that hold credential material.
369
+ *
370
+ * Named by their position under `.approval/`, so this is the same pure segment
371
+ * matching every other rule in this file uses: `vault*` (the sealed store and
372
+ * anything beside it), `keys/` (the subtree), and `env` (plus `env.local` and
373
+ * kin), which is the environment map holding the Telegram token, the vault
374
+ * passphrase and the sampling secret.
375
+ */
376
+ function isCredentialPath(candidate) {
377
+ if (candidate.length === 0)
378
+ return false;
379
+ const segments = pathSegments(candidate);
380
+ for (let index = 0; index < segments.length; index += 1) {
381
+ if (segments[index] !== ".approval")
382
+ continue;
383
+ const next = segments[index + 1];
384
+ if (next === undefined)
385
+ return false;
386
+ if (next.startsWith("vault"))
387
+ return true;
388
+ if (next === "keys")
389
+ return true;
390
+ if (next === "env" || next.startsWith("env."))
391
+ return true;
392
+ }
393
+ return false;
394
+ }
395
+ /**
396
+ * Binaries whose effect on a named path is a WRITE, and nothing else.
397
+ *
398
+ * The precedence between this task and APRV-198, in one list. A write to
399
+ * `.approval/env` is `policy.core` — it is an edit of the gate's own directory,
400
+ * and the protected-path override already says so — while a READ of the same
401
+ * file is `account.credential`, because what leaves the machine is the secret.
402
+ * These binaries are the write half: naming them here makes the credential rule
403
+ * decline, and the segment falls through to the `policy.core` override below.
404
+ *
405
+ * `cp` is deliberately absent. It reads its source and writes its destination,
406
+ * the classifier cannot tell which argument is which (that is the
407
+ * direction-blindness APRV-198 preserves), and of the two readings the
408
+ * exfiltrating one is the one worth naming: a `cp` touching credential material
409
+ * is `account.credential` in either direction. Both classes are gated, so the
410
+ * choice is about what the approver is told, not about whether they are asked.
411
+ */
412
+ const CREDENTIAL_WRITE_BINS = [
413
+ "rm",
414
+ "mv",
415
+ "tee",
416
+ "truncate",
417
+ "chmod",
418
+ "chown",
419
+ "ln",
420
+ "touch",
421
+ "mkdir",
422
+ "rmdir",
423
+ "git",
424
+ "dd",
425
+ "install",
426
+ ];
427
+ /**
428
+ * Environment variables whose NAME says they carry a secret.
429
+ *
430
+ * Prefix-matched, because the classifier reads command text and never an
431
+ * environment: it cannot know which `APPROVAL_*` holds a token, so it treats
432
+ * the family alike and lets the allowlist below carve out the runtime's own
433
+ * non-secret names. Erring wide costs one approval prompt.
434
+ *
435
+ * Exported since APRV-205: `core/child-env.ts` starves a spawned child of the
436
+ * same family, and two copies of this list would be one list that drifts.
437
+ *
438
+ * `AGENTMAIL_` joins the family with the AgentMail adapter (APRV-224). An
439
+ * AgentMail API key is a mailbox in one string, and the deployment the adapter
440
+ * assumes hands the agent a key that cannot send while the sending key waits in
441
+ * the vault (SPEC.md §10.4). A key of either half in a granted child's
442
+ * environment would undo that split, so the prefix is withheld like the rest.
443
+ * The adapter's own declared credentials are vault names (`agentmail.api_key`,
444
+ * `agentmail.inbox_id`), so nothing under this prefix passes through by
445
+ * declaration either.
446
+ */
447
+ export const SECRET_ENV_PREFIXES = [
448
+ "APPROVAL_",
449
+ "TELEGRAM_",
450
+ "VAULT_",
451
+ "AGENTMAIL_",
452
+ ];
453
+ /**
454
+ * The runtime's own variables under those prefixes that hold no secret: an
455
+ * identity, a rendering switch, a path. Listed rather than pattern-matched so
456
+ * that adding one is a deliberate act with a reviewer.
457
+ */
458
+ export const NON_SECRET_ENV_NAMES = [
459
+ "APPROVAL_HUMAN",
460
+ "APPROVAL_AGENT",
461
+ "APPROVAL_ASCII",
462
+ "APPROVAL_MD",
463
+ "APPROVAL_HOME",
464
+ "APPROVAL_DIR",
465
+ ];
466
+ /**
467
+ * Does this bare variable name name credential material?
468
+ *
469
+ * Exported since APRV-205 for the same reason the two lists are: the scrub that
470
+ * builds a granted child's environment asks exactly this question, of a real
471
+ * environment rather than of command text, and it must ask it the same way.
472
+ */
473
+ export function isSecretEnvName(name) {
474
+ if (NON_SECRET_ENV_NAMES.includes(name))
475
+ return false;
476
+ return SECRET_ENV_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length);
477
+ }
478
+ /** `$NAME` and `${NAME}` anywhere inside a word, including inside quotes. */
479
+ const ENV_REFERENCE = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/gu;
480
+ /** The first secret-named variable this word expands, or `null`. */
481
+ function secretEnvReference(word) {
482
+ ENV_REFERENCE.lastIndex = 0;
483
+ let match = ENV_REFERENCE.exec(word);
484
+ while (match !== null) {
485
+ const name = match[1];
486
+ if (name !== undefined && isSecretEnvName(name))
487
+ return name;
488
+ match = ENV_REFERENCE.exec(word);
489
+ }
490
+ return null;
491
+ }
492
+ /**
493
+ * Is this segment a credential touch? (`null` when it is not.)
494
+ *
495
+ * Two shapes, and neither reads a value. A command naming a credential FILE
496
+ * that it is not merely writing is a read of the material; a command whose text
497
+ * expands a secret-named variable carries the secret into whatever it does with
498
+ * it, which is why the rule fires on `curl -H "…: $APPROVAL_TG_TOKEN"` as well
499
+ * as on `echo $APPROVAL_TG_TOKEN`. Because the classifier is pure over command
500
+ * text it can only ever report the variable's NAME: there is no environment
501
+ * here to read a value from, which is how SPEC.md §11.1's "raw secrets never
502
+ * appear in the log" invariant survives a refusal message that names what it
503
+ * refused.
504
+ */
505
+ function credentialTouch(basename, args, positionals) {
506
+ const inPlaceSed = basename === "sed" &&
507
+ (hasFlag(args, ["--in-place"]) ||
508
+ args.some((arg) => arg.startsWith("-i") && !arg.startsWith("--")));
509
+ const writesOnly = CREDENTIAL_WRITE_BINS.includes(basename) || inPlaceSed;
510
+ if (!writesOnly) {
511
+ const named = positionals.find((arg) => isCredentialPath(arg));
512
+ if (named !== undefined) {
513
+ return { class: CREDENTIAL_CLASS, rule: "credential-path", path: named };
514
+ }
515
+ }
516
+ for (const word of args) {
517
+ if (secretEnvReference(word) !== null) {
518
+ return { class: CREDENTIAL_CLASS, rule: "credential-env" };
519
+ }
520
+ }
521
+ return null;
522
+ }
523
+ /** `printenv` prints one variable, or all of them. */
524
+ function refinePrintenv(ctx) {
525
+ if (ctx.positionals.length === 0) {
526
+ return { class: CREDENTIAL_CLASS, rule: "printenv-all" };
527
+ }
528
+ return ctx.positionals.some((name) => isSecretEnvName(name))
529
+ ? { class: CREDENTIAL_CLASS, rule: "printenv-secret" }
530
+ : { class: "read.shell", rule: "printenv-read" };
531
+ }
532
+ const OPERATOR_CHARS = new Set(["&", "|", ";", "(", ")", "<", ">", "\n"]);
533
+ /**
534
+ * Split a command line into segments.
535
+ *
536
+ * Understood: single and double quotes, backslash escapes and line
537
+ * continuations, leading `VAR=value` assignments (left in the word list and
538
+ * stripped by the classifier), redirections including heredocs, the operators
539
+ * `&& || ; | &` and newline, subshell parentheses, `$(…)` command substitution
540
+ * (captured for recursive classification) and backticks (recorded as opaque).
541
+ *
542
+ * Not understood, on purpose: parameter expansion values. `$VAR` and `${VAR}`
543
+ * are kept verbatim in the word text, and every rule that reads a path or a
544
+ * refspec treats a word containing `$` as unknown, which resolves stricter.
545
+ */
546
+ function lex(command) {
547
+ const segments = [];
548
+ let words = [];
549
+ let redirects = [];
550
+ let opaque = null;
551
+ let segmentStart = 0;
552
+ let index = 0;
553
+ const pending = [];
554
+ const flush = (end) => {
555
+ if (words.length > 0 || redirects.length > 0 || opaque !== null) {
556
+ segments.push({
557
+ text: command.slice(segmentStart, end).trim(),
558
+ words,
559
+ redirects,
560
+ opaque,
561
+ });
562
+ }
563
+ words = [];
564
+ redirects = [];
565
+ opaque = null;
566
+ segmentStart = end;
567
+ };
568
+ /**
569
+ * Read one word starting at `index`, stopping at unquoted whitespace or an
570
+ * operator character. Returns `null` for an unterminated quote or
571
+ * substitution.
572
+ */
573
+ const readWord = () => {
574
+ let text = "";
575
+ let quoted = false;
576
+ const substitutions = [];
577
+ const start = index;
578
+ for (; index < command.length; index += 1) {
579
+ const ch = command[index];
580
+ if (ch === " " || ch === "\t")
581
+ break;
582
+ if (OPERATOR_CHARS.has(ch))
583
+ break;
584
+ if (ch === "\\") {
585
+ const next = command[index + 1];
586
+ if (next === undefined) {
587
+ text += "\\";
588
+ continue;
589
+ }
590
+ // A backslash-newline is a line continuation and contributes nothing.
591
+ if (next !== "\n")
592
+ text += next;
593
+ index += 1;
594
+ continue;
595
+ }
596
+ if (ch === "'") {
597
+ const close = command.indexOf("'", index + 1);
598
+ if (close === -1)
599
+ return null;
600
+ text += command.slice(index + 1, close);
601
+ quoted = true;
602
+ index = close;
603
+ continue;
604
+ }
605
+ if (ch === '"') {
606
+ const scan = readDoubleQuoted(command, index);
607
+ if (scan === null)
608
+ return null;
609
+ text += scan.text;
610
+ substitutions.push(...scan.substitutions);
611
+ if (scan.opaque !== null)
612
+ opaque = scan.opaque;
613
+ quoted = true;
614
+ index = scan.end;
615
+ continue;
616
+ }
617
+ if (ch === "`") {
618
+ // A backtick substitution is legal shell and unreadable here: its inner
619
+ // text is nested-quoted differently from `$(…)`, and the construct is
620
+ // rare enough that refusing it costs nothing a rewrite cannot fix.
621
+ const close = command.indexOf("`", index + 1);
622
+ if (close === -1)
623
+ return null;
624
+ opaque = "backtick command substitution";
625
+ index = close;
626
+ continue;
627
+ }
628
+ if (ch === "$" && command[index + 1] === "(") {
629
+ if (command[index + 2] === "(") {
630
+ const end = command.indexOf("))", index + 3);
631
+ if (end === -1)
632
+ return null;
633
+ opaque = "arithmetic expansion";
634
+ index = end + 1;
635
+ continue;
636
+ }
637
+ const scan = readSubstitution(command, index + 1);
638
+ if (scan === null)
639
+ return null;
640
+ substitutions.push(scan.inner);
641
+ index = scan.end;
642
+ continue;
643
+ }
644
+ text += ch;
645
+ }
646
+ // Nothing consumed means the caller was at a delimiter: no word here. A
647
+ // word that consumed characters and produced no text is still a word (`''`,
648
+ // or a backtick substitution that only set the opaque flag).
649
+ if (index === start)
650
+ return null;
651
+ return { text, quoted, substitutions };
652
+ };
653
+ while (index < command.length) {
654
+ const ch = command[index];
655
+ if (ch === " " || ch === "\t" || ch === "\r") {
656
+ index += 1;
657
+ continue;
658
+ }
659
+ if (ch === "\\" && command[index + 1] === "\n") {
660
+ index += 2;
661
+ continue;
662
+ }
663
+ if (ch === "\n") {
664
+ const boundary = index;
665
+ index += 1;
666
+ // Heredoc bodies belong to the line that opened them, so they are
667
+ // consumed here and never classified. A body is data, not commands.
668
+ while (pending.length > 0) {
669
+ const heredoc = pending.shift();
670
+ const consumed = skipHeredocBody(command, index, heredoc);
671
+ if (consumed === null) {
672
+ return { ok: false, detail: `heredoc <<${heredoc.terminator} is never terminated` };
673
+ }
674
+ index = consumed;
675
+ }
676
+ flush(boundary);
677
+ segmentStart = index;
678
+ continue;
679
+ }
680
+ // Redirections, including the fd-prefixed and dup forms.
681
+ const redirect = /^(\d*)(>>|>&|>\||>|<<-|<<|<&|<)/u.exec(command.slice(index));
682
+ if (redirect !== null) {
683
+ const op = redirect[2];
684
+ index += redirect[0].length;
685
+ if (op === "<<" || op === "<<-") {
686
+ while (command[index] === " " || command[index] === "\t")
687
+ index += 1;
688
+ const terminator = readWord();
689
+ if (terminator === null) {
690
+ return { ok: false, detail: "heredoc has no terminator word" };
691
+ }
692
+ pending.push({ terminator: terminator.text, stripTabs: op === "<<-" });
693
+ continue;
694
+ }
695
+ if (op === ">&" || op === "<&") {
696
+ // `2>&1` and friends: a file descriptor dup, no path involved.
697
+ while (index < command.length && /[\d-]/u.test(command[index]))
698
+ index += 1;
699
+ continue;
700
+ }
701
+ while (command[index] === " " || command[index] === "\t")
702
+ index += 1;
703
+ const target = readWord();
704
+ if (target === null) {
705
+ return { ok: false, detail: `redirection ${op} has no target` };
706
+ }
707
+ redirects.push({ op: op === ">>" ? ">>" : op === "<" ? "<" : ">", target });
708
+ continue;
709
+ }
710
+ if (ch === "&" || ch === "|" || ch === ";" || ch === "(" || ch === ")") {
711
+ const boundary = index;
712
+ index += ch === "&" && command[index + 1] === "&" ? 2 : ch === "|" && command[index + 1] === "|" ? 2 : 1;
713
+ flush(boundary);
714
+ segmentStart = index;
715
+ continue;
716
+ }
717
+ const word = readWord();
718
+ if (word === null) {
719
+ return { ok: false, detail: "unterminated quote or command substitution" };
720
+ }
721
+ words.push(word);
722
+ }
723
+ if (pending.length > 0) {
724
+ const heredoc = pending[0];
725
+ return { ok: false, detail: `heredoc <<${heredoc.terminator} is never terminated` };
726
+ }
727
+ flush(command.length);
728
+ return { ok: true, segments };
729
+ }
730
+ /** Scan a double-quoted string starting at the opening quote. */
731
+ function readDoubleQuoted(command, start) {
732
+ let text = "";
733
+ const substitutions = [];
734
+ let opaque = null;
735
+ let index = start + 1;
736
+ for (; index < command.length; index += 1) {
737
+ const ch = command[index];
738
+ if (ch === '"')
739
+ return { text, end: index, substitutions, opaque };
740
+ if (ch === "\\") {
741
+ const next = command[index + 1];
742
+ if (next === undefined)
743
+ break;
744
+ if (next !== "\n")
745
+ text += next;
746
+ index += 1;
747
+ continue;
748
+ }
749
+ if (ch === "`") {
750
+ const close = command.indexOf("`", index + 1);
751
+ if (close === -1)
752
+ return null;
753
+ opaque = "backtick command substitution";
754
+ index = close;
755
+ continue;
756
+ }
757
+ if (ch === "$" && command[index + 1] === "(") {
758
+ if (command[index + 2] === "(") {
759
+ const end = command.indexOf("))", index + 3);
760
+ if (end === -1)
761
+ return null;
762
+ opaque = "arithmetic expansion";
763
+ index = end + 1;
764
+ continue;
765
+ }
766
+ const scan = readSubstitution(command, index + 1);
767
+ if (scan === null)
768
+ return null;
769
+ substitutions.push(scan.inner);
770
+ index = scan.end;
771
+ continue;
772
+ }
773
+ text += ch;
774
+ }
775
+ return null;
776
+ }
777
+ /**
778
+ * Scan `(…)` starting at the opening paren, honouring nesting and quotes, and
779
+ * return the inner text plus the index of the closing paren.
780
+ */
781
+ function readSubstitution(command, start) {
782
+ let depth = 0;
783
+ for (let index = start; index < command.length; index += 1) {
784
+ const ch = command[index];
785
+ if (ch === "\\") {
786
+ index += 1;
787
+ continue;
788
+ }
789
+ if (ch === "'") {
790
+ const close = command.indexOf("'", index + 1);
791
+ if (close === -1)
792
+ return null;
793
+ index = close;
794
+ continue;
795
+ }
796
+ if (ch === '"') {
797
+ const close = closingDoubleQuote(command, index);
798
+ if (close === null)
799
+ return null;
800
+ index = close;
801
+ continue;
802
+ }
803
+ if (ch === "(")
804
+ depth += 1;
805
+ else if (ch === ")") {
806
+ depth -= 1;
807
+ if (depth === 0)
808
+ return { inner: command.slice(start + 1, index), end: index };
809
+ }
810
+ }
811
+ return null;
812
+ }
813
+ /** Index of the `"` closing the one at `start`, or `null`. */
814
+ function closingDoubleQuote(command, start) {
815
+ for (let index = start + 1; index < command.length; index += 1) {
816
+ const ch = command[index];
817
+ if (ch === "\\") {
818
+ index += 1;
819
+ continue;
820
+ }
821
+ if (ch === '"')
822
+ return index;
823
+ }
824
+ return null;
825
+ }
826
+ /** Consume a heredoc body; returns the index after it, or `null` if unclosed. */
827
+ function skipHeredocBody(command, start, heredoc) {
828
+ let index = start;
829
+ for (;;) {
830
+ if (index >= command.length)
831
+ return null;
832
+ const newline = command.indexOf("\n", index);
833
+ const line = newline === -1 ? command.slice(index) : command.slice(index, newline);
834
+ const compared = heredoc.stripTabs ? line.replace(/^\t+/u, "") : line;
835
+ if (compared.trimEnd() === heredoc.terminator) {
836
+ return newline === -1 ? command.length : newline + 1;
837
+ }
838
+ if (newline === -1)
839
+ return null;
840
+ index = newline + 1;
841
+ }
842
+ }
843
+ /** Is `word` a flag rather than a positional? */
844
+ function isFlag(word) {
845
+ return word.startsWith("-") && word !== "-";
846
+ }
847
+ /** Does any argument match one of these exact flags? */
848
+ function hasFlag(args, names) {
849
+ return args.some((arg) => {
850
+ const equals = arg.indexOf("=");
851
+ const name = equals === -1 ? arg : arg.slice(0, equals);
852
+ return names.includes(name);
853
+ });
854
+ }
855
+ /** Does any short-flag bundle (`-rf`) carry one of these letters? */
856
+ function hasShortFlag(args, letters) {
857
+ return args.some((arg) => {
858
+ if (!arg.startsWith("-") || arg.startsWith("--"))
859
+ return false;
860
+ const bundle = arg.slice(1);
861
+ return letters.some((letter) => bundle.includes(letter));
862
+ });
863
+ }
864
+ /**
865
+ * A value the classifier cannot read: an unexpanded parameter, a glob, a home
866
+ * shortcut. Every rule that reads one resolves to its stricter branch.
867
+ */
868
+ function isUnknownValue(word) {
869
+ return word.includes("$") || word.includes("*") || word.includes("?") || word.startsWith("~");
870
+ }
871
+ /** `git push` — the three push classes turn on flags and refspecs. */
872
+ function refineGitPush(ctx) {
873
+ const args = ctx.args.slice(1);
874
+ if (hasFlag(args, ["--force", "-f", "--force-with-lease", "--force-if-includes"])) {
875
+ return { class: "vcs.history.rewrite", rule: "git-push-force" };
876
+ }
877
+ const positionals = args.filter((arg) => !isFlag(arg));
878
+ // A deletion, or a push with no refspec at all: the destination is either the
879
+ // trunk or unknown, and unknown resolves to the stricter class.
880
+ if (hasFlag(args, ["--delete", "-d"])) {
881
+ return { class: "vcs.push.main", rule: "git-push-delete" };
882
+ }
883
+ const refspecs = positionals.slice(1);
884
+ if (refspecs.length === 0) {
885
+ return { class: "vcs.push.main", rule: "git-push-implicit" };
886
+ }
887
+ let sawMain = false;
888
+ for (const refspec of refspecs) {
889
+ if (refspec.startsWith("+")) {
890
+ return { class: "vcs.history.rewrite", rule: "git-push-force" };
891
+ }
892
+ const colon = refspec.indexOf(":");
893
+ const destination = colon === -1 ? refspec : refspec.slice(colon + 1);
894
+ // `:branch` (empty source) and `src:` (empty destination) both delete a
895
+ // remote ref. A deletion is destructive whatever it names, so it takes the
896
+ // stricter class rather than the branch one.
897
+ if (destination.length === 0 || (colon !== -1 && refspec.slice(0, colon).length === 0)) {
898
+ return { class: "vcs.push.main", rule: "git-push-delete" };
899
+ }
900
+ if (isUnknownValue(destination)) {
901
+ sawMain = true;
902
+ continue;
903
+ }
904
+ const branch = destination.replace(/^refs\/heads\//u, "");
905
+ if (branch === "main" || branch === "master")
906
+ sawMain = true;
907
+ }
908
+ return sawMain
909
+ ? { class: "vcs.push.main", rule: "git-push-main" }
910
+ : { class: "vcs.push.branch", rule: "git-push-branch" };
911
+ }
912
+ /**
913
+ * The class of a delete that only removes the agent's own scratch (APRV-267).
914
+ *
915
+ * Every `files.delete.out_of_scope` question this repository's log held between
916
+ * 2026-08-17 and 2026-09-05 was a lane removing its own session scratchpad or a
917
+ * probe directory under the system temp root. Eleven were approved and two
918
+ * expired, which is thirteen human interruptions and zero decisions: an agent
919
+ * deleting the temp files it just made is not a decision, and pricing it at a
920
+ * person's attention spends the audit budget SPEC.md §11 asks to protect.
921
+ *
922
+ * It is a sibling of `files.delete.out_of_scope` and not a replacement for it.
923
+ * Everything that is not provably scratch keeps the old class.
924
+ */
925
+ const SCRATCH_DELETE_CLASS = "files.delete.scratch";
926
+ /**
927
+ * Is `candidate` a STRICT descendant of `root`? Both are compared by path
928
+ * segment, so `/private/tmpfoo` is not under `/private/tmp` and a root is never
929
+ * under itself: deleting the temp root wholesale is not tidying up.
930
+ *
931
+ * Pure segment matching, like every other path test in this file. The caller
932
+ * has already resolved both sides (see {@link ClassifierContext}).
933
+ */
934
+ function isUnderRoot(candidate, root) {
935
+ const want = pathSegments(root);
936
+ const have = pathSegments(candidate);
937
+ if (want.length === 0)
938
+ return false;
939
+ if (have.length <= want.length)
940
+ return false;
941
+ return want.every((segment, index) => segment === have[index]);
942
+ }
943
+ /**
944
+ * Does every one of these targets sit strictly under a scratch root?
945
+ *
946
+ * Four ways to say no, and each is a fail-closed branch rather than a filter:
947
+ * an empty target list (an `rm` with only flags is not a delete this rule can
948
+ * vouch for), a relative path (its meaning depends on a working directory the
949
+ * classifier does not have), a `..` segment or an unreadable value (either can
950
+ * leave the root after expansion), and a path under no root at all. ALL targets
951
+ * must pass, because the class describes the command and a command that removes
952
+ * one scratch file and one real one is not a scratch delete.
953
+ */
954
+ function allTargetsAreScratch(targets, roots) {
955
+ if (targets.length === 0 || roots.length === 0)
956
+ return false;
957
+ for (const target of targets) {
958
+ if (!target.startsWith("/"))
959
+ return false;
960
+ if (isUnknownValue(target))
961
+ return false;
962
+ if (pathSegments(target).includes(".."))
963
+ return false;
964
+ if (!roots.some((root) => isUnderRoot(target, root)))
965
+ return false;
966
+ }
967
+ return true;
968
+ }
969
+ /** `rm` — everything outside the workspace, and every unreadable path, is manual. */
970
+ function refineRm(ctx) {
971
+ const recursive = hasFlag(ctx.args, ["--recursive"]) || hasShortFlag(ctx.args, ["r", "R"]);
972
+ // APRV-267, checked first because it is the narrowest branch: every target
973
+ // strictly under a root the CALLER resolved, with no `..` and nothing the
974
+ // text cannot read. The symlink and git-checkout halves of the rule need the
975
+ // disk and live in `src/cli/hook.ts`, which can only tighten this answer back
976
+ // to `files.delete.out_of_scope`; a caller that passes no roots never reaches
977
+ // this branch at all.
978
+ if (allTargetsAreScratch(ctx.positionals, ctx.context.scratchRoots ?? [])) {
979
+ return { class: SCRATCH_DELETE_CLASS, rule: "rm-scratch" };
980
+ }
981
+ for (const path of ctx.positionals) {
982
+ if (path.startsWith("/"))
983
+ return { class: "files.delete.out_of_scope", rule: "rm-absolute" };
984
+ if (pathSegments(path).includes("..")) {
985
+ return { class: "files.delete.out_of_scope", rule: "rm-parent" };
986
+ }
987
+ if (isUnknownValue(path)) {
988
+ return { class: "files.delete.out_of_scope", rule: "rm-unreadable-path" };
989
+ }
990
+ if (recursive && (path === "." || path === "..")) {
991
+ return { class: "files.delete.out_of_scope", rule: "rm-recursive-root" };
992
+ }
993
+ }
994
+ return { class: "files.write.workspace", rule: "rm-workspace" };
995
+ }
996
+ /** `git commit --amend` rewrites; a plain commit does not. */
997
+ function refineGitCommit(ctx) {
998
+ return hasFlag(ctx.args, ["--amend"])
999
+ ? { class: "vcs.history.rewrite", rule: "git-commit-amend" }
1000
+ : { class: "vcs.commit.branch", rule: "git-commit" };
1001
+ }
1002
+ /** `git reset --hard` discards committed work; a soft reset moves a pointer. */
1003
+ function refineGitReset(ctx) {
1004
+ return hasFlag(ctx.args, ["--hard"])
1005
+ ? { class: "vcs.history.rewrite", rule: "git-reset-hard" }
1006
+ : { class: "vcs.commit.branch", rule: "git-reset" };
1007
+ }
1008
+ /** `git branch` reads until it is asked to delete, rename or force-set one. */
1009
+ function refineGitBranch(ctx) {
1010
+ const mutating = hasFlag(ctx.args, ["--delete", "--move", "--copy", "--force", "--set-upstream-to"]) ||
1011
+ hasShortFlag(ctx.args, ["d", "D", "m", "M", "c", "C", "f"]);
1012
+ return mutating
1013
+ ? { class: "vcs.commit.branch", rule: "git-branch-write" }
1014
+ : { class: "read.shell", rule: "git-branch-read" };
1015
+ }
1016
+ /** `npm install` with a package name adds a dependency; without one it restores. */
1017
+ function refineNpmInstall(ctx) {
1018
+ const packages = ctx.positionals.slice(1);
1019
+ return packages.length === 0
1020
+ ? { class: "deps.install", rule: "npm-install-lockfile" }
1021
+ : { class: "deps.add", rule: "npm-install-package" };
1022
+ }
1023
+ /**
1024
+ * `find` primaries that run another command. Opaque, for the reason `xargs` is
1025
+ * (APRV-283): what `-exec` runs is a command line this classifier is not
1026
+ * reading, so the segment's effect is not in the words it can see.
1027
+ */
1028
+ const FIND_EXEC_PRIMARIES = ["-exec", "-execdir", "-ok", "-okdir"];
1029
+ /**
1030
+ * `find` primaries that write. `-delete` removes every match; `-fprint`,
1031
+ * `-fprintf` and `-fls` each create or truncate the file named after them.
1032
+ */
1033
+ const FIND_WRITE_PRIMARIES = ["-fprint", "-fprintf", "-fls"];
1034
+ /**
1035
+ * `find` walks and prints, until a primary makes it act (APRV-283).
1036
+ *
1037
+ * Until this row existed `find` sat in the read table with `ls` and `grep`, so
1038
+ * `find . -type f -delete` and `find . -exec rm {} +` both classified
1039
+ * `read.shell`: a recursive delete answered as a listing. It went unnoticed
1040
+ * because a `2>/dev/null` on the end reclassified the whole segment
1041
+ * `files.write.workspace` by the redirect override, which is the bug this task
1042
+ * fixes; taking that override away without this row would have left the delete
1043
+ * reading as a walk.
1044
+ *
1045
+ * `-delete` is `files.delete.out_of_scope` rather than a workspace write on the
1046
+ * same reasoning `refineRm` uses for `rm -r .`: `find` is recursive by
1047
+ * construction and its start points are usually relative, so the classifier
1048
+ * cannot establish what a delete covers, and a delete whose scope cannot be
1049
+ * established is the manual one.
1050
+ */
1051
+ function refineFind(ctx) {
1052
+ const running = ctx.args.find((arg) => FIND_EXEC_PRIMARIES.includes(arg));
1053
+ if (running !== undefined) {
1054
+ return {
1055
+ opaque: `find ${running} runs a command this classifier does not read`,
1056
+ };
1057
+ }
1058
+ if (ctx.args.includes("-delete")) {
1059
+ return { class: "files.delete.out_of_scope", rule: "find-delete" };
1060
+ }
1061
+ const writing = ctx.args.find((arg) => FIND_WRITE_PRIMARIES.includes(arg));
1062
+ if (writing !== undefined)
1063
+ return { class: "files.write.workspace", rule: "find-write" };
1064
+ return { class: "read.shell", rule: "find-read" };
1065
+ }
1066
+ /** `sed -i` edits in place; every other `sed` reads. */
1067
+ function refineSed(ctx) {
1068
+ const inPlace = hasFlag(ctx.args, ["--in-place"]) ||
1069
+ ctx.args.some((arg) => arg.startsWith("-i") && !arg.startsWith("--"));
1070
+ return inPlace
1071
+ ? { class: "files.write.workspace", rule: "sed-in-place" }
1072
+ : { class: "read.shell", rule: "sed-read" };
1073
+ }
1074
+ /**
1075
+ * The methods a fetch may name and still be a read: GET, and HEAD, which is a
1076
+ * GET that discards the body. Everything else, including a method the
1077
+ * classifier cannot read, is a write as far as this file is concerned.
1078
+ */
1079
+ const READ_METHODS = ["GET", "HEAD"];
1080
+ /**
1081
+ * Read the method out of a method-naming flag.
1082
+ *
1083
+ * `long` holds the exact spellings (`--request`, `-X`, `--method`), matched
1084
+ * both bare (`-X GET`) and joined (`--request=GET`); `short` is the short flag
1085
+ * whose value may be glued to it (`-XGET`). A flag present with a value we
1086
+ * cannot read, or with no value at all, is `other`: an unreadable method is a
1087
+ * method we must assume mutates.
1088
+ */
1089
+ function readMethodFlag(args, long, short) {
1090
+ let verdict = "absent";
1091
+ for (let index = 0; index < args.length; index += 1) {
1092
+ const arg = args[index];
1093
+ const equals = arg.indexOf("=");
1094
+ const name = equals === -1 ? arg : arg.slice(0, equals);
1095
+ let value;
1096
+ if (long.includes(name)) {
1097
+ value = equals === -1 ? args[index + 1] : arg.slice(equals + 1);
1098
+ }
1099
+ else if (!arg.startsWith("--") && arg.startsWith(short) && arg.length > short.length) {
1100
+ value = arg.slice(short.length);
1101
+ }
1102
+ else {
1103
+ continue;
1104
+ }
1105
+ if (value === undefined || isUnknownValue(value) || !READ_METHODS.includes(value.toUpperCase())) {
1106
+ return "other";
1107
+ }
1108
+ verdict = "read";
1109
+ }
1110
+ // A short-flag bundle carrying the method letter without being the whole flag
1111
+ // (`-sSX POST`) hides its value from the scan above. This is a classifier over
1112
+ // shell text, not curl's option grammar, so the bundle itself is the answer.
1113
+ if (verdict === "absent" && hasShortFlag(args, [short.slice(1)]))
1114
+ return "other";
1115
+ return verdict;
1116
+ }
1117
+ /**
1118
+ * Flags that hand curl, wget or httpie a request body or an upload. Any one of
1119
+ * them makes the invocation a write whatever method it names, so they are
1120
+ * checked before the method is.
1121
+ */
1122
+ const WEB_BODY_FLAGS = [
1123
+ "-d",
1124
+ "--data",
1125
+ "--data-raw",
1126
+ "--data-ascii",
1127
+ "--data-binary",
1128
+ "--data-urlencode",
1129
+ "--json",
1130
+ "-F",
1131
+ "--form",
1132
+ "--form-string",
1133
+ "-T",
1134
+ "--upload-file",
1135
+ "--post-data",
1136
+ "--post-file",
1137
+ "--body-data",
1138
+ "--body-file",
1139
+ ];
1140
+ /**
1141
+ * Flags that let a file supply options this classifier never sees. A curl
1142
+ * `-K config` can name any method and carry any body, so the invocation is
1143
+ * unreadable in the only sense that matters here and takes the stricter class.
1144
+ */
1145
+ const WEB_CONFIG_FLAGS = ["-K", "--config"];
1146
+ /**
1147
+ * Is this httpie word a request item (`name=value`, `field:=1`, `file@path`)
1148
+ * rather than a URL? httpie turns request items into a JSON body and the method
1149
+ * into POST, so an item is a write.
1150
+ *
1151
+ * A URL is exempted by its scheme or its path separator, which keeps
1152
+ * `https://x/?a=b` a read; anything else carrying `=` or `@` is an item.
1153
+ */
1154
+ function isHttpieRequestItem(word) {
1155
+ if (word.startsWith("http://") || word.startsWith("https://"))
1156
+ return false;
1157
+ if (word.includes("/"))
1158
+ return false;
1159
+ return word.includes("=") || word.includes("@");
1160
+ }
1161
+ /**
1162
+ * curl, wget and httpie — a GET-shaped fetch is `read.web` (APRV-114).
1163
+ *
1164
+ * SPEC.md §7 already puts "web fetch, API GET" under `read.*`, and before this
1165
+ * refinement the classifier answered `network.call` for every one of them, so a
1166
+ * policy holding mutating calls at manual held every research fetch there too.
1167
+ * That is the APRV-83 shape of problem (a class too coarse to state the policy
1168
+ * the taxonomy already describes), and it takes the APRV-83 fix.
1169
+ *
1170
+ * The read branch is deliberately narrow: a body or upload flag, a method that
1171
+ * is not GET or HEAD, a config file that could hold either, a short-flag bundle
1172
+ * we decline to unbundle, or a bare `$VAR` that could expand into any of them
1173
+ * all take `network.call`. Over-classifying a read as a write costs one
1174
+ * approval; the reverse runs an unreviewed write.
1175
+ */
1176
+ function refineWebFetch(ctx) {
1177
+ const write = { class: "network.call", rule: "web-write" };
1178
+ // The method flag may carry its value glued on (`-XGET`), and those letters
1179
+ // are not a short-flag bundle; scanning them for body letters would read the
1180
+ // `T` in `GET` as an upload. The method is read on its own below.
1181
+ const bundles = ctx.args.filter((arg) => arg.startsWith("--") || !arg.startsWith("-X"));
1182
+ if (hasFlag(ctx.args, WEB_BODY_FLAGS) || hasShortFlag(bundles, ["d", "F", "T"]))
1183
+ return write;
1184
+ if (hasFlag(ctx.args, WEB_CONFIG_FLAGS) || hasShortFlag(bundles, ["K"]))
1185
+ return write;
1186
+ // A word that is an unexpanded expansion, or that came out of a command
1187
+ // substitution, is not a URL we can read; it is whatever the environment puts
1188
+ // there, flags included.
1189
+ if (ctx.substituted || ctx.args.some((arg) => arg.startsWith("$")))
1190
+ return write;
1191
+ if (readMethodFlag(ctx.args, ["-X", "--request", "--method"], "-X") === "other")
1192
+ return write;
1193
+ if (ctx.bin === "http" || ctx.bin === "httpie") {
1194
+ // httpie names its method in a bare word and its body in request items.
1195
+ // Every bare word is tested for the method, not only the first: a flag
1196
+ // value ahead of it (`http -a user:pass POST url`) shifts its position, and
1197
+ // this classifier does not know which flags take values.
1198
+ for (const positional of ctx.positionals) {
1199
+ if (/^[A-Z]+$/u.test(positional) && !READ_METHODS.includes(positional))
1200
+ return write;
1201
+ if (isHttpieRequestItem(positional))
1202
+ return write;
1203
+ }
1204
+ }
1205
+ return { class: "read.web", rule: "web-read" };
1206
+ }
1207
+ /**
1208
+ * Flags that give `gh api` a request body. `-f`/`-F` here are gh's field flags,
1209
+ * not curl's form and upload ones, and `--input` reads a body from a file.
1210
+ */
1211
+ const GH_API_FIELD_FLAGS = ["-f", "--field", "-F", "--raw-field", "--input"];
1212
+ // ---------------------------------------------------------------------------
1213
+ // GitHub metadata on the repository's own remote (APRV-268)
1214
+ // ---------------------------------------------------------------------------
1215
+ /**
1216
+ * Nudging the forge about THIS checkout's own repository.
1217
+ *
1218
+ * From the log, 2026-09-05: of 52 `network.call` questions since 2026-08-17, 48
1219
+ * were approved, and three forms account for the bulk of them: `gh api graphql`
1220
+ * queries, `gh pr update-branch` and `gh run rerun`, all against this
1221
+ * repository's own origin. Sending things is what `network.call` is FOR (a
1222
+ * webhook, an email, an arbitrary POST), and those stay manual. Asking GitHub a
1223
+ * question about the repository the checkout already tracks, or telling it to
1224
+ * redo bookkeeping about work already pushed, is a different act, and it had no
1225
+ * class of its own to be granted through.
1226
+ *
1227
+ * The class is exactly three forms wide, and that width is the point. APRV-268
1228
+ * first drew it wider, over `gh pr view`, `gh run list`, `gh issue view` and a
1229
+ * plain `gh api` GET as well. Those were already `read.vcs.remote`, which this
1230
+ * repository's policy makes autonomous, and an undeclared class falls to the
1231
+ * manual default: moving them would have RAISED friction on the commonest reads
1232
+ * in the repo to buy a class none of them needed. So the rule covers only the
1233
+ * forms the log showed as `network.call`, and every read keeps the class it had.
1234
+ *
1235
+ * The class sits beside `read.vcs.remote` and `vcs.pr.open`: same forge, same
1236
+ * repository, and no payload of the operator's authorship leaves the machine.
1237
+ * Two of the three are metadata MUTATIONS (`pr update-branch`, `run rerun`), in
1238
+ * because what they change is the forge's own bookkeeping about work already
1239
+ * pushed, not content: the merge-base of a branch, a re-run of a workflow that
1240
+ * already ran.
1241
+ */
1242
+ const REMOTE_META_CLASS = "vcs.remote.meta";
1243
+ /**
1244
+ * Flags that point `gh` at a repository other than the checkout's own, or at
1245
+ * another forge entirely.
1246
+ *
1247
+ * The classifier is pure: it cannot resolve `origin`, so it cannot tell
1248
+ * `-R approval-md/approval-md` (this repository, named explicitly) from
1249
+ * `-R someone/else`. It therefore treats EVERY one of these as foreign and
1250
+ * falls back to today's class. Over-classifying costs one approval prompt; the
1251
+ * other direction would let `gh api -R victim/repo` ride a rule written for
1252
+ * this repository's own metadata.
1253
+ */
1254
+ const GH_FOREIGN_TARGET_FLAGS = ["-R", "--repo", "--hostname"];
1255
+ /**
1256
+ * Does this invocation use gh's DEFAULT repository resolution?
1257
+ *
1258
+ * `gh` with no `-R`/`--repo` resolves the repository from the checkout's git
1259
+ * remotes, which is exactly "the checkout's own origin repository" — the only
1260
+ * form this rule vouches for. A substitution or an unexpanded `$VAR` anywhere
1261
+ * in the argv hides words the classifier never sees, one of which could be a
1262
+ * `--repo`, so those are foreign too.
1263
+ */
1264
+ function isOwnRepoInvocation(ctx) {
1265
+ if (ctx.substituted)
1266
+ return false;
1267
+ if (ctx.args.some((arg) => arg.includes("$")))
1268
+ return false;
1269
+ return !hasFlag(ctx.args, GH_FOREIGN_TARGET_FLAGS);
1270
+ }
1271
+ /**
1272
+ * The gh noun/action pairs that are metadata on the repository's own remote.
1273
+ *
1274
+ * Exactly the two the log showed as `network.call`, and no wider. Every other
1275
+ * action on these nouns keeps the class it had: `gh pr view`, `gh pr list`,
1276
+ * `gh pr checks`, `gh pr diff`, `gh pr status`, `gh run view`, `gh run list`
1277
+ * and `gh issue view/list` stay `read.vcs.remote`, `gh pr create` stays
1278
+ * `vcs.pr.open`, `gh pr merge` stays `vcs.push.main`. A rule that grew by
1279
+ * analogy would be a rule nobody reviewed.
1280
+ */
1281
+ const GH_META_ACTIONS = {
1282
+ pr: ["update-branch"],
1283
+ run: ["rerun"],
1284
+ };
1285
+ /**
1286
+ * The GraphQL keyword that turns a query into a write.
1287
+ *
1288
+ * Matched as a word so a field named `mutationCount` cannot trip it and a
1289
+ * `mutation(` cannot slip past. Anchored nowhere: an operation can appear
1290
+ * anywhere in a document, and a document with a mutation anywhere in it is a
1291
+ * mutation.
1292
+ */
1293
+ const GRAPHQL_MUTATION = /(^|[^A-Za-z0-9_])mutation([^A-Za-z0-9_]|$)/u;
1294
+ /**
1295
+ * Is this `gh api graphql` call a pure query?
1296
+ *
1297
+ * Every word of the invocation is searched, because gh takes the document in a
1298
+ * field (`-f query=…`, `--field query=@file`) and the classifier does not know
1299
+ * gh's option grammar well enough to say which word is the document. Two ways
1300
+ * to answer no, both fail-closed: the text contains `mutation` anywhere, or it
1301
+ * reads the document from a file (`@path`, `--input`), whose contents this
1302
+ * classifier will never see.
1303
+ */
1304
+ function isGraphqlQueryOnly(args) {
1305
+ if (hasFlag(args, ["--input"]))
1306
+ return false;
1307
+ for (const arg of args) {
1308
+ if (GRAPHQL_MUTATION.test(arg))
1309
+ return false;
1310
+ // `-f query=@file` and `--field query=@-` read the document from elsewhere.
1311
+ const equals = arg.indexOf("=");
1312
+ if (equals !== -1 && arg.slice(equals + 1).startsWith("@"))
1313
+ return false;
1314
+ }
1315
+ return true;
1316
+ }
1317
+ /**
1318
+ * `gh api` — a GET reads as it always has (APRV-114); a GraphQL query on this
1319
+ * checkout's own repository is metadata (APRV-268); everything else is a call.
1320
+ *
1321
+ * gh defaults to GET, and to POST the moment a field appears, so those two flag
1322
+ * families were the whole test before APRV-268 and remain it: a bodyless,
1323
+ * methodless call is `read.vcs.remote`, whatever repository it names, exactly as
1324
+ * it has classified since APRV-114.
1325
+ *
1326
+ * GraphQL is the one shape that test could not read, and the only thing APRV-268
1327
+ * moves here. A query is carried in a field, so `gh api graphql -f query='query
1328
+ * {…}'` looks exactly like a POST and classified `network.call`, which is how a
1329
+ * run of approved read questions came to sit in the log. It is promoted only out
1330
+ * of `network.call`, never out of the read class: the branch below runs after
1331
+ * the GET test, so a form that read before still reads.
1332
+ *
1333
+ * The row this refines also matches `auth`, `gist`, `secret` and `workflow`,
1334
+ * which stay `network.call` unconditionally.
1335
+ */
1336
+ function refineGhApi(ctx) {
1337
+ if (ctx.sub !== "api")
1338
+ return { class: "network.call", rule: "gh-api" };
1339
+ const bundles = ctx.args.filter((arg) => arg.startsWith("--") || !arg.startsWith("-X"));
1340
+ const methodIsWrite = readMethodFlag(ctx.args, ["-X", "--method"], "-X") === "other";
1341
+ const bodied = hasFlag(ctx.args, GH_API_FIELD_FLAGS) ||
1342
+ hasShortFlag(bundles, ["f", "F"]) ||
1343
+ ctx.substituted ||
1344
+ ctx.args.some((arg) => arg.startsWith("$")) ||
1345
+ methodIsWrite;
1346
+ // Unchanged by APRV-268: no body and no method is a GET, and a GET reads.
1347
+ if (!bodied)
1348
+ return { class: "read.vcs.remote", rule: "gh-api-read" };
1349
+ // The one carve-out: a GraphQL document carrying no `mutation`, on the
1350
+ // repository gh resolves from this checkout's own remotes. Anything the
1351
+ // classifier cannot read (a document from a file, a `$VAR`, an explicit
1352
+ // `--repo`) fails closed to the class it had.
1353
+ if (ctx.positionals[1] === "graphql" &&
1354
+ !methodIsWrite &&
1355
+ isOwnRepoInvocation(ctx) &&
1356
+ isGraphqlQueryOnly(ctx.args)) {
1357
+ return { class: REMOTE_META_CLASS, rule: "gh-api-graphql-query" };
1358
+ }
1359
+ return { class: "network.call", rule: "gh-api-write" };
1360
+ }
1361
+ /** Does this path invoke the compiled `approval` CLI? */
1362
+ function isGateEntrypoint(path) {
1363
+ const segments = pathSegments(path);
1364
+ const last = segments[segments.length - 1];
1365
+ // The repository-root wrapper (`cli.js`, `./cli.js`), or the compiled entry
1366
+ // point under dist/. A `cli.js` in some other directory is just a script.
1367
+ if (last === "cli.js") {
1368
+ const dir = segments.slice(0, -1).filter((segment) => segment !== ".");
1369
+ return dir.length === 0;
1370
+ }
1371
+ if (last !== "main.js")
1372
+ return false;
1373
+ return segments.slice(0, -1).join("/").endsWith("dist/src/cli");
1374
+ }
1375
+ /**
1376
+ * The `approval` invocations that are NOT pass-through (APRV-125, APRV-214).
1377
+ *
1378
+ * Everything else this CLI does is the enforcement path itself, and gating the
1379
+ * gate with the gate deadlocks or recurses (see {@link GATE_SELF_CLASS}). `log
1380
+ * sync` and `log advance` are different in kind: they move the log FILE and
1381
+ * they drive git against a shared remote, which is a real-world effect, and the
1382
+ * policy has to be able to hold them at manual while trust builds and to relax
1383
+ * them independently later.
1384
+ *
1385
+ * `gate open` and `gate close` (APRV-214, amended SPEC.md §5.2) are different
1386
+ * in the same way and more so: opening the window SUSPENDS the policy for every
1387
+ * harness tool call under the root, which makes it the most consequential thing
1388
+ * this CLI can do. Classified `policy.core` it lands where APPROVAL.md already
1389
+ * puts the policy's own machinery, which today is `human-only`, so the hook
1390
+ * denies an agent running the ceremony with `hook-class-human-only` — the
1391
+ * classification lock, sitting behind the terminal lock and the typed word.
1392
+ * `gate status` reports and writes nothing, so it stays pass-through.
1393
+ *
1394
+ * Naming them here is also what stops the prompt lying. Performed by hand, the
1395
+ * ritual reached the approver's phone as `policy.edit` over a protected path —
1396
+ * true, and useless. Classified by name it arrives as what it is.
1397
+ *
1398
+ * `positionals` is read rather than `args`, so a flag between the words cannot
1399
+ * hide the verb: `approval --json log sync` is the same invocation.
1400
+ */
1401
+ function refineApprovalVerb(positionals) {
1402
+ const verb = positionals[0];
1403
+ const sub = positionals[1];
1404
+ if (verb === "log") {
1405
+ if (sub === "sync")
1406
+ return { class: "log.sync", rule: "approval-log-sync" };
1407
+ if (sub === "advance")
1408
+ return { class: "log.advance", rule: "approval-log-advance" };
1409
+ // APRV-220. Signing a checkpoint is the human's own ceremony, exactly as
1410
+ // `gate open` is: the whole value of a checkpoint is that an agent process
1411
+ // cannot produce one, and an agent that could run this verb could vouch for
1412
+ // a chain it had just written. Classified `policy.core`, which the
1413
+ // reference policy holds human-only, so the hook denies it with
1414
+ // `hook-class-human-only` — the classification lock, sitting behind the
1415
+ // vault passphrase an agent's environment does not carry. It mints no new
1416
+ // class (SPEC.md §11.1 invariant 9): `policy.core` already exists and is
1417
+ // already in this row's `emits`.
1418
+ if (sub === "checkpoint")
1419
+ return { class: "policy.core", rule: "approval-log-checkpoint" };
1420
+ return null;
1421
+ }
1422
+ if (verb === "gate") {
1423
+ if (sub === "open")
1424
+ return { class: "policy.core", rule: "approval-gate-open" };
1425
+ if (sub === "close")
1426
+ return { class: "policy.core", rule: "approval-gate-close" };
1427
+ return null;
1428
+ }
1429
+ // APRV-257. `setup checkpoint` MINTS the key `log checkpoint` signs with, so
1430
+ // an agent that could run it could mint a key, store it, and vouch for a
1431
+ // chain it had just written — the mechanism defeated at its source rather
1432
+ // than at its use. Classified where the use already is (`policy.core`,
1433
+ // human-only in the reference policy), so the hook denies it with
1434
+ // `hook-class-human-only`, behind the terminal check and the `--as` gate the
1435
+ // setup family already carries. It mints no new class (SPEC.md §11.1
1436
+ // invariant 9).
1437
+ //
1438
+ // The other `setup` subcommands stay pass-through. They write `.approval/env`
1439
+ // lines and OS keystore items, which the family's terminal check already
1440
+ // reserves to a human at a machine, and none of them mints a witness.
1441
+ if (verb === "setup" && sub === "checkpoint") {
1442
+ return { class: "policy.core", rule: "approval-setup-checkpoint" };
1443
+ }
1444
+ return null;
1445
+ }
1446
+ /**
1447
+ * `approval …` — the gate's own CLI, minus the two verbs that move the log.
1448
+ *
1449
+ * Never returns `null`: in this table a `null` refinement means "opaque, I
1450
+ * cannot read this command" (see `refineNode`'s inline-source branch), and
1451
+ * every `approval` invocation is readable. Everything that is not one of the
1452
+ * two log verbs keeps the pass-through class and the row's own rule id.
1453
+ */
1454
+ function refineApproval(ctx) {
1455
+ return refineApprovalVerb(ctx.positionals) ?? { class: GATE_SELF_CLASS, rule: "approval" };
1456
+ }
1457
+ /**
1458
+ * `node` — an inline script is opaque, the gate's own entry point is
1459
+ * pass-through, and anything else is a workspace script.
1460
+ */
1461
+ function refineNode(ctx) {
1462
+ if (hasFlag(ctx.args, ["-e", "--eval", "-p", "--print"]))
1463
+ return null;
1464
+ const script = ctx.positionals[0];
1465
+ if (script !== undefined && isGateEntrypoint(script)) {
1466
+ // `node cli.js log sync` is `approval log sync` spelled the long way, and
1467
+ // it must classify identically or the classification is a spelling test.
1468
+ return (refineApprovalVerb(ctx.positionals.slice(1)) ?? {
1469
+ class: GATE_SELF_CLASS,
1470
+ rule: "node-approval-cli",
1471
+ });
1472
+ }
1473
+ return { class: "files.write.workspace", rule: "node-script" };
1474
+ }
1475
+ /**
1476
+ * The table.
1477
+ *
1478
+ * Order matters: the first row whose binary and subcommand match decides. Rows
1479
+ * are grouped by binary, strictest interpretation first within a binary, and
1480
+ * every class named here is one SPEC.md §7 declares (§7's developer-workstation
1481
+ * namespaces, plus `read.shell` / `read.vcs.remote` / `read.web` under
1482
+ * `read.*`), with one addition: the `log.*` namespace of the two verbs that
1483
+ * move the log file, introduced by SPEC §10.1's APRV-125 amendment.
1484
+ */
1485
+ export const COMMAND_RULES = [
1486
+ // -- git -----------------------------------------------------------------
1487
+ {
1488
+ id: "git-push",
1489
+ bins: ["git"],
1490
+ subs: ["push"],
1491
+ class: "vcs.push.main",
1492
+ emits: ["vcs.push.branch", "vcs.push.main", "vcs.history.rewrite"],
1493
+ refine: refineGitPush,
1494
+ },
1495
+ {
1496
+ id: "git-rewrite",
1497
+ bins: ["git"],
1498
+ subs: ["rebase", "filter-branch", "filter-repo"],
1499
+ class: "vcs.history.rewrite",
1500
+ },
1501
+ { id: "git-reset", bins: ["git"], subs: ["reset"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitReset },
1502
+ { id: "git-commit", bins: ["git"], subs: ["commit"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitCommit },
1503
+ { id: "git-branch", bins: ["git"], subs: ["branch"], class: "read.shell", emits: ["vcs.commit.branch"], refine: refineGitBranch },
1504
+ { id: "git-tag", bins: ["git"], subs: ["tag"], class: "release.publish" },
1505
+ { id: "git-clone", bins: ["git"], subs: ["clone"], class: "network.call" },
1506
+ {
1507
+ id: "git-write",
1508
+ bins: ["git"],
1509
+ subs: [
1510
+ "add",
1511
+ "apply",
1512
+ "checkout",
1513
+ "cherry-pick",
1514
+ "merge",
1515
+ "mv",
1516
+ "pull",
1517
+ "restore",
1518
+ "revert",
1519
+ "rm",
1520
+ "stash",
1521
+ "switch",
1522
+ "worktree",
1523
+ ],
1524
+ class: "vcs.commit.branch",
1525
+ },
1526
+ {
1527
+ id: "git-remote-read",
1528
+ bins: ["git"],
1529
+ subs: ["fetch", "ls-remote", "remote"],
1530
+ class: "read.vcs.remote",
1531
+ },
1532
+ {
1533
+ id: "git-read",
1534
+ bins: ["git"],
1535
+ subs: [
1536
+ "blame",
1537
+ "describe",
1538
+ "diff",
1539
+ "grep",
1540
+ "log",
1541
+ "ls-files",
1542
+ "reflog",
1543
+ "rev-list",
1544
+ "rev-parse",
1545
+ "shortlog",
1546
+ "show",
1547
+ "status",
1548
+ ],
1549
+ class: "read.shell",
1550
+ },
1551
+ // -- gh ------------------------------------------------------------------
1552
+ { id: "gh-release", bins: ["gh"], subs: ["release"], class: "release.publish" },
1553
+ {
1554
+ id: "gh-api",
1555
+ bins: ["gh"],
1556
+ subs: ["api", "auth", "gist", "secret", "workflow"],
1557
+ class: "network.call",
1558
+ emits: ["read.vcs.remote", REMOTE_META_CLASS],
1559
+ refine: refineGhApi,
1560
+ },
1561
+ {
1562
+ id: "gh-simple-read",
1563
+ bins: ["gh"],
1564
+ subs: ["browse", "search", "status"],
1565
+ class: "read.vcs.remote",
1566
+ },
1567
+ // `gh pr`, `gh issue`, `gh repo` and `gh run` split on their own second word;
1568
+ // the split is a refinement because the table matches one subcommand deep.
1569
+ {
1570
+ id: "gh",
1571
+ bins: ["gh"],
1572
+ subs: ["pr", "issue", "repo", "run", "cache"],
1573
+ class: "network.call",
1574
+ emits: [
1575
+ "read.vcs.remote",
1576
+ "network.call",
1577
+ REMOTE_META_CLASS,
1578
+ "vcs.pr.open",
1579
+ "vcs.pr.update",
1580
+ "vcs.push.main",
1581
+ "vcs.commit.branch",
1582
+ ],
1583
+ refine: refineGh,
1584
+ },
1585
+ // -- package managers ----------------------------------------------------
1586
+ { id: "npm-publish", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["publish", "version", "deprecate", "dist-tag", "unpublish"], class: "release.publish" },
1587
+ { id: "npm-install", bins: ["npm", "bun"], subs: ["install", "i", "add"], class: "deps.add", emits: ["deps.install"], refine: refineNpmInstall },
1588
+ { id: "yarn-add", bins: ["yarn", "pnpm"], subs: ["add"], class: "deps.add" },
1589
+ { id: "yarn-install", bins: ["yarn", "pnpm"], subs: ["install"], class: "deps.install" },
1590
+ { id: "npm-ci", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["ci"], class: "deps.install" },
1591
+ { id: "npm-update", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["update", "upgrade", "up"], class: "deps.upgrade" },
1592
+ { id: "npm-remove", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["uninstall", "remove", "rm", "un"], class: "deps.remove" },
1593
+ { id: "npm-link", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["link"], class: "deps.add" },
1594
+ { id: "npm-network", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["audit", "outdated", "view", "search", "info", "login", "whoami"], class: "network.call" },
1595
+ { id: "npm-list", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["ls", "list", "config", "help"], class: "read.shell" },
1596
+ { id: "npm-script", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["run", "run-script", "test", "start", "build", "lint", "exec"], class: "files.write.workspace" },
1597
+ // -- harness self-update (APRV-228) --------------------------------------
1598
+ // The coding-agent harnesses' own `update` verbs, and the unattended updater
1599
+ // that drives them. A harness upgrade swaps the binary that HOSTS this hook,
1600
+ // which SPEC.md §7 already calls a supply-chain decision (`deps.*`), and
1601
+ // before these rows it fell to `unclassified`: denied, but denied as "no
1602
+ // rule", which told the approver nothing and gave a human no class to grant
1603
+ // through the ordinary manual path. `npm install -g <harness>` was `deps.add`
1604
+ // all along and keeps that class; these rows name the spellings that bypass
1605
+ // the package manager.
1606
+ //
1607
+ // Both resolve to an EXISTING class and mint no authority for a human-only
1608
+ // one (SPEC.md §11.1 invariant 9): `deps.upgrade` is manual under the
1609
+ // reference policy, so the refusal now says what it is.
1610
+ //
1611
+ // `claude update` matches on its subcommand, so `claude --version`,
1612
+ // `claude -p …` and a bare `claude` stay unclassified: they are not upgrades,
1613
+ // and this row must not become the rule that lets an agent launch a nested
1614
+ // harness unattended. `uca` matches with ANY arguments, `--dry-run` included:
1615
+ // the classifier reads text, cannot know which flags the script honours, and
1616
+ // the strictest reading of an updater is that it updates.
1617
+ { id: "harness-update", bins: ["claude", "codex", "gemini"], subs: ["update"], class: "deps.upgrade" },
1618
+ { id: "harness-updater", bins: ["uca"], class: "deps.upgrade" },
1619
+ // -- workspace tools -----------------------------------------------------
1620
+ // APRV-193: three of the rules below hand control to code the runtime did not
1621
+ // author, and they are named in {@link CODE_EXECUTING_RULES}.
1622
+ {
1623
+ id: "node",
1624
+ bins: ["node"],
1625
+ class: "files.write.workspace",
1626
+ emits: [GATE_SELF_CLASS, "log.sync", "log.advance", "policy.core"],
1627
+ refine: refineNode,
1628
+ },
1629
+ {
1630
+ id: "approval",
1631
+ bins: ["approval"],
1632
+ class: GATE_SELF_CLASS,
1633
+ emits: ["log.sync", "log.advance", "policy.core"],
1634
+ refine: refineApproval,
1635
+ },
1636
+ {
1637
+ id: "workspace-tool",
1638
+ bins: ["npx", "tsx", "ts-node", "tsc", "oxlint", "eslint", "prettier", "vitest", "jest", "backlog", "make"],
1639
+ class: "files.write.workspace",
1640
+ },
1641
+ {
1642
+ id: "workspace-write",
1643
+ bins: ["mkdir", "cp", "mv", "touch", "tee", "ln", "chmod", "truncate", "rmdir"],
1644
+ class: "files.write.workspace",
1645
+ },
1646
+ {
1647
+ id: "rm",
1648
+ bins: ["rm"],
1649
+ class: "files.write.workspace",
1650
+ emits: ["files.delete.out_of_scope", SCRATCH_DELETE_CLASS],
1651
+ refine: refineRm,
1652
+ },
1653
+ { id: "sed", bins: ["sed"], class: "read.shell", emits: ["files.write.workspace"], refine: refineSed },
1654
+ // APRV-283. Its own row rather than a seat in the read table: `find` is the
1655
+ // one reader with primaries that delete, write and run other commands.
1656
+ {
1657
+ id: "find",
1658
+ bins: ["find"],
1659
+ class: "read.shell",
1660
+ emits: ["files.delete.out_of_scope", "files.write.workspace"],
1661
+ refine: refineFind,
1662
+ },
1663
+ // -- network -------------------------------------------------------------
1664
+ // The HTTP clients split on their flags; the transports do not. What `ssh`,
1665
+ // `rsync` or `nc` will do at the far end is not written in the argv, so there
1666
+ // is no read-shaped invocation to carve out and they stay manual.
1667
+ {
1668
+ id: "web-fetch",
1669
+ bins: ["curl", "wget", "http", "httpie"],
1670
+ class: "network.call",
1671
+ emits: ["read.web"],
1672
+ refine: refineWebFetch,
1673
+ },
1674
+ {
1675
+ id: "network",
1676
+ bins: ["ssh", "scp", "sftp", "rsync", "nc", "telnet", "ftp"],
1677
+ class: "network.call",
1678
+ },
1679
+ // -- credentials (APRV-194) ----------------------------------------------
1680
+ // The keychain readers. Every subcommand of these binaries exists to move
1681
+ // credential material, so the row does not split on one: `security` is
1682
+ // macOS's keychain, `secret-tool` the libsecret CLI, `keyring` the Python
1683
+ // one, `pass` the unix password store.
1684
+ {
1685
+ id: "keychain",
1686
+ bins: ["security", "secret-tool", "keyring", "pass"],
1687
+ class: CREDENTIAL_CLASS,
1688
+ },
1689
+ {
1690
+ id: "printenv",
1691
+ bins: ["printenv"],
1692
+ class: CREDENTIAL_CLASS,
1693
+ emits: ["read.shell"],
1694
+ refine: refinePrintenv,
1695
+ },
1696
+ // -- reads ---------------------------------------------------------------
1697
+ {
1698
+ id: "read-shell",
1699
+ bins: [
1700
+ "basename",
1701
+ "cat",
1702
+ "cd",
1703
+ "cksum",
1704
+ "cut",
1705
+ "diff",
1706
+ "dirname",
1707
+ "du",
1708
+ "echo",
1709
+ "false",
1710
+ "file",
1711
+ "grep",
1712
+ "head",
1713
+ "jq",
1714
+ "ls",
1715
+ "md5sum",
1716
+ "printf",
1717
+ "pwd",
1718
+ "readlink",
1719
+ "realpath",
1720
+ "rg",
1721
+ "shasum",
1722
+ "sha256sum",
1723
+ "sort",
1724
+ "stat",
1725
+ "tail",
1726
+ "test",
1727
+ "tr",
1728
+ "tree",
1729
+ "true",
1730
+ "type",
1731
+ "uniq",
1732
+ "wc",
1733
+ "which",
1734
+ ],
1735
+ class: "read.shell",
1736
+ },
1737
+ ];
1738
+ /** `gh pr view` reads; `gh pr create` reaches the network on the repo's behalf. */
1739
+ const GH_READ_ACTIONS = [
1740
+ "view",
1741
+ "list",
1742
+ "status",
1743
+ "checks",
1744
+ "diff",
1745
+ "watch",
1746
+ "download",
1747
+ ];
1748
+ /**
1749
+ * `gh pr` writes get their own classes (APRV-83). Opening or updating a pull
1750
+ * request is the routine partner of pushing a feature branch, and a policy
1751
+ * that wants to treat it as such needs a class narrower than `network.call`.
1752
+ * Merging is a write to main whatever the transport, so it shares
1753
+ * `vcs.push.main`; `checkout` only touches the local clone.
1754
+ */
1755
+ const GH_PR_UPDATE_ACTIONS = [
1756
+ "edit",
1757
+ "comment",
1758
+ "review",
1759
+ "ready",
1760
+ "close",
1761
+ "reopen",
1762
+ "lock",
1763
+ "unlock",
1764
+ ];
1765
+ function refineGh(ctx) {
1766
+ const noun = ctx.positionals[0];
1767
+ const action = ctx.positionals[1];
1768
+ // APRV-268: the two listed noun/action pairs (`pr update-branch`, `run
1769
+ // rerun`), on the repository gh would resolve from this checkout's own
1770
+ // remotes. Neither is a read, so this sits above the read branch only for
1771
+ // symmetry with the rest of the refinement; everything else on these nouns,
1772
+ // reads included, falls through unchanged.
1773
+ if (noun !== undefined &&
1774
+ action !== undefined &&
1775
+ (GH_META_ACTIONS[noun] ?? []).includes(action) &&
1776
+ isOwnRepoInvocation(ctx)) {
1777
+ return { class: REMOTE_META_CLASS, rule: "gh-remote-meta" };
1778
+ }
1779
+ if (action !== undefined && GH_READ_ACTIONS.includes(action)) {
1780
+ return { class: "read.vcs.remote", rule: "gh-read" };
1781
+ }
1782
+ if (noun === "pr" && action !== undefined) {
1783
+ if (action === "create")
1784
+ return { class: "vcs.pr.open", rule: "gh-pr-open" };
1785
+ if (GH_PR_UPDATE_ACTIONS.includes(action))
1786
+ return { class: "vcs.pr.update", rule: "gh-pr-update" };
1787
+ if (action === "merge")
1788
+ return { class: "vcs.push.main", rule: "gh-pr-merge" };
1789
+ if (action === "checkout")
1790
+ return { class: "vcs.commit.branch", rule: "gh-pr-checkout" };
1791
+ }
1792
+ return { class: "network.call", rule: "gh-write" };
1793
+ }
1794
+ /**
1795
+ * Binaries whose effect lives in a string this classifier will not interpret.
1796
+ *
1797
+ * A second parser for the same text is a second answer waiting to disagree with
1798
+ * the shell's, so these refuse instead. `bash -c "…"`, `eval`, `xargs` and the
1799
+ * `-e` interpreters can express anything at all; `sudo` and `env` re-launch
1800
+ * something else with different authority.
1801
+ */
1802
+ const OPAQUE_BINS = {
1803
+ bash: "runs a shell script",
1804
+ sh: "runs a shell script",
1805
+ zsh: "runs a shell script",
1806
+ dash: "runs a shell script",
1807
+ ksh: "runs a shell script",
1808
+ fish: "runs a shell script",
1809
+ eval: "evaluates a constructed command",
1810
+ source: "runs another file in this shell",
1811
+ ".": "runs another file in this shell",
1812
+ exec: "replaces this shell with another command",
1813
+ sudo: "runs a command with different authority",
1814
+ doas: "runs a command with different authority",
1815
+ env: "runs a command with a modified environment",
1816
+ nohup: "detaches a command from this shell",
1817
+ xargs: "runs a command built from its input",
1818
+ watch: "re-runs a command on a timer",
1819
+ timeout: "runs another command under a timer",
1820
+ time: "runs another command under a timer",
1821
+ };
1822
+ /** Interpreters that are opaque only when handed inline source. */
1823
+ const INLINE_SOURCE_BINS = {
1824
+ python: ["-c"],
1825
+ python3: ["-c"],
1826
+ perl: ["-e", "-E"],
1827
+ ruby: ["-e"],
1828
+ deno: ["eval"],
1829
+ };
1830
+ // ===========================================================================
1831
+ // Classification
1832
+ // ===========================================================================
1833
+ /** `VAR=value` prefixes, which are not the command. */
1834
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/u;
1835
+ /**
1836
+ * Redirection targets that create no file (APRV-283).
1837
+ *
1838
+ * `> file` is a write because it creates or truncates one. `2>/dev/null` does
1839
+ * neither: the kernel's bit bucket has no contents to lose and no directory
1840
+ * entry to make. The same is true of the standard streams by path
1841
+ * (`/dev/stdout`, `/dev/stderr`), of the controlling terminal, and of
1842
+ * `/dev/fd/<n>`, which names a descriptor this process already holds.
1843
+ *
1844
+ * The set is EXACT and closed. Anything else under `/dev` classifies as a write,
1845
+ * because a device node this list does not know is a device node this file
1846
+ * cannot vouch for, and the strict reading of a redirection is that it writes.
1847
+ */
1848
+ const DISCARD_TARGETS = new Set([
1849
+ "/dev/null",
1850
+ "/dev/stdout",
1851
+ "/dev/stderr",
1852
+ "/dev/tty",
1853
+ ]);
1854
+ /** `/dev/fd/3`, and nothing that merely starts with it. */
1855
+ const DEV_FD = /^\/dev\/fd\/\d+$/u;
1856
+ /** Does redirecting onto `target` create nothing? (APRV-283.) */
1857
+ function isDiscardTarget(target) {
1858
+ return DISCARD_TARGETS.has(target) || DEV_FD.test(target);
1859
+ }
1860
+ /**
1861
+ * Strictest-first, and the order is normative (APRV-198): a segment naming
1862
+ * more than one protected path is answered by the most consequential of them.
1863
+ */
1864
+ const PROTECTED_PRECEDENCE = [
1865
+ "log.mutate",
1866
+ "policy.core",
1867
+ "policy.edit",
1868
+ ];
1869
+ /**
1870
+ * Where a surface sits in {@link PROTECTED_PRECEDENCE}.
1871
+ *
1872
+ * A `policy.edit` sub-class (APRV-266) ranks exactly where `policy.edit` ranks,
1873
+ * and that is not a shortcut: routing re-labels the `policy.edit` tier and can
1874
+ * reach no other, so a routed class IS a `policy.edit` surface wearing the name
1875
+ * its policy gave it. Ranking it by the autonomy the policy declares for it was
1876
+ * the alternative and is rejected — this function is the pure classifier, it
1877
+ * has no policy to resolve against, and a precedence that moved with a rate
1878
+ * would make the class a command takes depend on a number an author was tuning.
1879
+ */
1880
+ function protectedRank(surface) {
1881
+ const index = PROTECTED_PRECEDENCE.indexOf(surface);
1882
+ return index === -1 ? PROTECTED_PRECEDENCE.indexOf("policy.edit") : index;
1883
+ }
1884
+ /**
1885
+ * The strictest protected surface named by these words, with the word itself.
1886
+ *
1887
+ * `null` when none of them is protected. The word is returned verbatim, which
1888
+ * is what {@link ClassifiedSegment.path} carries to the approver.
1889
+ *
1890
+ * Among several words at the same rank the FIRST wins, which is what it always
1891
+ * did and is what keeps a bare-string policy byte-identical: two routed paths
1892
+ * in one segment are two equally consequential surfaces, and the segment's
1893
+ * class names one of them while `ClassifiedSegment.path` names the word.
1894
+ */
1895
+ function strictestProtected(words, protectedPaths) {
1896
+ let best = null;
1897
+ for (const word of words) {
1898
+ const surface = protectedPathClass(word, protectedPaths);
1899
+ if (surface === null)
1900
+ continue;
1901
+ if (best === null || protectedRank(surface) < protectedRank(best.surface)) {
1902
+ best = { surface, path: word };
1903
+ }
1904
+ }
1905
+ return best;
1906
+ }
1907
+ /**
1908
+ * The rules whose commands RUN CODE THIS RUNTIME DID NOT AUTHOR (APRV-193).
1909
+ *
1910
+ * Rule ids rather than classes, because the class does not separate them: `npm
1911
+ * test`, `node build.mjs`, `tsc` and `mkdir` all resolve to
1912
+ * `files.write.workspace`, and only the first three execute a file an agent may
1913
+ * have written a minute ago. That is the whole distinction laundering turns on
1914
+ * — the command's NAME stops describing its effect exactly when the effect is
1915
+ * in a file the name does not mention — so it is drawn here, once, where a
1916
+ * future rule's author will see it.
1917
+ *
1918
+ * Read by `APPROVAL_HOOK_REQUIRE_SANDBOX` (`src/cli/hook.ts`) and by nothing
1919
+ * else. It grants nothing and denies nothing on its own: it says which commands
1920
+ * the hook may be asked to require a sandbox for, and the requirement is off
1921
+ * unless an operator turns it on.
1922
+ */
1923
+ export const CODE_EXECUTING_RULES = [
1924
+ /** `npm test`, `npm run <script>`, `npm exec` and the pnpm/yarn/bun spellings. */
1925
+ "npm-script",
1926
+ /** `node <script>` — the plainest spelling of "run what I just wrote". */
1927
+ "node-script",
1928
+ /** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
1929
+ "workspace-tool",
1930
+ ];
1931
+ /**
1932
+ * Every class the table can emit, for docs and for the dogfood test.
1933
+ *
1934
+ * Fixed, and it does not include the `policy.edit` sub-classes (APRV-266): a
1935
+ * routed class is emitted only because a particular policy named it, so the set
1936
+ * of them is a property of that file rather than of this table. A reader
1937
+ * asking "can the classifier ever emit this class?" of a routed name must ask
1938
+ * it WITH the policy in hand — {@link emittableClass} is that question.
1939
+ */
1940
+ export const CLASSIFIER_CLASSES = (() => {
1941
+ const seen = new Set();
1942
+ for (const rule of COMMAND_RULES) {
1943
+ seen.add(rule.class);
1944
+ for (const extra of rule.emits ?? [])
1945
+ seen.add(extra);
1946
+ }
1947
+ // Emitted outside the binary table: the three protected-path classes
1948
+ // (APRV-198), the credential overrides (APRV-194: a credential path named by
1949
+ // a binary the table does not know, a secret-named variable expansion, a
1950
+ // bare `env`), the redirect-write override, and the bare-assignment segment.
1951
+ for (const surface of PROTECTED_PRECEDENCE)
1952
+ seen.add(surface);
1953
+ seen.add(CREDENTIAL_CLASS);
1954
+ seen.add("files.write.workspace");
1955
+ seen.add("read.shell");
1956
+ return [...seen].sort();
1957
+ })();
1958
+ /**
1959
+ * Can the classifier emit `actionClass` for a project whose policy carries
1960
+ * these `protected_paths`? (APRV-266.)
1961
+ *
1962
+ * {@link CLASSIFIER_CLASSES} answers for the binary table, which is fixed. A
1963
+ * routed class is not in that table and never will be: it exists because one
1964
+ * policy wrote it beside one path, and the same name in another project's
1965
+ * policy would be a different class over different files. So the reachability
1966
+ * question — the one `core/policy-expectations.ts` asks of every class a policy
1967
+ * declares, so that a policy line nobody can ever fire is caught at the
1968
+ * ceremony rather than believed for a year — takes the policy's own entries.
1969
+ *
1970
+ * A routed name is reachable exactly when some entry routes to it. A
1971
+ * `policy.edit.spec` rule in a policy whose `protected_paths` routes nothing to
1972
+ * it is a line that will never fire, and saying so is the whole point.
1973
+ */
1974
+ export function emittableClass(actionClass, protectedPaths = []) {
1975
+ if (CLASSIFIER_CLASSES.includes(actionClass))
1976
+ return true;
1977
+ if (!POLICY_EDIT_SUBCLASS.test(actionClass))
1978
+ return false;
1979
+ return protectedPaths.some((entry) => parseProtectedEntry(entry)?.routed === actionClass);
1980
+ }
1981
+ // ---------------------------------------------------------------------------
1982
+ // Sandbox wrappers (APRV-193)
1983
+ // ---------------------------------------------------------------------------
1984
+ /** How many nested wrappers are unwrapped before the classifier gives up. */
1985
+ const SANDBOX_WRAPPER_DEPTH = 4;
1986
+ /**
1987
+ * `sandbox-exec [-f <profile>]… [--] <argv…>`.
1988
+ *
1989
+ * Only `-f` is modelled. `-p` takes a profile inline, `-n` names a built-in
1990
+ * one, `-D` binds a parameter the profile reads: each changes what the room
1991
+ * allows, and a rule that skipped them would be reading past the part that
1992
+ * matters. So they are `unreadable` rather than guessed at, which denies.
1993
+ */
1994
+ function seatbeltWrapper(words, start) {
1995
+ let index = start + 1;
1996
+ while (index < words.length) {
1997
+ const word = words[index];
1998
+ if (word === "--") {
1999
+ index += 1;
2000
+ break;
2001
+ }
2002
+ if (word === "-f") {
2003
+ if (words[index + 1] === undefined) {
2004
+ return { kind: "unreadable", detail: "sandbox-exec -f names no profile" };
2005
+ }
2006
+ index += 2;
2007
+ continue;
2008
+ }
2009
+ if (!isFlag(word))
2010
+ break;
2011
+ return {
2012
+ kind: "unreadable",
2013
+ detail: `sandbox-exec flag ${word} is not modelled; only -f <profile> is read, because -p, -n and -D change what the profile allows`,
2014
+ };
2015
+ }
2016
+ if (words[index] === undefined) {
2017
+ return { kind: "unreadable", detail: "sandbox-exec runs no command" };
2018
+ }
2019
+ return { kind: "wrapper", skip: index - start, wrapper: "external" };
2020
+ }
2021
+ /**
2022
+ * `approval [flags…] sandbox [flags…] -- <argv…>`, and its `node cli.js`
2023
+ * spelling.
2024
+ *
2025
+ * The word `sandbox` is looked for anywhere before the separator rather than
2026
+ * only in the first position, because `approval --log x sandbox -- curl …`
2027
+ * would otherwise keep the pass-through `gate.self` class and BE the laundering
2028
+ * device this whole rule exists to remove. Over-matching is safe in a way
2029
+ * under-matching is not: unwrapping can only move a segment off `gate.self`
2030
+ * (the permissive pseudo-class) and onto the inner command's real one.
2031
+ *
2032
+ * No separator means nothing runs — `approval sandbox --help` prints text — so
2033
+ * that falls through to the ordinary `approval` row rather than refusing.
2034
+ */
2035
+ function approvalSandboxWrapper(words, from, start) {
2036
+ const separator = words.indexOf("--", from);
2037
+ if (separator === -1)
2038
+ return null;
2039
+ if (!words.slice(from, separator).includes("sandbox"))
2040
+ return null;
2041
+ if (words[separator + 1] === undefined)
2042
+ return null;
2043
+ return { kind: "wrapper", skip: separator + 1 - start, wrapper: "runtime" };
2044
+ }
2045
+ /** The wrapper standing at `start`, if any. */
2046
+ function sandboxWrapper(words, start) {
2047
+ const bin = words[start];
2048
+ if (bin === undefined)
2049
+ return null;
2050
+ const base = pathSegments(bin).slice(-1)[0] ?? bin;
2051
+ if (base === "sandbox-exec")
2052
+ return seatbeltWrapper(words, start);
2053
+ if (base === "approval")
2054
+ return approvalSandboxWrapper(words, start + 1, start);
2055
+ if (base === "node") {
2056
+ const script = words[start + 1];
2057
+ if (script === undefined || !isGateEntrypoint(script))
2058
+ return null;
2059
+ return approvalSandboxWrapper(words, start + 2, start);
2060
+ }
2061
+ // bwrap and unshare are deliberately absent: this build has no Linux
2062
+ // mechanism, so a rule for their wrappers would read commands nothing here
2063
+ // can produce (`docs/sandboxed-exec.md`, and APRV-193's Linux follow-up).
2064
+ return null;
2065
+ }
2066
+ /** Find the first table row matching this binary and subcommand. */
2067
+ function matchRule(bin, sub) {
2068
+ for (const rule of COMMAND_RULES) {
2069
+ if (!rule.bins.includes(bin))
2070
+ continue;
2071
+ if (rule.subs !== undefined) {
2072
+ if (sub === null || !rule.subs.includes(sub))
2073
+ continue;
2074
+ }
2075
+ return rule;
2076
+ }
2077
+ return null;
2078
+ }
2079
+ function classifySegment(segment, protectedPaths, context) {
2080
+ if (segment.opaque !== null) {
2081
+ return { ok: false, code: "opaque", detail: segment.opaque };
2082
+ }
2083
+ // `$(…)` is classified recursively. A substitution that only reads is inert;
2084
+ // anything else taints the segment, because its effect happens before the
2085
+ // outer command even starts and the outer class would not describe it.
2086
+ for (const word of segment.words) {
2087
+ for (const inner of word.substitutions) {
2088
+ const nested = classifyCommand(inner, protectedPaths, context);
2089
+ if (!nested.ok) {
2090
+ return {
2091
+ ok: false,
2092
+ code: nested.code === "unparseable" ? "unparseable" : nested.code,
2093
+ detail: `command substitution $(${inner}): ${nested.detail}`,
2094
+ };
2095
+ }
2096
+ const effectful = nested.classes.filter((cls) => !cls.startsWith("read."));
2097
+ if (effectful.length > 0) {
2098
+ return {
2099
+ ok: false,
2100
+ code: "opaque",
2101
+ detail: `command substitution $(${inner}) is ${effectful.join(", ")}; only read.* substitutions run unattended`,
2102
+ };
2103
+ }
2104
+ }
2105
+ }
2106
+ const words = segment.words.map((word) => word.text);
2107
+ let cursor = 0;
2108
+ while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
2109
+ cursor += 1;
2110
+ // APRV-193. A sandbox wrapper is not a command: it is a room, and what
2111
+ // matters is what runs inside it. `approval sandbox -- npm install` is
2112
+ // `deps.add`, and it has to be, in both directions.
2113
+ //
2114
+ // If the wrapper kept a class of its own it would be a laundering device —
2115
+ // wrap anything, get `gate.self`, run unapproved. And if it stayed
2116
+ // unclassified (which is where `sandbox-exec` sat until this task) the hook
2117
+ // would DENY the safe spelling of a command it allows unwrapped, which is a
2118
+ // gate that punishes protection. So the cursor is advanced past the wrapper
2119
+ // and everything below decides on the inner argv, rule id included.
2120
+ //
2121
+ // A wrapper this rule cannot read in full is `unclassified`: it never softens
2122
+ // anything, and a flag the rule does not model could change what runs.
2123
+ let sandbox = null;
2124
+ for (let depth = 0; depth < SANDBOX_WRAPPER_DEPTH; depth += 1) {
2125
+ const found = sandboxWrapper(words, cursor);
2126
+ if (found === null)
2127
+ break;
2128
+ if (found.kind === "unreadable") {
2129
+ return { ok: false, code: "unclassified", detail: found.detail };
2130
+ }
2131
+ // The strictest marker wins over nesting: an `approval sandbox` inside a
2132
+ // hand-written `sandbox-exec` is still, at the outermost layer, a profile
2133
+ // this runtime did not write.
2134
+ if (sandbox === null)
2135
+ sandbox = found.wrapper;
2136
+ cursor += found.skip;
2137
+ }
2138
+ const writeTargets = segment.redirects
2139
+ .filter((redirect) => redirect.op !== "<")
2140
+ .map((redirect) => redirect.target.text)
2141
+ // APRV-283: a redirection to a discard device creates nothing, so it is not
2142
+ // a write. `2>/dev/null` is the suffix an agent writes on half its reads,
2143
+ // and until this it turned every one of them into `files.write.workspace`.
2144
+ .filter((target) => !isDiscardTarget(target));
2145
+ // A redirection onto a protected path is a write to that path, whatever the
2146
+ // command in front of it was going to do. The CLASS says which surface was
2147
+ // aimed at (APRV-198); the RULE stays `redirect-protected`, because the
2148
+ // mechanism is unchanged and the hook's tiers and the channel's protected-path
2149
+ // view are keyed on the rule.
2150
+ const redirected = strictestProtected(writeTargets, protectedPaths);
2151
+ if (redirected !== null) {
2152
+ return {
2153
+ ok: true,
2154
+ class: redirected.surface,
2155
+ rule: "redirect-protected",
2156
+ path: redirected.path,
2157
+ };
2158
+ }
2159
+ const bin = words[cursor];
2160
+ if (bin === undefined) {
2161
+ // `VAR=value` alone, or a bare redirection. `> file` truncates, so it is a
2162
+ // write; an assignment on its own touches nothing.
2163
+ return writeTargets.length > 0
2164
+ ? { ok: true, class: "files.write.workspace", rule: "redirect-write" }
2165
+ : { ok: true, class: "read.shell", rule: "assignment" };
2166
+ }
2167
+ const basename = pathSegments(bin).slice(-1)[0] ?? bin;
2168
+ const args = words.slice(cursor + 1);
2169
+ // `env` with nothing to run prints the whole environment, secrets included,
2170
+ // and it is checked HERE, above the opaque table, because `env <command>` is
2171
+ // opaque for a different reason (it re-launches something else with a
2172
+ // modified environment) and the dump would otherwise be denied as
2173
+ // unreadable rather than named for what it is (APRV-194).
2174
+ if (basename === "env" && args.filter((arg) => !isFlag(arg)).length === 0) {
2175
+ return { ok: true, class: CREDENTIAL_CLASS, rule: "env-dump" };
2176
+ }
2177
+ const opaqueReason = OPAQUE_BINS[basename];
2178
+ if (opaqueReason !== undefined) {
2179
+ return { ok: false, code: "opaque", detail: `${basename} ${opaqueReason}` };
2180
+ }
2181
+ const inlineFlags = INLINE_SOURCE_BINS[basename];
2182
+ if (inlineFlags !== undefined && hasFlag(args, inlineFlags)) {
2183
+ return { ok: false, code: "opaque", detail: `${basename} runs inline source` };
2184
+ }
2185
+ const positionals = args.filter((arg) => !isFlag(arg));
2186
+ // Credential material, below the opaque checks so `sudo cat .approval/env`
2187
+ // stays opaque (a refusal) rather than being softened into a request, and
2188
+ // above the binary table so a reader the table does not know (`base64`,
2189
+ // `xxd`, `less`) is named rather than answered `unclassified` (APRV-194).
2190
+ const credential = credentialTouch(basename, args, positionals);
2191
+ if (credential !== null)
2192
+ return { ok: true, ...credential };
2193
+ const sub = positionals[0] ?? null;
2194
+ const rule = matchRule(basename, sub);
2195
+ if (rule === null) {
2196
+ return {
2197
+ ok: false,
2198
+ code: "unclassified",
2199
+ detail: sub === null
2200
+ ? `no rule for ${basename}`
2201
+ : `no rule for ${basename} ${sub}`,
2202
+ };
2203
+ }
2204
+ const substituted = segment.words
2205
+ .slice(cursor + 1)
2206
+ .some((word) => word.substitutions.length > 0);
2207
+ const ctx = { bin: basename, args, positionals, sub, substituted, context };
2208
+ const refined = rule.refine === undefined ? null : rule.refine(ctx);
2209
+ if (rule.refine !== undefined && refined === null) {
2210
+ return { ok: false, code: "opaque", detail: `${basename} runs inline source` };
2211
+ }
2212
+ // APRV-283: a refinement that carries its own reason for being unreadable.
2213
+ if (refined !== null && "opaque" in refined) {
2214
+ return { ok: false, code: "opaque", detail: refined.opaque };
2215
+ }
2216
+ let cls = refined === null ? rule.class : refined.class;
2217
+ let ruleId = refined === null ? rule.id : refined.rule;
2218
+ // A protected path anywhere in an effectful segment takes that path's class:
2219
+ // the command is editing the gate, whatever else it is doing. Every
2220
+ // positional is scanned, source and destination alike, so `cp` stays
2221
+ // direction-blind — a copy OUT of the policy directory is as gated as a copy
2222
+ // into it, because the classifier cannot tell which argument the binary will
2223
+ // treat as the destination and guessing would be the ungated direction.
2224
+ if (!cls.startsWith("read.") && cls !== GATE_SELF_CLASS) {
2225
+ const named = strictestProtected(positionals, protectedPaths);
2226
+ if (named !== null) {
2227
+ return { ok: true, class: named.surface, rule: "protected-path", path: named.path };
2228
+ }
2229
+ }
2230
+ // A read command with a write redirection writes. `ls > out.txt` creates a
2231
+ // file, and the class has to say so.
2232
+ if (cls.startsWith("read.") && writeTargets.length > 0) {
2233
+ cls = "files.write.workspace";
2234
+ ruleId = "redirect-write";
2235
+ }
2236
+ return { ok: true, class: cls, rule: ruleId, ...(sandbox === null ? {} : { sandbox }) };
2237
+ }
2238
+ /**
2239
+ * Classify a shell command line into the classes it would produce.
2240
+ *
2241
+ * Every segment must classify: one unreadable segment refuses the whole
2242
+ * command, because a command line's effect is the union of its parts and a
2243
+ * partial answer would authorize the parts we happened to understand.
2244
+ *
2245
+ * `protectedPaths` is `policy.protected_paths` (APRV-107), added to the
2246
+ * built-in protected set rather than replacing it. Omitting it classifies
2247
+ * against the built-ins alone, which is the strictly narrower answer, so a
2248
+ * caller that forgets it under-reports the protected classes rather than inventing an
2249
+ * authorization; every enforcement path passes the loaded policy's list.
2250
+ *
2251
+ * Since APRV-266 an entry may be `{path, class}`, routing that path family to a
2252
+ * `policy.edit` sub-class. The classifier stays what it was: the entry's class
2253
+ * is DATA it copies out of the policy, matched by the same segment matcher as
2254
+ * every other entry, so this resolves no autonomy at all.
2255
+ *
2256
+ * `context` (APRV-267) carries the machine facts a caller has resolved: today
2257
+ * only `scratchRoots`. It behaves exactly as `protectedPaths` does: omitting it
2258
+ * yields the strictly narrower answer, because every rule that reads it can only
2259
+ * ever LOOSEN a class, and no rule reads it to loosen a protected or credential
2260
+ * one.
2261
+ */
2262
+ export function classifyCommand(command, protectedPaths = [], context = {}) {
2263
+ const lexed = lex(command);
2264
+ if (!lexed.ok) {
2265
+ return { ok: false, code: "unparseable", segment: command.trim(), detail: lexed.detail };
2266
+ }
2267
+ if (lexed.segments.length === 0) {
2268
+ return { ok: false, code: "unclassified", segment: command.trim(), detail: "empty command" };
2269
+ }
2270
+ const segments = [];
2271
+ const classes = [];
2272
+ for (const segment of lexed.segments) {
2273
+ const outcome = classifySegment(segment, protectedPaths, context);
2274
+ if (!outcome.ok) {
2275
+ return { ok: false, code: outcome.code, segment: segment.text, detail: outcome.detail };
2276
+ }
2277
+ segments.push({
2278
+ text: segment.text,
2279
+ class: outcome.class,
2280
+ rule: outcome.rule,
2281
+ ...(outcome.path === undefined ? {} : { path: outcome.path }),
2282
+ ...(outcome.sandbox === undefined ? {} : { sandbox: outcome.sandbox }),
2283
+ });
2284
+ if (!classes.includes(outcome.class))
2285
+ classes.push(outcome.class);
2286
+ }
2287
+ return { ok: true, segments, classes };
2288
+ }
2289
+ /**
2290
+ * The words of each segment, from the SAME parse {@link classifyCommand} uses.
2291
+ *
2292
+ * Exported for the channel-side command breakdown (APRV-144): a prompt that
2293
+ * says what a compound command does needs the verb and the arguments of each
2294
+ * segment, and a display layer that re-split the string itself would be a
2295
+ * second tokenizer, free to disagree with the one that chose the class. This
2296
+ * runs {@link lex} — the tokenizer — and applies the same assignment-prefix
2297
+ * skip `classifySegment` applies, and stops there: it classifies nothing and
2298
+ * decides nothing.
2299
+ *
2300
+ * `null` when the tokenizer refuses the string, which is the same input
2301
+ * `classifyCommand` answers `unparseable` for. Segments carrying no binary (a
2302
+ * bare assignment, a lone redirection) are omitted: they have no verb to show.
2303
+ */
2304
+ export function commandSegmentWords(command) {
2305
+ const lexed = lex(command);
2306
+ if (!lexed.ok)
2307
+ return null;
2308
+ const out = [];
2309
+ for (const segment of lexed.segments) {
2310
+ const words = segment.words.map((word) => word.text);
2311
+ let cursor = 0;
2312
+ while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
2313
+ cursor += 1;
2314
+ const bin = words[cursor];
2315
+ if (bin === undefined)
2316
+ continue;
2317
+ out.push({ text: segment.text, bin, args: words.slice(cursor + 1) });
2318
+ }
2319
+ return out;
2320
+ }
2321
+ //# sourceMappingURL=command-class.js.map