approval-md 0.3.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 +83 -914
- package/SPEC.md +98 -13
- 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 +19 -0
- package/dist/src/channels/contract.js +105 -1
- 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 +271 -1
- package/dist/src/channels/telegram.js +403 -45
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +350 -4
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- 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 +272 -26
- package/dist/src/cli/channel-telegram.js +478 -41
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +341 -20
- 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 +9 -1
- package/dist/src/cli/help.js +246 -38
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +371 -3
- package/dist/src/cli/hook.js +1926 -44
- package/dist/src/cli/hook.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 +51 -0
- 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/scaffold.js +6 -0
- 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/up.js +21 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +337 -39
- 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/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 +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +55 -3
- package/dist/src/core/audit.js +52 -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/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +760 -8
- package/dist/src/core/command-class.js.map +1 -1
- 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/gate.d.ts +306 -5
- package/dist/src/core/gate.js +772 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -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/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 +50 -21
- 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 +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +23 -0
- package/dist/src/core/policy-proposal.js +8 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- 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/sender-identity.d.ts +35 -10
- package/dist/src/core/sender-identity.js +48 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- 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/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/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 +298 -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 +22 -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 +1633 -43
- package/package.json +4 -2
- package/schema/event.schema.json +59 -2
- 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/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-requested-harness-cap.json +18 -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/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -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/policy.schema.json +50 -6
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 { harnessLaunchNeedsRule, harnessLaunchUnruledRefusal, 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";
|
|
@@ -533,6 +536,50 @@ export const GATE_REFUSAL_CODES = [
|
|
|
533
536
|
* nothing can ever open. Nothing is appended; the next attempt asks again.
|
|
534
537
|
*/
|
|
535
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",
|
|
536
583
|
];
|
|
537
584
|
function refuse(code, message, extra = {}) {
|
|
538
585
|
return { ok: false, code, message, ...extra };
|
|
@@ -665,6 +712,21 @@ function requireAttestation(records, read) {
|
|
|
665
712
|
function ttlOf(load) {
|
|
666
713
|
return load.ok ? load.durations.approvalTtlMs : null;
|
|
667
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
|
+
}
|
|
668
730
|
function budgetScopeOf(load, resolution) {
|
|
669
731
|
return {
|
|
670
732
|
classLimits: resolution.limits,
|
|
@@ -910,6 +972,15 @@ function attemptRegister(logPath, source, actor, options) {
|
|
|
910
972
|
}
|
|
911
973
|
return resolved.refusal;
|
|
912
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
|
+
}
|
|
913
984
|
const validation = validate("envelope", resolved.envelope, options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
|
|
914
985
|
if (!validation.ok) {
|
|
915
986
|
return refuse("envelope-invalid", `the envelope failed schema validation; nothing was appended`, { errors: validation.errors });
|
|
@@ -965,13 +1036,17 @@ function attemptRegister(logPath, source, actor, options) {
|
|
|
965
1036
|
return { ok: true, record: appended.record, task: resolved.task, actions };
|
|
966
1037
|
}
|
|
967
1038
|
/**
|
|
968
|
-
* The
|
|
969
|
-
*
|
|
970
|
-
* SPEC.md §
|
|
971
|
-
*
|
|
972
|
-
*
|
|
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.
|
|
973
1048
|
*/
|
|
974
|
-
export function
|
|
1049
|
+
export function taskRegistration(records, task) {
|
|
975
1050
|
let registration = null;
|
|
976
1051
|
for (const record of records) {
|
|
977
1052
|
if (record.event === "task.registered" && record.task === task)
|
|
@@ -980,6 +1055,20 @@ export function registeredAction(records, task, actionKey) {
|
|
|
980
1055
|
if (registration === null) {
|
|
981
1056
|
return refuse("not-registered", `task ${task} has no task.registered record; run \`approval register <task-file>\` first`);
|
|
982
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;
|
|
983
1072
|
const declared = payloadOf(registration)["actions"];
|
|
984
1073
|
const actions = Array.isArray(declared) ? declared : [];
|
|
985
1074
|
for (const entry of actions) {
|
|
@@ -1340,6 +1429,21 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1340
1429
|
return attested;
|
|
1341
1430
|
const load = parsePolicy(policyRead, options);
|
|
1342
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
|
+
}
|
|
1343
1447
|
// APRV-185, amended SPEC.md §5.2, and the first thing intake asks once the
|
|
1344
1448
|
// class has an autonomy: a `human-only` class is not requestable. The policy
|
|
1345
1449
|
// itself answers, so nothing is put in front of a human, nothing is drawn,
|
|
@@ -1547,6 +1651,17 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1547
1651
|
if (derivation.execution.started !== null) {
|
|
1548
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 });
|
|
1549
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
|
+
}
|
|
1550
1665
|
// APRV-173, SPEC.md §5.2's request-volume limits, enforced here and nowhere
|
|
1551
1666
|
// else. Placed AFTER the legality checks above and BEFORE budgets, and both
|
|
1552
1667
|
// halves of that placement are deliberate.
|
|
@@ -1715,6 +1830,13 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1715
1830
|
payload["execution"] = input.execution;
|
|
1716
1831
|
if (input.wait_until !== undefined)
|
|
1717
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;
|
|
1718
1840
|
const appended = append(logPath, {
|
|
1719
1841
|
ts,
|
|
1720
1842
|
event: "approval.requested",
|
|
@@ -1889,7 +2011,7 @@ function attemptDecide(logPath, actionKey, decision, actor, options) {
|
|
|
1889
2011
|
materialised = logged.record;
|
|
1890
2012
|
}
|
|
1891
2013
|
const message = derivation.expiredLazily
|
|
1892
|
-
? `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.`
|
|
1893
2015
|
: `action ${actionKey} expired at ${String(derivation.decisionTs)} (approval.expired, seq ${String(derivation.decisionSeq)}); an expired request is terminal`;
|
|
1894
2016
|
return refuse("expired", message, materialised === undefined
|
|
1895
2017
|
? { state: derivation.state }
|
|
@@ -2227,13 +2349,20 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2227
2349
|
if (logged.ok)
|
|
2228
2350
|
materialised = logged.record;
|
|
2229
2351
|
}
|
|
2230
|
-
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
|
|
2231
2353
|
? { state: derivation.state }
|
|
2232
2354
|
: { state: derivation.state, record: materialised });
|
|
2233
2355
|
}
|
|
2234
2356
|
if (derivation.state !== "requested") {
|
|
2235
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 });
|
|
2236
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
|
+
}
|
|
2237
2366
|
if (derivation.requestActor !== actor) {
|
|
2238
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 });
|
|
2239
2368
|
}
|
|
@@ -2282,8 +2411,9 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2282
2411
|
* refused at the append in {@link consumeHarnessGrant}. The single-use rule
|
|
2283
2412
|
* is the gate's existing one; this only stops the caller from queueing up a
|
|
2284
2413
|
* write that would be refused.
|
|
2285
|
-
* - *within the TTL*: state is derived at `ts` with `ttlMs`,
|
|
2286
|
-
*
|
|
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
|
|
2287
2417
|
* materialised an `approval.expired` record.
|
|
2288
2418
|
*
|
|
2289
2419
|
* PURE, and reads only records the caller verified — the enforcement path never
|
|
@@ -2292,7 +2422,7 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2292
2422
|
* request that came after it, exactly as {@link requestState} treats cycles.
|
|
2293
2423
|
*/
|
|
2294
2424
|
/**
|
|
2295
|
-
* Has a GRANTED request outlived
|
|
2425
|
+
* Has a GRANTED request outlived the window it was asked under?
|
|
2296
2426
|
*
|
|
2297
2427
|
* `requestState` reports a decided request by its decision forever: the TTL
|
|
2298
2428
|
* bounds the window in which a human may answer, not the answer's shelf life.
|
|
@@ -2305,7 +2435,14 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2305
2435
|
* Unparseable instants read as lapsed, and a policy with no TTL declares no
|
|
2306
2436
|
* lapse at all — both exactly as `core/token.ts` reads them.
|
|
2307
2437
|
*/
|
|
2308
|
-
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;
|
|
2309
2446
|
if (ttlMs === null)
|
|
2310
2447
|
return false;
|
|
2311
2448
|
const requestedAt = Date.parse(derivation.requestTs ?? "");
|
|
@@ -2314,7 +2451,63 @@ function grantLapsed(derivation, ts, ttlMs) {
|
|
|
2314
2451
|
return true;
|
|
2315
2452
|
return asked > requestedAt + ttlMs;
|
|
2316
2453
|
}
|
|
2317
|
-
|
|
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) {
|
|
2318
2511
|
if (!isPayloadHash(payloadHash))
|
|
2319
2512
|
return null;
|
|
2320
2513
|
// Distinct keys, latest request first: a later question about the same bytes
|
|
@@ -2331,6 +2524,11 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
|
|
|
2331
2524
|
continue;
|
|
2332
2525
|
if (keys.includes(record.action_key))
|
|
2333
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;
|
|
2334
2532
|
const payload = payloadOf(record);
|
|
2335
2533
|
if (payload["execution"] !== "harness")
|
|
2336
2534
|
continue;
|
|
@@ -2354,7 +2552,13 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
|
|
|
2354
2552
|
continue;
|
|
2355
2553
|
if (derivation.state === "granted") {
|
|
2356
2554
|
// An answer has a shelf life, and it is its request's TTL.
|
|
2357
|
-
|
|
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))
|
|
2358
2562
|
continue;
|
|
2359
2563
|
return {
|
|
2360
2564
|
actionKey,
|
|
@@ -2424,6 +2628,43 @@ export const HARNESS_GRANT_ORIGIN = "grant_origin";
|
|
|
2424
2628
|
* every reader treats absence as "no second name" rather than as a fault.
|
|
2425
2629
|
*/
|
|
2426
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
|
+
}
|
|
2427
2668
|
/**
|
|
2428
2669
|
* Spend a harness grant, exactly once (APRV-117).
|
|
2429
2670
|
*
|
|
@@ -2537,8 +2778,8 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
|
|
|
2537
2778
|
if (derivation.declared.execution !== "harness") {
|
|
2538
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 });
|
|
2539
2780
|
}
|
|
2540
|
-
if (grantLapsed(derivation, ts
|
|
2541
|
-
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 });
|
|
2542
2783
|
}
|
|
2543
2784
|
if (derivation.task === null) {
|
|
2544
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 });
|
|
@@ -2614,6 +2855,11 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
|
|
|
2614
2855
|
options.spendingTask === derivation.task
|
|
2615
2856
|
? "direct"
|
|
2616
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),
|
|
2617
2863
|
};
|
|
2618
2864
|
// APRV-287. A carried spend records WHICH tool call spent it, so the
|
|
2619
2865
|
// completion counterpart can find this start from the event that reports how
|
|
@@ -2765,6 +3011,16 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2765
3011
|
if (!isPayloadHash(bytes)) {
|
|
2766
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.`);
|
|
2767
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
|
+
}
|
|
2768
3024
|
const cost = costOf(input.est_cost_usd);
|
|
2769
3025
|
const budget = evaluateBudgetsWithTask(read.records, budgetScopeOf(load, resolution), { class: input.cls, est_cost_usd: cost }, ts, input.task);
|
|
2770
3026
|
if (!budget.pass) {
|
|
@@ -2798,6 +3054,11 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2798
3054
|
// APRV-146: unconditional, because a caller that states no bytes was refused
|
|
2799
3055
|
// above. The record says what ran, not only that something did.
|
|
2800
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),
|
|
2801
3062
|
};
|
|
2802
3063
|
const appended = append(logPath, {
|
|
2803
3064
|
ts,
|
|
@@ -2814,6 +3075,54 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2814
3075
|
return appended;
|
|
2815
3076
|
return { ok: true, record: appended.record };
|
|
2816
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
|
+
}
|
|
2817
3126
|
// ---------------------------------------------------------------------------
|
|
2818
3127
|
// finishHarnessExecution — the completion counterpart (APRV-145)
|
|
2819
3128
|
// ---------------------------------------------------------------------------
|
|
@@ -3008,9 +3317,19 @@ function appendExpiry(logPath, derivation, load, ts, options, expectedHead) {
|
|
|
3008
3317
|
const payload = {};
|
|
3009
3318
|
if (derivation.requestTs !== null)
|
|
3010
3319
|
payload["requested_ts"] = derivation.requestTs;
|
|
3011
|
-
|
|
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;
|
|
3012
3328
|
if (ttlMs !== null)
|
|
3013
3329
|
payload["ttl_ms"] = ttlMs;
|
|
3330
|
+
if (derivation.declared.harness_cap_ms !== null) {
|
|
3331
|
+
payload["harness_cap_ms"] = derivation.declared.harness_cap_ms;
|
|
3332
|
+
}
|
|
3014
3333
|
const onExpiry = load.ok ? load.policy.defaults?.on_expiry : undefined;
|
|
3015
3334
|
if (onExpiry !== undefined)
|
|
3016
3335
|
payload["on_expiry"] = onExpiry;
|
|
@@ -3063,9 +3382,9 @@ export function expire(logPath, actionKey, options = {}) {
|
|
|
3063
3382
|
if (derivation.state !== "requested") {
|
|
3064
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 });
|
|
3065
3384
|
}
|
|
3066
|
-
return refuse("not-expired",
|
|
3067
|
-
? `action ${actionKey} cannot expire: the policy declares no defaults.approval_ttl
|
|
3068
|
-
: `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 });
|
|
3069
3388
|
}
|
|
3070
3389
|
const expired = appendExpiry(logPath, derivation, load, ts, options, read.head);
|
|
3071
3390
|
if (expired.ok) {
|
|
@@ -3079,4 +3398,433 @@ export function expire(logPath, actionKey, options = {}) {
|
|
|
3079
3398
|
}
|
|
3080
3399
|
return expired;
|
|
3081
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
|
+
}
|
|
3082
3830
|
//# sourceMappingURL=gate.js.map
|