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.
- package/LICENSE +176 -0
- package/NOTICE +5 -0
- package/README.md +909 -4
- package/SPEC.md +445 -32
- package/cli.js +29 -3
- package/dist/src/adapters/agentmail.js +1200 -0
- package/dist/src/adapters/agentmail.js.map +1 -0
- package/dist/src/adapters/conformance.js +461 -0
- package/dist/src/adapters/conformance.js.map +1 -0
- package/dist/src/adapters/contract.js +941 -0
- package/dist/src/adapters/contract.js.map +1 -0
- package/dist/src/adapters/email.js +749 -0
- package/dist/src/adapters/email.js.map +1 -0
- package/dist/src/adapters/env-passphrase.js +132 -0
- package/dist/src/adapters/env-passphrase.js.map +1 -0
- package/dist/src/adapters/registry.js +76 -0
- package/dist/src/adapters/registry.js.map +1 -0
- package/dist/src/adapters/smtp.js +499 -0
- package/dist/src/adapters/smtp.js.map +1 -0
- package/dist/src/adapters/vault-provider.js +161 -0
- package/dist/src/adapters/vault-provider.js.map +1 -0
- package/dist/src/channels/batch.js +121 -0
- package/dist/src/channels/batch.js.map +1 -0
- package/dist/src/channels/cli.js +468 -0
- package/dist/src/channels/cli.js.map +1 -0
- package/dist/src/channels/conformance.js +445 -0
- package/dist/src/channels/conformance.js.map +1 -0
- package/dist/src/channels/contract.js +494 -0
- package/dist/src/channels/contract.js.map +1 -0
- package/dist/src/channels/payload-view.js +43 -0
- package/dist/src/channels/payload-view.js.map +1 -0
- package/dist/src/channels/render-queue.js +564 -0
- package/dist/src/channels/render-queue.js.map +1 -0
- package/dist/src/channels/tagging.js +723 -0
- package/dist/src/channels/tagging.js.map +1 -0
- package/dist/src/channels/telegram.js +3190 -0
- package/dist/src/channels/telegram.js.map +1 -0
- package/dist/src/channels/web.js +903 -0
- package/dist/src/channels/web.js.map +1 -0
- package/dist/src/cli/adapter.js +278 -0
- package/dist/src/cli/adapter.js.map +1 -0
- package/dist/src/cli/amend.js +2171 -0
- package/dist/src/cli/amend.js.map +1 -0
- package/dist/src/cli/args.js +86 -0
- package/dist/src/cli/args.js.map +1 -0
- package/dist/src/cli/attest.js +307 -0
- package/dist/src/cli/attest.js.map +1 -0
- package/dist/src/cli/audit-card.js +201 -0
- package/dist/src/cli/audit-card.js.map +1 -0
- package/dist/src/cli/audit.js +460 -0
- package/dist/src/cli/audit.js.map +1 -0
- package/dist/src/cli/channel-telegram.js +2063 -0
- package/dist/src/cli/channel-telegram.js.map +1 -0
- package/dist/src/cli/channel-web.js +357 -0
- package/dist/src/cli/channel-web.js.map +1 -0
- package/dist/src/cli/channel.js +438 -0
- package/dist/src/cli/channel.js.map +1 -0
- package/dist/src/cli/checkpoint-tap.js +238 -0
- package/dist/src/cli/checkpoint-tap.js.map +1 -0
- package/dist/src/cli/coverage.js +343 -0
- package/dist/src/cli/coverage.js.map +1 -0
- package/dist/src/cli/daemon.js +631 -0
- package/dist/src/cli/daemon.js.map +1 -0
- package/dist/src/cli/doctor.js +2648 -0
- package/dist/src/cli/doctor.js.map +1 -0
- package/dist/src/cli/env.js +302 -0
- package/dist/src/cli/env.js.map +1 -0
- package/dist/src/cli/execute.js +1682 -0
- package/dist/src/cli/execute.js.map +1 -0
- package/dist/src/cli/exit-codes.js +82 -0
- package/dist/src/cli/exit-codes.js.map +1 -0
- package/dist/src/cli/feedback.js +205 -0
- package/dist/src/cli/feedback.js.map +1 -0
- package/dist/src/cli/gate-window.js +294 -0
- package/dist/src/cli/gate-window.js.map +1 -0
- package/dist/src/cli/gate.js +557 -0
- package/dist/src/cli/gate.js.map +1 -0
- package/dist/src/cli/git-scope.js +295 -0
- package/dist/src/cli/git-scope.js.map +1 -0
- package/dist/src/cli/gloss-attach.js +107 -0
- package/dist/src/cli/gloss-attach.js.map +1 -0
- package/dist/src/cli/gloss-codex-child.js +149 -0
- package/dist/src/cli/gloss-codex-child.js.map +1 -0
- package/dist/src/cli/gloss-codex.js +255 -0
- package/dist/src/cli/gloss-codex.js.map +1 -0
- package/dist/src/cli/gloss-options.js +79 -0
- package/dist/src/cli/gloss-options.js.map +1 -0
- package/dist/src/cli/gloss.js +362 -0
- package/dist/src/cli/gloss.js.map +1 -0
- package/dist/src/cli/help.js +2217 -0
- package/dist/src/cli/help.js.map +1 -0
- package/dist/src/cli/hook.js +2743 -0
- package/dist/src/cli/hook.js.map +1 -0
- package/dist/src/cli/import.js +175 -0
- package/dist/src/cli/import.js.map +1 -0
- package/dist/src/cli/init.js +336 -0
- package/dist/src/cli/init.js.map +1 -0
- package/dist/src/cli/instructions.js +262 -0
- package/dist/src/cli/instructions.js.map +1 -0
- package/dist/src/cli/journal.js +238 -0
- package/dist/src/cli/journal.js.map +1 -0
- package/dist/src/cli/log-advance.js +749 -0
- package/dist/src/cli/log-advance.js.map +1 -0
- package/dist/src/cli/log-anchor.js +387 -0
- package/dist/src/cli/log-anchor.js.map +1 -0
- package/dist/src/cli/log-checkpoint.js +128 -0
- package/dist/src/cli/log-checkpoint.js.map +1 -0
- package/dist/src/cli/log-sync.js +849 -0
- package/dist/src/cli/log-sync.js.map +1 -0
- package/dist/src/cli/log-verbs.js +354 -0
- package/dist/src/cli/log-verbs.js.map +1 -0
- package/dist/src/cli/long-help.js +148 -0
- package/dist/src/cli/long-help.js.map +1 -0
- package/dist/src/cli/main.js +1056 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/mcp.js +306 -0
- package/dist/src/cli/mcp.js.map +1 -0
- package/dist/src/cli/paths.js +79 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/payload.js +253 -0
- package/dist/src/cli/payload.js.map +1 -0
- package/dist/src/cli/policy.js +229 -0
- package/dist/src/cli/policy.js.map +1 -0
- package/dist/src/cli/preflight.js +888 -0
- package/dist/src/cli/preflight.js.map +1 -0
- package/dist/src/cli/progress.js +112 -0
- package/dist/src/cli/progress.js.map +1 -0
- package/dist/src/cli/prompt.js +312 -0
- package/dist/src/cli/prompt.js.map +1 -0
- package/dist/src/cli/records.js +66 -0
- package/dist/src/cli/records.js.map +1 -0
- package/dist/src/cli/render.js +132 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/cli/sandbox.js +150 -0
- package/dist/src/cli/sandbox.js.map +1 -0
- package/dist/src/cli/scaffold.js +137 -0
- package/dist/src/cli/scaffold.js.map +1 -0
- package/dist/src/cli/setup-adapter.js +475 -0
- package/dist/src/cli/setup-adapter.js.map +1 -0
- package/dist/src/cli/setup-channel.js +635 -0
- package/dist/src/cli/setup-channel.js.map +1 -0
- package/dist/src/cli/setup-checkpoint.js +196 -0
- package/dist/src/cli/setup-checkpoint.js.map +1 -0
- package/dist/src/cli/setup-common.js +376 -0
- package/dist/src/cli/setup-common.js.map +1 -0
- package/dist/src/cli/setup-flow.js +476 -0
- package/dist/src/cli/setup-flow.js.map +1 -0
- package/dist/src/cli/setup-service.js +308 -0
- package/dist/src/cli/setup-service.js.map +1 -0
- package/dist/src/cli/setup.js +473 -0
- package/dist/src/cli/setup.js.map +1 -0
- package/dist/src/cli/style.js +469 -0
- package/dist/src/cli/style.js.map +1 -0
- package/dist/src/cli/token.js +274 -0
- package/dist/src/cli/token.js.map +1 -0
- package/dist/src/cli/up.js +847 -0
- package/dist/src/cli/up.js.map +1 -0
- package/dist/src/cli/usage.js +91 -0
- package/dist/src/cli/usage.js.map +1 -0
- package/dist/src/cli/values.js +189 -0
- package/dist/src/cli/values.js.map +1 -0
- package/dist/src/cli/vault.js +362 -0
- package/dist/src/cli/vault.js.map +1 -0
- package/dist/src/cli/verb-registry.js +2173 -0
- package/dist/src/cli/verb-registry.js.map +1 -0
- package/dist/src/cli/wordmark.js +52 -0
- package/dist/src/cli/wordmark.js.map +1 -0
- package/dist/src/core/advance-cycle.js +200 -0
- package/dist/src/core/advance-cycle.js.map +1 -0
- package/dist/src/core/agents-md.js +747 -0
- package/dist/src/core/agents-md.js.map +1 -0
- package/dist/src/core/attest.js +577 -0
- package/dist/src/core/attest.js.map +1 -0
- package/dist/src/core/audit.js +882 -0
- package/dist/src/core/audit.js.map +1 -0
- package/dist/src/core/budgets.js +449 -0
- package/dist/src/core/budgets.js.map +1 -0
- package/dist/src/core/checkpoint.js +738 -0
- package/dist/src/core/checkpoint.js.map +1 -0
- package/dist/src/core/child-env.js +86 -0
- package/dist/src/core/child-env.js.map +1 -0
- package/dist/src/core/clock.js +43 -0
- package/dist/src/core/clock.js.map +1 -0
- package/dist/src/core/command-class.js +2321 -0
- package/dist/src/core/command-class.js.map +1 -0
- package/dist/src/core/coverage-sources/adapter.js +71 -0
- package/dist/src/core/coverage-sources/adapter.js.map +1 -0
- package/dist/src/core/coverage-sources/gh.js +136 -0
- package/dist/src/core/coverage-sources/gh.js.map +1 -0
- package/dist/src/core/coverage-sources/git.js +269 -0
- package/dist/src/core/coverage-sources/git.js.map +1 -0
- package/dist/src/core/coverage.js +337 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/credential-spec.js +23 -0
- package/dist/src/core/credential-spec.js.map +1 -0
- package/dist/src/core/dark-session.js +714 -0
- package/dist/src/core/dark-session.js.map +1 -0
- package/dist/src/core/decision-refusal.js +265 -0
- package/dist/src/core/decision-refusal.js.map +1 -0
- package/dist/src/core/env-file.js +837 -0
- package/dist/src/core/env-file.js.map +1 -0
- package/dist/src/core/execute.js +1233 -0
- package/dist/src/core/execute.js.map +1 -0
- package/dist/src/core/frontmatter.js +100 -0
- package/dist/src/core/frontmatter.js.map +1 -0
- package/dist/src/core/gate-window.js +506 -0
- package/dist/src/core/gate-window.js.map +1 -0
- package/dist/src/core/gate.js +2947 -0
- package/dist/src/core/gate.js.map +1 -0
- package/dist/src/core/git-run.js +93 -0
- package/dist/src/core/git-run.js.map +1 -0
- package/dist/src/core/harness-version.js +210 -0
- package/dist/src/core/harness-version.js.map +1 -0
- package/dist/src/core/harness-wait.js +58 -0
- package/dist/src/core/harness-wait.js.map +1 -0
- package/dist/src/core/head-retry.js +121 -0
- package/dist/src/core/head-retry.js.map +1 -0
- package/dist/src/core/instance.js +319 -0
- package/dist/src/core/instance.js.map +1 -0
- package/dist/src/core/intake-limits.js +350 -0
- package/dist/src/core/intake-limits.js.map +1 -0
- package/dist/src/core/jcs.js +132 -0
- package/dist/src/core/jcs.js.map +1 -0
- package/dist/src/core/journal.js +200 -0
- package/dist/src/core/journal.js.map +1 -0
- package/dist/src/core/live-draw.js +703 -0
- package/dist/src/core/live-draw.js.map +1 -0
- package/dist/src/core/log-reconcile.js +136 -0
- package/dist/src/core/log-reconcile.js.map +1 -0
- package/dist/src/core/log.js +546 -0
- package/dist/src/core/log.js.map +1 -0
- package/dist/src/core/loop.js +476 -0
- package/dist/src/core/loop.js.map +1 -0
- package/dist/src/core/md-fence.js +74 -0
- package/dist/src/core/md-fence.js.map +1 -0
- package/dist/src/core/money.js +195 -0
- package/dist/src/core/money.js.map +1 -0
- package/dist/src/core/payload-census.js +146 -0
- package/dist/src/core/payload-census.js.map +1 -0
- package/dist/src/core/payload-store.js +340 -0
- package/dist/src/core/payload-store.js.map +1 -0
- package/dist/src/core/payload.js +80 -0
- package/dist/src/core/payload.js.map +1 -0
- package/dist/src/core/policy-diff.js +565 -0
- package/dist/src/core/policy-diff.js.map +1 -0
- package/dist/src/core/policy-expectations.js +394 -0
- package/dist/src/core/policy-expectations.js.map +1 -0
- package/dist/src/core/policy-explain.js +230 -0
- package/dist/src/core/policy-explain.js.map +1 -0
- package/dist/src/core/policy-load.js +524 -0
- package/dist/src/core/policy-load.js.map +1 -0
- package/dist/src/core/policy-match.js +467 -0
- package/dist/src/core/policy-match.js.map +1 -0
- package/dist/src/core/policy-proposal.js +458 -0
- package/dist/src/core/policy-proposal.js.map +1 -0
- package/dist/src/core/prompt-layout.js +422 -0
- package/dist/src/core/prompt-layout.js.map +1 -0
- package/dist/src/core/protected-path-guard.js +1087 -0
- package/dist/src/core/protected-path-guard.js.map +1 -0
- package/dist/src/core/registration.js +39 -0
- package/dist/src/core/registration.js.map +1 -0
- package/dist/src/core/reindex.js +336 -0
- package/dist/src/core/reindex.js.map +1 -0
- package/dist/src/core/sampler.js +388 -0
- package/dist/src/core/sampler.js.map +1 -0
- package/dist/src/core/sandbox.js +424 -0
- package/dist/src/core/sandbox.js.map +1 -0
- package/dist/src/core/seal.js +290 -0
- package/dist/src/core/seal.js.map +1 -0
- package/dist/src/core/state.js +1009 -0
- package/dist/src/core/state.js.map +1 -0
- package/dist/src/core/task-file.js +464 -0
- package/dist/src/core/task-file.js.map +1 -0
- package/dist/src/core/telegram-config.js +114 -0
- package/dist/src/core/telegram-config.js.map +1 -0
- package/dist/src/core/token.js +578 -0
- package/dist/src/core/token.js.map +1 -0
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -0
- package/dist/src/core/values.js +153 -0
- package/dist/src/core/values.js.map +1 -0
- package/dist/src/core/vault.js +612 -0
- package/dist/src/core/vault.js.map +1 -0
- package/dist/src/core/verified-snapshot.js +506 -0
- package/dist/src/core/verified-snapshot.js.map +1 -0
- package/dist/src/core/verify.js +549 -0
- package/dist/src/core/verify.js.map +1 -0
- package/dist/src/core/version.js +9 -0
- package/dist/src/core/version.js.map +1 -0
- package/dist/src/core/wysiwys.js +728 -0
- package/dist/src/core/wysiwys.js.map +1 -0
- package/dist/src/daemon/advance-child.js +78 -0
- package/dist/src/daemon/advance-child.js.map +1 -0
- package/dist/src/daemon/advance.js +849 -0
- package/dist/src/daemon/advance.js.map +1 -0
- package/dist/src/daemon/audit.js +90 -0
- package/dist/src/daemon/audit.js.map +1 -0
- package/dist/src/daemon/daemon.js +1988 -0
- package/dist/src/daemon/daemon.js.map +1 -0
- package/dist/src/daemon/dark-session.js +119 -0
- package/dist/src/daemon/dark-session.js.map +1 -0
- package/dist/src/daemon/draw-child.js +132 -0
- package/dist/src/daemon/draw-child.js.map +1 -0
- package/dist/src/daemon/draw.js +458 -0
- package/dist/src/daemon/draw.js.map +1 -0
- package/dist/src/daemon/git-evidence.js +345 -0
- package/dist/src/daemon/git-evidence.js.map +1 -0
- package/dist/src/daemon/projection.js +233 -0
- package/dist/src/daemon/projection.js.map +1 -0
- package/dist/src/daemon/prune.js +376 -0
- package/dist/src/daemon/prune.js.map +1 -0
- package/dist/src/mcp/http.js +343 -0
- package/dist/src/mcp/http.js.map +1 -0
- package/dist/src/mcp/server.js +594 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/docs/cli-reference.md +5363 -0
- package/package.json +43 -4
- package/schema/.gitkeep +0 -0
- package/schema/LICENSE +117 -0
- package/schema/envelope.schema.json +137 -0
- package/schema/event.schema.json +1810 -0
- package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
- package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
- package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
- package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
- package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
- package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
- package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
- package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
- package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
- package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
- package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
- package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
- package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
- package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
- package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
- package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
- package/schema/fixtures/envelope/valid/canonical.json +25 -0
- package/schema/fixtures/envelope/valid/minimal.json +7 -0
- package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
- package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
- package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
- package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
- package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
- package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
- package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
- package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
- package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
- package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
- package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
- package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
- package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
- package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
- package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
- package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
- package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
- package/schema/fixtures/event/invalid/missing-alg.json +14 -0
- package/schema/fixtures/event/invalid/missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
- package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
- package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
- package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
- package/schema/fixtures/event/invalid/short-hash.json +15 -0
- package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
- package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
- package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
- package/schema/fixtures/event/valid/approval-expired.json +15 -0
- package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
- package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
- package/schema/fixtures/event/valid/approval-granted.json +15 -0
- package/schema/fixtures/event/valid/approval-rejected.json +15 -0
- package/schema/fixtures/event/valid/approval-requested.json +19 -0
- package/schema/fixtures/event/valid/approval-revoked.json +15 -0
- package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
- package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
- package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
- package/schema/fixtures/event/valid/audit-sampled.json +14 -0
- package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
- package/schema/fixtures/event/valid/envelope-drift.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
- package/schema/fixtures/event/valid/execution-completed.json +15 -0
- package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-failed.json +15 -0
- package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
- package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
- package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
- package/schema/fixtures/event/valid/execution-started.json +14 -0
- package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
- package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
- package/schema/fixtures/event/valid/gate-closed.json +14 -0
- package/schema/fixtures/event/valid/gate-opened.json +16 -0
- package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
- package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
- package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
- package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
- package/schema/fixtures/event/valid/payload-pruned.json +17 -0
- package/schema/fixtures/event/valid/policy-declined.json +16 -0
- package/schema/fixtures/event/valid/policy-proposed.json +35 -0
- package/schema/fixtures/event/valid/policy-updated.json +14 -0
- package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
- package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
- package/schema/fixtures/event/valid/route-accepted.json +15 -0
- package/schema/fixtures/event/valid/route-proposed.json +16 -0
- package/schema/fixtures/event/valid/spec-example.json +15 -0
- package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
- package/schema/fixtures/event/valid/task-registered.json +14 -0
- package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
- package/schema/fixtures/hash/known-answer.json +74 -0
- package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
- package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
- package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
- package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
- package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/missing-version.json +8 -0
- package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
- package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
- package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
- package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
- package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
- package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
- package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
- package/schema/fixtures/policy/valid/canonical.json +47 -0
- package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
- package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
- package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
- package/schema/fixtures/policy/valid/global-budgets.json +19 -0
- package/schema/fixtures/policy/valid/human-only.json +9 -0
- package/schema/fixtures/policy/valid/minimal.json +6 -0
- package/schema/fixtures/policy/valid/protected-paths.json +10 -0
- package/schema/fixtures/policy/valid/record-namespace.json +13 -0
- package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
- package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
- package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
- package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
- package/schema/fixtures/policy/valid/wildcards.json +15 -0
- package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
- package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
- package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
- package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
- package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
- package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
- package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/valid/canonical.md +50 -0
- package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
- package/schema/fixtures/policy-md/valid/minimal.md +3 -0
- package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
- package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
- package/schema/fixtures/policy-md/valid/with-values.md +79 -0
- package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
- package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
- package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
- package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
- package/schema/fixtures/sample-record/valid/minimal.json +4 -0
- package/schema/fixtures/sample-record/valid/with-note.json +5 -0
- package/schema/fixtures/values/invalid/class-shaped.json +9 -0
- package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
- package/schema/fixtures/values/invalid/non-string-item.json +4 -0
- package/schema/fixtures/values/invalid/over-cap.json +26 -0
- package/schema/fixtures/values/invalid/unknown-key.json +5 -0
- package/schema/fixtures/values/invalid/version-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +7 -0
- package/schema/fixtures/values/valid/full.json +20 -0
- package/schema/fixtures/values/valid/minimal.json +1 -0
- package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
- package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
- package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
- package/schema/fixtures/values-md/valid/absent.md +50 -0
- package/schema/fixtures/values-md/valid/with-values.md +79 -0
- package/schema/policy.schema.json +481 -0
- package/schema/sample-record.schema.json +26 -0
- 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
|