approval-md 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -877
- package/SPEC.md +150 -19
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +53 -1
- package/dist/src/channels/contract.js +304 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +394 -12
- package/dist/src/channels/telegram.js +621 -68
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +9 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/amend.js +564 -34
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +177 -13
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +347 -28
- package/dist/src/cli/channel-telegram.js +763 -28
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +10 -1
- package/dist/src/cli/channel.js +14 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +1 -1
- package/dist/src/cli/codex.js +304 -7
- package/dist/src/cli/codex.js.map +1 -1
- package/dist/src/cli/daemon.js +41 -6
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +1322 -122
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +366 -22
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +14 -2
- package/dist/src/cli/help.js +395 -82
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +49 -1
- package/dist/src/cli/hook-codex.js +60 -1
- package/dist/src/cli/hook-codex.js.map +1 -1
- package/dist/src/cli/hook.d.ts +828 -4
- package/dist/src/cli/hook.js +2972 -142
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +56 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +151 -13
- package/dist/src/cli/preflight.js +398 -41
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +1 -1
- package/dist/src/cli/scaffold.js +7 -1
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/setup-channel.d.ts +9 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-common.d.ts +3 -1
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup.d.ts +2 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/up.js +134 -51
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +510 -47
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +51 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +20 -18
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +234 -2
- package/dist/src/core/attest.js +342 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +73 -3
- package/dist/src/core/audit.js +65 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/command-class.d.ts +205 -0
- package/dist/src/core/command-class.js +1433 -28
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +109 -8
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +23 -2
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +5 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +15 -2
- package/dist/src/core/execute.js +15 -2
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate.d.ts +392 -6
- package/dist/src/core/gate.js +852 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +181 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/instance.d.ts +59 -2
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +89 -22
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +17 -1
- package/dist/src/core/policy-explain.js +48 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +65 -1
- package/dist/src/core/policy-load.js +21 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +81 -0
- package/dist/src/core/policy-match.js +87 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +75 -0
- package/dist/src/core/policy-proposal.js +110 -4
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/protected-path-guard.d.ts +117 -4
- package/dist/src/core/protected-path-guard.js +362 -48
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +182 -0
- package/dist/src/core/read-scope.js +256 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +81 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +501 -0
- package/dist/src/core/sender-identity.js +609 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/values.d.ts +18 -8
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/advance.d.ts +10 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +307 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +30 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +2563 -73
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +6 -2
- package/schema/event.schema.json +595 -9
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +100 -4
- package/schema/values.schema.json +7 -11
- package/schema/fixtures/values/invalid/version-string.json +0 -1
|
@@ -32,7 +32,23 @@
|
|
|
32
32
|
* Self-reported text is never read. The hook passes the command only; the
|
|
33
33
|
* harness's `description` field is authored by the very agent being gated
|
|
34
34
|
* (SPEC.md §11.1: self-reported fields never reduce scrutiny).
|
|
35
|
+
*
|
|
36
|
+
* Two imports, and both are pure in exactly the same way this file is: no disk,
|
|
37
|
+
* no clock, no environment, no dependencies.
|
|
38
|
+
*
|
|
39
|
+
* `core/read-scope.ts` (APRV-347) holds the read-side path arithmetic, so the
|
|
40
|
+
* hook and the policy explainer can ask the same questions this file asks, of
|
|
41
|
+
* the same table, rather than each growing a copy of it.
|
|
42
|
+
*
|
|
43
|
+
* `core/policy-match.ts` (APRV-354) is imported for ONE name, the
|
|
44
|
+
* `harness.launch.` prefix. The classifier emits the family and two enforcement
|
|
45
|
+
* paths refuse a member of it that no policy rule names, so the three have to
|
|
46
|
+
* agree on what the family is; a second spelling of the prefix would close one
|
|
47
|
+
* of those doors and leave the other open. The import is type-safe in the
|
|
48
|
+
* dependency sense as well: `policy-match.ts` itself imports only types.
|
|
35
49
|
*/
|
|
50
|
+
import { HARNESS_LAUNCH_PREFIX } from "./policy-match.js";
|
|
51
|
+
import { READ_OUT_OF_SCOPE_CLASS, isUnreadableTarget, readTargetVerdict, readTargetsOf, } from "./read-scope.js";
|
|
36
52
|
/**
|
|
37
53
|
* The pass-through pseudo-class for the gate's own CLI.
|
|
38
54
|
*
|
|
@@ -42,6 +58,14 @@
|
|
|
42
58
|
* is never written to any envelope and no policy rule should name it.
|
|
43
59
|
*/
|
|
44
60
|
export const GATE_SELF_CLASS = "gate.self";
|
|
61
|
+
/**
|
|
62
|
+
* The class a write under `$HERMES_HOME/scripts/` takes (APRV-445): scheduling
|
|
63
|
+
* code that Hermes's cron runs later with no hook in front of it. Returned by
|
|
64
|
+
* {@link protectedPathClass} beside the organs, ranked with `policy.edit`, and
|
|
65
|
+
* deliberately not a {@link ProtectedPathClass}: it is a side effect an
|
|
66
|
+
* operator prices, not a surface of the gate itself.
|
|
67
|
+
*/
|
|
68
|
+
export const HERMES_SCHEDULED_SCRIPT_CLASS = "cron.manage";
|
|
45
69
|
/**
|
|
46
70
|
* The `policy.edit` sub-class namespace a `protected_paths` entry may route to
|
|
47
71
|
* (APRV-266).
|
|
@@ -240,6 +264,109 @@ export function protectedPathClass(candidate, extra = []) {
|
|
|
240
264
|
if (next === "hooks.json" || next === "hooks" || next === "agents")
|
|
241
265
|
return "policy.core";
|
|
242
266
|
}
|
|
267
|
+
// Grok Build's equivalent: `.grok/hooks/*.json` is where its PreToolUse
|
|
268
|
+
// entries are installed, and the scripts beside them are what those
|
|
269
|
+
// entries run. Same property as `.cursor/hooks.json` above and for the
|
|
270
|
+
// same reason (APRV-243): an agent that could write those could write
|
|
271
|
+
// itself out of the gate. Grok also READS `.claude/settings.json` and
|
|
272
|
+
// `.cursor/hooks.json` for compatibility, and both are already here.
|
|
273
|
+
if (segment === ".grok") {
|
|
274
|
+
const next = segments[index + 1];
|
|
275
|
+
if (next === "hooks.json" || next === "hooks")
|
|
276
|
+
return "policy.core";
|
|
277
|
+
}
|
|
278
|
+
// Muse Code's equivalent, OBSERVED rather than guessed (APRV-350): the live
|
|
279
|
+
// probe on muse-bin-1.3.0-R3233.1 established that the installed build reads
|
|
280
|
+
// `.muse/hooks.json` in the project, and that it rejects a malformed one
|
|
281
|
+
// loudly at startup. `settings` is listed beside it because Meta documents
|
|
282
|
+
// user-level hooks inside a `settings.json` `hooks` block, so a
|
|
283
|
+
// project-level copy would be the same organ under a second name; it never
|
|
284
|
+
// fired in the probe, and an entry for a file Muse does not read is INERT,
|
|
285
|
+
// while a missing entry for one it does read would be the hole.
|
|
286
|
+
//
|
|
287
|
+
// `worktrees` is deliberately NOT here. Muse keeps its own worktree state
|
|
288
|
+
// under `.muse/worktrees/`, which is ordinary workspace content: making it
|
|
289
|
+
// `policy.core` would price routine session bookkeeping at a human's
|
|
290
|
+
// attention, which is the failure mode §11 asks to avoid.
|
|
291
|
+
if (segment === ".muse") {
|
|
292
|
+
const next = segments[index + 1];
|
|
293
|
+
if (next === "hooks.json" || next === "hooks" || next?.startsWith("settings")) {
|
|
294
|
+
return "policy.core";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Hermes Agent's equivalent (APRV-398), and the one whose organ lives in
|
|
298
|
+
// the USER HOME by design rather than in the repository. Hermes reads its
|
|
299
|
+
// configuration from `$HERMES_HOME` (default `~/.hermes`) and documents no
|
|
300
|
+
// project-local directory, so the paths below are the ones a real install
|
|
301
|
+
// carries: `config.yaml`, which is where the `hooks:` entries are written;
|
|
302
|
+
// `agent-hooks/`, which holds the scripts those entries run; and
|
|
303
|
+
// `shell-hooks-allowlist.json`, which records the operator's one-time TTY
|
|
304
|
+
// consent to a hook command and would let an agent pre-consent itself to a
|
|
305
|
+
// command of its own. Same property as `.cursor/hooks.json`: an agent that
|
|
306
|
+
// could write any of these could write itself out of the gate.
|
|
307
|
+
//
|
|
308
|
+
// NO NEW PATH GRAMMAR IS NEEDED FOR THE HOME CASE, and that is worth
|
|
309
|
+
// stating because it is the one thing about this entry that differs from
|
|
310
|
+
// the four above it. This loop walks EVERY segment of the candidate, so
|
|
311
|
+
// `.hermes/config.yaml`, `/Users/x/.hermes/config.yaml` and
|
|
312
|
+
// `~/.hermes/config.yaml` all match at whichever position `.hermes` sits
|
|
313
|
+
// in; the existing matchers were already position-agnostic and nothing was
|
|
314
|
+
// extended for this. What a home path does change is the CLASS a mere READ
|
|
315
|
+
// of one takes: `cat ~/.hermes/config.yaml` is a read of a path outside the
|
|
316
|
+
// gate root, so the read jail answers it as `read.file.out_of_scope`
|
|
317
|
+
// (human-only in this repository's policy) before this tier is consulted,
|
|
318
|
+
// while a WRITE to it is `policy.core` here. Both refuse under an agent;
|
|
319
|
+
// they refuse for different reasons, and `docs/hermes-hook.md` says so.
|
|
320
|
+
//
|
|
321
|
+
// The bare `.hermes` directory itself lands here, exactly as bare
|
|
322
|
+
// `.approval` and bare `.codex` do: `rm -rf ~/.hermes` removes the gate's
|
|
323
|
+
// own installation. Anything else under it is deliberately NOT listed —
|
|
324
|
+
// session state and transcripts are ordinary bookkeeping, and pricing those
|
|
325
|
+
// at a human's attention is the failure mode §11 asks to avoid, the same
|
|
326
|
+
// reasoning that keeps `.muse/worktrees/` out of the entry above.
|
|
327
|
+
//
|
|
328
|
+
// APRV-445 adds three rows from the Agent Village DATA-234 survey of Hermes
|
|
329
|
+
// v2026.9.24. `shell-hooks-allowlist.json.lock` is the allowlist's flock
|
|
330
|
+
// sidecar: an agent that makes it unwritable makes the gateway's consent
|
|
331
|
+
// write raise at start, so it is the allowlist's organ as much as the file
|
|
332
|
+
// is (matched by prefix, so the sidecar and the file are one row).
|
|
333
|
+
// `approval/` is where a hosted install keeps the hook's own configuration
|
|
334
|
+
// beside the harness. And `scripts/` is `cron.manage`, not an organ: Hermes
|
|
335
|
+
// runs a scheduled job's script from `$HERMES_HOME/scripts/` with no
|
|
336
|
+
// `pre_tool_call` at all, so WRITING one is scheduling code that will run
|
|
337
|
+
// ungated later, which is the same act as `cronjob_manage create` and is
|
|
338
|
+
// priced by the same policy line.
|
|
339
|
+
//
|
|
340
|
+
// `.env` (and `.env.*`) and `auth.json` are organs too, for WRITES (APRV-445
|
|
341
|
+
// refutation, B2). `isCredentialPath` makes a READ of them
|
|
342
|
+
// `account.credential`, but that tier is consulted for reads only, so before
|
|
343
|
+
// this a `write_file`, a `patch`, a `>>`, a `sed -i` or a `tee -a` onto
|
|
344
|
+
// `$HERMES_HOME/.env` classified as an ordinary `files.write.workspace` and
|
|
345
|
+
// ran unattended: and Hermes loads that file with `override=True` into every
|
|
346
|
+
// process it starts, which makes it the switch for the gate itself
|
|
347
|
+
// (`HERMES_ACCEPT_HOOKS`) as well as a secret store. Reads stay
|
|
348
|
+
// `account.credential`.
|
|
349
|
+
if (segment === ".hermes") {
|
|
350
|
+
// Recheck 3 (lows): a profile home, `.hermes/profiles/<p>/`, is a home of
|
|
351
|
+
// its own with the same organs, so its paths are judged one level down.
|
|
352
|
+
const next = segments[index + 1] === "profiles" && segments[index + 2] !== undefined
|
|
353
|
+
? segments[index + 3]
|
|
354
|
+
: segments[index + 1];
|
|
355
|
+
if (next === undefined ||
|
|
356
|
+
next === "config.yaml" ||
|
|
357
|
+
next === "config.yml" ||
|
|
358
|
+
next === "agent-hooks" ||
|
|
359
|
+
next === "approval" ||
|
|
360
|
+
next === ".env" ||
|
|
361
|
+
next.startsWith(".env.") ||
|
|
362
|
+
next === "auth.json" ||
|
|
363
|
+
next.startsWith("shell-hooks-allowlist.json") ||
|
|
364
|
+
next.startsWith("hooks")) {
|
|
365
|
+
return "policy.core";
|
|
366
|
+
}
|
|
367
|
+
if (next === "scripts")
|
|
368
|
+
return HERMES_SCHEDULED_SCRIPT_CLASS;
|
|
369
|
+
}
|
|
243
370
|
// Codex installs its hook through these configuration and script paths.
|
|
244
371
|
if (segment === ".codex") {
|
|
245
372
|
const next = segments[index + 1];
|
|
@@ -383,12 +510,34 @@ const CREDENTIAL_CLASS = "account.credential";
|
|
|
383
510
|
* kin), which is the environment map holding the Telegram token, the vault
|
|
384
511
|
* passphrase and the sampling secret.
|
|
385
512
|
*/
|
|
386
|
-
function isCredentialPath(candidate) {
|
|
513
|
+
export function isCredentialPath(candidate) {
|
|
387
514
|
if (candidate.length === 0)
|
|
388
515
|
return false;
|
|
389
516
|
const segments = pathSegments(candidate);
|
|
390
517
|
for (let index = 0; index < segments.length; index += 1) {
|
|
391
|
-
|
|
518
|
+
const segment = segments[index];
|
|
519
|
+
// APRV-398. Hermes Agent's own home holds two files of credential material
|
|
520
|
+
// beside its configuration: `.env`, which is where its secrets go, and
|
|
521
|
+
// `auth.json`, which holds an OAuth grant. `$HERMES_HOME/config.yaml` and
|
|
522
|
+
// the hook paths beside it are `policy.core` (see `protectedPathClass`);
|
|
523
|
+
// these two are a different class for the reason `.approval/env` is, which
|
|
524
|
+
// is that what leaves the machine is the secret rather than the rule.
|
|
525
|
+
//
|
|
526
|
+
// Added by the task that taught this runtime about Hermes at all. A harness
|
|
527
|
+
// whose organ this codebase now recognises, whose credential files it did
|
|
528
|
+
// not, would be a Never-list item the classifier does not enforce — which is
|
|
529
|
+
// exactly the hole APRV-194 was filed for, one harness along.
|
|
530
|
+
if (segment === ".hermes") {
|
|
531
|
+
const next = segments[index + 1] === "profiles" && segments[index + 2] !== undefined
|
|
532
|
+
? segments[index + 3]
|
|
533
|
+
: segments[index + 1];
|
|
534
|
+
if (next === ".env" || next?.startsWith(".env.") === true)
|
|
535
|
+
return true;
|
|
536
|
+
if (next === "auth.json")
|
|
537
|
+
return true;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (segment !== ".approval")
|
|
392
541
|
continue;
|
|
393
542
|
const next = segments[index + 1];
|
|
394
543
|
if (next === undefined)
|
|
@@ -472,6 +621,19 @@ export const NON_SECRET_ENV_NAMES = [
|
|
|
472
621
|
"APPROVAL_MD",
|
|
473
622
|
"APPROVAL_HOME",
|
|
474
623
|
"APPROVAL_DIR",
|
|
624
|
+
/**
|
|
625
|
+
* Where the per-machine bot-ownership registry lives (APRV-390).
|
|
626
|
+
*
|
|
627
|
+
* A DIRECTORY PATH, and the same kind of thing `APPROVAL_HOME` and
|
|
628
|
+
* `APPROVAL_DIR` already are. It holds no secret and opens nothing: the file
|
|
629
|
+
* it points at carries bot ids, usernames and instance directories, all of
|
|
630
|
+
* which `.approval/env` carries in the open, and nothing reads it to widen a
|
|
631
|
+
* permission. Passed through because a child `approval` verb must resolve the
|
|
632
|
+
* SAME registry as its parent — a child that silently fell back to the
|
|
633
|
+
* platform default would answer "which instance owns this bot?" from a
|
|
634
|
+
* different file than the process that asked it.
|
|
635
|
+
*/
|
|
636
|
+
"APPROVAL_STATE_DIR",
|
|
475
637
|
];
|
|
476
638
|
/**
|
|
477
639
|
* Does this bare variable name name credential material?
|
|
@@ -552,6 +714,33 @@ const OPERATOR_CHARS = new Set(["&", "|", ";", "(", ")", "<", ">", "\n"]);
|
|
|
552
714
|
* Not understood, on purpose: parameter expansion values. `$VAR` and `${VAR}`
|
|
553
715
|
* are kept verbatim in the word text, and every rule that reads a path or a
|
|
554
716
|
* refspec treats a word containing `$` as unknown, which resolves stricter.
|
|
717
|
+
*
|
|
718
|
+
* ## Quoted text is DATA, and that is a contract (APRV-353)
|
|
719
|
+
*
|
|
720
|
+
* The command boundary this tokenizer honours is the shell's own. A quoted
|
|
721
|
+
* argument is ONE word to the shell, so nothing inside it is an operator, a
|
|
722
|
+
* redirection, a segment separator or a command name here either:
|
|
723
|
+
*
|
|
724
|
+
* - single-quoted text is wholly inert, every byte of it, `$` and backtick
|
|
725
|
+
* included;
|
|
726
|
+
* - double-quoted text is inert too, with the two exceptions the shell itself
|
|
727
|
+
* makes — `$(…)` and backticks, which it expands before the command runs and
|
|
728
|
+
* which therefore keep the class they have anywhere else (a substitution is
|
|
729
|
+
* classified recursively, a backtick is `opaque`);
|
|
730
|
+
* - adjacent quoted and unquoted runs concatenate into one word (`'a'"b"c`),
|
|
731
|
+
* and a backslash escape is applied where the shell applies it;
|
|
732
|
+
* - UNQUOTED operators split exactly as they always did, and quoting that does
|
|
733
|
+
* not balance is {@link LexResult} `ok: false` — `unparseable`, a refusal —
|
|
734
|
+
* rather than a guess at what the writer meant.
|
|
735
|
+
*
|
|
736
|
+
* This is stated rather than merely true because the failure it prevents is
|
|
737
|
+
* silent and one-directional. A backlog note that says the word `bash`, carries
|
|
738
|
+
* an angle-bracketed placeholder, a pipe or a semicolon is prose about work; a
|
|
739
|
+
* tokenizer that read it as syntax would refuse an ordinary workspace write and
|
|
740
|
+
* push its author toward rewording the record of what they did, which is the
|
|
741
|
+
* audit cost SPEC.md §11 exists to protect. Every printable ASCII character is
|
|
742
|
+
* covered in both quote styles by `tests/command-class-quoting.test.ts`, so an
|
|
743
|
+
* edit that loses the property fails there rather than in someone's notes.
|
|
555
744
|
*/
|
|
556
745
|
function lex(command) {
|
|
557
746
|
const segments = [];
|
|
@@ -737,6 +926,19 @@ function lex(command) {
|
|
|
737
926
|
flush(command.length);
|
|
738
927
|
return { ok: true, segments };
|
|
739
928
|
}
|
|
929
|
+
/**
|
|
930
|
+
* The refusal detail for a backtick the shell would expand inside a
|
|
931
|
+
* double-quoted argument (APRV-353).
|
|
932
|
+
*
|
|
933
|
+
* Same code (`opaque`), same verdict (deny), more use: double quotes are what
|
|
934
|
+
* an author reaches for when the text carries an apostrophe, and a note that
|
|
935
|
+
* quotes a command in backticks is then legal shell that really does run
|
|
936
|
+
* something. The refusal names the spelling that is inert, because a refusal a
|
|
937
|
+
* reader cannot act on costs the same attention as one they can (SPEC.md §11.1:
|
|
938
|
+
* refusals are machine-readable and distinct — the code stays the machine's
|
|
939
|
+
* half, this is the human's).
|
|
940
|
+
*/
|
|
941
|
+
const QUOTED_BACKTICK_OPAQUE = "backtick command substitution inside a double-quoted argument, which the shell expands; single quotes make the same text literal";
|
|
740
942
|
/** Scan a double-quoted string starting at the opening quote. */
|
|
741
943
|
function readDoubleQuoted(command, start) {
|
|
742
944
|
let text = "";
|
|
@@ -760,7 +962,7 @@ function readDoubleQuoted(command, start) {
|
|
|
760
962
|
const close = command.indexOf("`", index + 1);
|
|
761
963
|
if (close === -1)
|
|
762
964
|
return null;
|
|
763
|
-
opaque =
|
|
965
|
+
opaque = QUOTED_BACKTICK_OPAQUE;
|
|
764
966
|
index = close;
|
|
765
967
|
continue;
|
|
766
968
|
}
|
|
@@ -895,7 +1097,45 @@ function isTagRefspec(refspec) {
|
|
|
895
1097
|
return isTagRef(refspec);
|
|
896
1098
|
return isTagRef(refspec.slice(0, colon)) || isTagRef(refspec.slice(colon + 1));
|
|
897
1099
|
}
|
|
898
|
-
/**
|
|
1100
|
+
/**
|
|
1101
|
+
* Deleting a remote ref: its own class, never the trunk-push one (APRV-352).
|
|
1102
|
+
*
|
|
1103
|
+
* A `git push` that deletes refs was `vcs.push.main` until now, which reads as
|
|
1104
|
+
* "this reaches the trunk" and in a repository that samples trunk pushes
|
|
1105
|
+
* retrospectively means an irreversible removal proceeds unasked and is looked
|
|
1106
|
+
* at afterwards. It is not the same act. A push adds commits somebody can still
|
|
1107
|
+
* see; a deletion removes the only name an unmerged branch had, and the
|
|
1108
|
+
* reflog that could find it again lives on a server nobody in the session can
|
|
1109
|
+
* reach. The two belong on separate policy lines, and a policy that wants them
|
|
1110
|
+
* on one can still write `vcs.*`.
|
|
1111
|
+
*
|
|
1112
|
+
* Distinct from `vcs.history.rewrite` as well, which guards SHARED history: a
|
|
1113
|
+
* force push moves a ref other people have already built on. That class stays
|
|
1114
|
+
* exactly where it was, above this one, so a force push that also deletes is
|
|
1115
|
+
* still a rewrite.
|
|
1116
|
+
*/
|
|
1117
|
+
const REF_DELETE_CLASS = "vcs.ref.delete";
|
|
1118
|
+
/**
|
|
1119
|
+
* The ref a deleting refspec names, or `null` when the refspec deletes nothing.
|
|
1120
|
+
*
|
|
1121
|
+
* `:dst` (empty source) is the deletion git documents; `src:` (empty
|
|
1122
|
+
* destination) is the spelling the classifier has always treated as one too,
|
|
1123
|
+
* and it keeps doing so rather than being narrowed here. The non-empty side is
|
|
1124
|
+
* the name worth showing an approver either way.
|
|
1125
|
+
*/
|
|
1126
|
+
function deletedRef(refspec) {
|
|
1127
|
+
const colon = refspec.indexOf(":");
|
|
1128
|
+
if (colon === -1)
|
|
1129
|
+
return null;
|
|
1130
|
+
const source = refspec.slice(0, colon);
|
|
1131
|
+
const destination = refspec.slice(colon + 1);
|
|
1132
|
+
if (destination.length === 0)
|
|
1133
|
+
return source.length === 0 ? refspec : source;
|
|
1134
|
+
if (source.length === 0)
|
|
1135
|
+
return destination;
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
/** `git push` — force, release, deletion, trunk and branch turn on flags and refspecs. */
|
|
899
1139
|
function refineGitPush(ctx) {
|
|
900
1140
|
const args = ctx.args.slice(1);
|
|
901
1141
|
if (hasFlag(args, ["--force", "-f", "--force-with-lease", "--force-if-includes", "--mirror"])) {
|
|
@@ -906,29 +1146,45 @@ function refineGitPush(ctx) {
|
|
|
906
1146
|
if (refspecs.some((refspec) => refspec.startsWith("+"))) {
|
|
907
1147
|
return { class: "vcs.history.rewrite", rule: "git-push-force" };
|
|
908
1148
|
}
|
|
1149
|
+
// The tag check stays ABOVE the deletion check, deliberately. A tag is the
|
|
1150
|
+
// name a release was published under, and this repository's policy prices
|
|
1151
|
+
// `release.publish` accordingly; deleting one is a release act whichever
|
|
1152
|
+
// spelling removes it. Moving the deletion check up would re-label
|
|
1153
|
+
// `git push origin :refs/tags/v1.2.3` and a bulk form that mixes a tag in,
|
|
1154
|
+
// and APRV-352 asks for a class for branch deletions, not a loosening of the
|
|
1155
|
+
// tag surface.
|
|
909
1156
|
if (hasFlag(args, ["--tags", "--follow-tags"]) ||
|
|
910
1157
|
refspecs.some(isTagRefspec) ||
|
|
911
1158
|
refspecs.some((word, index) => word === "tag" && index + 1 < refspecs.length)) {
|
|
912
1159
|
return { class: "release.publish", rule: "git-push-tag" };
|
|
913
1160
|
}
|
|
914
|
-
//
|
|
915
|
-
//
|
|
1161
|
+
// `--delete` / `-d`: every refspec after the remote is a ref being removed.
|
|
1162
|
+
// A `--delete` naming no ref at all is a git error, and it stays in this
|
|
1163
|
+
// class with nothing bound rather than falling through to a push class: an
|
|
1164
|
+
// invocation whose targets cannot be read is the one that least deserves the
|
|
1165
|
+
// looser answer.
|
|
916
1166
|
if (hasFlag(args, ["--delete", "-d"])) {
|
|
917
|
-
return {
|
|
1167
|
+
return {
|
|
1168
|
+
class: REF_DELETE_CLASS,
|
|
1169
|
+
rule: "git-ref-delete",
|
|
1170
|
+
...(refspecs.length === 0 ? {} : { path: refspecs.join(" ") }),
|
|
1171
|
+
};
|
|
918
1172
|
}
|
|
919
1173
|
if (refspecs.length === 0) {
|
|
920
1174
|
return { class: "vcs.push.main", rule: "git-push-implicit" };
|
|
921
1175
|
}
|
|
1176
|
+
// The colon-refspec spellings, which need no flag: `:refs/heads/x`, `:x`, and
|
|
1177
|
+
// a bulk form mixing several. ONE deleting refspec makes the whole command a
|
|
1178
|
+
// deletion, because the command's effect is the union of its refspecs and the
|
|
1179
|
+
// destructive half is the half a person is being asked about.
|
|
1180
|
+
const deleted = refspecs.map(deletedRef).filter((ref) => ref !== null);
|
|
1181
|
+
if (deleted.length > 0) {
|
|
1182
|
+
return { class: REF_DELETE_CLASS, rule: "git-ref-delete", path: deleted.join(" ") };
|
|
1183
|
+
}
|
|
922
1184
|
let sawMain = false;
|
|
923
1185
|
for (const refspec of refspecs) {
|
|
924
1186
|
const colon = refspec.indexOf(":");
|
|
925
1187
|
const destination = colon === -1 ? refspec : refspec.slice(colon + 1);
|
|
926
|
-
// `:branch` (empty source) and `src:` (empty destination) both delete a
|
|
927
|
-
// remote ref. A deletion is destructive whatever it names, so it takes the
|
|
928
|
-
// stricter class rather than the branch one.
|
|
929
|
-
if (destination.length === 0 || (colon !== -1 && refspec.slice(0, colon).length === 0)) {
|
|
930
|
-
return { class: "vcs.push.main", rule: "git-push-delete" };
|
|
931
|
-
}
|
|
932
1188
|
if (isUnknownValue(destination)) {
|
|
933
1189
|
sawMain = true;
|
|
934
1190
|
continue;
|
|
@@ -955,6 +1211,223 @@ function refineGitPush(ctx) {
|
|
|
955
1211
|
* Everything that is not provably scratch keeps the old class.
|
|
956
1212
|
*/
|
|
957
1213
|
const SCRATCH_DELETE_CLASS = "files.delete.scratch";
|
|
1214
|
+
// ---------------------------------------------------------------------------
|
|
1215
|
+
// Agent harnesses (APRV-354)
|
|
1216
|
+
// ---------------------------------------------------------------------------
|
|
1217
|
+
/**
|
|
1218
|
+
* Launching an agent harness is its own class family, `harness.launch.NAME`.
|
|
1219
|
+
*
|
|
1220
|
+
* Until this, a command whose first word was `codex`, `muse`, `grok`, `claude`
|
|
1221
|
+
* or `cursor-agent` was `unclassified`: denied, which is fail closed and also
|
|
1222
|
+
* blunt. It told an approver nothing, it gave a human no class to grant through
|
|
1223
|
+
* the ordinary manual path, and it meant a lane could not so much as read a
|
|
1224
|
+
* harness version without going around the gate. The family fixes the second
|
|
1225
|
+
* half without touching the first: an unknown class still falls to
|
|
1226
|
+
* `defaults.autonomy`, so a project that adopts nothing is exactly where it was.
|
|
1227
|
+
*
|
|
1228
|
+
* ## The laundering hazard, which is why this is a class and not a table row
|
|
1229
|
+
*
|
|
1230
|
+
* A launched harness runs its own tools OUTSIDE this gate unless that harness's
|
|
1231
|
+
* own adapter is installed and attested. So a grant of `harness.launch.codex`
|
|
1232
|
+
* authorizes the LAUNCH and nothing the launched session then does — every
|
|
1233
|
+
* file it writes, every command it runs and every request it makes happens
|
|
1234
|
+
* where this runtime cannot see it. A policy that made the family autonomous
|
|
1235
|
+
* would have written itself out of the gate in one line, which is why SPEC.md
|
|
1236
|
+
* §7 says the family is never inferred autonomous and why the reference
|
|
1237
|
+
* proposal holds it at `manual`. The blessed Codex entry point is the confined
|
|
1238
|
+
* `approval codex start` (APRV-325.3), which keeps its own class.
|
|
1239
|
+
*
|
|
1240
|
+
* ## Muse, which carries a second hazard
|
|
1241
|
+
*
|
|
1242
|
+
* A Muse Code model id ending `-contributor` trades price for permission to
|
|
1243
|
+
* train on prompts and completions, so running one over real code shares that
|
|
1244
|
+
* code. Carter has ruled that Muse must never run with a Contributor model
|
|
1245
|
+
* selected. The classifier cannot enforce that — the model is a self-reported
|
|
1246
|
+
* argument, and a session may select one in its own UI where no command line
|
|
1247
|
+
* shows it — so it does the one thing a classifier honestly can: it BINDS the
|
|
1248
|
+
* `--model` value where it appears and gives a contributor value its own rule
|
|
1249
|
+
* id, so a prompt and a policy can both see it. This may only ever RAISE
|
|
1250
|
+
* scrutiny. A `-standard` id, or no `--model` at all, returns the ordinary rule
|
|
1251
|
+
* id and changes nothing, because a self-reported field that could lower
|
|
1252
|
+
* scrutiny is a field an agent would learn to write (SPEC.md §11.1 invariant 4).
|
|
1253
|
+
*
|
|
1254
|
+
* ## Where the family's name lives
|
|
1255
|
+
*
|
|
1256
|
+
* {@link HARNESS_LAUNCH_PREFIX} is `core/policy-match.ts`'s, not this file's,
|
|
1257
|
+
* and is imported rather than repeated. Two enforcement paths refuse a launch
|
|
1258
|
+
* that no policy rule names (`harnessLaunchNeedsRule`), and a second spelling of
|
|
1259
|
+
* the prefix is the shape of bug that closes one of those doors and leaves the
|
|
1260
|
+
* other open.
|
|
1261
|
+
*/
|
|
1262
|
+
/** The rule id prefix, so a reader can tell a launch row from a probe. */
|
|
1263
|
+
const HARNESS_LAUNCH_RULE_PREFIX = "harness-launch-";
|
|
1264
|
+
/**
|
|
1265
|
+
* Harness binaries, by BASENAME, to the name their class carries.
|
|
1266
|
+
*
|
|
1267
|
+
* Basenames, because that is what {@link classifySegment} derives before any
|
|
1268
|
+
* rule sees a command, and it is what makes `/opt/homebrew/bin/codex`,
|
|
1269
|
+
* `~/.local/bin/muse` and `$HOME/.local/bin/muse` all land here without this
|
|
1270
|
+
* table knowing anything about where a binary lives. A spelling the basename
|
|
1271
|
+
* derivation cannot see through — `$MUSE_BIN`, a wrapper script of another
|
|
1272
|
+
* name — is `unclassified`, which is the answer it had before and the answer it
|
|
1273
|
+
* should keep.
|
|
1274
|
+
*
|
|
1275
|
+
* `cursor-agent` carries the name `cursor` so the class reads
|
|
1276
|
+
* `harness.launch.cursor` beside the `cursor` adapter and the `.cursor/`
|
|
1277
|
+
* protected paths. `gemini` is deliberately absent: APRV-354 names five
|
|
1278
|
+
* harnesses, `gemini update` keeps its `deps.upgrade` row above this one, and a
|
|
1279
|
+
* bare `gemini` stays `unclassified` until somebody makes that its own decision.
|
|
1280
|
+
*/
|
|
1281
|
+
const HARNESS_BINS = {
|
|
1282
|
+
codex: "codex",
|
|
1283
|
+
muse: "muse",
|
|
1284
|
+
grok: "grok",
|
|
1285
|
+
claude: "claude",
|
|
1286
|
+
"cursor-agent": "cursor",
|
|
1287
|
+
// APRV-398. `hermes` is the console script Hermes Agent installs, and the
|
|
1288
|
+
// harness kind and the binary share one spelling, so the class reads
|
|
1289
|
+
// `harness.launch.hermes` beside the `hermes` adapter and the `.hermes/`
|
|
1290
|
+
// protected paths with no name mapping in between.
|
|
1291
|
+
hermes: "hermes",
|
|
1292
|
+
};
|
|
1293
|
+
/**
|
|
1294
|
+
* Package specs a package runner may name, EXACTLY, to the same harness names.
|
|
1295
|
+
*
|
|
1296
|
+
* Exact, and the exactness is the rule: `npx codex-helper` is not a codex
|
|
1297
|
+
* launch, and a substring match that said it was would let any package whose
|
|
1298
|
+
* name happens to contain a harness's take a class it did not earn. A spec this
|
|
1299
|
+
* table does not know keeps whatever class the runner already had.
|
|
1300
|
+
*/
|
|
1301
|
+
const HARNESS_PACKAGES = {
|
|
1302
|
+
codex: "codex",
|
|
1303
|
+
"@openai/codex": "codex",
|
|
1304
|
+
claude: "claude",
|
|
1305
|
+
"@anthropic-ai/claude-code": "claude",
|
|
1306
|
+
"cursor-agent": "cursor",
|
|
1307
|
+
muse: "muse",
|
|
1308
|
+
grok: "grok",
|
|
1309
|
+
// APRV-398. Hermes Agent is a Python distribution, so a package runner is not
|
|
1310
|
+
// how it is launched; the entries exist for the same reason the `muse` and
|
|
1311
|
+
// `grok` ones do, which is that a spec this table does not know keeps
|
|
1312
|
+
// whatever class the runner already had, and a spec it does know cannot be
|
|
1313
|
+
// spelled around the direct row.
|
|
1314
|
+
hermes: "hermes",
|
|
1315
|
+
"hermes-agent": "hermes",
|
|
1316
|
+
};
|
|
1317
|
+
/**
|
|
1318
|
+
* Argv that starts nothing: a version or help probe.
|
|
1319
|
+
*
|
|
1320
|
+
* A probe prints a string and exits, so it is a read, and reading a harness's
|
|
1321
|
+
* own version is exactly what a session needs to be able to do without a
|
|
1322
|
+
* prompt. `help` counts only as the WHOLE argv: `codex help` prints usage,
|
|
1323
|
+
* while `codex help me refactor this` is a session.
|
|
1324
|
+
*/
|
|
1325
|
+
const HARNESS_PROBE_FLAGS = ["--version", "-V", "--help", "-h"];
|
|
1326
|
+
/** The probe's rule id and class. A probe starts no session, so it reads. */
|
|
1327
|
+
const HARNESS_PROBE_RULE = "harness-probe";
|
|
1328
|
+
const HARNESS_PROBE_CLASS = "read.shell";
|
|
1329
|
+
/** The rule id a Muse launch takes when its `--model` names a Contributor model. */
|
|
1330
|
+
const MUSE_CONTRIBUTOR_RULE = "harness-launch-muse-contributor";
|
|
1331
|
+
/**
|
|
1332
|
+
* The suffix that marks a Muse model as training on what it is shown.
|
|
1333
|
+
*
|
|
1334
|
+
* Exported since APRV-350 so the hook adapter's contributor guard and this
|
|
1335
|
+
* classifier's `harness.launch.muse` refinement test the SAME mark. Two
|
|
1336
|
+
* spellings of "which models are unsafe" would drift, and the direction they
|
|
1337
|
+
* drift in is the one where a launch is refused and a tool call is not.
|
|
1338
|
+
*/
|
|
1339
|
+
export const CONTRIBUTOR_SUFFIX = "-contributor";
|
|
1340
|
+
/** The `--model` value in either spelling, or `null` when none is written. */
|
|
1341
|
+
function harnessModel(args) {
|
|
1342
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1343
|
+
const arg = args[index];
|
|
1344
|
+
if (arg === "--model") {
|
|
1345
|
+
const value = args[index + 1];
|
|
1346
|
+
return value === undefined || isFlag(value) ? null : value;
|
|
1347
|
+
}
|
|
1348
|
+
if (arg.startsWith("--model="))
|
|
1349
|
+
return arg.slice("--model=".length);
|
|
1350
|
+
}
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* The harness a package spec names, version suffix stripped, or `null`.
|
|
1355
|
+
*
|
|
1356
|
+
* `@openai/codex@0.152.1` is the scoped case the split has to get right: the
|
|
1357
|
+
* `@` that opens a scope is not the `@` that opens a version.
|
|
1358
|
+
*/
|
|
1359
|
+
function harnessPackage(spec) {
|
|
1360
|
+
const at = spec.startsWith("@") ? spec.indexOf("@", 1) : spec.indexOf("@");
|
|
1361
|
+
const bare = at === -1 ? spec : spec.slice(0, at);
|
|
1362
|
+
return HARNESS_PACKAGES[bare] ?? null;
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* One harness invocation, given its name and the argv that follows its identity.
|
|
1366
|
+
*
|
|
1367
|
+
* Shared by the direct rows and the package-runner refinement, so a launch
|
|
1368
|
+
* spelled `npx @openai/codex exec` answers exactly as `codex exec` does. The
|
|
1369
|
+
* argv is bound to `path`: the segment's own `text` carries the command as
|
|
1370
|
+
* written, and `path` carries the part the class is ABOUT, which is what lets a
|
|
1371
|
+
* channel name it without re-parsing the line.
|
|
1372
|
+
*/
|
|
1373
|
+
function harnessRefinement(name, args) {
|
|
1374
|
+
if (args.length === 1 &&
|
|
1375
|
+
(HARNESS_PROBE_FLAGS.includes(args[0]) || args[0] === "help")) {
|
|
1376
|
+
return { class: HARNESS_PROBE_CLASS, rule: HARNESS_PROBE_RULE };
|
|
1377
|
+
}
|
|
1378
|
+
// Anything else is a session. A probe flag beside other arguments is NOT a
|
|
1379
|
+
// probe here: the classifier cannot know which of the two the binary will
|
|
1380
|
+
// honour, and the stricter reading of an ambiguous harness invocation is the
|
|
1381
|
+
// one that assumes a session started.
|
|
1382
|
+
const model = name === "muse" ? harnessModel(args) : null;
|
|
1383
|
+
const contributor = model !== null && model.toLowerCase().endsWith(CONTRIBUTOR_SUFFIX);
|
|
1384
|
+
return {
|
|
1385
|
+
class: `${HARNESS_LAUNCH_PREFIX}${name}`,
|
|
1386
|
+
rule: contributor ? MUSE_CONTRIBUTOR_RULE : `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
|
|
1387
|
+
...(args.length === 0 ? {} : { path: args.join(" ") }),
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
/** `codex …`, `muse …`, `grok …`, `claude …`, `cursor-agent …`. */
|
|
1391
|
+
function refineHarness(ctx) {
|
|
1392
|
+
const name = HARNESS_BINS[ctx.bin];
|
|
1393
|
+
// Unreachable through the table, which matches on these basenames; a defensive
|
|
1394
|
+
// arm rather than a silent wrong class if a row is ever edited apart from the
|
|
1395
|
+
// table it is generated from.
|
|
1396
|
+
if (name === undefined) {
|
|
1397
|
+
return { opaque: `${ctx.bin} is an agent harness this table cannot name` };
|
|
1398
|
+
}
|
|
1399
|
+
return harnessRefinement(name, ctx.args);
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* `npx`, `tsx`, `tsc`, … — unchanged, except a package runner naming a harness.
|
|
1403
|
+
*
|
|
1404
|
+
* `npx @openai/codex` starts the same session `codex` starts, and before this
|
|
1405
|
+
* it was `files.write.workspace`: the looser of the two answers, reachable by
|
|
1406
|
+
* typing four extra characters. The refinement is deliberately narrow — only
|
|
1407
|
+
* `npx`, only an EXACT package spec, and everything else returns the row's own
|
|
1408
|
+
* answer byte for byte, which is what keeps this from being a widening of the
|
|
1409
|
+
* workspace-tool row.
|
|
1410
|
+
*/
|
|
1411
|
+
function refineWorkspaceTool(ctx) {
|
|
1412
|
+
const unchanged = { class: "files.write.workspace", rule: "workspace-tool" };
|
|
1413
|
+
if (ctx.bin !== "npx")
|
|
1414
|
+
return unchanged;
|
|
1415
|
+
const index = ctx.args.findIndex((arg) => !isFlag(arg));
|
|
1416
|
+
if (index === -1)
|
|
1417
|
+
return unchanged;
|
|
1418
|
+
const name = harnessPackage(ctx.args[index]);
|
|
1419
|
+
if (name === null)
|
|
1420
|
+
return unchanged;
|
|
1421
|
+
return harnessRefinement(name, ctx.args.slice(index + 1));
|
|
1422
|
+
}
|
|
1423
|
+
/** The five generated harness rows, one per binary, sharing one refinement. */
|
|
1424
|
+
const HARNESS_RULES = Object.entries(HARNESS_BINS).map(([bin, name]) => ({
|
|
1425
|
+
id: `${HARNESS_LAUNCH_RULE_PREFIX}${name}`,
|
|
1426
|
+
bins: [bin],
|
|
1427
|
+
class: `${HARNESS_LAUNCH_PREFIX}${name}`,
|
|
1428
|
+
emits: [HARNESS_PROBE_CLASS],
|
|
1429
|
+
refine: refineHarness,
|
|
1430
|
+
}));
|
|
958
1431
|
/**
|
|
959
1432
|
* Is `candidate` a STRICT descendant of `root`? Both are compared by path
|
|
960
1433
|
* segment, so `/private/tmpfoo` is not under `/private/tmp` and a root is never
|
|
@@ -998,6 +1471,43 @@ function allTargetsAreScratch(targets, roots) {
|
|
|
998
1471
|
}
|
|
999
1472
|
return true;
|
|
1000
1473
|
}
|
|
1474
|
+
// ===========================================================================
|
|
1475
|
+
// Read scope (read.file.out_of_scope, APRV-347)
|
|
1476
|
+
// ===========================================================================
|
|
1477
|
+
/** The rule id for a read whose ABSOLUTE target sits outside every root. */
|
|
1478
|
+
export const READ_OUT_OF_SCOPE_RULE = "read-out-of-scope";
|
|
1479
|
+
/** The rule id for a read target whose expansion the text cannot show. */
|
|
1480
|
+
export const READ_UNREADABLE_TARGET_RULE = "read-unreadable-path";
|
|
1481
|
+
/**
|
|
1482
|
+
* The first target of this read command that the TEXT places outside every
|
|
1483
|
+
* root, or `null` when nothing here settles it.
|
|
1484
|
+
*
|
|
1485
|
+
* `null` covers three different situations on purpose, and all three are
|
|
1486
|
+
* handed on rather than decided: the binary is not a scoped reader, every
|
|
1487
|
+
* target is provably inside a root, or a target is relative (or carries `..`)
|
|
1488
|
+
* and therefore means nothing without a working directory. The last of those
|
|
1489
|
+
* is the common case, and it is why the hook's second pass exists.
|
|
1490
|
+
*/
|
|
1491
|
+
function escapedReadTarget(bin, positionals, args, roots) {
|
|
1492
|
+
const targets = readTargetsOf(bin, positionals, args);
|
|
1493
|
+
if (targets === null)
|
|
1494
|
+
return null;
|
|
1495
|
+
for (const target of targets) {
|
|
1496
|
+
switch (readTargetVerdict(target, roots)) {
|
|
1497
|
+
case "out-of-scope":
|
|
1498
|
+
return {
|
|
1499
|
+
path: target,
|
|
1500
|
+
rule: isUnreadableTarget(target)
|
|
1501
|
+
? READ_UNREADABLE_TARGET_RULE
|
|
1502
|
+
: READ_OUT_OF_SCOPE_RULE,
|
|
1503
|
+
};
|
|
1504
|
+
case "in-scope":
|
|
1505
|
+
case "needs-disk":
|
|
1506
|
+
break;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1001
1511
|
/** `rm` — everything outside the workspace, and every unreadable path, is manual. */
|
|
1002
1512
|
function refineRm(ctx) {
|
|
1003
1513
|
const recursive = hasFlag(ctx.args, ["--recursive"]) || hasShortFlag(ctx.args, ["r", "R"]);
|
|
@@ -1103,6 +1613,429 @@ function refineSed(ctx) {
|
|
|
1103
1613
|
? { class: "files.write.workspace", rule: "sed-in-place" }
|
|
1104
1614
|
: { class: "read.shell", rule: "sed-read" };
|
|
1105
1615
|
}
|
|
1616
|
+
// ===========================================================================
|
|
1617
|
+
// Packaging and archives (APRV-397)
|
|
1618
|
+
// ===========================================================================
|
|
1619
|
+
/**
|
|
1620
|
+
* The read-only packaging and archive tools, and the one rule they share.
|
|
1621
|
+
*
|
|
1622
|
+
* ## What was wrong
|
|
1623
|
+
*
|
|
1624
|
+
* A release verification is mostly reading: pack a tarball, list what is in it,
|
|
1625
|
+
* unpack it somewhere disposable, hash a file, decode a base64 blob, ask a
|
|
1626
|
+
* binary its version. Every one of those commands was `unclassified` until this
|
|
1627
|
+
* task, so every one of them was DENIED, and the verifier that met the wall on
|
|
1628
|
+
* 2026-09-20 did the work anyway by fetching the tarball with `curl` and
|
|
1629
|
+
* parsing it in a script: a chain of network and interpreter calls in place of
|
|
1630
|
+
* six readers. A gate that refuses the legible spelling and leaves the
|
|
1631
|
+
* illegible one open has made the session less inspectable rather than safer.
|
|
1632
|
+
*
|
|
1633
|
+
* ## The one rule
|
|
1634
|
+
*
|
|
1635
|
+
* Each of these commands either reads what it names or writes into a
|
|
1636
|
+
* destination it names, and the destination is in the text. So they are decided
|
|
1637
|
+
* by the arithmetic {@link refineRm} already uses, hoisted into
|
|
1638
|
+
* {@link scopedWrite}: a destination strictly under a scratch root the CALLER
|
|
1639
|
+
* resolved is the agent's own space, a relative destination is the workspace,
|
|
1640
|
+
* and an absolute path anywhere else, a `..` segment, or a value the text cannot
|
|
1641
|
+
* read is out of scope.
|
|
1642
|
+
*
|
|
1643
|
+
* ## Why the out-of-scope answer is a class that already exists
|
|
1644
|
+
*
|
|
1645
|
+
* {@link OUT_OF_SCOPE_WRITE_CLASS} is `files.delete.out_of_scope`, which is
|
|
1646
|
+
* what `rm` and `find -delete` already answer for a destructive file operation
|
|
1647
|
+
* the text places outside the workspace. Minting `files.write.out_of_scope`
|
|
1648
|
+
* instead would have been the one change here capable of loosening something: a
|
|
1649
|
+
* class no policy names resolves by `defaults.autonomy` (SPEC.md §7), so in
|
|
1650
|
+
* every deployment whose defaults are permissive a brand-new name arrives
|
|
1651
|
+
* autonomous, while the existing class is held at `manual` by the reference
|
|
1652
|
+
* policy and by this repository's. An extraction is also honestly described by
|
|
1653
|
+
* it: unpacking an archive over a directory overwrites whatever it finds there,
|
|
1654
|
+
* which is the destruction the class is about.
|
|
1655
|
+
*
|
|
1656
|
+
* The strictness this inherits is `rm`'s, and it is deliberate. An ABSOLUTE
|
|
1657
|
+
* destination is out of scope even when it happens to sit inside the checkout,
|
|
1658
|
+
* because this file holds no workspace root, and the read roots it does hold
|
|
1659
|
+
* are a read notion that must not become a write authorization.
|
|
1660
|
+
*/
|
|
1661
|
+
const OUT_OF_SCOPE_WRITE_CLASS = "files.delete.out_of_scope";
|
|
1662
|
+
/** The one rule id every out-of-scope packaging destination reports. */
|
|
1663
|
+
const OUT_OF_SCOPE_WRITE_RULE = "packaging-write-out-of-scope";
|
|
1664
|
+
/**
|
|
1665
|
+
* A destination a flag names, as a list {@link scopedWrite} can read.
|
|
1666
|
+
*
|
|
1667
|
+
* Three answers, and the third is the one worth naming: `[]` when the flag is
|
|
1668
|
+
* absent (the command writes where it stands, which is the working directory),
|
|
1669
|
+
* `[value]` when the flag names something the text can read, and `[null]` when
|
|
1670
|
+
* the flag is PRESENT and its value is not in the words. The last is a refusal
|
|
1671
|
+
* rather than an absence, because a flag present with nothing readable after it
|
|
1672
|
+
* would otherwise be answered as "no destination named" and take the looser
|
|
1673
|
+
* branch.
|
|
1674
|
+
*/
|
|
1675
|
+
function namedDestination(args, names) {
|
|
1676
|
+
if (!hasFlag(args, names))
|
|
1677
|
+
return [];
|
|
1678
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1679
|
+
const arg = args[index];
|
|
1680
|
+
const equals = arg.indexOf("=");
|
|
1681
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
1682
|
+
if (!names.includes(name))
|
|
1683
|
+
continue;
|
|
1684
|
+
if (equals !== -1) {
|
|
1685
|
+
const value = arg.slice(equals + 1);
|
|
1686
|
+
return [value.length === 0 ? null : value];
|
|
1687
|
+
}
|
|
1688
|
+
const value = args[index + 1];
|
|
1689
|
+
return [value === undefined || isFlag(value) ? null : value];
|
|
1690
|
+
}
|
|
1691
|
+
return [null];
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* The class of a write into destinations the command names (APRV-397).
|
|
1695
|
+
*
|
|
1696
|
+
* The same fail-closed branches {@link refineRm} has, in the same order,
|
|
1697
|
+
* against the same roots: all-scratch loosens, and an unreadable destination, an
|
|
1698
|
+
* absolute one, or a `..` segment is out of scope with the destination BOUND so
|
|
1699
|
+
* an approver is told which path earned the class. An empty destination list is
|
|
1700
|
+
* the working directory, which this file cannot see and must not guess at, so it
|
|
1701
|
+
* takes the row's own workspace answer exactly as `rm` with no target does.
|
|
1702
|
+
*/
|
|
1703
|
+
function scopedWrite(ctx, destinations, rule) {
|
|
1704
|
+
const named = destinations.filter((entry) => entry !== null);
|
|
1705
|
+
if (named.length < destinations.length) {
|
|
1706
|
+
return { class: OUT_OF_SCOPE_WRITE_CLASS, rule: OUT_OF_SCOPE_WRITE_RULE };
|
|
1707
|
+
}
|
|
1708
|
+
if (allTargetsAreScratch(named, ctx.context.scratchRoots ?? [])) {
|
|
1709
|
+
return { class: "files.write.workspace", rule };
|
|
1710
|
+
}
|
|
1711
|
+
for (const destination of named) {
|
|
1712
|
+
if (destination.startsWith("/") ||
|
|
1713
|
+
pathSegments(destination).includes("..") ||
|
|
1714
|
+
isUnknownValue(destination)) {
|
|
1715
|
+
return { class: OUT_OF_SCOPE_WRITE_CLASS, rule: OUT_OF_SCOPE_WRITE_RULE, path: destination };
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
return { class: "files.write.workspace", rule };
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* An argv that asks a binary about itself: a version or a usage string.
|
|
1722
|
+
*
|
|
1723
|
+
* The harness table has its own copy of this idea ({@link HARNESS_PROBE_FLAGS})
|
|
1724
|
+
* and keeps it, because a harness probe still EXECUTES a whole second agent's
|
|
1725
|
+
* binary and is named in {@link CODE_EXECUTING_RULES} for that reason. This one
|
|
1726
|
+
* is the package managers', where `npm --version` prints a string and starts
|
|
1727
|
+
* nothing at all. `-v` is here and absent there: it is npm's version flag and
|
|
1728
|
+
* nothing else's.
|
|
1729
|
+
*/
|
|
1730
|
+
const VERSION_PROBE_FLAGS = ["--version", "-v", "-V", "--help", "-h"];
|
|
1731
|
+
/** Is this argv nothing but probe flags? One word at minimum, so a bare binary is not a probe. */
|
|
1732
|
+
function isVersionProbe(args) {
|
|
1733
|
+
return args.length > 0 && args.every((arg) => VERSION_PROBE_FLAGS.includes(arg));
|
|
1734
|
+
}
|
|
1735
|
+
/** Positionals, with the values of value-taking flags left out of the count. */
|
|
1736
|
+
function positionalsBesideValues(args, valueFlags) {
|
|
1737
|
+
const out = [];
|
|
1738
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1739
|
+
const arg = args[index];
|
|
1740
|
+
if (isFlag(arg)) {
|
|
1741
|
+
if (valueFlags.includes(arg))
|
|
1742
|
+
index += 1;
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
out.push(arg);
|
|
1746
|
+
}
|
|
1747
|
+
return out;
|
|
1748
|
+
}
|
|
1749
|
+
/** `npm pack`'s flags whose value is a path or a workspace name, never a package spec. */
|
|
1750
|
+
const NPM_PACK_VALUE_FLAGS = ["--pack-destination", "--workspace", "-w"];
|
|
1751
|
+
/**
|
|
1752
|
+
* Does this `npm pack` argument name something on disk rather than a registry
|
|
1753
|
+
* package?
|
|
1754
|
+
*
|
|
1755
|
+
* A path, in the spellings npm accepts for one. Everything else is a package
|
|
1756
|
+
* spec, which `npm pack` resolves by FETCHING it, and a value the text cannot
|
|
1757
|
+
* read is a spec too: the looser reading of an unreadable argument is the one
|
|
1758
|
+
* that lets a download through as a local pack.
|
|
1759
|
+
*/
|
|
1760
|
+
function isLocalPackSpec(spec) {
|
|
1761
|
+
if (isUnknownValue(spec))
|
|
1762
|
+
return false;
|
|
1763
|
+
if (spec.startsWith(".") || spec.startsWith("/"))
|
|
1764
|
+
return true;
|
|
1765
|
+
return spec.includes("/") && !spec.startsWith("@");
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* `npm pack` — a tarball into the working directory, or into
|
|
1769
|
+
* `--pack-destination`.
|
|
1770
|
+
*
|
|
1771
|
+
* Two answers beside the scoped write. A positional naming a REGISTRY package
|
|
1772
|
+
* is `network.call`, because that spelling downloads the package before it packs
|
|
1773
|
+
* it and the tarball it then writes is beside the point. And the write itself
|
|
1774
|
+
* runs the package's own `prepack` and `prepare` scripts, which is why the rule
|
|
1775
|
+
* id is in {@link CODE_EXECUTING_RULES}.
|
|
1776
|
+
*/
|
|
1777
|
+
function refineNpmPack(ctx) {
|
|
1778
|
+
const specs = positionalsBesideValues(ctx.args, NPM_PACK_VALUE_FLAGS).slice(1);
|
|
1779
|
+
if (specs.some((spec) => !isLocalPackSpec(spec))) {
|
|
1780
|
+
return { class: "network.call", rule: "npm-pack-remote" };
|
|
1781
|
+
}
|
|
1782
|
+
return scopedWrite(ctx, namedDestination(ctx.args, ["--pack-destination"]), "npm-pack");
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* `npm init` — `package.json` into the working directory.
|
|
1786
|
+
*
|
|
1787
|
+
* No flag moves it, so there is no destination to scope and the answer is the
|
|
1788
|
+
* workspace write the row declares. `npm init <pkg>` is a different act, since
|
|
1789
|
+
* it downloads an initializer and runs it, which is `npm exec` wearing another
|
|
1790
|
+
* name; it takes its own rule id and its seat in {@link CODE_EXECUTING_RULES}
|
|
1791
|
+
* beside `npm-script`.
|
|
1792
|
+
*/
|
|
1793
|
+
function refineNpmInit(ctx) {
|
|
1794
|
+
const initializer = positionalsBesideValues(ctx.args, ["--workspace", "-w"]).slice(1);
|
|
1795
|
+
return initializer.length === 0
|
|
1796
|
+
? { class: "files.write.workspace", rule: "npm-init" }
|
|
1797
|
+
: { class: "files.write.workspace", rule: "npm-init-create" };
|
|
1798
|
+
}
|
|
1799
|
+
/** `tar`'s long spellings of each mode, beside the one-letter ones. */
|
|
1800
|
+
const TAR_LIST_FLAGS = ["--list"];
|
|
1801
|
+
const TAR_EXTRACT_FLAGS = ["--extract", "--get"];
|
|
1802
|
+
const TAR_CREATE_FLAGS = [
|
|
1803
|
+
"--create",
|
|
1804
|
+
"--append",
|
|
1805
|
+
"--update",
|
|
1806
|
+
"--delete",
|
|
1807
|
+
"--concatenate",
|
|
1808
|
+
"--catenate",
|
|
1809
|
+
];
|
|
1810
|
+
/** The argv that ask `tar` about itself and open no archive at all. */
|
|
1811
|
+
const TAR_PROBE_FLAGS = ["--version", "--help", "--usage"];
|
|
1812
|
+
/**
|
|
1813
|
+
* The mode letters `tar` was given, from the short bundles and the old-style
|
|
1814
|
+
* first word.
|
|
1815
|
+
*
|
|
1816
|
+
* `tar xzf a.tgz` carries no dash at all, and it is the spelling most sessions
|
|
1817
|
+
* write, so a rule that read only `-x` would answer the common form with a
|
|
1818
|
+
* refusal. Only the FIRST word can be an old-style bundle, and a word is read as
|
|
1819
|
+
* a bundle only when it is letters and nothing else: `-fa.tgz` is a glued
|
|
1820
|
+
* filename, and a letter taken out of a filename is a mode the command never
|
|
1821
|
+
* named. Dropping such a word costs a refusal (the mode becomes unreadable) and
|
|
1822
|
+
* can never invent a read.
|
|
1823
|
+
*/
|
|
1824
|
+
function tarModeLetters(args) {
|
|
1825
|
+
let letters = "";
|
|
1826
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1827
|
+
const arg = args[index];
|
|
1828
|
+
if (/^-[A-Za-z]+$/u.test(arg))
|
|
1829
|
+
letters += arg.slice(1);
|
|
1830
|
+
else if (index === 0 && /^[A-Za-z]+$/u.test(arg))
|
|
1831
|
+
letters += arg;
|
|
1832
|
+
}
|
|
1833
|
+
return letters;
|
|
1834
|
+
}
|
|
1835
|
+
/** The tar letters that consume the next word, which is what makes a bundle ambiguous. */
|
|
1836
|
+
const TAR_VALUE_LETTERS = ["f", "C"];
|
|
1837
|
+
/** The option letters of a tar word, or `null` when it is not a bundle of them. */
|
|
1838
|
+
function tarBundle(arg, index) {
|
|
1839
|
+
if (/^-[A-Za-z]+$/u.test(arg))
|
|
1840
|
+
return arg.slice(1);
|
|
1841
|
+
if (index === 0 && /^[A-Za-z]+$/u.test(arg))
|
|
1842
|
+
return arg;
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* The value a tar flag carries, in every spelling tar accepts for it.
|
|
1847
|
+
*
|
|
1848
|
+
* The glued bundle is the whole reason this exists rather than
|
|
1849
|
+
* {@link namedDestination}: `tar -czf out.tgz src` names its archive with an
|
|
1850
|
+
* `f` buried in `-czf`, which an exact-word flag match cannot see, and a rule
|
|
1851
|
+
* that missed it would answer the commonest spelling of a create with the
|
|
1852
|
+
* looser branch.
|
|
1853
|
+
*
|
|
1854
|
+
* One bundle carrying BOTH value-taking letters (`-xCf`) is `null`, which
|
|
1855
|
+
* {@link scopedWrite} refuses: which of the following words feeds which letter
|
|
1856
|
+
* depends on tar's own option order, so the destination is not in the text
|
|
1857
|
+
* (SPEC.md §11.1, ambiguity resolves to the stricter class).
|
|
1858
|
+
*/
|
|
1859
|
+
function tarFlagValue(args, letter, longNames) {
|
|
1860
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1861
|
+
const arg = args[index];
|
|
1862
|
+
const equals = arg.indexOf("=");
|
|
1863
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
1864
|
+
if (longNames.includes(name)) {
|
|
1865
|
+
if (equals === -1) {
|
|
1866
|
+
const next = args[index + 1];
|
|
1867
|
+
return [next === undefined || isFlag(next) ? null : next];
|
|
1868
|
+
}
|
|
1869
|
+
const value = arg.slice(equals + 1);
|
|
1870
|
+
return [value.length === 0 ? null : value];
|
|
1871
|
+
}
|
|
1872
|
+
const bundle = tarBundle(arg, index);
|
|
1873
|
+
if (bundle === null || !bundle.includes(letter))
|
|
1874
|
+
continue;
|
|
1875
|
+
if (TAR_VALUE_LETTERS.filter((candidate) => bundle.includes(candidate)).length > 1) {
|
|
1876
|
+
return [null];
|
|
1877
|
+
}
|
|
1878
|
+
const next = args[index + 1];
|
|
1879
|
+
return [next === undefined || isFlag(next) ? null : next];
|
|
1880
|
+
}
|
|
1881
|
+
return [];
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* `tar` — a listing reads, an extraction writes where `-C` points, a creation
|
|
1885
|
+
* writes the archive `-f` names.
|
|
1886
|
+
*
|
|
1887
|
+
* The write branch is checked FIRST and takes both destinations when one command
|
|
1888
|
+
* names both modes, because `tar` resolves a contradictory mode by its own
|
|
1889
|
+
* option order and the stricter reading is the one that assumes it writes.
|
|
1890
|
+
*
|
|
1891
|
+
* A mode the text does not carry is `opaque` rather than a guess, and that is
|
|
1892
|
+
* the honest code for it: a `tar` whose mode is not in its words is a command
|
|
1893
|
+
* whose effect is not in its words, which is what {@link OPAQUE_BINS} refuses
|
|
1894
|
+
* for the same reason.
|
|
1895
|
+
*/
|
|
1896
|
+
function refineTar(ctx) {
|
|
1897
|
+
const letters = tarModeLetters(ctx.args);
|
|
1898
|
+
const extracting = letters.includes("x") || hasFlag(ctx.args, TAR_EXTRACT_FLAGS);
|
|
1899
|
+
const creating = ["c", "r", "u", "A"].some((letter) => letters.includes(letter)) ||
|
|
1900
|
+
hasFlag(ctx.args, TAR_CREATE_FLAGS);
|
|
1901
|
+
if (extracting || creating) {
|
|
1902
|
+
const destinations = [
|
|
1903
|
+
...(extracting ? tarFlagValue(ctx.args, "C", ["--directory"]) : []),
|
|
1904
|
+
...(creating ? tarFlagValue(ctx.args, "f", ["--file"]) : []),
|
|
1905
|
+
];
|
|
1906
|
+
return scopedWrite(ctx, destinations, extracting ? "tar-extract" : "tar-create");
|
|
1907
|
+
}
|
|
1908
|
+
if (letters.includes("t") || hasFlag(ctx.args, TAR_LIST_FLAGS)) {
|
|
1909
|
+
return { class: "read.shell", rule: "tar-list" };
|
|
1910
|
+
}
|
|
1911
|
+
if (ctx.args.length > 0 && ctx.args.every((arg) => TAR_PROBE_FLAGS.includes(arg))) {
|
|
1912
|
+
return { class: "read.shell", rule: "tar-probe" };
|
|
1913
|
+
}
|
|
1914
|
+
return { opaque: "tar names no mode this classifier can read" };
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* `gunzip` — the stdout, test and list forms read; every other form REPLACES a
|
|
1918
|
+
* file.
|
|
1919
|
+
*
|
|
1920
|
+
* The default is the trap: `gunzip pkg.tgz` removes `pkg.tgz` and leaves
|
|
1921
|
+
* `pkg.tar` in its place, so the plain spelling is a write of the path it names.
|
|
1922
|
+
* Three flags leave the disk alone: `-c`/`--stdout` decompresses to standard
|
|
1923
|
+
* output, `-t`/`--test` checks the integrity of the archive, and `-l`/`--list`
|
|
1924
|
+
* prints the compressed and uncompressed sizes, the ratio and the member name.
|
|
1925
|
+
* A listing is the same kind of act as `tar -t` beside it and belongs in the
|
|
1926
|
+
* same class. `-k` (`--keep`) is deliberately not a read: it spares the input
|
|
1927
|
+
* and still creates the output.
|
|
1928
|
+
*/
|
|
1929
|
+
function refineGunzip(ctx) {
|
|
1930
|
+
const reading = hasShortFlag(ctx.args, ["c", "t", "l"]) ||
|
|
1931
|
+
hasFlag(ctx.args, ["--stdout", "--to-stdout", "--test", "--list"]);
|
|
1932
|
+
return reading
|
|
1933
|
+
? { class: "read.shell", rule: "gunzip-read" }
|
|
1934
|
+
: scopedWrite(ctx, ctx.positionals, "gunzip-write");
|
|
1935
|
+
}
|
|
1936
|
+
/** `base64` — a read of what it names, unless `-o` names a file to write. */
|
|
1937
|
+
function refineBase64(ctx) {
|
|
1938
|
+
const destination = namedDestination(ctx.args, ["-o", "--output"]);
|
|
1939
|
+
return destination.length === 0
|
|
1940
|
+
? { class: "read.shell", rule: "base64-read" }
|
|
1941
|
+
: scopedWrite(ctx, destination, "base64-write");
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* `openssl`'s digest subcommands, and only those.
|
|
1945
|
+
*
|
|
1946
|
+
* The row names the digests rather than the binary, so `openssl enc`, `genrsa`,
|
|
1947
|
+
* `req`, `rand` and `s_client` stay exactly as unclassified as they were. Each
|
|
1948
|
+
* of those writes key material, encrypts, or opens a socket, and none of them is
|
|
1949
|
+
* what a release verification runs; a row for the binary would have swept all of
|
|
1950
|
+
* them into a read.
|
|
1951
|
+
*/
|
|
1952
|
+
const OPENSSL_DIGEST_SUBS = [
|
|
1953
|
+
"dgst",
|
|
1954
|
+
"md5",
|
|
1955
|
+
"sha1",
|
|
1956
|
+
"sha256",
|
|
1957
|
+
"sha384",
|
|
1958
|
+
"sha512",
|
|
1959
|
+
];
|
|
1960
|
+
/** `openssl dgst …` — a read of its named files, unless `-out` names one to write. */
|
|
1961
|
+
function refineOpensslDigest(ctx) {
|
|
1962
|
+
const destination = namedDestination(ctx.args, ["-out"]);
|
|
1963
|
+
return destination.length === 0
|
|
1964
|
+
? { class: "read.shell", rule: "openssl-digest" }
|
|
1965
|
+
: scopedWrite(ctx, destination, "openssl-digest-out");
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* `git tag` — the listing forms read (APRV-397).
|
|
1969
|
+
*
|
|
1970
|
+
* APRV-305 gave the whole verb `release.publish`, which is right for the half
|
|
1971
|
+
* of it that creates, moves, signs and deletes tags: a tag is the name a
|
|
1972
|
+
* release was published under. It is wrong for the other half. `git tag -l` is
|
|
1973
|
+
* a listing of local refs, and on 2026-09-20 it put a nine-minute question on a
|
|
1974
|
+
* phone during a state check. Nothing about a listing reaches a registry, a
|
|
1975
|
+
* remote or a consumer, and the class it takes is `read.shell`, the class every
|
|
1976
|
+
* other reader of local repository metadata takes (`git log`, `git branch`,
|
|
1977
|
+
* `git status`).
|
|
1978
|
+
*
|
|
1979
|
+
* The split is an ALLOWLIST of the flags that list, and that direction is the
|
|
1980
|
+
* whole safety argument: a flag this rule has never heard of is a creation, so a
|
|
1981
|
+
* future `git tag` option cannot arrive as a read by default. A positional is a
|
|
1982
|
+
* TAG NAME, which creates one, unless `-l` is present and makes it a pattern.
|
|
1983
|
+
* The shape mirrors {@link refineGitBranch}, which splits read and write halves
|
|
1984
|
+
* of one verb for the same reason.
|
|
1985
|
+
*/
|
|
1986
|
+
const GIT_TAG_READ_FLAGS = [
|
|
1987
|
+
"-l",
|
|
1988
|
+
"--list",
|
|
1989
|
+
"-n",
|
|
1990
|
+
"-i",
|
|
1991
|
+
"--ignore-case",
|
|
1992
|
+
"--omit-empty",
|
|
1993
|
+
"--column",
|
|
1994
|
+
"--no-column",
|
|
1995
|
+
"--color",
|
|
1996
|
+
"--no-color",
|
|
1997
|
+
];
|
|
1998
|
+
/** Listing flags whose next word is a value rather than a tag name. */
|
|
1999
|
+
const GIT_TAG_READ_VALUE_FLAGS = [
|
|
2000
|
+
"--contains",
|
|
2001
|
+
"--no-contains",
|
|
2002
|
+
"--points-at",
|
|
2003
|
+
"--merged",
|
|
2004
|
+
"--no-merged",
|
|
2005
|
+
"--sort",
|
|
2006
|
+
"--format",
|
|
2007
|
+
];
|
|
2008
|
+
function refineGitTag(ctx) {
|
|
2009
|
+
const publish = { class: "release.publish", rule: "git-tag" };
|
|
2010
|
+
const args = ctx.args.slice(1);
|
|
2011
|
+
const names = [];
|
|
2012
|
+
let listing = false;
|
|
2013
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
2014
|
+
const arg = args[index];
|
|
2015
|
+
if (!isFlag(arg)) {
|
|
2016
|
+
names.push(arg);
|
|
2017
|
+
continue;
|
|
2018
|
+
}
|
|
2019
|
+
const equals = arg.indexOf("=");
|
|
2020
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
2021
|
+
if (name === "-l" || name === "--list") {
|
|
2022
|
+
listing = true;
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
if (GIT_TAG_READ_FLAGS.includes(name) || /^-n\d+$/u.test(arg))
|
|
2026
|
+
continue;
|
|
2027
|
+
if (GIT_TAG_READ_VALUE_FLAGS.includes(name)) {
|
|
2028
|
+
if (equals === -1)
|
|
2029
|
+
index += 1;
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
// Everything the allowlist does not name: a creation, a deletion, a
|
|
2033
|
+
// signature, a force-move, and also a flag that did not exist when this rule
|
|
2034
|
+
// was written.
|
|
2035
|
+
return publish;
|
|
2036
|
+
}
|
|
2037
|
+
return names.length > 0 && !listing ? publish : { class: "read.shell", rule: "git-tag-read" };
|
|
2038
|
+
}
|
|
1106
2039
|
/**
|
|
1107
2040
|
* The methods a fetch may name and still be a read: GET, and HEAD, which is a
|
|
1108
2041
|
* GET that discards the body. Everything else, including a method the
|
|
@@ -1427,12 +2360,28 @@ function isGateEntrypoint(path) {
|
|
|
1427
2360
|
* ritual reached the approver's phone as `policy.edit` over a protected path —
|
|
1428
2361
|
* true, and useless. Classified by name it arrives as what it is.
|
|
1429
2362
|
*
|
|
1430
|
-
* `
|
|
1431
|
-
*
|
|
2363
|
+
* `policy attest --path` (APRV-338) is the newest member and the one that reads
|
|
2364
|
+
* a FLAG, because the flag is what changes the act. Without it the verb attests
|
|
2365
|
+
* the policy file or a gate organ, which are records about the gate's own
|
|
2366
|
+
* configuration; with it the verb ratifies protected TEXT, and that record is
|
|
2367
|
+
* what resolves SPEC.md's pending-sign-off suffix. An agent able to write one
|
|
2368
|
+
* could ratify its own amendments, so it is classified where the rest of the
|
|
2369
|
+
* gate's ceremonies are: `policy.core`, which this repository's policy holds
|
|
2370
|
+
* human-only, so the hook denies it with `hook-class-human-only` before the
|
|
2371
|
+
* verb's own `actor-not-human` refusal is ever reached. It mints no new class
|
|
2372
|
+
* (§11.1 invariant 9): `policy.core` already exists and is already in the row's
|
|
2373
|
+
* `emits`.
|
|
2374
|
+
*
|
|
2375
|
+
* `positionals` is read rather than `args` for the verb words, so a flag between
|
|
2376
|
+
* them cannot hide the verb: `approval --json log sync` is the same invocation.
|
|
2377
|
+
* `args` is read only where a flag is the act, as it is above.
|
|
1432
2378
|
*/
|
|
1433
|
-
function refineApprovalVerb(positionals) {
|
|
2379
|
+
function refineApprovalVerb(positionals, args = []) {
|
|
1434
2380
|
const verb = positionals[0];
|
|
1435
2381
|
const sub = positionals[1];
|
|
2382
|
+
if (verb === "policy" && sub === "attest" && hasFlag(args, ["--path"])) {
|
|
2383
|
+
return { class: "policy.core", rule: "approval-policy-signoff" };
|
|
2384
|
+
}
|
|
1436
2385
|
if (verb === "quickstart") {
|
|
1437
2386
|
return { class: "policy.core", rule: "approval-quickstart" };
|
|
1438
2387
|
}
|
|
@@ -1454,6 +2403,19 @@ function refineApprovalVerb(positionals) {
|
|
|
1454
2403
|
return { class: "policy.core", rule: "approval-log-checkpoint" };
|
|
1455
2404
|
return null;
|
|
1456
2405
|
}
|
|
2406
|
+
// APRV-455. `channel relay` records gestures attributed to an EdgeOS account
|
|
2407
|
+
// on the word of whoever holds a secret the LAUNCHER chose. An agent that
|
|
2408
|
+
// could start one with a secret of its own, and post to it, would write
|
|
2409
|
+
// records that read as transport-authenticated evidence from a person's
|
|
2410
|
+
// session, which a Telegram listener cannot be made to do without the bot
|
|
2411
|
+
// token. So starting it is the operator's ceremony: `policy.core`, held
|
|
2412
|
+
// human-only by the reference policy, and the hook denies it with
|
|
2413
|
+
// `hook-class-human-only`. A deployment starts it from its own launcher, as
|
|
2414
|
+
// the store's user, outside any agent's hook. It mints no new class (SPEC.md
|
|
2415
|
+
// §11.1 invariant 9).
|
|
2416
|
+
if (verb === "channel" && sub === "relay") {
|
|
2417
|
+
return { class: "policy.core", rule: "approval-channel-relay" };
|
|
2418
|
+
}
|
|
1457
2419
|
if (verb === "gate") {
|
|
1458
2420
|
if (sub === "open")
|
|
1459
2421
|
return { class: "policy.core", rule: "approval-gate-open" };
|
|
@@ -1461,6 +2423,22 @@ function refineApprovalVerb(positionals) {
|
|
|
1461
2423
|
return { class: "policy.core", rule: "approval-gate-close" };
|
|
1462
2424
|
return null;
|
|
1463
2425
|
}
|
|
2426
|
+
// APRV-343. `policy apply` WRITES `APPROVAL.md`, which is the one file in this
|
|
2427
|
+
// repository nothing but a human's own hand may change: an agent that could
|
|
2428
|
+
// run it could widen the policy that governs it and then attest the result
|
|
2429
|
+
// through the amendment the verb goes on to run. Classified where the file
|
|
2430
|
+
// already is (`policy.core`, human-only in the reference policy), so the hook
|
|
2431
|
+
// denies it with `hook-class-human-only`, behind the verb's own
|
|
2432
|
+
// `apply-agent-actor` refusal. It mints no new class (SPEC.md §11.1 invariant
|
|
2433
|
+
// 9): `policy.core` already exists and already covers the policy's machinery.
|
|
2434
|
+
//
|
|
2435
|
+
// The other `policy` subcommands stay pass-through. `check` and `test` read,
|
|
2436
|
+
// `attest` and `amend` refuse a non-human actor in code and collect a human's
|
|
2437
|
+
// tap through a channel when an agent runs them, which is the widening
|
|
2438
|
+
// APRV-109 deliberately made — and neither of them writes the policy file.
|
|
2439
|
+
if (verb === "policy" && sub === "apply") {
|
|
2440
|
+
return { class: "policy.core", rule: "approval-policy-apply" };
|
|
2441
|
+
}
|
|
1464
2442
|
// APRV-257. `setup checkpoint` MINTS the key `log checkpoint` signs with, so
|
|
1465
2443
|
// an agent that could run it could mint a key, store it, and vouch for a
|
|
1466
2444
|
// chain it had just written — the mechanism defeated at its source rather
|
|
@@ -1487,7 +2465,7 @@ function refineApprovalVerb(positionals) {
|
|
|
1487
2465
|
* two log verbs keeps the pass-through class and the row's own rule id.
|
|
1488
2466
|
*/
|
|
1489
2467
|
function refineApproval(ctx) {
|
|
1490
|
-
return refineApprovalVerb(ctx.positionals) ?? { class: GATE_SELF_CLASS, rule: "approval" };
|
|
2468
|
+
return (refineApprovalVerb(ctx.positionals, ctx.args) ?? { class: GATE_SELF_CLASS, rule: "approval" });
|
|
1491
2469
|
}
|
|
1492
2470
|
/**
|
|
1493
2471
|
* `node` — an inline script is opaque, the gate's own entry point is
|
|
@@ -1500,7 +2478,7 @@ function refineNode(ctx) {
|
|
|
1500
2478
|
if (script !== undefined && isGateEntrypoint(script)) {
|
|
1501
2479
|
// `node cli.js log sync` is `approval log sync` spelled the long way, and
|
|
1502
2480
|
// it must classify identically or the classification is a spelling test.
|
|
1503
|
-
return (refineApprovalVerb(ctx.positionals.slice(1)) ?? {
|
|
2481
|
+
return (refineApprovalVerb(ctx.positionals.slice(1), ctx.args) ?? {
|
|
1504
2482
|
class: GATE_SELF_CLASS,
|
|
1505
2483
|
rule: "node-approval-cli",
|
|
1506
2484
|
});
|
|
@@ -1524,7 +2502,7 @@ export const COMMAND_RULES = [
|
|
|
1524
2502
|
bins: ["git"],
|
|
1525
2503
|
subs: ["push"],
|
|
1526
2504
|
class: "vcs.push.main",
|
|
1527
|
-
emits: ["vcs.push.branch", "vcs.push.main", "vcs.history.rewrite"],
|
|
2505
|
+
emits: ["vcs.push.branch", "vcs.push.main", "vcs.history.rewrite", "vcs.ref.delete"],
|
|
1528
2506
|
refine: refineGitPush,
|
|
1529
2507
|
},
|
|
1530
2508
|
{
|
|
@@ -1536,7 +2514,16 @@ export const COMMAND_RULES = [
|
|
|
1536
2514
|
{ id: "git-reset", bins: ["git"], subs: ["reset"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitReset },
|
|
1537
2515
|
{ id: "git-commit", bins: ["git"], subs: ["commit"], class: "vcs.commit.branch", emits: ["vcs.history.rewrite"], refine: refineGitCommit },
|
|
1538
2516
|
{ id: "git-branch", bins: ["git"], subs: ["branch"], class: "read.shell", emits: ["vcs.commit.branch"], refine: refineGitBranch },
|
|
1539
|
-
|
|
2517
|
+
// APRV-397 split the verb: the listing flags read, everything else publishes.
|
|
2518
|
+
// See {@link refineGitTag} for why the split is an allowlist.
|
|
2519
|
+
{
|
|
2520
|
+
id: "git-tag",
|
|
2521
|
+
bins: ["git"],
|
|
2522
|
+
subs: ["tag"],
|
|
2523
|
+
class: "release.publish",
|
|
2524
|
+
emits: ["read.shell"],
|
|
2525
|
+
refine: refineGitTag,
|
|
2526
|
+
},
|
|
1540
2527
|
{ id: "git-clone", bins: ["git"], subs: ["clone"], class: "network.call" },
|
|
1541
2528
|
{
|
|
1542
2529
|
id: "git-write",
|
|
@@ -1618,7 +2605,30 @@ export const COMMAND_RULES = [
|
|
|
1618
2605
|
refine: refineGh,
|
|
1619
2606
|
},
|
|
1620
2607
|
// -- package managers ----------------------------------------------------
|
|
2608
|
+
// APRV-397. The probe row matches ONLY an argv that is nothing but version or
|
|
2609
|
+
// help flags, which is what `probe` means and what keeps `npm doctor`,
|
|
2610
|
+
// `npm cache clean` and every other subcommand this table does not name at the
|
|
2611
|
+
// `unclassified` deny they have today. `npm version` is a different word and
|
|
2612
|
+
// keeps its `release.publish` row below.
|
|
2613
|
+
{ id: "npm-version", bins: ["npm", "pnpm", "yarn", "bun"], class: "read.shell", probe: true },
|
|
1621
2614
|
{ id: "npm-publish", bins: ["npm", "pnpm", "yarn", "bun"], subs: ["publish", "version", "deprecate", "dist-tag", "unpublish"], class: "release.publish" },
|
|
2615
|
+
// APRV-397: the two package-manager verbs that write into a directory they
|
|
2616
|
+
// name rather than touching the registry or the lockfile.
|
|
2617
|
+
{
|
|
2618
|
+
id: "npm-pack",
|
|
2619
|
+
bins: ["npm", "pnpm", "yarn", "bun"],
|
|
2620
|
+
subs: ["pack"],
|
|
2621
|
+
class: "files.write.workspace",
|
|
2622
|
+
emits: [OUT_OF_SCOPE_WRITE_CLASS, "network.call"],
|
|
2623
|
+
refine: refineNpmPack,
|
|
2624
|
+
},
|
|
2625
|
+
{
|
|
2626
|
+
id: "npm-init",
|
|
2627
|
+
bins: ["npm", "pnpm", "yarn", "bun"],
|
|
2628
|
+
subs: ["init"],
|
|
2629
|
+
class: "files.write.workspace",
|
|
2630
|
+
refine: refineNpmInit,
|
|
2631
|
+
},
|
|
1622
2632
|
{ id: "npm-install", bins: ["npm", "bun"], subs: ["install", "i", "add"], class: "deps.add", emits: ["deps.install"], refine: refineNpmInstall },
|
|
1623
2633
|
{ id: "yarn-add", bins: ["yarn", "pnpm"], subs: ["add"], class: "deps.add" },
|
|
1624
2634
|
{ id: "yarn-install", bins: ["yarn", "pnpm"], subs: ["install"], class: "deps.install" },
|
|
@@ -1649,8 +2659,20 @@ export const COMMAND_RULES = [
|
|
|
1649
2659
|
// harness unattended. `uca` matches with ANY arguments, `--dry-run` included:
|
|
1650
2660
|
// the classifier reads text, cannot know which flags the script honours, and
|
|
1651
2661
|
// the strictest reading of an updater is that it updates.
|
|
2662
|
+
//
|
|
2663
|
+
// APRV-354 answers the other half of that sentence: the launch rows below now
|
|
2664
|
+
// name what a bare `claude` or `codex …` is. This row stays ABOVE them on
|
|
2665
|
+
// purpose, so `codex update` and `claude update` keep `deps.upgrade` — an
|
|
2666
|
+
// upgrade swaps the binary that hosts the hook, which is the stricter of the
|
|
2667
|
+
// two readings and the class they already had.
|
|
1652
2668
|
{ id: "harness-update", bins: ["claude", "codex", "gemini"], subs: ["update"], class: "deps.upgrade" },
|
|
1653
2669
|
{ id: "harness-updater", bins: ["uca"], class: "deps.upgrade" },
|
|
2670
|
+
// -- agent harness launch (APRV-354) --------------------------------------
|
|
2671
|
+
// Generated from {@link HARNESS_BINS}, one row per binary, all sharing
|
|
2672
|
+
// {@link refineHarness}. Below `harness-update` so an upgrade keeps its
|
|
2673
|
+
// class; above the workspace tools so a package-runner spelling is the only
|
|
2674
|
+
// one that has to be refined rather than matched.
|
|
2675
|
+
...HARNESS_RULES,
|
|
1654
2676
|
// -- workspace tools -----------------------------------------------------
|
|
1655
2677
|
// APRV-193: three of the rules below hand control to code the runtime did not
|
|
1656
2678
|
// author, and they are named in {@link CODE_EXECUTING_RULES}.
|
|
@@ -1672,6 +2694,14 @@ export const COMMAND_RULES = [
|
|
|
1672
2694
|
id: "workspace-tool",
|
|
1673
2695
|
bins: ["npx", "tsx", "ts-node", "tsc", "oxlint", "eslint", "prettier", "vitest", "jest", "backlog", "make"],
|
|
1674
2696
|
class: "files.write.workspace",
|
|
2697
|
+
// APRV-354: only `npx` naming a harness package changes; see
|
|
2698
|
+
// {@link refineWorkspaceTool}, which returns this row's own answer for
|
|
2699
|
+
// every other binary and every other package.
|
|
2700
|
+
emits: [
|
|
2701
|
+
HARNESS_PROBE_CLASS,
|
|
2702
|
+
...Object.values(HARNESS_PACKAGES).map((name) => `${HARNESS_LAUNCH_PREFIX}${name}`),
|
|
2703
|
+
],
|
|
2704
|
+
refine: refineWorkspaceTool,
|
|
1675
2705
|
},
|
|
1676
2706
|
{
|
|
1677
2707
|
id: "workspace-write",
|
|
@@ -1695,6 +2725,40 @@ export const COMMAND_RULES = [
|
|
|
1695
2725
|
emits: ["files.delete.out_of_scope", "files.write.workspace"],
|
|
1696
2726
|
refine: refineFind,
|
|
1697
2727
|
},
|
|
2728
|
+
// -- packaging and archives (APRV-397) -----------------------------------
|
|
2729
|
+
// Four binaries a release verification cannot do without, each of which either
|
|
2730
|
+
// reads what it names or writes into a destination it names. The section
|
|
2731
|
+
// comment above {@link OUT_OF_SCOPE_WRITE_CLASS} holds the reasoning; these
|
|
2732
|
+
// rows only say which binary reaches which refinement.
|
|
2733
|
+
{
|
|
2734
|
+
id: "tar",
|
|
2735
|
+
bins: ["tar"],
|
|
2736
|
+
class: "files.write.workspace",
|
|
2737
|
+
emits: ["read.shell", OUT_OF_SCOPE_WRITE_CLASS],
|
|
2738
|
+
refine: refineTar,
|
|
2739
|
+
},
|
|
2740
|
+
{
|
|
2741
|
+
id: "gunzip",
|
|
2742
|
+
bins: ["gunzip"],
|
|
2743
|
+
class: "files.write.workspace",
|
|
2744
|
+
emits: ["read.shell", OUT_OF_SCOPE_WRITE_CLASS],
|
|
2745
|
+
refine: refineGunzip,
|
|
2746
|
+
},
|
|
2747
|
+
{
|
|
2748
|
+
id: "base64",
|
|
2749
|
+
bins: ["base64"],
|
|
2750
|
+
class: "read.shell",
|
|
2751
|
+
emits: ["files.write.workspace", OUT_OF_SCOPE_WRITE_CLASS],
|
|
2752
|
+
refine: refineBase64,
|
|
2753
|
+
},
|
|
2754
|
+
{
|
|
2755
|
+
id: "openssl-digest",
|
|
2756
|
+
bins: ["openssl"],
|
|
2757
|
+
subs: OPENSSL_DIGEST_SUBS,
|
|
2758
|
+
class: "read.shell",
|
|
2759
|
+
emits: ["files.write.workspace", OUT_OF_SCOPE_WRITE_CLASS],
|
|
2760
|
+
refine: refineOpensslDigest,
|
|
2761
|
+
},
|
|
1698
2762
|
// -- network -------------------------------------------------------------
|
|
1699
2763
|
// The HTTP clients split on their flags; the transports do not. What `ssh`,
|
|
1700
2764
|
// `rsync` or `nc` will do at the far end is not written in the argv, so there
|
|
@@ -1830,9 +2894,17 @@ function refineGh(ctx) {
|
|
|
1830
2894
|
* Binaries whose effect lives in a string this classifier will not interpret.
|
|
1831
2895
|
*
|
|
1832
2896
|
* A second parser for the same text is a second answer waiting to disagree with
|
|
1833
|
-
* the shell's, so these refuse instead. `
|
|
1834
|
-
*
|
|
2897
|
+
* the shell's, so these refuse instead. `eval`, `xargs` and the `-e`
|
|
2898
|
+
* interpreters can express anything at all; `sudo` and `env` re-launch
|
|
1835
2899
|
* something else with different authority.
|
|
2900
|
+
*
|
|
2901
|
+
* ONE NARROW EXCEPTION since APRV-380, and it is a narrowing of this rule
|
|
2902
|
+
* rather than a hole in it: a segment that is EXACTLY a known shell, one
|
|
2903
|
+
* inline-script flag and one script word is classified by the script, through
|
|
2904
|
+
* this same classifier. No second parser is written and the text is not read a
|
|
2905
|
+
* second way. {@link loginShellScript} states the shape and the reasoning, and
|
|
2906
|
+
* everything outside it — including the same shell with a script file, or a
|
|
2907
|
+
* shell nested inside an unwrapped script — is refused here exactly as before.
|
|
1836
2908
|
*/
|
|
1837
2909
|
const OPAQUE_BINS = {
|
|
1838
2910
|
bash: "runs a shell script",
|
|
@@ -1854,6 +2926,93 @@ const OPAQUE_BINS = {
|
|
|
1854
2926
|
timeout: "runs another command under a timer",
|
|
1855
2927
|
time: "runs another command under a timer",
|
|
1856
2928
|
};
|
|
2929
|
+
/**
|
|
2930
|
+
* The shells {@link loginShellScript} will unwrap: the six in
|
|
2931
|
+
* {@link OPAQUE_BINS} that run a script.
|
|
2932
|
+
*
|
|
2933
|
+
* The other opaque binaries stay opaque under every shape. `eval`, `xargs` and
|
|
2934
|
+
* the `-e` interpreters build their text from somewhere this file cannot see;
|
|
2935
|
+
* `sudo`, `doas`, `env`, `nohup`, `exec`, `source`, `.`, `watch`, `timeout` and
|
|
2936
|
+
* `time` re-launch something else, with different authority or under a timer,
|
|
2937
|
+
* and what they re-launch is an argv rather than a script.
|
|
2938
|
+
*/
|
|
2939
|
+
const UNWRAPPABLE_SHELLS = new Set([
|
|
2940
|
+
"bash",
|
|
2941
|
+
"sh",
|
|
2942
|
+
"zsh",
|
|
2943
|
+
"dash",
|
|
2944
|
+
"ksh",
|
|
2945
|
+
"fish",
|
|
2946
|
+
]);
|
|
2947
|
+
/**
|
|
2948
|
+
* The inline-script flags a wrapper may carry: `-c`, with any run of `l` and
|
|
2949
|
+
* `i` before it (APRV-380).
|
|
2950
|
+
*
|
|
2951
|
+
* `-lc` is the shape the 2026-09-18 probe recorded on every Codex exec request.
|
|
2952
|
+
* `c` must be LAST, because that is the letter that takes the next word as its
|
|
2953
|
+
* argument, and a combination where it is not last is one whose reading depends
|
|
2954
|
+
* on the shell's own option parser. That is a shape this rule declines to guess
|
|
2955
|
+
* at, so it stays opaque.
|
|
2956
|
+
*/
|
|
2957
|
+
const INLINE_SCRIPT_FLAG = /^-[li]*c$/u;
|
|
2958
|
+
/**
|
|
2959
|
+
* The script a LOGIN-SHELL WRAPPER runs, when the segment is exactly one
|
|
2960
|
+
* (APRV-380), or `null`.
|
|
2961
|
+
*
|
|
2962
|
+
* ## Why this is not the second parser the table above refuses
|
|
2963
|
+
*
|
|
2964
|
+
* {@link OPAQUE_BINS} states the position this narrows: a second parser for the
|
|
2965
|
+
* same text is a second answer waiting to disagree with the shell's. The hazard
|
|
2966
|
+
* that names is reading shell text a SECOND WAY. This is not that. When the
|
|
2967
|
+
* argv is exactly `[shell, -lc, script]` and nothing else, the script is the
|
|
2968
|
+
* text a Claude Code `Bash` call hands this classifier directly, and what
|
|
2969
|
+
* happens to it here is what happens to that: the same lexer, the same segment
|
|
2970
|
+
* rules, the same table. No new parser is written, and the text is not read a
|
|
2971
|
+
* second way — it is read the first way, by the only reader there is.
|
|
2972
|
+
*
|
|
2973
|
+
* ## The line, and it is exact
|
|
2974
|
+
*
|
|
2975
|
+
* Three words, no more: a known shell, one inline-script flag, one script. Any
|
|
2976
|
+
* of these keeps the wrapper opaque, because each is a shape whose effect
|
|
2977
|
+
* depends on something the argv alone does not say:
|
|
2978
|
+
*
|
|
2979
|
+
* - extra words (a script FILE, `--`, an option this rule does not model);
|
|
2980
|
+
* - an assignment prefix, which changes the environment the script runs in;
|
|
2981
|
+
* - a redirection on the wrapper, which is the outer shell's and not the
|
|
2982
|
+
* script's;
|
|
2983
|
+
* - a substitution in any of the three words, whose effect happens before the
|
|
2984
|
+
* shell even starts;
|
|
2985
|
+
* - a nested shell inside the script, refused by {@link ClassifierContext} when
|
|
2986
|
+
* the recursion runs (the inner classification unwraps nothing).
|
|
2987
|
+
*
|
|
2988
|
+
* The BINDING does not move. `cli/hook.ts` binds the outer command and argv
|
|
2989
|
+
* exactly as APRV-362 built them; what this changes is only which text is
|
|
2990
|
+
* classified, and the classes are additional evidence about bytes that are
|
|
2991
|
+
* bound elsewhere and unchanged.
|
|
2992
|
+
*/
|
|
2993
|
+
export function loginShellScript(segment) {
|
|
2994
|
+
if (segment.opaque !== null)
|
|
2995
|
+
return null;
|
|
2996
|
+
if (segment.redirects.length > 0)
|
|
2997
|
+
return null;
|
|
2998
|
+
if (segment.words.length !== 3)
|
|
2999
|
+
return null;
|
|
3000
|
+
const [shell, flag, script] = segment.words;
|
|
3001
|
+
if (shell.substitutions.length > 0)
|
|
3002
|
+
return null;
|
|
3003
|
+
if (flag.substitutions.length > 0)
|
|
3004
|
+
return null;
|
|
3005
|
+
if (script.substitutions.length > 0)
|
|
3006
|
+
return null;
|
|
3007
|
+
const base = pathSegments(shell.text).slice(-1)[0] ?? shell.text;
|
|
3008
|
+
if (!UNWRAPPABLE_SHELLS.has(base))
|
|
3009
|
+
return null;
|
|
3010
|
+
if (!INLINE_SCRIPT_FLAG.test(flag.text))
|
|
3011
|
+
return null;
|
|
3012
|
+
// An empty script runs nothing, and `classifyCommand` answers `unclassified`
|
|
3013
|
+
// for an empty command. Leaving it wrapped keeps that answer the wrapper's.
|
|
3014
|
+
return script.text.trim().length === 0 ? null : script.text;
|
|
3015
|
+
}
|
|
1857
3016
|
/** Interpreters that are opaque only when handed inline source. */
|
|
1858
3017
|
const INLINE_SOURCE_BINS = {
|
|
1859
3018
|
python: ["-c"],
|
|
@@ -1916,6 +3075,70 @@ function protectedRank(surface) {
|
|
|
1916
3075
|
const index = PROTECTED_PRECEDENCE.indexOf(surface);
|
|
1917
3076
|
return index === -1 ? PROTECTED_PRECEDENCE.indexOf("policy.edit") : index;
|
|
1918
3077
|
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Is this word PROSE that merely spells a protected path, rather than a path?
|
|
3080
|
+
* (APRV-409.)
|
|
3081
|
+
*
|
|
3082
|
+
* The incident: a `backlog task create` whose acceptance-criterion argument
|
|
3083
|
+
* began with the literal text of a workflow path and continued into a sentence
|
|
3084
|
+
* classified `policy.edit.ci`, sat the full hook wait on the gate and was
|
|
3085
|
+
* denied on timeout. The command writes one task file and touches no workflow.
|
|
3086
|
+
* {@link pathSegments} splits on slashes only, so a word whose leading segments
|
|
3087
|
+
* are a protected DIRECTORY run prefix-matches whatever follows those slashes,
|
|
3088
|
+
* sentence included. An exact-file entry does not, because it matches on the
|
|
3089
|
+
* FINAL segment, which is why the same sentence beginning `CLAUDE.md` always
|
|
3090
|
+
* classified as an ordinary workspace write.
|
|
3091
|
+
*
|
|
3092
|
+
* The test drawn here is the narrow one, and it is narrow in a stated way: the
|
|
3093
|
+
* word is prose only when the protected match came ENTIRELY from a
|
|
3094
|
+
* whitespace-free head. Concretely, all three hold:
|
|
3095
|
+
*
|
|
3096
|
+
* 1. the word contains whitespace at all;
|
|
3097
|
+
* 2. the first of its segments carrying whitespace is not the first segment,
|
|
3098
|
+
* so there is a head in front of it;
|
|
3099
|
+
* 3. that head, on its own, already classifies as the SAME surface the whole
|
|
3100
|
+
* word did.
|
|
3101
|
+
*
|
|
3102
|
+
* When (3) fails the word is kept, which is the fail-closed direction and is
|
|
3103
|
+
* what keeps the shapes below unmoved:
|
|
3104
|
+
*
|
|
3105
|
+
* - `my notes/CLAUDE.md` and any other exact-file match at any depth: the match
|
|
3106
|
+
* needs the final segment, the head classifies as nothing, and the word keeps
|
|
3107
|
+
* `policy.edit`. A real file named with a space therefore stays protected.
|
|
3108
|
+
* - `my notes/.github/workflows/x.yml`: whitespace BEFORE the protected run,
|
|
3109
|
+
* head `my notes` classifies as nothing, word unmoved.
|
|
3110
|
+
* - every word with no whitespace at all, which is the whole ordinary universe
|
|
3111
|
+
* of paths: condition (1) fails before anything is computed.
|
|
3112
|
+
*
|
|
3113
|
+
* What does move is the one shape prose and a path share exactly: a
|
|
3114
|
+
* directory-prefix match whose remainder carries whitespace. `cp x
|
|
3115
|
+
* ".approval/my file.txt"` is a real path of that shape and now takes the
|
|
3116
|
+
* command's own class. That is a deliberate trade and not an oversight —
|
|
3117
|
+
* `my file.txt` and `ci.yml should not gate this task` are the same string
|
|
3118
|
+
* shape, and no pure, disk-free rule tells them apart. A threshold on how much
|
|
3119
|
+
* whitespace the remainder carries would have rescued the first, and it is
|
|
3120
|
+
* rejected: a number here is folklore a second implementation has to guess at,
|
|
3121
|
+
* and the conformance suite would be pinning it. The write itself is not
|
|
3122
|
+
* unguarded: `core/protected-path-guard.ts` judges what a commit actually
|
|
3123
|
+
* changed, from the log rather than from a session, and a `.approval/` file
|
|
3124
|
+
* with no authorization record still fails the pull request.
|
|
3125
|
+
*
|
|
3126
|
+
* Pure and disk-free like the rest of this file: no resolution, no stat, no
|
|
3127
|
+
* question about whether anything exists.
|
|
3128
|
+
*/
|
|
3129
|
+
function proseNamingProtectedPath(word, surface, protectedPaths) {
|
|
3130
|
+
if (!/\s/u.test(word))
|
|
3131
|
+
return false;
|
|
3132
|
+
const segments = pathSegments(word);
|
|
3133
|
+
const firstSpaced = segments.findIndex((segment) => /\s/u.test(segment));
|
|
3134
|
+
// `-1` is a word whose whitespace survived no segment (nothing produces it
|
|
3135
|
+
// today) and `0` is whitespace in or before the first segment: both leave no
|
|
3136
|
+
// whitespace-free head to have matched, so both keep the word.
|
|
3137
|
+
if (firstSpaced <= 0)
|
|
3138
|
+
return false;
|
|
3139
|
+
const head = segments.slice(0, firstSpaced).join("/");
|
|
3140
|
+
return protectedPathClass(head, protectedPaths) === surface;
|
|
3141
|
+
}
|
|
1919
3142
|
/**
|
|
1920
3143
|
* The strictest protected surface named by these words, with the word itself.
|
|
1921
3144
|
*
|
|
@@ -1926,13 +3149,22 @@ function protectedRank(surface) {
|
|
|
1926
3149
|
* did and is what keeps a bare-string policy byte-identical: two routed paths
|
|
1927
3150
|
* in one segment are two equally consequential surfaces, and the segment's
|
|
1928
3151
|
* class names one of them while `ClassifiedSegment.path` names the word.
|
|
3152
|
+
*
|
|
3153
|
+
* `skipProse` turns on {@link proseNamingProtectedPath} (APRV-409) and is
|
|
3154
|
+
* passed by the POSITIONAL scan alone. The other caller here is the
|
|
3155
|
+
* write-redirection targets, whose words are paths by construction — the shell
|
|
3156
|
+
* is about to create that file — and the same is true of the paths
|
|
3157
|
+
* `core/apply-patch.ts` and the hook's file-tool pass hand to
|
|
3158
|
+
* {@link protectedPathClass} directly. None of them is offered the skip.
|
|
1929
3159
|
*/
|
|
1930
|
-
function strictestProtected(words, protectedPaths) {
|
|
3160
|
+
function strictestProtected(words, protectedPaths, skipProse = false) {
|
|
1931
3161
|
let best = null;
|
|
1932
3162
|
for (const word of words) {
|
|
1933
3163
|
const surface = protectedPathClass(word, protectedPaths);
|
|
1934
3164
|
if (surface === null)
|
|
1935
3165
|
continue;
|
|
3166
|
+
if (skipProse && proseNamingProtectedPath(word, surface, protectedPaths))
|
|
3167
|
+
continue;
|
|
1936
3168
|
if (best === null || protectedRank(surface) < protectedRank(best.surface)) {
|
|
1937
3169
|
best = { surface, path: word };
|
|
1938
3170
|
}
|
|
@@ -1962,6 +3194,32 @@ export const CODE_EXECUTING_RULES = [
|
|
|
1962
3194
|
"node-script",
|
|
1963
3195
|
/** `npx`, `tsx`, `tsc`, `vitest`, `jest`, `make`, and kin. */
|
|
1964
3196
|
"workspace-tool",
|
|
3197
|
+
/**
|
|
3198
|
+
* `npm pack` and `npm init <initializer>` (APRV-397).
|
|
3199
|
+
*
|
|
3200
|
+
* Both run code the runtime did not author, for different reasons. `npm pack`
|
|
3201
|
+
* runs the package's own `prepack` and `prepare` scripts, which are files in
|
|
3202
|
+
* the workspace an agent may have written a minute ago, exactly as `npm test`
|
|
3203
|
+
* is. `npm init <pkg>` downloads an initializer and runs it, which is
|
|
3204
|
+
* `npm exec` wearing another name. A bare `npm init` writes `package.json` and
|
|
3205
|
+
* runs nothing, so it is absent.
|
|
3206
|
+
*/
|
|
3207
|
+
"npm-pack",
|
|
3208
|
+
"npm-init-create",
|
|
3209
|
+
/**
|
|
3210
|
+
* Launching an agent harness, and probing one (APRV-354).
|
|
3211
|
+
*
|
|
3212
|
+
* Every spelling is here, the probe included. A launch hands control to a
|
|
3213
|
+
* whole second agent, which is the most complete form of "code the runtime
|
|
3214
|
+
* did not author"; a probe still executes the same binary. The list is
|
|
3215
|
+
* matched against a segment's RULE, so the generated launch ids and the two
|
|
3216
|
+
* ids a refinement can return on its own — the probe and the Muse
|
|
3217
|
+
* contributor id — all have to be named, or `npx @openai/codex` would have
|
|
3218
|
+
* quietly stopped requiring a sandbox by gaining a better class.
|
|
3219
|
+
*/
|
|
3220
|
+
...HARNESS_RULES.map((rule) => rule.id),
|
|
3221
|
+
HARNESS_PROBE_RULE,
|
|
3222
|
+
MUSE_CONTRIBUTOR_RULE,
|
|
1965
3223
|
];
|
|
1966
3224
|
/**
|
|
1967
3225
|
* Every class the table can emit, for docs and for the dogfood test.
|
|
@@ -1988,8 +3246,45 @@ export const CLASSIFIER_CLASSES = (() => {
|
|
|
1988
3246
|
seen.add(CREDENTIAL_CLASS);
|
|
1989
3247
|
seen.add("files.write.workspace");
|
|
1990
3248
|
seen.add("read.shell");
|
|
3249
|
+
// APRV-347: emitted from `classifySegment`'s tail rather than from a row of
|
|
3250
|
+
// the table, because it is a refinement of `read.shell` against roots the
|
|
3251
|
+
// CALLER resolved and no binary implies it on its own.
|
|
3252
|
+
seen.add(READ_OUT_OF_SCOPE_CLASS);
|
|
1991
3253
|
return [...seen].sort();
|
|
1992
3254
|
})();
|
|
3255
|
+
/**
|
|
3256
|
+
* The class the DAEMON's own cadence advance is gated as (APRV-382).
|
|
3257
|
+
*
|
|
3258
|
+
* The sub-class exists because the policy grammar has no actor condition and
|
|
3259
|
+
* this repository wanted one: an advance publishes records the log already
|
|
3260
|
+
* holds, so the daemon may make it unattended, while the same act from a
|
|
3261
|
+
* session in a worktree or a human terminal stays supervised. Two classes are
|
|
3262
|
+
* how that is written down, and which of them a cycle asks under is decided by
|
|
3263
|
+
* `core/advance-cycle.ts` from the running process, never from an argument.
|
|
3264
|
+
*
|
|
3265
|
+
* NO COMMAND SPELLS IT, on purpose. `approval log advance` classifies
|
|
3266
|
+
* `log.advance` whoever types it, so the looser line is unreachable from a
|
|
3267
|
+
* shell an agent can drive: it is reached only from inside the daemon process,
|
|
3268
|
+
* which an agent cannot become without a `gate.self` command this policy holds
|
|
3269
|
+
* at the manual default.
|
|
3270
|
+
*/
|
|
3271
|
+
export const ADVANCE_DAEMON_CLASS = "log.advance.daemon";
|
|
3272
|
+
/**
|
|
3273
|
+
* Classes this RUNTIME emits for its own gated actions, which no command spells.
|
|
3274
|
+
*
|
|
3275
|
+
* Separate from {@link CLASSIFIER_CLASSES}, which is the binary table's own set
|
|
3276
|
+
* and is what `docs/claude-code-hook.md` documents row by row. A class here is
|
|
3277
|
+
* emitted by a runtime cycle that registers and requests it directly — the
|
|
3278
|
+
* daemon's advance is the first — so a policy declaring it is declaring a line
|
|
3279
|
+
* that CAN fire, and the reachability check `core/policy-expectations.ts` runs
|
|
3280
|
+
* at the amendment ceremony must say so. Without this list that ceremony would
|
|
3281
|
+
* refuse the line `unreachable`, which is a true statement about the command
|
|
3282
|
+
* classifier and a false one about the runtime.
|
|
3283
|
+
*
|
|
3284
|
+
* Adding a name here is a claim that some path in this codebase asks the gate
|
|
3285
|
+
* for that class, and widening it is a reviewable diff.
|
|
3286
|
+
*/
|
|
3287
|
+
export const RUNTIME_CLASSES = [ADVANCE_DAEMON_CLASS];
|
|
1993
3288
|
/**
|
|
1994
3289
|
* Can the classifier emit `actionClass` for a project whose policy carries
|
|
1995
3290
|
* these `protected_paths`? (APRV-266.)
|
|
@@ -2005,10 +3300,17 @@ export const CLASSIFIER_CLASSES = (() => {
|
|
|
2005
3300
|
* A routed name is reachable exactly when some entry routes to it. A
|
|
2006
3301
|
* `policy.edit.spec` rule in a policy whose `protected_paths` routes nothing to
|
|
2007
3302
|
* it is a line that will never fire, and saying so is the whole point.
|
|
3303
|
+
*
|
|
3304
|
+
* {@link RUNTIME_CLASSES} is the third answer (APRV-382): a class no command
|
|
3305
|
+
* spells and a runtime cycle asks for directly is reachable in every project,
|
|
3306
|
+
* with no policy entry needed, because the path that emits it is in this
|
|
3307
|
+
* codebase rather than in the operator's file.
|
|
2008
3308
|
*/
|
|
2009
3309
|
export function emittableClass(actionClass, protectedPaths = []) {
|
|
2010
3310
|
if (CLASSIFIER_CLASSES.includes(actionClass))
|
|
2011
3311
|
return true;
|
|
3312
|
+
if (RUNTIME_CLASSES.includes(actionClass))
|
|
3313
|
+
return true;
|
|
2012
3314
|
if (!POLICY_EDIT_SUBCLASS.test(actionClass))
|
|
2013
3315
|
return false;
|
|
2014
3316
|
return protectedPaths.some((entry) => parseProtectedEntry(entry)?.routed === actionClass);
|
|
@@ -2098,11 +3400,18 @@ function sandboxWrapper(words, start) {
|
|
|
2098
3400
|
// can produce (`docs/sandboxed-exec.md`, and APRV-193's Linux follow-up).
|
|
2099
3401
|
return null;
|
|
2100
3402
|
}
|
|
2101
|
-
/**
|
|
2102
|
-
|
|
3403
|
+
/**
|
|
3404
|
+
* Find the first table row matching this binary, subcommand and argv.
|
|
3405
|
+
*
|
|
3406
|
+
* The argv is read for one purpose only (APRV-397): a `probe` row matches an
|
|
3407
|
+
* argv that is nothing but version or help flags, and no other row reads it.
|
|
3408
|
+
*/
|
|
3409
|
+
function matchRule(bin, sub, args) {
|
|
2103
3410
|
for (const rule of COMMAND_RULES) {
|
|
2104
3411
|
if (!rule.bins.includes(bin))
|
|
2105
3412
|
continue;
|
|
3413
|
+
if (rule.probe === true && !isVersionProbe(args))
|
|
3414
|
+
continue;
|
|
2106
3415
|
if (rule.subs !== undefined) {
|
|
2107
3416
|
if (sub === null || !rule.subs.includes(sub))
|
|
2108
3417
|
continue;
|
|
@@ -2226,7 +3535,7 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
2226
3535
|
if (credential !== null)
|
|
2227
3536
|
return { ok: true, ...credential };
|
|
2228
3537
|
const sub = positionals[0] ?? null;
|
|
2229
|
-
const rule = matchRule(basename, sub);
|
|
3538
|
+
const rule = matchRule(basename, sub, args);
|
|
2230
3539
|
if (rule === null) {
|
|
2231
3540
|
return {
|
|
2232
3541
|
ok: false,
|
|
@@ -2250,14 +3559,25 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
2250
3559
|
}
|
|
2251
3560
|
let cls = refined === null ? rule.class : refined.class;
|
|
2252
3561
|
let ruleId = refined === null ? rule.id : refined.rule;
|
|
3562
|
+
// The value a refinement bound, when one did (APRV-352). Same field and same
|
|
3563
|
+
// meaning as the protected-path binding above: the words the classifier
|
|
3564
|
+
// matched, verbatim, so an approver is told WHICH refs a deletion names
|
|
3565
|
+
// rather than being handed a class and left to re-read the command.
|
|
3566
|
+
const boundPath = refined !== null && "path" in refined ? refined.path : undefined;
|
|
2253
3567
|
// A protected path anywhere in an effectful segment takes that path's class:
|
|
2254
3568
|
// the command is editing the gate, whatever else it is doing. Every
|
|
2255
3569
|
// positional is scanned, source and destination alike, so `cp` stays
|
|
2256
3570
|
// direction-blind — a copy OUT of the policy directory is as gated as a copy
|
|
2257
3571
|
// into it, because the classifier cannot tell which argument the binary will
|
|
2258
3572
|
// treat as the destination and guessing would be the ungated direction.
|
|
3573
|
+
//
|
|
3574
|
+
// This is the ONE scan that skips prose (APRV-409, `skipProse`): a positional
|
|
3575
|
+
// is whatever the author quoted, and an acceptance criterion that opens with
|
|
3576
|
+
// a directory path is a sentence rather than a file. Redirection targets
|
|
3577
|
+
// above, and the apply-patch and file-tool paths elsewhere, are paths by
|
|
3578
|
+
// construction and are not offered the skip.
|
|
2259
3579
|
if (!cls.startsWith("read.") && cls !== GATE_SELF_CLASS) {
|
|
2260
|
-
const named = strictestProtected(positionals, protectedPaths);
|
|
3580
|
+
const named = strictestProtected(positionals, protectedPaths, true);
|
|
2261
3581
|
if (named !== null) {
|
|
2262
3582
|
return { ok: true, class: named.surface, rule: "protected-path", path: named.path };
|
|
2263
3583
|
}
|
|
@@ -2268,7 +3588,31 @@ function classifySegment(segment, protectedPaths, context) {
|
|
|
2268
3588
|
cls = "files.write.workspace";
|
|
2269
3589
|
ruleId = "redirect-write";
|
|
2270
3590
|
}
|
|
2271
|
-
|
|
3591
|
+
// APRV-347, last because it is the narrowest: a read whose target the TEXT
|
|
3592
|
+
// places outside every root the caller named. Only `read.shell` is scoped —
|
|
3593
|
+
// `read.web` reaches no file, and a segment that has already taken a
|
|
3594
|
+
// protected, credential or write class is not a read at all. The relative
|
|
3595
|
+
// and symlinked cases are deliberately NOT decided here; they are left at
|
|
3596
|
+
// `read.shell` for the hook's disk pass, which tightens and never loosens.
|
|
3597
|
+
if (cls === "read.shell" && (context.readRoots ?? []).length > 0) {
|
|
3598
|
+
const escaped = escapedReadTarget(basename, positionals, args, context.readRoots ?? []);
|
|
3599
|
+
if (escaped !== null) {
|
|
3600
|
+
return {
|
|
3601
|
+
ok: true,
|
|
3602
|
+
class: READ_OUT_OF_SCOPE_CLASS,
|
|
3603
|
+
rule: escaped.rule,
|
|
3604
|
+
path: escaped.path,
|
|
3605
|
+
...(sandbox === null ? {} : { sandbox }),
|
|
3606
|
+
};
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
return {
|
|
3610
|
+
ok: true,
|
|
3611
|
+
class: cls,
|
|
3612
|
+
rule: ruleId,
|
|
3613
|
+
...(boundPath === undefined ? {} : { path: boundPath }),
|
|
3614
|
+
...(sandbox === null ? {} : { sandbox }),
|
|
3615
|
+
};
|
|
2272
3616
|
}
|
|
2273
3617
|
/**
|
|
2274
3618
|
* Classify a shell command line into the classes it would produce.
|
|
@@ -2305,6 +3649,30 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
2305
3649
|
const segments = [];
|
|
2306
3650
|
const classes = [];
|
|
2307
3651
|
for (const segment of lexed.segments) {
|
|
3652
|
+
// APRV-380. A LOGIN-SHELL WRAPPER is classified by the script it runs.
|
|
3653
|
+
// `loginShellScript` states the exact shape and why this is not the second
|
|
3654
|
+
// parser `OPAQUE_BINS` refuses; the inner classification is run with
|
|
3655
|
+
// `unwrapShell: false`, so a shell nested inside the script stays opaque
|
|
3656
|
+
// and the recursion is one level deep by construction.
|
|
3657
|
+
const script = context.unwrapShell === false ? null : loginShellScript(segment);
|
|
3658
|
+
if (script !== null) {
|
|
3659
|
+
const inner = classifyCommand(script, protectedPaths, { ...context, unwrapShell: false });
|
|
3660
|
+
if (!inner.ok) {
|
|
3661
|
+
// The refusal is the INNER one, reported against the inner segment: an
|
|
3662
|
+
// operator told `hook-opaque` for `zsh` learns nothing, and one told
|
|
3663
|
+
// which part of their script could not be read can rewrite it.
|
|
3664
|
+
return inner;
|
|
3665
|
+
}
|
|
3666
|
+
// Spliced rather than collapsed into one segment: a script is a command
|
|
3667
|
+
// line and its parts have their own classes, which is the thing a
|
|
3668
|
+
// one-class answer would lose.
|
|
3669
|
+
for (const found of inner.segments) {
|
|
3670
|
+
segments.push(found);
|
|
3671
|
+
if (!classes.includes(found.class))
|
|
3672
|
+
classes.push(found.class);
|
|
3673
|
+
}
|
|
3674
|
+
continue;
|
|
3675
|
+
}
|
|
2308
3676
|
const outcome = classifySegment(segment, protectedPaths, context);
|
|
2309
3677
|
if (!outcome.ok) {
|
|
2310
3678
|
return { ok: false, code: outcome.code, segment: segment.text, detail: outcome.detail };
|
|
@@ -2336,6 +3704,43 @@ export function classifyCommand(command, protectedPaths = [], context = {}) {
|
|
|
2336
3704
|
* `classifyCommand` answers `unparseable` for. Segments carrying no binary (a
|
|
2337
3705
|
* bare assignment, a lone redirection) are omitted: they have no verb to show.
|
|
2338
3706
|
*/
|
|
3707
|
+
/**
|
|
3708
|
+
* The paths each segment WRITES through a redirection, from the same parse
|
|
3709
|
+
* (APRV-445). `<` is a read and is left out, as are the discard devices.
|
|
3710
|
+
* `null` when the tokenizer refuses the string.
|
|
3711
|
+
*/
|
|
3712
|
+
export function commandSegmentWriteTargets(command) {
|
|
3713
|
+
const lexed = lex(command);
|
|
3714
|
+
if (!lexed.ok)
|
|
3715
|
+
return null;
|
|
3716
|
+
return lexed.segments.map((segment) => segment.redirects
|
|
3717
|
+
.filter((redirect) => redirect.op !== "<")
|
|
3718
|
+
.map((redirect) => redirect.target.text)
|
|
3719
|
+
.filter((target) => !isDiscardTarget(target)));
|
|
3720
|
+
}
|
|
3721
|
+
export function commandSegmentShapes(command) {
|
|
3722
|
+
const lexed = lex(command);
|
|
3723
|
+
if (!lexed.ok)
|
|
3724
|
+
return null;
|
|
3725
|
+
return lexed.segments.map((segment) => {
|
|
3726
|
+
const words = segment.words.map((word) => word.text);
|
|
3727
|
+
let cursor = 0;
|
|
3728
|
+
while (cursor < words.length && ASSIGNMENT.test(words[cursor]))
|
|
3729
|
+
cursor += 1;
|
|
3730
|
+
return {
|
|
3731
|
+
text: segment.text,
|
|
3732
|
+
bin: words[cursor] ?? null,
|
|
3733
|
+
args: words.slice(cursor + 1),
|
|
3734
|
+
writes: segment.redirects
|
|
3735
|
+
.filter((redirect) => redirect.op !== "<")
|
|
3736
|
+
.map((redirect) => redirect.target.text)
|
|
3737
|
+
.filter((target) => !isDiscardTarget(target)),
|
|
3738
|
+
reads: segment.redirects
|
|
3739
|
+
.filter((redirect) => redirect.op === "<")
|
|
3740
|
+
.map((redirect) => redirect.target.text),
|
|
3741
|
+
};
|
|
3742
|
+
});
|
|
3743
|
+
}
|
|
2339
3744
|
export function commandSegmentWords(command) {
|
|
2340
3745
|
const lexed = lex(command);
|
|
2341
3746
|
if (!lexed.ok)
|