From da4b17ab397a113eade3fe36a2891081a6455323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Jacob=20Read=20IV=20=E2=80=94=20Creator=20of=20?= =?UTF-8?q?=C4=80ML=E2=84=A2?= Date: Sun, 18 Oct 2026 22:53:17 -0700 Subject: [PATCH] Pin file action authority mode and anchored grants --- .../action-boundary/AUTHORITY_CHECKPOINT.md | 4 +- pilots/action-boundary/README.md | 2 + .../REVOCABLE_PREPARED_MAIL.md | 2 +- .../file-revocation-handoff.mjs | 54 +++++++- ...ion-boundary-file-authority-anchor.test.js | 46 +++++-- ...n-boundary-file-revocation-handoff.test.js | 126 +++++++++++++++++- 6 files changed, 213 insertions(+), 21 deletions(-) diff --git a/pilots/action-boundary/AUTHORITY_CHECKPOINT.md b/pilots/action-boundary/AUTHORITY_CHECKPOINT.md index 71f0009a..ecf99394 100644 --- a/pilots/action-boundary/AUTHORITY_CHECKPOINT.md +++ b/pilots/action-boundary/AUTHORITY_CHECKPOINT.md @@ -17,9 +17,9 @@ The file anchor is a **one-machine example**. It uses an owner-only directory, a Protocol 2 sorts the four decision-file namespaces and their filenames, canonicalizes each parsed JSON record, hashes the individual records, and commits the ordered list's digest to both checkpoints. An unexpected filename, partial temporary record, missing record, or changed record value stops the authority. Validation scans the decision files on startup and at the authority boundary; its cost grows with retained records. The host clock and post-callback outcome records are outside this commitment and retain their prior handling. -Existing protocol-1 checkpoints remain readable and continue using their original **whole-directory rollback** detection. They do not silently gain selective-record protection or change protocol during a write. Starting a new protocol-2 authority requires a fresh empty local directory and a fresh independent anchor after the host has reconciled any earlier operations; copying an old store into one is not a migration procedure. +Protocol-1 checkpoints with an established authority mode record remain readable and continue using their original **whole-directory rollback** detection. They do not silently gain selective-record protection or change protocol during a write. A pre-mode store without that record fails closed and requires operator reconciliation and migration; it cannot silently adopt a mode. Starting a new protocol-2 authority requires a fresh empty local directory and a fresh independent anchor after the host has reconciled any earlier operations; copying an old store into one is not a migration procedure. -The separately supported `claimOnce` hook can be configured at the same time. It guards the spent host operation with an atomic external insert, while this checkpoint also records issuance and revocation transitions. Both configured mechanisms must permit dispatch before its callback runs. A checkpoint mismatch after an awaited external claim stops the callback and requires reconciliation. +The separately supported `claimOnce` hook can be configured at the same time. Choose that combined mode when initializing the authority; adding or removing the hook on an existing store fails its mode check and needs an explicit reconciled migration. It guards the spent host operation with an atomic external insert, while this checkpoint also records issuance and revocation transitions. Both configured mechanisms must permit dispatch before its callback runs. A checkpoint mismatch after an awaited external claim stops the callback and requires reconciliation. The separate `epochAnchor` option uses an epoch-aware external claim at handoff and an asynchronous revocation protocol. It cannot be configured alongside this checkpoint; choose one revocation protection for an authority directory. The epoch-aware claim checks the held issuer epoch at dispatch. Protocol-2 checkpoints detect selective changes to the local epoch and other committed decision records before issuing or dispatching, provided the outside checkpoint remains current. diff --git a/pilots/action-boundary/README.md b/pilots/action-boundary/README.md index 9d21c192..fae370ea 100644 --- a/pilots/action-boundary/README.md +++ b/pilots/action-boundary/README.md @@ -69,6 +69,8 @@ const revoked = await authority.revokeIssuerAnchored({ tenant, issuer }); The host must keep that store independent of the local authority directory and retain both its epochs and claims through local rollback. The local lock spans the external epoch update and local epoch write; a rejected or uncertain advance leaves the lock stranded for operator reconciliation. A restored old directory can still issue a stale grant, but the epoch-aware external claim rejects it before the callback. [`testing-epoch-anchor.mjs`](testing-epoch-anchor.mjs) demonstrates the protocol with separate local files and a cross-process rollback test; it is not a production database, multi-machine authority, or tamper-resistant store. All participating local workers and revocation routes must use the **same** local authority lock; independent local stores can race an external advance after a claim and before provider handoff. An anchor outage blocks callbacks, and a returned callback remains no proof of provider acceptance. +The first operation on a fresh, empty authority store writes a durable `authority-mode.json` record for `local`, `claim-anchored`, or `epoch-anchored` mode; a checkpoint authority initializes its mode with its checkpoint and separately distinguishes checkpoint plus claim. Every later operation checks that record under the local lock, including after an awaited external claim or epoch advance. A worker with the wrong mode gets `authority_mode_mismatch` before it can issue, revoke, or dispatch; a checkpoint worker that omits the anchor fails at construction as `authority_anchor_required`. A mode mismatch releases the lock so a correctly configured worker can continue. An existing store with a missing mode record fails closed as `authority_mode_unavailable` on issuance and `authority_store_unavailable` on dispatch (or at construction for a checkpoint authority). New grants using an external claim separately record their required claim protocol; if a valid weaker mode record is substituted, such a grant still gets `required_anchor_unavailable` before a claim or callback. Do not change modes in place. An existing pre-mode store needs operator reconciliation and migration to a new authority store with the intended external anchor; its old records cannot silently adopt a mode. These local records do not bind a worker to a particular external backend, protect grants from older versions that lack the requirement field, prevent unanchored issuance from a snapshot predating mode adoption, resist modification by a process with the same OS identity, or stop a tool path that bypasses this authority. + **Bind approval to a pre-revocation epoch:** [`createRevocablePreparedMailToolExecute`](REVOCABLE_PREPARED_MAIL.md) is an opt-in host adapter for a prepared mail request and this file store. `observeIssuer()` reads the tenant/issuer epoch under the authority lock before the host awaits authenticated approval. After exact digest approval and an unchanged policy check, `issue(..., { expectedEpoch })` conditionally creates the grant under that same lock; a revocation during approval blocks issuance without storing an unapproved grant. Revocation between issuance and callback invocation is checked again by `dispatch()`. The integration tests include cross-process revocation during both windows and an in-memory sender; no live mail provider is involved. **Bind one host intent across retries:** The adapter now requires `operationIdFor(context)` to return a stable host-generated ID. `issue(..., { operationId })` writes a durable operation marker under the same authority lock **before** the grant, scoped to tenant and issuer. Another worker cannot issue a second grant for that operation, even when a fresh approval returns the same digest. Reusing the ID with a different request or policy blocks as a mismatch; a new intended send must use a new ID and approval. The optional `operationId` on the lower-level `issue()` preserves its older direct-call behavior when omitted. These markers need deliberate retention and operator reconciliation after uncertain or lost sends. The local operation marker alone assumes the store is preserved; a fresh instance needs an independently retained external claim or checkpoint to withstand an older restored directory. diff --git a/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md b/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md index 8fca0169..4c7e43bc 100644 --- a/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md +++ b/pilots/action-boundary/REVOCABLE_PREPARED_MAIL.md @@ -37,7 +37,7 @@ The policy must have one exact `send_message` / `send` rule for the **prepared** The returned `receipt.execution_status` is `blocked`, `dispatched`, or `unknown`, with `dispatch_count` from the authority. `dispatched` means the host callback returned, **not** provider acceptance or delivery. `unknown` must not trigger an automatic retry. A repeated ID blocks with `operation_already_claimed`; a changed prepared request or policy under that ID blocks with `operation_payload_mismatch`. The operation is spent **before** its grant is written. An error or crash in that gap can lose a send, so investigate the provider and local records before authorizing a genuinely new operation ID. The operation record does not expire with the short-lived grant and must not be automatically deleted. An approval can remain pending indefinitely unless the host enforces its own approval timeout; the grant TTL starts at conditional issuance. A fresh action begun after revocation may be approved under the newer epoch because `revokeIssuer` invalidates old work, rather than suspending an issuer. Every policy revocation that must invalidate pending approvals must also advance the same issuer epoch. -The adapter does not authenticate a human, assess policy meaning, inspect the provider's actual routing, prevent another code path from sending, or coordinate machines. Use a private trusted local filesystem shared by all participating workers; the file store intentionally strands a crashed lock for operator reconciliation. Its one-operation property depends on preserving that store unless the host supplies an independently held atomic `claimOnce` hook, as described in [the file authority contract](README.md#anchor-a-one-use-claim-outside-the-local-authority-store), or enables the [independent checkpoint](AUTHORITY_CHECKPOINT.md). The hook uses the stable host operation key to reject a new grant for a spent intent after local rollback. To preserve issuer revocation against old *unspent* grants, use either the [`epochAnchor` contract](README.md#preserve-issuer-revocation-across-local-rollback) and route every revocation through `await authority.revokeIssuerAnchored(...)`, or the checkpoint with its separately retained state; the two options cannot be configured together. An asynchronous claim keeps the local lock busy and rechecks expiry before the callback; an uncertain claim consumes the local grant and never sends. The included tests use an in-memory sender and separate Node processes for revocation and duplicate operation claims, not a live provider or outside audit. +The adapter does not authenticate a human, assess policy meaning, inspect the provider's actual routing, prevent another code path from sending, or coordinate machines. Use a private trusted local filesystem shared by all participating workers; the file store intentionally strands a crashed lock for operator reconciliation. Its one-operation property depends on preserving that store unless the host supplies an independently held atomic `claimOnce` hook, as described in [the file authority contract](README.md#anchor-a-one-use-claim-outside-the-local-authority-store), or enables the [independent checkpoint](AUTHORITY_CHECKPOINT.md). The hook uses the stable host operation key to reject a new grant for a spent intent after local rollback. To preserve issuer revocation against old *unspent* grants, use either the [`epochAnchor` contract](README.md#preserve-issuer-revocation-across-local-rollback) and route every revocation through `await authority.revokeIssuerAnchored(...)`, or the checkpoint with its separately retained state; the two options cannot be configured together. The local store pins its authority mode, and grants using an external claim pin their required claim protocol; a restarted worker with the wrong mode cannot issue or dispatch against that store. An asynchronous claim keeps the local lock busy and rechecks expiry before the callback; an uncertain claim consumes the local grant and never sends. The included tests use an in-memory sender and separate Node processes for revocation and duplicate operation claims, not a live provider or outside audit. ```sh node --test test/action-boundary-revocable-prepared-mail.test.js test/action-boundary-file-revocation-handoff.test.js diff --git a/pilots/action-boundary/file-revocation-handoff.mjs b/pilots/action-boundary/file-revocation-handoff.mjs index 84fcedeb..f091dbce 100644 --- a/pilots/action-boundary/file-revocation-handoff.mjs +++ b/pilots/action-boundary/file-revocation-handoff.mjs @@ -11,6 +11,8 @@ import { hashHandoffBytes as hash, validHandoffIdentity as identity, const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const DIGEST = /^[0-9a-f]{64}$/; +const CLAIM_V1 = "aml-file-authority-external-claim/1"; +const CLAIM_V2 = "aml-file-authority-external-claim/2"; // A private leaf is insufficient if a parent can be switched to an older // authority snapshot. /tmp-style sticky directories protect an owned child; @@ -173,6 +175,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), } const externalClaim = epochAnchor ? epochAnchor.claimOnce.bind(epochAnchor) : claimOnce; const externalAdvance = epochAnchor?.advanceEpoch.bind(epochAnchor); + const claimProtocol = epochAnchor ? CLAIM_V2 : claimOnce ? CLAIM_V1 : null; privateDirectory(directory, true); const epochs = path.join(directory, "epochs"); const grants = path.join(directory, "grants"); @@ -181,7 +184,11 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), const outcomes = path.join(directory, "outcomes"); const lock = path.join(directory, "authority.lock"); const clockFile = path.join(directory, "clock.json"); + const modeFile = path.join(directory, "authority-mode.json"); const checkpointFile = path.join(directory, "checkpoint.json"); + const authorityMode = epochAnchor ? "epoch-anchored" : anchor ? + claimOnce ? "checkpoint-claim-anchored" : "checkpoint-anchored" : + claimOnce ? "claim-anchored" : "local"; for (const child of [epochs, grants, operations, claimed, outcomes]) privateDirectory(child, true); const pinned = new Map(); for (const child of [directory, epochs, grants, operations, claimed, outcomes]) { @@ -225,6 +232,28 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), } } + function checkMode({ initialize = true } = {}) { + const record = readRecord(modeFile); + if (!record) { + // A missing mode in an existing store may be a rollback or lost mode + // record. Never infer a weaker mode from the current worker's config. + if (!initialize || fs.existsSync(clockFile) || fs.existsSync(checkpointFile) || + [epochs, grants, operations, claimed, outcomes] + .some(child => fs.readdirSync(child).length !== 0)) { + throw Error("authority_mode_unavailable"); + } + createExclusive(modeFile, { + protocol: "aml-file-authority-mode/1", mode: authorityMode + }, directory); + } else if (record.protocol !== "aml-file-authority-mode/1" || + !["local", "claim-anchored", "epoch-anchored", "checkpoint-anchored", + "checkpoint-claim-anchored"].includes(record.mode)) { + throw Error("invalid_authority_mode"); + } else if (record.mode !== authorityMode) { + throw Error("authority_mode_mismatch"); + } + } + function checkpointRecord(value) { const v1 = value?.protocol === "aml-file-authority-checkpoint/1"; const v2 = value?.protocol === "aml-file-authority-checkpoint/2"; @@ -269,6 +298,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), throw error; } try { + checkMode(); if (readRecord(checkpointFile)) checkCheckpoint(); else { if (externalState() !== null || fs.existsSync(clockFile) || @@ -320,10 +350,13 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), throw error; } syncDirectory(directory); // If this fails, leave the lock to fail closed. - // A different worker might have committed between the earlier read and - // lock acquisition. A mismatch now leaves no usable authority lock. - try { checkCheckpoint(); } - catch (error) { fs.rmdirSync(lock); syncDirectory(directory); throw error; } + try { checkCheckpoint(); checkMode(); } + catch (error) { + // A mismatched worker must not strand the legitimate workers' lock. + // A failed release still fails closed for everyone. + release(); + throw error; + } } function release() { @@ -408,6 +441,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), if (result === false) throw Error("external_epoch_mismatch"); if (result !== true) throw Error("external_epoch_unavailable"); checkDirectories(); + checkMode({ initialize: false }); if (currentEpoch(key) !== expected) throw Error("authority_epoch_changed"); replaceAtomic(path.join(epochs, `${key}.json`), { protocol: "aml-file-authority-epoch/1", issuer_sha256: key, epoch: next @@ -468,6 +502,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), issuer_sha256: issuer, binding_sha256: bound.sha256, epoch, issued_at_ms: issuedAt, expires_at_ms: issuedAt + ttlMs, + ...(claimProtocol ? { required_claim_protocol: claimProtocol } : {}), ...(operationHash ? { operation_sha256: operationHash } : {}) }; anchoredWrite("issue", grantId, { operation: operationRecord, grant: grantRecord }, () => { @@ -503,6 +538,9 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), else if (record.protocol !== "aml-file-authority-grant/1" || record.grant_id !== grant_id || !DIGEST.test(record.binding_sha256) || !DIGEST.test(record.issuer_sha256) || (record.operation_sha256 !== undefined && !DIGEST.test(record.operation_sha256)) || + (record.required_claim_protocol !== undefined && + record.required_claim_protocol !== CLAIM_V1 && + record.required_claim_protocol !== CLAIM_V2) || !Number.isSafeInteger(record.epoch) || record.epoch < 0 || !Number.isSafeInteger(record.issued_at_ms) || !Number.isSafeInteger(record.expires_at_ms) || record.issued_at_ms >= record.expires_at_ms) { @@ -510,6 +548,8 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), } else if (record.binding_sha256 !== bound.sha256 || record.issuer_sha256 !== issuerHash(bound.context.tenant, bound.context.issuer)) { decision = denied("blocked", "binding_mismatch"); + } else if (record.required_claim_protocol && record.required_claim_protocol !== claimProtocol) { + decision = denied("indeterminate", "required_anchor_unavailable"); } else if (record.operation_sha256 && !operationMatches(record)) { decision = denied("indeterminate", "operation_record_unavailable"); } else { @@ -536,8 +576,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), // restored local snapshot cannot erase that separate claim. try { const result = await externalClaim(Object.freeze({ - protocol: epochAnchor ? "aml-file-authority-external-claim/2" : - "aml-file-authority-external-claim/1", + protocol: claimProtocol, // An operation-backed grant keeps the same host intent key // even if a restored local snapshot mints a different grant. claim_key_sha256: record.operation_sha256 ?? idempotencyKey, @@ -554,6 +593,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), // authority path replacement. Recheck immediately before the // callback, still under the lock and with the claim spent. checkDirectories(); + checkMode({ initialize: false }); checkCheckpoint(); if (record.operation_sha256 && !operationMatches(record)) { decision = denied("indeterminate", "operation_record_unavailable"); @@ -578,7 +618,7 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), } } catch (error) { decision = invoked ? { status: "outcome_unknown", reason: "authority_or_callback_error", dispatch_count: 1 } : - denied("indeterminate", error.message === "authority_busy_or_crashed" ? error.message : + denied("indeterminate", ["authority_busy_or_crashed", "authority_mode_mismatch"].includes(error.message) ? error.message : "authority_store_unavailable"); } finally { if (held) { diff --git a/test/action-boundary-file-authority-anchor.test.js b/test/action-boundary-file-authority-anchor.test.js index 02cca5e2..1c69cdff 100644 --- a/test/action-boundary-file-authority-anchor.test.js +++ b/test/action-boundary-file-authority-anchor.test.js @@ -31,14 +31,17 @@ function copyAuthority(directory, snapshot) { } } } -async function fixture(run) { +async function fixture(run, { withClaim = false } = {}) { const parent = fs.mkdtempSync(path.join(os.tmpdir(), "aml-anchor-test-")); const directory = path.join(parent, "authority"); const anchorDirectory = path.join(parent, "independent-anchor"); + const claimDirectory = path.join(parent, "independent-claims"); try { const anchor = createFileAuthorityAnchor({ directory: anchorDirectory }); - const host = createFileRevocationHandoff({ directory, anchor }); - return await run({ parent, directory, anchorDirectory, anchor, host }); + if (withClaim) fs.mkdirSync(claimDirectory, { mode: 0o700 }); + const host = createFileRevocationHandoff({ directory, anchor, + ...(withClaim ? { claimOnce: createTestingClaimOnce(claimDirectory) } : {}) }); + return await run({ parent, directory, anchorDirectory, claimDirectory, anchor, host }); } finally { fs.rmSync(parent, { recursive: true, force: true }); } } @@ -64,6 +67,26 @@ test("an independent checkpoint advances for issue, claim, and revocation", () = assert.throws(() => createFileRevocationHandoff({ directory }), /authority_anchor_required/); })); +test("checkpoint authority rejects a different claim mode and a lost mode record", () => + fixture(async ({ parent, directory, anchor, host }) => { + const claimDirectory = path.join(parent, "additional-claims"); + fs.mkdirSync(claimDirectory, { mode: 0o700 }); + assert.throws(() => createFileRevocationHandoff({ directory, anchor, + claimOnce: createTestingClaimOnce(claimDirectory) }), /authority_mode_mismatch/); + const grant = host.issue(input()); + assert.equal(JSON.parse(fs.readFileSync(path.join(directory, + "authority-mode.json"), "utf8")).mode, "checkpoint-anchored"); + fs.rmSync(path.join(directory, "authority-mode.json")); + assert.throws(() => host.issue(input()), /authority_mode_unavailable/); + assert.throws(() => createFileRevocationHandoff({ directory, anchor }), + /authority_mode_unavailable/); + let calls = 0; + const result = await host.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + assert.deepEqual([result.status, result.dispatch_count, calls], ["indeterminate", 0, 0]); + assert.equal(anchor.read().sequence, 1); + })); + test("selectively restoring an epoch file cannot revive an unspent grant", () => fixture(async ({ directory, anchorDirectory, anchor, host }) => { const grant = host.issue(input(), { operationId: "host-intent-selective-epoch" }); @@ -112,11 +135,14 @@ test("selective grant edits and operation loss disagree with the checkpoint", () /authority_state_mismatch/); })); -test("a protocol-1 checkpoint remains readable without an automatic upgrade", () => +test("a protocol-1 checkpoint with a mode pin remains readable without an upgrade", () => fixture(async ({ parent }) => { const directory = path.join(parent, "legacy-authority"); const anchor = createFileAuthorityAnchor({ directory: path.join(parent, "legacy-anchor") }); createFileRevocationHandoff({ directory }); // Create an otherwise empty authority. + fs.writeFileSync(path.join(directory, "authority-mode.json"), JSON.stringify({ + protocol: "aml-file-authority-mode/1", mode: "checkpoint-anchored" + }) + "\n", { mode: 0o600 }); const initial = { protocol: "aml-file-authority-checkpoint/1", store_id: randomUUID(), sequence: 0, head_sha256: "a".repeat(64) }; assert.equal(anchor.compareAndSwap(null, initial), true); @@ -179,11 +205,11 @@ test("a restored unspent grant cannot bypass a revocation absent a spent-claim a })); test("checkpoint and external one-use claim can gate the same callback", () => - fixture(async ({ parent, directory, anchor, host }) => { - const claimDirectory = path.join(parent, "claims-independent"); - fs.mkdirSync(claimDirectory, { mode: 0o700 }); + fixture(async ({ directory, anchor, claimDirectory, host }) => { const claim = createTestingClaimOnce(claimDirectory); const grant = host.issue(input(), { operationId: "host-intent-two-anchors" }); + assert.equal(JSON.parse(fs.readFileSync(path.join(directory, + "authority-mode.json"), "utf8")).mode, "checkpoint-claim-anchored"); const combined = createFileRevocationHandoff({ directory, anchor, claimOnce: claim }); let calls = 0; const first = await combined.dispatch({ ...input(), grant_id: grant.grant_id, @@ -193,7 +219,7 @@ test("checkpoint and external one-use claim can gate the same callback", () => assert.deepEqual([first.status, retry.dispatch_count, calls, anchor.read().sequence], ["returned", 0, 1, 2]); assert.equal(fs.readdirSync(claimDirectory).length, 1); - })); + }, { withClaim: true })); test("a checkpoint mismatch during an awaited external claim stops the callback", () => fixture(async ({ directory, anchor, host }) => { @@ -210,7 +236,7 @@ test("a checkpoint mismatch during an awaited external claim stops the callback" execute: () => { calls++; } }); assert.deepEqual([result.status, result.dispatch_count, calls], ["indeterminate", 0, 0]); assert.equal(anchor.read().sequence, 2); - })); + }, { withClaim: true })); test("restoring both the authority and its file anchor permits replay after restart", () => fixture(async ({ parent, directory, anchorDirectory, anchor, host }) => { @@ -284,6 +310,6 @@ test("an existing unanchored store cannot acquire a synthetic past, or host its const legacy = createFileRevocationHandoff({ directory: other }); legacy.issue(input()); assert.throws(() => createFileRevocationHandoff({ directory: other, anchor }), - /anchor_requires_empty_authority/); + /authority_mode_mismatch/); assert.equal(host.observeIssuer(input()).epoch, 0); })); diff --git a/test/action-boundary-file-revocation-handoff.test.js b/test/action-boundary-file-revocation-handoff.test.js index b2f80548..227d9fae 100644 --- a/test/action-boundary-file-revocation-handoff.test.js +++ b/test/action-boundary-file-revocation-handoff.test.js @@ -383,6 +383,10 @@ test("an external issuer epoch rejects unspent grants after local rollback and r fs.renameSync(directory, path.join(parent, "revoked-local")); fs.renameSync(snapshot, directory); + const downgraded = await exec(process.execPath, + [worker, directory, grant.grant_id, "normal"]); + assert.deepEqual([JSON.parse(downgraded.stdout).reason, + JSON.parse(downgraded.stdout).dispatch_count], ["authority_mode_mismatch", 0]); const restarted = await exec(process.execPath, [worker, directory, grant.grant_id, "epoch-anchored", anchorDirectory]); const result = JSON.parse(restarted.stdout); @@ -441,6 +445,107 @@ test("an epoch-aware claim dispatches once in each current issuer epoch", () => assert.deepEqual([seenEpochs, calls], [[0, 1], 2]); })); +test("a grant issued with an epoch anchor rejects downgraded workers", () => + withAsyncDirectory(async parent => { + const directory = path.join(parent, "authority"); + const anchorDirectory = path.join(parent, "epoch-anchor"); + fs.mkdirSync(anchorDirectory, { mode: 0o700 }); + const host = createFileRevocationHandoff({ directory, + epochAnchor: createTestingEpochAnchor(anchorDirectory) }); + const grant = host.issue(context()); + const saved = JSON.parse(fs.readFileSync(path.join(directory, "grants", + `${grant.grant_id}.json`), "utf8")); + assert.equal(saved.required_claim_protocol, "aml-file-authority-external-claim/2"); + assert.equal(JSON.parse(fs.readFileSync(path.join(directory, + "authority-mode.json"), "utf8")).mode, "epoch-anchored"); + assert.throws(() => createFileRevocationHandoff({ directory }).issue(context()), + /authority_mode_mismatch/); + assert.throws(() => createFileRevocationHandoff({ directory }).revokeIssuer(context()), + /authority_mode_mismatch/); + for (const mode of ["normal", "anchored"]) { + const attempted = await exec(process.execPath, + [worker, directory, grant.grant_id, mode, anchorDirectory]); + const result = JSON.parse(attempted.stdout); + assert.deepEqual([result.status, result.reason, result.dispatch_count], + ["indeterminate", "authority_mode_mismatch", 0]); + } + assert.equal(fs.existsSync(path.join(directory, "effects.log")), false); + assert.equal(fs.readdirSync(path.join(directory, "claimed")).length, 0); + const correct = await exec(process.execPath, + [worker, directory, grant.grant_id, "epoch-anchored", anchorDirectory]); + assert.equal(JSON.parse(correct.stdout).dispatch_count, 1); + assert.equal(fs.readFileSync(path.join(directory, "effects.log"), "utf8"), "callback\n"); + })); + +test("a spent-only anchored grant rejects an unanchored worker and invalid mode data", () => + withAsyncDirectory(async parent => { + const directory = path.join(parent, "authority"); + const anchorDirectory = path.join(parent, "held-claims"); + fs.mkdirSync(anchorDirectory, { mode: 0o700 }); + const host = createFileRevocationHandoff({ directory, + claimOnce: createTestingClaimOnce(anchorDirectory) }); + const grant = host.issue(context()); + const unanchored = await exec(process.execPath, + [worker, directory, grant.grant_id, "normal"]); + assert.deepEqual([JSON.parse(unanchored.stdout).reason, + JSON.parse(unanchored.stdout).dispatch_count], ["authority_mode_mismatch", 0]); + const file = path.join(directory, "grants", `${grant.grant_id}.json`); + const saved = JSON.parse(fs.readFileSync(file, "utf8")); + fs.writeFileSync(file, JSON.stringify({ ...saved, required_claim_protocol: "unknown" }) + "\n"); + let calls = 0; + const invalid = await host.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + assert.deepEqual([invalid.status, invalid.reason, invalid.dispatch_count, calls], + ["indeterminate", "invalid_grant_record", 0, 0]); + })); + +test("a missing mode closes an existing store, and a grant retains its anchor requirement", () => + withAsyncDirectory(async parent => { + const directory = path.join(parent, "authority"); + const anchorDirectory = path.join(parent, "epoch-anchor"); + fs.mkdirSync(anchorDirectory, { mode: 0o700 }); + const host = createFileRevocationHandoff({ directory, + epochAnchor: createTestingEpochAnchor(anchorDirectory) }); + const grant = host.issue(context()); + const copy = path.join(parent, "copy-without-mode"); + fs.cpSync(directory, copy, { recursive: true }); + fs.chmodSync(copy, 0o700); + for (const child of ["epochs", "grants", "operations", "claimed", "outcomes"]) { + fs.chmodSync(path.join(copy, child), 0o700); + } + fs.chmodSync(path.join(copy, "clock.json"), 0o600); + fs.chmodSync(path.join(copy, "grants", `${grant.grant_id}.json`), 0o600); + fs.rmSync(path.join(copy, "authority-mode.json")); + const downgraded = await exec(process.execPath, + [worker, copy, grant.grant_id, "normal"]); + assert.deepEqual([JSON.parse(downgraded.stdout).reason, + JSON.parse(downgraded.stdout).dispatch_count], ["authority_store_unavailable", 0]); + assert.throws(() => createFileRevocationHandoff({ directory: copy }).issue(context()), + /authority_mode_unavailable/); + assert.equal(fs.existsSync(path.join(copy, "effects.log")), false); + assert.equal(fs.readdirSync(path.join(copy, "claimed")).length, 0); + + // The grant still guards dispatch if the local mode is replaced with a + // syntactically valid weaker mode. The local record is not tamper proof. + const weaker = path.join(parent, "copy-with-weaker-mode"); + fs.cpSync(directory, weaker, { recursive: true }); + fs.chmodSync(weaker, 0o700); + for (const child of ["epochs", "grants", "operations", "claimed", "outcomes"]) { + fs.chmodSync(path.join(weaker, child), 0o700); + } + fs.chmodSync(path.join(weaker, "clock.json"), 0o600); + fs.chmodSync(path.join(weaker, "grants", `${grant.grant_id}.json`), 0o600); + fs.writeFileSync(path.join(weaker, "authority-mode.json"), JSON.stringify({ + protocol: "aml-file-authority-mode/1", mode: "local" + }) + "\n", { mode: 0o600 }); + const pinned = await exec(process.execPath, + [worker, weaker, grant.grant_id, "normal"]); + assert.deepEqual([JSON.parse(pinned.stdout).reason, + JSON.parse(pinned.stdout).dispatch_count], ["required_anchor_unavailable", 0]); + assert.equal(fs.existsSync(path.join(weaker, "effects.log")), false); + assert.equal(fs.readdirSync(path.join(weaker, "claimed")).length, 0); + })); + test("an uncertain external epoch advance strands the local lock", () => withAsyncDirectory(async directory => { const host = createFileRevocationHandoff({ directory, epochAnchor: { @@ -568,7 +673,7 @@ test("async external claim holds the local lock until the provider handoff", () allowClaim(true); const result = await action; assert.deepEqual([result.status, result.dispatch_count, calls], ["returned", 1, 1]); - assert.equal(createFileRevocationHandoff({ directory }).revokeIssuer({ + assert.equal(createFileRevocationHandoff({ directory, claimOnce: () => true }).revokeIssuer({ tenant: "tenant-a", issuer: "host-a" }).epoch, 1); })); @@ -614,6 +719,25 @@ test("a lost operation marker during an awaited claim blocks the callback", () = ["indeterminate", "operation_record_unavailable", 0, 0]); })); +test("a lost authority mode during an awaited claim blocks the callback", () => + withAsyncDirectory(async directory => { + let resolveClaim, entered; + const waiting = new Promise(resolve => { entered = resolve; }); + const host = createFileRevocationHandoff({ directory, + claimOnce: () => { entered(); return new Promise(resolve => { resolveClaim = resolve; }); } }); + const grant = host.issue(context()); + let calls = 0; + const action = host.dispatch({ ...context(), grant_id: grant.grant_id, + execute: () => { calls++; } }); + await waiting; + fs.rmSync(path.join(directory, "authority-mode.json")); + resolveClaim(true); + const result = await action; + assert.deepEqual([result.status, result.reason, result.dispatch_count, calls], + ["indeterminate", "authority_store_unavailable", 0, 0]); + assert.equal(fs.existsSync(path.join(directory, "claimed", `${grant.grant_id}.json`)), true); + })); + test("an authority path replaced during an awaited claim cannot invoke the callback", () => withAsyncDirectory(async parent => { const directory = path.join(parent, "authority");