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
package/docs/cli-reference.md
CHANGED
|
@@ -65,11 +65,20 @@ tampering.
|
|
|
65
65
|
```
|
|
66
66
|
clean {"status":"clean","records":3,"head":{"seq":3,"hash":"<64 hex>"}}
|
|
67
67
|
torn-tail {"status":"torn-tail","records":3,"head":null,
|
|
68
|
-
"intactThroughSeq":3,"
|
|
68
|
+
"intactThroughSeq":3,"tear":"nul-filled","tornBytes":456,
|
|
69
|
+
"intactBytes":2817,"message":"..."}
|
|
69
70
|
corrupt {"status":"corrupt","records":null,"head":null,
|
|
70
71
|
"firstBadSeq":2,"reason":"hash-mismatch","message":"..."}
|
|
71
72
|
```
|
|
72
73
|
|
|
74
|
+
On a torn tail, `tear` says which crash left it (APRV-440). `nul-filled` means
|
|
75
|
+
every byte of the unterminated tail is NUL: the crash-before-writeback
|
|
76
|
+
signature, where the file grew but its data never reached the disk, so nothing
|
|
77
|
+
was tampered and no record was half-written. `partial-line` is a writer that
|
|
78
|
+
died mid-line. `intactBytes` is where the verified records end, which is the
|
|
79
|
+
length to truncate the file to; `tornBytes` is what that removes. Nothing is
|
|
80
|
+
truncated by this verb.
|
|
81
|
+
|
|
73
82
|
`head` is null for an empty log. `reason` is one of `malformed-line`,
|
|
74
83
|
`schema-invalid`, `bad-alg`, `hash-mismatch`, `prev-mismatch`, `seq-gap`,
|
|
75
84
|
`seq-duplicate`, `not-genesis`, `head-mismatch`. `anomalies` is ADDITIVE and
|
|
@@ -390,6 +399,13 @@ did left conflict markers inside the log mid-ceremony.
|
|
|
390
399
|
So the ritual became deterministic code, on the `policy amend` precedent: when a
|
|
391
400
|
hand-ritual proves dangerous, it becomes a verb the gate can read.
|
|
392
401
|
|
|
402
|
+
**You rarely type it any more (APRV-346).** `approval up`'s preflight calls this
|
|
403
|
+
function itself whenever the working log is a byte-for-byte extension of the
|
|
404
|
+
committed one, which is the state every records advance leaves behind. What is
|
|
405
|
+
left for a hand-run `approval log sync` is the fork: two chains that share a
|
|
406
|
+
prefix and then carry different records at the same `seq`. See
|
|
407
|
+
[up](#up) for what the preflight checks before it delegates.
|
|
408
|
+
|
|
393
409
|
Everything runs inside ONE hold of the append lockfile. The lock is normally
|
|
394
410
|
taken per append; here it spans the whole operation, because an append landing
|
|
395
411
|
between the snapshot and the restore is exactly the interleaving that forks a
|
|
@@ -485,6 +501,17 @@ range they cover, and pushed to a short-lived records branch that exists for
|
|
|
485
501
|
exactly that commit. Main is protected here, so the commit reaches it through a
|
|
486
502
|
pull request; `--pr` opens that pull request through the ordinary `gh` path.
|
|
487
503
|
|
|
504
|
+
This verb carries the class `log.advance` for everyone who runs it, in a
|
|
505
|
+
session, in an orchestrator, or at a human terminal. The daemon's cadence
|
|
506
|
+
advance is the one that may carry `log.advance.daemon` instead, and only from
|
|
507
|
+
inside the daemon process: see the `--advance` paragraph under `daemon run`
|
|
508
|
+
(APRV-382).
|
|
509
|
+
|
|
510
|
+
An advance publishes the WHOLE log, so while its branch is open on origin it is
|
|
511
|
+
the log's publisher and `policy amend` stands aside: an amendment opened in that
|
|
512
|
+
window carries only the policy bytes and cannot conflict with the records. See
|
|
513
|
+
"Who publishes the log" under `policy amend` (APRV-420).
|
|
514
|
+
|
|
488
515
|
`--co-author "Name <email>"` adds one validated `Co-authored-by` trailer to the
|
|
489
516
|
generated records commit and to the pull request body. When the day's pull
|
|
490
517
|
request already exists, the verb preserves its body and adds the trailer once.
|
|
@@ -656,9 +683,18 @@ channel; policy-authorized execution is never represented as a human grant.
|
|
|
656
683
|
"autonomy":"autonomous","winner":true,
|
|
657
684
|
"tieBreak":"specificity"|"strictest-autonomy"|
|
|
658
685
|
"lexicographic"|"tied-specificity"}],
|
|
686
|
+
"agentRequest":{"allowed":false,"explicit":true,"pattern":null},
|
|
659
687
|
"decisionPath":["...","..."]}
|
|
660
688
|
```
|
|
661
689
|
|
|
690
|
+
`agentRequest` (APRV-445) answers whether `approval propose` would admit this
|
|
691
|
+
class: `explicit` is whether the class is an exact key of `classes`, `pattern`
|
|
692
|
+
is the key whose `agent_may_request` decided (the exact key, or the most
|
|
693
|
+
specific declared `<prefix>.*` family that states one), and `allowed` is both.
|
|
694
|
+
It is read without the resolution above, because a wildcard match never makes a
|
|
695
|
+
class proposable. A `decisionPath` line appears only when some key states the
|
|
696
|
+
flag for this class.
|
|
697
|
+
|
|
662
698
|
`specificity` is [literalSegments, wildcardSegments, totalSegments] (SPEC §5.2).
|
|
663
699
|
`overridden.pattern` is null when the floor overrode `defaults.autonomy` rather
|
|
664
700
|
than a rule.
|
|
@@ -712,7 +748,77 @@ refusal {"ok":false,"error":{"code":"...","message":"..."}} on stderr
|
|
|
712
748
|
|
|
713
749
|
`path` is the file that was hashed; the logged payload carries its basename only,
|
|
714
750
|
so an exported log leaks no home directory. The event's payload is
|
|
715
|
-
`{"policy_path":"APPROVAL.md","sha256":"<64 hex>"}`.
|
|
751
|
+
`{"policy_path":"APPROVAL.md","sha256":"<64 hex>","payload_hash":"<64 hex>"}`.
|
|
752
|
+
|
|
753
|
+
**`payload_hash`: the attested bytes, recoverable (APRV-356).** The verb also
|
|
754
|
+
writes the attested text to the payload store beside the log and binds that
|
|
755
|
+
file's hash on the record, so the policy IN FORCE can be produced rather than
|
|
756
|
+
only named. A digest cannot produce the file it names, and the privileged-gesture
|
|
757
|
+
rule (`policy.core` gestures from a channel, SPEC.md §10.3) has to be decided
|
|
758
|
+
against the policy in force rather than against the one being proposed. A reader
|
|
759
|
+
re-hashes the recovered text against `sha256` from the verified log, so the store
|
|
760
|
+
is checked rather than trusted, and a store that cannot be written REFUSES the
|
|
761
|
+
attestation: nothing is appended, because a record claiming a binding whose bytes
|
|
762
|
+
are absent is a worse artifact than no record. Records written before this
|
|
763
|
+
existed still validate and verify; a reader treats the absent field as the
|
|
764
|
+
pre-amendment state, where the in-force bytes are unrecoverable and the
|
|
765
|
+
fail-closed fallback applies.
|
|
766
|
+
|
|
767
|
+
**No terminal is needed.** The identity is declared, so the verb runs the same
|
|
768
|
+
from a provisioning script with stdin at `/dev/null` as from a shell. The
|
|
769
|
+
`attest-requires-terminal` refusal belongs to the channel path (a tap cannot
|
|
770
|
+
attest a policy that maps no sender for that channel), never to this verb.
|
|
771
|
+
|
|
772
|
+
### `--bootstrap`: a store's first policy, safe to re-run (APRV-449)
|
|
773
|
+
|
|
774
|
+
The plain verb is an unconditional assertion: it reads no log and appends a
|
|
775
|
+
`policy.updated` every time it runs. That is right for a human at a terminal and
|
|
776
|
+
wrong for a provisioning step that runs again on every update and recreate of a
|
|
777
|
+
hosted tenant, where a re-run would add an attestation per redeploy and, once
|
|
778
|
+
the tenant has attested or amended their own policy, would put the operator back
|
|
779
|
+
on record as its attester. `--bootstrap` reads the VERIFIED log first:
|
|
780
|
+
|
|
781
|
+
```
|
|
782
|
+
approval policy attest --bootstrap --as human:<operator> [--json]
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
| the log carries | answer | exit |
|
|
786
|
+
|---|---|---|
|
|
787
|
+
| no attestation | attests, exactly as the plain verb | 0 |
|
|
788
|
+
| an attestation of these exact bytes | `policy-already-attested`, nothing appended | 1 |
|
|
789
|
+
| an attestation of other bytes | `policy-amendment-required`, nothing appended | 1 |
|
|
790
|
+
|
|
791
|
+
Both refusals add `seq` and `attested_by` (the attestation in force and who made
|
|
792
|
+
it) to the error object:
|
|
793
|
+
|
|
794
|
+
```
|
|
795
|
+
refusal {"ok":false,"error":{"code":"policy-already-attested","message":"...",
|
|
796
|
+
"seq":1,"attested_by":"human:carter"}} on stderr
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
`policy-already-attested` is the expected answer on a re-run, so a provisioning
|
|
800
|
+
step branches on `error.code` and treats that one code as done.
|
|
801
|
+
`policy-amendment-required` means the bytes on disk moved after the store was
|
|
802
|
+
attested. The operator does not attest that change: a change to an attested
|
|
803
|
+
policy is an amendment, and an amendment is the approver's act through a channel
|
|
804
|
+
(SPEC.md §10.3). The append is compare-and-append against the head the decision
|
|
805
|
+
was read from, and binds the digest it checked, so an attestation landing in
|
|
806
|
+
between, or a file rewritten in between, refuses (`head-moved`, or `io` naming
|
|
807
|
+
the changed bytes) rather than stacking an operator attestation on top. A torn
|
|
808
|
+
log is exit 3 and a log that does not verify is `log-corrupt` at exit 1.
|
|
809
|
+
|
|
810
|
+
With `--bootstrap` the log is the store's: it resolves under `--dir`
|
|
811
|
+
(`<dir>/.approval/log/events.jsonl`) unless `--log` names one, so `approval
|
|
812
|
+
policy attest --bootstrap --dir "$STORE"` attests the store's own log from any
|
|
813
|
+
working directory. The plain verb keeps its working-directory default. A
|
|
814
|
+
refused append can leave one unbound copy of the policy text in the payload
|
|
815
|
+
store, because the text is stored before the append (APRV-356). It is not
|
|
816
|
+
removed: the record that moved the head may be another attestation of the same
|
|
817
|
+
bytes, which binds that very file. An unbound file is inert, and `approval
|
|
818
|
+
status` counts it under `payload_store.orphans`.
|
|
819
|
+
|
|
820
|
+
`--bootstrap` beside `--organ` or `--path` is a usage error. The whole
|
|
821
|
+
provisioning sequence is [docs/hosted-provisioning.md](hosted-provisioning.md).
|
|
716
822
|
|
|
717
823
|
### `--organ <path>`: the gate's organs (APRV-272)
|
|
718
824
|
|
|
@@ -769,6 +875,92 @@ current bytes carry no attestation. It never moves doctor's exit code: an
|
|
|
769
875
|
unattested organ breaks nothing on this machine, and the enforcement for one is
|
|
770
876
|
the guard in CI.
|
|
771
877
|
|
|
878
|
+
### `--path <path>`: signing off a protected file (APRV-338)
|
|
879
|
+
|
|
880
|
+
SPEC.md's amendment-provenance rule says text that reached a protected file
|
|
881
|
+
without a grant carries `(Amended APRV-n, pending sign-off.)` and holds no more
|
|
882
|
+
authority than a proposal until a human ratifies it. Nothing recorded the
|
|
883
|
+
ratification: no event, no verb, and no way for CI or doctor to tell a ratified
|
|
884
|
+
amendment from a pending one. This flag is that record.
|
|
885
|
+
|
|
886
|
+
```
|
|
887
|
+
approval policy attest --path SPEC.md --as human:carter
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
One path per call, repository-relative (an absolute path under `--dir` is
|
|
891
|
+
accepted and recorded relative). The runtime hashes the bytes on disk; there is
|
|
892
|
+
no flag for the digest. The record is a **`gate.path.signed_off`** event:
|
|
893
|
+
|
|
894
|
+
```
|
|
895
|
+
{"event":"gate.path.signed_off","actor":"human:<id>",
|
|
896
|
+
"payload":{"path":"SPEC.md","sha256":"<64 hex>"}}
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
**Which paths.** Exactly those whose edits classify `policy.edit` or a
|
|
900
|
+
`policy.edit.*` sub-class: the built-in prose set (`CLAUDE.md`, `AGENTS.md`,
|
|
901
|
+
`.npmrc`, `.github/workflows/`) plus everything the live policy's
|
|
902
|
+
`protected_paths` widens to, including a path routed to a sub-class such as
|
|
903
|
+
`policy.edit.design`. The policy is loaded to answer that, and a policy that
|
|
904
|
+
does not load contributes nothing, which narrows what may be signed rather than
|
|
905
|
+
widening it.
|
|
906
|
+
|
|
907
|
+
Three refusals are specific to this flag, all exit 2:
|
|
908
|
+
|
|
909
|
+
```
|
|
910
|
+
path-is-policy the policy file: use `approval policy attest` with no flag
|
|
911
|
+
path-is-core a gate organ (use --organ), the approval home, or the log
|
|
912
|
+
directory, which no verb ratifies
|
|
913
|
+
path-not-protected an ordinary file, or a path that is not repository-relative
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
`--organ` and `--path` together are a usage error, as are `--policy` and
|
|
917
|
+
`--path`: each pair names two different claims and guessing which one was meant
|
|
918
|
+
is how the wrong record gets written. `--json` adds `signed_path`:
|
|
919
|
+
|
|
920
|
+
```
|
|
921
|
+
success {"ok":true,"seq":7,"sha256":"<64 hex>","path":"/abs/SPEC.md",
|
|
922
|
+
"signed_path":"SPEC.md"}
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
**Weaker than a grant, and read last.** A grant binds the exact hunk a human saw
|
|
926
|
+
in a prompt; a sign-off stands for the whole file. So the protected-path guard
|
|
927
|
+
asks about a sign-off only after its search for a grant covering the change has
|
|
928
|
+
come up empty, and the finding it prints says so in words. A change that a grant
|
|
929
|
+
does cover still passes on the grant and still names it. Signing off is for the
|
|
930
|
+
case the pending-sign-off suffix was invented for: text a human has read at that
|
|
931
|
+
commit and agrees with, for which no grant was ever taken.
|
|
932
|
+
|
|
933
|
+
**Signing off bytes that are on a branch.** The digest the guard checks is the
|
|
934
|
+
file's blob at the commit under review, and that is usually a pull request's
|
|
935
|
+
head rather than anything on disk in the primary checkout. `--dir` and `--log`
|
|
936
|
+
are separate flags for exactly this: `--dir` is the checkout whose bytes are
|
|
937
|
+
hashed and which the recorded path is relative to, `--log` is the log the record
|
|
938
|
+
is appended to. So a human ratifying an open pull request reads the change,
|
|
939
|
+
puts a checkout at that commit somewhere (a `git worktree`, or the branch
|
|
940
|
+
checked out in a scratch clone), and runs:
|
|
941
|
+
|
|
942
|
+
```
|
|
943
|
+
approval policy attest --path SPEC.md \
|
|
944
|
+
--dir /path/to/checkout-at-that-commit \
|
|
945
|
+
--log /path/to/primary/.approval/log/events.jsonl \
|
|
946
|
+
--as human:<id>
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
The record lands in the primary checkout's log, where a log advance carries it
|
|
950
|
+
to a records branch, and the guard then finds it for that path at that digest.
|
|
951
|
+
The verb never writes a log inside the worktree it hashed. If the digest it
|
|
952
|
+
prints is not the one the guard's failure named, the checkout is at the wrong
|
|
953
|
+
commit or the file has been edited since: the two must agree exactly, and a
|
|
954
|
+
mismatch is a sign-off on bytes nobody reviewed.
|
|
955
|
+
|
|
956
|
+
The verb classifies `policy.core` when `--path` is present (without it the verb
|
|
957
|
+
attests the gate's own configuration and stays pass-through), so under this
|
|
958
|
+
repository's policy the harness hook denies it to an agent with
|
|
959
|
+
`hook-class-human-only` before the verb's own `actor-not-human` refusal is
|
|
960
|
+
reached. `approval doctor`'s `pending-sign-off` row lists the protected files
|
|
961
|
+
that still carry the marker with no record over their current bytes; like
|
|
962
|
+
`gate-organs` it never moves doctor's exit code.
|
|
963
|
+
|
|
772
964
|
## policy amend
|
|
773
965
|
|
|
774
966
|
**Progress, on stderr.** The verb re-verifies the whole chain and recovers the
|
|
@@ -832,6 +1024,16 @@ resolution still prints in the semantic diff below for the human who attests it.
|
|
|
832
1024
|
Until APRV-296 every declared class had to be pinned, in both directions, and a
|
|
833
1025
|
one-line TTL amendment on 2026-09-07 took three runs to land because of it.
|
|
834
1026
|
|
|
1027
|
+
The one check every declared class still faces is REACHABILITY: a class nothing
|
|
1028
|
+
can emit is a line that will never fire, and the ceremony refuses it rather than
|
|
1029
|
+
leaving an operator believing in it for a year. Three ways to be reachable: the
|
|
1030
|
+
command classifier's fixed table, a `protected_paths` entry routing a path
|
|
1031
|
+
family to a `policy.edit` sub-class, and `RUNTIME_CLASSES` in
|
|
1032
|
+
`src/core/command-class.ts`, which names the classes a runtime cycle asks the
|
|
1033
|
+
gate for directly and no command spells (`log.advance.daemon` is the first,
|
|
1034
|
+
APRV-382). A new class of that third kind needs its line there in the SAME build
|
|
1035
|
+
the ceremony runs, or the amendment refuses `policy-suite-failed`.
|
|
1036
|
+
|
|
835
1037
|
**And then the whole dogfood suite, still before the attestation.** The pin check
|
|
836
1038
|
is a subset of `tests/dogfood.test.ts`, and the seq 23351 ceremony passed the pins
|
|
837
1039
|
and went red on CI over a dogfood test about the values block. So `--commit` also
|
|
@@ -912,15 +1114,125 @@ base carries it, and a pins edit somebody else landed is not reverted by this
|
|
|
912
1114
|
ceremony. The pin deltas print in the semantic diff beside the class deltas, ride
|
|
913
1115
|
in the commit subject, and appear in `--json` as `pins`.
|
|
914
1116
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1117
|
+
**Who publishes the log (APRV-420).** The log is in that file set only when this
|
|
1118
|
+
amendment is the thing publishing it. On 2026-09-21 it always was, and PR 530
|
|
1119
|
+
paid for it: two records advances merged first carrying the same records (the
|
|
1120
|
+
attestation at seq 65736 and its payload reached main through PR 531), and git
|
|
1121
|
+
does not care that one side's appended lines are a prefix of the other's, so the
|
|
1122
|
+
amendment went DIRTY, lost its arm, and the repair was a hand merge of
|
|
1123
|
+
`events.jsonl` in a throwaway worktree.
|
|
1124
|
+
|
|
1125
|
+
The cause was two publishers carrying the same bytes, so the ceremony now leaves
|
|
1126
|
+
one of them holding it. It asks two questions of the base it is about to commit
|
|
1127
|
+
on, in this order:
|
|
1128
|
+
|
|
1129
|
+
1. does the base's log already carry every record this attestation added? Then
|
|
1130
|
+
the log is published and the amendment has nothing to add to it.
|
|
1131
|
+
2. is a records advance live, meaning origin carries a `records-log-*` branch?
|
|
1132
|
+
That advance publishes the log, so the amendment has nothing to add to it
|
|
1133
|
+
either. Whether the branch already carries THIS attestation is checked
|
|
1134
|
+
rather than assumed: the ceremony fetches the branch and compares chains.
|
|
1135
|
+
An advance pushed before the human signed does not carry it, which is the
|
|
1136
|
+
ordinary order; the ceremony then says so, the next advance publishes the
|
|
1137
|
+
record, and the pull request's protected-path guard holds the policy
|
|
1138
|
+
change until a records branch or main carries it.
|
|
1139
|
+
|
|
1140
|
+
Either way the commit is the policy bytes, the attested text and the pins, and a
|
|
1141
|
+
commit that does not touch `events.jsonl` cannot conflict on `events.jsonl`,
|
|
1142
|
+
whichever side lands first. Otherwise the amendment is the only publisher and
|
|
1143
|
+
carries the log, exactly as it always has. The ceremony prints which of the three
|
|
1144
|
+
happened, so the pull request's file list is never a surprise.
|
|
1145
|
+
|
|
1146
|
+
The question is asked with `git ls-remote`, not `gh`, so it answers the same on a
|
|
1147
|
+
box with no GitHub CLI and no token. And it fails toward CARRYING: anything the
|
|
1148
|
+
check cannot establish leaves the amendment holding the log. A dirty pull request
|
|
1149
|
+
is a nuisance; an attestation that reaches `main` in nobody's commit is a policy
|
|
1150
|
+
in force that the committed log does not record.
|
|
1151
|
+
|
|
1152
|
+
**A re-run repairs the pull request it finds (APRV-420).** A second run used to
|
|
1153
|
+
stop at `nothing to amend`, which is true and useless: the live policy does match
|
|
1154
|
+
its attestation, and the pull request carrying that attestation is open,
|
|
1155
|
+
unmergeable and unarmed. So when `--commit` or `--pr` is asked for and origin
|
|
1156
|
+
already carries the amendment branch, the re-run fetches the default branch and
|
|
1157
|
+
computes what it still LACKS — the policy bytes, the attested text, the pins, and
|
|
1158
|
+
the log under the rule above — and then:
|
|
1159
|
+
|
|
1160
|
+
- nothing lacking: the amendment landed in full. It says so and prints `gh pr
|
|
1161
|
+
close <branch> --delete-branch`.
|
|
1162
|
+
- something lacking: it rebuilds that commit on the CURRENT default branch and
|
|
1163
|
+
force-updates the branch, then re-arms `gh pr merge <branch> --merge --auto`.
|
|
1164
|
+
The trunk's log is a superset of the amendment's by construction, so rebuilding
|
|
1165
|
+
on it is the re-merge without a merge.
|
|
1166
|
+
|
|
1167
|
+
The rebuild is `commitOnBase`'s scratch index like every other commit this verb
|
|
1168
|
+
makes, so nothing is checked out and no live appender has the log moved
|
|
1169
|
+
underneath it. The force-push is the one in this codebase, and it is bounded: the
|
|
1170
|
+
remote branch must be a single commit on top of a commit the base already
|
|
1171
|
+
contains, which is the shape this ceremony creates and nothing else. A branch
|
|
1172
|
+
carrying anything more is reported with `git log --oneline <base>..<tip>` and
|
|
1173
|
+
left alone. `--dry-run` and `--no-publish` compute the repair, print the
|
|
1174
|
+
commands and run none of it. `--json` carries a `repair` object (`branch`,
|
|
1175
|
+
`state` of `landed`/`repaired`/`owed`/`failed`, `base`, `carried`, `commit`,
|
|
1176
|
+
`pushed`, `autoMerge`, `commands`, `message`), additively and always present:
|
|
1177
|
+
`null` on every run that had an amendment to make and on every no-op that found
|
|
1178
|
+
no branch standing. Only `failed` is a nonzero exit.
|
|
1179
|
+
|
|
1180
|
+
It refuses outside a git repository (`commit-preconditions`), and refuses
|
|
1181
|
+
`staged-unrelated` when the index holds staged changes to anything beyond those
|
|
1182
|
+
three: a commit that swept in an unrelated staged edit would make "this commit is
|
|
1183
|
+
the amendment" false. It refuses `dirty-tree` when one of those three is staged
|
|
1184
|
+
in one state and modified again in the working tree, because the commit is
|
|
1185
|
+
assembled from the WORKING TREE and would otherwise carry bytes the operator's
|
|
1186
|
+
`git diff --cached` never showed. An unrelated *unstaged* path is deliberately
|
|
1187
|
+
not a refusal: the scratch index lays exactly the ceremony's paths over the
|
|
1188
|
+
remote's tree, so nothing else can reach the commit, and refusing over one would
|
|
1189
|
+
stop the ceremony in the checkout it is written for, where the daemon's envelope
|
|
1190
|
+
write-backs leave task files modified as a matter of course. On the branch flow
|
|
918
1191
|
it also refuses when there is no `origin` remote, and when a `--branch` name
|
|
919
1192
|
already exists. Every one of those refusals happens BEFORE the attestation, so a
|
|
920
1193
|
refused `--commit` never leaves an attested policy without its commit. The same
|
|
921
1194
|
holds for the fetch, the two base checks, the policy suite and the dogfood suite
|
|
922
1195
|
above.
|
|
923
1196
|
|
|
1197
|
+
**`--pr`: the ceremony finishes its own job (APRV-341).** `--pr` is `--commit`
|
|
1198
|
+
plus "and publish it": it forces the BRANCH flow whatever the protection probe
|
|
1199
|
+
answered, so the amendment is committed on `origin/<default branch>` in a scratch
|
|
1200
|
+
index, pushed to `policy-amend-<seq>`, carried by a pull request, and armed with
|
|
1201
|
+
`gh pr merge <branch> --merge --auto`. The merge queue picks the strategy from
|
|
1202
|
+
there. `--pr --direct` and `--pr --no-publish` are usage errors: each pair asks
|
|
1203
|
+
for opposite ceremonies.
|
|
1204
|
+
|
|
1205
|
+
The pull request is OPENED or UPDATED. `gh pr list --head <branch> --state open`
|
|
1206
|
+
is asked first, and when one is already standing its title and body are edited
|
|
1207
|
+
rather than a second being opened, so a re-run of a ceremony that stopped
|
|
1208
|
+
half-way finishes rather than failing at `gh pr create`. A `gh` that cannot
|
|
1209
|
+
answer the question falls through to `create`, which is the path that was there
|
|
1210
|
+
before.
|
|
1211
|
+
|
|
1212
|
+
**The verb never switches branches.** On 2026-09-16 an agent-written runbook for
|
|
1213
|
+
the primary checkout ended with `git checkout main` after the amend commit, which
|
|
1214
|
+
rewound `APPROVAL.md` and `events.jsonl` under a live appender; the hook then
|
|
1215
|
+
appended 204 records on the stale chain and the log forked. `--pr` exists so that
|
|
1216
|
+
runbook does not: the commit is assembled with `git read-tree` into a scratch
|
|
1217
|
+
index and pushed by sha, the checkout ends the verb on the branch it started on,
|
|
1218
|
+
with the same HEAD, the same index and the same working tree, and the only file
|
|
1219
|
+
that moved is the log, which gained the attestation. A test compares all four
|
|
1220
|
+
before and after.
|
|
1221
|
+
|
|
1222
|
+
Without `--pr` (and on a box with no `gh`) the printed runbook does the same
|
|
1223
|
+
thing by hand, and it does not switch branches either (APRV-360): `approval log
|
|
1224
|
+
sync`, `git add`, `git commit`, `git push origin HEAD:refs/heads/policy-amend-<seq>`,
|
|
1225
|
+
`gh pr create --head`, `gh pr merge --auto --merge`. A fixture test pins the six
|
|
1226
|
+
commands and asserts that no printed line contains a `git checkout`, because a
|
|
1227
|
+
fallback nobody checks is where the branch switch came back.
|
|
1228
|
+
|
|
1229
|
+
The form printed before APRV-360 opened with `git checkout -b policy-amend-<seq>
|
|
1230
|
+
origin/main`. On 2026-09-18 the primary's main was fourteen commits behind, the
|
|
1231
|
+
switch refused rather than overwrite `QUEUE.md`, the working log and six
|
|
1232
|
+
payloads, and the ceremony stalled with an edited, attested, unpublished policy.
|
|
1233
|
+
`approval log sync` is the first line now for that reason: it brings the checkout
|
|
1234
|
+
current, and it refuses `log-diverged` rather than fast-forwarding over a fork.
|
|
1235
|
+
|
|
924
1236
|
`--commit` also pushes, on both flows. When there is no `origin` to push to, the
|
|
925
1237
|
direct flow reports the push as still to run rather than listing it among the
|
|
926
1238
|
commands it ran. `--no-publish` stops the ceremony at the commit: nothing is
|
|
@@ -962,9 +1274,9 @@ beneath it.
|
|
|
962
1274
|
sha256 8acbd01cda98
|
|
963
1275
|
|
|
964
1276
|
Committed
|
|
965
|
-
✓ committed the policy and the
|
|
1277
|
+
✓ committed the policy, the log and the attested policy text together:
|
|
966
1278
|
|
|
967
|
-
git add APPROVAL.md .approval/log/events.jsonl
|
|
1279
|
+
git add APPROVAL.md .approval/log/events.jsonl .approval/payloads/8acbd01cda98….json
|
|
968
1280
|
git commit -m "Policy: amend APPROVAL.md: 1 class resolution(s) (attested seq 2)"
|
|
969
1281
|
|
|
970
1282
|
Publishing
|
|
@@ -1057,10 +1369,16 @@ is the human rendering only.
|
|
|
1057
1369
|
prints the SEMANTIC diff, computed by the real engine on both versions;
|
|
1058
1370
|
4. runs the load advisory;
|
|
1059
1371
|
5. asks for confirmation (skipped by `--yes` and `--dry-run`);
|
|
1060
|
-
6. attests: one `policy.updated` event, identical to `approval policy attest
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1372
|
+
6. attests: one `policy.updated` event, identical to `approval policy attest`,
|
|
1373
|
+
which since APRV-356 also stores the attested bytes in the payload store and
|
|
1374
|
+
binds their hash on the record;
|
|
1375
|
+
7. prints, or with `--commit` runs, the git ceremony — `git add <policy> <log>
|
|
1376
|
+
<attested bytes>` (plus the pins when they moved), a `git commit` citing the
|
|
1377
|
+
attestation seq, and the push (and, on the branch flow, the branch and the
|
|
1378
|
+
pull request). The store file rides in the same commit because a committed
|
|
1379
|
+
log carrying a binding whose bytes were never committed leaves the policy in
|
|
1380
|
+
force unrecoverable for everyone reading that copy, the CI protected-path
|
|
1381
|
+
guard included;
|
|
1064
1382
|
8. publishes, unless `--no-publish`: a push the remote refuses is answered by
|
|
1065
1383
|
the branch, push, pull request and auto-merge above, each reported as it
|
|
1066
1384
|
lands, and a step that fails drops to the runbook from there.
|
|
@@ -1117,7 +1435,7 @@ attestation may still proceed.
|
|
|
1117
1435
|
"publishing":null|{"attempted":true,"complete":true,
|
|
1118
1436
|
"via":"direct"|"branch"|"recovery"|"none",
|
|
1119
1437
|
"branch":null|"policy-amend-2","pushed":true,
|
|
1120
|
-
"prUrl":null|"https://...",
|
|
1438
|
+
"prUrl":null|"https://...","prUpdated":false,
|
|
1121
1439
|
"autoMerge":"armed"|"refused"|"not-attempted",
|
|
1122
1440
|
"steps":[{"command":"git push origin main","ok":false}],
|
|
1123
1441
|
"stoppedAt":null|"git push -u origin policy-amend-2",
|
|
@@ -1149,10 +1467,19 @@ the message.
|
|
|
1149
1467
|
- `io` — the policy file or the log could not be read or written.
|
|
1150
1468
|
- `load-failed` — `--require-load` and the policy does not load. Nothing was
|
|
1151
1469
|
appended.
|
|
1152
|
-
- `commit-preconditions` — `--commit` outside a git repository, with
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1470
|
+
- `commit-preconditions` — `--commit` outside a git repository, with the policy
|
|
1471
|
+
and the log in different repositories, or (branch flow) with no origin remote
|
|
1472
|
+
or a `--branch` name already taken. Checked before the attestation; nothing
|
|
1473
|
+
was appended.
|
|
1474
|
+
- `staged-unrelated` — the index carries staged changes beyond the policy, the
|
|
1475
|
+
log and the pins (APRV-341). Its own code because its repair is its own: one
|
|
1476
|
+
`git restore --staged <path>`. Checked before the attestation; nothing was
|
|
1477
|
+
appended.
|
|
1478
|
+
- `dirty-tree` — one of those three is staged in one state and modified again in
|
|
1479
|
+
the working tree (APRV-341). The commit is assembled from the working tree, so
|
|
1480
|
+
it would carry bytes `git diff --cached` does not show. An unrelated *unstaged*
|
|
1481
|
+
path is not this refusal and never was. Checked before the attestation;
|
|
1482
|
+
nothing was appended.
|
|
1156
1483
|
- `fetch-failed` / `base-policy-diverged` / `base-log-diverged` — the remote the
|
|
1157
1484
|
amendment would be based on could not be fetched, carries a policy this edit
|
|
1158
1485
|
was not written against, or carries a log this working log does not contain.
|
|
@@ -1181,6 +1508,114 @@ the message.
|
|
|
1181
1508
|
- `log-unreadable` / `log-torn-tail` / `log-corrupt` — nothing is amended from a
|
|
1182
1509
|
log that does not verify.
|
|
1183
1510
|
|
|
1511
|
+
## policy apply
|
|
1512
|
+
|
|
1513
|
+
**The hand-paste this replaces (APRV-343).** Agents may not write `APPROVAL.md`:
|
|
1514
|
+
it is `policy.core`, and this project's policy holds that class human-only. So a
|
|
1515
|
+
policy change an agent proposes travels as a document under `docs/proposals/`
|
|
1516
|
+
that quotes each current line byte for byte beside its replacement, and the
|
|
1517
|
+
human pastes. Two things go wrong with a paste, and both have:
|
|
1518
|
+
|
|
1519
|
+
- **a whole-file copy reverts what it did not know about.** The prepared file was
|
|
1520
|
+
written against the policy as it stood when the proposal was drafted, so
|
|
1521
|
+
anything landed in between is silently undone. That is the failure
|
|
1522
|
+
`base-policy-diverged` catches for the commit, one step too late to help;
|
|
1523
|
+
- **a paste carries its wrapper.** One paste of a proposal page took the page's
|
|
1524
|
+
own fence with it, which hid a block from the loader (APRV-273).
|
|
1525
|
+
|
|
1526
|
+
`approval policy apply <proposal.md>` answers both by construction. It writes no
|
|
1527
|
+
byte that is not anchored to a byte it proved present in the live file, and it
|
|
1528
|
+
reads fences by their backtick run, so a four-backtick wrapper around a
|
|
1529
|
+
three-backtick block is the wrapper it is rather than part of the content.
|
|
1530
|
+
|
|
1531
|
+
### The proposal format
|
|
1532
|
+
|
|
1533
|
+
A proposal is ordinary markdown. Anywhere in it, a fenced block **with a
|
|
1534
|
+
declared language** whose immediately preceding non-blank line is a label is a
|
|
1535
|
+
member of a pair:
|
|
1536
|
+
|
|
1537
|
+
| label | means |
|
|
1538
|
+
|---|---|
|
|
1539
|
+
| `Current:` | the bytes as they stand in the live policy |
|
|
1540
|
+
| `Replace with:` | what they become |
|
|
1541
|
+
| `Supersedes:` | optional: an earlier section's RESULT, to match instead |
|
|
1542
|
+
|
|
1543
|
+
A fence with no info string is skipped, which is APRV-273's hazard turned into a
|
|
1544
|
+
rule. A block whose label names no role is skipped too, so a proposal page can
|
|
1545
|
+
carry a `bash` block of commands to run afterwards without the applier mistaking
|
|
1546
|
+
it for policy text. Pairs apply in document order.
|
|
1547
|
+
|
|
1548
|
+
**Supersession is declared, never inferred from position.** A later section that
|
|
1549
|
+
rewrites a line an earlier section already rewrote quotes the earlier section's
|
|
1550
|
+
*result* under a `Supersedes:` label; the applier looks for that text when the
|
|
1551
|
+
section's own `Current` block is no longer in the file, which is exactly the
|
|
1552
|
+
state the earlier section left behind. Position could not carry this: two
|
|
1553
|
+
sections that touch one line are not in general in the order the file needs, and
|
|
1554
|
+
a rule inferred from order is a rule nobody can read off the page. A pair whose
|
|
1555
|
+
`Current` **and** `Supersedes` blocks both occur in the file is
|
|
1556
|
+
`proposal-ambiguous`: two spellings of one line is a question about which the
|
|
1557
|
+
file means, and no verb here will pick.
|
|
1558
|
+
|
|
1559
|
+
**Whole-file replacement is not accepted**, and that is the decision rather than
|
|
1560
|
+
an omission. The verb's whole value is that every byte it writes is anchored to
|
|
1561
|
+
a byte it proved present, which is what makes a stale proposal a refusal instead
|
|
1562
|
+
of a silent revert. A whole-file blob has no anchor. `approval policy amend`
|
|
1563
|
+
over a hand-edited file is already the supported way to replace the file
|
|
1564
|
+
deliberately.
|
|
1565
|
+
|
|
1566
|
+
**The values block is treated exactly as the policy block is**, by knowing
|
|
1567
|
+
nothing about either. The applier is a byte-level replacement over the whole
|
|
1568
|
+
file: it does not parse the policy, does not locate blocks, and does not care
|
|
1569
|
+
which fence a pair lands in. The values block is inert (SPEC §11.1 invariant
|
|
1570
|
+
10), so applying one changes no verdict, and the attestation the amendment
|
|
1571
|
+
appends covers the whole file's bytes either way (SPEC §5.2, §5.3).
|
|
1572
|
+
|
|
1573
|
+
`docs/proposals/README.md` is the contract as a page for proposal authors, and
|
|
1574
|
+
`docs/proposals/approval-md-2026-09.md` is a worked example of every part of it.
|
|
1575
|
+
|
|
1576
|
+
### What it does, in order
|
|
1577
|
+
|
|
1578
|
+
1. Refuses an agent identity (`apply-agent-actor`) before reading anything.
|
|
1579
|
+
2. Parses the proposal into ordered pairs.
|
|
1580
|
+
3. Resolves every pair against an **in-memory** copy of the policy. A proposal
|
|
1581
|
+
whose third pair is stale writes nothing at all, so "a stale proposal cannot
|
|
1582
|
+
half-apply" is a property of the code rather than of the order somebody wrote
|
|
1583
|
+
the sections in.
|
|
1584
|
+
4. Prints the replacements, each as its matched block and its replacement.
|
|
1585
|
+
5. Asks for confirmation (`--yes` skips it, `--dry-run` stops here).
|
|
1586
|
+
6. Writes the policy, then runs `approval policy amend --pr` in this process —
|
|
1587
|
+
which asks its OWN question about the semantic diff, because "are these the
|
|
1588
|
+
bytes" and "is this the policy" are different questions.
|
|
1589
|
+
|
|
1590
|
+
**It publishes by default (APRV-360).** The amendment runs with `--pr`, so the
|
|
1591
|
+
branch is created on the remote by refspec, the pull request is opened, the
|
|
1592
|
+
merge is armed, and the checkout ends where it started. Before this the amend
|
|
1593
|
+
ran with no flag: the policy was written and attested, and the operator was
|
|
1594
|
+
handed a procedure that began with a branch switch. On 2026-09-18 the primary
|
|
1595
|
+
refused that switch and the ceremony stalled with an edited, attested,
|
|
1596
|
+
unpublished policy, which is the one state in which every gated operation on the
|
|
1597
|
+
box refuses. `--no-publish` stops at the commit and is passed through to the
|
|
1598
|
+
amend; `--pr` is accepted and does nothing, because runbooks already say it, and
|
|
1599
|
+
`--pr` with `--no-publish` is a usage error.
|
|
1600
|
+
|
|
1601
|
+
`--no-amend` writes and stops, and says loudly that the policy is now edited and
|
|
1602
|
+
unattested. A run where every pair resolves and no byte moves is a success and a
|
|
1603
|
+
no-op: the proposal has already been applied.
|
|
1604
|
+
|
|
1605
|
+
**Refusal codes** (`error.code` with `--json`; frozen public API): `usage`,
|
|
1606
|
+
`io`, `apply-agent-actor`, `proposal-empty`, `proposal-malformed` (a `Current`
|
|
1607
|
+
with no `Replace with`, or the reverse), `proposal-stale` (a quoted current text
|
|
1608
|
+
is not in the file), `proposal-ambiguous` (it occurs more than once, or both it
|
|
1609
|
+
and its superseded text occur). Every one of them writes nothing.
|
|
1610
|
+
|
|
1611
|
+
Two outcomes are deliberately not in that union. Answering no at the
|
|
1612
|
+
confirmation is exit 0 with `aborted:` on stdout, exactly as `policy amend`
|
|
1613
|
+
answers it: nothing failed, and an error object at exit 0 would be a
|
|
1614
|
+
contradiction the caller has to resolve. An amendment that refuses has already
|
|
1615
|
+
printed its own code from its own frozen union, so this verb adds a sentence
|
|
1616
|
+
naming the state that leaves behind — the replacements written, the policy
|
|
1617
|
+
unattested — and returns the amendment's exit code unchanged.
|
|
1618
|
+
|
|
1184
1619
|
## register
|
|
1185
1620
|
|
|
1186
1621
|
The task file is read only. Nothing is rewritten, so unknown frontmatter keys
|
|
@@ -1231,6 +1666,24 @@ hash must equal the declared `payload_hash` and it is filed in
|
|
|
1231
1666
|
bytes from. Supply it here once and no channel needs `--payload-dir` or
|
|
1232
1667
|
`--payloads` at all.
|
|
1233
1668
|
|
|
1669
|
+
For an action `approval run` will execute, produce those bytes with `approval
|
|
1670
|
+
payload run -- <cmd…>` rather than by hand. Since APRV-401 the payload carries
|
|
1671
|
+
the size and digest of the script the argv names, and a hand-written
|
|
1672
|
+
`{"argv":…,"cwd":…}` for a command that names a script is a declaration the
|
|
1673
|
+
execution will refuse `payload-mismatch`, correctly, because it describes bytes
|
|
1674
|
+
nobody bound:
|
|
1675
|
+
|
|
1676
|
+
```
|
|
1677
|
+
approval payload run --hash -- bash scripts/install.sh # the declaration
|
|
1678
|
+
approval payload run -- bash scripts/install.sh \
|
|
1679
|
+
| approval request <task> --action <key> --as agent:<id> --payload -
|
|
1680
|
+
```
|
|
1681
|
+
|
|
1682
|
+
Material supplied here is checked against the declared hash and never against
|
|
1683
|
+
the filesystem: this verb does not know where the run will happen, and the
|
|
1684
|
+
guarantee is that `approval run` recomputes the same value from the tree it is
|
|
1685
|
+
about to spawn into and refuses on any difference.
|
|
1686
|
+
|
|
1234
1687
|
When the policy permits an unattended action, supplied material is still checked
|
|
1235
1688
|
against the registered action's task, class and payload hash and retained before
|
|
1236
1689
|
`proceed:true` is returned. This creates no approval event and does not require
|
|
@@ -1252,6 +1705,129 @@ refusal {"ok":false,"error":{"code":"...","message":"...",
|
|
|
1252
1705
|
The refusal goes to stderr, and `seq` is the `budget.exceeded` record that WAS
|
|
1253
1706
|
appended.
|
|
1254
1707
|
|
|
1708
|
+
## propose
|
|
1709
|
+
|
|
1710
|
+
`approval propose --class <c> --key <k> --summary <s> --payload-json <json>`
|
|
1711
|
+
registers one action and requests it in one call (APRV-445, amended SPEC.md
|
|
1712
|
+
§10.1). It exists for a requester that holds its payload in memory and has no
|
|
1713
|
+
file on the gate's machine: an agent in a sandbox reaching `approval serve`
|
|
1714
|
+
with the agent credential, which refuses every path flag. `--payload-json` is
|
|
1715
|
+
the payload as the flag's VALUE, a JSON object of at most 262144 bytes of UTF-8
|
|
1716
|
+
as sent; an array, string, number or `null` is a usage error, and a larger
|
|
1717
|
+
payload is refused `payload-too-large` at exit 2 before it is parsed.
|
|
1718
|
+
|
|
1719
|
+
**Why this verb may name its class.** `request` never does, so that an agent
|
|
1720
|
+
cannot call a `financial.spend` a `read.web`. Here the agent is also the
|
|
1721
|
+
registrant, so the bound is the operator's: the class must be an EXACT key of
|
|
1722
|
+
the policy's `classes` (a wildcard rule that happens to match does not count),
|
|
1723
|
+
and that key or a declared `<prefix>.*` family of it must set
|
|
1724
|
+
`agent_may_request: true` (SPEC.md §5.2). Anything else refuses
|
|
1725
|
+
`class-not-agent-requestable` and registers nothing. A `human-only` member
|
|
1726
|
+
under an open family refuses `class-human-only`, also before anything is
|
|
1727
|
+
registered. The check runs again inside the request's own read-check-append
|
|
1728
|
+
cycle, so a re-attestation in between cannot slip a closed class through. What
|
|
1729
|
+
the agent picks AMONG the opened classes is still its own statement: an
|
|
1730
|
+
operator who opens a manual class and an autonomous one under the same family
|
|
1731
|
+
is trusting the agent to pick the right one, and the record names the class it
|
|
1732
|
+
picked.
|
|
1733
|
+
|
|
1734
|
+
```yaml approval-policy
|
|
1735
|
+
classes:
|
|
1736
|
+
intent.publish.*: { autonomy: manual, agent_may_request: true }
|
|
1737
|
+
intent.publish.inferred.index: { autonomy: manual }
|
|
1738
|
+
intent.publish.stated.index: { autonomy: autonomous }
|
|
1739
|
+
```
|
|
1740
|
+
|
|
1741
|
+
**The key** must begin with `<class>:` and an id of printable characters with
|
|
1742
|
+
no whitespace or control characters (refused
|
|
1743
|
+
`key-class-mismatch`, exit 2): `intent.publish.inferred.index:<intention id>`.
|
|
1744
|
+
It is recorded verbatim as `action_key`, and a consumer reads the class from the
|
|
1745
|
+
record rather than parsing it back out of the key. The key is at most 1024 bytes
|
|
1746
|
+
and `--summary` at most 4096; a lone UTF-16 surrogate in either or anywhere in
|
|
1747
|
+
the payload, and a number JSON parses to Infinity (`1e400`), are usage errors. An
|
|
1748
|
+
integer beyond 2^53 is hashed as the double it parses to, so send large ids as
|
|
1749
|
+
strings.
|
|
1750
|
+
|
|
1751
|
+
**Do not open a `supervised-live` class to agents.** The live draw would decide,
|
|
1752
|
+
per proposal, whether a human is asked at all, which the requester can neither
|
|
1753
|
+
predict nor explain to the person whose words it is proposing.
|
|
1754
|
+
|
|
1755
|
+
**The task id** is `propose:` and the first 32 hex digits of the SHA-256 over
|
|
1756
|
+
the RFC 8785 canonical JSON of `[actor, class, key]`. The actor is the tenant
|
|
1757
|
+
half: one `approval serve` process is one store and one fixed `agent:<id>`. Two
|
|
1758
|
+
actors proposing one key derive two tasks, and the second registration is
|
|
1759
|
+
refused `task-already-registered` because the key is already declared under the
|
|
1760
|
+
first.
|
|
1761
|
+
|
|
1762
|
+
**Records.** One `task.registered` (actor = the caller, envelope `origin.app:
|
|
1763
|
+
approval-propose`, one action: the class, the summary, `idempotency_key` = the
|
|
1764
|
+
key, `payload_hash`) and one `approval.requested` exactly as `request` writes it,
|
|
1765
|
+
with `action_key` = the key verbatim, `summary`, `payload_hash`, `display_hash`,
|
|
1766
|
+
`policy_sha256` and `execution: "harness"` (a grant mints no token; the
|
|
1767
|
+
requester proceeds on the grant and records it with `start`). The summary is in
|
|
1768
|
+
the log in cleartext, twice; the payload is never in the log, only in the
|
|
1769
|
+
payload store (`.approval/payloads/<hash>.json`, mode 0600 in a 0700 directory),
|
|
1770
|
+
which the tenant credential's `GET /export` includes and the agent credential
|
|
1771
|
+
can never read back. Set `payload_retention` so the daemon prunes bytes whose
|
|
1772
|
+
action has finished.
|
|
1773
|
+
|
|
1774
|
+
**Idempotency.** The same class, key and bytes append nothing and answer the
|
|
1775
|
+
request's standing with `idempotent: true` while it still means something:
|
|
1776
|
+
pending, granted and spendable, rejected, revoked, or executed (`state:
|
|
1777
|
+
"executed"`, after `start`). The same call ASKS AGAIN, under the same
|
|
1778
|
+
registration, when the request can no longer be answered usefully: withdrawn,
|
|
1779
|
+
expired, a grant whose request window lapsed (a grant has no window of its own,
|
|
1780
|
+
so a tap at hour 71 of 72 authorizes nothing at hour 73), or a grant or pending
|
|
1781
|
+
request pinned to a policy that has since been re-attested (a pending one is
|
|
1782
|
+
first withdrawn, reason `superseded`). Two identical calls racing to register
|
|
1783
|
+
are one proposal. The same key with different bytes refuses
|
|
1784
|
+
`duplicate-request` while a request is live and `payload-mismatch` otherwise: a
|
|
1785
|
+
key is bound to the bytes it was first proposed with. A plain `approval request`
|
|
1786
|
+
on a `propose:` task is refused `task-is-proposal`.
|
|
1787
|
+
|
|
1788
|
+
**Off the manual path.** A class that resolves `autonomous` or `supervised` is
|
|
1789
|
+
registered and answered at once with `decision: "autonomous"` (or
|
|
1790
|
+
`"supervised"`) and no approval record. Act, then record it with `start`.
|
|
1791
|
+
|
|
1792
|
+
**`--json`** (one object on stdout):
|
|
1793
|
+
|
|
1794
|
+
```
|
|
1795
|
+
manual {"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
|
|
1796
|
+
"payload_hash":"<64 hex>","decision":"requested","state":"requested",
|
|
1797
|
+
"seq":7,"idempotent":false}
|
|
1798
|
+
retry {...,"state":"granted","seq":9,"idempotent":true}
|
|
1799
|
+
autonomous {...,"decision":"autonomous","state":null,"seq":null,"idempotent":false}
|
|
1800
|
+
```
|
|
1801
|
+
|
|
1802
|
+
Poll a `requested` proposal with `approval wait <task> --timeout 0`.
|
|
1803
|
+
|
|
1804
|
+
## start
|
|
1805
|
+
|
|
1806
|
+
`approval start <task> --action <key> --payload-json <json>` appends the one
|
|
1807
|
+
`execution.started` for an action its requester is about to carry out itself,
|
|
1808
|
+
outside any harness hook (APRV-445). Proposals only: any other task is refused
|
|
1809
|
+
`task-not-proposal`. REQUESTER-ONLY: the caller must be the actor
|
|
1810
|
+
that registered the task (`not-requester`). The bytes presented must hash to the
|
|
1811
|
+
registered `payload_hash` (`payload-mismatch`), so what is done is what was
|
|
1812
|
+
proposed.
|
|
1813
|
+
|
|
1814
|
+
It writes through the two existing harness spenders and nothing else. A key
|
|
1815
|
+
whose request is pending or granted and unspent spends its harness grant: refused `not-granted` while
|
|
1816
|
+
pending or after a rejection, `expired` after the window, `policy-drift` under
|
|
1817
|
+
a re-attested policy. A key with no such request (none, or one withdrawn,
|
|
1818
|
+
expired, rejected or revoked, which binds nothing) is recorded as authorized by
|
|
1819
|
+
the policy itself: refused `not-granted` for a manual class and `class-human-only`
|
|
1820
|
+
for a human-only one, and charged against the budgets. A second start of one key
|
|
1821
|
+
is `already-executed`. The record carries `execution: "harness"`, which is why no
|
|
1822
|
+
`execution.completed` ever follows it.
|
|
1823
|
+
|
|
1824
|
+
**`--json`**:
|
|
1825
|
+
|
|
1826
|
+
```
|
|
1827
|
+
{"ok":true,"task":"propose:<32 hex>","action_key":"<k>","class":"<c>",
|
|
1828
|
+
"authorization":"grant"|"policy","seq":12}
|
|
1829
|
+
```
|
|
1830
|
+
|
|
1255
1831
|
## grant
|
|
1256
1832
|
|
|
1257
1833
|
Legal only on a request that is awaiting a decision. A second decision is
|
|
@@ -1426,8 +2002,8 @@ refusal {"ok":false,"error":{"code":"...","message":"...","state"?:"..."}}
|
|
|
1426
2002
|
|
|
1427
2003
|
## gate refusal codes
|
|
1428
2004
|
|
|
1429
|
-
The vocabulary every gate verb (register, request,
|
|
1430
|
-
withdraw, expire) returns in `error.code` with `--json`. Frozen public API: an agent branches on it
|
|
2005
|
+
The vocabulary every gate verb (register, request, propose, start, grant,
|
|
2006
|
+
reject, revoke, withdraw, expire) returns in `error.code` with `--json`. Frozen public API: an agent branches on it
|
|
1431
2007
|
to decide whether to fix itself, stop retrying, or ask a human.
|
|
1432
2008
|
|
|
1433
2009
|
- `policy-not-attested` — policy unattested or its bytes changed since
|
|
@@ -1491,7 +2067,8 @@ to decide whether to fix itself, stop retrying, or ask a human.
|
|
|
1491
2067
|
it, or is withdrawing one it already withdrew. Distinct from `already-decided`:
|
|
1492
2068
|
nobody answered, and nobody can now. Request the action again.
|
|
1493
2069
|
- `not-requester` — a withdrawal was attempted by an actor other than the one
|
|
1494
|
-
that opened the request
|
|
2070
|
+
that opened the request, or a `start` by an actor other than the one that
|
|
2071
|
+
registered the task. Only the party that asked may take the question back;
|
|
1495
2072
|
anyone else who wants it gone rejects it.
|
|
1496
2073
|
- `expired` — the TTL lapsed, judged from the request's own `ts`.
|
|
1497
2074
|
- `not-expired` — expire was called before the TTL lapsed, or the policy declares
|
|
@@ -1529,6 +2106,20 @@ to decide whether to fix itself, stop retrying, or ask a human.
|
|
|
1529
2106
|
at the verb (exit 2) rather than a member of this union.
|
|
1530
2107
|
- `log-unreadable` (exit 4) / `log-torn-tail` (exit 3) / `log-corrupt` (exit 1):
|
|
1531
2108
|
nothing is authorized from a log that does not verify.
|
|
2109
|
+
- `class-not-agent-requestable` — `propose` named a class the policy has not
|
|
2110
|
+
opened to agents: not an exact key of `classes`, or neither that key nor a
|
|
2111
|
+
declared `<prefix>.*` family sets `agent_may_request: true`. Nothing is
|
|
2112
|
+
registered or appended; the repair is a policy edit and a human attestation.
|
|
2113
|
+
- `payload-too-large` (exit 2) — a `propose` or `start` payload over 262144
|
|
2114
|
+
bytes of UTF-8. Refused before it is parsed; nothing is stored.
|
|
2115
|
+
- `task-is-proposal` — a plain `request` on a task `propose` registered, or a
|
|
2116
|
+
task file claiming an id in the `propose:` namespace. Nothing is appended.
|
|
2117
|
+
- `task-not-proposal` — `start` on a task `propose` did not register.
|
|
2118
|
+
- `key-class-mismatch` (exit 2) — a `propose` key that does not begin with
|
|
2119
|
+
`<class>:` and an identifier.
|
|
2120
|
+
- `contended` — `propose` restarted its bounded number of times on a log that
|
|
2121
|
+
kept moving under it and stopped; nothing more was appended. Read the
|
|
2122
|
+
standing with `wait --timeout 0`, or call again.
|
|
1532
2123
|
- `append-failed` — the append itself failed; the exit code follows the cause.
|
|
1533
2124
|
`head-moved` means the log grew between this command's read and its write, so
|
|
1534
2125
|
nothing was written. Since APRV-236 you see it only after the command has
|
|
@@ -1660,9 +2251,23 @@ measurement.
|
|
|
1660
2251
|
Content binding (amended SPEC.md §6.2, §10.4): run computes the hash of the argv
|
|
1661
2252
|
and cwd it is about to spawn, always, and presents that. The command IS the
|
|
1662
2253
|
action here, and an executor that had to be told what it was running could be
|
|
1663
|
-
told wrong.
|
|
2254
|
+
told wrong.
|
|
2255
|
+
|
|
2256
|
+
Since APRV-401 that computation includes THE SCRIPT THE ARGV NAMES. A known
|
|
2257
|
+
interpreter followed by a path operand, or a path at `argv[0]`, carries that
|
|
2258
|
+
file's size and SHA-256 inside the hashed payload, so a grant over `bash
|
|
2259
|
+
install.sh` binds the script's bytes rather than its path: a script edited
|
|
2260
|
+
between the declaration and the run hashes differently and is refused
|
|
2261
|
+
`payload-mismatch`, with nothing appended and the grant still live for the
|
|
2262
|
+
approved bytes. An argv naming no script hashes exactly as it always did.
|
|
2263
|
+
`approval payload run` prints the same value, which is how a requester obtains
|
|
2264
|
+
the declaration in the first place; `docs/run-payload-binding.md` states the
|
|
2265
|
+
rule and the six things it does not reach.
|
|
2266
|
+
|
|
2267
|
+
`--payload-hash` is consequently a CHECK and never a substitute
|
|
1664
2268
|
(APRV-140): a value differing from the recomputed one is refused
|
|
1665
|
-
`payload-mismatch` before the child exists and before anything is appended
|
|
2269
|
+
`payload-mismatch` before the child exists and before anything is appended, and
|
|
2270
|
+
the refusal names the script it bound so the repair is visible. An
|
|
1666
2271
|
action whose payload is content rather than an argv (an email body, a record
|
|
1667
2272
|
write, a message and its recipients) is executed through the adapter contract of
|
|
1668
2273
|
§10.4, `approval adapter email`, which hashes those bytes itself; a token for
|
|
@@ -1798,7 +2403,7 @@ refusal {"ok":false,"error":{"code":"...","message":"...","detail"?:"...",
|
|
|
1798
2403
|
## sandbox
|
|
1799
2404
|
|
|
1800
2405
|
```
|
|
1801
|
-
approval sandbox [--allow-loopback] [--log <path>] -- <cmd> [args…]
|
|
2406
|
+
approval sandbox [--allow-loopback] [--read-jail] [--log <path>] -- <cmd> [args…]
|
|
1802
2407
|
```
|
|
1803
2408
|
|
|
1804
2409
|
Runs a command with outbound network denied by the operating system. It appends
|
|
@@ -1836,6 +2441,15 @@ is a sandbox somebody turns off.
|
|
|
1836
2441
|
server. It is a real widening: a port is a port, and anything listening on one is
|
|
1837
2442
|
reachable from inside.
|
|
1838
2443
|
|
|
2444
|
+
**`--read-jail`** (APRV-347) goes the other way and makes the room smaller: file
|
|
2445
|
+
reads become deny-default, with the gate root, the scratch roots and a fixed
|
|
2446
|
+
runtime set opened by `subpath`. It is already on, without the flag, whenever
|
|
2447
|
+
the policy declares a `read_scope` block, which is the spelling an operator
|
|
2448
|
+
commits and attests; the flag is for trying it on one command first. There is no
|
|
2449
|
+
flag that turns the jail OFF where a policy asked for it, because a flag an agent
|
|
2450
|
+
can pass must only ever narrow what it can do. See
|
|
2451
|
+
[docs/sandboxed-exec.md](./sandboxed-exec.md) for the profile and its limits.
|
|
2452
|
+
|
|
1839
2453
|
**Exit 127** means the command was NOT run: this machine has no working sandbox
|
|
1840
2454
|
primitive, or the command is not on `PATH`. Unlike `approval run`, this verb
|
|
1841
2455
|
fails closed on both: it makes one promise and has nothing else to offer.
|
|
@@ -1852,6 +2466,33 @@ costs, and the carve-outs.
|
|
|
1852
2466
|
|
|
1853
2467
|
## wait
|
|
1854
2468
|
|
|
2469
|
+
`--timeout 0` (also `0s`, `0ms`) reads the current state once and never sleeps
|
|
2470
|
+
(APRV-445): an undecided request answers `timeout`, exit 6, at once, and a
|
|
2471
|
+
decided one answers as it always would. Use it to poll through `approval serve`,
|
|
2472
|
+
which runs every verb call whole under the store lock that hook calls take for
|
|
2473
|
+
their appends (APRV-427), so a wait that sleeps there holds the tenant's hook
|
|
2474
|
+
traffic for as long as it sleeps (APRV-441). The
|
|
2475
|
+
duration grammar has no zero anywhere else. `--withdraw-on-timeout` is refused
|
|
2476
|
+
beside it (exit 2): a status read that withdrew would retract every question it
|
|
2477
|
+
looked at.
|
|
2478
|
+
|
|
2479
|
+
**What a waiter is told is what the request still authorizes** (APRV-445). A
|
|
2480
|
+
grant whose request window has lapsed reads `expired` (exit 3), not `granted`. A
|
|
2481
|
+
pending request pinned to a policy hash that is no longer the attested one (any
|
|
2482
|
+
task), a HARNESS grant so pinned (`execution: "harness"`, every proposal
|
|
2483
|
+
included: its spend refuses `policy-drift`), or a request the runtime withdrew
|
|
2484
|
+
for `policy-drift`, reads `void`, **exit 7**: the answer can never be used, and
|
|
2485
|
+
the repair is to ask again (for a proposal, the same `propose` call re-files). A
|
|
2486
|
+
token grant is NOT void after a re-attest: `approval run` spends it without
|
|
2487
|
+
comparing policy hashes, so `wait && run` keeps working and `wait` reads
|
|
2488
|
+
`granted`. Precedence: rejected > withdrawn >
|
|
2489
|
+
void > expired > granted > executed > nothing-to-wait-for. An action that ran
|
|
2490
|
+
on the policy's own authority after its request ended (a `start` after a
|
|
2491
|
+
withdrawal and a re-tiering) reads `executed`, status `executed`, exit 0. A
|
|
2492
|
+
registered task with no requests answers `nothing-to-wait-for` at exit 0, and an
|
|
2493
|
+
unknown task id is refused `not-registered` at exit 1 (APRV-428), so poll the
|
|
2494
|
+
task id `propose` returned.
|
|
2495
|
+
|
|
1855
2496
|
Polls the log and writes nothing — not even the `approval.expired` event it may
|
|
1856
2497
|
derive: expiry is judged lazily from the request's own timestamp, and
|
|
1857
2498
|
materialising it is `approval expire`'s job, not a reader's. The one exception is
|
|
@@ -1860,9 +2501,27 @@ materialising it is `approval expire`'s job, not a reader's. The one exception i
|
|
|
1860
2501
|
For `approval wait` the exit code IS the decision (SPEC.md §10.1). The
|
|
1861
2502
|
overloading of 1 (integrity / rejected) and 3 (torn tail / expired) is
|
|
1862
2503
|
deliberate: wait appends nothing and cannot fail a chain verification of its
|
|
1863
|
-
own, and `--json` names the outcome exactly (`granted |
|
|
1864
|
-
expired | timeout`) for callers that need more than a
|
|
1865
|
-
review.
|
|
2504
|
+
own, and `--json` names the outcome exactly (`granted | executed | rejected |
|
|
2505
|
+
withdrawn | void | expired | nothing-to-wait-for | timeout`) for callers that need more than a
|
|
2506
|
+
number. Flagged for human review.
|
|
2507
|
+
|
|
2508
|
+
**Nothing to wait for, and nothing registered** (APRV-428). Exit 0 has always
|
|
2509
|
+
answered at once for a task with no requests, because only the manual path
|
|
2510
|
+
produces requests and the supervised path tells an agent its `proceed: true`
|
|
2511
|
+
needs no wait. Its `status` is `nothing-to-wait-for`, never `granted`: a
|
|
2512
|
+
registered task with no `approval.requested` at all (no action declared, none
|
|
2513
|
+
reached the manual path, or none requested yet), or one whose every granted
|
|
2514
|
+
action has already started executing, so every grant is spent. `granted` means
|
|
2515
|
+
at least one granted action has not executed yet, and only that. The per-action
|
|
2516
|
+
rows are unchanged; an executed grant still reads `granted` there.
|
|
2517
|
+
|
|
2518
|
+
A task with no `task.registered` record is refused before anything is derived:
|
|
2519
|
+
`not-registered`, the gate union's code for exactly that condition (SPEC.md
|
|
2520
|
+
§11.2), at exit 1, the code `approval request` exits for the same refusal.
|
|
2521
|
+
Before APRV-428 such a wait answered `{"ok":true,"status":"granted","actions":[]}`,
|
|
2522
|
+
so a typo read as a grant. Through `approval serve` the verb's refusal passes
|
|
2523
|
+
through unchanged: the body's `stderr` carries the same object and `exit_code`
|
|
2524
|
+
is 1.
|
|
1866
2525
|
|
|
1867
2526
|
`withdrawn` (APRV-106) reuses exit **1** rather than claiming a new number. The
|
|
1868
2527
|
exit table in `src/cli/exit-codes.ts` is frozen public API and agents already
|
|
@@ -1890,10 +2549,11 @@ itself fails is reported on stderr and leaves the request live; the exit code is
|
|
|
1890
2549
|
|
|
1891
2550
|
```
|
|
1892
2551
|
decided {"ok":true,"task":"task-042",
|
|
1893
|
-
"status":"granted"|"rejected"|"withdrawn"|"expired",
|
|
2552
|
+
"status":"granted"|"rejected"|"withdrawn"|"expired"|"nothing-to-wait-for",
|
|
1894
2553
|
"actions":[{"action_key":"...","state":"granted","seq":4}]}
|
|
1895
2554
|
timeout {"ok":false,"task":"task-042","status":"timeout",
|
|
1896
2555
|
"actions":[{"action_key":"...","state":"requested","seq":3}]}
|
|
2556
|
+
refused {"ok":false,"error":{"code":"not-registered","message":"..."}} (stderr, exit 1)
|
|
1897
2557
|
```
|
|
1898
2558
|
|
|
1899
2559
|
**Sealed token delivery** (APRV-105). Under policy `defaults.token_delivery:
|
|
@@ -1925,8 +2585,8 @@ so the paste path is preserved rather than replaced.
|
|
|
1925
2585
|
passed, listing the keys actually retracted; the default shape is unchanged.
|
|
1926
2586
|
|
|
1927
2587
|
`state` is the per-action derived state; `status` is the whole task's outcome,
|
|
1928
|
-
with rejected/revoked outranking withdrawn, withdrawn outranking expired,
|
|
1929
|
-
expired outranking granted. `--timeout` and `--interval` take the SPEC.md §5.2
|
|
2588
|
+
with rejected/revoked outranking withdrawn, withdrawn outranking expired,
|
|
2589
|
+
expired outranking granted, and granted outranking nothing-to-wait-for. `--timeout` and `--interval` take the SPEC.md §5.2
|
|
1930
2590
|
duration grammar, `<positive integer><ms|s|m|h|d|w>`.
|
|
1931
2591
|
|
|
1932
2592
|
## queue
|
|
@@ -1950,11 +2610,16 @@ requested, and how much of the TTL is left.
|
|
|
1950
2610
|
{"ok":true,"pending":[{"action_key":"task-042:chaser","task":"task-042",
|
|
1951
2611
|
"class":"communicate.email.external","est_cost_usd":"0.02",
|
|
1952
2612
|
"requested_ts":"2026-08-06T10:00:00.000Z","seq":3,
|
|
1953
|
-
"ttl_remaining_ms":3599000}]}
|
|
2613
|
+
"ttl_remaining_ms":3599000,"ttl_ms":3600000}]}
|
|
1954
2614
|
```
|
|
1955
2615
|
|
|
1956
|
-
`pending` is `[]` for an empty inbox.
|
|
1957
|
-
|
|
2616
|
+
`pending` is `[]` for an empty inbox. Both TTL fields are measured against the
|
|
2617
|
+
window the gate judges the request by (APRV-423): the policy's
|
|
2618
|
+
`defaults.approval_ttl`, narrowed by the harness ceiling the requesting hook
|
|
2619
|
+
declared, if any. `ttl_ms` is that whole window and `ttl_remaining_ms` is what is
|
|
2620
|
+
left of it; both are null only when nothing bounds the request (no TTL and no
|
|
2621
|
+
cap means no lapse). A hook-opened request under a policy with no TTL therefore
|
|
2622
|
+
shows its real remaining window here rather than `null`.
|
|
1958
2623
|
|
|
1959
2624
|
## gate
|
|
1960
2625
|
|
|
@@ -2095,9 +2760,47 @@ informational: it moves neither the health verdict nor the exit code. An empty
|
|
|
2095
2760
|
store is the normal state of a repo that has never made a request carrying
|
|
2096
2761
|
`--payload`. (`approval doctor` is where an unwritable store is a failure.)
|
|
2097
2762
|
|
|
2763
|
+
**attestation** names the policy in force: its state, the seq of the attestation
|
|
2764
|
+
the log last recorded, and `attested_by`, the actor of that record (APRV-449).
|
|
2765
|
+
The identity is read from the verified record and never from the file, so a
|
|
2766
|
+
hosted tenant can see whether their starting policy is the operator's
|
|
2767
|
+
bootstrap or their own later act (docs/hosted-provisioning.md). It is the
|
|
2768
|
+
identity the attesting process declared, which the runtime does not
|
|
2769
|
+
authenticate. `attested_by` is `null` when nothing was attested; on
|
|
2770
|
+
`hash-mismatch` it names who attested the bytes the live file no longer
|
|
2771
|
+
matches. The text row reads `attested (seq 1, by human:carter)`, and `approval
|
|
2772
|
+
doctor`'s attestation row carries the same name.
|
|
2773
|
+
|
|
2098
2774
|
**anomalies** are informational for the same reason `approval log verify`
|
|
2099
2775
|
declined to refuse on them: status does not get to overrule that.
|
|
2100
2776
|
|
|
2777
|
+
**refusals** is the two refusal families, counted, with the newest five of each
|
|
2778
|
+
(APRV-376). `decision` counts `audit.decision_refused`: a human decided and the
|
|
2779
|
+
gate would not take it, which is APRV-235's record. `gesture` counts
|
|
2780
|
+
`audit.gesture_refused`: a human made a gesture that is not a decision (a
|
|
2781
|
+
checkpoint signature, a review, a review note) and the surface refused it before
|
|
2782
|
+
any verb ran, which is APRV-355's. Until this field existed neither was reported
|
|
2783
|
+
anywhere, and an operator could reach them only through `approval log tail` and
|
|
2784
|
+
`approval log export`. The question the field answers is small enough that the
|
|
2785
|
+
chain was a poor place to answer it: told that taps from an account they did not
|
|
2786
|
+
map are being refused, an operator wants to know how many and from which account.
|
|
2787
|
+
|
|
2788
|
+
Each entry carries the `seq`, the surface's own refusal code verbatim, and the
|
|
2789
|
+
observed `sender` when the record carries one, in the form the record carries it
|
|
2790
|
+
(`hashed: true` marks APRV-370's keyed digest rather than a raw account id). The
|
|
2791
|
+
count is every record of that family in the log; the listing is the newest five,
|
|
2792
|
+
newest first, because a row that grew with the log would push the rest of the
|
|
2793
|
+
report off a terminal for a fact that decides nothing.
|
|
2794
|
+
|
|
2795
|
+
It is informational, exactly as `coverage` and `anomalies` are: it moves neither
|
|
2796
|
+
`healthy` nor the exit code. A refusal is the gate having worked, and a report
|
|
2797
|
+
that went red because the gate turned away a stranger's tap would teach an
|
|
2798
|
+
operator to stop reading it. It reads only verified records (SPEC.md §11.1
|
|
2799
|
+
invariant 1), which is also why a log that does not verify reports no refusals
|
|
2800
|
+
rather than reporting some it cannot stand behind. A family with no records is
|
|
2801
|
+
absent rather than zero, and the whole field is absent when both families are, so
|
|
2802
|
+
a repository where nothing has been refused emits the object it always emitted.
|
|
2803
|
+
|
|
2101
2804
|
**coverage** is one line of `approval coverage` (APRV-245): the commits git
|
|
2102
2805
|
recorded on THIS branch, counted against the verified log. The range is the
|
|
2103
2806
|
merge base with `origin/main` to `HEAD`, so what it measures is what this branch
|
|
@@ -2118,7 +2821,7 @@ does not know the flag exists.
|
|
|
2118
2821
|
**What it reports**, in one object:
|
|
2119
2822
|
|
|
2120
2823
|
- `attestation` — attested | hash-mismatch | not-attested | unreadable, with the
|
|
2121
|
-
seq of the governing `policy.updated` record.
|
|
2824
|
+
seq of the governing `policy.updated` record and `attested_by`, its actor.
|
|
2122
2825
|
- `verification` — the latest chain verdict, and the record count (null when
|
|
2123
2826
|
corrupt).
|
|
2124
2827
|
- `dangling` — executions the runtime meant to watch and never closed. Not
|
|
@@ -2141,12 +2844,25 @@ does not know the flag exists.
|
|
|
2141
2844
|
how many the log records as pruned, and how many are unbound. Informational.
|
|
2142
2845
|
- `anomalies` — additive and present only when non-empty: gate-typed events whose
|
|
2143
2846
|
`ts` steps backwards by more than 2s. Informational.
|
|
2847
|
+
- `refusals` — additive and present only when the log carries a refused decision
|
|
2848
|
+
or a refused gesture: `{count, recent}` per family, each recent entry naming the
|
|
2849
|
+
`seq`, the refusal `code` and the observed `sender` where there was one.
|
|
2850
|
+
Informational.
|
|
2851
|
+
- `daemon` — `{id, source, allowed}`, always present (APRV-383): the daemon
|
|
2852
|
+
instance id a daemon run against THIS log would write onto every record it
|
|
2853
|
+
appends, whether it came from `APPROVAL_DAEMON_ID` (`source: "environment"`) or
|
|
2854
|
+
was derived from the instance (`source: "derived"`), and the `daemons` allowlist
|
|
2855
|
+
the attested policy puts in force. `id: null` means `APPROVAL_DAEMON_ID` holds
|
|
2856
|
+
something that is not a usable id, so a daemon started here would be refused
|
|
2857
|
+
every append. `allowed: null` means no restriction, which is not the same fact as
|
|
2858
|
+
an empty list. Informational: nothing here moves `healthy` or the exit code, and
|
|
2859
|
+
being listed grants a daemon nothing beyond the ability to write.
|
|
2144
2860
|
|
|
2145
2861
|
**`--json`** (one object on stdout):
|
|
2146
2862
|
|
|
2147
2863
|
```
|
|
2148
2864
|
{"ok":true,"healthy":false,
|
|
2149
|
-
"attestation":{"state":"attested","seq":1},
|
|
2865
|
+
"attestation":{"state":"attested","seq":1,"attested_by":"human:carter"},
|
|
2150
2866
|
"verification":{"status":"clean","records":6},
|
|
2151
2867
|
"dangling":[{"action_key":"...","task":"...","ts":"...","seq":5}],
|
|
2152
2868
|
"budgets":[{"limit":"global.daily_usd","scope":"global",
|
|
@@ -2158,12 +2874,16 @@ does not know the flag exists.
|
|
|
2158
2874
|
"reconciliation":[{"seq":18,"ts":"...","action_key":"...","task":"...",
|
|
2159
2875
|
"class":"records.write","obligation":"gated-revert","review_seq":17}],
|
|
2160
2876
|
"payload_store":{"present":true,"files":2,"pruned":0,"orphans":0,
|
|
2161
|
-
"note":"..."}
|
|
2877
|
+
"note":"..."},
|
|
2878
|
+
"refusals":{"decision":{"count":2,"recent":[{"seq":21,"code":"policy-drift"}]},
|
|
2879
|
+
"gesture":{"count":1,"recent":[{"seq":24,"code":"sender-unmapped",
|
|
2880
|
+
"sender":{"channel":"telegram","id":"5551234567"}}]}},
|
|
2881
|
+
"daemon":{"id":"daemon-3f2a9c11","source":"derived","allowed":null}}
|
|
2162
2882
|
```
|
|
2163
2883
|
|
|
2164
|
-
`ok` is true whenever status ran; `healthy` is the verdict. `attestation.seq`
|
|
2165
|
-
null for not-attested and unreadable. `note`
|
|
2166
|
-
verbatim.
|
|
2884
|
+
`ok` is true whenever status ran; `healthy` is the verdict. `attestation.seq` and
|
|
2885
|
+
`attestation.attested_by` are null for not-attested and unreadable. `note`
|
|
2886
|
+
carries the unrebuildable warning verbatim.
|
|
2167
2887
|
|
|
2168
2888
|
## coverage
|
|
2169
2889
|
|
|
@@ -2355,7 +3075,8 @@ The checks, at length:
|
|
|
2355
3075
|
`--as`: this reports what the next command will find.
|
|
2356
3076
|
- **attestation** — anything other than "the live bytes match" makes every gated
|
|
2357
3077
|
operation refuse, and that refusal reads like "the policy says no" when it
|
|
2358
|
-
means "the policy is unverified".
|
|
3078
|
+
means "the policy is unverified". A pass names the seq and the attester read
|
|
3079
|
+
from that record (`attested at seq 1 by human:carter`, APRV-449).
|
|
2359
3080
|
- **log** — a torn tail and a corrupt log are both failures here; neither is
|
|
2360
3081
|
repaired, and doctor never truncates a torn line.
|
|
2361
3082
|
- **telegram** — `getMe` against `--api-base`, when both variables are set;
|
|
@@ -2372,7 +3093,17 @@ The checks, at length:
|
|
|
2372
3093
|
`--payload` request creates it); an existing directory this process cannot
|
|
2373
3094
|
write FAILS, because a request already accepted by the gate would refuse
|
|
2374
3095
|
`payload-store-failed` mid ceremony. The probe creates and removes one empty
|
|
2375
|
-
file
|
|
3096
|
+
file. Since APRV-457 the row also reads back every payload a verified record
|
|
3097
|
+
binds (pruned ones excepted) and FAILS on two distinct readings. An empty or
|
|
3098
|
+
all-NUL file, or a missing file whose record proves the store held it
|
|
3099
|
+
(`policy.proposed`, `policy.updated`, or `approval.requested` carrying
|
|
3100
|
+
`display_hash`), is the crash-before-writeback signature: nothing was
|
|
3101
|
+
tampered and the bytes are gone from this store; the fix moves a torn file
|
|
3102
|
+
aside and restores a copy that `approval payload hash` confirms. A file
|
|
3103
|
+
holding any non-NUL byte that does not verify is NOT the crash signature
|
|
3104
|
+
(a partial writeback leaving a valid prefix and a NUL tail lands here too, the stricter reading) and reads as tampering or
|
|
3105
|
+
corruption; the fix moves it aside as `.suspect`. Doctor moves, restores and
|
|
3106
|
+
deletes nothing.
|
|
2376
3107
|
- **audit-sampling** — sampling fails open by design (SPEC.md §5.2), so an
|
|
2377
3108
|
unconfigured sampler silently audits nothing; this states the disabled reason
|
|
2378
3109
|
out loud. A sampler nobody configured skips; a half-configured one fails,
|
|
@@ -2447,17 +3178,25 @@ The checks, at length:
|
|
|
2447
3178
|
of the gate, so the repair is a line for a human to commit, printed by
|
|
2448
3179
|
`approval instructions hook`.
|
|
2449
3180
|
- **harness-hook-wiring** — whether THIS checkout's `.claude/settings.json`
|
|
2450
|
-
registers `approval hook` for PreToolUse over every gated tool
|
|
2451
|
-
|
|
3181
|
+
registers `approval hook claude-code` for PreToolUse over every gated tool.
|
|
3182
|
+
The roster is read from the adapter that answers the calls (Bash, Edit, Write,
|
|
3183
|
+
MultiEdit, NotebookEdit since APRV-408, where it was a hand list that had
|
|
3184
|
+
drifted behind them). SKIP, named, when the file is absent, unreadable, or
|
|
2452
3185
|
registers the hook for only some tools: a spawned-agent worktree without the
|
|
2453
3186
|
entry is how the APRV-151 bypasses happened, and a session started elsewhere
|
|
2454
3187
|
may still be hooked, so this row can only speak for the checkout it runs in.
|
|
2455
3188
|
PASS means the entry is present on disk, and says so plainly: it is not proof
|
|
2456
|
-
the running session loaded it. The
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
3189
|
+
the running session loaded it. The one FAIL is a handler whose `--dir` names a
|
|
3190
|
+
checkout other than this one, which answers from another policy, another log
|
|
3191
|
+
and another open window. Two informational lines never decide anything: the
|
|
3192
|
+
adapter read tools the matcher leaves out (Read, Glob, Grep) are named as the
|
|
3193
|
+
documented default they are, and a matcher tool the adapter handles as none of
|
|
3194
|
+
its own is named as the unclassified `allow` it will actually receive. The
|
|
3195
|
+
check that trusts no session is the CI-side grant cross-check
|
|
3196
|
+
(`scripts/protected-path-guard.mjs`) over the committed log, which since
|
|
3197
|
+
APRV-202 requires every added and removed line of a protected path to trace to
|
|
3198
|
+
the bound material of a grant, rather than only that the path was granted at
|
|
3199
|
+
some point in the week.
|
|
2461
3200
|
- **keychain-scope** — whose keystore items this instance's `.approval/env`
|
|
2462
3201
|
names, answered from the NAMES alone so that it too can never block on an
|
|
2463
3202
|
unlock dialog. FAIL for an item whose eight-hex scope suffix belongs to
|
|
@@ -2530,6 +3269,23 @@ The checks, at length:
|
|
|
2530
3269
|
log sync` for a diverged log, `approval up` otherwise — never a `git` command:
|
|
2531
3270
|
a repair line telling an operator to reset a branch would be doctor making the
|
|
2532
3271
|
decision this project keeps human.
|
|
3272
|
+
- **attested-policy-on-main** — whether the policy the log vouches for is the
|
|
3273
|
+
policy `origin/<branch>` carries (APRV-342). `attestation` above asks whether
|
|
3274
|
+
the LOCAL file is attested; between a `policy amend` and its pull request
|
|
3275
|
+
merging that answer is yes while a fresh checkout of main carries the old
|
|
3276
|
+
policy with no attestation covering it, so every gate operation there refuses
|
|
3277
|
+
`policy-not-attested`. Nothing said so until this row: on 2026-09-16 `approval
|
|
3278
|
+
up` ran in exactly that state and reported "already at the remote tip". PASS
|
|
3279
|
+
when the attested hash equals the SHA-256 of `APPROVAL.md` at the remote tip.
|
|
3280
|
+
FAIL with `attested at seq N, not yet on main`, naming
|
|
3281
|
+
`policy-amend-<seq>` when this checkout has already seen that branch on the
|
|
3282
|
+
remote, and fixing with `approval policy amend --pr` — which opens the pull
|
|
3283
|
+
request or updates the open one, so the same command is right either way.
|
|
3284
|
+
SKIP with no attestation, outside a git checkout, and where there is no
|
|
3285
|
+
remote-tracking ref. **It fetches nothing**, for the reason
|
|
3286
|
+
`main-behind-origin` fetches nothing, and it looks for the amend branch among
|
|
3287
|
+
the remote-tracking refs rather than asking GitHub. `approval up`'s preflight
|
|
3288
|
+
prints the same sentence on stderr and never refuses on it.
|
|
2533
3289
|
- **harness-version-unverified** — whether the harness binary hosting the
|
|
2534
3290
|
PreToolUse hook changed since the log last saw a record from it (APRV-227).
|
|
2535
3291
|
The only row that asks anything about a program outside this repository, and
|
|
@@ -2544,7 +3300,14 @@ The checks, at length:
|
|
|
2544
3300
|
the `fix` is the promptless self-test in `docs/claude-code-hook.md`, one
|
|
2545
3301
|
supervised-class tool call, after which the row is green. PASS says only that
|
|
2546
3302
|
the versions match — the field is self-reported (SPEC.md §11.1 invariant 4)
|
|
2547
|
-
and reduces nothing anywhere, so a match is not proof the hook fired.
|
|
3303
|
+
and reduces nothing anywhere, so a match is not proof the hook fired. Since
|
|
3304
|
+
APRV-415 the row carries a second finding for one harness: a Hermes below the
|
|
3305
|
+
**fail-closed version floor** (a build at or after `main` `118984d7` of
|
|
3306
|
+
2026-09-20) FAILS, because `v0.21.3` ignores `fail_closed` silently and every
|
|
3307
|
+
broken hook on it proceeds. That half is read from `hermes --version` rather than
|
|
3308
|
+
from a record — Hermes prints its build stamp behind a non-ASCII separator the
|
|
3309
|
+
write boundary refuses, so no record can carry it — and it compares the build
|
|
3310
|
+
date and the upstream commit, because the two builds report the same semver.
|
|
2548
3311
|
- **live-draw** — whether a daemon is answering `supervised-live` draws for this
|
|
2549
3312
|
log (APRV-208). SKIP when the policy declares no live class: no draw is ever
|
|
2550
3313
|
made, and a missing socket is nothing. It FAILS in three shapes, all of them
|
|
@@ -2573,7 +3336,14 @@ The checks, at length:
|
|
|
2573
3336
|
words SPEC.md §5.3 fixes: a file with no block is an operator who has declared
|
|
2574
3337
|
no values, which is a state and not a fault. The only FAIL is a block that is
|
|
2575
3338
|
present and unreadable, and its fix names the code rather than proposing a
|
|
2576
|
-
repair, because what the block should say is the human's to write.
|
|
3339
|
+
repair, because what the block should say is the human's to write. The one
|
|
3340
|
+
exception is the block APRV-336 replaced (`version: 1` with a `wants` list):
|
|
3341
|
+
that is a correct document of the wrong vintage rather than a broken one, so
|
|
3342
|
+
the row's detail carries the loader's migration message and its fix names the
|
|
3343
|
+
three edits (fold `wants:` into `like:`, rename `responds:` to
|
|
3344
|
+
`communication:`, quote `version: "0.2"`) plus the re-attestation that the
|
|
3345
|
+
whole-file digest requires. The pass detail lists what the block declares,
|
|
3346
|
+
which are `love`, `like`, `dislike` and `communication` and nothing else.
|
|
2577
3347
|
- **checkpoint** — how this log stands against its own human-signed checkpoints
|
|
2578
3348
|
(APRV-257), running the same check as `approval log verify --checkpoints`, so
|
|
2579
3349
|
two implementations of "does this log's own signature contradict it" cannot
|
|
@@ -2622,6 +3392,73 @@ The checks, at length:
|
|
|
2622
3392
|
doctor does not interpret or merge the TOML hook tables. Malformed JSON
|
|
2623
3393
|
FAILS; a different valid Codex hook profile SKIPS as undetermined rather than
|
|
2624
3394
|
being called broken.
|
|
3395
|
+
- **autonomy-alias** — which rules of this policy still write the deprecated
|
|
3396
|
+
bare `supervised` (APRV-335). The spelling parses as `supervised-retro` and
|
|
3397
|
+
every gate enforces it as one, so the row is never a FAIL and never moves the
|
|
3398
|
+
exit code: a red line over a spelling would be doctor going red over prose.
|
|
3399
|
+
The two PASS shapes are the whole row. Where some rule uses it, the detail
|
|
3400
|
+
names each one, in the order the loader's own notes name them, and the `fix`
|
|
3401
|
+
is `approval policy amend`, which owns the edit and the re-attestation that
|
|
3402
|
+
edit costs. Where none does, the detail says so plainly, which is the answer
|
|
3403
|
+
an operator wants before a future schema version drops the alias. A policy
|
|
3404
|
+
that did not load is a SKIP: it names no level at all, and its own failure is
|
|
3405
|
+
reported by the attestation row and by `approval policy check`.
|
|
3406
|
+
- **pending-sign-off** — which protected files carry SPEC.md's
|
|
3407
|
+
`(Amended APRV-n, pending sign-off.)` marker with no `gate.path.signed_off`
|
|
3408
|
+
record over their current bytes (APRV-338). Informational and never a FAIL,
|
|
3409
|
+
for the two reasons `gate-organs` is: nothing on this machine is broken by
|
|
3410
|
+
unratified prose, and the enforcement that does bite is the CI-side
|
|
3411
|
+
protected-path guard. What the row buys is that the debt is visible at the
|
|
3412
|
+
terminal rather than discovered when a pull request fails. It reads the
|
|
3413
|
+
enumerated directories (`.`, `.github/workflows/`, `docs/`, `design/`) plus
|
|
3414
|
+
every path the policy's `protected_paths` names, keeps only what classifies
|
|
3415
|
+
`policy.edit` or a `policy.edit.*` sub-class, and reports a file whose current
|
|
3416
|
+
bytes ARE signed off as ratified rather than pending. The `fix` is
|
|
3417
|
+
`approval policy attest --path <p> --as human:<id>`, to be run after reading
|
|
3418
|
+
the file; a marker whose text was later granted through the gate should lose
|
|
3419
|
+
the suffix instead.
|
|
3420
|
+
- **sender-mapping** — which approvers a channel whose senders the policy maps
|
|
3421
|
+
can still recognize (APRV-324). A SKIP where no approver declares a `senders`
|
|
3422
|
+
block, which is every installation before the key existed: decisions are
|
|
3423
|
+
recorded against the identity the deciding process was launched with, no
|
|
3424
|
+
channel enforces a mapping, and nothing is wrong. A FAIL where the policy
|
|
3425
|
+
maps senders for a channel and lists an approver on that channel with no id
|
|
3426
|
+
of their own there — that person's next tap is refused `sender-unmapped` and
|
|
3427
|
+
nothing is recorded, so the file says they may decide on a surface where they
|
|
3428
|
+
cannot. A PASS where every approver a mapped channel reaches carries an id
|
|
3429
|
+
there. The `fix` is `approval policy amend`, which owns the edit and the
|
|
3430
|
+
re-attestation it costs. The row reads the policy and nothing else: no log,
|
|
3431
|
+
no network, no credential, and it prints nobody's account id — the mapping is
|
|
3432
|
+
in a file the operator can open, and a health row is read over shoulders.
|
|
3433
|
+
- **codex-auto-reviewer** — has anything other than this gate answered a
|
|
3434
|
+
question this gate exists to ask (APRV-378)? It reads
|
|
3435
|
+
`audit.question_preempted` and nothing else. A FAIL where one landed in the
|
|
3436
|
+
last 24 hours, naming the source, the question in the other party's terms and
|
|
3437
|
+
their verdict; a PASS where none did, mentioning any older ones; a SKIP where
|
|
3438
|
+
the chain did not verify. A PASS says the log holds no such record and NOT
|
|
3439
|
+
that a harness auto-reviewer is off: whether it runs is configuration this
|
|
3440
|
+
runtime cannot read, and a row written against a guessed configuration key
|
|
3441
|
+
would find nothing and report green, which is the worst direction a health
|
|
3442
|
+
check can fail in. The `fix` points at the harness's own configuration,
|
|
3443
|
+
because nothing here can turn another system's reviewer off.
|
|
3444
|
+
- **daemon-identity** — which daemon id a daemon run against this log would
|
|
3445
|
+
write onto every record it appends, and whether the attested policy admits it
|
|
3446
|
+
(APRV-383). The id is `APPROVAL_DAEMON_ID` from this shell where it is set and
|
|
3447
|
+
otherwise `daemon-` plus the instance id the `keychain-scope` row names, so the
|
|
3448
|
+
two rows describe one gate. A FAIL where `APPROVAL_DAEMON_ID` is set to
|
|
3449
|
+
something that is not an id: a daemon started from this shell would be refused
|
|
3450
|
+
`daemon-id-invalid` on every append, so it would read, render and report while
|
|
3451
|
+
writing nothing, and the `fix` is that one variable. A loud SKIP where the
|
|
3452
|
+
attested policy's `daemons` list does not admit the id, naming what the list
|
|
3453
|
+
does admit — expected where the daemon for this log runs on another machine,
|
|
3454
|
+
which is the hosted case the key exists for, and wrong if that daemon is meant
|
|
3455
|
+
to be this one. A PASS where the list admits it, and where there is no list at
|
|
3456
|
+
all, which is every installation that never adopts the key. Being listed grants
|
|
3457
|
+
nothing beyond the ability to write: no verdict, budget, floor, draw or token
|
|
3458
|
+
reads the id. The row reads the policy and one variable's SHAPE, prints no
|
|
3459
|
+
value on any path, and asks no running daemon anything.
|
|
3460
|
+
|
|
3461
|
+
|
|
2625
3462
|
|
|
2626
3463
|
**`--json`** (one object on stdout):
|
|
2627
3464
|
|
|
@@ -2650,8 +3487,9 @@ reaches this backlog. A `supervised-live` class puts a declared `live_rate`
|
|
|
2650
3487
|
fraction of its actions through the human gate BEFORE they run; those are
|
|
2651
3488
|
ordinary manual requests with ordinary grants and tokens, a person has already
|
|
2652
3489
|
answered them, and they are not drawn a second time for retrospective review. A
|
|
2653
|
-
`supervised-retro` class — and the bare `supervised`, which is now
|
|
2654
|
-
|
|
3490
|
+
`supervised-retro` class — and the bare `supervised`, which is now a deprecated
|
|
3491
|
+
alias for it, still parsed and removed in a future schema version (APRV-335) —
|
|
3492
|
+
is what this page is about. `approval policy check` names the mode in its
|
|
2655
3493
|
final line and in `outcome.supervision`.
|
|
2656
3494
|
|
|
2657
3495
|
Supervised actions execute immediately and are audited afterwards. The daemon
|
|
@@ -3291,6 +4129,16 @@ This starts the standalone Telegram component. For normal operation, use
|
|
|
3291
4129
|
`up` or another listener polling the same bot, even for a different policy
|
|
3292
4130
|
project: competing `getUpdates` calls produce Telegram HTTP 409.
|
|
3293
4131
|
|
|
4132
|
+
A bot whose updates go to a webhook cannot be long-polled at all, so this verb
|
|
4133
|
+
and `approval up` refuse `webhook-registered` in the same preflight that asks
|
|
4134
|
+
`getMe`, naming the origin that holds it. That preflight also takes this gate's
|
|
4135
|
+
transport lease (`.approval/daemon/telegram-transport.lock`), so a second
|
|
4136
|
+
listener or a webhook runner started in the same project refuses
|
|
4137
|
+
`telegram-poller-running` naming the pid that holds it, rather than both
|
|
4138
|
+
processes putting every request on the phone under their own nonce. See
|
|
4139
|
+
[`channel telegram webhook`](#channel-telegram-webhook), which is the other
|
|
4140
|
+
transport and removes both its registration and its lease when it exits.
|
|
4141
|
+
|
|
3294
4142
|
**Delivery is per cycle, not only at startup.** Before every `getUpdates` the
|
|
3295
4143
|
listener re-derives the pending queue from the verified log and sends whatever
|
|
3296
4144
|
it has not already sent, so a request appended while this listener is running
|
|
@@ -3501,6 +4349,64 @@ leaves the sample exactly where it was: open, listed by `approval audit list`,
|
|
|
3501
4349
|
and reviewable with `approval audit review <seq>`. Nothing in this channel can
|
|
3502
4350
|
empty the backlog, which is the property a sampled-audit backlog exists to have.
|
|
3503
4351
|
|
|
4352
|
+
**A refused gesture leaves a record (APRV-355).** When the policy maps senders,
|
|
4353
|
+
a checkpoint signature or a review from an account the attested policy names
|
|
4354
|
+
nobody for is refused before any verb runs, and since this change the attempt is
|
|
4355
|
+
recorded: one **`audit.gesture_refused`**, with a `system:gate` actor, the
|
|
4356
|
+
channel, and a payload carrying `gesture`
|
|
4357
|
+
(`checkpoint-signature`, `review`, `review-note`), `code`
|
|
4358
|
+
(`sender-unmapped`, `sender-ambiguous`, `sender-key-unavailable`,
|
|
4359
|
+
`policy-not-attested`), the refusal
|
|
4360
|
+
`message`, the observed `sender`, and `actor` only where the runtime could name
|
|
4361
|
+
a person. Under a keyed mapping (APRV-370) the `sender.id` is the digest rather
|
|
4362
|
+
than the account, and `sender.hashed` is `true` beside it.
|
|
4363
|
+
|
|
4364
|
+
```
|
|
4365
|
+
{"event":"audit.gesture_refused","actor":"system:gate","channel":"telegram",
|
|
4366
|
+
"payload":{"gesture":"checkpoint-signature","code":"sender-unmapped",
|
|
4367
|
+
"sender":{"channel":"telegram","id":"5551234567"},"message":"…"}}
|
|
4368
|
+
```
|
|
4369
|
+
|
|
4370
|
+
It exists because the only refusal record before it,
|
|
4371
|
+
`audit.decision_refused`, requires an `action_key` and a `decision` of grant,
|
|
4372
|
+
reject or revoke, and a signature or a review has neither; writing one there
|
|
4373
|
+
would mean inventing both. The record is audit tier in the strict sense: it
|
|
4374
|
+
authorizes nothing, settles no request, charges no budget, is not sampled, and
|
|
4375
|
+
no enforcement path reads it. `approval log tail` and `approval log export`
|
|
4376
|
+
show it like any other record. Nothing about the refusal itself changed — no
|
|
4377
|
+
signature is appended and no review is recorded — and a listener whose policy
|
|
4378
|
+
maps no senders never reaches this path at all.
|
|
4379
|
+
|
|
4380
|
+
**A question answered by something other than the gate leaves a record
|
|
4381
|
+
(APRV-378).** Codex's app-server can resolve an approval with a model call
|
|
4382
|
+
before `approval codex bridge` is asked, and disclose it afterwards through an
|
|
4383
|
+
`item/autoApprovalReview` notification. The bridge stops the session on one
|
|
4384
|
+
(`bridge-auto-reviewer-active`), and it appends exactly one
|
|
4385
|
+
**`audit.question_preempted`** first, through the same append path as every
|
|
4386
|
+
other record:
|
|
4387
|
+
|
|
4388
|
+
```
|
|
4389
|
+
{"event":"audit.question_preempted","actor":"system:gate",
|
|
4390
|
+
"payload":{"source":"codex-auto-reviewer","verdict":"accept",
|
|
4391
|
+
"question":{"id":"item_01H9","method":"item/autoApprovalReview/completed",
|
|
4392
|
+
"thread":"thread_7f2","turn":"turn_3"}}}
|
|
4393
|
+
```
|
|
4394
|
+
|
|
4395
|
+
`source` is a closed set naming who answered, so the next system that does this
|
|
4396
|
+
gains a member rather than a type. `question` is the other party's own
|
|
4397
|
+
identifiers, because a record about their decision has to name it in their terms
|
|
4398
|
+
or a reader cannot go and find it on their side. `verdict` is their word
|
|
4399
|
+
verbatim and is ABSENT when the disclosure stated none: a record that said
|
|
4400
|
+
`accept` by default would be this runtime inventing somebody else's decision.
|
|
4401
|
+
|
|
4402
|
+
The record is audit tier in the strict sense: it authorizes nothing, settles no
|
|
4403
|
+
request, charges no budget, is not sampled, and no enforcement path reads it.
|
|
4404
|
+
`approval log tail` and `approval log export` show it like any other record, and
|
|
4405
|
+
`approval doctor`'s `codex-auto-reviewer` row reads it: fail when one landed in
|
|
4406
|
+
the last 24 hours, pass otherwise, and a pass says the log holds no such record
|
|
4407
|
+
rather than that any auto-reviewer is off. Nothing on this machine can say the
|
|
4408
|
+
latter, which is why the bridge probes (APRV-364) instead of reading a setting.
|
|
4409
|
+
|
|
3504
4410
|
**A settled request stops looking live.** Every terminal state the listener
|
|
3505
4411
|
observes for a message it sent edits that message: the text becomes the outcome
|
|
3506
4412
|
(`✓ APPROVED`, `✗ REJECTED`, `✗ REVOKED`, `✗ EXPIRED`, `WITHDRAWN`) with the
|
|
@@ -3542,6 +4448,253 @@ rather than a query:
|
|
|
3542
4448
|
|
|
3543
4449
|
The raw execution token is never in the JSON stream.
|
|
3544
4450
|
|
|
4451
|
+
## channel telegram webhook
|
|
4452
|
+
|
|
4453
|
+
The same channel, the other arrival (APRV-424). `listen` holds a socket open
|
|
4454
|
+
against `getUpdates` for as long as the gate exists, which needs one
|
|
4455
|
+
always-running process per bot and forbids a second poller on the same token.
|
|
4456
|
+
This registers a URL with `setWebhook` and serves the callback, so Telegram
|
|
4457
|
+
posts each update as it happens and a host that sleeps between requests can run
|
|
4458
|
+
a gate.
|
|
4459
|
+
|
|
4460
|
+
**Only the arrival changes, and that is the whole claim.** The pending queue is
|
|
4461
|
+
re-derived from the verified log every cycle by the same `dispatchPending`. The
|
|
4462
|
+
handlers are the same four function objects `listen` registers. A tap becomes a
|
|
4463
|
+
decision through the same `handleUpdate`, the same `callback_query.from.id`
|
|
4464
|
+
reading, the same `approvers.<id>.senders` resolution, the same
|
|
4465
|
+
`recordChannelDecision` and the same annotate-after-decision edit. There is no
|
|
4466
|
+
second decision path and no second copy of the sender mapping:
|
|
4467
|
+
`tests/telegram-webhook.test.ts` drives one callback through both transports and
|
|
4468
|
+
compares the records the log ends up holding, field for field.
|
|
4469
|
+
|
|
4470
|
+
### The secret, which is the whole of the authentication
|
|
4471
|
+
|
|
4472
|
+
`APPROVAL_TG_WEBHOOK_SECRET` is REQUIRED and is read from the launch
|
|
4473
|
+
environment, never from a file in the tree (SPEC.md §11.1 invariant 7, the rule
|
|
4474
|
+
`approval serve`'s two credentials follow). It is Telegram's own `secret_token`:
|
|
4475
|
+
set on `setWebhook`, echoed on every delivery in the
|
|
4476
|
+
`X-Telegram-Bot-Api-Secret-Token` header, compared here in constant time over
|
|
4477
|
+
SHA-256 digests.
|
|
4478
|
+
|
|
4479
|
+
A webhook URL is a public endpoint, and nothing in a request body could tell a
|
|
4480
|
+
delivery from a forgery — a self-reported field never reduces scrutiny (§11.1
|
|
4481
|
+
invariant 4). So the header is checked FIRST, before the path, the method and
|
|
4482
|
+
the body, and a post without the matching value is refused with its own code,
|
|
4483
|
+
counted, and reported on stderr. **A refusal is never a decision, and it appends
|
|
4484
|
+
nothing to the log.** That is the rule `TELEGRAM_ANOMALY_KINDS` states for
|
|
4485
|
+
ignored callbacks, and it binds harder here: an endpoint the internet can reach
|
|
4486
|
+
that could append would be an endpoint anyone could use to pad the record a
|
|
4487
|
+
human is asked to trust.
|
|
4488
|
+
|
|
4489
|
+
The value is refused at startup when it is unset, shorter than 24 characters, or
|
|
4490
|
+
holds a character outside Telegram's `A-Z a-z 0-9 _ -`. It appears in no policy,
|
|
4491
|
+
no log, no record, no message and no error line: the channel redacts it from
|
|
4492
|
+
everything that leaves it, exactly as it redacts the bot token.
|
|
4493
|
+
|
|
4494
|
+
| refusal | what happened |
|
|
4495
|
+
|---|---|
|
|
4496
|
+
| `webhook-secret-mismatch` | no `X-Telegram-Bot-Api-Secret-Token`, or not the registered value |
|
|
4497
|
+
| `webhook-duplicate-secret-header` | more than one of that header; malformed, and not resolvable by choosing |
|
|
4498
|
+
| `webhook-malformed-request` | the request line and `Host` header do not form a URL (400) |
|
|
4499
|
+
| `webhook-unknown-path` | a path this process does not serve (404) |
|
|
4500
|
+
| `webhook-method-not-allowed` | the right path, the wrong method; Telegram POSTs |
|
|
4501
|
+
| `webhook-body-too-large` | over 256 KiB; drained before the refusal is written |
|
|
4502
|
+
| `webhook-body-unreadable` | the body could not be read off the socket, or is not JSON |
|
|
4503
|
+
| `webhook-not-an-update` | JSON that is not an Update object |
|
|
4504
|
+
| `webhook-transport-conflict` | this channel is already receiving updates by long poll |
|
|
4505
|
+
| `webhook-handler-failed` | the channel threw; the log is what says what was recorded |
|
|
4506
|
+
|
|
4507
|
+
Startup refuses in its own vocabulary before anything binds:
|
|
4508
|
+
`webhook-secret-missing`, `webhook-secret-weak`, `webhook-secret-charset`,
|
|
4509
|
+
`webhook-url-missing`, `webhook-url-insecure`, `webhook-url-port`,
|
|
4510
|
+
`webhook-url-userinfo`, `webhook-path-invalid`, `webhook-path-mismatch`,
|
|
4511
|
+
`webhook-cycle`, and `webhook-registration-failed` for a `setWebhook` the Bot
|
|
4512
|
+
API turned down (its status and its own description, scrubbed of the token and
|
|
4513
|
+
the secret), plus every `channel telegram listen` refusal, which this verb
|
|
4514
|
+
inherits because it needs the same bot token, chat, identity, log and policy.
|
|
4515
|
+
|
|
4516
|
+
Four of those are the review's, and each one closes a start that used to
|
|
4517
|
+
succeed and then not work:
|
|
4518
|
+
|
|
4519
|
+
- **`--path` must be the url's own path.** `telegram/webhook` with no leading
|
|
4520
|
+
slash, or `/hook/` against a `--url` ending `/hook`, registered cleanly and
|
|
4521
|
+
then answered every real delivery 404 after verifying its secret. `--path` is
|
|
4522
|
+
an explicit restatement of the url's path and nothing else; rewriting belongs
|
|
4523
|
+
in the proxy in front of this process.
|
|
4524
|
+
- **`--url` may not carry userinfo.** `https://user:pw@host/hook` is a
|
|
4525
|
+
credential on a command line. It is refused, never stripped.
|
|
4526
|
+
- **The bind port may not be 0**, and must be inside 1..65535. Port 0 asks the
|
|
4527
|
+
kernel for an ephemeral port, and this transport is a fixed public url
|
|
4528
|
+
forwarded to a fixed local address: a receiver on a port nobody registered
|
|
4529
|
+
verifies nothing and answers nothing.
|
|
4530
|
+
- **Every line that prints the url prints its origin and a redacted path**, in
|
|
4531
|
+
the start-up banner, in `webhook_started` and in refusals: with two segments
|
|
4532
|
+
or more the first is kept and the rest becomes `<path redacted>`, a path of
|
|
4533
|
+
exactly one segment is replaced whole (that is the shape a tunnel's own token
|
|
4534
|
+
takes), and a query string becomes `<query redacted>`. A tunnel that hands out
|
|
4535
|
+
`https://host/hook/<random>` puts a bearer value in a path, and this runtime
|
|
4536
|
+
does not copy it into three more places. The local bind line is redacted the
|
|
4537
|
+
same way, because it carries the same path. The operator has the whole of it
|
|
4538
|
+
already: they typed it into `--url`.
|
|
4539
|
+
|
|
4540
|
+
### The proxy or tunnel is required, and this process holds no certificate
|
|
4541
|
+
|
|
4542
|
+
Telegram delivers to HTTPS only, on port 443, 80, 88 or 8443. This process
|
|
4543
|
+
terminates no TLS and holds no certificate, exactly as `approval serve` does
|
|
4544
|
+
not: `--url` names the PUBLIC address your proxy or tunnel answers on, and
|
|
4545
|
+
`--port` / `--listen` names this process's own bind, which is `127.0.0.1:4683`
|
|
4546
|
+
unless you say otherwise. A routable bind takes both `--listen <host:port>` and
|
|
4547
|
+
`--allow-non-loopback` and prints a banner, because the secret arrives in a
|
|
4548
|
+
header and a cleartext hop hands it to whoever is on it.
|
|
4549
|
+
|
|
4550
|
+
So a working deployment is a reverse proxy (nginx, Caddy, a cloud load balancer)
|
|
4551
|
+
or a tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) terminating TLS on a
|
|
4552
|
+
name you control and forwarding to the loopback bind. Without one there is
|
|
4553
|
+
nothing to register: a plain-HTTP `--url`, or one on another port, is refused
|
|
4554
|
+
before any call is made rather than attempted and rejected by the Bot API.
|
|
4555
|
+
|
|
4556
|
+
### One transport per bot, refused on both sides
|
|
4557
|
+
|
|
4558
|
+
`getUpdates` is refused by the Bot API outright while a webhook is set, so the
|
|
4559
|
+
two are alternatives and never a pair. Four checks say so before anything runs,
|
|
4560
|
+
and they are in that order because the first needs no network:
|
|
4561
|
+
|
|
4562
|
+
- **A lease per gate.** `approval up`, `approval channel telegram listen` and
|
|
4563
|
+
this verb take a lockfile in the gate's own derived state directory,
|
|
4564
|
+
`.approval/daemon/telegram-transport.lock`, holding the holder's pid, which
|
|
4565
|
+
transport it is running and when it started. It is created with `O_EXCL`, so
|
|
4566
|
+
two processes racing cannot both believe they took it, and a holder that is
|
|
4567
|
+
no longer running is reclaimed automatically (a crash must not lock a gate
|
|
4568
|
+
out of its own channel). A second taker refuses `telegram-poller-running`
|
|
4569
|
+
when a poller holds it and `webhook-registered` when a webhook does, naming
|
|
4570
|
+
the pid and the mode; a directory the lease cannot be written in refuses
|
|
4571
|
+
`telegram-lease-unavailable`, because this file is the only thing keeping one
|
|
4572
|
+
gate from running two transports. Both transports release it on a clean stop.
|
|
4573
|
+
|
|
4574
|
+
**A holder is a process, not a number.** Pids are reused, so the probe asks
|
|
4575
|
+
three questions: does a process with that number exist, can this process
|
|
4576
|
+
signal it, and did it start before the lease was written. A number that is
|
|
4577
|
+
gone, one owned by a process this one cannot signal, and one belonging to a
|
|
4578
|
+
process that started *after* the lease are all reclaimed, and the line says
|
|
4579
|
+
which of the three it was. Only a process that is running, signalable and
|
|
4580
|
+
older than its own lease keeps the gate. Where the platform will not report a
|
|
4581
|
+
start time, a running pid keeps the gate, which is the stricter reading.
|
|
4582
|
+
|
|
4583
|
+
**Taking a lease over is a critical section, and the section is owned.**
|
|
4584
|
+
Reclaiming happens inside a sibling `O_EXCL` lock that carries the entering
|
|
4585
|
+
process's pid and a nonce. Inside it the lease record must still be exactly
|
|
4586
|
+
the one that was judged (same pid, same `started_at`), the lock must still
|
|
4587
|
+
name this entry at the moment of the write, and the lock is removed on the
|
|
4588
|
+
way out only while it still does. Without the first, two processes that had
|
|
4589
|
+
both read one dead holder took turns deleting each other's live lease;
|
|
4590
|
+
without the other two, a process that stalled inside the section renamed its
|
|
4591
|
+
lease over the one that replaced it and deleted the new holder's lock. The
|
|
4592
|
+
replacement is a rename, so the lockfile is never absent and never half
|
|
4593
|
+
written. A reclaim lock is evicted only when its own process is gone, judged
|
|
4594
|
+
by the probe above: its age is reported and decides nothing, because "five
|
|
4595
|
+
seconds have passed" says nothing about whether anybody is still inside. A
|
|
4596
|
+
lock this build cannot read names no process, so the take refuses and names
|
|
4597
|
+
the file rather than forcing it.
|
|
4598
|
+
|
|
4599
|
+
This is the check the other three cannot make. Two processes started in the
|
|
4600
|
+
SAME project — one `approval up` long-polling, one `approval channel telegram
|
|
4601
|
+
webhook` — passed all of them: `getWebhookInfo` is empty while the poller is
|
|
4602
|
+
the one running, and the ownership registry compares instance ids, which are
|
|
4603
|
+
equal because it is the same instance. Each then ran its own dispatch cycle
|
|
4604
|
+
over its own state against one log, so every request reached the phone twice
|
|
4605
|
+
under two nonces and only one copy could resolve a tap.
|
|
4606
|
+
- **What the Bot API says.** `listen` and `approval up` ask `getWebhookInfo` in
|
|
4607
|
+
the same preflight that asks `getMe`, and refuse `webhook-registered` naming
|
|
4608
|
+
the origin that holds the bot. For a poller an unreachable Bot API is not a
|
|
4609
|
+
refusal (a captive portal says nothing about which transport owns a bot) and
|
|
4610
|
+
the HTTP 409 path is the backstop. For THIS verb it is: a probe it could not
|
|
4611
|
+
make refuses `webhook-probe-failed`, because the next call would be a
|
|
4612
|
+
`setWebhook` that overwrites a registration this process never saw.
|
|
4613
|
+
- **Any existing registration, including the same url, refuses.** Telegram
|
|
4614
|
+
allows one webhook per bot and keeps the last registration, so a second host
|
|
4615
|
+
registering takes the taps: it overwrites the first host's `secret_token`, and
|
|
4616
|
+
from then on every real tap arrives at the first host with a secret it does
|
|
4617
|
+
not recognise, indistinguishable from a forgery. `--reclaim` is how an
|
|
4618
|
+
operator takes the bot deliberately; with it, a url that matches the
|
|
4619
|
+
registered one (host lowercased, trailing slash dropped) is reported as a
|
|
4620
|
+
re-registration and anything else as a takeover that stops the other receiver
|
|
4621
|
+
being posted to.
|
|
4622
|
+
|
|
4623
|
+
**One case needs no flag: this gate's own runner was killed.** A `SIGKILL`
|
|
4624
|
+
leaves the registration in place, because the dead process never reached
|
|
4625
|
+
`deleteWebhook`, and a restart that refused every time would teach operators
|
|
4626
|
+
to put `--reclaim` in the unit file, which retires the protection above for
|
|
4627
|
+
good in exchange for a crash recovery. So a restart may re-register the SAME
|
|
4628
|
+
normalised url without the flag when the lease it just reclaimed was this
|
|
4629
|
+
gate's own, written by a webhook runner, and that process is **gone**. A dead
|
|
4630
|
+
poller's lease does not count, a different url does not count, and a gate
|
|
4631
|
+
with no such lease does not count. Nor do the other two reclaim reasons: a
|
|
4632
|
+
lease reclaimed because its pid is owned by a process this one cannot signal,
|
|
4633
|
+
or because the number has been reused, is the right call for the lease and no
|
|
4634
|
+
evidence at all that the runner which registered the url has stopped. Those
|
|
4635
|
+
keep the refusal and the `--reclaim` demand.
|
|
4636
|
+
- Within one process, `TelegramChannel.claimTransport` refuses the second claim.
|
|
4637
|
+
|
|
4638
|
+
A clean stop (SIGINT, SIGTERM) removes the webhook, so long polling works again
|
|
4639
|
+
afterwards. Pending updates are kept in both directions: a tap that arrived
|
|
4640
|
+
while the process was down is the approver's answer, and the gate is what
|
|
4641
|
+
decides whether it is still honourable.
|
|
4642
|
+
|
|
4643
|
+
### Redelivery, and the cycle
|
|
4644
|
+
|
|
4645
|
+
Telegram retries a delivery it did not get a 2xx for, so the same tap can arrive
|
|
4646
|
+
twice. Nothing deduplicates it: the second one reaches the gate through the same
|
|
4647
|
+
path as the first and is refused `already-decided`, with the first human answer
|
|
4648
|
+
standing — the property a button pressed twice has had since the channel
|
|
4649
|
+
shipped. Updates are handled one at a time, in arrival order, and the response
|
|
4650
|
+
is written after the update has been handled.
|
|
4651
|
+
|
|
4652
|
+
`--cycle <duration>` (default 30s) is the dispatch period, the webhook's
|
|
4653
|
+
equivalent of the poll loop's per-cycle dispatch: it re-derives the pending
|
|
4654
|
+
queue and sends what has not been sent. A cycle also runs immediately after each
|
|
4655
|
+
handled update, so a paced listener's next question follows a tap without
|
|
4656
|
+
waiting for the timer.
|
|
4657
|
+
|
|
4658
|
+
**A stop waits for the update it is holding.** Ctrl-C stops the receiver
|
|
4659
|
+
accepting, then drains: an update mid-append keeps its socket, finishes its
|
|
4660
|
+
decision and writes its response, and only then is `deleteWebhook` called and
|
|
4661
|
+
the stopped line printed. A second Ctrl-C during the drain is absorbed rather
|
|
4662
|
+
than killing the process, and a request that never finishes is dropped after ten
|
|
4663
|
+
seconds, because a stop has to end. `deleteWebhook` on that path is given five
|
|
4664
|
+
seconds rather than the channel's usual thirty, for the same reason: a stop an
|
|
4665
|
+
operator has asked for twice should not sit behind an unreachable Bot API, and
|
|
4666
|
+
a removal that does not land is reported as a webhook still registered.
|
|
4667
|
+
|
|
4668
|
+
### The daemon id it stamps (APRV-448)
|
|
4669
|
+
|
|
4670
|
+
Every record this process appends (a decision a tap delivers, the refusal of
|
|
4671
|
+
one, anything a dispatch cycle writes) carries the `daemon` field, with the id
|
|
4672
|
+
`approval status` reports for the same store: `APPROVAL_DAEMON_ID` from the
|
|
4673
|
+
launch environment, or `daemon-` plus the instance id derived from the log's
|
|
4674
|
+
`.approval` directory, which is the id the daemon loop derives. The started line
|
|
4675
|
+
names it (`records stamped daemon <id> (<source>)`) and `webhook_started`
|
|
4676
|
+
carries it as `daemon`. A declared id that is not a usable id refuses the verb
|
|
4677
|
+
before the bot is claimed or a port is bound, exit 2.
|
|
4678
|
+
|
|
4679
|
+
The attested policy's `daemons` list governs this process at the write
|
|
4680
|
+
boundary, as it does the daemon and [`approval serve`](#serve). It is re-read at
|
|
4681
|
+
startup and at the top of every dispatch cycle, which is this process's tick: a
|
|
4682
|
+
list a human narrows and re-attests takes effect within one cycle (a cycle runs
|
|
4683
|
+
after every handled update and every `--cycle`), and a resolution that fails
|
|
4684
|
+
leaves the previous list standing. A tap whose decision is refused
|
|
4685
|
+
`daemon-not-allowed` appends nothing, and the refusal is reported on stderr. The
|
|
4686
|
+
process declares the id without marking itself the daemon.
|
|
4687
|
+
|
|
4688
|
+
```json
|
|
4689
|
+
{"event":"webhook_started","url":"https://gate.example/telegram/<path redacted>",
|
|
4690
|
+
"host":"127.0.0.1","port":4683,"path":"/telegram/<path redacted>","cycle_ms":30000,
|
|
4691
|
+
"daemon":"daemon-3f2a9c11"}
|
|
4692
|
+
{"event":"stopped","notified":1,"updates":1,"decisions":1,"pollErrors":0,
|
|
4693
|
+
"anomalies":{"foreign-chat":0,"malformed-callback":0,"unknown-callback":0,
|
|
4694
|
+
"key-mismatch":0},
|
|
4695
|
+
"webhook":{"requests":2,"updates":1,"refusals":{"webhook-secret-mismatch":1}}}
|
|
4696
|
+
```
|
|
4697
|
+
|
|
3545
4698
|
## channel telegram health
|
|
3546
4699
|
|
|
3547
4700
|
Makes no network call. A health check that contacted the Bot API would announce
|
|
@@ -3555,6 +4708,194 @@ belong to a RUNNING listener: they are on its stderr as they happen, in its
|
|
|
3555
4708
|
Which variables are read comes from the policy, so a renamed variable reads back
|
|
3556
4709
|
as the name you set.
|
|
3557
4710
|
|
|
4711
|
+
## channel relay
|
|
4712
|
+
|
|
4713
|
+
A third arrival for a human gesture, beside a Telegram tap and a terminal
|
|
4714
|
+
(APRV-455). An operator's control plane holds an authenticated session for a
|
|
4715
|
+
person (the Agent Village control plane holds an EdgeOS session for each
|
|
4716
|
+
resident), shows them a request or their policy, and posts what they did to
|
|
4717
|
+
this verb. The post carries the gesture, the EdgeOS `/humans/me` id of the
|
|
4718
|
+
session that made it, the policy hash or the action key it answers, a nonce and
|
|
4719
|
+
an `issued_at`. The daemon resolves the id against the attested policy exactly
|
|
4720
|
+
as it resolves a Telegram `from.id`, and records the gesture through the same
|
|
4721
|
+
`recordChannelDecision` a tap goes through.
|
|
4722
|
+
|
|
4723
|
+
```
|
|
4724
|
+
approval channel relay [--listen [host:]port | --port <n>] [--allow-non-loopback]
|
|
4725
|
+
[--proposer agent:<id>] [--policy <p>] [--dir <p>]
|
|
4726
|
+
[--log <p>] [--json]
|
|
4727
|
+
```
|
|
4728
|
+
|
|
4729
|
+
### Trust level
|
|
4730
|
+
|
|
4731
|
+
**The daemon trusts the relay's attribution.** A post that carries the relay
|
|
4732
|
+
secret is the operator's control plane reporting which EdgeOS account acted,
|
|
4733
|
+
and the daemon believes that report the way it believes the Telegram Bot API's
|
|
4734
|
+
report of `callback_query.from.id`. That is operator trust, stated in
|
|
4735
|
+
APRV-422's words: no hosted service has authority over decisions. The relay
|
|
4736
|
+
carries a person's gesture and decides nothing. Which approver the account is,
|
|
4737
|
+
whether that approver may decide this request or attest this policy, and
|
|
4738
|
+
whether the request is still answerable are all decided by the runtime, from
|
|
4739
|
+
the verified log and the attested policy, with the same codes a tap gets.
|
|
4740
|
+
|
|
4741
|
+
What the relay cannot do, by construction:
|
|
4742
|
+
|
|
4743
|
+
- **Act as anyone of its own.** It takes no `--as` and reads no
|
|
4744
|
+
`APPROVAL_HUMAN`. Every gesture is attributed to the account
|
|
4745
|
+
`approvers.<id>.senders.edgeos` maps, or refused `sender-unmapped` with one
|
|
4746
|
+
`audit.decision_refused`. A policy that maps no EdgeOS account refuses every
|
|
4747
|
+
gesture the same way; there is no fallback to a configured identity.
|
|
4748
|
+
- **Read a mapping nobody has attested.** Every gesture, decisions included,
|
|
4749
|
+
resolves its sender against the policy IN FORCE: the verified bytes the
|
|
4750
|
+
latest attestation names, recovered from the payload store. A mapping
|
|
4751
|
+
sitting unattested on disk (the relay's own `propose` puts one there) decides
|
|
4752
|
+
nothing until a human attests it. Where the in-force bytes cannot be
|
|
4753
|
+
recovered, a decision is refused `policy-not-attested` (an attestation,
|
|
4754
|
+
`attest-requires-terminal`) and nothing is appended; a log that cannot be
|
|
4755
|
+
read keeps its own code.
|
|
4756
|
+
- **Choose who may sign for a policy change.** An attestation is resolved
|
|
4757
|
+
against the policy IN FORCE, never the one being attested, so an account a
|
|
4758
|
+
proposal adds or repoints cannot accept the proposal that names it, and an
|
|
4759
|
+
amendment that introduces the EdgeOS mapping is refused
|
|
4760
|
+
`attest-requires-terminal` (the operator bootstrap of APRV-449 is what maps
|
|
4761
|
+
the resident the first time).
|
|
4762
|
+
- **Hold a token.** A grant that mints a token for the deciding surface has it
|
|
4763
|
+
dropped unread; the response says only `token_issued`. Requests in the Agent
|
|
4764
|
+
Village are harness-executed or sealed, so none is lost; a manual-delivery
|
|
4765
|
+
request granted here has a token nobody holds and is asked again.
|
|
4766
|
+
- **Be reached through `approval serve` or `approval mcp serve`.** It is its
|
|
4767
|
+
own process, port and credential. The registry marks it `human_only`, so no
|
|
4768
|
+
wrapper publishes it, and neither serve credential reaches it.
|
|
4769
|
+
- **Be started by an agent.** The attribution a relay records is exactly as
|
|
4770
|
+
strong as control over who launches it, because its launcher chooses the
|
|
4771
|
+
secret. The hook classifier holds `approval channel relay` as `policy.core`
|
|
4772
|
+
(human-only in the reference policy), so an agent session cannot start one; a
|
|
4773
|
+
deployment starts it from its own launcher as the store's user. A keyed
|
|
4774
|
+
`senders.edgeos` mapping adds a second lock: a process without
|
|
4775
|
+
`APPROVAL_SENDER_KEY` cannot resolve any account.
|
|
4776
|
+
|
|
4777
|
+
### The secret
|
|
4778
|
+
|
|
4779
|
+
`APPROVAL_RELAY_SECRET` is REQUIRED and is read from the launch environment,
|
|
4780
|
+
never from a file in the tree (SPEC.md §11.1 invariant 7). At least 24
|
|
4781
|
+
characters of `A-Z a-z 0-9 _ -` (generate it: `openssl rand -hex 32`), with the
|
|
4782
|
+
same value given to the control plane; surrounding whitespace is stripped at
|
|
4783
|
+
start, so the control plane sends the trimmed value. Under the `APPROVAL_`
|
|
4784
|
+
prefix it is withheld from every child `approval run` spawns. The control plane sends it
|
|
4785
|
+
in `x-approval-relay-secret`; it is compared in constant time over SHA-256
|
|
4786
|
+
digests, BEFORE the path, the method or the body, and a duplicate header is
|
|
4787
|
+
refused rather than resolved. **A refusal never reaches the gate and appends
|
|
4788
|
+
nothing to the log**: it is counted, written to stderr, and answered with its
|
|
4789
|
+
code. The secret appears in no response, no log record and no stderr line.
|
|
4790
|
+
|
|
4791
|
+
Loopback by default (port 4684). There is no TLS here; a routable bind takes
|
|
4792
|
+
`--listen <host:port> --allow-non-loopback` and prints a banner every start.
|
|
4793
|
+
|
|
4794
|
+
### The body
|
|
4795
|
+
|
|
4796
|
+
`POST /relay/gesture`, one JSON object, closed: an unknown field is refused, so
|
|
4797
|
+
nothing that looks like authority (an `actor`, a second sender, a `ts`) rides
|
|
4798
|
+
along on a valid post.
|
|
4799
|
+
|
|
4800
|
+
```json
|
|
4801
|
+
{"gesture":"propose","policy_sha256":"<64 hex>","nonce":"<16..128 of A-Za-z0-9_->","issued_at":"2026-10-03T12:00:00Z"}
|
|
4802
|
+
{"gesture":"attest","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"<EdgeOS id>"},"nonce":"...","issued_at":"..."}
|
|
4803
|
+
{"gesture":"decline","policy_sha256":"<64 hex>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
|
|
4804
|
+
{"gesture":"grant","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
|
|
4805
|
+
{"gesture":"reject","action_key":"<the request's key>","sender":{"channel":"edgeos","id":"..."},"nonce":"...","issued_at":"..."}
|
|
4806
|
+
```
|
|
4807
|
+
|
|
4808
|
+
- `sender.channel` is `edgeos` and nothing else. `sender.id` is the raw
|
|
4809
|
+
`/humans/me` id: an ASCII letter or digit, then up to 127 letters, digits,
|
|
4810
|
+
`.`, `_` or `-`, the grammar `senders.edgeos` pins. Never a digest (a keyed
|
|
4811
|
+
policy is resolved with `APPROVAL_SENDER_KEY` in the relay's environment) and
|
|
4812
|
+
never an email address.
|
|
4813
|
+
- `issued_at` is RFC 3339 in UTC ending in `Z`, within five minutes of the
|
|
4814
|
+
relay's clock either side. It is used for that window and recorded nowhere;
|
|
4815
|
+
every record's `ts` is the runtime's own.
|
|
4816
|
+
- `nonce` is claimed before the gesture is applied, in a ledger of exclusively
|
|
4817
|
+
linked files under the gate's `.approval/daemon/relay-nonces/`, kept for eleven
|
|
4818
|
+
minutes. A replay is refused `relay-nonce-replayed` at this process, after a
|
|
4819
|
+
restart, or at a second relay on the same gate. A retry is a new post with a
|
|
4820
|
+
new nonce, and the gate answers a repeated decision `already-decided`.
|
|
4821
|
+
|
|
4822
|
+
`propose` is the control plane's half of the onboarding review. It writes the
|
|
4823
|
+
rendered policy into the store, then posts its hash: the relay re-hashes the
|
|
4824
|
+
file and refuses `relay-policy-mismatch` when the bytes are not the ones named,
|
|
4825
|
+
then appends a `policy.proposed` under `--proposer` (an `agent:` id, default
|
|
4826
|
+
`agent:edgeos-relay`: the relay is a machine, and a `human:` proposer would say
|
|
4827
|
+
a person proposed what nobody typed) with the semantic diff against the
|
|
4828
|
+
verified in-force text and a deadline one hour out, after which the prompt
|
|
4829
|
+
retires from every queue. A retry of an open proposal of the same bytes returns it with
|
|
4830
|
+
`"existing":true`. It also admits bytes ALREADY in force (`"reaffirm":true`):
|
|
4831
|
+
the review most residents make is "accept, unchanged", and that acceptance is
|
|
4832
|
+
still their attestation. A reaffirmation changes the attester of record and
|
|
4833
|
+
nothing else. The prompt also reaches Telegram, where the resident's tap
|
|
4834
|
+
answers it equally.
|
|
4835
|
+
|
|
4836
|
+
**Until a proposal is attested, the bytes on disk are not the policy in
|
|
4837
|
+
force.** Every gated call refuses `policy-not-attested` in that window (a
|
|
4838
|
+
grant through the relay included), exactly as during any amendment. So the
|
|
4839
|
+
control plane proposes and collects the acceptance in the same screen, and
|
|
4840
|
+
restores the in-force bytes when the resident declines or the proposal lapses.
|
|
4841
|
+
A `decline` appends `policy.declined` and leaves the attestation in force
|
|
4842
|
+
where it was, the operator's included: it is a refusal to adopt the proposed
|
|
4843
|
+
bytes, never a withdrawal of the policy already attested.
|
|
4844
|
+
|
|
4845
|
+
`attest` / `decline` answer the open proposal of that hash. `attest` appends
|
|
4846
|
+
`policy.updated` with `actor: human:<approver>` and `payload.sender
|
|
4847
|
+
{channel: edgeos, id}`. A hash that names no open proposal (superseded,
|
|
4848
|
+
answered, never proposed) is refused `proposal-not-found`, and a file changed
|
|
4849
|
+
under the proposal is refused `proposal-stale`; neither attests anything.
|
|
4850
|
+
|
|
4851
|
+
`grant` / `reject` answer a pending request exactly as a Telegram tap does,
|
|
4852
|
+
including `expired`, `policy-drift` (with its `audit.decision_refused` and
|
|
4853
|
+
withdrawal), `already-decided` and `actor-not-approver`.
|
|
4854
|
+
|
|
4855
|
+
### Responses
|
|
4856
|
+
|
|
4857
|
+
| status | body | meaning |
|
|
4858
|
+
| --- | --- | --- |
|
|
4859
|
+
| 200 | `{"ok":true,"gesture":…,"event":…,"seq":…,"actor":…}` | recorded (`propose` returns `sha256`, `seq`, `existing`, `reaffirm`, `changes`, `loads`) |
|
|
4860
|
+
| 409 | `{"ok":false,"gesture":…,"refusal":{"code","message"}}` | the gate or the decision surface refused; the code is from `gate_refusal_codes` or `channel_decision_refusal_codes` |
|
|
4861
|
+
| 4xx/5xx | `{"error":{"code","message"}}` | the relay refused the post; the code is from `relay_refusal_codes`, and nothing was appended |
|
|
4862
|
+
|
|
4863
|
+
`relay_refusal_codes` (frozen, pinned in `conformance/vectors/refusal-unions.v1.json`):
|
|
4864
|
+
`relay-secret-mismatch`, `relay-duplicate-secret-header` (401),
|
|
4865
|
+
`relay-malformed-request` (400), `relay-unknown-path` (404),
|
|
4866
|
+
`relay-method-not-allowed` (405), `relay-body-too-large` (413),
|
|
4867
|
+
`relay-body-unreadable`, `relay-body-invalid`, `relay-sender-invalid`,
|
|
4868
|
+
`relay-gesture-stale` (400), `relay-nonce-replayed` (409),
|
|
4869
|
+
`relay-nonce-unavailable` (503, the ledger could not record the nonce, so
|
|
4870
|
+
nothing was decided), `relay-policy-mismatch` (409), `relay-handler-failed`
|
|
4871
|
+
(500).
|
|
4872
|
+
|
|
4873
|
+
Start-up refusals (exit 2, `{"error":{"code"}}` under `--json`):
|
|
4874
|
+
`relay-secret-missing`, `relay-secret-weak`, `relay-secret-charset`,
|
|
4875
|
+
`relay-proposer-invalid`, `relay-bind-invalid`. An unreadable log exits 4, a
|
|
4876
|
+
torn tail 3, a corrupt chain 1.
|
|
4877
|
+
|
|
4878
|
+
With `--json`, stdout carries one object per line: `relay_started` (`host`,
|
|
4879
|
+
`port`, `path`, `proposer`), one `relay_gesture` per gesture that reached the
|
|
4880
|
+
gate (`gesture`, `ok`, and `seq`/`record`/`actor` or `code`), and `stopped`
|
|
4881
|
+
with the counters. Never a body, a sender id or the secret.
|
|
4882
|
+
|
|
4883
|
+
There is no transport lease. A Telegram bot admits one receiver; a relay has no
|
|
4884
|
+
such constraint, because compare-and-append, the gate's idempotency and the
|
|
4885
|
+
nonce ledger (each claim written whole and linked into place, so exactly one
|
|
4886
|
+
claimant wins) make two relays on one gate safe. A second relay on the same
|
|
4887
|
+
port fails to bind.
|
|
4888
|
+
|
|
4889
|
+
**Who is listening on the port is the caller's check.** The secret travels in a
|
|
4890
|
+
header, so whoever holds the loopback port when the control plane posts
|
|
4891
|
+
receives it. In a sandbox where another uid can bind loopback (the Agent
|
|
4892
|
+
Village's `hermes` user), the control plane checks that the listener on 4684
|
|
4893
|
+
belongs to the relay's own user before it posts, the same foreign-listener
|
|
4894
|
+
rule the hook applies to `serve`, and posts to the literal `127.0.0.1`, never
|
|
4895
|
+
`localhost`, which another uid could answer on `[::1]`. A unix socket in a directory only the relay's
|
|
4896
|
+
user can write, or a signature over the body in place of a bearer secret, would
|
|
4897
|
+
remove the check; neither is in this version.
|
|
4898
|
+
|
|
3558
4899
|
## quickstart
|
|
3559
4900
|
|
|
3560
4901
|
`approval quickstart [--dir <path>] [--api-base <url>]` is the human-only solo setup ceremony. It
|
|
@@ -3605,17 +4946,20 @@ code. A re-run in a scaffolded directory writes nothing and exits 0. A directory
|
|
|
3605
4946
|
carrying `APPROVALS.md` (the SPEC.md §5 fallback filename) already has a policy:
|
|
3606
4947
|
init reports `policy-exists` and writes no `APPROVAL.md` beside it.
|
|
3607
4948
|
|
|
3608
|
-
Payloads are tracked. `.approval/payloads/`
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
4949
|
+
Payloads are ignored (APRV-445; they were tracked before it). `.approval/payloads/`
|
|
4950
|
+
holds the exact bytes each approval bound to, and for a proposal those are a
|
|
4951
|
+
person's own words, so the merged block lists it. To keep the bytes in history
|
|
4952
|
+
as evidence instead, remove that line yourself; the log keeps every
|
|
4953
|
+
`payload_hash` either way. Files in the store are written 0600 in a 0700
|
|
4954
|
+
directory whatever the umask.
|
|
3612
4955
|
|
|
3613
4956
|
Keys are not. The merged block ignores `.approval/*.sqlite` (a projection),
|
|
3614
4957
|
`.approval/vault.enc`, `.approval/env`, `.approval/keys/`, `.approval/**/*.tmp-*`
|
|
3615
4958
|
and `.approval-journal/`. `.approval/keys/` is the one that matters most and is
|
|
3616
4959
|
easiest to miss: it holds the X25519 private halves of sealed token delivery, and
|
|
3617
|
-
because the payload store beside it
|
|
3618
|
-
|
|
4960
|
+
because the payload store beside it was tracked by default before APRV-445 (and
|
|
4961
|
+
still is wherever an operator chose that), `.approval/` is a directory people
|
|
4962
|
+
`git add` from. A key committed there opens that action's
|
|
3619
4963
|
`token_sealed` for everyone holding the log. The line is written whether or not
|
|
3620
4964
|
your policy has opted into `token_delivery: sealed`, and `approval doctor`'s
|
|
3621
4965
|
`sealed-keys` row reports a key that is tracked or in a store no line covers.
|
|
@@ -3647,6 +4991,95 @@ A HUMAN commits those files: they are `policy.edit`. `docs/agent-sdk-hook.md`
|
|
|
3647
4991
|
is the third caller: a Python Agent SDK application has no settings file, so it
|
|
3648
4992
|
spawns this same verb from a hook callback (APRV-242).
|
|
3649
4993
|
|
|
4994
|
+
**`hook grok` is the one exception to the exit codes above (APRV-243).** Grok
|
|
4995
|
+
Build reads exit 2 as the deny and exit 0 as the allow, whatever stdout said,
|
|
4996
|
+
so on that harness alone a deny is exit 2 with `{"decision":"deny","reason":…}`
|
|
4997
|
+
on stdout, and the post-execution event exits 0 in every case rather than
|
|
4998
|
+
using 2 for visibility. Its envelope is camelCase (`toolName`, `toolInput`,
|
|
4999
|
+
`sessionId`, `hookEventName`, plus a `workspaceRoot` this runtime ignores in
|
|
5000
|
+
favour of `cwd`); snake_case is still read and wins when both spellings are
|
|
5001
|
+
present. `approval hook grok --help` prints the `.grok/hooks/pre-tool-use.json`
|
|
5002
|
+
the human commits, and that entry's own `timeout` must exceed `--timeout`.
|
|
5003
|
+
Grok Build FAILS OPEN on hook timeout, crash and malformed output, with no
|
|
5004
|
+
setting to change it, which the fail-closed invariant does not survive;
|
|
5005
|
+
`docs/grok-hook.md` states which cases the adapter cannot cover and is worth
|
|
5006
|
+
reading before the file is committed.
|
|
5007
|
+
|
|
5008
|
+
**`hook muse` emits exactly ONE dialect and nothing else (APRV-350).** Muse Code
|
|
5009
|
+
treats output carrying any key it does not support as a failed hook, and a failed
|
|
5010
|
+
hook fails OPEN, so the belt-and-braces payload that satisfies several harnesses
|
|
5011
|
+
at once satisfies this one not at all. Its envelope is snake_case with its own
|
|
5012
|
+
tool names (`bash` carrying a per-call `workdir`, `write_file`, `read_file`, and
|
|
5013
|
+
`search` naming an ARRAY under `paths`), and a Contributor-tier model is refused
|
|
5014
|
+
for every tool call regardless of policy (`hook-muse-contributor-model`).
|
|
5015
|
+
`approval hook muse --help` prints the `.muse/hooks.json` the human commits;
|
|
5016
|
+
`docs/muse-hook.md` opens with the fail-open finding and the model warning.
|
|
5017
|
+
|
|
5018
|
+
**`hook hermes` is the second exception to the exit codes above, and the one
|
|
5019
|
+
harness whose configuration is not in the repository (APRV-398).** Its event
|
|
5020
|
+
names are its own — `pre_tool_call` and `post_tool_call`, not `PreToolUse` — and
|
|
5021
|
+
its envelope is snake_case (`tool_name`, `tool_input`, `session_id`, `cwd`,
|
|
5022
|
+
`profile`, `extra`). A deny is `{"action":"block","message":…}` **at exit 2**,
|
|
5023
|
+
which Hermes treats as an unconditional block whose message it takes from the
|
|
5024
|
+
stdout directive first; an **allow is `{}`** at exit 0, because Hermes has no
|
|
5025
|
+
allow directive and no directive is the allow, so an allow's reason goes to
|
|
5026
|
+
stderr. `execute_code` is refused outright
|
|
5027
|
+
(`hook-hermes-execute-code-unbound`): it carries a program and no path, no argv
|
|
5028
|
+
and no working directory, so no verdict could bind it. **A call whose effective
|
|
5029
|
+
directory the event does not carry is refused too** (`hook-unsupported-execution-context`,
|
|
5030
|
+
APRV-415): a live probe found that the envelope `cwd` is the Hermes PROCESS
|
|
5031
|
+
directory while `terminal` keeps a per-session recorded directory that a `cd` moves
|
|
5032
|
+
and no field reports, and that the file tools resolve relative paths against that
|
|
5033
|
+
one — so a `terminal` call with no absolute `workdir`, and a `write_file`, `patch`,
|
|
5034
|
+
`read_file` or `search_files` call whose path is relative or missing, are refused
|
|
5035
|
+
with a reason telling the session to retry with an absolute path. The file a human
|
|
5036
|
+
commits is `$HERMES_HOME/config.yaml` — name the home explicitly and spell its last
|
|
5037
|
+
segment `.hermes`, or the classifier does not recognise the organ — where the event
|
|
5038
|
+
is a mapping KEY rather than an `event:` field, `fail_closed: true` belongs on every
|
|
5039
|
+
entry because its default is false, and TWO timeouts bound the wait: the per-entry
|
|
5040
|
+
`timeout` caps at 300s and `plugins.hook_callback_timeout` defaults to 30s and
|
|
5041
|
+
fails closed by itself, so `--timeout` must come down under 300s. `--dir` is
|
|
5042
|
+
mandatory here, because a gateway session's `cwd` is the user's home. Without
|
|
5043
|
+
`hooks_auto_accept: true` (or `HERMES_ACCEPT_HOOKS=1`) and no TTY, Hermes
|
|
5044
|
+
**silently never registers the hook**. `fail_closed: true` was OBSERVED to block a
|
|
5045
|
+
hook crash, a hook timeout and unparseable output on `main` `118984d7`, which makes
|
|
5046
|
+
this the first adapter since Claude Code that is a gate rather than a backstop;
|
|
5047
|
+
`v0.21.3` (build 2026.9.14) does not know the key and fails open silently, so
|
|
5048
|
+
`approval doctor`'s `harness-version-unverified` row pins that floor.
|
|
5049
|
+
`approval hook hermes --help` prints the YAML; `docs/hermes-hook.md` opens with the
|
|
5050
|
+
fail-closed result, the floor, and what a post event does not prove.
|
|
5051
|
+
|
|
5052
|
+
**`--harness-cap <duration>` makes the question end before its asker does
|
|
5053
|
+
(APRV-423).** Every harness in the table kills the hook at some ceiling (Hermes
|
|
5054
|
+
caps a `pre_tool_call` entry at 300s, Claude Code kills it at the `timeout` in
|
|
5055
|
+
its settings file), and every one of them reads the killed process as a
|
|
5056
|
+
non-blocking error and runs the tool call. Before this flag the request that
|
|
5057
|
+
hook had opened stayed pending afterwards, so a tap arriving later was recorded
|
|
5058
|
+
as a plain `approval.granted` on a call nobody was holding, and the deny and the
|
|
5059
|
+
grant described one request and disagreed about it (APRV-410).
|
|
5060
|
+
|
|
5061
|
+
The flag states the ceiling. The hook records it on `approval.requested` as
|
|
5062
|
+
`payload.harness_cap_ms`, and the runtime judges the request against the SHORTER
|
|
5063
|
+
of the policy's `defaults.approval_ttl` and that ceiling minus a 60s margin
|
|
5064
|
+
(`HARNESS_CAP_MARGIN_MS` in `src/core/harness-wait.ts`, two of the daemon's 30s
|
|
5065
|
+
sweep intervals, so the `approval.expired` record lands while the harness is
|
|
5066
|
+
still listening in the common case; a skipped sweep or a slow intake can put it
|
|
5067
|
+
after the kill, and what keeps a late tap safe then is the gate's lazy `expired`
|
|
5068
|
+
refusal, which judges the lapse by arithmetic whether or not the record exists).
|
|
5069
|
+
A cap can only SHORTEN: the runtime takes a minimum, so a stated ceiling longer
|
|
5070
|
+
than the TTL changes nothing, which is SPEC.md §11.1 invariant 4 applied to a
|
|
5071
|
+
duration. Without the flag, `hook hermes` assumes **30s**, Hermes's default
|
|
5072
|
+
`plugins.hook_callback_timeout`, which does not clear the margin: every
|
|
5073
|
+
manual-class call is refused `hook-harness-cap-too-short` until the operator
|
|
5074
|
+
raises `plugins.hook_callback_timeout` above the per-entry `timeout` and passes
|
|
5075
|
+
`--harness-cap` with the smaller of the two (`--harness-cap 300s` for the
|
|
5076
|
+
documented `600` over `300`). A stated cap is still clamped to Hermes's observed
|
|
5077
|
+
300s per-entry maximum. No other adapter assumes a ceiling, because no other
|
|
5078
|
+
harness documents one. A ceiling at or below the margin leaves no window for a
|
|
5079
|
+
human and is refused `hook-harness-cap-too-short` before anything is registered
|
|
5080
|
+
or requested. `approval serve` pins the same value on every hook call it makes
|
|
5081
|
+
with `--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`.
|
|
5082
|
+
|
|
3650
5083
|
**Register the same command for the post-execution event too (APRV-145).** One
|
|
3651
5084
|
binary answers two events, dispatched on `hook_event_name`. A `PostToolUse` or
|
|
3652
5085
|
`PostToolUseFailure` run closes the delegated `execution.started` the
|
|
@@ -3679,8 +5112,42 @@ gate.self the "approval" CLI itself is pass-through
|
|
|
3679
5112
|
Bash (Claude Code) and Shell (Cursor) commands are classified into SPEC.md §7
|
|
3680
5113
|
action classes. A `git push` that names `refs/tags/*`, a bare `v`-prefixed
|
|
3681
5114
|
semantic-version-shaped tag, `tag <name>`, `--tags`, or `--follow-tags` is
|
|
3682
|
-
`release.publish`; force and mirror pushes remain `vcs.history.rewrite
|
|
3683
|
-
|
|
5115
|
+
`release.publish`; force and mirror pushes remain `vcs.history.rewrite`; a push
|
|
5116
|
+
that DELETES a remote ref — `--delete`, `-d`, or a colon refspec such as
|
|
5117
|
+
`:refs/heads/x` — is `vcs.ref.delete` with the ref names bound (APRV-352), and a
|
|
5118
|
+
tag deletion keeps `release.publish`; ordinary branch pushes retain their branch
|
|
5119
|
+
or trunk class.
|
|
5120
|
+
|
|
5121
|
+
`git tag` splits on its flags since APRV-397: a LISTING (`-l`, `--list`, `-n`,
|
|
5122
|
+
`-n<num>`, `--contains`, `--points-at`, `--merged`, `--sort`, `--format`,
|
|
5123
|
+
`--color`, `--ignore-case`, `--omit-empty`, or no argument at all) is
|
|
5124
|
+
`read.shell`, rule `git-tag-read`, the same class `git log`, `git status` and
|
|
5125
|
+
`git branch` take for reading local repository metadata. Everything else stays
|
|
5126
|
+
`release.publish`: `-a`, `-d`, `-f`, `-s`, `-m`, a bare tag name, and any flag
|
|
5127
|
+
the listing allowlist does not name, so a future `git tag` option cannot arrive
|
|
5128
|
+
as a read. A positional under `-l` is a pattern rather than a name.
|
|
5129
|
+
|
|
5130
|
+
The packaging and archive tools are classified too, for the same reason and in
|
|
5131
|
+
the same release (APRV-397): they were `unclassified`, so a verification could
|
|
5132
|
+
not run them and reached for `curl` and a script instead. `npm pack` is a
|
|
5133
|
+
workspace write of the tarball, scoped to `--pack-destination`, and
|
|
5134
|
+
`network.call` when a positional names a registry package; `npm init` writes
|
|
5135
|
+
`package.json`; a bare `npm --version`, `-v`, `-V`, `--help` or `-h` is a read,
|
|
5136
|
+
while every npm subcommand the table does not name keeps its `unclassified`
|
|
5137
|
+
deny; `tar -t` lists (a read) and `tar -x`/`-c` writes into `-C` or the archive
|
|
5138
|
+
`-f` names; `gunzip` reads only with `-c`/`--stdout`, `-t`/`--test` or
|
|
5139
|
+
`-l`/`--list` and otherwise replaces the file it names; `base64` reads unless
|
|
5140
|
+
`-o` names an output;
|
|
5141
|
+
`openssl dgst` (and the `md5`/`sha*` spellings) reads unless `-out` names one,
|
|
5142
|
+
while every other `openssl` subcommand stays unclassified. Each write is scoped
|
|
5143
|
+
by the arithmetic `rm` uses: a destination under a resolved scratch root or a
|
|
5144
|
+
relative one is `files.write.workspace`, and an absolute destination elsewhere, a
|
|
5145
|
+
`..` segment, an unreadable value, or a destination flag with nothing readable
|
|
5146
|
+
after it is `files.delete.out_of_scope`, rule `packaging-write-out-of-scope`,
|
|
5147
|
+
with the destination bound. A `tar` whose mode is not in its words is
|
|
5148
|
+
`hook-opaque`. `docs/claude-code-hook.md` holds the table and the reasoning.
|
|
5149
|
+
|
|
5150
|
+
Claude file tools
|
|
3684
5151
|
(Edit, Write, MultiEdit, NotebookEdit) and
|
|
3685
5152
|
Cursor Write/Delete are gated only when the file is policy-protected
|
|
3686
5153
|
(`APPROVAL.md`, `.approval/`, `CLAUDE.md`, `AGENTS.md`, `.claude/settings*`,
|
|
@@ -3698,13 +5165,27 @@ its own flags are not parsed as this verb's.
|
|
|
3698
5165
|
no human is asked. This union's spelling of the gate's `class-human-only`,
|
|
3699
5166
|
which the detail names in full. The opposite repair to `hook-unclassified`:
|
|
3700
5167
|
that one says declare a class, this one says a person runs the command.
|
|
5168
|
+
- `hook-harness-launch-unruled` — some class of the command is in the
|
|
5169
|
+
`harness.launch.*` family and this policy names no rule for it (APRV-354).
|
|
5170
|
+
The family resolves only under an explicit rule, `harness.launch.*` or
|
|
5171
|
+
`harness.launch.NAME`, and never under `defaults.autonomy`, because a grant
|
|
5172
|
+
of the class covers the launch and nothing the launched session then does.
|
|
5173
|
+
Distinct from `hook-unclassified` (the classifier had nothing to say; here it
|
|
5174
|
+
was clear and the policy is silent) and from `hook-class-human-only` (the
|
|
5175
|
+
policy has spoken and reserved the class; the repair there is for a person to
|
|
5176
|
+
run the command, and here it is to write a line).
|
|
3701
5177
|
- `hook-opaque` — a construct whose effect cannot be read from the text
|
|
3702
|
-
(`
|
|
5178
|
+
(`eval`, `xargs`, backticks, a non-read substitution). A login shell around
|
|
5179
|
+
ONE inline script is classified by that script since APRV-380, so
|
|
5180
|
+
`zsh -lc 'git push origin main'` is `vcs.push.main`; a script file, an extra
|
|
5181
|
+
word, a redirection on the wrapper, an assignment prefix and a nested shell
|
|
5182
|
+
all stay opaque.
|
|
3703
5183
|
- `hook-unparseable` — the command line could not be tokenized.
|
|
3704
5184
|
- `hook-rejected` — a human said no.
|
|
3705
5185
|
- `hook-revoked` — a granted approval was withdrawn.
|
|
3706
5186
|
- `hook-expired` — the TTL lapsed before a decision.
|
|
3707
|
-
- `hook-timeout` — no decision inside `--timeout
|
|
5187
|
+
- `hook-timeout` — no decision inside the wait (`--timeout`, clamped to what
|
|
5188
|
+
`--harness-cap` leaves after the 60s margin; APRV-423); the request stays live.
|
|
3708
5189
|
- `hook-gate-refused:<c>` — the gate refused intake; `<c>` is its own frozen code
|
|
3709
5190
|
(`policy-not-attested`, `budget-exceeded`, …).
|
|
3710
5191
|
- `hook-policy-unavailable` — `APPROVAL.md` could not be loaded.
|
|
@@ -3778,8 +5259,10 @@ importer collects the bullets under those headings into a second draft fence,
|
|
|
3778
5259
|
` ```yaml approval-values ` (SPEC.md §5.3), printed after the policy draft on
|
|
3779
5260
|
stdout and written after it with `--out`; `--json` carries it as
|
|
3780
5261
|
`values_draft`, or `null` when no such heading exists. Every bullet lands in
|
|
3781
|
-
`
|
|
3782
|
-
|
|
5262
|
+
`like`, the middle grade, and none in `love` or `dislike`: how strongly a line
|
|
5263
|
+
is meant is the human's to say, and an importer that reached for the strongest
|
|
5264
|
+
grade would be putting words in their mouth. (`like` is where what an operator
|
|
5265
|
+
asks for lives since APRV-336 folded `wants` into it.) A
|
|
3783
5266
|
bullet over the schema's 200 characters is truncated with a warning rather than
|
|
3784
5267
|
dropped, and bullets past the twentieth are kept as comments inside the fence,
|
|
3785
5268
|
which is the same stance the permissions half takes on unmapped bullets. The
|
|
@@ -3911,6 +5394,19 @@ in the file said what the operator wanted the work to be like. The optional
|
|
|
3911
5394
|
` ```yaml approval-values ` block (SPEC.md §5.3) is that, and this verb prints
|
|
3912
5395
|
it.
|
|
3913
5396
|
|
|
5397
|
+
**The keys.** `version`, the quoted string `"0.2"` and the only required one;
|
|
5398
|
+
the standing grades `love`, `like` and `dislike`, printed as `loves:`, `likes:`
|
|
5399
|
+
and `dislikes:`; and `communication`, one sentence or two on how the operator
|
|
5400
|
+
reads and answers, printed under `communication:`. APRV-336 settled that shape:
|
|
5401
|
+
a `wants` list for what the operator asks of an agent folded into `like`, since
|
|
5402
|
+
a request about behaviour and a preference about the output are graded by the
|
|
5403
|
+
same person in the same way and one list is easier to keep true, and `responds`
|
|
5404
|
+
became `communication`, which no longer reads as a sibling of `approval
|
|
5405
|
+
feedback`. A block written to the earlier format (`version: 1`, a `wants` list)
|
|
5406
|
+
is refused with the code `version-unsupported` and a message naming both edits,
|
|
5407
|
+
rather than with a schema violation a reader has to decode. The same code
|
|
5408
|
+
answers `version: 0.2` written without quotes, which YAML reads as a float.
|
|
5409
|
+
|
|
3914
5410
|
**It is guidance, and it is never policy.** Every output form opens with the
|
|
3915
5411
|
banner saying so, `--json` carries the same sentence in `note`, and the reason
|
|
3916
5412
|
is the same discipline `journal read` applies in the opposite direction: a
|
|
@@ -4055,6 +5551,57 @@ defined over the canonical VALUE, so non-JSON input has no defined
|
|
|
4055
5551
|
could reproduce. Empty input is the same answer. A file that exists but cannot be
|
|
4056
5552
|
read is exit 4.
|
|
4057
5553
|
|
|
5554
|
+
## payload run
|
|
5555
|
+
|
|
5556
|
+
The payload `approval run` will recompute for a command, printed before any
|
|
5557
|
+
approval exists: its argv, the cwd it will run in, and the size and SHA-256 of
|
|
5558
|
+
the script that argv names.
|
|
5559
|
+
|
|
5560
|
+
```
|
|
5561
|
+
approval payload run -- bash scripts/install.sh
|
|
5562
|
+
{"argv":["bash","scripts/install.sh"],"cwd":"/repo",
|
|
5563
|
+
"script":{"argv_index":1,"path":"/repo/scripts/install.sh","bytes":412,
|
|
5564
|
+
"sha256":"fef0…"}}
|
|
5565
|
+
```
|
|
5566
|
+
|
|
5567
|
+
The `script` object is the whole point of the verb (APRV-401). A grant over
|
|
5568
|
+
`bash scripts/install.sh` used to bind that STRING, so the bytes behind the path
|
|
5569
|
+
were whatever the file held at execution time and the requester controlled the
|
|
5570
|
+
file between the request and the grant. The digest is inside the hashed value,
|
|
5571
|
+
so a script edited after the declaration is refused `payload-mismatch` before the
|
|
5572
|
+
child is spawned and before anything is appended, and the approver's card carries
|
|
5573
|
+
the path, the byte count and the digest because the payload does.
|
|
5574
|
+
|
|
5575
|
+
The argv names a script in two shapes: a known interpreter (the six shells,
|
|
5576
|
+
`node`, `python`/`python3`, `perl`, `ruby`, `deno` — each a name the command
|
|
5577
|
+
classifier already knows) followed by a path operand, or a path at `argv[0]` the
|
|
5578
|
+
kernel reads a shebang from. An inline program (`bash -c …`, `node -e …`) is
|
|
5579
|
+
already a word of the argv and binds no file.
|
|
5580
|
+
|
|
5581
|
+
An argv naming no readable script carries no `script` key and hashes exactly as
|
|
5582
|
+
it did before the rule existed, which is why every record already in a log and
|
|
5583
|
+
every declaration already written into a task file still verifies.
|
|
5584
|
+
|
|
5585
|
+
`--hash` prints the `payload_hash` instead of the bytes — the value a task file's
|
|
5586
|
+
action declaration carries. `--cwd` states the directory the run will happen in,
|
|
5587
|
+
because that directory is inside the hash and every relative argv word resolves
|
|
5588
|
+
against it. The two ends must agree, so pass the same `--cwd` the run will use.
|
|
5589
|
+
|
|
5590
|
+
What is NOT bound is stated in full in `docs/run-payload-binding.md`: nothing
|
|
5591
|
+
resolved through `PATH`, nothing a bound script itself reads or executes, and the
|
|
5592
|
+
instant between this hash and the spawn.
|
|
5593
|
+
|
|
5594
|
+
This verb reads the one file the argv names as its script and nothing else. No
|
|
5595
|
+
log, no policy, no network, no token, and it executes nothing.
|
|
5596
|
+
|
|
5597
|
+
It is **local only**: the MCP wrapper and `approval serve` withhold it, because
|
|
5598
|
+
the path it digests is one of the command's own words and no transport guard
|
|
5599
|
+
confines those the way the store confinement confines `payload hash`'s
|
|
5600
|
+
positional. A remote caller could otherwise ask for the digest of any file the
|
|
5601
|
+
server process can read (`-- bash /etc/shadow`) and learn that the path exists
|
|
5602
|
+
and what its bytes fingerprint to. Compute the binding where the command will
|
|
5603
|
+
run, which is the only place the value is true.
|
|
5604
|
+
|
|
4058
5605
|
## payload agentmail-draft
|
|
4059
5606
|
|
|
4060
5607
|
An AgentMail draft is mutable server-side state, so an approval of a draft id
|
|
@@ -4191,7 +5738,22 @@ the clock starts when the daemon starts), and at a clean shutdown when records
|
|
|
4191
5738
|
are still owed. Every attempt goes through the gate as `agent:daemon`: the cycle
|
|
4192
5739
|
registers, requests, and proceeds only where the policy lets it, so a
|
|
4193
5740
|
`supervised-live` draw that selects the advance, or a class that resolves
|
|
4194
|
-
`manual`, stops it with nothing committed and the question in the queue.
|
|
5741
|
+
`manual`, stops it with nothing committed and the question in the queue.
|
|
5742
|
+
|
|
5743
|
+
**Which class it asks under, and which actor may advance autonomously
|
|
5744
|
+
(APRV-382).** Two classes, and the running process picks between them.
|
|
5745
|
+
`log.advance.daemon` is the daemon's own, asked only by the cadence advance
|
|
5746
|
+
inside `approval daemon run` and `approval up`; `log.advance` is what every
|
|
5747
|
+
other actor asks under, a session in a worktree and a human terminal alike. A
|
|
5748
|
+
policy may hold the daemon's class `autonomous` (this repository's does, since
|
|
5749
|
+
the advance publishes records the log already holds, appends nothing and decides
|
|
5750
|
+
nothing) while leaving the base class where it was. Nothing an agent can type
|
|
5751
|
+
reaches the looser line: `approval log advance` classifies `log.advance`
|
|
5752
|
+
whoever runs it. The choice is read from the process rather than from any
|
|
5753
|
+
argument, a policy that declares no rule for the daemon's class leaves the
|
|
5754
|
+
cadence gated exactly as it was, and a cycle that is NOT the daemon's whose
|
|
5755
|
+
class resolves `autonomous` is refused `advance-actor-not-daemon` before
|
|
5756
|
+
anything is appended. A gated
|
|
4195
5757
|
or failed attempt is an `advance` line plus an `advance-refused` warning, and the
|
|
4196
5758
|
next tick tries again — the cadence interval is the retry bound, so a refusal
|
|
4197
5759
|
never loops. One records branch and ONE PULL REQUEST PER DAY: the first advance
|
|
@@ -4295,7 +5857,15 @@ says nothing about it.
|
|
|
4295
5857
|
it, and neither is a log to append onto. A working log that is a strict prefix
|
|
4296
5858
|
of the committed copy is an `anchor-behind` warning, not a stop. The `started`
|
|
4297
5859
|
line names the rev and seq this run is held to (`"anchor"`), and the tick line
|
|
4298
|
-
carries the comparison it made. Git is read, never fetched.
|
|
5860
|
+
carries the comparison it made. Git is read, never fetched. When the log was
|
|
5861
|
+
rewritten between the tick's opening read and the check's own read of the file
|
|
5862
|
+
(a `log sync` or a records pull replacing `events.jsonl` under the loop), the
|
|
5863
|
+
check reads the file again through the same verified read, reports one
|
|
5864
|
+
`anchor-reread` warning naming both heads, and the verdict beside it is the
|
|
5865
|
+
second read's (APRV-389). Every `anchor-diverged` stop is reached from the
|
|
5866
|
+
file's bytes and confirmed by that second read, and its message names the seq
|
|
5867
|
+
the chains parted at and both heads without also claiming either chain is a
|
|
5868
|
+
prefix of the other.
|
|
4299
5869
|
- ENVELOPE DRIFT — a task file whose `state:` contradicts the log gets an
|
|
4300
5870
|
`envelope.drift` event (actor `system:daemon`), once per claim.
|
|
4301
5871
|
- TTL SWEEP — every live request whose TTL lapsed gets an `approval.expired`
|
|
@@ -4414,8 +5984,11 @@ Warnings go to stderr as `{"event":"warning","code":"...","message":"..."}`, wit
|
|
|
4414
5984
|
`code` one of `task-unreadable`, `frontmatter-invalid`, `envelope-invalid`,
|
|
4415
5985
|
`task-id-missing`, `tasks-dir-unreadable`, `append-refused`, `expire-refused`,
|
|
4416
5986
|
`render-failed`, `watch-unavailable`, `prune-refused`, `write-back-refused`,
|
|
4417
|
-
`advance-refused`, `
|
|
4418
|
-
|
|
5987
|
+
`advance-refused`, `dark-session-undetermined`, `anchor-behind`,
|
|
5988
|
+
`anchor-reread`, `checkpoint-due`, `draw-unavailable`, `sample-deferred`,
|
|
5989
|
+
`drift-deferred`. A
|
|
5990
|
+
warning never stops the loop, and neither does
|
|
5991
|
+
`{"event":"git_evidence_failed","step":"commit",…}`.
|
|
4419
5992
|
|
|
4420
5993
|
Dangling advance cycles (APRV-264): at startup and before every trigger, the
|
|
4421
5994
|
daemon lists every advance execution nobody closed and closes each one this
|
|
@@ -4440,6 +6013,73 @@ rather than inferred from the queue's backlog. `rendered` is emitted when the qu
|
|
|
4440
6013
|
CHANGES; the file itself is rewritten every tick, because TTL countdowns move even
|
|
4441
6014
|
when the log does not.
|
|
4442
6015
|
|
|
6016
|
+
**A sample that met another writer is deferred, not dropped (APRV-381).** The
|
|
6017
|
+
audit sweep's append is a compare-and-append against the head it read, so it can
|
|
6018
|
+
meet a lock another writer holds (`lock-timeout`) or a record another writer
|
|
6019
|
+
landed in between (`head-moved`). Both are reported as `sample-deferred` on
|
|
6020
|
+
stderr, naming the action key and saying that the sweep retries on the next tick:
|
|
6021
|
+
|
|
6022
|
+
```
|
|
6023
|
+
warning sample-deferred audit sampling deferred hook:6f2a:vcs.push.main
|
|
6024
|
+
(lock-timeout): another writer holds .approval/log/events.jsonl.lock; gave up
|
|
6025
|
+
after 2000ms The sample is NOT lost: it is still pending in the log's own terms
|
|
6026
|
+
(eligible, drawn, no audit.sampled yet), and the sweep retries it on the next
|
|
6027
|
+
tick.
|
|
6028
|
+
audit.sampled: hook:6f2a:vcs.push.main drawn for review (execution.started at
|
|
6029
|
+
seq 51445) — recorded at seq 51447, the retry of the sample this run deferred
|
|
6030
|
+
earlier
|
|
6031
|
+
```
|
|
6032
|
+
|
|
6033
|
+
The second line is the same `sampled` event with `"retry":true` added, and it
|
|
6034
|
+
appears only when this run is the run that deferred the sample: a restarted
|
|
6035
|
+
daemon still makes the append, and says nothing about a promise it did not make.
|
|
6036
|
+
Nothing is remembered across processes because nothing needs to be: the sweep
|
|
6037
|
+
re-derives the pending set (eligible, drawn by the sampler, no `audit.sampled`
|
|
6038
|
+
yet) from the verified log on every tick, so a deferral is lost work only if the
|
|
6039
|
+
daemon never runs again.
|
|
6040
|
+
|
|
6041
|
+
Every other append refusal on a sample keeps the `append-refused` form, because
|
|
6042
|
+
`validation`, `canonicalization`, `corrupt-tail` and `io` are facts about the
|
|
6043
|
+
record or the file that no amount of retrying repairs.
|
|
6044
|
+
|
|
6045
|
+
**A drift record that met another writer is deferred too (APRV-403).** The drift
|
|
6046
|
+
scan's append is the same compare-and-append against the head it read, and it
|
|
6047
|
+
met the same contention: the end-to-end test that holds the append lockfile for
|
|
6048
|
+
a whole tick showed the scan printing `append-refused … (lock-timeout)` for an
|
|
6049
|
+
`envelope.drift` the next tick went on to write. So the same split applies, from
|
|
6050
|
+
the same closed list of transient codes, on both drift reasons:
|
|
6051
|
+
|
|
6052
|
+
```
|
|
6053
|
+
warning drift-deferred envelope.drift for task-042 was deferred (lock-timeout):
|
|
6054
|
+
another writer holds .approval/log/events.jsonl.lock; gave up after 2000ms The
|
|
6055
|
+
record is NOT lost: the disagreement is still there in the log's own terms, and
|
|
6056
|
+
the scan re-derives it and retries on the next tick.
|
|
6057
|
+
envelope.drift: task-042 (backlog/tasks/task-042.md) claims state approved, the
|
|
6058
|
+
log says proposed — recorded at seq 12, the retry of the record this run
|
|
6059
|
+
deferred earlier; the file is repaired to match the log later in this tick
|
|
6060
|
+
```
|
|
6061
|
+
|
|
6062
|
+
The `retry` flag has the same standing as the sample's: it is on the `drift`
|
|
6063
|
+
event only when THIS run is the one that deferred the record, it is output
|
|
6064
|
+
bookkeeping that no append consults, and a restarted daemon makes the same
|
|
6065
|
+
append and claims nothing. Deferring costs nothing because the scan carries no
|
|
6066
|
+
state between ticks: it re-reads the folder, re-derives the state from the
|
|
6067
|
+
verified log, and `driftAlreadyLogged` keeps a repeat idempotent.
|
|
6068
|
+
|
|
6069
|
+
An `envelope.drift` refused for `validation`, `canonicalization`, `corrupt-tail`
|
|
6070
|
+
or `io` keeps the `append-refused` form it always had.
|
|
6071
|
+
|
|
6072
|
+
**The sweep waits `2000ms` for the lock and no longer, by decision.** The daemon
|
|
6073
|
+
is the one writer that could afford a longer wait and deliberately takes the
|
|
6074
|
+
default. A tick is serial, so a blocking wait here delays the expiry lines, the
|
|
6075
|
+
`state:` write-back and `QUEUE.md`; the writers it contends with hold the lock
|
|
6076
|
+
across spans no polite wait covers (`approval log advance` across a whole
|
|
6077
|
+
verify-and-commit, `approval log sync` across a baseline move); and the appends it
|
|
6078
|
+
would be muscling in front of are the ones somebody is waiting on, a hook's gate
|
|
6079
|
+
verdict or a lane's execution record. Deferring costs one line and at most one
|
|
6080
|
+
tick interval, and it is provably lossless. Raise `--interval` only if you want
|
|
6081
|
+
the retry later; there is no flag for the lock wait, on purpose.
|
|
6082
|
+
|
|
4443
6083
|
## up
|
|
4444
6084
|
|
|
4445
6085
|
**Normal startup after setup and human attestation.** Run from the intended
|
|
@@ -4483,6 +6123,46 @@ Stop `up` before running Telegram setup or a standalone listener for its bot.
|
|
|
4483
6123
|
One bot must have one polling runtime. After setup changes, reload the instance
|
|
4484
6124
|
environment before starting `up` again.
|
|
4485
6125
|
|
|
6126
|
+
**Two refusals keep one bot to one instance (APRV-390).** Both are made before
|
|
6127
|
+
the daemon's first tick and before the first poll, and both exit 1 with a
|
|
6128
|
+
machine-readable code on stderr:
|
|
6129
|
+
|
|
6130
|
+
| code | what it means |
|
|
6131
|
+
| --- | --- |
|
|
6132
|
+
| `cross-instance-credential` | A credential variable holds a value this instance did not configure: an `.approval/env` line naming another instance's keystore item, or an export whose value is not what this instance's file resolves to today. The message carries the `unset` line that fixes it. |
|
|
6133
|
+
| `bot-owned-elsewhere` | One `getMe`, before any `getUpdates`, named a bot another instance on this machine has already claimed. The message names that instance's directory. |
|
|
6134
|
+
|
|
6135
|
+
`--allow-cross-instance` overrides both, starts, and prints on every run what it
|
|
6136
|
+
is overriding. Until APRV-390 the first of these was a warning printed on the
|
|
6137
|
+
way past a runtime that had already started, which is how a demo gate spent an
|
|
6138
|
+
evening polling the primary's bot.
|
|
6139
|
+
|
|
6140
|
+
The first refusal COMPARES VALUES rather than only reading names, because it is
|
|
6141
|
+
the one check that is about to use the value. That closes the case names cannot
|
|
6142
|
+
see: `approval env` never overrides a variable this shell has already exported,
|
|
6143
|
+
so a token re-stored in the keystore does not reach a terminal holding the old
|
|
6144
|
+
one, and the daemon answers 401 while the same line read by hand passes `getMe`.
|
|
6145
|
+
It also removes a false positive — an export whose value IS what the file
|
|
6146
|
+
resolves to is correct however it got there, so the documented `eval "$(approval
|
|
6147
|
+
env)"` is not a finding. `approval doctor` keeps the name-only rule; a
|
|
6148
|
+
diagnostic may not block on a keystore-unlock dialog. No value is printed on any
|
|
6149
|
+
path.
|
|
6150
|
+
|
|
6151
|
+
The `getMe` result is recorded against the instance in
|
|
6152
|
+
`.approval/channel-owner.json` (gitignored) and in a per-machine registry under
|
|
6153
|
+
the platform's user state directory, `approval/bots.json`. `APPROVAL_STATE_DIR`
|
|
6154
|
+
relocates that registry. A bot is identified by its id AND its Bot API base,
|
|
6155
|
+
because an id is unique within one deployment and nothing more: two gates
|
|
6156
|
+
pointed at two different `--api-base` values hold two different bots and neither
|
|
6157
|
+
refuses the other. Neither file is evidence, nothing reads them to widen a
|
|
6158
|
+
permission, and losing them costs one `getMe`. An unreachable Bot API is not a
|
|
6159
|
+
refusal: it says so and starts, because a captive portal is also a `getUpdates`
|
|
6160
|
+
that cannot conflict with anything.
|
|
6161
|
+
|
|
6162
|
+
A 409 that still happens at runtime — another machine, or a poller started
|
|
6163
|
+
outside this runtime — is reported once, with the instances the registry knows
|
|
6164
|
+
about, and its repeats are counted rather than reprinted.
|
|
6165
|
+
|
|
4486
6166
|
**The ambient runtime: the daemon loop and every configured channel in one
|
|
4487
6167
|
supervised foreground process.** `approval daemon run --with-channels` is the
|
|
4488
6168
|
same verb spelled from the other side, and it reaches the same function before
|
|
@@ -4505,13 +6185,15 @@ because `git status` does not say what the upstream range changed. So the verb
|
|
|
4505
6185
|
does all four, and `approval daemon run` runs the identical preflight from the
|
|
4506
6186
|
identical module, printing the identical two lines.
|
|
4507
6187
|
|
|
4508
|
-
It is allowed exactly
|
|
6188
|
+
It is allowed exactly four writes: a `--ff-only` merge, `npm run build`,
|
|
4509
6189
|
clearing an untracked `backlog/tasks/` file the incoming commit already contains
|
|
4510
|
-
out of the merge's way (APRV-300, described below)
|
|
6190
|
+
out of the merge's way (APRV-300, described below), and the reconcile `approval
|
|
6191
|
+
log sync` performs on its behalf (APRV-346, described below). It
|
|
4511
6192
|
never resets, never stashes, never checks anything out, and never touches the
|
|
4512
|
-
working log. That list is not caution for its own sake: a working
|
|
4513
|
-
rewound through git underneath a live appender is fork 2 of
|
|
4514
|
-
incident `approval log sync` exists to prevent
|
|
6193
|
+
working log itself. That list is not caution for its own sake: a working
|
|
6194
|
+
`events.jsonl` rewound through git underneath a live appender is fork 2 of
|
|
6195
|
+
2026-08-20, the incident `approval log sync` exists to prevent, which is why the
|
|
6196
|
+
one write that does move that file is made by that verb and by nothing here.
|
|
4515
6197
|
|
|
4516
6198
|
**Safe** means both of: this checkout is not AHEAD of the remote, and no path the
|
|
4517
6199
|
upstream range changes is locally modified. When it is safe, the preflight
|
|
@@ -4521,11 +6203,45 @@ running. When it is not, it refuses, and changes nothing:
|
|
|
4521
6203
|
| code | fires when | next |
|
|
4522
6204
|
|---|---|---|
|
|
4523
6205
|
| `up-preflight-behind-ahead` | `origin/<branch>..HEAD` is non-empty: this checkout carries commits the remote has never seen. A fast-forward is not the operation for that state, and choosing a side is a decision. | look at them (`git log --oneline origin/main..HEAD`), then push them or `git reset --keep` |
|
|
4524
|
-
| `up-preflight-log-diverged` | the upstream range rewrites `.approval/log/events.jsonl` or `.approval/QUEUE.md`,
|
|
6206
|
+
| `up-preflight-log-diverged` | the upstream range rewrites `.approval/log/events.jsonl` or `.approval/QUEUE.md`, this working copy has uncommitted changes to one of them, and the two chains are **not** in a prefix relationship (or cannot be compared at all). The judgment a human could not make by eye. A working log that merely extends the committed one is reconciled instead, see below. | `approval log sync` |
|
|
4525
6207
|
| `up-preflight-dirty-protected` | some other path the upstream range changes is locally modified, so `git merge --ff-only` would refuse rather than overwrite it. | look at the diff, or `approval up --no-preflight` |
|
|
4526
6208
|
| `up-preflight-task-file-conflict` | an untracked file under `backlog/tasks/` stopped the fast-forward and it holds lines the incoming copy does not. Which version is wanted is a question, and no verb here will pick. | read the two copies, move yours aside, run `approval up` again |
|
|
4527
6209
|
| `up-preflight-failed` | a write the preflight attempted did not complete: the fast-forward, or the rebuild. Not a judgment, so it is not in the union above; the message names the step, and for a build it names the exit code `npm run build` came back with. | `npm run build` to see the whole error, or `approval up --no-build` if you mean to run the stale one |
|
|
4528
6210
|
|
|
6211
|
+
**A working log that merely extends the committed one is reconciled, not
|
|
6212
|
+
refused (APRV-346).** Every records advance moves `origin/main`'s
|
|
6213
|
+
`.approval/log/events.jsonl` while the hook keeps appending locally, so
|
|
6214
|
+
"upstream changed the log and so did this working copy" is the normal state of
|
|
6215
|
+
the primary checkout after a merge. Refusing it sent the operator to `approval
|
|
6216
|
+
log sync` and then back to `approval up`, every time. So the collision is a
|
|
6217
|
+
question now: `core/log-reconcile.ts` — the same comparison `log sync` and
|
|
6218
|
+
doctor's `log-drift` row use — is asked how the working chain stands to the
|
|
6219
|
+
committed chain at the fetched tip, and:
|
|
6220
|
+
|
|
6221
|
+
- **`ahead`, `behind` or `equal`** — one chain contains the other whole, so
|
|
6222
|
+
adopting the longer one extends and rewinds nothing. The preflight calls
|
|
6223
|
+
`approval log sync` itself, which holds the append lock for its whole ceremony
|
|
6224
|
+
(snapshot, baseline, fast-forward, reconcile, rebuild the projections,
|
|
6225
|
+
post-verify), and prints one line: `synced: fast-forwarded to origin/main
|
|
6226
|
+
<sha>, kept K local records`. The `--json` stream carries it as a
|
|
6227
|
+
`preflight_sync` event and the `preflight` line's `log_synced` reads true.
|
|
6228
|
+
Nothing here reimplements any of that ceremony; it supplies a caller for it;
|
|
6229
|
+
- **`diverged`** — two appenders built different records on one predecessor.
|
|
6230
|
+
Hash chains do not merge, so this is the `up-preflight-log-diverged` refusal
|
|
6231
|
+
above, unchanged, and it is now the **only** case that needs a hand-run
|
|
6232
|
+
`approval log sync` (which will tell you the same thing, at more length).
|
|
6233
|
+
|
|
6234
|
+
Four conditions have to hold before the reconcile is even offered, and each of
|
|
6235
|
+
them answers "refuse" rather than "probably fine": the log is the repository's
|
|
6236
|
+
own `.approval/log/events.jsonl` (not some other file named with `--log`), no
|
|
6237
|
+
*other* path the upstream range touches is locally modified, both chains verify
|
|
6238
|
+
clean, and the relation is a prefix one. A `log sync` that refuses anyway — an
|
|
6239
|
+
appender that took the lock first (`log-sync-locked`), a fork that landed
|
|
6240
|
+
between the read and the ceremony, a git failure — comes back as the same
|
|
6241
|
+
`up-preflight-log-diverged` refusal with the sync's own code and sentence in
|
|
6242
|
+
YOUR STATE. Nothing starts, and the working log is exactly as `log sync` found
|
|
6243
|
+
it.
|
|
6244
|
+
|
|
4529
6245
|
**An untracked task file no longer stops it (APRV-300).** A lane files
|
|
4530
6246
|
`backlog/tasks/aprv-299` on its branch and its pull request merges, while the
|
|
4531
6247
|
primary checkout holds the same path untracked from its own `backlog task
|
|
@@ -5342,6 +7058,51 @@ disables it whenever the policy names no variable, and this verb does not edit a
|
|
|
5342
7058
|
attested policy file. It prints the block to add and the `approval policy amend`
|
|
5343
7059
|
ceremony that attests it.
|
|
5344
7060
|
|
|
7061
|
+
## setup sender-key
|
|
7062
|
+
|
|
7063
|
+
The operator-held key that turns a channel account id into the value an
|
|
7064
|
+
`approvers[id].senders` mapping carries (APRV-370). It exists for the deployment
|
|
7065
|
+
that PUBLISHES its policy and its log, where the raw account id is disclosed
|
|
7066
|
+
once in the file and then on every decision.
|
|
7067
|
+
|
|
7068
|
+
A plain unkeyed digest would not fix that. A Telegram account id is a ten-digit
|
|
7069
|
+
decimal number, the whole space is enumerable on a laptop, and a digest anybody
|
|
7070
|
+
can reverse states a privacy property it does not have. So the mapping value is
|
|
7071
|
+
`hmac-sha256:<hex>`, computed under this key, which is in the environment and
|
|
7072
|
+
never in the policy file.
|
|
7073
|
+
|
|
7074
|
+
Bare, the verb mints, stores and records the key, exactly as `setup sampling`
|
|
7075
|
+
does with its secret, and edits no policy file. The value is not printed and
|
|
7076
|
+
there is no verb that prints it.
|
|
7077
|
+
|
|
7078
|
+
With `--id <account-id>` it mints nothing and stores nothing. It reads the key
|
|
7079
|
+
from the environment, prints the `hmac-sha256:<hex>` for that account, and
|
|
7080
|
+
prints the `senders` line and a paste-ready proposal pair around it. That is the
|
|
7081
|
+
one thing an agent writing a policy proposal cannot do — the digest depends on a
|
|
7082
|
+
secret only the operator's machine holds — so the proposal page says to run it
|
|
7083
|
+
rather than carrying a placeholder:
|
|
7084
|
+
|
|
7085
|
+
```sh
|
|
7086
|
+
approval setup sender-key # once, interactive, human-only
|
|
7087
|
+
eval "$(approval env)"
|
|
7088
|
+
approval setup sender-key --id 7345216485
|
|
7089
|
+
```
|
|
7090
|
+
|
|
7091
|
+
Because it stores nothing and prints a value designed to be published, `--id` is
|
|
7092
|
+
the one `setup` path that runs without a terminal and accepts `--json`.
|
|
7093
|
+
|
|
7094
|
+
**The key is not an authenticator.** Nothing about the gate's safety rests on
|
|
7095
|
+
its secrecy: somebody who learns it learns which account ids a policy names,
|
|
7096
|
+
which is what the raw form told everybody. What it buys is that the published
|
|
7097
|
+
policy and the published log stop carrying the account.
|
|
7098
|
+
|
|
7099
|
+
**A listener that holds no key under a keyed mapping decides nothing** on that
|
|
7100
|
+
channel. Every tap is refused `sender-key-unavailable`, and there is no fallback
|
|
7101
|
+
to comparing the observed id against the raw entries: without the key the
|
|
7102
|
+
runtime cannot tell whether the account is also claimed by a keyed approver, so
|
|
7103
|
+
it cannot run the ambiguity check the mapping rests on. `approval doctor`'s
|
|
7104
|
+
`sender-mapping` row names the form in use and says whether the key resolves.
|
|
7105
|
+
|
|
5345
7106
|
## setup checkpoint
|
|
5346
7107
|
|
|
5347
7108
|
Mints the Ed25519 keypair a human signs the log's head with (APRV-220's record,
|
|
@@ -5418,6 +7179,14 @@ STARTTLS, AUTH — and then QUIT. It proves the host answers, that the TLS mode
|
|
|
5418
7179
|
the one the server offers, and that the credential is accepted. It does not prove
|
|
5419
7180
|
delivery, and it puts no message on the wire.
|
|
5420
7181
|
|
|
7182
|
+
An `smtp.host` that is an IP address is probed without SNI (APRV-416). A server
|
|
7183
|
+
name is a name, so there is nothing to send for an address, and Node 26 refuses
|
|
7184
|
+
the session outright where earlier versions only warned. The certificate is
|
|
7185
|
+
still verified; what it is verified against is the address, which the server's
|
|
7186
|
+
certificate has to carry as an IP entry in its subject alternative names. A
|
|
7187
|
+
relay whose certificate names only a hostname therefore fails the probe when it
|
|
7188
|
+
is configured by address and passes when it is configured by that name.
|
|
7189
|
+
|
|
5421
7190
|
A failed probe keeps the values. A laptop behind a captive portal is not a reason
|
|
5422
7191
|
to make you type five things again. The refusal prints the SMTP code and the
|
|
5423
7192
|
server's first line, with the credential redacted, and the undo.
|
|
@@ -5551,6 +7320,28 @@ a `human:<id>` and an `agent:` or `system:` actor is refused at exit 2. Exit 1
|
|
|
5551
7320
|
means the far end refused: an invalid token, a 409 from a running listener, or no
|
|
5552
7321
|
message reaching the bot before the deadline.
|
|
5553
7322
|
|
|
7323
|
+
**One bot per instance (APRV-390), and both names are derived.** The keystore
|
|
7324
|
+
item this verb creates is `approval-tg-token-<instance id>`, where the instance
|
|
7325
|
+
id is the eight hex digits `approval doctor` prints in its `keychain-scope` row,
|
|
7326
|
+
so two gates on one machine store two items. An operator who prefers a readable
|
|
7327
|
+
suffix may write their own name into `.approval/env` instead; nothing is
|
|
7328
|
+
migrated and `approval env --check` reports which item each variable resolves
|
|
7329
|
+
through. The variable is whatever the policy's
|
|
7330
|
+
`channels.telegram.token_env` declares; a policy that declares nothing gets
|
|
7331
|
+
`APPROVAL_TG_TOKEN`, which every other silent policy on the machine also gets,
|
|
7332
|
+
so a second gate on one machine declares a pair of its own (the packaged demo
|
|
7333
|
+
policy declares `APPROVAL_DEMO_TG_TOKEN` and `APPROVAL_DEMO_TG_CHAT`).
|
|
7334
|
+
|
|
7335
|
+
The `getMe` that proves the token also says which bot it is, and this verb
|
|
7336
|
+
records that against the instance in `.approval/channel-owner.json` (gitignored)
|
|
7337
|
+
and in a per-machine registry under the platform's user state directory,
|
|
7338
|
+
`approval/bots.json`. A bot another local instance has already claimed is
|
|
7339
|
+
refused before anything is written, naming that instance's directory; the values
|
|
7340
|
+
are open names and never a token. `--allow-cross-instance` records this instance
|
|
7341
|
+
as an owner anyway and says what it is doing. An instance set up before this
|
|
7342
|
+
existed keeps working: nothing is migrated, and the first `approval up` or
|
|
7343
|
+
listener start writes the record from its own `getMe`.
|
|
7344
|
+
|
|
5554
7345
|
## setup service
|
|
5555
7346
|
|
|
5556
7347
|
**It writes one file: the launchd user agent or the systemd user unit that runs
|
|
@@ -5696,6 +7487,442 @@ The guest instructions string says so, tells the caller to poll `status`, and
|
|
|
5696
7487
|
states plainly that a granted request executes nowhere: the demo is the approval
|
|
5697
7488
|
flow itself.
|
|
5698
7489
|
|
|
7490
|
+
## serve
|
|
7491
|
+
|
|
7492
|
+
`approval serve` is the same agent-facing surface as `mcp serve`, over HTTP,
|
|
7493
|
+
for a harness that has no local log and no local policy: a sandboxed tenant
|
|
7494
|
+
whose daemon runs elsewhere, one process per tenant. It is a transport and not
|
|
7495
|
+
a second gate. Every verdict, every verb and every batch of records comes from
|
|
7496
|
+
the function the CLI dispatches to; nothing under `src/serve/` classifies a
|
|
7497
|
+
command, resolves a policy, mints a token or verifies a chain.
|
|
7498
|
+
|
|
7499
|
+
STDOUT IS EMPTY. The bound address, the identity, the daemon id every record
|
|
7500
|
+
it appends carries (see [Which daemon wrote it](#which-daemon-wrote-it-aprv-448))
|
|
7501
|
+
and the store root go to stderr on one started line. SIGINT and SIGTERM close
|
|
7502
|
+
the listener and exit 0.
|
|
7503
|
+
|
|
7504
|
+
### The two credentials
|
|
7505
|
+
|
|
7506
|
+
Two bearer values, both read from the launch environment (SPEC.md §11.1
|
|
7507
|
+
invariant 7: nothing is loaded out of the working tree), both required before
|
|
7508
|
+
the listener exists, and refused when they are equal or shorter than 24
|
|
7509
|
+
characters:
|
|
7510
|
+
|
|
7511
|
+
| variable | opens |
|
|
7512
|
+
|---|---|
|
|
7513
|
+
| `APPROVAL_SERVE_AGENT_TOKEN` | `GET /verbs`, `POST /hook/<harness>`, and the allowlisted verbs below |
|
|
7514
|
+
| `APPROVAL_SERVE_TENANT_TOKEN` | `GET /verbs`, `GET /log/follow`, `GET /export`, `GET /status`, and every other published verb |
|
|
7515
|
+
|
|
7516
|
+
**The agent credential never reads the log it is judged by.** That is the
|
|
7517
|
+
sentence the split exists for. An agent that could stream the verified log
|
|
7518
|
+
could read every decision made about it and the shape of the oversight it is
|
|
7519
|
+
under; an agent that could export the store could carry all of it away.
|
|
7520
|
+
|
|
7521
|
+
Authorization is therefore per verb, and it is an ALLOWLIST. The party at the
|
|
7522
|
+
other end of this transport is a sandboxed harness on another machine, not the
|
|
7523
|
+
operator at their own laptop, so the agent credential opens only what a harness
|
|
7524
|
+
under oversight needs in order to ASK and to ACT ON a grant:
|
|
7525
|
+
|
|
7526
|
+
> `instructions`, `hook_classify`, `request`, `wait`, `withdraw`
|
|
7527
|
+
|
|
7528
|
+
Five verbs, and the shortness is the point: a harness under oversight asks and
|
|
7529
|
+
is answered, and everything else about the gate belongs to the party the gate
|
|
7530
|
+
is for.
|
|
7531
|
+
|
|
7532
|
+
Neither payload builder is on it, under the rule that a builder is the agent's
|
|
7533
|
+
only if it builds from the request's own arguments and never reaches a host
|
|
7534
|
+
resource. `payload_hash` takes a FILE, and on this transport that file is on
|
|
7535
|
+
the daemon's machine while the caller is not: every path it could name is one
|
|
7536
|
+
the host put there, so answering would be a hash oracle that also files the
|
|
7537
|
+
bytes into the payload store. `payload_agentmail-draft` takes two ids rather
|
|
7538
|
+
than a path, but reads the draft over HTTPS with `AGENTMAIL_API_KEY` from this
|
|
7539
|
+
server's own environment, so a remote caller would be spending the host's
|
|
7540
|
+
credential on ids it chose: the same fault wearing a network instead of a
|
|
7541
|
+
filesystem.
|
|
7542
|
+
|
|
7543
|
+
Everything else the catalog publishes is the tenant's. The ones easiest to wave
|
|
7544
|
+
through and most worth naming: `log_tail` and `log_export` return records,
|
|
7545
|
+
`log_verify` and `gate_status` return the log's length, its head hash and the
|
|
7546
|
+
live state of the gate, `queue` returns the tenant's pending decisions,
|
|
7547
|
+
`policy_check` and `policy_test` answer what the policy does with a class and
|
|
7548
|
+
name the absolute store path while doing it, and `status` is the tenant's view
|
|
7549
|
+
of the oversight the agent is under. So are the verbs that touch the host
|
|
7550
|
+
rather than the log: `run` and `sandbox` spawn argv on the daemon's machine,
|
|
7551
|
+
every `adapter_<name>` spends vault credentials, `log_advance` opens pull
|
|
7552
|
+
requests against the tenant's records. And so are `register` (its positional is
|
|
7553
|
+
a host path; the hook endpoint is a harness's way to register, and it
|
|
7554
|
+
synthesises the envelope itself), the two payload builders, `token`,
|
|
7555
|
+
`coverage`, `doctor`, `audit_list`, `audit_obligations`,
|
|
7556
|
+
`channel_telegram_health`, `journal_write`, `journal_read`, `feedback`,
|
|
7557
|
+
`values`, `import_agents-md`, `reindex` and `render`.
|
|
7558
|
+
|
|
7559
|
+
**No caller names a path, and no positional wears a dash.** Four rules, and
|
|
7560
|
+
each closes a route around the others.
|
|
7561
|
+
|
|
7562
|
+
1. The server appends `--dir`, `--log` and `--policy` to every verb call in
|
|
7563
|
+
every scope from its own launch configuration, and a call supplying one of
|
|
7564
|
+
them is refused `serve-path-pinned` even when the value is correct.
|
|
7565
|
+
2. A positional beginning with `-` is refused `serve-positional-flag`, in every
|
|
7566
|
+
scope. Positionals are emitted first and verbatim, and the verb reads any
|
|
7567
|
+
token starting with `-` as a flag — including the `--payload=/path`
|
|
7568
|
+
spelling — so without this rule a refused flag could be sent as a value and
|
|
7569
|
+
reach the parser through the front door. `trailing` needs no such rule: it
|
|
7570
|
+
always follows a `--` separator, and the flag parser stops there, which is
|
|
7571
|
+
what lets `hook classify -- git push -f` classify a command line that has
|
|
7572
|
+
flags in it.
|
|
7573
|
+
3. The AGENT credential may not use a path-typed flag at all
|
|
7574
|
+
(`serve-flag-not-permitted`), and may use only the flags on a short
|
|
7575
|
+
per-verb list. A harness reached through this transport has no files on the
|
|
7576
|
+
daemon's machine, so a path it names is either useless or somebody else's;
|
|
7577
|
+
payload bytes arrive through the hook route, which builds the envelope from
|
|
7578
|
+
the tool call.
|
|
7579
|
+
4. The TENANT credential's path-typed flags are confined to the store
|
|
7580
|
+
(`serve-path-outside-store`), resolved through `realpath` on the deepest
|
|
7581
|
+
existing ancestor so traversal and a symlinked directory both refuse.
|
|
7582
|
+
|
|
7583
|
+
Which flags are path-typed comes from the registry rather than from a list
|
|
7584
|
+
kept beside the server: a flag declared `"path"` publishes as an ordinary
|
|
7585
|
+
`{"type":"string"}`, so the wire contract is unchanged, and it is confined on
|
|
7586
|
+
the day it appears. A test walks every published verb and fails on any flag
|
|
7587
|
+
that is neither declared `"path"` nor reviewed as something else.
|
|
7588
|
+
|
|
7589
|
+
A verb added to the registry tomorrow is published on both surfaces the same
|
|
7590
|
+
day and is TENANT-scoped until somebody decides otherwise. The allowlist is
|
|
7591
|
+
what widens, and widening it is a diff in the server and a diff in a test.
|
|
7592
|
+
|
|
7593
|
+
An agent-credential call to anything off that list is refused
|
|
7594
|
+
`serve-agent-forbidden`, and a tenant-credential call to an allowlisted verb
|
|
7595
|
+
(`request`, `wait`, `withdraw`, `hook_classify`, or `consume`, which this
|
|
7596
|
+
surface withholds entirely) is refused `serve-tenant-forbidden`. Both are scope refusals rather
|
|
7597
|
+
than not-founds: a caller that guessed a path learns that the door exists and
|
|
7598
|
+
is not theirs.
|
|
7599
|
+
|
|
7600
|
+
Every path requires one of the two. There is no unauthenticated surface at all,
|
|
7601
|
+
not a health check and not a 404, so a caller with no credential learns nothing
|
|
7602
|
+
about what exists.
|
|
7603
|
+
|
|
7604
|
+
### The endpoints
|
|
7605
|
+
|
|
7606
|
+
`GET /verbs` is the published catalog: `approval mcp serve`'s tool list, name
|
|
7607
|
+
for name and schema for schema, because it is derived from the same registry
|
|
7608
|
+
filter by the same function. `--as` is absent from every published schema and
|
|
7609
|
+
the launch identity is appended last to every argv, so a caller can neither
|
|
7610
|
+
name an identity nor change one. `grant` is absent, as it is there and for the
|
|
7611
|
+
same reason: SPEC.md §11 makes the agent the untrusted policy, and a tool list
|
|
7612
|
+
is a statement about what a surface is for. Each entry carries the scope that
|
|
7613
|
+
opens it, so the catalog is honest about `status` being the tenant's.
|
|
7614
|
+
|
|
7615
|
+
### The response contract
|
|
7616
|
+
|
|
7617
|
+
`POST /verb/<tool_name>` and `POST /hook/<harness>` answer with ONE body:
|
|
7618
|
+
|
|
7619
|
+
```json
|
|
7620
|
+
{"exit_code": 0, "stdout": "…", "stderr": "…",
|
|
7621
|
+
"stdout_truncated": false, "stderr_truncated": false}
|
|
7622
|
+
```
|
|
7623
|
+
|
|
7624
|
+
`stdout` and `stderr` are exactly what the CLI wrote to each stream, and
|
|
7625
|
+
`exit_code` is exactly what it exited. A client reproduces the invocation by
|
|
7626
|
+
writing `stdout`, writing `stderr`, and exiting `exit_code`. A client that
|
|
7627
|
+
wants the verb's machine-readable refusal parses `stderr`, which is the stream
|
|
7628
|
+
the CLI prints refusals on. Both streams are bounded at 256 KiB and clipped at
|
|
7629
|
+
a codepoint boundary, with a flag saying whether that happened.
|
|
7630
|
+
|
|
7631
|
+
**Nothing about a verdict travels in a header.** It used to: the exit code rode
|
|
7632
|
+
on `x-approval-exit-code`, and server-authored refusals did not set it. A
|
|
7633
|
+
client following the documented rule then read a missing header as zero, and
|
|
7634
|
+
zero is ALLOW on Claude Code, Cursor, Codex and Muse — so a 1.1 MB hook
|
|
7635
|
+
envelope came back as a 413 that meant *proceed*. The exit code is now data in
|
|
7636
|
+
the body, it is present on every response including every refusal, and on a
|
|
7637
|
+
refusal it is always non-zero.
|
|
7638
|
+
|
|
7639
|
+
**A client treats a missing, unparseable or truncated body as a BLOCK.** That
|
|
7640
|
+
rule is not a nicety: a transport failure this server never saw (a proxy, a
|
|
7641
|
+
dropped connection, a process killed mid-response) has no exit code to carry,
|
|
7642
|
+
and the only safe reading of "no answer" from a gate is no.
|
|
7643
|
+
|
|
7644
|
+
`POST /hook/<harness>` takes the harness envelope the stdin form would read, for
|
|
7645
|
+
every harness this runtime speaks a hook protocol for. Its `stdout` is
|
|
7646
|
+
BYTE-FOR-BYTE what `approval hook <harness>` prints for that envelope, because
|
|
7647
|
+
it IS that stdout: the request body is handed to `commandHook` as its stdin and
|
|
7648
|
+
nothing reshapes the answer. The exit code must travel with it, because the
|
|
7649
|
+
dialects disagree about where a block lives — Claude Code, Cursor, Codex and
|
|
7650
|
+
Muse put it in the body at exit 0; Grok Build and Hermes use exit 2. Hermes's
|
|
7651
|
+
ALLOW is `{}` and carries its reason on stderr and nowhere else, which is why
|
|
7652
|
+
`stderr` is part of the contract rather than a diagnostic.
|
|
7653
|
+
|
|
7654
|
+
A refusal the SERVER raises on the hook route — an oversized body, a credential
|
|
7655
|
+
at the wrong door, a method it does not answer — carries `{"error":{…}}` and
|
|
7656
|
+
`exit_code: 2` **and** a `stdout` holding the harness's own block directive,
|
|
7657
|
+
rendered by the same function that prints one. A client that writes `stdout`
|
|
7658
|
+
and exits `exit_code` therefore blocks on both halves of every dialect at once.
|
|
7659
|
+
That holds for the refusals that fire BEFORE the URL is parsed, too — a
|
|
7660
|
+
rotated credential, a malformed `Host` — because the harness is read off the
|
|
7661
|
+
raw request target for the purpose of the refusal body and nothing else. A
|
|
7662
|
+
credential rotation must not read as a permission, and on four of the six
|
|
7663
|
+
dialects a body with no block directive at exit 0 is exactly that.
|
|
7664
|
+
|
|
7665
|
+
Where the harness is not known (an unroutable path, an unrecognised name)
|
|
7666
|
+
`stdout` is absent, and the client rule in the paragraph above is what covers
|
|
7667
|
+
it.
|
|
7668
|
+
|
|
7669
|
+
A deny is a VERDICT, so its HTTP status is 200; only a refusal by the server
|
|
7670
|
+
is not.
|
|
7671
|
+
|
|
7672
|
+
**A Hermes tenant needs `--hook-harness-cap`, or every manual class is refused
|
|
7673
|
+
(APRV-423).** `approval serve` pins `--harness-cap` on every hook call with
|
|
7674
|
+
`--hook-harness-cap`, as it pins `--timeout` with `--hook-timeout`. Without it a
|
|
7675
|
+
`POST /hook/hermes` runs as `approval hook hermes` with no flag, which assumes
|
|
7676
|
+
Hermes's 30s default `plugins.hook_callback_timeout`; 30s does not clear the 60s
|
|
7677
|
+
margin, so every manual-class call answers `hook-harness-cap-too-short` (a
|
|
7678
|
+
block, nothing registered or requested, no prompt sent) and a tenant is gated
|
|
7679
|
+
shut rather than gated. The repair for a serve operator, who does not own the
|
|
7680
|
+
tenant's `$HERMES_HOME`: have the tenant raise `plugins.hook_callback_timeout` in
|
|
7681
|
+
the harness's config above the per-entry `timeout`, then start `serve` with
|
|
7682
|
+
`--hook-harness-cap <the smaller of the two>` (`--hook-harness-cap 300s` for the
|
|
7683
|
+
documented `600` over `300`). The value is per server rather than per tenant, so
|
|
7684
|
+
a server fronting several Hermes tenants states the smallest ceiling any of them
|
|
7685
|
+
runs under; a stated cap can only shorten a window, never lengthen one. The
|
|
7686
|
+
same flag bounds the hook's WAIT on every route: `--hook-timeout` is clamped to
|
|
7687
|
+
what the ceiling leaves after the margin, so a hook that adopts an already-open
|
|
7688
|
+
question under a ceiling with no room in it answers `hook-timeout` at once
|
|
7689
|
+
(question left open for the retry grace) instead of being killed mid-wait.
|
|
7690
|
+
|
|
7691
|
+
`GET /log/follow?from=<seq>&cursor_hash=<64hex>&limit=<n>` answers one page of
|
|
7692
|
+
verified records and the cursor to ask with next time. The cursor is EXCLUSIVE
|
|
7693
|
+
and the subscription is `core/log-subscribe.ts`'s, so SPEC.md §8 holds
|
|
7694
|
+
unchanged: the chain is verified from genesis before anything is emitted, and a
|
|
7695
|
+
`cursor_hash` that does not match the retained prefix is refused with the
|
|
7696
|
+
`integrity` code, `cursor-mismatch` as its reason, and NO RECORDS, not even the
|
|
7697
|
+
ones a partial drain had already produced. `caught_up` says whether the page
|
|
7698
|
+
exhausted the log or the limit. The server keeps no subscription state: a page
|
|
7699
|
+
is a function of the cursor in the request and the bytes on disk, so a host that
|
|
7700
|
+
sleeps and wakes serves the same next page it would have served before.
|
|
7701
|
+
|
|
7702
|
+
**`from` greater than zero REQUIRES `cursor_hash`.** The subscription calls the
|
|
7703
|
+
sequence-only form a weaker bootstrap, weak in exactly one way: it cannot
|
|
7704
|
+
detect a fully recomputed replacement prefix on its first read. A streaming
|
|
7705
|
+
consumer pays that once and then retains the digest it verified. A paged one
|
|
7706
|
+
never does — every request is a first read — so the weakness would be
|
|
7707
|
+
permanent, and a caller who dropped the hash would be served a replaced prefix
|
|
7708
|
+
silently while an honest caller who kept it got the refusal. `from=0` is the
|
|
7709
|
+
only hashless form, because replaying from genesis binds nothing.
|
|
7710
|
+
|
|
7711
|
+
`from` must be a sequence number this runtime can represent and `limit` must be
|
|
7712
|
+
between 1 and 1000. Both are refused `serve-invalid-cursor` when they are not,
|
|
7713
|
+
rather than clamped: a clamp answers a different question than the one asked
|
|
7714
|
+
and the caller cannot tell it happened.
|
|
7715
|
+
|
|
7716
|
+
`GET /export` answers the store as a gzipped POSIX tar. Its contents are a
|
|
7717
|
+
positive allowlist (`APPROVAL.md`, `.approval/log/`, `.approval/payloads/`,
|
|
7718
|
+
`.approval/QUEUE.md`, `.approval/index.sqlite`), so `.approval/keys`,
|
|
7719
|
+
`.approval/env`, `.approval/daemon` and any `vault.enc` are out by not being
|
|
7720
|
+
named rather than by a denylist remembering them. `.approval/payloads/` is in,
|
|
7721
|
+
and it is the entry the reasoning turns on: the log records a `payload_hash`
|
|
7722
|
+
for every action a human was shown, and those bytes live there. An archive
|
|
7723
|
+
carrying the hashes without the bytes would hand a tenant a chain of references
|
|
7724
|
+
to evidence they no longer hold, which is a receipt for an exit rather than an
|
|
7725
|
+
exit.
|
|
7726
|
+
|
|
7727
|
+
**No link is followed, of either kind, and a link refuses the whole export.**
|
|
7728
|
+
A name-based allowlist is no defence against one: `ln -s
|
|
7729
|
+
.approval/keys/sender.key .approval/log/note.jsonl` passes every check that
|
|
7730
|
+
reads the name and hands over the key, and a link out of the store hands over
|
|
7731
|
+
any file on the host.
|
|
7732
|
+
|
|
7733
|
+
So every entry is `lstat`ed and a symbolic link refuses the export with
|
|
7734
|
+
`serve-export-symlink`; every file is then opened `O_NOFOLLOW` and `fstat`ed,
|
|
7735
|
+
so the checks are made against the descriptor that is actually read rather
|
|
7736
|
+
than against a name that could have been swapped in between; and a regular
|
|
7737
|
+
file whose link count is greater than one refuses with
|
|
7738
|
+
`serve-export-hardlink`. That last is the case with nothing to notice — a hard
|
|
7739
|
+
link is a second name for one inode, with no link to refuse to follow and no
|
|
7740
|
+
target to inspect — so the link count is the only thing that distinguishes it.
|
|
7741
|
+
Each refusal names the path and never its target.
|
|
7742
|
+
|
|
7743
|
+
The whole export is refused rather than the offending file skipped, because an
|
|
7744
|
+
archive silently missing a file is one nobody can tell from a complete one.
|
|
7745
|
+
|
|
7746
|
+
The only file the export drops on its own account is the append lockfile
|
|
7747
|
+
derived from the log path, which exists for exactly the span of the copy
|
|
7748
|
+
because the export holds that lock. It is matched as an exact path, not as a
|
|
7749
|
+
`*.lock` class: a tenant's own `.approval/payloads/x.lock` is the tenant's, and
|
|
7750
|
+
a name this runtime happens to use for bookkeeping is no reason to drop
|
|
7751
|
+
somebody else's file.
|
|
7752
|
+
|
|
7753
|
+
`GET /status` is the `status` verb, answered to the tenant credential.
|
|
7754
|
+
|
|
7755
|
+
### The bind, and where TLS is
|
|
7756
|
+
|
|
7757
|
+
`--port <n>` picks the port (default 4682) and always binds `127.0.0.1`.
|
|
7758
|
+
`--listen <[host:]port>` is the only way to bind anything else, passing both is
|
|
7759
|
+
a usage error, and a non-loopback host ALSO requires `--allow-non-loopback`: two
|
|
7760
|
+
flags, because the credentials here are bearer values and a cleartext hop hands
|
|
7761
|
+
them to whoever is on it. A widened bind prints a banner on stderr saying
|
|
7762
|
+
exactly that. This process terminates no TLS and holds no certificate; the
|
|
7763
|
+
supported deployment is a loopback bind behind a proxy the operator owns.
|
|
7764
|
+
|
|
7765
|
+
`--listen unix:<path>` (or `APPROVAL_SERVE_LISTEN=unix:<path>`, which the flag
|
|
7766
|
+
overrides) binds a unix-domain socket instead (APRV-445), for a sandbox on the
|
|
7767
|
+
same machine running as a different uid. The path must be absolute, inside an
|
|
7768
|
+
existing directory OWNED BY THE SERVING UID: that directory is the access
|
|
7769
|
+
control, so a directory somebody else owns is refused at startup (exit 2). The
|
|
7770
|
+
socket itself is opened `0666` once bound, and who may reach it is decided by
|
|
7771
|
+
the directory's mode, group or ACL. An other-writable directory (`/tmp`) is
|
|
7772
|
+
refused, since anyone could swap the socket. The widening to `0666` happens only
|
|
7773
|
+
in a directory nobody but the serving uid can write; in a group-writable one the
|
|
7774
|
+
socket keeps the umask's mode (a notice says so). The residual: between the bind
|
|
7775
|
+
and the chmod the socket carries the umask's narrower mode, which fails closed.
|
|
7776
|
+
A socket file is stale, and replaced, only when a connect to it is refused
|
|
7777
|
+
(`ECONNREFUSED`) or it vanished (`ENOENT`); a live one (another server answers)
|
|
7778
|
+
is refused rather than taken over, one whose probe fails any other way
|
|
7779
|
+
(`EACCES`, a full backlog) is left in place and refused, and anything at the
|
|
7780
|
+
path that is not a socket is refused rather than destroyed. Clients speak plain HTTP over the socket, with the same two
|
|
7781
|
+
bearer credentials. `--allow-non-loopback` has nothing to say about it.
|
|
7782
|
+
|
|
7783
|
+
### Which daemon wrote it (APRV-448)
|
|
7784
|
+
|
|
7785
|
+
Every record this server appends, from a verb call and from a hook call alike,
|
|
7786
|
+
carries the `daemon` field, and the id is the one on the started line
|
|
7787
|
+
(`records stamped daemon <id> (<source>)`). It is resolved once, at startup, by
|
|
7788
|
+
the rule the daemon loop uses: `APPROVAL_DAEMON_ID` from the launch environment
|
|
7789
|
+
when it is set, otherwise `daemon-` plus the instance id derived from the
|
|
7790
|
+
store's `.approval` directory. That is the id `approval status` reports for the
|
|
7791
|
+
same store, so a tenant who reads `daemon` on a record can look it up. No
|
|
7792
|
+
request names any part of it. A declared id that is not a usable id (lowercase
|
|
7793
|
+
letters, digits, `.`, `-`, `_`, at most 64 characters) refuses the server
|
|
7794
|
+
before it binds, exit 2.
|
|
7795
|
+
|
|
7796
|
+
The attested policy's `daemons` list governs this server at the write boundary,
|
|
7797
|
+
exactly as it governs the daemon. It is re-read from the verified log and the
|
|
7798
|
+
attested policy inside the store lock before every verb call and every hook
|
|
7799
|
+
call, so a list a human narrows and re-attests while the server runs refuses
|
|
7800
|
+
the very next call. A refused append writes nothing, and the verb answers
|
|
7801
|
+
`append-failed` with the write boundary's code under `error.append`:
|
|
7802
|
+
|
|
7803
|
+
```json
|
|
7804
|
+
{"ok":false,"error":{"code":"append-failed","append":"daemon-not-allowed",
|
|
7805
|
+
"message":"task.registered could not be appended: this daemon's id is village-goa-1 and the attested policy's `daemons` list admits village-goa-2, …"}}
|
|
7806
|
+
```
|
|
7807
|
+
|
|
7808
|
+
A hook call whose append is refused blocks, in the harness's own dialect.
|
|
7809
|
+
|
|
7810
|
+
The processes that stamp the field are three: the daemon loop (`approval daemon
|
|
7811
|
+
run`, and `approval up`, whose Telegram listener runs in the daemon's process),
|
|
7812
|
+
this server, and [`channel telegram webhook`](#channel-telegram-webhook). A
|
|
7813
|
+
session's own CLI calls, `approval hook <harness>` run as a harness's own
|
|
7814
|
+
process, and a standalone `channel telegram listen` stamp nothing. This server
|
|
7815
|
+
declares the id without marking itself the daemon: the mark is what routes an
|
|
7816
|
+
advance under the daemon's own autonomous class, and a process that dispatches
|
|
7817
|
+
the verbs an agent asks for does not hold it.
|
|
7818
|
+
|
|
7819
|
+
### What it never does
|
|
7820
|
+
|
|
7821
|
+
It appends no record on its own account. Every event in the log under it was
|
|
7822
|
+
written by a verb a caller asked for, under the identity the operator fixed at
|
|
7823
|
+
launch, and stamped with the daemon id on the started line. It reads no
|
|
7824
|
+
`.approval/env`.
|
|
7825
|
+
|
|
7826
|
+
### What it serialises, and what it does not (APRV-427)
|
|
7827
|
+
|
|
7828
|
+
One lock per store, held inside this process. It is the lock for every stretch
|
|
7829
|
+
of work that may append to the store or must read it as one snapshot:
|
|
7830
|
+
|
|
7831
|
+
| work | holds the store lock |
|
|
7832
|
+
|---|---|
|
|
7833
|
+
| `POST /verb/<name>`, including `GET /status` | for the whole call |
|
|
7834
|
+
| `GET /log/follow` | for the page, so no append this process makes lands under a verified read |
|
|
7835
|
+
| `GET /export` | for the whole snapshot, and the log's append lockfile as well, so no other process appends during it either |
|
|
7836
|
+
| `POST /hook/<harness>` | for its MUTATION sections only: everything up to its poll loop (intake, the abandoned-question sweep, register, request) and everything after it (the spend, a withdrawal) |
|
|
7837
|
+
| a hook call's WAIT | never |
|
|
7838
|
+
| `GET /verbs` | never |
|
|
7839
|
+
|
|
7840
|
+
So one hook call waiting on a human holds nothing. While it polls, the tenant's
|
|
7841
|
+
`status`, `queue` and follow answer, and a second hook call opens its own
|
|
7842
|
+
question and waits beside the first. A hook call runs on a worker thread of
|
|
7843
|
+
this process, because a hook's wait is a synchronous sleep and on the
|
|
7844
|
+
listener's own thread it would stop the listener; the verdict, its bytes and
|
|
7845
|
+
its exit code are `approval hook <harness>`'s, exactly as before. A new thread
|
|
7846
|
+
reads the log once BEFORE it asks for the store lock, so the cold walk of a
|
|
7847
|
+
mature log (hundreds of milliseconds) happens in parallel and never inside the
|
|
7848
|
+
lock; its reads under the lock are then warm.
|
|
7849
|
+
|
|
7850
|
+
**The pool is bounded, because each thread holds its own copy of the log**
|
|
7851
|
+
(about 26 MB idle, plus roughly 84 MB once it has read a 73k-record log). At
|
|
7852
|
+
most `--hook-threads` hook calls run at once, 16 by default, and no more
|
|
7853
|
+
threads than that ever exist. Up to `--hook-queue` further calls, 64 by
|
|
7854
|
+
default, wait for a slot in arrival order; a queued call has registered and
|
|
7855
|
+
requested nothing yet. A call that finds every slot and every place in line
|
|
7856
|
+
taken is refused at once with `serve-hook-saturated` (HTTP 503), carrying the
|
|
7857
|
+
harness's own block directive in `stdout` and `exit_code: 2`, and nothing is
|
|
7858
|
+
appended for it. Four finished threads are kept warm for the next call.
|
|
7859
|
+
|
|
7860
|
+
**Time in line is charged to the harness's ceiling.** The ceiling a harness
|
|
7861
|
+
puts on its hook (`--hook-harness-cap`, or the adapter's documented default)
|
|
7862
|
+
runs from the moment the call ARRIVED at this server. A call that waits for a
|
|
7863
|
+
slot or for the store lock spends that budget, and the hook judges, waits by
|
|
7864
|
+
and records on its request the ceiling less that wait, so any question it
|
|
7865
|
+
opens lapses at arrival plus ceiling minus the 60s margin, while the harness
|
|
7866
|
+
is still listening. A call that arrived with room and has less than the
|
|
7867
|
+
margin left when its slot or the lock arrives is refused
|
|
7868
|
+
`serve-hook-saturated` instead, appending nothing: a question opened then
|
|
7869
|
+
would still be on the approver's phone when the harness kills its asker. A
|
|
7870
|
+
ceiling that never had room is the hook's own `hook-harness-cap-too-short`,
|
|
7871
|
+
as before.
|
|
7872
|
+
|
|
7873
|
+
A torn read in a hook's poll (another writer's line caught half-landed, which a
|
|
7874
|
+
filesystem that grows a file a page at a time can show a reader) is read again
|
|
7875
|
+
on the next tick, up to five ticks in a row, before the hook treats the log as
|
|
7876
|
+
unreadable. This holds for the stdin form too, beside the daemon. Verb calls still run one at a time, in the
|
|
7877
|
+
order they arrive, because some of them are synchronous and blocking too (`run`
|
|
7878
|
+
spawns, and `wait` sleeps on the listener's thread, so an agent's `POST
|
|
7879
|
+
/verb/wait` still holds the listener for its own timeout; the hook route is the
|
|
7880
|
+
one a harness waits on).
|
|
7881
|
+
|
|
7882
|
+
Two appends never interleave. Inside this process the store lock is the
|
|
7883
|
+
reason: every append is inside one of the sections above, and the sections run
|
|
7884
|
+
one at a time. Across processes (a daemon, a CLI run beside this server) the
|
|
7885
|
+
reason is the one every `approval` process relies on: each append takes the
|
|
7886
|
+
log's lockfile and compares-and-appends against the head it read.
|
|
7887
|
+
|
|
7888
|
+
**A client that goes away is not answered, and does not spend.** When the HTTP
|
|
7889
|
+
connection of a waiting hook call closes before its answer, the call stops at
|
|
7890
|
+
its next poll tick without spending a grant and without withdrawing its
|
|
7891
|
+
question, and nothing is sent. A grant that lands afterwards is left for the
|
|
7892
|
+
harness's retry of the same command, which carries it and spends it once; an
|
|
7893
|
+
undecided question is adopted by that retry. A call still in line for a
|
|
7894
|
+
thread simply leaves the line. A grant already spent before the connection
|
|
7895
|
+
closed stays spent.
|
|
7896
|
+
|
|
7897
|
+
Closing the listener runs in this order: stop accepting connections; cancel
|
|
7898
|
+
every hook call the same way (waiting calls stop at their next tick, queued
|
|
7899
|
+
ones leave the line); terminate the hook threads from inside the store lock,
|
|
7900
|
+
which also waits out any verb or mutation section still running, so no thread
|
|
7901
|
+
is killed while holding the log's append lockfile; and only then destroy the
|
|
7902
|
+
remaining sockets. A question a cancelled call opened stays open for the retry
|
|
7903
|
+
grace, exactly as for an `approval hook` process that was killed mid-wait.
|
|
7904
|
+
Shutdown therefore takes as long as the longest work already holding the store
|
|
7905
|
+
lock (a `POST /verb/wait` runs to its own timeout), so a platform that kills
|
|
7906
|
+
the process a fixed time after SIGTERM (a container's stop timeout, a
|
|
7907
|
+
supervisor's kill timeout) should allow more than that, or the kill lands on
|
|
7908
|
+
work the lock was protecting. A hook call whose thread fails answers a
|
|
7909
|
+
refusal, `serve-hook-failed`, with the harness's own block directive in
|
|
7910
|
+
`stdout`.
|
|
7911
|
+
|
|
7912
|
+
## muse
|
|
7913
|
+
|
|
7914
|
+
`approval muse` starts a local, single-tenant synthetic consumer facade. The
|
|
7915
|
+
operator fixes the tenant and distinct read/propose credentials in the launch
|
|
7916
|
+
environment and the store root with `--dir` before the listener binds to
|
|
7917
|
+
loopback. The proposal
|
|
7918
|
+
scope can register an in-memory envelope and request its declared action. The
|
|
7919
|
+
read scope sees status, pending requests and the canonical rendering of a live
|
|
7920
|
+
request. Both scopes are narrow: the listener has no grant, reject, execution,
|
|
7921
|
+
token, generic verb or export route. Human decisions use the separately
|
|
7922
|
+
configured Telegram listener. A pending request does not prove delivery to
|
|
7923
|
+
Telegram. See `docs/muse-connector.md` for the local HTTP contract, native
|
|
7924
|
+
evidence limits and prerequisites.
|
|
7925
|
+
|
|
5699
7926
|
## Constrained Codex preparation
|
|
5700
7927
|
|
|
5701
7928
|
approval codex prepare is an artifact generator. It writes one fresh review
|
|
@@ -5710,7 +7937,270 @@ the broker and runner present? POSIX ownership does not establish ACL custody,
|
|
|
5710
7937
|
so this slice executes no manifest-selected binary and reports runtime versions
|
|
5711
7938
|
unchecked. Unknown evidence is a refusal.
|
|
5712
7939
|
|
|
5713
|
-
The first slice deliberately
|
|
7940
|
+
The first slice deliberately made start and serve return codex-not-ready. An
|
|
5714
7941
|
npm install, generated config, or passing bundle check does not create a
|
|
5715
7942
|
mandatory boundary. The whole approval codex family is operator-only and absent
|
|
5716
7943
|
from the ordinary broad MCP catalog.
|
|
7944
|
+
|
|
7945
|
+
### The workspace broker (APRV-325.2)
|
|
7946
|
+
|
|
7947
|
+
approval codex apply is the executable half. It takes an instance manifest and a
|
|
7948
|
+
proposal file, and the split between them is the design: the manifest supplies
|
|
7949
|
+
the acting identity (agent:codex-<instance_id>), the workspace root, the policy
|
|
7950
|
+
file and the log, and the proposal supplies operations and the SHA-256 of the
|
|
7951
|
+
policy it was built against. A proposal naming anything else, an actor, a root,
|
|
7952
|
+
a class, a token, a sandbox posture, is refused input-invalid rather than having
|
|
7953
|
+
the extra key ignored, because a caller that wrote one meant something by it.
|
|
7954
|
+
|
|
7955
|
+
The order of one apply, and why each step is where it is:
|
|
7956
|
+
|
|
7957
|
+
1. Read the log under chain verification, read the policy once, hash those exact
|
|
7958
|
+
bytes, and check them against the latest attestation. An unattested or
|
|
7959
|
+
drifted policy refuses here, before a plan exists.
|
|
7960
|
+
2. Compare the caller's expected digest. A mismatch is attestation-drift: the
|
|
7961
|
+
proposal was built against a policy nobody is enforcing.
|
|
7962
|
+
3. Plan through src/codex/workspace-plan.ts, which does the path work: traversal,
|
|
7963
|
+
symlinks, hardlinks, case aliases, missing sources, existing destinations,
|
|
7964
|
+
preimage digests, and human-only classes refused before any preimage is read.
|
|
7965
|
+
4. Register one action per distinct path class under a task id derived from the
|
|
7966
|
+
payload hash. Classes are never collapsed: the class is what the operator's
|
|
7967
|
+
roster, budget and autonomy are keyed to.
|
|
7968
|
+
5. Authorize every leg. A leg the policy sends to a human refuses
|
|
7969
|
+
approval-required and names the action keys to grant; nothing is written, and
|
|
7970
|
+
the same proposal applies once a person has decided.
|
|
7971
|
+
6. Start every leg before any byte moves. A later leg refusing to start closes
|
|
7972
|
+
the earlier ones execution.failed and leaves the workspace untouched by
|
|
7973
|
+
construction rather than by cleanup.
|
|
7974
|
+
7. Take the workspace lock, then revalidate the plan under it. A revalidation
|
|
7975
|
+
that precedes the lock proves only what was true before another writer could
|
|
7976
|
+
act.
|
|
7977
|
+
8. Stage the new bytes and the preimages on the same filesystem, fsync them,
|
|
7978
|
+
write a journal naming the before-state and the after-state, fsync that, and
|
|
7979
|
+
only then apply.
|
|
7980
|
+
9. Read the workspace back. All-after completes every leg, all-before fails every
|
|
7981
|
+
leg, and anything else, a partial apply, a failed rollback, an endpoint that
|
|
7982
|
+
cannot be read, records execution.indeterminate with the reason
|
|
7983
|
+
workspace-commit-unknown on every leg and retains the journal and the lock.
|
|
7984
|
+
|
|
7985
|
+
approval codex recover reads that retained journal and reports before, after or
|
|
7986
|
+
mixed. It repairs nothing, and the restraint is the point: rolling a mixed
|
|
7987
|
+
workspace forward would guess which half the human approved, and rolling it back
|
|
7988
|
+
would delete the half that committed. Exit 1 means mixed. The resolution is a
|
|
7989
|
+
person's, through approval execution reconcile.
|
|
7990
|
+
|
|
7991
|
+
Custody is claimed only as far as the platform proves it. The broker takes an
|
|
7992
|
+
O_CREAT|O_EXCL lock, which excludes other cooperating brokers and nothing else,
|
|
7993
|
+
and then asks POSIX ownership and mode whether any other principal can write the
|
|
7994
|
+
directories it is about to touch. It reports os-exclusive only when both hold and
|
|
7995
|
+
advisory otherwise, and it always reports acl-unproven, because ownership and
|
|
7996
|
+
mode say nothing about ACLs. --require-exclusive-custody turns the weak answer
|
|
7997
|
+
into a refusal rather than a footnote.
|
|
7998
|
+
|
|
7999
|
+
approval codex serve publishes that broker over stdio as exactly one MCP tool,
|
|
8000
|
+
codex_workspace_apply, checked at call time as well as at list time. It is a
|
|
8001
|
+
different server from approval mcp serve, whose catalog is the whole verb
|
|
8002
|
+
registry and therefore grows: a constrained session has to reach one door, and
|
|
8003
|
+
the same door next month.
|
|
8004
|
+
|
|
8005
|
+
### The confined session (APRV-325.3)
|
|
8006
|
+
|
|
8007
|
+
approval codex start prepares the room a session's shell runs in. With no
|
|
8008
|
+
-- <command> it reports the room and runs nothing, which is what an operator
|
|
8009
|
+
checking a host should not have to start Codex to learn. With one, it runs that
|
|
8010
|
+
command inside the room and exits with the child's own code.
|
|
8011
|
+
|
|
8012
|
+
The shell gets a disposable workspace under the system temporary directory, and
|
|
8013
|
+
that workspace is the only path it may write. The canonical workspace is
|
|
8014
|
+
readable and never writable. Reads are jailed to exactly those two roots
|
|
8015
|
+
(APRV-347), so the gate home, other repositories and everything else the
|
|
8016
|
+
operator's home holds are unreadable whether or not anyone thought to name them,
|
|
8017
|
+
and the credential denials are emitted after the jail's allows so they remain
|
|
8018
|
+
the last word on the vault, the environment map and the sealing keys. The
|
|
8019
|
+
environment is an allow-list rather than a filtered copy of the operator's, so a
|
|
8020
|
+
provider key nobody taught this runtime about is absent rather than forgotten.
|
|
8021
|
+
Outbound network is denied, loopback included. Descendants inherit all of it,
|
|
8022
|
+
which is the property that matters: a session does not write files by calling
|
|
8023
|
+
into this runtime, it spawns shells that do.
|
|
8024
|
+
|
|
8025
|
+
There is no opt-out flag and no unwrapped fallback. approval run has
|
|
8026
|
+
--no-sandbox because a human's grant over exact bytes is authority to reach the
|
|
8027
|
+
world; a confined session has no such authority to present, so a host with no
|
|
8028
|
+
sandbox mechanism refuses (sandbox-unsupported) where approval run would record
|
|
8029
|
+
unsupported and proceed. A command that cannot be resolved on the session PATH is
|
|
8030
|
+
refused rather than spawned outside the room.
|
|
8031
|
+
|
|
8032
|
+
The disposable workspace is removed when the session ends, so a replay of the
|
|
8033
|
+
same shell work starts from an empty room. Only a brokered change survives it,
|
|
8034
|
+
which is the whole arrangement: the shell cannot reach the canonical workspace,
|
|
8035
|
+
and approval codex apply is how a change that a policy admitted does.
|
|
8036
|
+
|
|
8037
|
+
### The app-server bridge (APRV-361)
|
|
8038
|
+
|
|
8039
|
+
```
|
|
8040
|
+
approval codex bridge --prompt <text> [--workspace <dir>] [-- <server command>]
|
|
8041
|
+
```
|
|
8042
|
+
|
|
8043
|
+
approval codex bridge starts `codex app-server` and answers every approval
|
|
8044
|
+
request it raises through the policy and the log. Each
|
|
8045
|
+
`item/commandExecution/requestApproval` carries `command` and `cwd` on the same
|
|
8046
|
+
frame, minted by the harness runtime, and those two fields are exactly the pair
|
|
8047
|
+
the native hook lacks: a Codex `Bash` pre-event names only the command, so
|
|
8048
|
+
`approval hook codex` refuses every shell call as
|
|
8049
|
+
`hook-unsupported-execution-context` rather than bind bytes whose directory it
|
|
8050
|
+
does not know. Here the directory arrives with the question.
|
|
8051
|
+
|
|
8052
|
+
It reuses the hook's decision path rather than a copy of it. The request becomes
|
|
8053
|
+
the hook's own input (tool `Bash`, `tool_input.command` the string the server
|
|
8054
|
+
sent, `cwd` the directory it named) and goes through the same classifier, the
|
|
8055
|
+
same human-only refusal, the same sandbox requirement, the same loop floor and
|
|
8056
|
+
unattended guard, and the same register, request and wait against the verified
|
|
8057
|
+
view. What differs is where the answer goes: `{id, result: {decision}}` on the
|
|
8058
|
+
connection instead of a decision object on stdout.
|
|
8059
|
+
|
|
8060
|
+
The deadline is the policy's `approval_ttl`, not a harness ceiling. Every hook
|
|
8061
|
+
adapter answers inside a timeout its harness sets, and the retry grace exists so
|
|
8062
|
+
a denial-by-deadline is recoverable; this transport has no timeout at all, so a
|
|
8063
|
+
human who answers in eleven minutes is answering rather than arriving too late.
|
|
8064
|
+
`--wait` overrides it. The bridge therefore states no `--harness-cap` and the
|
|
8065
|
+
window shortening of APRV-423 never applies to it: there is no process here that
|
|
8066
|
+
something else will kill, so there is nothing for a cap to keep ahead of.
|
|
8067
|
+
|
|
8068
|
+
It answers accept or decline only, in the vocabulary the request advertised
|
|
8069
|
+
through `availableDecisions`, matched exactly and never by prefix, so
|
|
8070
|
+
`acceptWithExecpolicyAmendment` is not read as an accept. It never sends
|
|
8071
|
+
`acceptForSession` (standing authority for a whole session is a grant shape this
|
|
8072
|
+
project does not have), `cancel` or `abort` (those mean "stop the turn", and
|
|
8073
|
+
sending one would record an interruption as a denial). A request advertising
|
|
8074
|
+
nothing gets `accept` or `decline` and the report says the word was this
|
|
8075
|
+
runtime's own.
|
|
8076
|
+
|
|
8077
|
+
The vocabulary is eight spellings of those two words (`accept`, `approved`,
|
|
8078
|
+
`approve`, `allow`; `decline`, `denied`, `deny`, `reject`), and since APRV-367
|
|
8079
|
+
it is a type rather than a convention: the reply value cannot be constructed
|
|
8080
|
+
outside the list, one function turns a decision into bytes and re-checks
|
|
8081
|
+
membership there, and a word that somehow failed that check would be sent as a
|
|
8082
|
+
decline, since the only safe substitute for a word you cannot name is no. The
|
|
8083
|
+
match is case-insensitive, and what goes on the wire is this runtime's own
|
|
8084
|
+
spelling of the matched word. The `bridge-decisions` conformance suite pins the
|
|
8085
|
+
behaviour for a second implementation.
|
|
8086
|
+
|
|
8087
|
+
Its own refusals, beside the gate's:
|
|
8088
|
+
|
|
8089
|
+
```
|
|
8090
|
+
bridge-request-unbound no command, no cwd, or no call identity on the request
|
|
8091
|
+
bridge-command-unbound a command string that names no argv this client can bind (APRV-362)
|
|
8092
|
+
bridge-file-change-unbound an item-based file change whose content this client cannot
|
|
8093
|
+
produce from the item/started frame its item id names: no such
|
|
8094
|
+
frame, an item that is not a fileChange, an empty change set, or
|
|
8095
|
+
a frame belonging to another thread or turn (APRV-379)
|
|
8096
|
+
bridge-file-change-already-completed
|
|
8097
|
+
item/completed for that item arrived before the question, so the
|
|
8098
|
+
change had finished before this client was asked (APRV-379)
|
|
8099
|
+
bridge-unknown-request a server request this client has no reading for
|
|
8100
|
+
```
|
|
8101
|
+
|
|
8102
|
+
The **item-based file change is correlated, not guessed** (APRV-379). That API
|
|
8103
|
+
puts the content on an earlier `item/started` notification and the approval
|
|
8104
|
+
request refers to it by `itemId`, so the bridge keeps every item the thread
|
|
8105
|
+
announces and decides the request against the frame that id names: the paths
|
|
8106
|
+
take their classes, the payload binds the change set verbatim with
|
|
8107
|
+
`content_sha256` over it as received, and nothing parses the `diff`. The request
|
|
8108
|
+
carries no directory, so the paths resolve against the workspace the bridge
|
|
8109
|
+
named on `thread/start`, and one landing outside it is refused `hook-io`.
|
|
8110
|
+
|
|
8111
|
+
The **exec request binds words, not a rendering** (APRV-362). The item-based API
|
|
8112
|
+
delivers the command as one string, joined from the argv Codex will run, so the
|
|
8113
|
+
bridge un-joins it and the registered payload carries the string that arrived
|
|
8114
|
+
and the argv beside it. A string that is not readable as a join (an unterminated
|
|
8115
|
+
quote, a double quote outside a quoted run, a trailing backslash, or separation
|
|
8116
|
+
no join produces) is `bridge-command-unbound`, because approving it would
|
|
8117
|
+
approve this client's own re-parse. Byte equality with a re-rendering is
|
|
8118
|
+
deliberately not required: a join written for shell safety quotes more than this
|
|
8119
|
+
one does, and demanding equality would refuse ordinary traffic over a quoting
|
|
8120
|
+
rule nothing here records. A legacy argv array is rendered word by word rather
|
|
8121
|
+
than concatenated, so `["bash", "-lc", "rm -rf build"]` reaches the classifier
|
|
8122
|
+
as `bash -lc 'rm -rf build'` and not as five separate words.
|
|
8123
|
+
|
|
8124
|
+
A **legacy `applyPatchApproval` is decided rather than declined** (APRV-363).
|
|
8125
|
+
Its `fileChanges` map rides on the request, so there is nothing to correlate and
|
|
8126
|
+
nothing is re-rendered: the change is classified by the paths it names, through
|
|
8127
|
+
the same protected-path rules every other file tool uses, and the registered
|
|
8128
|
+
payload carries those paths, the change verbatim and `content_sha256` over the
|
|
8129
|
+
map as it arrived. A path that is absolute, or that resolves outside the
|
|
8130
|
+
directory the server named, is refused `hook-io`; a request with a map and no
|
|
8131
|
+
directory (`cwd` or `grantRoot`) is `bridge-request-unbound`. The item-based
|
|
8132
|
+
`item/fileChange/requestApproval`, which carries an identifier and no content,
|
|
8133
|
+
stays declined.
|
|
8134
|
+
|
|
8135
|
+
The thread is started with `approvalPolicy: untrusted` and `sandbox: read-only`.
|
|
8136
|
+
`untrusted` is the wire spelling of the source's `UnlessTrusted`, the only
|
|
8137
|
+
variant under which every command asks, and the server refuses the source name.
|
|
8138
|
+
There is no flag for it: a session gating an unknown fraction of itself is what
|
|
8139
|
+
the pin exists to prevent (APRV-366).
|
|
8140
|
+
|
|
8141
|
+
The pin is checked as well as sent, and a failure ends the run rather than
|
|
8142
|
+
declining one request:
|
|
8143
|
+
|
|
8144
|
+
```
|
|
8145
|
+
bridge-thread-start-refused the server refused thread/start, so no thread
|
|
8146
|
+
exists and no policy was established; its own
|
|
8147
|
+
error is carried verbatim
|
|
8148
|
+
bridge-approval-policy-mismatch the server reported an effective approval
|
|
8149
|
+
policy that is not untrusted
|
|
8150
|
+
```
|
|
8151
|
+
|
|
8152
|
+
A server that reports no policy at all is run against, because the observed
|
|
8153
|
+
0.155.0 server echoes none and a client demanding an echo could not start. The
|
|
8154
|
+
report says which case it was: `thread.requested` is what went on the wire,
|
|
8155
|
+
`thread.effective` is what the server said, and `thread.confirmed` names where
|
|
8156
|
+
the claim comes from: `unconfirmed`, `reported` (a frame echoed the pin back)
|
|
8157
|
+
or `observed` (a probe command produced an approval request that reached this
|
|
8158
|
+
client). Both stops exit 4, as every other protocol stop in this verb does; the
|
|
8159
|
+
code in the report is the part to branch on.
|
|
8160
|
+
|
|
8161
|
+
**A preflight probe runs before every turn** (APRV-364). Codex's auto-reviewer
|
|
8162
|
+
can resolve an approval with a model call before this client is asked, and
|
|
8163
|
+
nothing in the protocol reports whether it is running, so the bridge watches one
|
|
8164
|
+
command instead of reading a setting. Each start asks a preflight turn for
|
|
8165
|
+
`true` and nothing else, before the operator's prompt, with no flag to skip it;
|
|
8166
|
+
it costs one turn per start. The probe's own request is declined immediately as
|
|
8167
|
+
an observation and never reaches the gate, so nothing is registered for it and
|
|
8168
|
+
no approver is asked about it.
|
|
8169
|
+
|
|
8170
|
+
```
|
|
8171
|
+
bridge-preflight-void the preflight turn ran no command at all, so
|
|
8172
|
+
nothing was established; the report carries
|
|
8173
|
+
the turn's frames verbatim and nothing is
|
|
8174
|
+
retried. Run the verb again
|
|
8175
|
+
bridge-auto-reviewer-active an item/autoApprovalReview notification
|
|
8176
|
+
arrived in either turn: something other than
|
|
8177
|
+
this client answered a question
|
|
8178
|
+
```
|
|
8179
|
+
|
|
8180
|
+
A probe that RAN without asking is `bridge-approval-policy-mismatch`, because a
|
|
8181
|
+
policy under which one command did not ask is not `untrusted` whatever the
|
|
8182
|
+
server reports about itself. A probe that was asked about lets the real turn
|
|
8183
|
+
run, and `thread.confirmed` becomes `observed`. That word is narrow on purpose:
|
|
8184
|
+
it says one question reached this client unanswered by anything else, and it is
|
|
8185
|
+
not a claim that the auto-reviewer is off. The report's `preflight` block
|
|
8186
|
+
carries the turn id, the command, the outcome (`asked`, `executed`, `void` or
|
|
8187
|
+
`pending`), the word sent, and, on a void, the frames.
|
|
8188
|
+
|
|
8189
|
+
**It is an advisory checkpoint and not a boundary**, for reasons
|
|
8190
|
+
docs/codex-app-server-bridge.md states in full: Codex's auto-reviewer can
|
|
8191
|
+
resolve a question before this client sees it, and the approval policy and
|
|
8192
|
+
sandbox posture decide how many questions exist. An open gate window is not
|
|
8193
|
+
honoured here either, which is the strict direction. The claim it supports is
|
|
8194
|
+
"this client decided every question this app-server child asked in this
|
|
8195
|
+
session", and nothing wider.
|
|
8196
|
+
|
|
8197
|
+
**Custody is the operating system's** (APRV-365). The server is started by this
|
|
8198
|
+
verb as its own child over stdio pipes: there is no socket, nothing binds a
|
|
8199
|
+
path, and no other process holds a descriptor to speak on, so the replay of a
|
|
8200
|
+
pending request to whatever connects next cannot arise inside one run. The
|
|
8201
|
+
scope of the claim is that child and that session; a Codex started outside this
|
|
8202
|
+
arrangement is a different process and nothing here observes it. The verb does
|
|
8203
|
+
not inspect the command after `--` for a shape that would attach to something
|
|
8204
|
+
already running instead: that would be a guess at another program's command
|
|
8205
|
+
line, and a check written against a guessed shape finds nothing while reporting
|
|
8206
|
+
that it looked.
|