approval-md 0.3.0 → 0.4.1
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 +101 -13
- package/cli.js +134 -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 +72 -0
- package/dist/src/channels/contract.js +161 -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.d.ts +25 -0
- package/dist/src/channels/tagging.js +63 -6
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- 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 +725 -20
- package/dist/src/channels/telegram.js +1332 -123
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +358 -5
- 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/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +22 -10
- package/dist/src/cli/audit.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 +275 -0
- package/dist/src/cli/channel-telegram-webhook.js +716 -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 +648 -56
- 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 +29 -1
- package/dist/src/cli/execute.js +387 -21
- 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 +10 -1
- package/dist/src/cli/help.js +283 -54
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +485 -6
- package/dist/src/cli/hook.js +2370 -70
- 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/log-unlock.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +109 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +72 -4
- 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 +26 -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 +338 -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 +153 -12
- package/dist/src/core/audit.js +415 -20
- 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 +766 -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 +364 -6
- package/dist/src/core/gate.js +802 -27
- 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-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- 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 +130 -22
- package/dist/src/core/log.js +473 -50
- 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 +40 -0
- package/dist/src/core/policy-proposal.js +37 -16
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.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 +59 -11
- package/dist/src/core/sender-identity.js +79 -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/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.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 +166 -0
- package/dist/src/serve/archive.js +363 -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 +768 -0
- package/docs/cli-reference.md +2139 -67
- package/package.json +4 -2
- package/schema/event.schema.json +413 -30
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -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/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/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +16 -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 +102 -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";
|
|
@@ -161,8 +164,12 @@ const HUMAN_ACTOR = /^human:.+/u;
|
|
|
161
164
|
* language. An empty list names nobody and therefore matches nobody;
|
|
162
165
|
* `approvers` carries `minItems: 1`, so a valid policy cannot produce one, and
|
|
163
166
|
* that branch stays a fail-closed backstop rather than a reachable path.
|
|
167
|
+
*
|
|
168
|
+
* Exported for `core/audit.ts` (APRV-483), whose review check is this one
|
|
169
|
+
* comparison applied to a reviewer, so a grant and a review can never disagree
|
|
170
|
+
* about who is on a roster.
|
|
164
171
|
*/
|
|
165
|
-
function namesApprover(approvers, actor) {
|
|
172
|
+
export function namesApprover(approvers, actor) {
|
|
166
173
|
const bare = actor.startsWith("human:") ? actor.slice("human:".length) : actor;
|
|
167
174
|
return approvers.some((name) => name === actor || name === bare);
|
|
168
175
|
}
|
|
@@ -533,6 +540,50 @@ export const GATE_REFUSAL_CODES = [
|
|
|
533
540
|
* nothing can ever open. Nothing is appended; the next attempt asks again.
|
|
534
541
|
*/
|
|
535
542
|
"token-delivery-unavailable",
|
|
543
|
+
/**
|
|
544
|
+
* `approval propose` named a class the policy has not opened to agents
|
|
545
|
+
* (APRV-445, amended SPEC.md §5.2): the class is not an exact key of
|
|
546
|
+
* `policy.classes`, or neither that key nor a declared `<prefix>.*` family of
|
|
547
|
+
* it sets `agent_may_request: true`. Nothing is registered and nothing is
|
|
548
|
+
* appended. The repair is the operator's, a policy edit and a re-attestation;
|
|
549
|
+
* no retry by the agent changes it.
|
|
550
|
+
*/
|
|
551
|
+
"class-not-agent-requestable",
|
|
552
|
+
/**
|
|
553
|
+
* `approval propose` / `approval start` carried a payload larger than
|
|
554
|
+
* {@link PROPOSE_PAYLOAD_MAX_BYTES} (APRV-445). Refused before the bytes are
|
|
555
|
+
* parsed or hashed; nothing is stored and nothing is appended.
|
|
556
|
+
*/
|
|
557
|
+
"payload-too-large",
|
|
558
|
+
/**
|
|
559
|
+
* `approval request` (or a task FILE's registration) named a `propose:` task
|
|
560
|
+
* (APRV-445 refutation, S1). A proposal is requested only through `propose`,
|
|
561
|
+
* which re-checks the operator's opening of its class; a plain request on the
|
|
562
|
+
* same task would skip that check, and a file-registered `propose:` id would
|
|
563
|
+
* borrow the exclusions proposals get from the harness hook.
|
|
564
|
+
*/
|
|
565
|
+
"task-is-proposal",
|
|
566
|
+
/**
|
|
567
|
+
* `approval start` named a task `propose` did not register (APRV-445, L9).
|
|
568
|
+
* `start` records executions of proposals and of nothing else; every other
|
|
569
|
+
* action executes through `approval run`, an adapter, or the hook.
|
|
570
|
+
*/
|
|
571
|
+
"task-not-proposal",
|
|
572
|
+
/**
|
|
573
|
+
* `approval propose --key` does not begin with `<class>:` (APRV-445, S4). The
|
|
574
|
+
* key names the class it was proposed under, so a key cannot be read back
|
|
575
|
+
* later as belonging to a different class than its record says.
|
|
576
|
+
*/
|
|
577
|
+
"key-class-mismatch",
|
|
578
|
+
/**
|
|
579
|
+
* `approval propose` found the log moving under it on every one of its
|
|
580
|
+
* bounded attempts (APRV-445 recheck 3): each re-read showed an answer the
|
|
581
|
+
* next append was refused for. Exit 1, nothing more was appended, and the
|
|
582
|
+
* proposal's standing is whatever the log now says; read it with `wait
|
|
583
|
+
* --timeout 0` or call again. Distinct from `already-decided`, which would
|
|
584
|
+
* tell a caller a proposal is answered when it may stand granted or pending.
|
|
585
|
+
*/
|
|
586
|
+
"contended",
|
|
536
587
|
];
|
|
537
588
|
function refuse(code, message, extra = {}) {
|
|
538
589
|
return { ok: false, code, message, ...extra };
|
|
@@ -571,7 +622,13 @@ export function readGateRecords(logPath, schemaDir) {
|
|
|
571
622
|
* `checkAttestation` reports `unreadable` and the gate refuses — a missing
|
|
572
623
|
* policy is never a pass.
|
|
573
624
|
*/
|
|
574
|
-
|
|
625
|
+
/**
|
|
626
|
+
* The policy file a gate operation reads: `policy.file`, or the first policy
|
|
627
|
+
* filename found in `policy.dir` (default: the working directory). Exported for
|
|
628
|
+
* `core/audit.ts` (APRV-483), whose roster check must read the same file a
|
|
629
|
+
* grant would and hold it to the same attestation.
|
|
630
|
+
*/
|
|
631
|
+
export function policyPathOf(options) {
|
|
575
632
|
const policy = options.policy ?? {};
|
|
576
633
|
if (policy.file !== undefined)
|
|
577
634
|
return policy.file;
|
|
@@ -665,6 +722,21 @@ function requireAttestation(records, read) {
|
|
|
665
722
|
function ttlOf(load) {
|
|
666
723
|
return load.ok ? load.durations.approvalTtlMs : null;
|
|
667
724
|
}
|
|
725
|
+
/**
|
|
726
|
+
* The clause a refusal adds when a harness ceiling, rather than the policy,
|
|
727
|
+
* fixed this request's deadline (APRV-423).
|
|
728
|
+
*
|
|
729
|
+
* Empty for every request that declared no cap, which is every request made
|
|
730
|
+
* outside a harness hook — so the messages a human has been reading since v0.1
|
|
731
|
+
* are unchanged, and the one new sentence appears exactly where it explains
|
|
732
|
+
* something the policy alone does not.
|
|
733
|
+
*/
|
|
734
|
+
function cappedBy(derivation) {
|
|
735
|
+
const cap = derivation.declared.harness_cap_ms;
|
|
736
|
+
if (cap === null)
|
|
737
|
+
return "";
|
|
738
|
+
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)`;
|
|
739
|
+
}
|
|
668
740
|
function budgetScopeOf(load, resolution) {
|
|
669
741
|
return {
|
|
670
742
|
classLimits: resolution.limits,
|
|
@@ -910,6 +982,15 @@ function attemptRegister(logPath, source, actor, options) {
|
|
|
910
982
|
}
|
|
911
983
|
return resolved.refusal;
|
|
912
984
|
}
|
|
985
|
+
// APRV-445 refutation (S1). The `propose:` namespace belongs to `propose`;
|
|
986
|
+
// a task FILE claiming an id in it would borrow the exclusions proposals get
|
|
987
|
+
// from the harness hook (its sweep and its carry).
|
|
988
|
+
// Recheck L-c: the in-memory sources too (the muse `POST /v1/registrations`
|
|
989
|
+
// route registers whatever task id its caller names); only `propose` itself
|
|
990
|
+
// passes `proposal: true`.
|
|
991
|
+
if (isProposalTask(resolved.task) && options.proposal !== true) {
|
|
992
|
+
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.`);
|
|
993
|
+
}
|
|
913
994
|
const validation = validate("envelope", resolved.envelope, options.schemaDir === undefined ? {} : { schemaDir: options.schemaDir });
|
|
914
995
|
if (!validation.ok) {
|
|
915
996
|
return refuse("envelope-invalid", `the envelope failed schema validation; nothing was appended`, { errors: validation.errors });
|
|
@@ -965,13 +1046,17 @@ function attemptRegister(logPath, source, actor, options) {
|
|
|
965
1046
|
return { ok: true, record: appended.record, task: resolved.task, actions };
|
|
966
1047
|
}
|
|
967
1048
|
/**
|
|
968
|
-
* The
|
|
969
|
-
*
|
|
970
|
-
* SPEC.md §
|
|
971
|
-
*
|
|
972
|
-
*
|
|
1049
|
+
* The `task.registered` record for `task`, or the `not-registered` refusal.
|
|
1050
|
+
*
|
|
1051
|
+
* The one raise site for SPEC.md §11.2's `not-registered` ("no task.registered
|
|
1052
|
+
* record exists for the task id"). `request` reaches it through
|
|
1053
|
+
* {@link registeredAction}; `approval wait` calls it directly (APRV-428), so a
|
|
1054
|
+
* wait on a task the log has never seen is refused in the same words rather
|
|
1055
|
+
* than answered as a vacuous grant. The latest registration wins, as it always
|
|
1056
|
+
* has for `registeredAction`; the register path refuses a second one, so a log
|
|
1057
|
+
* carrying two was written by something else.
|
|
973
1058
|
*/
|
|
974
|
-
export function
|
|
1059
|
+
export function taskRegistration(records, task) {
|
|
975
1060
|
let registration = null;
|
|
976
1061
|
for (const record of records) {
|
|
977
1062
|
if (record.event === "task.registered" && record.task === task)
|
|
@@ -980,6 +1065,20 @@ export function registeredAction(records, task, actionKey) {
|
|
|
980
1065
|
if (registration === null) {
|
|
981
1066
|
return refuse("not-registered", `task ${task} has no task.registered record; run \`approval register <task-file>\` first`);
|
|
982
1067
|
}
|
|
1068
|
+
return { ok: true, record: registration };
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* The declared action for `(task, actionKey)`, as registered in the log.
|
|
1072
|
+
*
|
|
1073
|
+
* SPEC.md §7: "an action's class MUST be declared before an execution token can
|
|
1074
|
+
* be requested for it". The declaration lives in `task.registered`, so the log —
|
|
1075
|
+
* not the file, which may have been edited since — is what the gate reads back.
|
|
1076
|
+
*/
|
|
1077
|
+
export function registeredAction(records, task, actionKey) {
|
|
1078
|
+
const registered = taskRegistration(records, task);
|
|
1079
|
+
if (!registered.ok)
|
|
1080
|
+
return registered;
|
|
1081
|
+
const registration = registered.record;
|
|
983
1082
|
const declared = payloadOf(registration)["actions"];
|
|
984
1083
|
const actions = Array.isArray(declared) ? declared : [];
|
|
985
1084
|
for (const entry of actions) {
|
|
@@ -1340,6 +1439,21 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1340
1439
|
return attested;
|
|
1341
1440
|
const load = parsePolicy(policyRead, options);
|
|
1342
1441
|
const resolution = resolve(load, input.cls, input.reversible === undefined ? {} : { reversible: input.reversible });
|
|
1442
|
+
// APRV-445 refutation (S1). A `propose:` task is requested through `propose`
|
|
1443
|
+
// and nothing else, so its agent-requestability check cannot be skipped by
|
|
1444
|
+
// asking for the same registration with a plain `request`.
|
|
1445
|
+
if (isProposalTask(input.task) && input.agentProposal !== true) {
|
|
1446
|
+
return refuse("task-is-proposal", `task ${input.task} was registered by \`approval propose\` and is requested only through it; nothing was appended`);
|
|
1447
|
+
}
|
|
1448
|
+
// APRV-445. A proposal's class came from the requester, so the operator's
|
|
1449
|
+
// opening of that class to agents is re-read here, from the same bytes the
|
|
1450
|
+
// attestation above verified, before anything else can be decided for it.
|
|
1451
|
+
if (input.agentProposal === true) {
|
|
1452
|
+
const opened = agentRequestability(load, input.cls);
|
|
1453
|
+
if (!opened.allowed) {
|
|
1454
|
+
return refuse("class-not-agent-requestable", notAgentRequestable(input.cls, opened));
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1343
1457
|
// APRV-185, amended SPEC.md §5.2, and the first thing intake asks once the
|
|
1344
1458
|
// class has an autonomy: a `human-only` class is not requestable. The policy
|
|
1345
1459
|
// itself answers, so nothing is put in front of a human, nothing is drawn,
|
|
@@ -1547,6 +1661,17 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1547
1661
|
if (derivation.execution.started !== null) {
|
|
1548
1662
|
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
1663
|
}
|
|
1664
|
+
// APRV-445 recheck (SF1). A proposal is asked again only when its last
|
|
1665
|
+
// answer can no longer be used, and that is judged HERE, against the records
|
|
1666
|
+
// this attempt appends on, not against whatever its caller read earlier. A
|
|
1667
|
+
// usable grant (unexpired, under the policy in force) or a human's refusal
|
|
1668
|
+
// stands: a stale caller may not put a fresh question over it.
|
|
1669
|
+
if (input.agentProposal === true) {
|
|
1670
|
+
const standing = requestStanding(read.records, input.actionKey, ts, ttlOf(load), attested.sha256);
|
|
1671
|
+
if (standing === "granted" || standing === "rejected" || standing === "revoked") {
|
|
1672
|
+
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 });
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1550
1675
|
// APRV-173, SPEC.md §5.2's request-volume limits, enforced here and nowhere
|
|
1551
1676
|
// else. Placed AFTER the legality checks above and BEFORE budgets, and both
|
|
1552
1677
|
// halves of that placement are deliberate.
|
|
@@ -1715,6 +1840,13 @@ function attemptRequest(logPath, input, actor, options) {
|
|
|
1715
1840
|
payload["execution"] = input.execution;
|
|
1716
1841
|
if (input.wait_until !== undefined)
|
|
1717
1842
|
payload["wait_until"] = input.wait_until;
|
|
1843
|
+
// APRV-423, on the same terms as the two above: recorded because the log is
|
|
1844
|
+
// the only place a later reader — the daemon's sweep, a channel, the retry
|
|
1845
|
+
// that adopts this question — can learn what bounded it, and it cannot
|
|
1846
|
+
// lengthen anything. `core/state.ts` takes the minimum of this and the
|
|
1847
|
+
// policy's TTL; see `RequestInput.harnessCapMs`.
|
|
1848
|
+
if (input.harnessCapMs !== undefined)
|
|
1849
|
+
payload["harness_cap_ms"] = input.harnessCapMs;
|
|
1718
1850
|
const appended = append(logPath, {
|
|
1719
1851
|
ts,
|
|
1720
1852
|
event: "approval.requested",
|
|
@@ -1889,7 +2021,7 @@ function attemptDecide(logPath, actionKey, decision, actor, options) {
|
|
|
1889
2021
|
materialised = logged.record;
|
|
1890
2022
|
}
|
|
1891
2023
|
const message = derivation.expiredLazily
|
|
1892
|
-
? `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(
|
|
2024
|
+
? `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
2025
|
: `action ${actionKey} expired at ${String(derivation.decisionTs)} (approval.expired, seq ${String(derivation.decisionSeq)}); an expired request is terminal`;
|
|
1894
2026
|
return refuse("expired", message, materialised === undefined
|
|
1895
2027
|
? { state: derivation.state }
|
|
@@ -2001,6 +2133,16 @@ function attemptDecide(logPath, actionKey, decision, actor, options) {
|
|
|
2001
2133
|
if (options.senderSource !== undefined)
|
|
2002
2134
|
payload["sender_source"] = options.senderSource;
|
|
2003
2135
|
}
|
|
2136
|
+
// APRV-489. Grant and reject only, the two answers a prompt collects, and only
|
|
2137
|
+
// when the surface drew (or was asked for) a non-default layout. Copied in a
|
|
2138
|
+
// fixed shape so nothing else a caller hangs on the object reaches the log.
|
|
2139
|
+
if (decision !== "revoke" && options.rendering !== undefined) {
|
|
2140
|
+
payload["rendering"] = {
|
|
2141
|
+
style: options.rendering.style,
|
|
2142
|
+
...(options.rendering.fallback === undefined ? {} : { fallback: options.rendering.fallback }),
|
|
2143
|
+
...(options.rendering.say_does_ignored === true ? { say_does_ignored: true } : {}),
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2004
2146
|
if (decision === "grant") {
|
|
2005
2147
|
const cls = derivation.declared.class ?? "";
|
|
2006
2148
|
const resolution = resolve(load, cls, derivation.declared.reversible === null ? {} : { reversible: derivation.declared.reversible });
|
|
@@ -2227,13 +2369,20 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2227
2369
|
if (logged.ok)
|
|
2228
2370
|
materialised = logged.record;
|
|
2229
2371
|
}
|
|
2230
|
-
return refuse("expired", `action ${actionKey} expired: the request at ${String(derivation.requestTs)} lapsed its ${String(
|
|
2372
|
+
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
2373
|
? { state: derivation.state }
|
|
2232
2374
|
: { state: derivation.state, record: materialised });
|
|
2233
2375
|
}
|
|
2234
2376
|
if (derivation.state !== "requested") {
|
|
2235
2377
|
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
2378
|
}
|
|
2379
|
+
if (options.onlyIfVoid === true) {
|
|
2380
|
+
const attested = requireAttestation(read.records, readPolicyOnce(options));
|
|
2381
|
+
const pinned = derivation.declared.policy_sha256;
|
|
2382
|
+
if (!attested.ok || pinned === null || pinned === attested.sha256) {
|
|
2383
|
+
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 });
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2237
2386
|
if (derivation.requestActor !== actor) {
|
|
2238
2387
|
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
2388
|
}
|
|
@@ -2282,8 +2431,9 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2282
2431
|
* refused at the append in {@link consumeHarnessGrant}. The single-use rule
|
|
2283
2432
|
* is the gate's existing one; this only stops the caller from queueing up a
|
|
2284
2433
|
* write that would be refused.
|
|
2285
|
-
* - *within the TTL*: state is derived at `ts` with `ttlMs`,
|
|
2286
|
-
*
|
|
2434
|
+
* - *within the TTL*: state is derived at `ts` with `ttlMs`, narrowed by the
|
|
2435
|
+
* candidate's own declared harness ceiling (APRV-423), so a lapsed request
|
|
2436
|
+
* reads `expired` and carries nothing, whether or not the daemon has
|
|
2287
2437
|
* materialised an `approval.expired` record.
|
|
2288
2438
|
*
|
|
2289
2439
|
* PURE, and reads only records the caller verified — the enforcement path never
|
|
@@ -2292,7 +2442,7 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2292
2442
|
* request that came after it, exactly as {@link requestState} treats cycles.
|
|
2293
2443
|
*/
|
|
2294
2444
|
/**
|
|
2295
|
-
* Has a GRANTED request outlived
|
|
2445
|
+
* Has a GRANTED request outlived the window it was asked under?
|
|
2296
2446
|
*
|
|
2297
2447
|
* `requestState` reports a decided request by its decision forever: the TTL
|
|
2298
2448
|
* bounds the window in which a human may answer, not the answer's shelf life.
|
|
@@ -2305,7 +2455,14 @@ function attemptWithdraw(logPath, actionKey, actor, options) {
|
|
|
2305
2455
|
* Unparseable instants read as lapsed, and a policy with no TTL declares no
|
|
2306
2456
|
* lapse at all — both exactly as `core/token.ts` reads them.
|
|
2307
2457
|
*/
|
|
2308
|
-
function grantLapsed(derivation, ts
|
|
2458
|
+
function grantLapsed(derivation, ts) {
|
|
2459
|
+
// APRV-423: the SAME window the request was judged by, read off the
|
|
2460
|
+
// derivation rather than recomputed from the policy. A grant whose question
|
|
2461
|
+
// was bounded by a harness ceiling keeps that bound; a retry a few seconds
|
|
2462
|
+
// later then asks again instead of carrying an answer the request's own
|
|
2463
|
+
// clock had already run out on. The stricter of the two readings, and the
|
|
2464
|
+
// one that keeps `requestState` the single place the deadline is decided.
|
|
2465
|
+
const ttlMs = derivation.effectiveTtlMs;
|
|
2309
2466
|
if (ttlMs === null)
|
|
2310
2467
|
return false;
|
|
2311
2468
|
const requestedAt = Date.parse(derivation.requestTs ?? "");
|
|
@@ -2314,7 +2471,63 @@ function grantLapsed(derivation, ts, ttlMs) {
|
|
|
2314
2471
|
return true;
|
|
2315
2472
|
return asked > requestedAt + ttlMs;
|
|
2316
2473
|
}
|
|
2317
|
-
|
|
2474
|
+
/**
|
|
2475
|
+
* Was the question already abandoned when it was answered? (APRV-410.)
|
|
2476
|
+
*
|
|
2477
|
+
* The state this names, observed 2026-09-20 in this repository's own log. A
|
|
2478
|
+
* hook wait expired at 18:52:37 on a request opened at 18:43:37; the retry
|
|
2479
|
+
* grace ran out at 18:57:37 with nothing retrying; a human tapped approve at
|
|
2480
|
+
* 19:03:32 and `approval.granted` seq 64473 was recorded, six minutes past the
|
|
2481
|
+
* grace, on a question no hook process was holding. The grant authorized
|
|
2482
|
+
* nothing that ran on the day only because the session had already moved on. It
|
|
2483
|
+
* was still live: a retry of that exact command in that directory inside the
|
|
2484
|
+
* TTL would have found it here and proceeded on it, which is one human tap
|
|
2485
|
+
* authorizing an execution nobody connected to the tap.
|
|
2486
|
+
*
|
|
2487
|
+
* {@link grantLapsed} bounds a grant by the request's TTL, which is the shelf
|
|
2488
|
+
* life of an ANSWER. This bounds it by the life of the QUESTION: past
|
|
2489
|
+
* `requestTs + abandonAfterMs` the asker is gone (`core/harness-wait.ts` is
|
|
2490
|
+
* where that duration and its reasoning live), so an answer arriving after it
|
|
2491
|
+
* answered nobody. Both bounds apply; either one alone lets a case through.
|
|
2492
|
+
*
|
|
2493
|
+
* Two facts and no third. The request's own runtime-assigned timestamp and the
|
|
2494
|
+
* decision record's own runtime-assigned timestamp, both read from the log the
|
|
2495
|
+
* caller verified. Nothing self-reported enters it, and the caller's
|
|
2496
|
+
* `abandonAfterMs` is the window the CALLER is configured for rather than
|
|
2497
|
+
* anything the requester said about itself, which is why a false claim cannot
|
|
2498
|
+
* widen it.
|
|
2499
|
+
*
|
|
2500
|
+
* Strictly stricter, and opt-in. A caller that passes no window gets exactly
|
|
2501
|
+
* today's behaviour, and a caller that passes one can only lose a carry it
|
|
2502
|
+
* would otherwise have had. Losing one costs a fresh question; keeping one
|
|
2503
|
+
* costs an execution on an answer nobody was waiting for.
|
|
2504
|
+
*
|
|
2505
|
+
* An unparseable instant on either side reads as abandoned, exactly as
|
|
2506
|
+
* {@link grantLapsed} reads one as lapsed: the strict direction is the one a
|
|
2507
|
+
* corrupt byte must resolve to.
|
|
2508
|
+
*/
|
|
2509
|
+
function questionAbandoned(derivation, abandonAfterMs) {
|
|
2510
|
+
if (abandonAfterMs === null)
|
|
2511
|
+
return false;
|
|
2512
|
+
const requestedAt = Date.parse(derivation.requestTs ?? "");
|
|
2513
|
+
const answeredAt = Date.parse(derivation.decisionTs ?? "");
|
|
2514
|
+
if (Number.isNaN(requestedAt) || Number.isNaN(answeredAt))
|
|
2515
|
+
return true;
|
|
2516
|
+
return answeredAt > requestedAt + Math.max(0, abandonAfterMs);
|
|
2517
|
+
}
|
|
2518
|
+
export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs,
|
|
2519
|
+
/**
|
|
2520
|
+
* How long a question of this caller's outlives the wait that opened it
|
|
2521
|
+
* (APRV-410, `core/harness-wait.ts`'s `abandonedAfterMs`), or `null` for a
|
|
2522
|
+
* caller that declares no such window.
|
|
2523
|
+
*
|
|
2524
|
+
* Only a GRANTED candidate is bounded by it. A pending one is the question
|
|
2525
|
+
* this invocation is about to adopt, and the withdrawal of an abandoned
|
|
2526
|
+
* pending question is the requester's own business, not this function's:
|
|
2527
|
+
* `cli/hook.ts` sweeps those through `withdraw`, which is requester-only by
|
|
2528
|
+
* design (APRV-106 rule 1).
|
|
2529
|
+
*/
|
|
2530
|
+
abandonAfterMs = null) {
|
|
2318
2531
|
if (!isPayloadHash(payloadHash))
|
|
2319
2532
|
return null;
|
|
2320
2533
|
// Distinct keys, latest request first: a later question about the same bytes
|
|
@@ -2331,6 +2544,11 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
|
|
|
2331
2544
|
continue;
|
|
2332
2545
|
if (keys.includes(record.action_key))
|
|
2333
2546
|
continue;
|
|
2547
|
+
// APRV-445. A proposal's harness grant belongs to `approval start` and to
|
|
2548
|
+
// nothing else: a hook call that happened to present the same bytes in the
|
|
2549
|
+
// same class must not carry or adopt it.
|
|
2550
|
+
if (isProposalTask(record.task))
|
|
2551
|
+
continue;
|
|
2334
2552
|
const payload = payloadOf(record);
|
|
2335
2553
|
if (payload["execution"] !== "harness")
|
|
2336
2554
|
continue;
|
|
@@ -2354,7 +2572,13 @@ export function findHarnessCarry(records, payloadHash, cls, ts, ttlMs) {
|
|
|
2354
2572
|
continue;
|
|
2355
2573
|
if (derivation.state === "granted") {
|
|
2356
2574
|
// An answer has a shelf life, and it is its request's TTL.
|
|
2357
|
-
|
|
2575
|
+
// APRV-423: judged by the request's own effective window, read off the
|
|
2576
|
+
// derivation, never the raw policy TTL.
|
|
2577
|
+
if (grantLapsed(derivation, ts))
|
|
2578
|
+
continue;
|
|
2579
|
+
// And a question has a life of its own: an answer that landed after the
|
|
2580
|
+
// asker was gone authorized nobody (APRV-410).
|
|
2581
|
+
if (questionAbandoned(derivation, abandonAfterMs))
|
|
2358
2582
|
continue;
|
|
2359
2583
|
return {
|
|
2360
2584
|
actionKey,
|
|
@@ -2424,6 +2648,43 @@ export const HARNESS_GRANT_ORIGIN = "grant_origin";
|
|
|
2424
2648
|
* every reader treats absence as "no second name" rather than as a fault.
|
|
2425
2649
|
*/
|
|
2426
2650
|
export const HARNESS_SPENDING_TASK = "spent_by_task";
|
|
2651
|
+
/**
|
|
2652
|
+
* The policy stamp every harness `execution.started` carries (APRV-447).
|
|
2653
|
+
*
|
|
2654
|
+
* `payload.policy_sha256` names the attested policy the write boundary
|
|
2655
|
+
* re-checked this start's class against: the hash {@link requireAttestation}
|
|
2656
|
+
* returned for the one policy read the appending attempt made, which is the
|
|
2657
|
+
* read the class was resolved from and the attestation was judged on. On the
|
|
2658
|
+
* hook route the verdict itself was drawn from the hook's own, earlier read;
|
|
2659
|
+
* a re-attest landing between that read and this append leaves the stamp
|
|
2660
|
+
* naming the policy in force at the append (the two-read window predates this
|
|
2661
|
+
* field, and is a follow-up to close). Before this field a
|
|
2662
|
+
* policy-authorized start (autonomous, supervised, an unselected live draw)
|
|
2663
|
+
* carried no policy at all, and a reader had to reconstruct the rules in force
|
|
2664
|
+
* from the latest `policy.updated` before the record's seq. A granted start
|
|
2665
|
+
* could recover it through `grant_seq`; it is stamped too, by the same line, so
|
|
2666
|
+
* every harness start answers the question the same way.
|
|
2667
|
+
*
|
|
2668
|
+
* WRITTEN, never accepted. The value is passed in from the attempt's own
|
|
2669
|
+
* attestation check and from nowhere else: neither {@link HarnessStartInput}
|
|
2670
|
+
* nor {@link ConsumeHarnessOptions} has a field for it, and the payload object
|
|
2671
|
+
* is built here from runtime-held values, so a caller's input, option bag or
|
|
2672
|
+
* action payload that happens to carry the same name changes nothing (§11.1
|
|
2673
|
+
* invariant 1: the stamp comes from the verified attestation the gate already
|
|
2674
|
+
* resolved, never from an envelope). An unattested or edited policy never
|
|
2675
|
+
* reaches this line: both write paths refuse `policy-not-attested` first, and
|
|
2676
|
+
* a grant spend under a policy other than the one the grant pinned is refused
|
|
2677
|
+
* `policy-drift` before its payload is built.
|
|
2678
|
+
*
|
|
2679
|
+
* READ by nothing that decides. No verdict, budget, draw, loop floor or
|
|
2680
|
+
* single-use check consults it, and `core/state.ts` reads `policy_sha256` from
|
|
2681
|
+
* `approval.requested` alone. Its readers are downstream followers of the log
|
|
2682
|
+
* that join a start to the rules it ran under, so the field NAME is a stable
|
|
2683
|
+
* contract.
|
|
2684
|
+
*/
|
|
2685
|
+
function harnessStartPolicyStamp(attestedSha256) {
|
|
2686
|
+
return { [POLICY_HASH_FIELD]: attestedSha256 };
|
|
2687
|
+
}
|
|
2427
2688
|
/**
|
|
2428
2689
|
* Spend a harness grant, exactly once (APRV-117).
|
|
2429
2690
|
*
|
|
@@ -2537,8 +2798,8 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
|
|
|
2537
2798
|
if (derivation.declared.execution !== "harness") {
|
|
2538
2799
|
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
2800
|
}
|
|
2540
|
-
if (grantLapsed(derivation, ts
|
|
2541
|
-
return refuse("expired", `action ${actionKey}'s grant expired: the request at ${String(derivation.requestTs)} lapsed its
|
|
2801
|
+
if (grantLapsed(derivation, ts)) {
|
|
2802
|
+
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
2803
|
}
|
|
2543
2804
|
if (derivation.task === null) {
|
|
2544
2805
|
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 +2875,11 @@ function attemptHarnessConsume(logPath, actionKey, actor, options) {
|
|
|
2614
2875
|
options.spendingTask === derivation.task
|
|
2615
2876
|
? "direct"
|
|
2616
2877
|
: "carried"),
|
|
2878
|
+
// APRV-447: the attested policy this spend was judged under, the same
|
|
2879
|
+
// value `policy-drift` compared against the grant's pin above, so on this
|
|
2880
|
+
// path it equals that pin wherever the grant carries one. See
|
|
2881
|
+
// `harnessStartPolicyStamp` for why it is stamped and who reads it.
|
|
2882
|
+
...harnessStartPolicyStamp(attested.sha256),
|
|
2617
2883
|
};
|
|
2618
2884
|
// APRV-287. A carried spend records WHICH tool call spent it, so the
|
|
2619
2885
|
// completion counterpart can find this start from the event that reports how
|
|
@@ -2765,6 +3031,16 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2765
3031
|
if (!isPayloadHash(bytes)) {
|
|
2766
3032
|
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
3033
|
}
|
|
3034
|
+
// APRV-445 security pass. A proposal's start reaches this function only when
|
|
3035
|
+
// its key holds no live request, and the task id alone says it is a proposal
|
|
3036
|
+
// (read from the task, never from a caller flag). Re-judged here, inside the
|
|
3037
|
+
// attempt that appends, so a request filed or decided after `startProposed`
|
|
3038
|
+
// looked cannot slip past. Checked before the budget verdict, which writes.
|
|
3039
|
+
if (isProposalTask(input.task)) {
|
|
3040
|
+
const refusal = proposalPolicyStartRefusal(logPath, read.records, input.actionKey, bytes, load, resolution, attested.sha256, ts, options);
|
|
3041
|
+
if (refusal !== null)
|
|
3042
|
+
return refusal;
|
|
3043
|
+
}
|
|
2768
3044
|
const cost = costOf(input.est_cost_usd);
|
|
2769
3045
|
const budget = evaluateBudgetsWithTask(read.records, budgetScopeOf(load, resolution), { class: input.cls, est_cost_usd: cost }, ts, input.task);
|
|
2770
3046
|
if (!budget.pass) {
|
|
@@ -2787,7 +3063,14 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2787
3063
|
.join(", ")}`;
|
|
2788
3064
|
return logged.ok
|
|
2789
3065
|
? refuse("budget-exceeded", message, { verdicts: failed, record: logged.record })
|
|
2790
|
-
: refuse("budget-exceeded", `${message}; the budget.exceeded event could not be appended: ${logged.message}`,
|
|
3066
|
+
: refuse("budget-exceeded", `${message}; the budget.exceeded event could not be appended: ${logged.message}`,
|
|
3067
|
+
// APRV-478: the writer's own error rides along, so a caller that
|
|
3068
|
+
// waits for the log's lock on its event loop (the harness hook's
|
|
3069
|
+
// `spendUnderLock`) can see that the evidence lost a lock race,
|
|
3070
|
+
// re-run the whole attempt and give the record the same chance to
|
|
3071
|
+
// land that a synchronous lock wait gave it. The verdict is the
|
|
3072
|
+
// budget refusal either way.
|
|
3073
|
+
logged.append === undefined ? { verdicts: failed } : { verdicts: failed, append: logged.append });
|
|
2791
3074
|
}
|
|
2792
3075
|
const payload = {
|
|
2793
3076
|
// The budgets contract: class and est_cost_usd on every start event.
|
|
@@ -2798,6 +3081,11 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2798
3081
|
// APRV-146: unconditional, because a caller that states no bytes was refused
|
|
2799
3082
|
// above. The record says what ran, not only that something did.
|
|
2800
3083
|
payload_hash: bytes,
|
|
3084
|
+
// APRV-447: the attested policy this write boundary re-checked the class
|
|
3085
|
+
// against (see `harnessStartPolicyStamp` for the hook's two-read window).
|
|
3086
|
+
// Without it a reader of a policy-authorized start had to reconstruct the
|
|
3087
|
+
// rules in force from the latest `policy.updated` before this seq.
|
|
3088
|
+
...harnessStartPolicyStamp(attested.sha256),
|
|
2801
3089
|
};
|
|
2802
3090
|
const appended = append(logPath, {
|
|
2803
3091
|
ts,
|
|
@@ -2814,6 +3102,54 @@ function attemptHarnessStart(logPath, input, actor, options) {
|
|
|
2814
3102
|
return appended;
|
|
2815
3103
|
return { ok: true, record: appended.record };
|
|
2816
3104
|
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Why a proposal may NOT start on the policy path, or `null` when it may
|
|
3107
|
+
* (APRV-445 security pass).
|
|
3108
|
+
*
|
|
3109
|
+
* The policy path records an execution the policy itself authorizes. For a
|
|
3110
|
+
* proposal that is true only when no human question about these bytes is open
|
|
3111
|
+
* or answered no under the policy in force, and when the class would not have
|
|
3112
|
+
* put this action in front of a human at all. Three refusals, all `not-granted`:
|
|
3113
|
+
*
|
|
3114
|
+
* - a request for the key is pending or granted and unspent: the key is bound to
|
|
3115
|
+
* the grant path, and `start` must spend the grant (or wait for the decision);
|
|
3116
|
+
* - the key's latest request was rejected or revoked under the attested policy
|
|
3117
|
+
* (or pinned no policy): a human's no under the rules in force stands. After a
|
|
3118
|
+
* re-attest the rules that asked are gone, and the action is judged afresh;
|
|
3119
|
+
* - the class is `supervised-live` and the draw selects these bytes: the draw is
|
|
3120
|
+
* deterministic over the policy and the payload hash, so it reproduces the
|
|
3121
|
+
* intake answer, and a selected action is authorized by a grant, never by the
|
|
3122
|
+
* policy. Without this a withdrawn, expired, rejected or never-filed request
|
|
3123
|
+
* for a selected live action turned into a policy-authorized start.
|
|
3124
|
+
*
|
|
3125
|
+
* Asking the draw appends nothing; a draw that cannot be made gates, so this
|
|
3126
|
+
* fails closed.
|
|
3127
|
+
*/
|
|
3128
|
+
function proposalPolicyStartRefusal(logPath, records, actionKey, payloadHash, load, resolution, attestedSha256, ts, options) {
|
|
3129
|
+
const derivation = requestState(records, actionKey, ts, ttlOf(load));
|
|
3130
|
+
if (derivation.execution.started === null &&
|
|
3131
|
+
(derivation.state === "requested" || derivation.state === "granted")) {
|
|
3132
|
+
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.`);
|
|
3133
|
+
}
|
|
3134
|
+
if (derivation.state === "rejected" || derivation.state === "revoked") {
|
|
3135
|
+
const pinned = derivation.declared.policy_sha256;
|
|
3136
|
+
if (pinned === null || pinned === attestedSha256) {
|
|
3137
|
+
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.`);
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
if (resolution.supervision === "live") {
|
|
3141
|
+
const live = liveVerdict(load, resolution, payloadHash, options.env, {
|
|
3142
|
+
logPath,
|
|
3143
|
+
policyHash: attestedSha256,
|
|
3144
|
+
actionKey,
|
|
3145
|
+
ask: options.drawAsk ?? askDaemonDraw,
|
|
3146
|
+
});
|
|
3147
|
+
if (live.gated) {
|
|
3148
|
+
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.`);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
return null;
|
|
3152
|
+
}
|
|
2817
3153
|
// ---------------------------------------------------------------------------
|
|
2818
3154
|
// finishHarnessExecution — the completion counterpart (APRV-145)
|
|
2819
3155
|
// ---------------------------------------------------------------------------
|
|
@@ -3008,9 +3344,19 @@ function appendExpiry(logPath, derivation, load, ts, options, expectedHead) {
|
|
|
3008
3344
|
const payload = {};
|
|
3009
3345
|
if (derivation.requestTs !== null)
|
|
3010
3346
|
payload["requested_ts"] = derivation.requestTs;
|
|
3011
|
-
|
|
3347
|
+
// The window this request was ACTUALLY judged by, which is the policy's TTL
|
|
3348
|
+
// narrowed by whatever harness ceiling the requester declared (APRV-423).
|
|
3349
|
+
// Recording the policy's number here while expiring on a shorter one would
|
|
3350
|
+
// leave the record disagreeing with the arithmetic that produced it, and the
|
|
3351
|
+
// reader's obvious check — requested_ts + ttl_ms against ts — would fail on a
|
|
3352
|
+
// correct expiry. The cap rides along beside it so a reader can see WHY the
|
|
3353
|
+
// window was short without holding the request record too.
|
|
3354
|
+
const ttlMs = derivation.effectiveTtlMs;
|
|
3012
3355
|
if (ttlMs !== null)
|
|
3013
3356
|
payload["ttl_ms"] = ttlMs;
|
|
3357
|
+
if (derivation.declared.harness_cap_ms !== null) {
|
|
3358
|
+
payload["harness_cap_ms"] = derivation.declared.harness_cap_ms;
|
|
3359
|
+
}
|
|
3014
3360
|
const onExpiry = load.ok ? load.policy.defaults?.on_expiry : undefined;
|
|
3015
3361
|
if (onExpiry !== undefined)
|
|
3016
3362
|
payload["on_expiry"] = onExpiry;
|
|
@@ -3063,9 +3409,9 @@ export function expire(logPath, actionKey, options = {}) {
|
|
|
3063
3409
|
if (derivation.state !== "requested") {
|
|
3064
3410
|
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
3411
|
}
|
|
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(
|
|
3412
|
+
return refuse("not-expired", derivation.effectiveTtlMs === null
|
|
3413
|
+
? `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`
|
|
3414
|
+
: `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
3415
|
}
|
|
3070
3416
|
const expired = appendExpiry(logPath, derivation, load, ts, options, read.head);
|
|
3071
3417
|
if (expired.ok) {
|
|
@@ -3079,4 +3425,433 @@ export function expire(logPath, actionKey, options = {}) {
|
|
|
3079
3425
|
}
|
|
3080
3426
|
return expired;
|
|
3081
3427
|
}
|
|
3428
|
+
// ---------------------------------------------------------------------------
|
|
3429
|
+
// propose (APRV-445)
|
|
3430
|
+
// ---------------------------------------------------------------------------
|
|
3431
|
+
/**
|
|
3432
|
+
* The largest payload `approval propose` and `approval start` accept, in UTF-8
|
|
3433
|
+
* bytes of the JSON text as the caller sent it: 256 KiB.
|
|
3434
|
+
*
|
|
3435
|
+
* Well under `approval serve`'s 1 MiB body cap, so the refusal a caller meets is
|
|
3436
|
+
* this named one rather than a transport's. A proposal is a sentence or a
|
|
3437
|
+
* paragraph a human will read on a phone; a payload this size is not one.
|
|
3438
|
+
*/
|
|
3439
|
+
export const PROPOSE_PAYLOAD_MAX_BYTES = 256 * 1024;
|
|
3440
|
+
/** The longest `--key` `propose` accepts, in UTF-8 bytes (APRV-445, L6). */
|
|
3441
|
+
export const PROPOSE_KEY_MAX_BYTES = 1024;
|
|
3442
|
+
/** The longest `--summary` `propose` accepts, in UTF-8 bytes (APRV-445, L6). */
|
|
3443
|
+
export const PROPOSE_SUMMARY_MAX_BYTES = 4096;
|
|
3444
|
+
/**
|
|
3445
|
+
* The id after `<class>:` (APRV-445 recheck L-b): one or more printable
|
|
3446
|
+
* characters, no whitespace, no control, format, private-use, unassigned or
|
|
3447
|
+
* surrogate code points, so a key reads back the same in a log, a phone and a
|
|
3448
|
+
* shell.
|
|
3449
|
+
*/
|
|
3450
|
+
const PROPOSE_KEY_ID = /^[^\s\p{C}]+$/u;
|
|
3451
|
+
/** How many times `propose` starts again on a log that moved under it (SF1). */
|
|
3452
|
+
const PROPOSE_MAX_ATTEMPTS = 4;
|
|
3453
|
+
/** `origin.app` on every envelope `propose` registers. */
|
|
3454
|
+
export const PROPOSE_ORIGIN_APP = "approval-propose";
|
|
3455
|
+
/** The task-id prefix `propose` derives under. */
|
|
3456
|
+
export const PROPOSE_TASK_PREFIX = "propose:";
|
|
3457
|
+
/**
|
|
3458
|
+
* Was this task registered by `propose` (APRV-445)?
|
|
3459
|
+
*
|
|
3460
|
+
* A proposal's request carries `execution: "harness"` like a hook's, so the two
|
|
3461
|
+
* hook-side readers of that marker (the carry and the abandoned-question sweep)
|
|
3462
|
+
* ask this first: a proposal waits days for a human, is spent only by `start`,
|
|
3463
|
+
* and is withdrawn only by its requester's own call.
|
|
3464
|
+
*/
|
|
3465
|
+
export function isProposalTask(task) {
|
|
3466
|
+
return typeof task === "string" && task.startsWith(PROPOSE_TASK_PREFIX);
|
|
3467
|
+
}
|
|
3468
|
+
/** A concrete class, as `schema/envelope.schema.json` spells one: no wildcard. */
|
|
3469
|
+
const CONCRETE_CLASS = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/u;
|
|
3470
|
+
/**
|
|
3471
|
+
* The task id `propose` registers under, from who is asking, the class and the
|
|
3472
|
+
* key: `propose:` and the first 32 hex digits of the SHA-256 over the RFC 8785
|
|
3473
|
+
* canonical JSON of `[actor, class, key]`.
|
|
3474
|
+
*
|
|
3475
|
+
* Deterministic, so a retry lands on the task its first attempt registered and
|
|
3476
|
+
* is recognised as a retry. The ACTOR is the tenant half of the derivation: one
|
|
3477
|
+
* `approval serve` process serves one store as one fixed `agent:<id>`, so within
|
|
3478
|
+
* a store the actor is who the tenant's agent is, and two actors proposing the
|
|
3479
|
+
* same key derive two different tasks, the second of which `register` refuses
|
|
3480
|
+
* because the key is already declared under the first (`task-already-registered`).
|
|
3481
|
+
* The class is in it so that the same key proposed under a second class is a
|
|
3482
|
+
* different task rather than a silent re-declaration.
|
|
3483
|
+
*/
|
|
3484
|
+
export function proposedTaskId(actor, cls, actionKey) {
|
|
3485
|
+
return `${PROPOSE_TASK_PREFIX}${hashOfPayload([actor, cls, actionKey]).slice(0, 32)}`;
|
|
3486
|
+
}
|
|
3487
|
+
/** The sentence a `class-not-agent-requestable` refusal says. */
|
|
3488
|
+
function notAgentRequestable(cls, opened) {
|
|
3489
|
+
const why = !opened.explicit
|
|
3490
|
+
? `${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)`
|
|
3491
|
+
: opened.pattern === null
|
|
3492
|
+
? `neither ${JSON.stringify(cls)} nor any declared \`<prefix>.*\` family of it sets agent_may_request: true`
|
|
3493
|
+
: `${JSON.stringify(opened.pattern)} sets agent_may_request: false`;
|
|
3494
|
+
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).`;
|
|
3495
|
+
}
|
|
3496
|
+
/**
|
|
3497
|
+
* `approval propose` (APRV-445, amended SPEC.md §5.2): register and request one
|
|
3498
|
+
* action in one call, for a requester that holds the payload inline and no file
|
|
3499
|
+
* on the gate's machine.
|
|
3500
|
+
*
|
|
3501
|
+
* ## Why this verb may name its class when `request` may not
|
|
3502
|
+
*
|
|
3503
|
+
* `approval request` reads the class from the log's `task.registered` record so
|
|
3504
|
+
* that an agent cannot call a `financial.spend` a `read.web`. Here the agent is
|
|
3505
|
+
* also the registrant, so that rule alone would prove nothing, and the bound is
|
|
3506
|
+
* the operator's instead: the class must be an EXACT key of `policy.classes`, and
|
|
3507
|
+
* that key or a declared `<prefix>.*` family of it must say
|
|
3508
|
+
* `agent_may_request: true`. A class reached only through a wildcard is refused,
|
|
3509
|
+
* so the set of classes an agent can propose in is a set of lines the operator
|
|
3510
|
+
* wrote and attested by name. What the agent chooses among them is still its own
|
|
3511
|
+
* statement (SPEC.md §11.1 invariant 4 is why the choice is bounded at all), and
|
|
3512
|
+
* an operator opening two classes of different autonomy to agents is trusting
|
|
3513
|
+
* the agent to pick the right one; the record names the class it picked.
|
|
3514
|
+
*
|
|
3515
|
+
* ## Order
|
|
3516
|
+
*
|
|
3517
|
+
* Attestation, then the agent-requestability gate and the class's own autonomy
|
|
3518
|
+
* (`human-only`, an unruled `harness.launch.*`), all BEFORE anything is
|
|
3519
|
+
* registered, so a refused class leaves no registration behind. Then the
|
|
3520
|
+
* registration, then {@link request} with `agentProposal: true`, which repeats
|
|
3521
|
+
* the gate against the bytes its own attempt reads. `execution: "harness"` on the
|
|
3522
|
+
* request: nothing ever presents a token for a proposal, the requester proceeds
|
|
3523
|
+
* on the grant itself and records that with `approval start`, so a minted token
|
|
3524
|
+
* would be a live credential with no spender.
|
|
3525
|
+
*
|
|
3526
|
+
* ## Idempotency
|
|
3527
|
+
*
|
|
3528
|
+
* The task id is {@link proposedTaskId}. A second call with the same actor,
|
|
3529
|
+
* class, key and payload hash finds its own registration and appends nothing
|
|
3530
|
+
* while the request is live or decided, answering with the request's state and
|
|
3531
|
+
* `idempotent: true`. A withdrawn or expired request (a re-attestation voids
|
|
3532
|
+
* pending requests as `policy-drift`) is asked again under the same registration.
|
|
3533
|
+
* The same key with DIFFERENT bytes refuses: `duplicate-request` while a request
|
|
3534
|
+
* is live, `payload-mismatch` otherwise, because a key is bound to the bytes it
|
|
3535
|
+
* was first registered with and new bytes need a new key.
|
|
3536
|
+
*/
|
|
3537
|
+
export function propose(logPath, input, actor, options = {}, attempt = 0, appendedEarlier = false) {
|
|
3538
|
+
// APRV-445 recheck (SF1). Every decision below is re-checked by the append
|
|
3539
|
+
// it leads to (`withdraw` with `onlyIfVoid`, `request` with
|
|
3540
|
+
// `agentProposal`), so a caller that read a stale log is refused there
|
|
3541
|
+
// rather than writing over a concurrent caller. A refusal of that kind means
|
|
3542
|
+
// the log moved under this call; the call starts again from a fresh read, a
|
|
3543
|
+
// bounded number of times, and answers what it then finds.
|
|
3544
|
+
const again = (refusal) => attempt < PROPOSE_MAX_ATTEMPTS
|
|
3545
|
+
? propose(logPath, input, actor, options, attempt + 1, appendedEarlier || withdrewNow || registeredNow)
|
|
3546
|
+
: 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.`);
|
|
3547
|
+
if (!isPrincipalActor(actor)) {
|
|
3548
|
+
return refuse("actor-invalid", `propose requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
|
|
3549
|
+
}
|
|
3550
|
+
if (!CONCRETE_CLASS.test(input.cls)) {
|
|
3551
|
+
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.`);
|
|
3552
|
+
}
|
|
3553
|
+
if (input.actionKey.length === 0) {
|
|
3554
|
+
return refuse("envelope-invalid", "the action key is empty; nothing was registered and nothing was appended.");
|
|
3555
|
+
}
|
|
3556
|
+
// APRV-445 refutation (S4): the key carries its class, so a consumer that
|
|
3557
|
+
// parses the key can never attribute it to a class its record does not name.
|
|
3558
|
+
// Consumers read the class from the record; this keeps the key from lying.
|
|
3559
|
+
if (!input.actionKey.startsWith(`${input.cls}:`) ||
|
|
3560
|
+
!PROPOSE_KEY_ID.test(input.actionKey.slice(input.cls.length + 1))) {
|
|
3561
|
+
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.`);
|
|
3562
|
+
}
|
|
3563
|
+
if (Buffer.byteLength(input.actionKey, "utf8") > PROPOSE_KEY_MAX_BYTES) {
|
|
3564
|
+
return refuse("envelope-invalid", `the key is over ${String(PROPOSE_KEY_MAX_BYTES)} bytes; nothing was registered and nothing was appended.`);
|
|
3565
|
+
}
|
|
3566
|
+
if (Buffer.byteLength(input.summary, "utf8") > PROPOSE_SUMMARY_MAX_BYTES) {
|
|
3567
|
+
return refuse("envelope-invalid", `the summary is over ${String(PROPOSE_SUMMARY_MAX_BYTES)} bytes; nothing was registered and nothing was appended.`);
|
|
3568
|
+
}
|
|
3569
|
+
let hash;
|
|
3570
|
+
try {
|
|
3571
|
+
hash = hashOfPayload(input.payload);
|
|
3572
|
+
}
|
|
3573
|
+
catch (cause) {
|
|
3574
|
+
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.`);
|
|
3575
|
+
}
|
|
3576
|
+
const task = proposedTaskId(actor, input.cls, input.actionKey);
|
|
3577
|
+
const read = readGateRecords(logPath);
|
|
3578
|
+
if (!read.ok)
|
|
3579
|
+
return read;
|
|
3580
|
+
const policyRead = readPolicyOnce(options);
|
|
3581
|
+
const attested = requireAttestation(read.records, policyRead);
|
|
3582
|
+
if (!attested.ok)
|
|
3583
|
+
return attested;
|
|
3584
|
+
const load = parsePolicy(policyRead, options);
|
|
3585
|
+
const opened = agentRequestability(load, input.cls);
|
|
3586
|
+
const resolution = resolve(load, input.cls);
|
|
3587
|
+
if (!opened.allowed && !(opened.humanOnly && opened.explicit && opened.pattern !== null)) {
|
|
3588
|
+
return refuse("class-not-agent-requestable", notAgentRequestable(input.cls, opened));
|
|
3589
|
+
}
|
|
3590
|
+
if (harnessLaunchNeedsRule(input.cls, resolution)) {
|
|
3591
|
+
return refuse("harness-launch-unruled", harnessLaunchUnruledRefusal(input.cls, `the proposal ${input.actionKey} was not registered and nothing was appended`));
|
|
3592
|
+
}
|
|
3593
|
+
if (resolution.autonomy === "human-only") {
|
|
3594
|
+
return refuse("class-human-only", humanOnlyRefusal(input.cls, `the proposal ${input.actionKey} was not registered and nothing was appended`));
|
|
3595
|
+
}
|
|
3596
|
+
const answered = (decision, state, seq, idempotent) => ({
|
|
3597
|
+
ok: true,
|
|
3598
|
+
task,
|
|
3599
|
+
actionKey: input.actionKey,
|
|
3600
|
+
cls: input.cls,
|
|
3601
|
+
payloadHash: hash,
|
|
3602
|
+
decision,
|
|
3603
|
+
state,
|
|
3604
|
+
seq,
|
|
3605
|
+
idempotent: idempotent && !appendedEarlier,
|
|
3606
|
+
});
|
|
3607
|
+
const existing = registeredAction(read.records, task, input.actionKey);
|
|
3608
|
+
let registeredNow = false;
|
|
3609
|
+
let withdrewNow = false;
|
|
3610
|
+
if (existing.ok) {
|
|
3611
|
+
const derivation = requestState(read.records, input.actionKey, tick(options), ttlOf(load));
|
|
3612
|
+
if (existing.action.payload_hash !== hash) {
|
|
3613
|
+
if (derivation.state === "requested") {
|
|
3614
|
+
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 });
|
|
3615
|
+
}
|
|
3616
|
+
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 });
|
|
3617
|
+
}
|
|
3618
|
+
// APRV-445 refutation (B1): the retry is judged by what the request still
|
|
3619
|
+
// AUTHORIZES, not by what it recorded. A live question, a usable grant, a
|
|
3620
|
+
// human's no and a started execution are answered from the log and append
|
|
3621
|
+
// nothing. A question that can no longer be answered usefully is asked
|
|
3622
|
+
// again under the same registration: a lapsed or withdrawn request, a grant
|
|
3623
|
+
// whose window ran out (a tap at hour 71 of 72 authorizes nothing at 73),
|
|
3624
|
+
// and a grant or pending request pinned to a policy that has since been
|
|
3625
|
+
// re-attested (`void`: the spend and the decision both refuse it).
|
|
3626
|
+
const standing = requestStanding(read.records, input.actionKey, tick(options), ttlOf(load), attested.sha256);
|
|
3627
|
+
const seqOf = derivation.decisionSeq ?? derivation.requestSeq;
|
|
3628
|
+
if (standing === "executed") {
|
|
3629
|
+
return answered(derivation.requestSeq !== null
|
|
3630
|
+
? "requested"
|
|
3631
|
+
: resolution.autonomy === "autonomous"
|
|
3632
|
+
? "autonomous"
|
|
3633
|
+
: "supervised", "executed", seqOf, true);
|
|
3634
|
+
}
|
|
3635
|
+
if (standing === "requested" ||
|
|
3636
|
+
standing === "granted" ||
|
|
3637
|
+
standing === "rejected" ||
|
|
3638
|
+
standing === "revoked") {
|
|
3639
|
+
return answered("requested", standing, seqOf, true);
|
|
3640
|
+
}
|
|
3641
|
+
if (standing === "void" && derivation.state === "requested") {
|
|
3642
|
+
// A pending question no human may decide any more. Taken back by its own
|
|
3643
|
+
// requester (the gate's only way to end a request early that is not a
|
|
3644
|
+
// decision), then asked again below.
|
|
3645
|
+
const retracted = withdraw(logPath, input.actionKey, actor, {
|
|
3646
|
+
...options,
|
|
3647
|
+
onlyIfVoid: true,
|
|
3648
|
+
reason: "superseded",
|
|
3649
|
+
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",
|
|
3650
|
+
});
|
|
3651
|
+
if (!retracted.ok) {
|
|
3652
|
+
// A concurrent caller withdrew it, re-asked it, or a human answered it.
|
|
3653
|
+
if (retracted.code === "request-withdrawn" ||
|
|
3654
|
+
retracted.code === "already-decided" ||
|
|
3655
|
+
retracted.code === "duplicate-request" ||
|
|
3656
|
+
retracted.code === "expired") {
|
|
3657
|
+
return again(retracted);
|
|
3658
|
+
}
|
|
3659
|
+
return retracted;
|
|
3660
|
+
}
|
|
3661
|
+
withdrewNow = true;
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
else if (existing.code === "not-registered") {
|
|
3665
|
+
const registered = register(logPath, {
|
|
3666
|
+
task,
|
|
3667
|
+
envelope: {
|
|
3668
|
+
origin: { app: PROPOSE_ORIGIN_APP, created_by: actor },
|
|
3669
|
+
state: "proposed",
|
|
3670
|
+
actions: [
|
|
3671
|
+
{
|
|
3672
|
+
class: input.cls,
|
|
3673
|
+
summary: input.summary,
|
|
3674
|
+
idempotency_key: input.actionKey,
|
|
3675
|
+
payload_hash: hash,
|
|
3676
|
+
},
|
|
3677
|
+
],
|
|
3678
|
+
},
|
|
3679
|
+
}, actor, { ...options, proposal: true });
|
|
3680
|
+
if (!registered.ok) {
|
|
3681
|
+
// APRV-445 (L5). Two identical proposals racing: the other one registered
|
|
3682
|
+
// this exact task first. Its registration is ours byte for byte, so the
|
|
3683
|
+
// call is a retry and is answered as one.
|
|
3684
|
+
if (registered.code === "task-already-registered")
|
|
3685
|
+
return again(registered);
|
|
3686
|
+
return registered;
|
|
3687
|
+
}
|
|
3688
|
+
registeredNow = true;
|
|
3689
|
+
}
|
|
3690
|
+
else {
|
|
3691
|
+
// The derived task exists and declares some other key: nothing `propose`
|
|
3692
|
+
// registers looks like that, so somebody else registered this id.
|
|
3693
|
+
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.`);
|
|
3694
|
+
}
|
|
3695
|
+
const result = request(logPath, {
|
|
3696
|
+
task,
|
|
3697
|
+
actionKey: input.actionKey,
|
|
3698
|
+
cls: input.cls,
|
|
3699
|
+
summary: input.summary,
|
|
3700
|
+
payload_hash: hash,
|
|
3701
|
+
payload: { value: input.payload },
|
|
3702
|
+
execution: "harness",
|
|
3703
|
+
agentProposal: true,
|
|
3704
|
+
}, actor, options);
|
|
3705
|
+
if (!result.ok) {
|
|
3706
|
+
if (result.code === "duplicate-request" || result.code === "already-decided" || result.code === "already-executed") {
|
|
3707
|
+
return again(result);
|
|
3708
|
+
}
|
|
3709
|
+
return result;
|
|
3710
|
+
}
|
|
3711
|
+
if (result.record === null) {
|
|
3712
|
+
return answered(result.autonomy === "autonomous" ? "autonomous" : "supervised", null, null,
|
|
3713
|
+
// APRV-445 recheck (L-a): a call that withdrew a void request appended
|
|
3714
|
+
// something, so it is not an idempotent answer.
|
|
3715
|
+
!registeredNow && !withdrewNow);
|
|
3716
|
+
}
|
|
3717
|
+
return answered("requested", "requested", result.record.seq, false);
|
|
3718
|
+
}
|
|
3719
|
+
/**
|
|
3720
|
+
* `approval start <task> --action <key> --payload-json <json>` (APRV-445): record
|
|
3721
|
+
* the `execution.started` for an action its requester is about to carry out
|
|
3722
|
+
* itself, outside any harness hook.
|
|
3723
|
+
*
|
|
3724
|
+
* Until this verb an agent holding only the serve agent credential could ask and
|
|
3725
|
+
* wait, and could not say that it then acted: `approval run` spawns argv on the
|
|
3726
|
+
* gate's machine, and the harness hook writes starts only for tool calls it
|
|
3727
|
+
* classified. A proposal the policy authorizes outright (`autonomous`,
|
|
3728
|
+
* `supervised`) therefore left no record at all, and one a human granted left a
|
|
3729
|
+
* grant nobody spent, so a second publication of the same key was indistinguishable
|
|
3730
|
+
* from the first.
|
|
3731
|
+
*
|
|
3732
|
+
* It adds no authority. It writes through the two existing spenders and through
|
|
3733
|
+
* nothing else: {@link consumeHarnessGrant} when the key carries a request (it
|
|
3734
|
+
* refuses unless that request is granted, unexpired, harness-executed and
|
|
3735
|
+
* unspent, under the policy it was asked under), and {@link startHarnessExecution}
|
|
3736
|
+
* when it carries none (it refuses a `manual` or `human-only` class, an escalated
|
|
3737
|
+
* loop and a spent key, and charges the budgets). Both pin the single-use rule:
|
|
3738
|
+
* a second start of one key is `already-executed`.
|
|
3739
|
+
*
|
|
3740
|
+
* Two checks of its own, both before either spender runs. REQUESTER-ONLY: the
|
|
3741
|
+
* actor must be the one that registered the task (`not-requester` otherwise), as
|
|
3742
|
+
* `withdraw` is, so an agent cannot spend or exhaust a key someone else declared.
|
|
3743
|
+
* And the bytes: the caller presents the payload it is about to act on, and its
|
|
3744
|
+
* hash must equal the registered `payload_hash` (`payload-mismatch` otherwise),
|
|
3745
|
+
* so the thing done is the thing that was approved, which the grant path checks
|
|
3746
|
+
* again against the grant's own binding.
|
|
3747
|
+
*/
|
|
3748
|
+
export function startProposed(logPath, task, actionKey, payload, actor, options = {}) {
|
|
3749
|
+
if (!isPrincipalActor(actor)) {
|
|
3750
|
+
return refuse("actor-invalid", `start requires a human: or agent: actor, got ${JSON.stringify(actor)}`);
|
|
3751
|
+
}
|
|
3752
|
+
let hash;
|
|
3753
|
+
try {
|
|
3754
|
+
hash = hashOfPayload(payload);
|
|
3755
|
+
}
|
|
3756
|
+
catch (cause) {
|
|
3757
|
+
return refuse("payload-store-failed", `the payload for ${actionKey} could not be canonicalized: ${cause instanceof Error ? cause.message : String(cause)}. Nothing was appended.`);
|
|
3758
|
+
}
|
|
3759
|
+
if (!isProposalTask(task)) {
|
|
3760
|
+
return refuse("task-not-proposal", `task ${task} was not registered by \`approval propose\`; start records the executions of proposals only. Nothing was appended.`);
|
|
3761
|
+
}
|
|
3762
|
+
const read = readGateRecords(logPath);
|
|
3763
|
+
if (!read.ok)
|
|
3764
|
+
return read;
|
|
3765
|
+
const declared = registeredAction(read.records, task, actionKey);
|
|
3766
|
+
if (!declared.ok)
|
|
3767
|
+
return declared;
|
|
3768
|
+
let registrant = null;
|
|
3769
|
+
for (const record of read.records) {
|
|
3770
|
+
if (record.event === "task.registered" && record.task === task)
|
|
3771
|
+
registrant = record.actor;
|
|
3772
|
+
}
|
|
3773
|
+
if (registrant !== actor) {
|
|
3774
|
+
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.`);
|
|
3775
|
+
}
|
|
3776
|
+
if (declared.action.payload_hash !== hash) {
|
|
3777
|
+
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.`);
|
|
3778
|
+
}
|
|
3779
|
+
// APRV-445 refutation (S5). Only a request that can still answer binds the
|
|
3780
|
+
// key to the grant path: one that is pending (spending it refuses until a
|
|
3781
|
+
// human decides) or granted and unspent. A withdrawn, expired, rejected or
|
|
3782
|
+
// revoked request says nothing about an action the policy authorizes on its
|
|
3783
|
+
// own now, so after a re-tiering to autonomous the start is the policy's.
|
|
3784
|
+
// This read only routes; the policy path re-judges inside its own append
|
|
3785
|
+
// (`proposalPolicyStartRefusal`): a live request, a human's no under the
|
|
3786
|
+
// policy in force, and a selected supervised-live draw each refuse there.
|
|
3787
|
+
const derivation = requestState(read.records, actionKey, tick(options), ttlOf(parsePolicy(readPolicyOnce(options), options)));
|
|
3788
|
+
const binding = derivation.execution.started === null &&
|
|
3789
|
+
(derivation.state === "requested" || derivation.state === "granted");
|
|
3790
|
+
if (binding) {
|
|
3791
|
+
const spent = consumeHarnessGrant(logPath, actionKey, actor, {
|
|
3792
|
+
...options,
|
|
3793
|
+
presentedPayloadHash: hash,
|
|
3794
|
+
spendingTask: task,
|
|
3795
|
+
});
|
|
3796
|
+
if (!spent.ok)
|
|
3797
|
+
return spent;
|
|
3798
|
+
return { ok: true, task, actionKey, cls: declared.action.class, authorization: "grant", record: spent.record };
|
|
3799
|
+
}
|
|
3800
|
+
const started = startHarnessExecution(logPath, {
|
|
3801
|
+
task,
|
|
3802
|
+
actionKey,
|
|
3803
|
+
cls: declared.action.class,
|
|
3804
|
+
payload_hash: hash,
|
|
3805
|
+
...(declared.action.est_cost_usd === undefined ? {} : { est_cost_usd: declared.action.est_cost_usd }),
|
|
3806
|
+
}, actor, options);
|
|
3807
|
+
if (!started.ok)
|
|
3808
|
+
return started;
|
|
3809
|
+
return { ok: true, task, actionKey, cls: declared.action.class, authorization: "policy", record: started.record };
|
|
3810
|
+
}
|
|
3811
|
+
export function requestStanding(records, actionKey, ts, ttlMs, attestedSha256) {
|
|
3812
|
+
const derivation = requestState(records, actionKey, ts, ttlMs);
|
|
3813
|
+
if (derivation.execution.started !== null)
|
|
3814
|
+
return "executed";
|
|
3815
|
+
if (derivation.state === "granted") {
|
|
3816
|
+
if (grantLapsed(derivation, ts))
|
|
3817
|
+
return "expired";
|
|
3818
|
+
// APRV-445 recheck (B). A grant is void only where its SPEND refuses
|
|
3819
|
+
// `policy-drift`, which is the harness spend (`consumeHarnessGrant`) and
|
|
3820
|
+
// therefore every proposal. A token grant is spent by `approval run`
|
|
3821
|
+
// through `consumeToken`, which does not compare policy hashes, so after a
|
|
3822
|
+
// re-attest that grant still runs: calling it void would abort the
|
|
3823
|
+
// documented `wait && run` flow and send an agent to ask again over a live
|
|
3824
|
+
// human grant.
|
|
3825
|
+
const driftEnforced = derivation.declared.execution === "harness" || isProposalTask(derivation.task);
|
|
3826
|
+
const pinned = grantedPolicyHash(records, derivation.decisionSeq) ?? derivation.declared.policy_sha256;
|
|
3827
|
+
if (driftEnforced && attestedSha256 !== null && pinned !== null && pinned !== attestedSha256) {
|
|
3828
|
+
return "void";
|
|
3829
|
+
}
|
|
3830
|
+
return "granted";
|
|
3831
|
+
}
|
|
3832
|
+
if (derivation.state === "requested") {
|
|
3833
|
+
const pinned = derivation.declared.policy_sha256;
|
|
3834
|
+
if (attestedSha256 !== null && pinned !== null && pinned !== attestedSha256)
|
|
3835
|
+
return "void";
|
|
3836
|
+
return "requested";
|
|
3837
|
+
}
|
|
3838
|
+
if (derivation.state === "withdrawn" && derivation.decisionSeq !== null) {
|
|
3839
|
+
for (const record of records) {
|
|
3840
|
+
if (record.seq !== derivation.decisionSeq)
|
|
3841
|
+
continue;
|
|
3842
|
+
if (payloadOf(record)["reason"] === "policy-drift")
|
|
3843
|
+
return "void";
|
|
3844
|
+
}
|
|
3845
|
+
}
|
|
3846
|
+
return derivation.state;
|
|
3847
|
+
}
|
|
3848
|
+
/**
|
|
3849
|
+
* The SHA-256 the live policy bytes are attested under, or `null` when they are
|
|
3850
|
+
* not attested (unread, unattested, or changed since). One read of the file
|
|
3851
|
+
* through the same seam every gate operation uses.
|
|
3852
|
+
*/
|
|
3853
|
+
export function attestedPolicySha256(records, options = {}) {
|
|
3854
|
+
const attested = requireAttestation(records, readPolicyOnce(options));
|
|
3855
|
+
return attested.ok ? attested.sha256 : null;
|
|
3856
|
+
}
|
|
3082
3857
|
//# sourceMappingURL=gate.js.map
|