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/dist/src/core/gate.js
CHANGED
|
@@ -37,9 +37,12 @@
|
|
|
37
37
|
*
|
|
38
38
|
* ## Lazy expiry — the named requirement
|
|
39
39
|
*
|
|
40
|
-
* A request expires when `ts > requestTs +
|
|
41
|
-
* not** an `approval.expired` event exists.
|
|
42
|
-
*
|
|
40
|
+
* A request expires when `ts > requestTs + effective window`, **whether or
|
|
41
|
+
* not** an `approval.expired` event exists. The effective window is
|
|
42
|
+
* `defaults.approval_ttl`, narrowed since APRV-423 by whatever harness ceiling
|
|
43
|
+
* the requesting hook declared on `payload.harness_cap_ms` (a self-reported
|
|
44
|
+
* bound that can only shorten; see `core/harness-wait.ts`). Nothing may depend
|
|
45
|
+
* on a daemon having run: if the expiry sweep is asleep, a late grant must still be refused.
|
|
43
46
|
* {@link requestState} therefore computes expiry two ways — from the event, and
|
|
44
47
|
* lazily from the arithmetic — and treats them as equivalent.
|
|
45
48
|
*
|
|
@@ -124,7 +127,7 @@ import { normalizeUsd, usdOrZero } from "./money.js";
|
|
|
124
127
|
import { isPayloadHash, payloadHash as hashOfPayload } from "./payload.js";
|
|
125
128
|
import { loadPayload, payloadPath, payloadStoreDirFor, storePayload } from "./payload-store.js";
|
|
126
129
|
import { loadPolicyText, policyUnreadable, POLICY_FILENAMES, tokenDeliveryOf, } from "./policy-load.js";
|
|
127
|
-
import { humanOnlyRefusal, resolve } from "./policy-match.js";
|
|
130
|
+
import { agentRequestability, harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, humanOnlyRefusal, resolve, } from "./policy-match.js";
|
|
128
131
|
import { DRAW_PROTOCOL_VERSION, askDaemonDraw, } from "./live-draw.js";
|
|
129
132
|
import { LIVE_SELECTION, resolveLiveSelector, } from "./sampler.js";
|
|
130
133
|
import { forgetPrivateKey, isRecipientKey, keyStoreDirFor, mintRecipientKeypair, RECIPIENT_KEY_FIELD, sealToken, SEALED_TOKEN_FIELD, SELF_DELIVERY_FIELD, writePrivateKey, } from "./seal.js";
|
|
@@ -347,6 +350,27 @@ export const GATE_REFUSAL_CODES = [
|
|
|
347
350
|
* decide it, under which policy hash, or against which budget.
|
|
348
351
|
*/
|
|
349
352
|
"class-human-only",
|
|
353
|
+
/**
|
|
354
|
+
* A `harness.launch.*` class that no rule of this policy names (APRV-354).
|
|
355
|
+
*
|
|
356
|
+
* The gate's half of the hook's `hook-harness-launch-unruled`, and it exists
|
|
357
|
+
* so there is no second door. The hook refuses a harness launch it classified
|
|
358
|
+
* from a command line; this refuses one a caller DECLARES, through
|
|
359
|
+
* `approval register` and `approval request`, which is the other way an
|
|
360
|
+
* action class reaches the gate.
|
|
361
|
+
*
|
|
362
|
+
* SPEC.md §7: the family resolves only under an explicit rule, never under
|
|
363
|
+
* `defaults.autonomy`. What a grant of it covers is the launch and nothing
|
|
364
|
+
* the launched session then does, so a class arriving by upgrade rather than
|
|
365
|
+
* by an operator's decision would be a capability nobody chose.
|
|
366
|
+
*
|
|
367
|
+
* Evaluated in the same position as `class-human-only` and immediately above
|
|
368
|
+
* it: both are the policy answering before any question about registration,
|
|
369
|
+
* budget or approver, and this one is the narrower statement of the two — not
|
|
370
|
+
* "reserved to human hands" but "not spoken about at all". Nothing is
|
|
371
|
+
* appended, so no `approval.requested` exists for a class no rule governs.
|
|
372
|
+
*/
|
|
373
|
+
"harness-launch-unruled",
|
|
350
374
|
/**
|
|
351
375
|
* Loop safety escalated the task to manual (SPEC.md §10.2, APRV-18): three
|
|
352
376
|
* consecutive `execution.failed` events. Only the non-manual paths are
|
|
@@ -512,6 +536,50 @@ export const GATE_REFUSAL_CODES = [
|
|
|
512
536
|
* nothing can ever open. Nothing is appended; the next attempt asks again.
|
|
513
537
|
*/
|
|
514
538
|
"token-delivery-unavailable",
|
|
539
|
+
/**
|
|
540
|
+
* `approval propose` named a class the policy has not opened to agents
|
|
541
|
+
* (APRV-445, amended SPEC.md §5.2): the class is not an exact key of
|
|
542
|
+
* `policy.classes`, or neither that key nor a declared `<prefix>.*` family of
|
|
543
|
+
* it sets `agent_may_request: true`. Nothing is registered and nothing is
|
|
544
|
+
* appended. The repair is the operator's, a policy edit and a re-attestation;
|
|
545
|
+
* no retry by the agent changes it.
|
|
546
|
+
*/
|
|
547
|
+
"class-not-agent-requestable",
|
|
548
|
+
/**
|
|
549
|
+
* `approval propose` / `approval start` carried a payload larger than
|
|
550
|
+
* {@link PROPOSE_PAYLOAD_MAX_BYTES} (APRV-445). Refused before the bytes are
|
|
551
|
+
* parsed or hashed; nothing is stored and nothing is appended.
|
|
552
|
+
*/
|
|
553
|
+
"payload-too-large",
|
|
554
|
+
/**
|
|
555
|
+
* `approval request` (or a task FILE's registration) named a `propose:` task
|
|
556
|
+
* (APRV-445 refutation, S1). A proposal is requested only through `propose`,
|
|
557
|
+
* which re-checks the operator's opening of its class; a plain request on the
|
|
558
|
+
* same task would skip that check, and a file-registered `propose:` id would
|
|
559
|
+
* borrow the exclusions proposals get from the harness hook.
|
|
560
|
+
*/
|
|
561
|
+
"task-is-proposal",
|
|
562
|
+
/**
|
|
563
|
+
* `approval start` named a task `propose` did not register (APRV-445, L9).
|
|
564
|
+
* `start` records executions of proposals and of nothing else; every other
|
|
565
|
+
* action executes through `approval run`, an adapter, or the hook.
|
|
566
|
+
*/
|
|
567
|
+
"task-not-proposal",
|
|
568
|
+
/**
|
|
569
|
+
* `approval propose --key` does not begin with `<class>:` (APRV-445, S4). The
|
|
570
|
+
* key names the class it was proposed under, so a key cannot be read back
|
|
571
|
+
* later as belonging to a different class than its record says.
|
|
572
|
+
*/
|
|
573
|
+
"key-class-mismatch",
|
|
574
|
+
/**
|
|
575
|
+
* `approval propose` found the log moving under it on every one of its
|
|
576
|
+
* bounded attempts (APRV-445 recheck 3): each re-read showed an answer the
|
|
577
|
+
* next append was refused for. Exit 1, nothing more was appended, and the
|
|
578
|
+
* proposal's standing is whatever the log now says; read it with `wait
|
|
579
|
+
* --timeout 0` or call again. Distinct from `already-decided`, which would
|
|
580
|
+
* tell a caller a proposal is answered when it may stand granted or pending.
|
|
581
|
+
*/
|
|
582
|
+
"contended",
|
|
515
583
|
];
|
|
516
584
|
function refuse(code, message, extra = {}) {
|
|
517
585
|
return { ok: false, code, message, ...extra };
|
|
@@ -589,6 +657,28 @@ function parsePolicy(read, options) {
|
|
|
589
657
|
}
|
|
590
658
|
return loadPolicyText(read.path, Buffer.from(read.bytes).toString("utf8"), options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
|
|
591
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* The policy as a gate operation would load it: one read through the seam of
|
|
662
|
+
* {@link GateOptions.policy}, parsed, failing closed (APRV-324).
|
|
663
|
+
*
|
|
664
|
+
* Exported for one caller, `channels/contract.ts`, which resolves an
|
|
665
|
+
* authenticated sender to an approver identity BEFORE it calls {@link decide}
|
|
666
|
+
* and must do so against the same file, discovered the same way, through the
|
|
667
|
+
* same injectable reader. Writing a second `loadPolicy` call there would have
|
|
668
|
+
* been a second discovery path that could find a different file.
|
|
669
|
+
*
|
|
670
|
+
* It is still a SECOND read of the bytes, one operation earlier, and that is
|
|
671
|
+
* deliberate rather than overlooked: `decide` reads once per attempt on purpose
|
|
672
|
+
* (see {@link PolicyRead}), and pinning bytes across its bounded retry would
|
|
673
|
+
* make a re-attestation inside the retry window invisible to the drift check.
|
|
674
|
+
* What closes the gap is the check itself — a policy that changes between the
|
|
675
|
+
* two reads is either unattested or a different hash from the one the request
|
|
676
|
+
* pinned, and `decide` refuses `policy-not-attested` or `policy-drift` rather
|
|
677
|
+
* than authorizing under bytes the resolution never saw.
|
|
678
|
+
*/
|
|
679
|
+
export function readGatePolicy(options) {
|
|
680
|
+
return parsePolicy(readPolicyOnce(options), options);
|
|
681
|
+
}
|
|
592
682
|
function appendOptionsOf(options) {
|
|
593
683
|
const append = { ...options.append };
|
|
594
684
|
if (options.schemaDir !== undefined)
|
|
@@ -622,6 +712,21 @@ function requireAttestation(records, read) {
|
|
|
622
712
|
function ttlOf(load) {
|
|
623
713
|
return load.ok ? load.durations.approvalTtlMs : null;
|
|
624
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* The clause a refusal adds when a harness ceiling, rather than the policy,
|
|
717
|
+
* fixed this request's deadline (APRV-423).
|
|
718
|
+
*
|
|
719
|
+
* Empty for every request that declared no cap, which is every request made
|
|
720
|
+
* outside a harness hook — so the messages a human has been reading since v0.1
|
|
721
|
+
* are unchanged, and the one new sentence appears exactly where it explains
|
|
722
|
+
* something the policy alone does not.
|
|
723
|
+
*/
|
|
724
|
+
function cappedBy(derivation) {
|
|
725
|
+
const cap = derivation.declared.harness_cap_ms;
|
|
726
|
+
if (cap === null)
|
|
727
|
+
return "";
|
|
728
|
+
return ` (shortened from the policy's own TTL: the requesting hook declared a ${String(cap)}ms harness ceiling, and the window ends a margin before it so the expiry is recorded while the harness is still listening)`;
|
|
729
|
+
}
|
|
625
730
|
function budgetScopeOf(load, resolution) {
|
|
626
731
|
return {
|
|
627
732
|
classLimits: resolution.limits,
|
|
@@ -867,6 +972,15 @@ function attemptRegister(logPath, source, actor, options) {
|
|
|
867
972
|
}
|
|
868
973
|
return resolved.refusal;
|
|
869
974
|
}
|
|
975
|
+
// APRV-445 refutation (S1). The `propose:` namespace belongs to `propose`;
|
|
976
|
+
// a task FILE claiming an id in it would borrow the exclusions proposals get
|
|
977
|
+
// from the harness hook (its sweep and its carry).
|
|
978
|
+
// Recheck L-c: the in-memory sources too (the muse `POST /v1/registrations`
|
|
979
|
+
// route registers whatever task id its caller names); only `propose` itself
|
|
980
|
+
// passes `proposal: true`.
|
|
981
|
+
if (isProposalTask(resolved.task) && options.proposal !== true) {
|
|
982
|
+
return refuse("task-is-proposal", `task id ${resolved.task} is in the namespace \`approval propose\` derives its ids in (${PROPOSE_TASK_PREFIX}); only propose registers there. Nothing was appended.`);
|
|
983
|
+
}
|
|
870
984
|
const validation = validate("envelope", resolved.envelope, options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
|
|
871
985
|
if (!validation.ok) {
|
|
872
986
|
return refuse("envelope-invalid", `the envelope failed schema validation; nothing was appended`, { errors: validation.errors });
|
|
@@ -922,13 +1036,17 @@ function attemptRegister(logPath, source, actor, options) {
|
|
|
922
1036
|
return { ok: true, record: appended.record, task: resolved.task, actions };
|
|
923
1037
|
}
|
|
924
1038
|
/**
|
|
925
|
-
* The
|
|
926
|
-
*
|
|
927
|
-
* SPEC.md §
|
|
928
|
-
*
|
|
929
|
-
*
|
|
1039
|
+
* The `task.registered` record for `task`, or the `not-registered` refusal.
|
|
1040
|
+
*
|
|
1041
|
+
* The one raise site for SPEC.md §11.2's `not-registered` ("no task.registered
|
|
1042
|
+
* record exists for the task id"). `request` reaches it through
|
|
1043
|
+
* {@link registeredAction}; `approval wait` calls it directly (APRV-428), so a
|
|
1044
|
+
* wait on a task the log has never seen is refused in the same words rather
|
|
1045
|
+
* than answered as a vacuous grant. The latest registration wins, as it always
|
|
1046
|
+
* has for `registeredAction`; the register path refuses a second one, so a log
|
|
1047
|
+
* carrying two was written by something else.
|
|
930
1048
|
*/
|
|
931
|
-
export function
|
|
1049
|
+
export function taskRegistration(records, task) {
|
|
932
1050
|
let registration = null;
|
|
933
1051
|
for (const record of records) {
|
|
934
1052
|
if (record.event === "task.registered" && record.task === task)
|
|
@@ -937,6 +1055,20 @@ export function registeredAction(records, task, actionKey) {
|
|
|
937
1055
|
if (registration === null) {
|
|
938
1056
|
return refuse("not-registered", `task ${task} has no task.registered record; run \`approval register <task-file>\` first`);
|
|
939
1057
|
}
|
|
1058
|
+
return { ok: true, record: registration };
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* The declared action for `(task, actionKey)`, as registered in the log.
|
|
1062
|
+
*
|
|
1063
|
+
* SPEC.md §7: "an action's class MUST be declared before an execution token can
|
|
1064
|
+
* be requested for it". The declaration lives in `task.registered`, so the log —
|
|
1065
|
+
* not the file, which may have been edited since — is what the gate reads back.
|
|
1066
|
+
*/
|
|
1067
|
+
export function registeredAction(records, task, actionKey) {
|
|
1068
|
+
const registered = taskRegistration(records, task);
|
|
1069
|
+
if (!registered.ok)
|
|
1070
|
+
return registered;
|
|
1071
|
+
const registration = registered.record;
|
|
940
1072
|
const declared = payloadOf(registration)["actions"];
|
|
941
1073
|
const actions = Array.isArray(declared) ? declared : [];
|
|
942
1074
|
for (const entry of actions) {
|
|
@@ -1297,6 +1429,21 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1297
1429
|
return attested;
|
|
1298
1430
|
const load = parsePolicy(policyRead, options);
|
|
1299
1431
|
const resolution = resolve(load, input.cls, input.reversible === undefined ? {} : { reversible: input.reversible });
|
|
1432
|
+
// APRV-445 refutation (S1). A `propose:` task is requested through `propose`
|
|
1433
|
+
// and nothing else, so its agent-requestability check cannot be skipped by
|
|
1434
|
+
// asking for the same registration with a plain `request`.
|
|
1435
|
+
if (isProposalTask(input.task) && input.agentProposal !== true) {
|
|
1436
|
+
return refuse("task-is-proposal", `task ${input.task} was registered by \`approval propose\` and is requested only through it; nothing was appended`);
|
|
1437
|
+
}
|
|
1438
|
+
// APRV-445. A proposal's class came from the requester, so the operator's
|
|
1439
|
+
// opening of that class to agents is re-read here, from the same bytes the
|
|
1440
|
+
// attestation above verified, before anything else can be decided for it.
|
|
1441
|
+
if (input.agentProposal === true) {
|
|
1442
|
+
const opened = agentRequestability(load, input.cls);
|
|
1443
|
+
if (!opened.allowed) {
|
|
1444
|
+
return refuse("class-not-agent-requestable", notAgentRequestable(input.cls, opened));
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1300
1447
|
// APRV-185, amended SPEC.md §5.2, and the first thing intake asks once the
|
|
1301
1448
|
// class has an autonomy: a `human-only` class is not requestable. The policy
|
|
1302
1449
|
// itself answers, so nothing is put in front of a human, nothing is drawn,
|
|
@@ -1308,6 +1455,15 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1308
1455
|
// in a human-only class is refused for the class rather than for the missing
|
|
1309
1456
|
// registration: registering it would not help, and `not-registered` would
|
|
1310
1457
|
// send the caller to fix the one thing that cannot make this request valid.
|
|
1458
|
+
// APRV-354, immediately above the human-only check and for the same reason it
|
|
1459
|
+
// sits there in the hook: this is the narrower statement of the two. A
|
|
1460
|
+
// `harness.launch.*` class resolves only under a rule an operator wrote, so a
|
|
1461
|
+
// caller that DECLARES one through `register` and asks for it here is refused
|
|
1462
|
+
// when the policy names neither the family nor the member. Without this the
|
|
1463
|
+
// gate would be the second door the hook's refusal left open.
|
|
1464
|
+
if (harnessLaunchNeedsRule(input.cls, resolution)) {
|
|
1465
|
+
return refuse("harness-launch-unruled", harnessLaunchUnruledRefusal(input.cls, `action ${input.actionKey} cannot be requested and no approval.requested was written`));
|
|
1466
|
+
}
|
|
1311
1467
|
if (resolution.autonomy === "human-only") {
|
|
1312
1468
|
return refuse("class-human-only", humanOnlyRefusal(input.cls, `action ${input.actionKey} cannot be requested and no approval.requested was written`));
|
|
1313
1469
|
}
|
|
@@ -1495,6 +1651,17 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1495
1651
|
if (derivation.execution.started !== null) {
|
|
1496
1652
|
return refuse("already-executed", `action ${input.actionKey} already executed (execution.started at seq ${String(derivation.execution.started)}); an idempotency key is single-use`, { state: derivation.state });
|
|
1497
1653
|
}
|
|
1654
|
+
// APRV-445 recheck (SF1). A proposal is asked again only when its last
|
|
1655
|
+
// answer can no longer be used, and that is judged HERE, against the records
|
|
1656
|
+
// this attempt appends on, not against whatever its caller read earlier. A
|
|
1657
|
+
// usable grant (unexpired, under the policy in force) or a human's refusal
|
|
1658
|
+
// stands: a stale caller may not put a fresh question over it.
|
|
1659
|
+
if (input.agentProposal === true) {
|
|
1660
|
+
const standing = requestStanding(read.records, input.actionKey, ts, ttlOf(load), attested.sha256);
|
|
1661
|
+
if (standing === "granted" || standing === "rejected" || standing === "revoked") {
|
|
1662
|
+
return refuse("already-decided", `action ${input.actionKey} already carries a ${standing} answer that still stands at seq ${String(derivation.decisionSeq)}; a proposal is asked again only when its answer can no longer be used. Nothing was appended.`, { state: derivation.state });
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1498
1665
|
// APRV-173, SPEC.md §5.2's request-volume limits, enforced here and nowhere
|
|
1499
1666
|
// else. Placed AFTER the legality checks above and BEFORE budgets, and both
|
|
1500
1667
|
// halves of that placement are deliberate.
|
|
@@ -1663,6 +1830,13 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1663
1830
|
payload["execution"] = input.execution;
|
|
1664
1831
|
if (input.wait_until !== undefined)
|
|
1665
1832
|
payload["wait_until"] = input.wait_until;
|
|
1833
|
+
// APRV-423, on the same terms as the two above: recorded because the log is
|
|
1834
|
+
// the only place a later reader — the daemon's sweep, a channel, the retry
|
|
1835
|
+
// that adopts this question — can learn what bounded it, and it cannot
|
|
1836
|
+
// lengthen anything. `core/state.ts` takes the minimum of this and the
|
|
1837
|
+
// policy's TTL; see `RequestInput.harnessCapMs`.
|
|
1838
|
+
if (input.harnessCapMs !== undefined)
|
|
1839
|
+
payload["harness_cap_ms"] = input.harnessCapMs;
|
|
1666
1840
|
const appended = append(logPath, {
|
|
1667
1841
|
ts,
|
|
1668
1842
|
event: "approval.requested",
|
|
@@ -1837,7 +2011,7 @@ function attemptDecide(logPath, actionKey, decision, actor, options) {
|
|
|
1837
2011
|
materialised = logged.record;
|
|
1838
2012
|
}
|
|
1839
2013
|
const message = derivation.expiredLazily
|
|
1840
|
-
? `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(
|
|
2014
|
+
? `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(derivation.effectiveTtlMs)}ms window before ${ts}${cappedBy(derivation)}. The lapse is judged from the request's own timestamp, so a decision is refused whether or not an approval.expired event had been observed.`
|
|
1841
2015
|
: `action ${actionKey} expired at ${String(derivation.decisionTs)} (approval.expired, seq ${String(derivation.decisionSeq)}); an expired request is terminal`;
|
|
1842
2016
|
return refuse("expired", message, materialised === undefined
|
|
1843
2017
|
? { state: derivation.state }
|
|
@@ -1930,6 +2104,25 @@ function attemptDecide(logPath, actionKey, decision, actor, options) {
|
|
|
1930
2104
|
options.batchDeliveryId.length > 0) {
|
|
1931
2105
|
payload["batch_delivery_id"] = options.batchDeliveryId;
|
|
1932
2106
|
}
|
|
2107
|
+
// APRV-324. On all three decisions, because all three are answers a person
|
|
2108
|
+
// gives and all three are worth attributing. Written only when the surface
|
|
2109
|
+
// observed one: an omitted sender leaves no key, and that absence is the
|
|
2110
|
+
// record's own statement that the actor came from configuration rather than
|
|
2111
|
+
// from anything a transport authenticated. Copied, never derived — the
|
|
2112
|
+
// resolution that chose `actor` from this sender happened at the decision
|
|
2113
|
+
// surface against the attested policy, and re-deriving it here would be a
|
|
2114
|
+
// second answer to a question that already has one.
|
|
2115
|
+
if (options.sender !== undefined) {
|
|
2116
|
+
payload["sender"] = {
|
|
2117
|
+
channel: options.sender.channel,
|
|
2118
|
+
id: options.sender.id,
|
|
2119
|
+
// APRV-370. Only when it is true, so a raw record is byte-identical to
|
|
2120
|
+
// the record every build since APRV-324 wrote.
|
|
2121
|
+
...(options.sender.hashed === true ? { hashed: true } : {}),
|
|
2122
|
+
};
|
|
2123
|
+
if (options.senderSource !== undefined)
|
|
2124
|
+
payload["sender_source"] = options.senderSource;
|
|
2125
|
+
}
|
|
1933
2126
|
if (decision === "grant") {
|
|
1934
2127
|
const cls = derivation.declared.class ?? "";
|
|
1935
2128
|
const resolution = resolve(load, cls, derivation.declared.reversible === null ? {} : { reversible: derivation.declared.reversible });
|
|
@@ -2055,6 +2248,15 @@ function attemptDecide(logPath, actionKey, decision, actor, options) {
|
|
|
2055
2248
|
actor,
|
|
2056
2249
|
...(derivation.task === null ? {} : { task: derivation.task }),
|
|
2057
2250
|
action_key: actionKey,
|
|
2251
|
+
// APRV-324, and worth doing on its own merits: the base schema has
|
|
2252
|
+
// defined `channel` since v0.1 (SPEC.md §8's own example record carries
|
|
2253
|
+
// `"channel":"telegram"`) and the decision events never set it, so a
|
|
2254
|
+
// reader of a grant could not tell which surface collected it. Named by
|
|
2255
|
+
// the surface, from its own name; a decision that reached the runtime
|
|
2256
|
+
// through no channel names none.
|
|
2257
|
+
...(options.channel === undefined || options.channel.length === 0
|
|
2258
|
+
? {}
|
|
2259
|
+
: { channel: options.channel }),
|
|
2058
2260
|
payload,
|
|
2059
2261
|
}, options,
|
|
2060
2262
|
// The head read at the top of `decide`: transition legality and the budget
|
|
@@ -2147,13 +2349,20 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2147
2349
|
if (logged.ok)
|
|
2148
2350
|
materialised = logged.record;
|
|
2149
2351
|
}
|
|
2150
|
-
return refuse("expired", `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(
|
|
2352
|
+
return refuse("expired", `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(derivation.effectiveTtlMs)}ms window before ${ts}${cappedBy(derivation)}. A lapsed request has already ended; there is nothing left to withdraw.`, materialised === undefined
|
|
2151
2353
|
? { state: derivation.state }
|
|
2152
2354
|
: { state: derivation.state, record: materialised });
|
|
2153
2355
|
}
|
|
2154
2356
|
if (derivation.state !== "requested") {
|
|
2155
2357
|
return refuse("already-decided", `action ${actionKey} was already ${derivation.state} at seq ${String(derivation.decisionSeq)}; a human's answer stands, and withdrawing a question that has been answered would erase the answer`, { state: derivation.state });
|
|
2156
2358
|
}
|
|
2359
|
+
if (options.onlyIfVoid === true) {
|
|
2360
|
+
const attested = requireAttestation(read.records, readPolicyOnce(options));
|
|
2361
|
+
const pinned = derivation.declared.policy_sha256;
|
|
2362
|
+
if (!attested.ok || pinned === null || pinned === attested.sha256) {
|
|
2363
|
+
return refuse("duplicate-request", `action ${actionKey} has a live request at seq ${String(derivation.requestSeq)} routed under the policy in force, so it is not void and was not withdrawn`, { state: derivation.state });
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2157
2366
|
if (derivation.requestActor !== actor) {
|
|
2158
2367
|
return refuse("not-requester", `action ${actionKey} was requested by ${JSON.stringify(derivation.requestActor)} and cannot be withdrawn by ${JSON.stringify(actor)}; only the party that asked may take the question back. To end a pending request as someone else, reject it — that is a decision, and it is recorded as one.`, { state: derivation.state });
|
|
2159
2368
|
}
|
|
@@ -2202,8 +2411,9 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2202
2411
|
* refused at the append in {@link consumeHarnessGrant}. The single-use rule
|
|
2203
2412
|
* is the gate's existing one; this only stops the caller from queueing up a
|
|
2204
2413
|
* write that would be refused.
|
|
2205
|
-
* - *within the TTL*: state is derived at `ts` with `ttlMs`,
|
|
2206
|
-
*
|
|
2414
|
+
* - *within the TTL*: state is derived at `ts` with `ttlMs`, narrowed by the
|
|
2415
|
+
* candidate's own declared harness ceiling (APRV-423), so a lapsed request
|
|
2416
|
+
* reads `expired` and carries nothing, whether or not the daemon has
|
|
2207
2417
|
* materialised an `approval.expired` record.
|
|
2208
2418
|
*
|
|
2209
2419
|
* PURE, and reads only records the caller verified — the enforcement path never
|
|
@@ -2212,7 +2422,7 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2212
2422
|
* request that came after it, exactly as {@link requestState} treats cycles.
|
|
2213
2423
|
*/
|
|
2214
2424
|
/**
|
|
2215
|
-
* Has a GRANTED request outlived
|
|
2425
|
+
* Has a GRANTED request outlived the window it was asked under?
|
|
2216
2426
|
*
|
|
2217
2427
|
* `requestState` reports a decided request by its decision forever: the TTL
|
|
2218
2428
|
* bounds the window in which a human may answer, not the answer's shelf life.
|
|
@@ -2225,7 +2435,14 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2225
2435
|
* Unparseable instants read as lapsed, and a policy with no TTL declares no
|
|
2226
2436
|
* lapse at all — both exactly as `core/token.ts` reads them.
|
|
2227
2437
|
*/
|
|
2228
|
-
function grantLapsed(derivation, ts
|
|
2438
|
+
function grantLapsed(derivation, ts) {
|
|
2439
|
+
// APRV-423: the SAME window the request was judged by, read off the
|
|
2440
|
+
// derivation rather than recomputed from the policy. A grant whose question
|
|
2441
|
+
// was bounded by a harness ceiling keeps that bound; a retry a few seconds
|
|
2442
|
+
// later then asks again instead of carrying an answer the request's own
|
|
2443
|
+
// clock had already run out on. The stricter of the two readings, and the
|
|
2444
|
+
// one that keeps `requestState` the single place the deadline is decided.
|
|
2445
|
+
const ttlMs = derivation.effectiveTtlMs;
|
|
2229
2446
|
if (ttlMs === null)
|
|
2230
2447
|
return false;
|
|
2231
2448
|
const requestedAt = Date.parse(derivation.requestTs ?? "");
|
|
@@ -2234,7 +2451,63 @@ function grantLapsed(derivation, ts, ttlMs) {
|
|
|
2234
2451
|
return true;
|
|
2235
2452
|
return asked > requestedAt + ttlMs;
|
|
2236
2453
|
}
|
|
2237
|
-
|
|
2454
|
+
/**
|
|
2455
|
+
* Was the question already abandoned when it was answered? (APRV-410.)
|
|
2456
|
+
*
|
|
2457
|
+
* The state this names, observed 2026-09-20 in this repository's own log. A
|
|
2458
|
+
* hook wait expired at 18:52:37 on a request opened at 18:43:37; the retry
|
|
2459
|
+
* grace ran out at 18:57:37 with nothing retrying; a human tapped approve at
|
|
2460
|
+
* 19:03:32 and `approval.granted` seq 64473 was recorded, six minutes past the
|
|
2461
|
+
* grace, on a question no hook process was holding. The grant authorized
|
|
2462
|
+
* nothing that ran on the day only because the session had already moved on. It
|
|
2463
|
+
* was still live: a retry of that exact command in that directory inside the
|
|
2464
|
+
* TTL would have found it here and proceeded on it, which is one human tap
|
|
2465
|
+
* authorizing an execution nobody connected to the tap.
|
|
2466
|
+
*
|
|
2467
|
+
* {@link grantLapsed} bounds a grant by the request's TTL, which is the shelf
|
|
2468
|
+
* life of an ANSWER. This bounds it by the life of the QUESTION: past
|
|
2469
|
+
* `requestTs + abandonAfterMs` the asker is gone (`core/harness-wait.ts` is
|
|
2470
|
+
* where that duration and its reasoning live), so an answer arriving after it
|
|
2471
|
+
* answered nobody. Both bounds apply; either one alone lets a case through.
|
|
2472
|
+
*
|
|
2473
|
+
* Two facts and no third. The request's own runtime-assigned timestamp and the
|
|
2474
|
+
* decision record's own runtime-assigned timestamp, both read from the log the
|
|
2475
|
+
* caller verified. Nothing self-reported enters it, and the caller's
|
|
2476
|
+
* `abandonAfterMs` is the window the CALLER is configured for rather than
|
|
2477
|
+
* anything the requester said about itself, which is why a false claim cannot
|
|
2478
|
+
* widen it.
|
|
2479
|
+
*
|
|
2480
|
+
* Strictly stricter, and opt-in. A caller that passes no window gets exactly
|
|
2481
|
+
* today's behaviour, and a caller that passes one can only lose a carry it
|
|
2482
|
+
* would otherwise have had. Losing one costs a fresh question; keeping one
|
|
2483
|
+
* costs an execution on an answer nobody was waiting for.
|
|
2484
|
+
*
|
|
2485
|
+
* An unparseable instant on either side reads as abandoned, exactly as
|
|
2486
|
+
* {@link grantLapsed} reads one as lapsed: the strict direction is the one a
|
|
2487
|
+
* corrupt byte must resolve to.
|
|
2488
|
+
*/
|
|
2489
|
+
function questionAbandoned(derivation, abandonAfterMs) {
|
|
2490
|
+
if (abandonAfterMs === null)
|
|
2491
|
+
return false;
|
|
2492
|
+
const requestedAt = Date.parse(derivation.requestTs ?? "");
|
|
2493
|
+
const answeredAt = Date.parse(derivation.decisionTs ?? "");
|
|
2494
|
+
if (Number.isNaN(requestedAt) || Number.isNaN(answeredAt))
|
|
2495
|
+
return true;
|
|
2496
|
+
return answeredAt > requestedAt + Math.max(0, abandonAfterMs);
|
|
2497
|
+
}
|
|
2498
|
+
export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs,
|
|
2499
|
+
/**
|
|
2500
|
+
* How long a question of this caller's outlives the wait that opened it
|
|
2501
|
+
* (APRV-410, `core/harness-wait.ts`'s `abandonedAfterMs`), or `null` for a
|
|
2502
|
+
* caller that declares no such window.
|
|
2503
|
+
*
|
|
2504
|
+
* Only a GRANTED candidate is bounded by it. A pending one is the question
|
|
2505
|
+
* this invocation is about to adopt, and the withdrawal of an abandoned
|
|
2506
|
+
* pending question is the requester's own business, not this function's:
|
|
2507
|
+
* `cli/hook.ts` sweeps those through `withdraw`, which is requester-only by
|
|
2508
|
+
* design (APRV-106 rule 1).
|
|
2509
|
+
*/
|
|
2510
|
+
abandonAfterMs = null) {
|
|
2238
2511
|
if (!isPayloadHash(payloadHash))
|
|
2239
2512
|
return null;
|
|
2240
2513
|
// Distinct keys, latest request first: a later question about the same bytes
|
|
@@ -2251,6 +2524,11 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
|
|
|
2251
2524
|
continue;
|
|
2252
2525
|
if (keys.includes(record.action_key))
|
|
2253
2526
|
continue;
|
|
2527
|
+
// APRV-445. A proposal's harness grant belongs to `approval start` and to
|
|
2528
|
+
// nothing else: a hook call that happened to present the same bytes in the
|
|
2529
|
+
// same class must not carry or adopt it.
|
|
2530
|
+
if (isProposalTask(record.task))
|
|
2531
|
+
continue;
|
|
2254
2532
|
const payload = payloadOf(record);
|
|
2255
2533
|
if (payload["execution"] !== "harness")
|
|
2256
2534
|
continue;
|
|
@@ -2274,7 +2552,13 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
|
|
|
2274
2552
|
continue;
|
|
2275
2553
|
if (derivation.state === "granted") {
|
|
2276
2554
|
// An answer has a shelf life, and it is its request's TTL.
|
|
2277
|
-
|
|
2555
|
+
// APRV-423: judged by the request's own effective window, read off the
|
|
2556
|
+
// derivation, never the raw policy TTL.
|
|
2557
|
+
if (grantLapsed(derivation, ts))
|
|
2558
|
+
continue;
|
|
2559
|
+
// And a question has a life of its own: an answer that landed after the
|
|
2560
|
+
// asker was gone authorized nobody (APRV-410).
|
|
2561
|
+
if (questionAbandoned(derivation, abandonAfterMs))
|
|
2278
2562
|
continue;
|
|
2279
2563
|
return {
|
|
2280
2564
|
actionKey,
|
|
@@ -2344,6 +2628,43 @@ export const HARNESS_GRANT_ORIGIN = "grant_origin";
|
|
|
2344
2628
|
* every reader treats absence as "no second name" rather than as a fault.
|
|
2345
2629
|
*/
|
|
2346
2630
|
export const HARNESS_SPENDING_TASK = "spent_by_task";
|
|
2631
|
+
/**
|
|
2632
|
+
* The policy stamp every harness `execution.started` carries (APRV-447).
|
|
2633
|
+
*
|
|
2634
|
+
* `payload.policy_sha256` names the attested policy the write boundary
|
|
2635
|
+
* re-checked this start's class against: the hash {@link requireAttestation}
|
|
2636
|
+
* returned for the one policy read the appending attempt made, which is the
|
|
2637
|
+
* read the class was resolved from and the attestation was judged on. On the
|
|
2638
|
+
* hook route the verdict itself was drawn from the hook's own, earlier read;
|
|
2639
|
+
* a re-attest landing between that read and this append leaves the stamp
|
|
2640
|
+
* naming the policy in force at the append (the two-read window predates this
|
|
2641
|
+
* field, and is a follow-up to close). Before this field a
|
|
2642
|
+
* policy-authorized start (autonomous, supervised, an unselected live draw)
|
|
2643
|
+
* carried no policy at all, and a reader had to reconstruct the rules in force
|
|
2644
|
+
* from the latest `policy.updated` before the record's seq. A granted start
|
|
2645
|
+
* could recover it through `grant_seq`; it is stamped too, by the same line, so
|
|
2646
|
+
* every harness start answers the question the same way.
|
|
2647
|
+
*
|
|
2648
|
+
* WRITTEN, never accepted. The value is passed in from the attempt's own
|
|
2649
|
+
* attestation check and from nowhere else: neither {@link HarnessStartInput}
|
|
2650
|
+
* nor {@link ConsumeHarnessOptions} has a field for it, and the payload object
|
|
2651
|
+
* is built here from runtime-held values, so a caller's input, option bag or
|
|
2652
|
+
* action payload that happens to carry the same name changes nothing (§11.1
|
|
2653
|
+
* invariant 1: the stamp comes from the verified attestation the gate already
|
|
2654
|
+
* resolved, never from an envelope). An unattested or edited policy never
|
|
2655
|
+
* reaches this line: both write paths refuse `policy-not-attested` first, and
|
|
2656
|
+
* a grant spend under a policy other than the one the grant pinned is refused
|
|
2657
|
+
* `policy-drift` before its payload is built.
|
|
2658
|
+
*
|
|
2659
|
+
* READ by nothing that decides. No verdict, budget, draw, loop floor or
|
|
2660
|
+
* single-use check consults it, and `core/state.ts` reads `policy_sha256` from
|
|
2661
|
+
* `approval.requested` alone. Its readers are downstream followers of the log
|
|
2662
|
+
* that join a start to the rules it ran under, so the field NAME is a stable
|
|
2663
|
+
* contract.
|
|
2664
|
+
*/
|
|
2665
|
+
function harnessStartPolicyStamp(attestedSha256) {
|
|
2666
|
+
return { [POLICY_HASH_FIELD]: attestedSha256 };
|
|
2667
|
+
}
|
|
2347
2668
|
/**
|
|
2348
2669
|
* Spend a harness grant, exactly once (APRV-117).
|
|
2349
2670
|
*
|
|
@@ -2457,8 +2778,8 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
|
|
|
2457
2778
|
if (derivation.declared.execution !== "harness") {
|
|
2458
2779
|
return refuse("not-granted", `action ${actionKey} was granted as an ordinary request and minted an execution token; it is spent by presenting that token to \`approval run\`, not by a harness proceeding on it. Two spenders of one authorization is the property this refuses.`, { state: derivation.state });
|
|
2459
2780
|
}
|
|
2460
|
-
if (grantLapsed(derivation, ts
|
|
2461
|
-
return refuse("expired", `action ${actionKey}'s grant expired: the request at ${String(derivation.requestTs)} lapsed its
|
|
2781
|
+
if (grantLapsed(derivation, ts)) {
|
|
2782
|
+
return refuse("expired", `action ${actionKey}'s grant expired: the request at ${String(derivation.requestTs)} lapsed its ${String(derivation.effectiveTtlMs)}ms window before ${ts}${cappedBy(derivation)}. There is no separate grant TTL — an approval lives exactly as long as its parent request, which is the rule \`core/token.ts\` applies to a token-bearing grant.`, { state: derivation.state });
|
|
2462
2783
|
}
|
|
2463
2784
|
if (derivation.task === null) {
|
|
2464
2785
|
return refuse("not-registered", `action ${actionKey} has a grant but no task on its request record; an execution event names both (SPEC.md §8) and nothing here invents one`, { state: derivation.state });
|
|
@@ -2534,6 +2855,11 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
|
|
|
2534
2855
|
options.spendingTask === derivation.task
|
|
2535
2856
|
? "direct"
|
|
2536
2857
|
: "carried"),
|
|
2858
|
+
// APRV-447: the attested policy this spend was judged under, the same
|
|
2859
|
+
// value `policy-drift` compared against the grant's pin above, so on this
|
|
2860
|
+
// path it equals that pin wherever the grant carries one. See
|
|
2861
|
+
// `harnessStartPolicyStamp` for why it is stamped and who reads it.
|
|
2862
|
+
...harnessStartPolicyStamp(attested.sha256),
|
|
2537
2863
|
};
|
|
2538
2864
|
// APRV-287. A carried spend records WHICH tool call spent it, so the
|
|
2539
2865
|
// completion counterpart can find this start from the event that reports how
|
|
@@ -2685,6 +3011,16 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2685
3011
|
if (!isPayloadHash(bytes)) {
|
|
2686
3012
|
return refuse("payload-hash-required", `recording a harness execution of ${input.actionKey} requires the payload_hash of what is about to run (amended SPEC.md §6.2, APRV-140), and this caller presented ${bytes === undefined ? "none" : JSON.stringify(bytes)}. A start event that cannot state its bytes says only that something ran; the harness computes the hash of the payload it is about to execute and passes it here. Nothing was appended.`);
|
|
2687
3013
|
}
|
|
3014
|
+
// APRV-445 security pass. A proposal's start reaches this function only when
|
|
3015
|
+
// its key holds no live request, and the task id alone says it is a proposal
|
|
3016
|
+
// (read from the task, never from a caller flag). Re-judged here, inside the
|
|
3017
|
+
// attempt that appends, so a request filed or decided after `startProposed`
|
|
3018
|
+
// looked cannot slip past. Checked before the budget verdict, which writes.
|
|
3019
|
+
if (isProposalTask(input.task)) {
|
|
3020
|
+
const refusal = proposalPolicyStartRefusal(logPath, read.records, input.actionKey, bytes, load, resolution, attested.sha256, ts, options);
|
|
3021
|
+
if (refusal !== null)
|
|
3022
|
+
return refusal;
|
|
3023
|
+
}
|
|
2688
3024
|
const cost = costOf(input.est_cost_usd);
|
|
2689
3025
|
const budget = evaluateBudgetsWithTask(read.records, budgetScopeOf(load, resolution), { class: input.cls, est_cost_usd: cost }, ts, input.task);
|
|
2690
3026
|
if (!budget.pass) {
|
|
@@ -2718,6 +3054,11 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2718
3054
|
// APRV-146: unconditional, because a caller that states no bytes was refused
|
|
2719
3055
|
// above. The record says what ran, not only that something did.
|
|
2720
3056
|
payload_hash: bytes,
|
|
3057
|
+
// APRV-447: the attested policy this write boundary re-checked the class
|
|
3058
|
+
// against (see `harnessStartPolicyStamp` for the hook's two-read window).
|
|
3059
|
+
// Without it a reader of a policy-authorized start had to reconstruct the
|
|
3060
|
+
// rules in force from the latest `policy.updated` before this seq.
|
|
3061
|
+
...harnessStartPolicyStamp(attested.sha256),
|
|
2721
3062
|
};
|
|
2722
3063
|
const appended = append(logPath, {
|
|
2723
3064
|
ts,
|
|
@@ -2734,6 +3075,54 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2734
3075
|
return appended;
|
|
2735
3076
|
return { ok: true, record: appended.record };
|
|
2736
3077
|
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Why a proposal may NOT start on the policy path, or `null` when it may
|
|
3080
|
+
* (APRV-445 security pass).
|
|
3081
|
+
*
|
|
3082
|
+
* The policy path records an execution the policy itself authorizes. For a
|
|
3083
|
+
* proposal that is true only when no human question about these bytes is open
|
|
3084
|
+
* or answered no under the policy in force, and when the class would not have
|
|
3085
|
+
* put this action in front of a human at all. Three refusals, all `not-granted`:
|
|
3086
|
+
*
|
|
3087
|
+
* - a request for the key is pending or granted and unspent: the key is bound to
|
|
3088
|
+
* the grant path, and `start` must spend the grant (or wait for the decision);
|
|
3089
|
+
* - the key's latest request was rejected or revoked under the attested policy
|
|
3090
|
+
* (or pinned no policy): a human's no under the rules in force stands. After a
|
|
3091
|
+
* re-attest the rules that asked are gone, and the action is judged afresh;
|
|
3092
|
+
* - the class is `supervised-live` and the draw selects these bytes: the draw is
|
|
3093
|
+
* deterministic over the policy and the payload hash, so it reproduces the
|
|
3094
|
+
* intake answer, and a selected action is authorized by a grant, never by the
|
|
3095
|
+
* policy. Without this a withdrawn, expired, rejected or never-filed request
|
|
3096
|
+
* for a selected live action turned into a policy-authorized start.
|
|
3097
|
+
*
|
|
3098
|
+
* Asking the draw appends nothing; a draw that cannot be made gates, so this
|
|
3099
|
+
* fails closed.
|
|
3100
|
+
*/
|
|
3101
|
+
function proposalPolicyStartRefusal(logPath, records, actionKey, payloadHash, load, resolution, attestedSha256, ts, options) {
|
|
3102
|
+
const derivation = requestState(records, actionKey, ts, ttlOf(load));
|
|
3103
|
+
if (derivation.execution.started === null &&
|
|
3104
|
+
(derivation.state === "requested" || derivation.state === "granted")) {
|
|
3105
|
+
return refuse("not-granted", `action ${actionKey} has a ${derivation.state} request (seq ${String(derivation.requestSeq)}), so it is bound to the grant path: a pending request is decided by a human and a granted one is spent by start through the grant. Start it again; nothing was appended.`);
|
|
3106
|
+
}
|
|
3107
|
+
if (derivation.state === "rejected" || derivation.state === "revoked") {
|
|
3108
|
+
const pinned = derivation.declared.policy_sha256;
|
|
3109
|
+
if (pinned === null || pinned === attestedSha256) {
|
|
3110
|
+
return refuse("not-granted", `action ${actionKey} was ${derivation.state} by a human (seq ${String(derivation.decisionSeq)}) under the policy in force, and a human's no is not turned into a policy-authorized start. Propose it again only after the policy changes; nothing was appended.`);
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
if (resolution.supervision === "live") {
|
|
3114
|
+
const live = liveVerdict(load, resolution, payloadHash, options.env, {
|
|
3115
|
+
logPath,
|
|
3116
|
+
policyHash: attestedSha256,
|
|
3117
|
+
actionKey,
|
|
3118
|
+
ask: options.drawAsk ?? askDaemonDraw,
|
|
3119
|
+
});
|
|
3120
|
+
if (live.gated) {
|
|
3121
|
+
return refuse("not-granted", `class ${String(derivation.declared.class)} of ${actionKey} is supervised-live and the draw put these bytes in front of a human (${live.reason}); a selected action is authorized by a human's grant, never by the policy. Propose it and spend the grant; nothing was appended.`);
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
return null;
|
|
3125
|
+
}
|
|
2737
3126
|
// ---------------------------------------------------------------------------
|
|
2738
3127
|
// finishHarnessExecution — the completion counterpart (APRV-145)
|
|
2739
3128
|
// ---------------------------------------------------------------------------
|
|
@@ -2928,9 +3317,19 @@ function appendExpiry(logPath, derivation, load, ts, options, expectedHead) {
|
|
|
2928
3317
|
const payload = {};
|
|
2929
3318
|
if (derivation.requestTs !== null)
|
|
2930
3319
|
payload["requested_ts"] = derivation.requestTs;
|
|
2931
|
-
|
|
3320
|
+
// The window this request was ACTUALLY judged by, which is the policy's TTL
|
|
3321
|
+
// narrowed by whatever harness ceiling the requester declared (APRV-423).
|
|
3322
|
+
// Recording the policy's number here while expiring on a shorter one would
|
|
3323
|
+
// leave the record disagreeing with the arithmetic that produced it, and the
|
|
3324
|
+
// reader's obvious check — requested_ts + ttl_ms against ts — would fail on a
|
|
3325
|
+
// correct expiry. The cap rides along beside it so a reader can see WHY the
|
|
3326
|
+
// window was short without holding the request record too.
|
|
3327
|
+
const ttlMs = derivation.effectiveTtlMs;
|
|
2932
3328
|
if (ttlMs !== null)
|
|
2933
3329
|
payload["ttl_ms"] = ttlMs;
|
|
3330
|
+
if (derivation.declared.harness_cap_ms !== null) {
|
|
3331
|
+
payload["harness_cap_ms"] = derivation.declared.harness_cap_ms;
|
|
3332
|
+
}
|
|
2934
3333
|
const onExpiry = load.ok ? load.policy.defaults?.on_expiry : undefined;
|
|
2935
3334
|
if (onExpiry !== undefined)
|
|
2936
3335
|
payload["on_expiry"] = onExpiry;
|
|
@@ -2983,9 +3382,9 @@ export function expire(logPath, actionKey, options = {}) {
|
|
|
2983
3382
|
if (derivation.state !== "requested") {
|
|
2984
3383
|
return refuse("already-decided", `action ${actionKey} was already ${derivation.state} at seq ${String(derivation.decisionSeq)}; only a live request can expire`, { state: derivation.state });
|
|
2985
3384
|
}
|
|
2986
|
-
return refuse("not-expired",
|
|
2987
|
-
? `action ${actionKey} cannot expire: the policy declares no defaults.approval_ttl
|
|
2988
|
-
: `action ${actionKey} has not expired: the request at ${String(derivation.requestTs)} has not lapsed its ${String(
|
|
3385
|
+
return refuse("not-expired", derivation.effectiveTtlMs === null
|
|
3386
|
+
? `action ${actionKey} cannot expire: the policy declares no defaults.approval_ttl and the request declares no harness cap, so it is not bounded by any deadline`
|
|
3387
|
+
: `action ${actionKey} has not expired: the request at ${String(derivation.requestTs)} has not lapsed its ${String(derivation.effectiveTtlMs)}ms window as of ${ts}${cappedBy(derivation)}`, { state: derivation.state });
|
|
2989
3388
|
}
|
|
2990
3389
|
const expired = appendExpiry(logPath, derivation, load, ts, options, read.head);
|
|
2991
3390
|
if (expired.ok) {
|
|
@@ -2999,4 +3398,433 @@ export function expire(logPath, actionKey, options = {}) {
|
|
|
2999
3398
|
}
|
|
3000
3399
|
return expired;
|
|
3001
3400
|
}
|
|
3401
|
+
// ---------------------------------------------------------------------------
|
|
3402
|
+
// propose (APRV-445)
|
|
3403
|
+
// ---------------------------------------------------------------------------
|
|
3404
|
+
/**
|
|
3405
|
+
* The largest payload `approval propose` and `approval start` accept, in UTF-8
|
|
3406
|
+
* bytes of the JSON text as the caller sent it: 256 KiB.
|
|
3407
|
+
*
|
|
3408
|
+
* Well under `approval serve`'s 1 MiB body cap, so the refusal a caller meets is
|
|
3409
|
+
* this named one rather than a transport's. A proposal is a sentence or a
|
|
3410
|
+
* paragraph a human will read on a phone; a payload this size is not one.
|
|
3411
|
+
*/
|
|
3412
|
+
export const PROPOSE_PAYLOAD_MAX_BYTES = 256 * 1024;
|
|
3413
|
+
/** The longest `--key` `propose` accepts, in UTF-8 bytes (APRV-445, L6). */
|
|
3414
|
+
export const PROPOSE_KEY_MAX_BYTES = 1024;
|
|
3415
|
+
/** The longest `--summary` `propose` accepts, in UTF-8 bytes (APRV-445, L6). */
|
|
3416
|
+
export const PROPOSE_SUMMARY_MAX_BYTES = 4096;
|
|
3417
|
+
/**
|
|
3418
|
+
* The id after `<class>:` (APRV-445 recheck L-b): one or more printable
|
|
3419
|
+
* characters, no whitespace, no control, format, private-use, unassigned or
|
|
3420
|
+
* surrogate code points, so a key reads back the same in a log, a phone and a
|
|
3421
|
+
* shell.
|
|
3422
|
+
*/
|
|
3423
|
+
const PROPOSE_KEY_ID = /^[^\s\p{C}]+$/u;
|
|
3424
|
+
/** How many times `propose` starts again on a log that moved under it (SF1). */
|
|
3425
|
+
const PROPOSE_MAX_ATTEMPTS = 4;
|
|
3426
|
+
/** `origin.app` on every envelope `propose` registers. */
|
|
3427
|
+
export const PROPOSE_ORIGIN_APP = "approval-propose";
|
|
3428
|
+
/** The task-id prefix `propose` derives under. */
|
|
3429
|
+
export const PROPOSE_TASK_PREFIX = "propose:";
|
|
3430
|
+
/**
|
|
3431
|
+
* Was this task registered by `propose` (APRV-445)?
|
|
3432
|
+
*
|
|
3433
|
+
* A proposal's request carries `execution: "harness"` like a hook's, so the two
|
|
3434
|
+
* hook-side readers of that marker (the carry and the abandoned-question sweep)
|
|
3435
|
+
* ask this first: a proposal waits days for a human, is spent only by `start`,
|
|
3436
|
+
* and is withdrawn only by its requester's own call.
|
|
3437
|
+
*/
|
|
3438
|
+
export function isProposalTask(task) {
|
|
3439
|
+
return typeof task === "string" && task.startsWith(PROPOSE_TASK_PREFIX);
|
|
3440
|
+
}
|
|
3441
|
+
/** A concrete class, as `schema/envelope.schema.json` spells one: no wildcard. */
|
|
3442
|
+
const CONCRETE_CLASS = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/u;
|
|
3443
|
+
/**
|
|
3444
|
+
* The task id `propose` registers under, from who is asking, the class and the
|
|
3445
|
+
* key: `propose:` and the first 32 hex digits of the SHA-256 over the RFC 8785
|
|
3446
|
+
* canonical JSON of `[actor, class, key]`.
|
|
3447
|
+
*
|
|
3448
|
+
* Deterministic, so a retry lands on the task its first attempt registered and
|
|
3449
|
+
* is recognised as a retry. The ACTOR is the tenant half of the derivation: one
|
|
3450
|
+
* `approval serve` process serves one store as one fixed `agent:<id>`, so within
|
|
3451
|
+
* a store the actor is who the tenant's agent is, and two actors proposing the
|
|
3452
|
+
* same key derive two different tasks, the second of which `register` refuses
|
|
3453
|
+
* because the key is already declared under the first (`task-already-registered`).
|
|
3454
|
+
* The class is in it so that the same key proposed under a second class is a
|
|
3455
|
+
* different task rather than a silent re-declaration.
|
|
3456
|
+
*/
|
|
3457
|
+
export function proposedTaskId(actor, cls, actionKey) {
|
|
3458
|
+
return `${PROPOSE_TASK_PREFIX}${hashOfPayload([actor, cls, actionKey]).slice(0, 32)}`;
|
|
3459
|
+
}
|
|
3460
|
+
/** The sentence a `class-not-agent-requestable` refusal says. */
|
|
3461
|
+
function notAgentRequestable(cls, opened) {
|
|
3462
|
+
const why = !opened.explicit
|
|
3463
|
+
? `${JSON.stringify(cls)} is not a key of the policy's \`classes\` map, and propose takes only a class the operator declared by name (a wildcard rule that happens to match does not count)`
|
|
3464
|
+
: opened.pattern === null
|
|
3465
|
+
? `neither ${JSON.stringify(cls)} nor any declared \`<prefix>.*\` family of it sets agent_may_request: true`
|
|
3466
|
+
: `${JSON.stringify(opened.pattern)} sets agent_may_request: false`;
|
|
3467
|
+
return `class ${cls} is not open to agent requests: ${why}. Nothing was registered and nothing was appended. Opening a class to agents is a policy edit (agent_may_request: true on the class or its family) and a human re-attestation (amended SPEC.md §5.2).`;
|
|
3468
|
+
}
|
|
3469
|
+
/**
|
|
3470
|
+
* `approval propose` (APRV-445, amended SPEC.md §5.2): register and request one
|
|
3471
|
+
* action in one call, for a requester that holds the payload inline and no file
|
|
3472
|
+
* on the gate's machine.
|
|
3473
|
+
*
|
|
3474
|
+
* ## Why this verb may name its class when `request` may not
|
|
3475
|
+
*
|
|
3476
|
+
* `approval request` reads the class from the log's `task.registered` record so
|
|
3477
|
+
* that an agent cannot call a `financial.spend` a `read.web`. Here the agent is
|
|
3478
|
+
* also the registrant, so that rule alone would prove nothing, and the bound is
|
|
3479
|
+
* the operator's instead: the class must be an EXACT key of `policy.classes`, and
|
|
3480
|
+
* that key or a declared `<prefix>.*` family of it must say
|
|
3481
|
+
* `agent_may_request: true`. A class reached only through a wildcard is refused,
|
|
3482
|
+
* so the set of classes an agent can propose in is a set of lines the operator
|
|
3483
|
+
* wrote and attested by name. What the agent chooses among them is still its own
|
|
3484
|
+
* statement (SPEC.md §11.1 invariant 4 is why the choice is bounded at all), and
|
|
3485
|
+
* an operator opening two classes of different autonomy to agents is trusting
|
|
3486
|
+
* the agent to pick the right one; the record names the class it picked.
|
|
3487
|
+
*
|
|
3488
|
+
* ## Order
|
|
3489
|
+
*
|
|
3490
|
+
* Attestation, then the agent-requestability gate and the class's own autonomy
|
|
3491
|
+
* (`human-only`, an unruled `harness.launch.*`), all BEFORE anything is
|
|
3492
|
+
* registered, so a refused class leaves no registration behind. Then the
|
|
3493
|
+
* registration, then {@link request} with `agentProposal: true`, which repeats
|
|
3494
|
+
* the gate against the bytes its own attempt reads. `execution: "harness"` on the
|
|
3495
|
+
* request: nothing ever presents a token for a proposal, the requester proceeds
|
|
3496
|
+
* on the grant itself and records that with `approval start`, so a minted token
|
|
3497
|
+
* would be a live credential with no spender.
|
|
3498
|
+
*
|
|
3499
|
+
* ## Idempotency
|
|
3500
|
+
*
|
|
3501
|
+
* The task id is {@link proposedTaskId}. A second call with the same actor,
|
|
3502
|
+
* class, key and payload hash finds its own registration and appends nothing
|
|
3503
|
+
* while the request is live or decided, answering with the request's state and
|
|
3504
|
+
* `idempotent: true`. A withdrawn or expired request (a re-attestation voids
|
|
3505
|
+
* pending requests as `policy-drift`) is asked again under the same registration.
|
|
3506
|
+
* The same key with DIFFERENT bytes refuses: `duplicate-request` while a request
|
|
3507
|
+
* is live, `payload-mismatch` otherwise, because a key is bound to the bytes it
|
|
3508
|
+
* was first registered with and new bytes need a new key.
|
|
3509
|
+
*/
|
|
3510
|
+
export function propose(logPath, input, actor, options = {}, attempt = 0, appendedEarlier = false) {
|
|
3511
|
+
// APRV-445 recheck (SF1). Every decision below is re-checked by the append
|
|
3512
|
+
// it leads to (`withdraw` with `onlyIfVoid`, `request` with
|
|
3513
|
+
// `agentProposal`), so a caller that read a stale log is refused there
|
|
3514
|
+
// rather than writing over a concurrent caller. A refusal of that kind means
|
|
3515
|
+
// the log moved under this call; the call starts again from a fresh read, a
|
|
3516
|
+
// bounded number of times, and answers what it then finds.
|
|
3517
|
+
const again = (refusal) => attempt < PROPOSE_MAX_ATTEMPTS
|
|
3518
|
+
? propose(logPath, input, actor, options, attempt + 1, appendedEarlier || withdrewNow || registeredNow)
|
|
3519
|
+
: refuse("contended", `the log moved under propose on each of its ${String(PROPOSE_MAX_ATTEMPTS + 1)} attempts for ${input.actionKey} (last refusal: ${refusal.code}); nothing more was appended. Read the proposal's standing with \`approval wait <task> --timeout 0\`, or call again.`);
|
|
3520
|
+
if (!isPrincipalActor(actor)) {
|
|
3521
|
+
return refuse("actor-invalid", `propose requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
|
|
3522
|
+
}
|
|
3523
|
+
if (!CONCRETE_CLASS.test(input.cls)) {
|
|
3524
|
+
return refuse("envelope-invalid", `class ${JSON.stringify(input.cls)} is not a concrete action class: lowercase dot-separated segments of [a-z0-9_-], no wildcard. Nothing was registered and nothing was appended.`);
|
|
3525
|
+
}
|
|
3526
|
+
if (input.actionKey.length === 0) {
|
|
3527
|
+
return refuse("envelope-invalid", "the action key is empty; nothing was registered and nothing was appended.");
|
|
3528
|
+
}
|
|
3529
|
+
// APRV-445 refutation (S4): the key carries its class, so a consumer that
|
|
3530
|
+
// parses the key can never attribute it to a class its record does not name.
|
|
3531
|
+
// Consumers read the class from the record; this keeps the key from lying.
|
|
3532
|
+
if (!input.actionKey.startsWith(`${input.cls}:`) ||
|
|
3533
|
+
!PROPOSE_KEY_ID.test(input.actionKey.slice(input.cls.length + 1))) {
|
|
3534
|
+
return refuse("key-class-mismatch", `key ${JSON.stringify(input.actionKey)} is not ${JSON.stringify(`${input.cls}:`)} followed by an identifier of printable characters with no whitespace or control characters; a proposal's key names the class it is proposed under. Nothing was registered and nothing was appended.`);
|
|
3535
|
+
}
|
|
3536
|
+
if (Buffer.byteLength(input.actionKey, "utf8") > PROPOSE_KEY_MAX_BYTES) {
|
|
3537
|
+
return refuse("envelope-invalid", `the key is over ${String(PROPOSE_KEY_MAX_BYTES)} bytes; nothing was registered and nothing was appended.`);
|
|
3538
|
+
}
|
|
3539
|
+
if (Buffer.byteLength(input.summary, "utf8") > PROPOSE_SUMMARY_MAX_BYTES) {
|
|
3540
|
+
return refuse("envelope-invalid", `the summary is over ${String(PROPOSE_SUMMARY_MAX_BYTES)} bytes; nothing was registered and nothing was appended.`);
|
|
3541
|
+
}
|
|
3542
|
+
let hash;
|
|
3543
|
+
try {
|
|
3544
|
+
hash = hashOfPayload(input.payload);
|
|
3545
|
+
}
|
|
3546
|
+
catch (cause) {
|
|
3547
|
+
return refuse("payload-store-failed", `the payload for ${input.actionKey} could not be canonicalized: ${cause instanceof Error ? cause.message : String(cause)}. Nothing was registered and nothing was appended.`);
|
|
3548
|
+
}
|
|
3549
|
+
const task = proposedTaskId(actor, input.cls, input.actionKey);
|
|
3550
|
+
const read = readGateRecords(logPath);
|
|
3551
|
+
if (!read.ok)
|
|
3552
|
+
return read;
|
|
3553
|
+
const policyRead = readPolicyOnce(options);
|
|
3554
|
+
const attested = requireAttestation(read.records, policyRead);
|
|
3555
|
+
if (!attested.ok)
|
|
3556
|
+
return attested;
|
|
3557
|
+
const load = parsePolicy(policyRead, options);
|
|
3558
|
+
const opened = agentRequestability(load, input.cls);
|
|
3559
|
+
const resolution = resolve(load, input.cls);
|
|
3560
|
+
if (!opened.allowed && !(opened.humanOnly && opened.explicit && opened.pattern !== null)) {
|
|
3561
|
+
return refuse("class-not-agent-requestable", notAgentRequestable(input.cls, opened));
|
|
3562
|
+
}
|
|
3563
|
+
if (harnessLaunchNeedsRule(input.cls, resolution)) {
|
|
3564
|
+
return refuse("harness-launch-unruled", harnessLaunchUnruledRefusal(input.cls, `the proposal ${input.actionKey} was not registered and nothing was appended`));
|
|
3565
|
+
}
|
|
3566
|
+
if (resolution.autonomy === "human-only") {
|
|
3567
|
+
return refuse("class-human-only", humanOnlyRefusal(input.cls, `the proposal ${input.actionKey} was not registered and nothing was appended`));
|
|
3568
|
+
}
|
|
3569
|
+
const answered = (decision, state, seq, idempotent) => ({
|
|
3570
|
+
ok: true,
|
|
3571
|
+
task,
|
|
3572
|
+
actionKey: input.actionKey,
|
|
3573
|
+
cls: input.cls,
|
|
3574
|
+
payloadHash: hash,
|
|
3575
|
+
decision,
|
|
3576
|
+
state,
|
|
3577
|
+
seq,
|
|
3578
|
+
idempotent: idempotent && !appendedEarlier,
|
|
3579
|
+
});
|
|
3580
|
+
const existing = registeredAction(read.records, task, input.actionKey);
|
|
3581
|
+
let registeredNow = false;
|
|
3582
|
+
let withdrewNow = false;
|
|
3583
|
+
if (existing.ok) {
|
|
3584
|
+
const derivation = requestState(read.records, input.actionKey, tick(options), ttlOf(load));
|
|
3585
|
+
if (existing.action.payload_hash !== hash) {
|
|
3586
|
+
if (derivation.state === "requested") {
|
|
3587
|
+
return refuse("duplicate-request", `action ${input.actionKey} already has a live request at seq ${String(derivation.requestSeq)} awaiting a decision, over different bytes (${String(existing.action.payload_hash)}; this call presents ${hash}). A key is bound to the bytes it was first proposed with: wait for that request, withdraw it, or propose the new bytes under a new key. Nothing was appended.`, { state: derivation.state });
|
|
3588
|
+
}
|
|
3589
|
+
return refuse("payload-mismatch", `action ${input.actionKey} is registered under task ${task} with payload ${String(existing.action.payload_hash)}, and this call presents ${hash}. A key is bound to the bytes it was first proposed with; propose new bytes under a new key. Nothing was appended.`, { state: derivation.state });
|
|
3590
|
+
}
|
|
3591
|
+
// APRV-445 refutation (B1): the retry is judged by what the request still
|
|
3592
|
+
// AUTHORIZES, not by what it recorded. A live question, a usable grant, a
|
|
3593
|
+
// human's no and a started execution are answered from the log and append
|
|
3594
|
+
// nothing. A question that can no longer be answered usefully is asked
|
|
3595
|
+
// again under the same registration: a lapsed or withdrawn request, a grant
|
|
3596
|
+
// whose window ran out (a tap at hour 71 of 72 authorizes nothing at 73),
|
|
3597
|
+
// and a grant or pending request pinned to a policy that has since been
|
|
3598
|
+
// re-attested (`void`: the spend and the decision both refuse it).
|
|
3599
|
+
const standing = requestStanding(read.records, input.actionKey, tick(options), ttlOf(load), attested.sha256);
|
|
3600
|
+
const seqOf = derivation.decisionSeq ?? derivation.requestSeq;
|
|
3601
|
+
if (standing === "executed") {
|
|
3602
|
+
return answered(derivation.requestSeq !== null
|
|
3603
|
+
? "requested"
|
|
3604
|
+
: resolution.autonomy === "autonomous"
|
|
3605
|
+
? "autonomous"
|
|
3606
|
+
: "supervised", "executed", seqOf, true);
|
|
3607
|
+
}
|
|
3608
|
+
if (standing === "requested" ||
|
|
3609
|
+
standing === "granted" ||
|
|
3610
|
+
standing === "rejected" ||
|
|
3611
|
+
standing === "revoked") {
|
|
3612
|
+
return answered("requested", standing, seqOf, true);
|
|
3613
|
+
}
|
|
3614
|
+
if (standing === "void" && derivation.state === "requested") {
|
|
3615
|
+
// A pending question no human may decide any more. Taken back by its own
|
|
3616
|
+
// requester (the gate's only way to end a request early that is not a
|
|
3617
|
+
// decision), then asked again below.
|
|
3618
|
+
const retracted = withdraw(logPath, input.actionKey, actor, {
|
|
3619
|
+
...options,
|
|
3620
|
+
onlyIfVoid: true,
|
|
3621
|
+
reason: "superseded",
|
|
3622
|
+
note: "the policy this proposal was routed under has been re-attested, so the request is void; the same proposal is asked again under the policy in force",
|
|
3623
|
+
});
|
|
3624
|
+
if (!retracted.ok) {
|
|
3625
|
+
// A concurrent caller withdrew it, re-asked it, or a human answered it.
|
|
3626
|
+
if (retracted.code === "request-withdrawn" ||
|
|
3627
|
+
retracted.code === "already-decided" ||
|
|
3628
|
+
retracted.code === "duplicate-request" ||
|
|
3629
|
+
retracted.code === "expired") {
|
|
3630
|
+
return again(retracted);
|
|
3631
|
+
}
|
|
3632
|
+
return retracted;
|
|
3633
|
+
}
|
|
3634
|
+
withdrewNow = true;
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
else if (existing.code === "not-registered") {
|
|
3638
|
+
const registered = register(logPath, {
|
|
3639
|
+
task,
|
|
3640
|
+
envelope: {
|
|
3641
|
+
origin: { app: PROPOSE_ORIGIN_APP, created_by: actor },
|
|
3642
|
+
state: "proposed",
|
|
3643
|
+
actions: [
|
|
3644
|
+
{
|
|
3645
|
+
class: input.cls,
|
|
3646
|
+
summary: input.summary,
|
|
3647
|
+
idempotency_key: input.actionKey,
|
|
3648
|
+
payload_hash: hash,
|
|
3649
|
+
},
|
|
3650
|
+
],
|
|
3651
|
+
},
|
|
3652
|
+
}, actor, { ...options, proposal: true });
|
|
3653
|
+
if (!registered.ok) {
|
|
3654
|
+
// APRV-445 (L5). Two identical proposals racing: the other one registered
|
|
3655
|
+
// this exact task first. Its registration is ours byte for byte, so the
|
|
3656
|
+
// call is a retry and is answered as one.
|
|
3657
|
+
if (registered.code === "task-already-registered")
|
|
3658
|
+
return again(registered);
|
|
3659
|
+
return registered;
|
|
3660
|
+
}
|
|
3661
|
+
registeredNow = true;
|
|
3662
|
+
}
|
|
3663
|
+
else {
|
|
3664
|
+
// The derived task exists and declares some other key: nothing `propose`
|
|
3665
|
+
// registers looks like that, so somebody else registered this id.
|
|
3666
|
+
return refuse("task-already-registered", `task ${task} is already registered without action ${input.actionKey}; propose registers each derived task with exactly one action, so this id was registered by something else and is not reused. Nothing was appended.`);
|
|
3667
|
+
}
|
|
3668
|
+
const result = request(logPath, {
|
|
3669
|
+
task,
|
|
3670
|
+
actionKey: input.actionKey,
|
|
3671
|
+
cls: input.cls,
|
|
3672
|
+
summary: input.summary,
|
|
3673
|
+
payload_hash: hash,
|
|
3674
|
+
payload: { value: input.payload },
|
|
3675
|
+
execution: "harness",
|
|
3676
|
+
agentProposal: true,
|
|
3677
|
+
}, actor, options);
|
|
3678
|
+
if (!result.ok) {
|
|
3679
|
+
if (result.code === "duplicate-request" || result.code === "already-decided" || result.code === "already-executed") {
|
|
3680
|
+
return again(result);
|
|
3681
|
+
}
|
|
3682
|
+
return result;
|
|
3683
|
+
}
|
|
3684
|
+
if (result.record === null) {
|
|
3685
|
+
return answered(result.autonomy === "autonomous" ? "autonomous" : "supervised", null, null,
|
|
3686
|
+
// APRV-445 recheck (L-a): a call that withdrew a void request appended
|
|
3687
|
+
// something, so it is not an idempotent answer.
|
|
3688
|
+
!registeredNow && !withdrewNow);
|
|
3689
|
+
}
|
|
3690
|
+
return answered("requested", "requested", result.record.seq, false);
|
|
3691
|
+
}
|
|
3692
|
+
/**
|
|
3693
|
+
* `approval start <task> --action <key> --payload-json <json>` (APRV-445): record
|
|
3694
|
+
* the `execution.started` for an action its requester is about to carry out
|
|
3695
|
+
* itself, outside any harness hook.
|
|
3696
|
+
*
|
|
3697
|
+
* Until this verb an agent holding only the serve agent credential could ask and
|
|
3698
|
+
* wait, and could not say that it then acted: `approval run` spawns argv on the
|
|
3699
|
+
* gate's machine, and the harness hook writes starts only for tool calls it
|
|
3700
|
+
* classified. A proposal the policy authorizes outright (`autonomous`,
|
|
3701
|
+
* `supervised`) therefore left no record at all, and one a human granted left a
|
|
3702
|
+
* grant nobody spent, so a second publication of the same key was indistinguishable
|
|
3703
|
+
* from the first.
|
|
3704
|
+
*
|
|
3705
|
+
* It adds no authority. It writes through the two existing spenders and through
|
|
3706
|
+
* nothing else: {@link consumeHarnessGrant} when the key carries a request (it
|
|
3707
|
+
* refuses unless that request is granted, unexpired, harness-executed and
|
|
3708
|
+
* unspent, under the policy it was asked under), and {@link startHarnessExecution}
|
|
3709
|
+
* when it carries none (it refuses a `manual` or `human-only` class, an escalated
|
|
3710
|
+
* loop and a spent key, and charges the budgets). Both pin the single-use rule:
|
|
3711
|
+
* a second start of one key is `already-executed`.
|
|
3712
|
+
*
|
|
3713
|
+
* Two checks of its own, both before either spender runs. REQUESTER-ONLY: the
|
|
3714
|
+
* actor must be the one that registered the task (`not-requester` otherwise), as
|
|
3715
|
+
* `withdraw` is, so an agent cannot spend or exhaust a key someone else declared.
|
|
3716
|
+
* And the bytes: the caller presents the payload it is about to act on, and its
|
|
3717
|
+
* hash must equal the registered `payload_hash` (`payload-mismatch` otherwise),
|
|
3718
|
+
* so the thing done is the thing that was approved, which the grant path checks
|
|
3719
|
+
* again against the grant's own binding.
|
|
3720
|
+
*/
|
|
3721
|
+
export function startProposed(logPath, task, actionKey, payload, actor, options = {}) {
|
|
3722
|
+
if (!isPrincipalActor(actor)) {
|
|
3723
|
+
return refuse("actor-invalid", `start requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
|
|
3724
|
+
}
|
|
3725
|
+
let hash;
|
|
3726
|
+
try {
|
|
3727
|
+
hash = hashOfPayload(payload);
|
|
3728
|
+
}
|
|
3729
|
+
catch (cause) {
|
|
3730
|
+
return refuse("payload-store-failed", `the payload for ${actionKey} could not be canonicalized: ${cause instanceof Error ? cause.message : String(cause)}. Nothing was appended.`);
|
|
3731
|
+
}
|
|
3732
|
+
if (!isProposalTask(task)) {
|
|
3733
|
+
return refuse("task-not-proposal", `task ${task} was not registered by \`approval propose\`; start records the executions of proposals only. Nothing was appended.`);
|
|
3734
|
+
}
|
|
3735
|
+
const read = readGateRecords(logPath);
|
|
3736
|
+
if (!read.ok)
|
|
3737
|
+
return read;
|
|
3738
|
+
const declared = registeredAction(read.records, task, actionKey);
|
|
3739
|
+
if (!declared.ok)
|
|
3740
|
+
return declared;
|
|
3741
|
+
let registrant = null;
|
|
3742
|
+
for (const record of read.records) {
|
|
3743
|
+
if (record.event === "task.registered" && record.task === task)
|
|
3744
|
+
registrant = record.actor;
|
|
3745
|
+
}
|
|
3746
|
+
if (registrant !== actor) {
|
|
3747
|
+
return refuse("not-requester", `task ${task} was registered by ${String(registrant)}, and only its registrant may record that it is carrying the action out; ${actor} may not. Nothing was appended.`);
|
|
3748
|
+
}
|
|
3749
|
+
if (declared.action.payload_hash !== hash) {
|
|
3750
|
+
return refuse("payload-mismatch", `the payload presented for ${actionKey} hashes to ${hash}, but the action is registered with ${String(declared.action.payload_hash)}. What is carried out must be the bytes that were proposed; nothing was appended.`);
|
|
3751
|
+
}
|
|
3752
|
+
// APRV-445 refutation (S5). Only a request that can still answer binds the
|
|
3753
|
+
// key to the grant path: one that is pending (spending it refuses until a
|
|
3754
|
+
// human decides) or granted and unspent. A withdrawn, expired, rejected or
|
|
3755
|
+
// revoked request says nothing about an action the policy authorizes on its
|
|
3756
|
+
// own now, so after a re-tiering to autonomous the start is the policy's.
|
|
3757
|
+
// This read only routes; the policy path re-judges inside its own append
|
|
3758
|
+
// (`proposalPolicyStartRefusal`): a live request, a human's no under the
|
|
3759
|
+
// policy in force, and a selected supervised-live draw each refuse there.
|
|
3760
|
+
const derivation = requestState(read.records, actionKey, tick(options), ttlOf(parsePolicy(readPolicyOnce(options), options)));
|
|
3761
|
+
const binding = derivation.execution.started === null &&
|
|
3762
|
+
(derivation.state === "requested" || derivation.state === "granted");
|
|
3763
|
+
if (binding) {
|
|
3764
|
+
const spent = consumeHarnessGrant(logPath, actionKey, actor, {
|
|
3765
|
+
...options,
|
|
3766
|
+
presentedPayloadHash: hash,
|
|
3767
|
+
spendingTask: task,
|
|
3768
|
+
});
|
|
3769
|
+
if (!spent.ok)
|
|
3770
|
+
return spent;
|
|
3771
|
+
return { ok: true, task, actionKey, cls: declared.action.class, authorization: "grant", record: spent.record };
|
|
3772
|
+
}
|
|
3773
|
+
const started = startHarnessExecution(logPath, {
|
|
3774
|
+
task,
|
|
3775
|
+
actionKey,
|
|
3776
|
+
cls: declared.action.class,
|
|
3777
|
+
payload_hash: hash,
|
|
3778
|
+
...(declared.action.est_cost_usd === undefined ? {} : { est_cost_usd: declared.action.est_cost_usd }),
|
|
3779
|
+
}, actor, options);
|
|
3780
|
+
if (!started.ok)
|
|
3781
|
+
return started;
|
|
3782
|
+
return { ok: true, task, actionKey, cls: declared.action.class, authorization: "policy", record: started.record };
|
|
3783
|
+
}
|
|
3784
|
+
export function requestStanding(records, actionKey, ts, ttlMs, attestedSha256) {
|
|
3785
|
+
const derivation = requestState(records, actionKey, ts, ttlMs);
|
|
3786
|
+
if (derivation.execution.started !== null)
|
|
3787
|
+
return "executed";
|
|
3788
|
+
if (derivation.state === "granted") {
|
|
3789
|
+
if (grantLapsed(derivation, ts))
|
|
3790
|
+
return "expired";
|
|
3791
|
+
// APRV-445 recheck (B). A grant is void only where its SPEND refuses
|
|
3792
|
+
// `policy-drift`, which is the harness spend (`consumeHarnessGrant`) and
|
|
3793
|
+
// therefore every proposal. A token grant is spent by `approval run`
|
|
3794
|
+
// through `consumeToken`, which does not compare policy hashes, so after a
|
|
3795
|
+
// re-attest that grant still runs: calling it void would abort the
|
|
3796
|
+
// documented `wait && run` flow and send an agent to ask again over a live
|
|
3797
|
+
// human grant.
|
|
3798
|
+
const driftEnforced = derivation.declared.execution === "harness" || isProposalTask(derivation.task);
|
|
3799
|
+
const pinned = grantedPolicyHash(records, derivation.decisionSeq) ?? derivation.declared.policy_sha256;
|
|
3800
|
+
if (driftEnforced && attestedSha256 !== null && pinned !== null && pinned !== attestedSha256) {
|
|
3801
|
+
return "void";
|
|
3802
|
+
}
|
|
3803
|
+
return "granted";
|
|
3804
|
+
}
|
|
3805
|
+
if (derivation.state === "requested") {
|
|
3806
|
+
const pinned = derivation.declared.policy_sha256;
|
|
3807
|
+
if (attestedSha256 !== null && pinned !== null && pinned !== attestedSha256)
|
|
3808
|
+
return "void";
|
|
3809
|
+
return "requested";
|
|
3810
|
+
}
|
|
3811
|
+
if (derivation.state === "withdrawn" && derivation.decisionSeq !== null) {
|
|
3812
|
+
for (const record of records) {
|
|
3813
|
+
if (record.seq !== derivation.decisionSeq)
|
|
3814
|
+
continue;
|
|
3815
|
+
if (payloadOf(record)["reason"] === "policy-drift")
|
|
3816
|
+
return "void";
|
|
3817
|
+
}
|
|
3818
|
+
}
|
|
3819
|
+
return derivation.state;
|
|
3820
|
+
}
|
|
3821
|
+
/**
|
|
3822
|
+
* The SHA-256 the live policy bytes are attested under, or `null` when they are
|
|
3823
|
+
* not attested (unread, unattested, or changed since). One read of the file
|
|
3824
|
+
* through the same seam every gate operation uses.
|
|
3825
|
+
*/
|
|
3826
|
+
export function attestedPolicySha256(records, options = {}) {
|
|
3827
|
+
const attested = requireAttestation(records, readPolicyOnce(options));
|
|
3828
|
+
return attested.ok ? attested.sha256 : null;
|
|
3829
|
+
}
|
|
3002
3830
|
//# sourceMappingURL=gate.js.map
|