diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bd448c..bc7c962 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -525,6 +525,49 @@ jobs: echo "genuinely changed, that is a claim change and belongs on HAC-333." } >> "$GITHUB_STEP_SUMMARY" + filmed-run-gate: + name: Filmed run record gate + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.19.0' + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + # HAC-324's filmed-run record is *derived* from the packet the traversal + # emitted, which sits committed beside it. A derivation that quietly + # changed an execution fact would therefore look corroborated rather than + # contradicted, so the gate rebuilds the record and proves every execution + # fact is byte-identical to the emitted bytes. + - name: Verify the filmed-run record + run: pnpm run check:filmed-run + + - name: Every gate is proven to fail on the defect it targets + run: pnpm vitest run test/hac-324-filmed-run-gates.test.mjs + + - name: Explain the failure + if: failure() + run: | + { + echo "## Filmed run record gate failed" + echo + echo "**Invariant.** \`experiments/hac-324/evidence/filmed-run.json\` is" + echo "\`filmed-run.raw.json\` plus exactly one declared correction: the" + echo "observer principal, which the HAC-340 provisioning script recorded as" + echo "the provisioning caller rather than the principal that performed the" + echo "read-back. Every execution fact must be byte-identical between the two," + echo "the capture package must name the same run, and every frame must still" + echo "match its recorded digest." + echo + echo "Rebuild with \`node experiments/hac-324/bin/build-filmed-run.mjs\`." + echo "Do not hand-edit either file: the raw packet is the emitted evidence and" + echo "the derived record is generated from it." + } >> "$GITHUB_STEP_SUMMARY" + evaluation-gate: name: Evaluation gate runs-on: ubuntu-24.04 diff --git a/experiments/hac-324/bin/build-filmed-run.mjs b/experiments/hac-324/bin/build-filmed-run.mjs new file mode 100644 index 0000000..6fb68b5 --- /dev/null +++ b/experiments/hac-324/bin/build-filmed-run.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * HAC-324 — derive the filmed-run record from the packet the traversal emitted. + * + * The traversal's own packet carries one field that is not true of the run it + * describes. `10-provision.sh` records `observerPrincipal` from + * `gcloud config get-value account` at *provision* time, so it names the human + * operator. The authoritative traversal did not authenticate as that human: it + * impersonated a dedicated keyless observer service account, because gcloud 580 + * refuses audience-scoped identity tokens for user accounts. + * + * Two ways to fix that were available and both were wrong. Editing the emitted + * packet in place destroys the only verbatim record of what the run produced. + * Leaving it and explaining the discrepancy in prose elsewhere leaves two + * artifacts disagreeing about who performed OBSERVED, and the wrong one is the + * one that looks authoritative. + * + * So the emitted bytes stay untouched in `filmed-run.raw.json`, and this derives + * `filmed-run.json` from them, splitting one overloaded field into the two + * distinct facts it was conflating: + * + * operatorPrincipal — who provisioned the environment + * observerPrincipal — who performed the independently authenticated read-back + * + * The producing layer for HAC-324's evidence is this script. The HAC-340 script + * that emitted the raw packet is the approved runtime source at + * `ae6d0d3c405b6169d5f0495c22aaf05d8fc1de4a` and is deliberately not modified — + * changing it would change the runtime source SHA and break the parity claim + * the run exists to make. + * + * node experiments/hac-324/bin/build-filmed-run.mjs + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const evidence = join(here, '..', 'evidence'); + +/** + * The observer identity the traversal actually used. + * + * Recorded here rather than inferred: the Cloud Run services are deleted, so + * nothing can be re-queried to establish it, and a value that cannot be + * re-derived should be stated explicitly rather than reconstructed. + */ +const OBSERVER_PRINCIPAL = + 'serviceAccount:interlock-hac340-observer@interlock-film-260823.iam.gserviceaccount.com'; + +/** The one field this derivation is allowed to change, and what it becomes. */ +export const PRINCIPAL_CORRECTION = { + field: 'resources.observerPrincipal', + rawValue: 'user:qwynn@marcellelabs.io', + reclassifiedAs: 'resources.operatorPrincipal', + correctedValue: OBSERVER_PRINCIPAL, + reason: + '10-provision.sh records the provisioning caller, not the principal that performed the read-back. ' + + 'The traversal impersonated a dedicated keyless observer service account because gcloud 580 refuses ' + + 'audience-scoped identity tokens for user accounts. Adjudicated NON_MATERIAL: the substitution changed ' + + 'no authorization behaviour, and the fail-closed controls returned an identical 403/401/403.', + classification: 'NON_MATERIAL', +}; + +/** + * Everything about the run that this derivation must leave alone. + * + * Named rather than implied, so the verifier can prove the derivation touched + * no execution fact instead of asserting it. + */ +export const EXECUTION_FACTS = [ + 'commitSha', + 'model', + 'adkPath', + 'correlationId', + 'decision', + 'receiptId', + 'receiptDigest', + 'protectedMutation', + 'observation', + 'runtimeProof', + 'controls', + 'expectedConfiguration', + 'observedConfiguration', +]; + +export function deriveFilmedRun(raw) { + const derived = structuredClone(raw); + const resources = derived.resources; + + // Split the conflated field. The provisioning caller keeps its own name so + // the fact is preserved rather than overwritten. + resources.operatorPrincipal = raw.resources.observerPrincipal; + resources.observerPrincipal = OBSERVER_PRINCIPAL; + + derived.principalProjection = { + note: + 'observerPrincipal in the emitted packet named the provisioning caller. It is corrected here and the ' + + 'original value is preserved as operatorPrincipal. filmed-run.raw.json holds the emitted bytes unchanged.', + correction: PRINCIPAL_CORRECTION, + rawPacket: 'experiments/hac-324/evidence/filmed-run.raw.json', + producer: 'experiments/hac-324/bin/build-filmed-run.mjs', + }; + + return derived; +} + +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + const raw = JSON.parse(readFileSync(join(evidence, 'filmed-run.raw.json'), 'utf8')); + const derived = deriveFilmedRun(raw); + writeFileSync(join(evidence, 'filmed-run.json'), `${JSON.stringify(derived, null, 2)}\n`); + process.stdout.write( + 'HAC-324 filmed-run record derived\n' + + ` operatorPrincipal ${derived.resources.operatorPrincipal}\n` + + ` observerPrincipal ${derived.resources.observerPrincipal}\n` + + ` execution facts carried unchanged: ${EXECUTION_FACTS.length}\n`, + ); +} diff --git a/experiments/hac-324/bin/verify-filmed-run.mjs b/experiments/hac-324/bin/verify-filmed-run.mjs new file mode 100644 index 0000000..02686f2 --- /dev/null +++ b/experiments/hac-324/bin/verify-filmed-run.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * HAC-324 — refuses a filmed-run record that is not the emitted packet plus the + * one declared correction. + * + * A derivation that can silently change an execution fact is worse than no + * derivation: the raw packet would still be sitting beside it looking like + * corroboration. So this proves three things rather than asserting them. + * + * 1. `filmed-run.json` is exactly what the producer rebuilds from the raw + * packet — no hand edit survives. + * 2. Every execution fact is byte-identical between raw and derived. The + * correction touched the principal projection and nothing else. + * 3. The capture package agrees with the derived record about which run it + * is, and its frames are still the frames it claims. + * + * node experiments/hac-324/bin/verify-filmed-run.mjs + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { deriveFilmedRun, EXECUTION_FACTS, PRINCIPAL_CORRECTION } from './build-filmed-run.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, '..', '..', '..'); +const evidence = join(here, '..', 'evidence'); + +const errors = []; +const fail = (message) => errors.push(message); +const readJson = (p) => JSON.parse(readFileSync(p, 'utf8')); + +const raw = readJson(join(evidence, 'filmed-run.raw.json')); +const derived = readJson(join(evidence, 'filmed-run.json')); +const pkg = readJson(join(evidence, 'capture-package.json')); +const teardown = readJson(join(evidence, 'teardown.json')); + +/* -- 1. the record is derived, not hand-edited ----------------------------- */ + +const rebuilt = deriveFilmedRun(raw); +if (JSON.stringify(rebuilt) !== JSON.stringify(derived)) { + fail('filmed-run.json is not what build-filmed-run.mjs produces from the raw packet'); +} + +/* -- 2. the correction changed the projection and nothing else ------------- */ + +for (const field of EXECUTION_FACTS) { + if (JSON.stringify(raw[field]) !== JSON.stringify(derived[field])) { + fail(`execution fact "${field}" differs between the emitted packet and the derived record`); + } +} + +// The resources block is the only one that may differ, and only in the two +// principal keys. Anything else moving there would be a silent rewrite of the +// environment the run happened in. +const rawResourceKeys = Object.keys(raw.resources); +for (const key of rawResourceKeys) { + if (key === 'observerPrincipal') continue; + if (JSON.stringify(raw.resources[key]) !== JSON.stringify(derived.resources[key])) { + fail(`resources.${key} differs between the emitted packet and the derived record`); + } +} +if (derived.resources.operatorPrincipal !== raw.resources.observerPrincipal) { + fail('operatorPrincipal does not preserve the value the packet actually emitted'); +} +if (derived.resources.observerPrincipal === raw.resources.observerPrincipal) { + fail('observerPrincipal was not corrected; the record still names the provisioning caller'); +} +if (!derived.resources.observerPrincipal.startsWith('serviceAccount:')) { + fail('the corrected observerPrincipal is not a service account'); +} +if (derived.principalProjection?.correction?.classification !== PRINCIPAL_CORRECTION.classification) { + fail('the derived record does not carry the adjudicated classification for the correction'); +} + +/* -- 3. the capture package still describes this run, and these frames ----- */ + +if (pkg.filmedRunId !== derived.correlationId) { + fail(`capture package names run ${pkg.filmedRunId}, the record names ${derived.correlationId}`); +} +if (pkg.receiptId !== derived.receiptId) fail('capture package receipt id disagrees with the record'); +if (pkg.runtimeSourceSha !== derived.commitSha) fail('capture package runtime source SHA disagrees with the record'); +if (pkg.externalCallerPrincipal !== derived.resources.observerPrincipal) { + fail('capture package and record disagree about who performed the observation'); +} + +for (const frame of pkg.frames) { + const path = join(root, 'experiments', 'hac-324', 'frames', `scene-${frame.sceneId}.png`); + if (!existsSync(path)) { + fail(`frame for scene ${frame.sceneId} is missing on disk`); + continue; + } + const digest = createHash('sha256').update(readFileSync(path)).digest('hex'); + if (digest !== frame.sha256) fail(`frame ${frame.sceneId} does not match its recorded sha256`); + if (!frame.qualityPass) fail(`frame ${frame.sceneId} is recorded as failing the capture-quality floor`); +} + +if (teardown.status !== 'completed') fail('teardown evidence is not complete'); + +if (errors.length) { + process.stderr.write(`HAC-324 filmed-run record violated:\n${errors.map((e) => ` - ${e}`).join('\n')}\n`); + process.exit(1); +} + +process.stdout.write( + 'HAC-324 filmed-run record verified\n' + + ` run ${derived.correlationId}, receipt ${derived.receiptId}\n` + + ` ${EXECUTION_FACTS.length} execution facts byte-identical to the emitted packet\n` + + ` observer ${derived.resources.observerPrincipal}\n` + + ` operator ${derived.resources.operatorPrincipal}\n` + + ` ${pkg.frames.length} frames match their digests, all quality-PASS; teardown ${teardown.status}\n`, +); diff --git a/experiments/hac-324/evidence/capture-package.json b/experiments/hac-324/evidence/capture-package.json index 392c84b..8c4b9c5 100644 --- a/experiments/hac-324/evidence/capture-package.json +++ b/experiments/hac-324/evidence/capture-package.json @@ -80,10 +80,10 @@ "allFramesPassQuality": true, "knownDiscrepancies": [ { - "field": "filmed-run.json \u2192 resources.observerPrincipal", + "field": "filmed-run.raw.json \u2192 resources.observerPrincipal", "recorded": "user:qwynn@marcellelabs.io", "actual": "serviceAccount:interlock-hac340-observer@interlock-film-260823.iam.gserviceaccount.com", - "why": "10-provision.sh records the caller from `gcloud config get-value account` at provision time. The traversal authenticated by impersonating the dedicated keyless observer SA, because gcloud 580 refuses audience-scoped ID tokens for user accounts. The packet field was not edited \u2014 evidence is recorded as emitted, and the correction lives here.", + "why": "Corrected at the producing layer. The emitted packet is preserved verbatim as filmed-run.raw.json; experiments/hac-324/bin/build-filmed-run.mjs derives filmed-run.json, splitting the conflated field into operatorPrincipal (who provisioned) and observerPrincipal (who observed). experiments/hac-324/bin/verify-filmed-run.mjs proves every execution fact is byte-identical between the two.", "classification": "NON_MATERIAL \u2014 adjudicated principal-type substitution; controls returned identical 403/401/403" }, { @@ -99,5 +99,6 @@ "deletedAt": "2026-08-24T01:50:43.559Z", "projectLifecycleState": "DELETE_REQUESTED" }, - "packetVerification": "HAC-340 packet verified \u2014 all 19 assertions including teardown" + "packetVerification": "HAC-340 packet verified \u2014 all 19 assertions including teardown", + "operatorPrincipal": "user:qwynn@marcellelabs.io" } diff --git a/experiments/hac-324/evidence/filmed-run.json b/experiments/hac-324/evidence/filmed-run.json index 42b002d..fdd89b1 100644 --- a/experiments/hac-324/evidence/filmed-run.json +++ b/experiments/hac-324/evidence/filmed-run.json @@ -12,9 +12,10 @@ "agentServiceAccount": "serviceAccount:interlock-hac340-agent@interlock-film-260823.iam.gserviceaccount.com", "proxyServiceAccount": "serviceAccount:interlock-hac340-proxy@interlock-film-260823.iam.gserviceaccount.com", "targetServiceAccount": "serviceAccount:interlock-hac340-target@interlock-film-260823.iam.gserviceaccount.com", - "observerPrincipal": "user:qwynn@marcellelabs.io", + "observerPrincipal": "serviceAccount:interlock-hac340-observer@interlock-film-260823.iam.gserviceaccount.com", "nodeImage": "us-central1-docker.pkg.dev/interlock-film-260823/interlock-hac340/interlock-node:ae6d0d3", - "agentImage": "us-central1-docker.pkg.dev/interlock-film-260823/interlock-hac340/interlock-adk:ae6d0d3" + "agentImage": "us-central1-docker.pkg.dev/interlock-film-260823/interlock-hac340/interlock-adk:ae6d0d3", + "operatorPrincipal": "user:qwynn@marcellelabs.io" }, "correlationId": "ilk-hac340-cloud-1787536029323", "decision": "ALLOW", @@ -107,5 +108,18 @@ "wrongAudienceStatus": 401, "directBypassStatus": 403 }, - "teardown": "pending" + "teardown": "pending", + "principalProjection": { + "note": "observerPrincipal in the emitted packet named the provisioning caller. It is corrected here and the original value is preserved as operatorPrincipal. filmed-run.raw.json holds the emitted bytes unchanged.", + "correction": { + "field": "resources.observerPrincipal", + "rawValue": "user:qwynn@marcellelabs.io", + "reclassifiedAs": "resources.operatorPrincipal", + "correctedValue": "serviceAccount:interlock-hac340-observer@interlock-film-260823.iam.gserviceaccount.com", + "reason": "10-provision.sh records the provisioning caller, not the principal that performed the read-back. The traversal impersonated a dedicated keyless observer service account because gcloud 580 refuses audience-scoped identity tokens for user accounts. Adjudicated NON_MATERIAL: the substitution changed no authorization behaviour, and the fail-closed controls returned an identical 403/401/403.", + "classification": "NON_MATERIAL" + }, + "rawPacket": "experiments/hac-324/evidence/filmed-run.raw.json", + "producer": "experiments/hac-324/bin/build-filmed-run.mjs" + } } diff --git a/experiments/hac-324/evidence/filmed-run.raw.json b/experiments/hac-324/evidence/filmed-run.raw.json new file mode 100644 index 0000000..42b002d --- /dev/null +++ b/experiments/hac-324/evidence/filmed-run.raw.json @@ -0,0 +1,111 @@ +{ + "commitSha": "ae6d0d3c405b6169d5f0495c22aaf05d8fc1de4a", + "model": "gemini-3.5-flash", + "adkPath": "Google ADK 1.35.1 / Vertex AI", + "resources": { + "projectId": "interlock-film-260823", + "region": "us-central1", + "vertexLocation": "global", + "agentUrl": "https://interlock-hac340-agent-butemkskqa-uc.a.run.app", + "proxyUrl": "https://interlock-hac340-proxy-butemkskqa-uc.a.run.app", + "targetUrl": "https://interlock-hac340-target-butemkskqa-uc.a.run.app", + "agentServiceAccount": "serviceAccount:interlock-hac340-agent@interlock-film-260823.iam.gserviceaccount.com", + "proxyServiceAccount": "serviceAccount:interlock-hac340-proxy@interlock-film-260823.iam.gserviceaccount.com", + "targetServiceAccount": "serviceAccount:interlock-hac340-target@interlock-film-260823.iam.gserviceaccount.com", + "observerPrincipal": "user:qwynn@marcellelabs.io", + "nodeImage": "us-central1-docker.pkg.dev/interlock-film-260823/interlock-hac340/interlock-node:ae6d0d3", + "agentImage": "us-central1-docker.pkg.dev/interlock-film-260823/interlock-hac340/interlock-adk:ae6d0d3" + }, + "correlationId": "ilk-hac340-cloud-1787536029323", + "decision": "ALLOW", + "receiptId": "rcpt-e742d4f3-85d4-46b8-a1e0-320fa429358d", + "receiptDigest": "sha256:7fb65efe30d89bab241d1f5b8ea00ca58fb732a30b9fb87288a09deab411743f", + "protectedMutation": { + "status": "EXECUTED", + "correlationId": "ilk-hac340-cloud-1787536029323", + "receiptId": "rcpt-e742d4f3-85d4-46b8-a1e0-320fa429358d", + "revisionBefore": "sha256:c78fe996446426339788c38a6a6edaf6b1b8ab72f984c9c72786637148615a10", + "revisionAfter": "sha256:6a8acad8a0f7f4df96548ba9b60714b4a7e350cc480c1ef86f4d677db62b8a27", + "state": { + "totalReservable": 130, + "services": { + "alpha": 45, + "beta": 40, + "gamma": 20 + } + }, + "invariant": { + "holds": true, + "total": 105, + "totalReservable": 130, + "detail": "total 105 <= 130" + } + }, + "observation": { + "revision": "sha256:6a8acad8a0f7f4df96548ba9b60714b4a7e350cc480c1ef86f4d677db62b8a27", + "state": { + "totalReservable": 130, + "services": { + "alpha": 45, + "beta": 40, + "gamma": 20 + } + } + }, + "runtimeProof": { + "proxyLogEntries": [ + { + "insertId": "6a8ba2ad00064c4b17d4ba8b", + "jsonPayload": { + "correlationId": "ilk-hac340-cloud-1787536029323", + "event": "proxy.request", + "identity": "interlock-hac340-agent@interlock-film-260823.iam.gserviceaccount.com", + "identitySource": "oidc-id-token/platform-verified:email", + "transport": "mcp" + }, + "labels": { + "instanceId": "00a41e8c1d49b36ee26b08d3534054faba426aac33b91e8d6fdc8c140e52b9ca403bbbf58028049b7d91b7fe35c99b3b2d087f30ed862da58ebc8ceebd6d8f9317525208aa4efa04e8d8fa1162dc" + }, + "logName": "projects/interlock-film-260823/logs/run.googleapis.com%2Fstdout", + "receiveTimestamp": "2026-08-24T01:47:25.708547374Z", + "resource": { + "labels": { + "configuration_name": "interlock-hac340-proxy", + "location": "us-central1", + "project_id": "interlock-film-260823", + "revision_name": "interlock-hac340-proxy-00001-s76", + "service_name": "interlock-hac340-proxy" + }, + "type": "cloud_run_revision" + }, + "timestamp": "2026-08-24T01:47:25.412747Z" + } + ], + "agentHttpStatus": 200 + }, + "expectedConfiguration": { + "projectId": "interlock-film-260823", + "region": "us-central1", + "vertexLocation": "global", + "agentUrl": "https://interlock-hac340-agent-butemkskqa-uc.a.run.app", + "proxyUrl": "https://interlock-hac340-proxy-butemkskqa-uc.a.run.app", + "targetUrl": "https://interlock-hac340-target-butemkskqa-uc.a.run.app", + "agentServiceAccount": "serviceAccount:interlock-hac340-agent@interlock-film-260823.iam.gserviceaccount.com", + "proxyServiceAccount": "serviceAccount:interlock-hac340-proxy@interlock-film-260823.iam.gserviceaccount.com", + "targetServiceAccount": "serviceAccount:interlock-hac340-target@interlock-film-260823.iam.gserviceaccount.com", + "observerPrincipal": "user:qwynn@marcellelabs.io", + "nodeImage": "us-central1-docker.pkg.dev/interlock-film-260823/interlock-hac340/interlock-node:ae6d0d3", + "agentImage": "us-central1-docker.pkg.dev/interlock-film-260823/interlock-hac340/interlock-adk:ae6d0d3" + }, + "observedConfiguration": { + "agentRevision": "https://interlock-hac340-agent-butemkskqa-uc.a.run.app", + "proxyRevision": "https://interlock-hac340-proxy-butemkskqa-uc.a.run.app", + "targetRevision": "https://interlock-hac340-target-butemkskqa-uc.a.run.app" + }, + "controls": { + "forgedHeaderStatus": 403, + "wrongAudienceStatus": 401, + "directBypassStatus": 403 + }, + "teardown": "pending" +} diff --git a/package.json b/package.json index 705113e..1bd53a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@marcelle-labs/interlock", "version": "0.0.0", "private": true, - "description": "Interlock — composition engine and reference application for the WorkspaceJSON Interlock contest submission", + "description": "Interlock \u2014 composition engine and reference application for the WorkspaceJSON Interlock contest submission", "license": "UNLICENSED", "type": "module", "engines": { @@ -15,7 +15,8 @@ "check:packet": "node experiments/hac-330/bin/verify-packet.mjs", "check:packet:s2": "node experiments/hac-326/bin/verify-packet.mjs", "check:packet:eval": "pnpm run build && node experiments/hac-343/bin/verify-packet.mjs", - "check": "pnpm run check:provenance && pnpm run check:packet && pnpm run check:packet:s2 && pnpm run check:packet:public && pnpm run check:packet:eval && pnpm run check:storyboard && pnpm run check:cockpit && pnpm run check:visuals && pnpm run check:identity && pnpm run check:package", + "check:filmed-run": "node experiments/hac-324/bin/verify-filmed-run.mjs", + "check": "pnpm run check:provenance && pnpm run check:packet && pnpm run check:packet:s2 && pnpm run check:packet:public && pnpm run check:packet:eval && pnpm run check:filmed-run && pnpm run check:storyboard && pnpm run check:cockpit && pnpm run check:visuals && pnpm run check:identity && pnpm run check:package", "check:cockpit": "node media/hac-341/bin/verify-cockpit.mjs", "check:cockpit:visual": "node media/hac-341/bin/verify-cockpit-visual.mjs", "check:package": "node media/hac-335/bin/verify-package.mjs", diff --git a/test/hac-324-filmed-run-gates.test.mjs b/test/hac-324-filmed-run-gates.test.mjs new file mode 100644 index 0000000..8028e31 --- /dev/null +++ b/test/hac-324-filmed-run-gates.test.mjs @@ -0,0 +1,185 @@ +/** + * Proves the HAC-324 filmed-run gate fails on the defects it exists to catch. + * + * The risk this addresses is specific to a *derived* evidence record. The + * emitted packet sits right beside the derived one, so a derivation that + * quietly changed an execution fact would look corroborated rather than + * contradicted. Each case below breaks one property and expects the gate to + * notice. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { cpSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const NEEDED = ['experiments/hac-324']; +const GATE = 'experiments/hac-324/bin/verify-filmed-run.mjs'; + +let pristine; +const scratch = []; + +beforeAll(() => { + pristine = mkdtempSync(join(tmpdir(), 'hac324-pristine-')); + for (const rel of NEEDED) cpSync(join(repoRoot, rel), join(pristine, rel), { recursive: true }); +}); +afterAll(() => { + for (const d of [pristine, ...scratch]) rmSync(d, { recursive: true, force: true }); +}); + +const run = (dir) => { + const r = spawnSync(process.execPath, [join(dir, GATE)], { encoding: 'utf8' }); + return { code: r.status, out: `${r.stdout}${r.stderr}` }; +}; + +function perturbed(mutate) { + const dir = mkdtempSync(join(tmpdir(), 'hac324-case-')); + scratch.push(dir); + cpSync(pristine, dir, { recursive: true }); + const api = { + dir, + json: (f) => JSON.parse(readFileSync(join(dir, f), 'utf8')), + writeJson: (f, o) => writeFileSync(join(dir, f), `${JSON.stringify(o, null, 2)}\n`), + }; + mutate(api); + return run(dir); +} + +const RECORD = 'experiments/hac-324/evidence/filmed-run.json'; +const RAW = 'experiments/hac-324/evidence/filmed-run.raw.json'; +const PKG = 'experiments/hac-324/evidence/capture-package.json'; + +describe('the gate accepts the filmed-run record as built', () => { + it('passes unmodified', () => { + const r = run(pristine); + expect(r.out).toContain('HAC-324 filmed-run record verified'); + expect(r.code).toBe(0); + }); +}); + +describe('the derivation cannot rewrite the run', () => { + it('fails when an execution fact is changed in the derived record', () => { + const r = perturbed((p) => { + const rec = p.json(RECORD); + rec.decision = 'DENY'; + p.writeJson(RECORD, rec); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/execution fact "decision" differs/); + }); + + it('fails when the observation is altered', () => { + const r = perturbed((p) => { + const rec = p.json(RECORD); + rec.observation.state.services.alpha = 46; + p.writeJson(RECORD, rec); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/execution fact "observation" differs/); + }); + + it('fails when a control is relaxed', () => { + const r = perturbed((p) => { + const rec = p.json(RECORD); + rec.controls.directBypassStatus = 200; + p.writeJson(RECORD, rec); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/execution fact "controls" differs/); + }); + + it('fails when the record is hand-edited away from what the producer builds', () => { + const r = perturbed((p) => { + const rec = p.json(RECORD); + rec.principalProjection.note = 'hand edited'; + p.writeJson(RECORD, rec); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/not what build-filmed-run\.mjs produces/); + }); + + it('fails when an environment field other than the principal is rewritten', () => { + const r = perturbed((p) => { + const rec = p.json(RECORD); + rec.resources.region = 'europe-west1'; + p.writeJson(RECORD, rec); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/resources\.region differs/); + }); +}); + +describe('the correction itself must stay true', () => { + it('fails when the record still names the provisioning caller as observer', () => { + const r = perturbed((p) => { + const rec = p.json(RECORD); + rec.resources.observerPrincipal = rec.resources.operatorPrincipal; + p.writeJson(RECORD, rec); + }); + expect(r.code).toBe(1); + // The rebuild check catches it first; either message proves the point. + expect(r.out).toMatch(/observerPrincipal was not corrected|not what build-filmed-run\.mjs produces/); + }); + + it('fails when the operator value no longer preserves what the packet emitted', () => { + const r = perturbed((p) => { + const raw = p.json(RAW); + raw.resources.observerPrincipal = 'user:someone-else@example.invalid'; + p.writeJson(RAW, raw); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/not what build-filmed-run\.mjs produces|does not preserve/); + }); +}); + +describe('the capture package and the record cannot drift apart', () => { + it('fails when the package names a different run', () => { + const r = perturbed((p) => { + const pkg = p.json(PKG); + pkg.filmedRunId = 'ilk-hac340-cloud-0000000000000'; + p.writeJson(PKG, pkg); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/capture package names run/); + }); + + it('fails when the package disagrees about who observed', () => { + const r = perturbed((p) => { + const pkg = p.json(PKG); + pkg.externalCallerPrincipal = 'user:qwynn@marcellelabs.io'; + p.writeJson(PKG, pkg); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/disagree about who performed the observation/); + }); + + it('fails when a frame no longer matches its recorded digest', () => { + const r = perturbed((p) => { + const pkg = p.json(PKG); + pkg.frames[0].sha256 = '0'.repeat(64); + p.writeJson(PKG, pkg); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/does not match its recorded sha256/); + }); + + it('fails when a frame is recorded as failing the capture-quality floor', () => { + const r = perturbed((p) => { + const pkg = p.json(PKG); + pkg.frames[1].qualityPass = false; + p.writeJson(PKG, pkg); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/failing the capture-quality floor/); + }); + + it('fails when teardown is not complete', () => { + const r = perturbed((p) => { + p.writeJson('experiments/hac-324/evidence/teardown.json', { status: 'pending' }); + }); + expect(r.code).toBe(1); + expect(r.out).toMatch(/teardown evidence is not complete/); + }); +});