diff --git a/README.md b/README.md index f5aac36d..f4ad326a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ **Detect a restored authority:** An [opt-in external checkpoint](pilots/action-boundary/AUTHORITY_CHECKPOINT.md) advances before grant, revocation, and claim records become usable. New protocol-3 stores commit to their decision files and authority mode, so a fresh worker refuses an older directory, a selectively restored revocation or spent marker, or a substituted mode while the separate anchor remains current. The included file anchor is still one-machine; restoring both copies together defeats this check. +**Inspect a blocked authority:** The [read-only inspection command](pilots/action-boundary/AUTHORITY_CHECKPOINT.md#inspect-before-reconciliation) reports a present lock, checkpoint mismatch, or altered decision files without changing the store. Its result supports operator investigation; it does not resolve an uncertain provider action. + **Design your own boundary:** [Open the Action Boundary Studio](https://aruintelligence.github.io/aml-core/action-boundary-studio.html). Edit the proposed action **and** the host policy, inspect the live plan and canonical digest, then download a JSON calculation bundle. `node scripts/verify-action-studio-bundle.mjs examples/action-studio/changed-destination.json` rechecks a published example against the policy included in that bundle; the [guide](pilots/action-boundary/ACTION_STUDIO.md) explains the limits. The verifier checks consistency, not policy ownership or a real approval. [![Action Boundary Studio: editable proposal and policy produce a visible exact-rule decision and portable calculation bundle.](docs/action-studio-preview.svg)](https://aruintelligence.github.io/aml-core/action-boundary-studio.html) diff --git a/pilots/action-boundary/AUTHORITY_CHECKPOINT.md b/pilots/action-boundary/AUTHORITY_CHECKPOINT.md index c73d0de1..ad4bccd7 100644 --- a/pilots/action-boundary/AUTHORITY_CHECKPOINT.md +++ b/pilots/action-boundary/AUTHORITY_CHECKPOINT.md @@ -33,8 +33,22 @@ If the anchor refuses an advance, the local mutation and callback do not run. If The protocol-3 check detects a **whole-directory rollback or selective change to committed decisions or mode** while its independent anchor remains current. It does not help if both copies are restored together, if the same actor can rewrite both stores, if clock or outcome records are changed, or if a tool bypasses this authority. The local file anchor shares the process's OS identity and is neither a multi-machine consensus system nor a trusted external witness. Its hashes authenticate neither the provider nor the approving person. A successful callback result still does not prove delivery. Production needs an anchor with an independently controlled retention and restore boundary plus a reviewed recovery procedure. +## Inspect before reconciliation + +The read-only inspection command reports the pinned authority mode, observed authority lock and file-anchor lock, local and external checkpoint records, their agreement, and the protocol-2 or protocol-3 committed-state digest and decision-file counts: + +```sh +node pilots/action-boundary/inspect-file-authority.mjs \ + /absolute/private/authority /absolute/independent/anchor +``` + +The optional second argument is the file anchor directory. The command opens it with `create: false`; it never creates a missing anchor or authority directory. It exits zero for `observed_consistent` and 2 for a lock, mismatch, unreadable state, or other `needs_reconciliation` result. A present authority or anchor lock might belong to a live worker or a crashed one. `checkpoints.comparison` distinguishes a local/external mismatch; `committed_state.comparison` distinguishes a protocol-2 or protocol-3 state mismatch while the checkpoints still agree. `committed_state.includes_authority_mode` is true only for protocol 3. Protocol 1 reports `not_committed`, not a successful state verification. Omit the anchor only to inspect an unanchored store; an anchored local checkpoint without its anchor reports `authority_anchor_not_supplied`. + +The command performs no unlock, mode initialization, checkpoint advance, grant issue, clock update, provider call, or recovery. A missing mode reports `authority_mode_missing`, including in an empty directory: a never initialized store cannot be distinguished from one whose mode was deleted. Its scan is not an atomic snapshot of live workers, and `observed_consistent` cannot establish provider delivery, an independently protected anchor or external claim backend, or permission to resume an uncertain operation. Preserve the records and reconcile the provider's operation and idempotency history before any operator-controlled recovery. `inspectFileRevocationHandoff({ directory, anchor })` exposes the same observation to a host without invoking the CLI. + Run the project-authored child-process and failure tests: ```sh -node --test test/action-boundary-file-authority-anchor.test.js +node --test test/action-boundary-file-authority-anchor.test.js \ + test/action-boundary-file-authority-inspection.test.js ``` diff --git a/pilots/action-boundary/README.md b/pilots/action-boundary/README.md index ee0464b7..1299cc92 100644 --- a/pilots/action-boundary/README.md +++ b/pilots/action-boundary/README.md @@ -77,6 +77,8 @@ The first operation on a fresh, empty authority store writes a durable `authorit **Detect a restored local snapshot:** [The opt-in checkpoint](AUTHORITY_CHECKPOINT.md) records issue, revoke, and claim transitions in a separate host-controlled anchor before their local records become usable. New protocol-3 stores bind their grant, epoch, operation, spent-claim, and mode records to that anchor. A child process with an older restored authority directory or selectively missing epoch or spent marker blocks before its simulated provider callback. Existing protocol-1 stores retain only whole-directory rollback detection; protocol-2 stores bind decision files but exclude mode. The bundled `createFileAuthorityAnchor` is a separate local file example, not an independently operated service; restoring both directories together still defeats this check. Anchored and unanchored workers cannot safely share one authority. +**Inspect a stranded authority:** The [read-only inspection command](AUTHORITY_CHECKPOINT.md#inspect-before-reconciliation) shows whether an authority lock exists, whether local and external checkpoints agree, and whether protocol-2 or protocol-3 committed state matches. It neither clears the lock nor changes any authority record. Its observations can race live workers and require provider-side reconciliation before an uncertain operation can be resolved. + The checkpoint and `epochAnchor` are alternative revocation protections and cannot be configured together. Protocol 3 detects selective changes to committed local decision files and mode before issue or dispatch if its separately retained state stays current. The epoch-aware claim checks the independently held issuer epoch at dispatch. The checkpoint can be combined with spent-only `claimOnce`. This reference is deliberately unavailable after a crashed lock. **Do not automatically remove the lock, replace the authority directory, or issue a replacement grant**: first reconcile the claimed operation against provider records, any external state, and the persisted outcome, then follow an operator-controlled recovery procedure appropriate to the host. The pinned directory identity detects replacement only during the lifetime of that instance. The spent-only `claimOnce` hook protects a spent grant or host operation while its independently held claims remain durable and the host retains its operation ID. The epoch-aware claim additionally rejects an old unspent grant after local epoch rollback; the checkpoint detects a restored older authority directory while its external state stays current. No option protects a replaced or rolled-back external store or makes the local store tamper resistant. The module does not supply a production external store, an operator recovery procedure, authenticated approvals, policy evaluation, distributed consensus, network filesystem locking, store tamper resistance against another process with the same OS identity, or protection for tools that bypass the gate. If revocation attempts find the lock busy, the host must retain the pending revocation and retry it; a busy response does not mean revocation took effect. Provider-side revocation-at-handoff and a production multi-machine claim remain unproven. diff --git a/pilots/action-boundary/file-revocation-handoff.mjs b/pilots/action-boundary/file-revocation-handoff.mjs index 48ebc6fc..28032aee 100644 --- a/pilots/action-boundary/file-revocation-handoff.mjs +++ b/pilots/action-boundary/file-revocation-handoff.mjs @@ -13,6 +13,8 @@ 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"; +const AUTHORITY_MODES = ["local", "claim-anchored", "epoch-anchored", + "checkpoint-anchored", "checkpoint-claim-anchored"]; // A private leaf is insufficient if a parent can be switched to an older // authority snapshot. /tmp-style sticky directories protect an owned child; @@ -111,13 +113,71 @@ function freezeDeep(value) { return value; } +const DECISION_COLLECTIONS = [ + ["epochs", /^[0-9a-f]{64}\.json$/], + ["grants", /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/], + ["operations", /^[0-9a-f]{64}\.json$/], + ["claimed", /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/] +]; + +function decisionState(directory, overrides = [], includeMode = false) { + const replacements = new Map(overrides.map(({ collection, name, record }) => + [`${collection}/${name}`, record])); + const entries = [], counts = {}; + for (const [collection, pattern] of DECISION_COLLECTIONS) { + const child = path.join(directory, collection); + const names = fs.readdirSync(child); + if (names.some(name => !pattern.test(name))) throw Error("unexpected_authority_state_entry"); + for (const key of replacements.keys()) { + if (key.startsWith(`${collection}/`)) names.push(key.slice(collection.length + 1)); + } + const unique = [...new Set(names)].sort(); + counts[collection] = unique.length; + for (const name of unique) { + if (!pattern.test(name)) throw Error("unexpected_authority_state_entry"); + const key = `${collection}/${name}`; + const record = replacements.has(key) ? replacements.get(key) : + readRecord(path.join(child, name)); + if (!record) throw Error("authority_state_record_unavailable"); + entries.push([key, hash(canonicalJSONStringify(record))]); + } + } + if (includeMode) { + const mode = readRecord(path.join(directory, "authority-mode.json")); + if (!mode) throw Error("authority_mode_unavailable"); + entries.push(["authority-mode.json", hash(canonicalJSONStringify(mode))]); + } + return { digest: hash(canonicalJSONStringify(entries)), counts }; +} + +function nestedDirectories(first, second) { + const relative = path.relative(first, second); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)); +} + +function checkpointRecord(value) { + const v1 = value?.protocol === "aml-file-authority-checkpoint/1"; + const v2 = value?.protocol === "aml-file-authority-checkpoint/2"; + const v3 = value?.protocol === "aml-file-authority-checkpoint/3"; + return (v1 || v2 || v3) && + ID.test(value.store_id) && Number.isSafeInteger(value.sequence) && value.sequence >= 0 && + DIGEST.test(value.head_sha256) && + (v1 ? Object.keys(value).sort().join("|") === "head_sha256|protocol|sequence|store_id" : + DIGEST.test(value.state_sha256) && + Object.keys(value).sort().join("|") === + "head_sha256|protocol|sequence|state_sha256|store_id"); +} + // One-machine example of the host's independent monotonic checkpoint store. // Keep this directory under a separate backup/retention domain. Restoring // both directories together can still replay an older authority snapshot. -export function createFileAuthorityAnchor({ directory } = {}) { +export function createFileAuthorityAnchor({ directory, create = true } = {}) { if (typeof directory !== "string" || !path.isAbsolute(directory) || - directory !== path.resolve(directory)) throw new TypeError("trusted_anchor_path_required"); - privateDirectory(directory, true); + directory !== path.resolve(directory) || typeof create !== "boolean") { + throw new TypeError("trusted_anchor_path_required"); + } + privateDirectory(directory, create); const file = path.join(directory, "checkpoint.json"); const lock = path.join(directory, "anchor.lock"); const pinned = new Map(); @@ -156,6 +216,153 @@ export function createFileAuthorityAnchor({ directory } = {}) { return { directory, read, compareAndSwap }; } +// A non-atomic, read-only operator observation. It never takes/removes the +// authority lock or changes a checkpoint; live workers may change the view. +export function inspectFileRevocationHandoff({ directory, anchor = null } = {}) { + if (typeof directory !== "string" || !path.isAbsolute(directory) || + directory !== path.resolve(directory) || + (anchor !== null && typeof anchor.read !== "function")) { + throw new TypeError("A trusted absolute directory and optional readable anchor are required"); + } + privateDirectory(directory); + const children = [directory, ...[...DECISION_COLLECTIONS.map(([name]) => name), + "outcomes"].map(name => path.join(directory, name))]; + const pinned = new Map(); + for (const child of children) { + privateDirectory(child); + verifyAuthorityPath(child, { record: pinned }); + } + const reasons = []; + const lock = path.join(directory, "authority.lock"); + const observeLock = () => { + try { fs.lstatSync(lock); privateDirectory(lock); return "present"; } + catch (error) { return error.code === "ENOENT" ? "absent" : "invalid"; } + }; + const lockStatus = observeLock(); + if (lockStatus !== "absent") reasons.push(lockStatus === "present" ? + "authority_lock_present" : "invalid_authority_lock"); + let anchorLock = "not_applicable"; + let observeAnchorLock = null; + if (typeof anchor?.directory === "string" && path.isAbsolute(anchor.directory)) { + const anchorLockFile = path.join(anchor.directory, "anchor.lock"); + observeAnchorLock = () => { + try { fs.lstatSync(anchorLockFile); privateDirectory(anchorLockFile); + return "present"; } + catch (error) { return error.code === "ENOENT" ? "absent" : "invalid"; } + }; + anchorLock = observeAnchorLock(); + if (anchorLock !== "absent") reasons.push(anchorLock === "present" ? + "external_anchor_lock_present" : "invalid_external_anchor_lock"); + } + + let local = null, localRead = null, external = null, counts = null, observedDigest = null; + let localReadable = true, externalReadable = true; + const modeFile = path.join(directory, "authority-mode.json"); + let modeRead = null, modeRecord = null, modeStatus = "missing", modeReadable = true; + if (hasUncertainTemp(directory, "authority-mode.json")) reasons.push("uncertain_authority_mode"); + try { modeRead = readRecord(modeFile); modeRecord = modeRead; } + catch { reasons.push("authority_mode_unreadable"); modeReadable = false; + modeStatus = "unreadable"; } + if (modeRecord) { + if (modeRecord.protocol !== "aml-file-authority-mode/1" || + !AUTHORITY_MODES.includes(modeRecord.mode)) { + reasons.push("authority_mode_invalid"); + modeRecord = null; + modeStatus = "invalid"; + } else modeStatus = "valid"; + } + const checkpointFile = path.join(directory, "checkpoint.json"); + if (hasUncertainTemp(directory, "checkpoint.json")) reasons.push("uncertain_authority_checkpoint"); + try { localRead = readRecord(checkpointFile); local = localRead; } + catch { reasons.push("local_checkpoint_unreadable"); localReadable = false; } + if (local && !checkpointRecord(local)) { + reasons.push("local_checkpoint_invalid"); + local = null; + } + if (anchor) { + if (anchor.directory && (nestedDirectories(directory, anchor.directory) || + nestedDirectories(anchor.directory, directory))) reasons.push("anchor_not_independent"); + try { + external = anchor.read(); + if (external?.then) throw Error("synchronous_authority_anchor_required"); + } catch { reasons.push("external_checkpoint_unavailable"); external = null; + externalReadable = false; } + if (external && !checkpointRecord(external)) { + reasons.push("external_checkpoint_invalid"); + external = null; + externalReadable = false; + } + if (!localRead && localReadable) reasons.push("local_checkpoint_missing"); + if (!external && externalReadable) reasons.push("external_checkpoint_missing"); + } else if (local) reasons.push("authority_anchor_not_supplied"); + + let comparison = anchor ? "unavailable" : "not_configured"; + if (local && external) { + comparison = canonicalJSONStringify(local) === canonicalJSONStringify(external) ? + "match" : "mismatch"; + if (comparison === "mismatch") reasons.push("checkpoint_mismatch"); + } + let committedState = local && local.protocol !== "aml-file-authority-checkpoint/1" ? + "unavailable" : "not_committed"; + try { + ({ counts, digest: observedDigest } = decisionState(directory, [], + local?.protocol === "aml-file-authority-checkpoint/3")); + if (local && local.protocol !== "aml-file-authority-checkpoint/1") { + committedState = observedDigest === local.state_sha256 ? "match" : "mismatch"; + if (committedState === "mismatch") reasons.push("committed_state_mismatch"); + } + } catch { reasons.push("committed_state_unreadable"); } + // A genuinely fresh directory and an older empty store with a deleted mode + // are indistinguishable from a read-only scan. Neither is verified state. + if (modeStatus === "missing") reasons.push("authority_mode_missing"); + if (modeRecord && ((modeRecord.mode.startsWith("checkpoint-") && !local) || + (!modeRecord.mode.startsWith("checkpoint-") && local) || + (anchor && !modeRecord.mode.startsWith("checkpoint-")))) { + reasons.push("authority_mode_checkpoint_mismatch"); + } + + // A changed checkpoint during the scan makes the snapshot unsuitable for + // reassurance, even if the first two reads happened to match. + if (localReadable) { + try { + if (canonicalJSONStringify(readRecord(checkpointFile)) !== canonicalJSONStringify(localRead)) { + reasons.push("local_checkpoint_changed_during_inspection"); + } + } catch { reasons.push("local_checkpoint_changed_during_inspection"); } + } + if (anchor && externalReadable) { + try { + const again = anchor.read(); + if (again?.then || canonicalJSONStringify(again) !== canonicalJSONStringify(external)) { + reasons.push("external_checkpoint_changed_during_inspection"); + } + } catch { reasons.push("external_checkpoint_changed_during_inspection"); } + } + if (modeReadable) { + try { + if (canonicalJSONStringify(readRecord(modeFile)) !== canonicalJSONStringify(modeRead)) { + reasons.push("authority_mode_changed_during_inspection"); + } + } catch { reasons.push("authority_mode_changed_during_inspection"); } + } + try { for (const child of children) privateDirectory(child, false, pinned); } + catch { reasons.push("authority_path_changed_during_inspection"); } + if (observeLock() !== lockStatus) reasons.push("authority_lock_changed_during_inspection"); + if (observeAnchorLock && observeAnchorLock() !== anchorLock) { + reasons.push("external_anchor_lock_changed_during_inspection"); + } + return { + protocol: "aml-file-authority-inspection/1", + status: reasons.length ? "needs_reconciliation" : "observed_consistent", + reasons, lock: lockStatus, anchor_lock: anchorLock, mode: local?.protocol ?? + (localRead || !localReadable || anchor ? "unknown" : "unanchored"), + authority_mode: { status: modeStatus, record: modeRecord }, + checkpoints: { local, external, comparison }, + committed_state: { comparison: committedState, observed_sha256: observedDigest, + includes_authority_mode: local?.protocol === "aml-file-authority-checkpoint/3", counts } + }; +} + export function createFileRevocationHandoff({ directory, now = () => Date.now(), anchor, claimOnce = null, epochAnchor = null } = {}) { if (typeof directory !== "string" || !path.isAbsolute(directory) || @@ -169,9 +376,8 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), anchor !== undefined))) { throw new TypeError("A trusted directory, clock, and optional atomic host anchor are required; incompatible anchor options"); } - if (anchor?.directory && (anchor.directory === directory || - !path.relative(directory, anchor.directory).startsWith("..") && - !path.isAbsolute(path.relative(directory, anchor.directory)))) { + if (anchor?.directory && (nestedDirectories(directory, anchor.directory) || + nestedDirectories(anchor.directory, directory))) { throw new TypeError("independent_anchor_directory_required"); } const externalClaim = epochAnchor ? epochAnchor.claimOnce.bind(epochAnchor) : claimOnce; @@ -196,42 +402,10 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), verifyAuthorityPath(child, { record: pinned }); } - // Checkpoints 2 and 3 commit to durable authority decisions; version 3 - // additionally binds the configured mode. Clock and outcomes have a - // separate lifecycle and are outside this commitment. - const stateCollections = [ - ["epochs", epochs, /^[0-9a-f]{64}\.json$/], - ["grants", grants, /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/], - ["operations", operations, /^[0-9a-f]{64}\.json$/], - ["claimed", claimed, /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/] - ]; - - function stateDigest(overrides = [], includeMode = false) { - const replacements = new Map(overrides.map(({ collection, name, record }) => - [`${collection}/${name}`, record])); - const entries = []; - for (const [collection, child, pattern] of stateCollections) { - const names = fs.readdirSync(child); - if (names.some(name => !pattern.test(name))) throw Error("unexpected_authority_state_entry"); - for (const key of replacements.keys()) { - if (key.startsWith(`${collection}/`)) names.push(key.slice(collection.length + 1)); - } - for (const name of [...new Set(names)].sort()) { - if (!pattern.test(name)) throw Error("unexpected_authority_state_entry"); - const key = `${collection}/${name}`; - const record = replacements.has(key) ? replacements.get(key) : - readRecord(path.join(child, name)); - if (!record) throw Error("authority_state_record_unavailable"); - entries.push([key, hash(canonicalJSONStringify(record))]); - } - } - if (includeMode) { - const mode = readRecord(modeFile); - if (!mode) throw Error("authority_mode_unavailable"); - entries.push(["authority-mode.json", hash(canonicalJSONStringify(mode))]); - } - return hash(canonicalJSONStringify(entries)); - } + // Checkpoints 2 and 3 commit to durable decision files; version 3 binds + // the mode too. The host clock and outcomes have a separate lifecycle. + const stateDigest = (overrides = [], includeMode = false) => + decisionState(directory, overrides, includeMode).digest; function checkDirectories() { for (const child of [directory, epochs, grants, operations, claimed, outcomes]) { @@ -253,27 +427,13 @@ export function createFileRevocationHandoff({ directory, now = () => Date.now(), 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)) { + !AUTHORITY_MODES.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"; - const v3 = value?.protocol === "aml-file-authority-checkpoint/3"; - return (v1 || v2 || v3) && - ID.test(value.store_id) && Number.isSafeInteger(value.sequence) && value.sequence >= 0 && - DIGEST.test(value.head_sha256) && - (v1 ? Object.keys(value).sort().join("|") === "head_sha256|protocol|sequence|store_id" : - DIGEST.test(value.state_sha256) && - Object.keys(value).sort().join("|") === - "head_sha256|protocol|sequence|state_sha256|store_id"); - } - function externalState() { const value = anchor.read(); if (value?.then) throw Error("synchronous_authority_anchor_required"); diff --git a/pilots/action-boundary/inspect-file-authority.mjs b/pilots/action-boundary/inspect-file-authority.mjs new file mode 100644 index 00000000..3adc3b6d --- /dev/null +++ b/pilots/action-boundary/inspect-file-authority.mjs @@ -0,0 +1,22 @@ +// Read-only operator snapshot. Never clears a lock or edits authority state. +import { createFileAuthorityAnchor, inspectFileRevocationHandoff } from + "./file-revocation-handoff.mjs"; + +const [directory, anchorDirectory, ...extra] = process.argv.slice(2); +if (!directory || extra.length) { + process.stderr.write("Usage: node inspect-file-authority.mjs ABS_AUTHORITY_DIR [ABS_ANCHOR_DIR]\n"); + process.exitCode = 2; +} else { + try { + const anchor = anchorDirectory ? createFileAuthorityAnchor({ + directory: anchorDirectory, create: false + }) : null; + const report = inspectFileRevocationHandoff({ directory, anchor }); + process.stdout.write(JSON.stringify(report, null, 2) + "\n"); + if (report.status !== "observed_consistent") process.exitCode = 2; + } catch (error) { + process.stdout.write(JSON.stringify({ protocol: "aml-file-authority-inspection/1", + status: "inspection_unavailable", reason: error.message }) + "\n"); + process.exitCode = 2; + } +} diff --git a/test/action-boundary-file-authority-inspection.test.js b/test/action-boundary-file-authority-inspection.test.js new file mode 100644 index 00000000..cb3a57a5 --- /dev/null +++ b/test/action-boundary-file-authority-inspection.test.js @@ -0,0 +1,201 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { createFileAuthorityAnchor, createFileRevocationHandoff, + inspectFileRevocationHandoff } from "../pilots/action-boundary/file-revocation-handoff.mjs"; + +const exec = promisify(execFile); +const cli = new URL("../pilots/action-boundary/inspect-file-authority.mjs", import.meta.url).pathname; +const input = () => ({ tenant: "tenant-a", issuer: "host-a", actor: "reviewer-a", + audience: "mail-service", tool: "send_message", policy: { revision: 1 }, + preparedRequest: { to: "team@example.test", body: "Approved" } }); + +async function fixture(run) { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "aml-inspection-")); + const directory = path.join(parent, "authority"); + const anchorDirectory = path.join(parent, "outside-anchor"); + try { + const anchor = createFileAuthorityAnchor({ directory: anchorDirectory }); + const host = createFileRevocationHandoff({ directory, anchor }); + return await run({ parent, directory, anchorDirectory, anchor, host }); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } +} + +function snapshot(root) { + const entries = []; + function walk(directory) { + for (const name of fs.readdirSync(directory).sort()) { + const file = path.join(directory, name); + const stat = fs.lstatSync(file); + entries.push([path.relative(root, file), stat.mode, stat.size, stat.mtimeMs, + stat.isFile() ? fs.readFileSync(file, "utf8") : null]); + if (stat.isDirectory()) walk(file); + } + } + walk(root); + return entries; +} + +test("inspection and its CLI report consistent protocol-3 state without writes", () => + fixture(async ({ parent, directory, anchorDirectory, anchor, host }) => { + const grant = host.issue(input(), { operationId: "host-intent-inspect-001" }); + assert.equal((await host.dispatch({ ...input(), grant_id: grant.grant_id, + execute: () => true })).status, "returned"); + assert.equal(host.revokeIssuer(input()).epoch, 1); + const before = snapshot(parent); + const report = inspectFileRevocationHandoff({ directory, anchor }); + const command = JSON.parse((await exec(process.execPath, + [cli, directory, anchorDirectory])).stdout); + assert.deepEqual([report.status, report.lock, report.checkpoints.comparison, + report.committed_state.comparison], ["observed_consistent", "absent", "match", "match"]); + assert.equal(report.anchor_lock, "absent"); + assert.equal(report.committed_state.includes_authority_mode, true); + assert.deepEqual(report.authority_mode, { status: "valid", record: { + protocol: "aml-file-authority-mode/1", mode: "checkpoint-anchored" + } }); + assert.deepEqual(report.committed_state.counts, + { epochs: 1, grants: 1, operations: 1, claimed: 1 }); + assert.deepEqual(command, report); + assert.deepEqual(snapshot(parent), before); + })); + +test("a file anchor lock is visible even when both checkpoint records agree", () => + fixture(async ({ anchorDirectory, directory, anchor, host }) => { + host.issue(input()); + fs.mkdirSync(path.join(anchorDirectory, "anchor.lock"), { mode: 0o700 }); + const report = inspectFileRevocationHandoff({ directory, anchor }); + assert.deepEqual([report.status, report.lock, report.anchor_lock, + report.checkpoints.comparison], + ["needs_reconciliation", "absent", "present", "match"]); + assert.ok(report.reasons.includes("external_anchor_lock_present")); + assert.equal(fs.existsSync(path.join(anchorDirectory, "anchor.lock")), true); + })); + +test("a missing pinned authority mode is reported without recreating it", () => + fixture(async ({ directory, anchor, host }) => { + host.issue(input()); + const modeFile = path.join(directory, "authority-mode.json"); + fs.rmSync(modeFile); + const report = inspectFileRevocationHandoff({ directory, anchor }); + assert.equal(report.status, "needs_reconciliation"); + assert.equal(report.authority_mode.status, "missing"); + assert.ok(report.reasons.includes("authority_mode_missing")); + assert.equal(fs.existsSync(modeFile), false); + })); + +test("a valid substituted mode disagrees with protocol-3 committed state", () => + fixture(async ({ directory, anchor, host }) => { + host.issue(input()); + const modeFile = path.join(directory, "authority-mode.json"); + fs.writeFileSync(modeFile, JSON.stringify({ protocol: "aml-file-authority-mode/1", + mode: "checkpoint-claim-anchored" }) + "\n"); + const report = inspectFileRevocationHandoff({ directory, anchor }); + assert.equal(report.authority_mode.status, "valid"); + assert.equal(report.checkpoints.comparison, "match"); + assert.equal(report.committed_state.comparison, "mismatch"); + assert.ok(report.reasons.includes("committed_state_mismatch")); + })); + +test("an empty local store with no mode is not called verified", () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "aml-inspection-empty-")); + try { + const directory = path.join(parent, "authority"); + const host = createFileRevocationHandoff({ directory }); + const before = snapshot(parent); + const report = inspectFileRevocationHandoff({ directory }); + assert.equal(report.status, "needs_reconciliation"); + assert.deepEqual(report.reasons, ["authority_mode_missing"]); + assert.deepEqual(snapshot(parent), before); + host.observeIssuer({ tenant: "tenant-a", issuer: "host-a" }); + assert.equal(inspectFileRevocationHandoff({ directory }).status, "observed_consistent"); + } finally { fs.rmSync(parent, { recursive: true, force: true }); } +}); + +test("a present lock is reported without clearing it or authorizing another action", () => + fixture(async ({ parent, directory, anchorDirectory, anchor, host }) => { + host.issue(input()); + fs.mkdirSync(path.join(directory, "authority.lock"), { mode: 0o700 }); + const before = snapshot(parent); + const report = inspectFileRevocationHandoff({ directory, anchor }); + assert.deepEqual([report.status, report.lock, report.reasons], + ["needs_reconciliation", "present", ["authority_lock_present"]]); + await assert.rejects(() => exec(process.execPath, [cli, directory, anchorDirectory]), + error => error.code === 2 && + JSON.parse(error.stdout).reasons.includes("authority_lock_present")); + assert.deepEqual(snapshot(parent), before); + assert.throws(() => host.issue(input()), /authority_busy_or_crashed/); + })); + +test("selective epoch loss and an advanced outside checkpoint are distinguished", () => + fixture(async ({ directory, anchor, host }) => { + host.issue(input()); + host.revokeIssuer(input()); + const epochFile = path.join(directory, "epochs", fs.readdirSync(path.join(directory, + "epochs"))[0]); + fs.rmSync(epochFile); + const first = inspectFileRevocationHandoff({ directory, anchor }); + assert.deepEqual([first.checkpoints.comparison, first.committed_state.comparison], + ["match", "mismatch"]); + assert.ok(first.reasons.includes("committed_state_mismatch")); + const old = anchor.read(); + assert.equal(anchor.compareAndSwap(old, { ...old, sequence: old.sequence + 1, + head_sha256: "b".repeat(64) }), true); + const second = inspectFileRevocationHandoff({ directory, anchor }); + assert.deepEqual([second.checkpoints.comparison, second.committed_state.comparison], + ["mismatch", "mismatch"]); + assert.ok(second.reasons.includes("checkpoint_mismatch")); + })); + +test("a malformed local checkpoint is identified without being called a missing one", () => + fixture(async ({ directory, anchor, host }) => { + host.issue(input()); + fs.writeFileSync(path.join(directory, "checkpoint.json"), "{}\n"); + const report = inspectFileRevocationHandoff({ directory, anchor }); + assert.equal(report.status, "needs_reconciliation"); + assert.equal(report.mode, "unknown"); + assert.ok(report.reasons.includes("local_checkpoint_invalid")); + assert.equal(report.reasons.includes("local_checkpoint_missing"), false); + assert.equal(report.reasons.includes("local_checkpoint_changed_during_inspection"), false); + })); + +test("a malformed outside checkpoint is reported distinctly from an unavailable one", () => + fixture(async ({ anchorDirectory, directory, anchor, host }) => { + host.issue(input()); + fs.writeFileSync(path.join(anchorDirectory, "checkpoint.json"), "{}\n"); + const report = inspectFileRevocationHandoff({ directory, anchor }); + assert.ok(report.reasons.includes("external_checkpoint_invalid")); + assert.equal(report.reasons.includes("external_checkpoint_unavailable"), false); + assert.equal(report.reasons.includes("external_checkpoint_missing"), false); + })); + +test("a directory moved during inspection cannot be reported consistent", () => + fixture(async ({ parent, directory, anchor, host }) => { + host.issue(input()); + let reads = 0; + const movingAnchor = { read() { + if (++reads === 2) fs.renameSync(directory, path.join(parent, "moved-authority")); + return anchor.read(); + } }; + const report = inspectFileRevocationHandoff({ directory, anchor: movingAnchor }); + assert.equal(report.status, "needs_reconciliation"); + assert.ok(report.reasons.includes("authority_path_changed_during_inspection")); + })); + +test("inspection refuses unsafe paths and never creates a missing anchor", () => + fixture(async ({ parent, directory, anchor }) => { + const missing = path.join(parent, "missing-anchor"); + assert.throws(() => createFileAuthorityAnchor({ directory: missing, create: false }), + /ENOENT/); + assert.equal(fs.existsSync(missing), false); + const alias = path.join(parent, "authority-alias"); + fs.symlinkSync(directory, alias); + assert.throws(() => inspectFileRevocationHandoff({ directory: alias, anchor }), + /trusted_authority_path_required/); + assert.throws(() => createFileRevocationHandoff({ directory, + anchor: createFileAuthorityAnchor({ directory: parent, create: false }) }), + /independent_anchor_directory_required/); + }));