From 933457b61f614331e0f62e3a19c1c5862c8e6945 Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Sun, 23 Aug 2026 22:29:07 -0400 Subject: [PATCH] HAC-324: correct the observer principal at the producing layer 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 available fixes were both wrong. Editing the emitted packet 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 as filmed-run.raw.json, and build-filmed-run.mjs 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 HAC-340 script that emitted the raw packet is the approved runtime source at ae6d0d3 and is deliberately not modified. Changing it would change the runtime source SHA and break the parity claim the run exists to make. The producing layer for HAC-324's own evidence is this script. verify-filmed-run.mjs proves the derivation rather than asserting it: the record is exactly what the producer rebuilds, all 13 execution facts are byte-identical to the emitted packet, no environment field other than the principal projection moved, the capture package names the same run and the same observer, and every frame still matches its recorded digest. That proof matters more than usual here. The raw packet sits committed beside the derived one, so a derivation that quietly changed an execution fact would look corroborated rather than contradicted. 13 negative proofs. Wired into `pnpm run check` and into CI as its own job, so the gate actually runs rather than only existing. No GCP rerun: this repairs incorrectly projected metadata using evidence already preserved. No execution fact changed, and the frames are untouched. pnpm run check exit 0, 658 tests, typecheck and build clean. --- .github/workflows/ci.yml | 43 ++++ experiments/hac-324/bin/build-filmed-run.mjs | 118 +++++++++++ experiments/hac-324/bin/verify-filmed-run.mjs | 114 +++++++++++ .../hac-324/evidence/capture-package.json | 7 +- experiments/hac-324/evidence/filmed-run.json | 20 +- .../hac-324/evidence/filmed-run.raw.json | 111 +++++++++++ package.json | 5 +- test/hac-324-filmed-run-gates.test.mjs | 185 ++++++++++++++++++ 8 files changed, 595 insertions(+), 8 deletions(-) create mode 100644 experiments/hac-324/bin/build-filmed-run.mjs create mode 100644 experiments/hac-324/bin/verify-filmed-run.mjs create mode 100644 experiments/hac-324/evidence/filmed-run.raw.json create mode 100644 test/hac-324-filmed-run-gates.test.mjs 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/); + }); +});