fix(testing): bind image receipts to pinned build base (#470)

This commit is contained in:
jason.woltje committed 2026-10-29 00:33:01 -05:00
1 parent 155cb8c3e5
commit 7fff6a20e6
11 files changed
+134 -11

No files matched your search

+2
View File
@@ -5818,3 +5818,5 @@ Before: agents can read the library; supporting-path and push-credential depende
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
Q284 / #470 source-only security fix 2026-10-29T03:33:00.640624+00:00: pin worker base Node24.22.2 multi-platform indexsha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c, official registry metadata retained under docs/security/q284-node-base. Hash/digest/platform references checked; no image pull/build/runtime/test/CI/deploy. Current deployed Node states remain UNKNOWN. No frozen packet change; dedicated CI and cross-family merge review pending.
2026-10-29 | Sage | #470 independent-review repair | M1 binds preparation/verification baseDigest to exactly one literal digest-pinned Containerfile FROM; stale cached tag metadata cannot be substituted. Focused fixture TDD: 0/3 RED then 3/3 GREEN, fake build/inspection only, no Docker command or image operation. L1 docs corrected. L2 additive registry config hash/version binding retained; prior metadata receipt preserved with correction. Earlier no-commit statement describes initial source session; Mos then published PR1837. #660 admitted runner and fresh independent review remain required; frozen H1/packets unchanged.
+1 -1
View File
@@ -6,7 +6,7 @@ not implemented (see BRIEF.md, "Explicit exclusions").
## L0 — Implemented: container returns MOSAIC_HELLO_OK
One image (`mosaic-poc-agent:0.84.4`, built on `node:24-bookworm-slim`, non-root,
One image (`mosaic-poc-agent:0.84.4`, built on digest-pinned `node:24.22.2-bookworm-slim`, non-root,
pinned Pi) runs one Pi agent one-shot. Four immutable local contract files are
loaded in fixed order into the generated system prompt
(`/var/lib/mosaic/system-prompt.md`). One real model request is sent
+1 -1
View File
@@ -27,7 +27,7 @@ production-ready.
BRIEF.md requirements for the original container proof
BUILD-LOG.md append-only build/verification log
LAYERS.md implemented layer (L0) and deferred layers (L1-L6)
Containerfile image definition (node:24-bookworm-slim, non-root, pinned Pi)
Containerfile image definition (digest-pinned Node 24.22.2 Bookworm slim, non-root, pinned Pi)
compose.yaml one service: mosaic-agent (one-shot; configured via env)
package.json pins @earendil-works/pi-coding-agent at exactly 0.84.4
package-lock.json resolved lockfile used by npm ci in the image
+2
View File
@@ -1881,3 +1881,5 @@ are never rewritten or removed; corrections are new entries.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
2026-10-29T03:33:00.640624+00:00 | Sage | Q284 #470 patched Node base source | Digest pin and official metadata prepared; no build, CI, commit, PR, merge or live action.
2026-10-29 | Sage | #470 independent-review repair | M1 binds preparation/verification baseDigest to exactly one literal digest-pinned Containerfile FROM; stale cached tag metadata cannot be substituted. Focused fixture TDD: 0/3 RED then 3/3 GREEN, fake build/inspection only, no Docker command or image operation. L1 docs corrected. L2 additive registry config hash/version binding retained; prior metadata receipt preserved with correction. Earlier no-commit statement describes initial source session; Mos then published PR1837. #660 admitted runner and fresh independent review remain required; frozen H1/packets unchanged.
@@ -0,0 +1,14 @@
{
"at": "2026-10-29T05:31:41.106Z",
"source": "https://registry-1.docker.io/v2/library/node/blobs/sha256:9564952982df837e05d23c77e319715650fafc77b1abce39a504b920fda46a44",
"method": "public registry config metadata only; anonymous pull-scoped authorization kept in memory",
"bytes": 6707,
"sha256": "9564952982df837e05d23c77e319715650fafc77b1abce39a504b920fda46a44",
"configDigest": "sha256:9564952982df837e05d23c77e319715650fafc77b1abce39a504b920fda46a44",
"manifest": "linux-amd64.json",
"declaredNodeVersion": "24.22.2",
"platform": "linux/amd64",
"correction": "Earlier RECEIPT.json method mentions config reads; that earlier step fetched manifests only. This additive record binds the config now; earlier evidence is unchanged.",
"binaryVersionObserved": false,
"imagePullBuildRun": false
}
+15
View File
@@ -28,3 +28,18 @@ current runtime readbacks and separate operating approval.
The apt inputs and tag-only production activation receipts remain #714.
The Pi/Undici dependency correction remains #713. This base pin does not replace
either fix or modify any frozen #660/#678/#683 evidence.
Independent-review follow-up: `linux-amd64-config.json` is the exact 6,707-byte
registry config blob selected by the saved manifest. Its SHA256 is
`9564952982df837e05d23c77e319715650fafc77b1abce39a504b920fda46a44`, and its
image environment declares `NODE_VERSION=24.22.2`. `CONFIG-RECEIPT.json` records
the later public metadata read and corrects the earlier receipt's imprecise
"manifest/config reads" wording; the earlier receipt itself is preserved.
This is a declared config/version binding, not execution of `node --version`.
The image-preparation helper now records the one digest-pinned Containerfile
FROM reference directly. A well-formed receipt naming a stale mutable-tag image
refuses before image inspection. The helper does not inspect an unrelated local
base tag. Frozen #660 packets and running H1 inputs are unchanged; this is only
a future source candidate. The dedicated runner must admit and validate the
exact joined candidate before any merge or image use.
@@ -0,0 +1 @@
{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","NODE_VERSION=24.22.2","YARN_VERSION=1.22.22"],"Entrypoint":["docker-entrypoint.sh"],"Cmd":["node"]},"created":"2026-10-28T20:56:52.2122198Z","history":[{"created":"2026-10-27T00:00:00Z","created_by":"# debian.sh --arch 'amd64' out/ 'bookworm' '@1793059200'","comment":"debuerreotype 0.17"},{"created":"2026-10-28T20:56:22.171664505Z","created_by":"RUN /bin/sh -c groupadd --gid 1000 node \u0026\u0026 useradd --uid 1000 --gid node --shell /bin/bash --create-home node # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-28T20:56:40.262042355Z","created_by":"ENV NODE_VERSION=24.22.2","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-28T20:56:40.262042355Z","created_by":"RUN /bin/sh -c ARCH= OPENSSL_ARCH= \u0026\u0026 dpkgArch=\"$(dpkg --print-architecture)\" \u0026\u0026 case \"${dpkgArch##*-}\" in amd64) ARCH='x64' OPENSSL_ARCH='linux-x86_64';; ppc64el) ARCH='ppc64le' OPENSSL_ARCH='linux-ppc64le';; arm64) ARCH='arm64' OPENSSL_ARCH='linux-aarch64';; *) echo \"unsupported architecture\"; exit 1 ;; esac \u0026\u0026 set -ex \u0026\u0026 apt-get update \u0026\u0026 apt-get install -y ca-certificates curl wget gnupg dirmngr xz-utils libatomic1 --no-install-recommends \u0026\u0026 rm -rf /var/lib/apt/lists/* \u0026\u0026 export GNUPGHOME=\"$(mktemp -d)\" \u0026\u0026 for key in 5BE8A3F6C8A5C01D106C0AD820B1A390B168D356 DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7 CC68F5A3106FF448322E48ED27F5E38D5B0A215F 890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C 108F52B48DB57BB0CC439B2997B01419BD92F80A 655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD A363A499291CBBC940DD62E41F10027AF002F8B0 ; do { gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys \"$key\" \u0026\u0026 gpg --batch --fingerprint \"$key\"; } || { gpg --batch --keyserver keyserver.ubuntu.com --recv-keys \"$key\" \u0026\u0026 gpg --batch --fingerprint \"$key\"; } ; done \u0026\u0026 curl -fsSLO --compressed \"https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz\" \u0026\u0026 curl -fsSLO --compressed \"https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc\" \u0026\u0026 gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \u0026\u0026 gpgconf --kill all \u0026\u0026 rm -rf \"$GNUPGHOME\" \u0026\u0026 grep \" node-v$NODE_VERSION-linux-$ARCH.tar.xz\\$\" SHASUMS256.txt | sha256sum -c - \u0026\u0026 tar -xJf \"node-v$NODE_VERSION-linux-$ARCH.tar.xz\" -C /usr/local --strip-components=1 --no-same-owner \u0026\u0026 rm \"node-v$NODE_VERSION-linux-$ARCH.tar.xz\" SHASUMS256.txt.asc SHASUMS256.txt \u0026\u0026 find /usr/local/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name \"$OPENSSL_ARCH\" -exec rm -rf {} \\; \u0026\u0026 apt-mark auto '.*' \u003e /dev/null \u0026\u0026 find /usr/local -type f -executable -exec ldd '{}' ';' | awk '/=\u003e/ { so = $(NF-1); if (index(so, \"/usr/local/\") == 1) { next }; gsub(\"^/(usr/)?\", \"\", so); print so }' | sort -u | xargs -r dpkg-query --search | cut -d: -f1 | sort -u | xargs -r apt-mark manual \u0026\u0026 apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \u0026\u0026 ln -s /usr/local/bin/node /usr/local/bin/nodejs \u0026\u0026 node --version \u0026\u0026 npm --version \u0026\u0026 rm -rf /tmp/* # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2026-10-28T20:56:40.262042355Z","created_by":"ENV YARN_VERSION=1.22.22","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2026-10-28T20:56:52.190825843Z","created_by":"RUN /bin/sh -c set -ex \u0026\u0026 savedAptMark=\"$(apt-mark showmanual)\" \u0026\u0026 apt-get update \u0026\u0026 apt-get install -y ca-certificates curl wget gnupg dirmngr --no-install-recommends \u0026\u0026 rm -rf /var/lib/apt/lists/* \u0026\u0026 export GNUPGHOME=\"$(mktemp -d)\" \u0026\u0026 for key in 6A010C5166006599AA17F08146C2130DFD2497F5 ; do { gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys \"$key\" \u0026\u0026 gpg --batch --fingerprint \"$key\"; } || { gpg --batch --keyserver keyserver.ubuntu.com --recv-keys \"$key\" \u0026\u0026 gpg --batch --fingerprint \"$key\"; } ; done \u0026\u0026 curl -fsSLO --compressed \"https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz\" \u0026\u0026 curl -fsSLO --compressed \"https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz.asc\" \u0026\u0026 gpg --batch --verify yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz \u0026\u0026 gpgconf --kill all \u0026\u0026 rm -rf \"$GNUPGHOME\" \u0026\u0026 mkdir -p /opt \u0026\u0026 tar -xzf yarn-v$YARN_VERSION.tar.gz -C /opt/ \u0026\u0026 ln -s /optLine truncated
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { imageFingerprint, prepareImage, verifyImage } from '../../../scripts/testing/lane-image.mjs';
function fixture(t) {
const root = execFileSync('mktemp', ['-d', '-p', '/mnt/storage/scratch/tmp'], { encoding: 'utf8' }).trimEnd();
t.diagnostic(`scratch created: ${root}`);
assert.match(root, /^\/mnt\/storage\/scratch\/tmp\/tmp\.[A-Za-z0-9]{10}$/);
const initial = lstatSync(root);
assert.ok(initial.isDirectory() && !initial.isSymbolicLink());
assert.equal(initial.uid, process.getuid());
assert.equal(initial.mode & 0o777, 0o700);
t.after(() => {
const current = lstatSync(root);
assert.equal(current.dev, initial.dev); assert.equal(current.ino, initial.ino);
assert.equal(current.uid, initial.uid); assert.ok(current.isDirectory() && !current.isSymbolicLink());
rmSync(root, { recursive: true, force: false });
assert.equal(existsSync(root), false); t.diagnostic(`scratch disposed: ${root}`);
});
for (const file of ['Containerfile', 'package.json', 'package-lock.json', 'RELEASE']) {
cpSync(new URL('../../../' + file, import.meta.url), join(root, file));
}
for (const dir of ['contracts', 'src', 'adapters', 'packages/processing-control/src']) mkdirSync(join(root, dir), { recursive: true });
for (const file of ['state.mjs', 'entrypoint.mjs']) cpSync(new URL('../../processing-control/src/' + file, import.meta.url), join(root, 'packages/processing-control/src', file));
return root;
}
const actual = { Id: 'sha256:' + 'a'.repeat(64), Os: 'linux', Architecture: 'amd64' };
function base(root) { return /^FROM (\S+)$/m.exec(readFileSync(join(root, 'Containerfile'), 'utf8'))[1]; }
function receipt(root) {
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const release = readFileSync(join(root, 'RELEASE'), 'utf8').trim();
return { inputDigest: imageFingerprint(root), imageId: actual.Id, tag: `mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`, baseDigest: base(root), platform: 'linux/amd64' };
}
test('image receipt records the actual pinned FROM rather than a stale mutable tag', t => {
const root = fixture(t), file = join(root, 'Containerfile');
// A second arbitrary valid pin rules out a hardcoded replacement tag/digest.
const selected = 'node:24.22.2-bookworm-slim@sha256:' + 'd'.repeat(64);
writeFileSync(file, readFileSync(file, 'utf8').replace(/^FROM .+$/m, 'FROM ' + selected));
const calls = [];
const invoke = args => {
calls.push(args);
if (args[0] === 'compose') return '';
if (args[2] === 'node:24-bookworm-slim') return JSON.stringify({ RepoDigests: ['node@sha256:' + 'b'.repeat(64)] });
return JSON.stringify(actual);
};
const result = prepareImage(root, { grant: 'fixture-only', tempRoot: root }, invoke);
assert.equal(result.baseDigest, selected);
assert.equal(calls.some(args => args[2] === 'node:24-bookworm-slim'), false);
assert.equal(calls.filter(args => args[0] === 'compose').length, 1);
});
test('a well-formed stale base receipt refuses before any image inspection', t => {
const root = fixture(t), good = receipt(root);
assert.equal(verifyImage(good, root, () => actual), good);
let inspected = false;
assert.throws(() => verifyImage({ ...good, baseDigest: 'node@sha256:' + 'b'.repeat(64) }, root, () => { inspected = true; return actual; }), /base differs/);
assert.equal(inspected, false);
});
test('mutable, multistage and indirect FROM values refuse before build dispatch', t => {
const root = fixture(t), file = join(root, 'Containerfile'), original = readFileSync(file, 'utf8');
for (const from of ['FROM node:24-bookworm-slim', 'FROM ${BASE}', 'FROM scratch', `FROM ${base(root)} AS build`, `FROM ${base(root)}\nFROM ${base(root)}`]) {
writeFileSync(file, original.replace(/^FROM .+$/m, from));
let invoked = false;
assert.throws(() => prepareImage(root, { grant: 'fixture-only', tempRoot: root }, () => { invoked = true; return ''; }), /pinned FROM/);
assert.equal(invoked, false);
}
});
+6 -5
View File
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
const image=await import('../../../scripts/testing/lane-image.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
const docker=await import('../../../scripts/testing/lane-docker.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
function fixtureBase(root){return /^FROM (\S+)$/m.exec(readFileSync(join(root,'Containerfile'),'utf8'))[1];}
const processingInputs=['packages/processing-control/src/state.mjs','packages/processing-control/src/entrypoint.mjs'];
function copyProcessingInputs(root){
mkdirSync(join(root,'packages/processing-control/src'),{recursive:true});
@@ -17,7 +18,7 @@ test('a changed COPY input or actual image ID refuses compatible image sharing',
copyProcessingInputs(root);
writeFileSync(join(root,'RELEASE'),'0.0.1\n');
for(const d of ['contracts','src','adapters']){mkdirSync(join(root,d));writeFileSync(join(root,d,'one'),'same');}
const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:fixtureBase(root),platform:'linux/amd64'};
const inspect=()=>({Id:id,Os:'linux',Architecture:'amd64'});
assert.equal(image.verifyImage(receipt,root,inspect).imageId,id);
assert.throws(()=>image.verifyImage(receipt,root,()=>({...inspect(),Id:'sha256:'+'c'.repeat(64)})),/image ID/);
@@ -35,7 +36,7 @@ test('exclusive image preparation records actual image ID and build wall time an
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
copyProcessingInputs(root);
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});};
const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify({Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});};
assert.throws(()=>image.prepareImage(root,{grant:'',tempRoot:root},invoke),/grant/);
const result=image.prepareImage(root,{grant:'fixture',tempRoot:root},invoke);assert.equal(result.imageId,'sha256:'+'a'.repeat(64));assert.equal(result.builds,1);assert.ok(result.buildWallMs>=0);assert.ok(calls.some(x=>x.join(' ')==='compose build'));
});
@@ -51,7 +52,7 @@ function imageFixture(t){
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
copyProcessingInputs(root);
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:fixtureBase(root),platform:'linux/amd64'};
return {root,receipt};
}
test('missing receipt and missing image refuse without permitting a gate',t=>{
@@ -70,9 +71,9 @@ test('a tag changed between supervised steps fails the next boundary and suppres
assert.equal(events.filter(e=>e.kind==='step').length,1);
assert.equal(events.some(e=>e.kind==='terminal'&&e.rc===0),false);
});
test('mutable-base rebuilds with identical files have distinct image IDs; cold and warm counters stay separate',t=>{
test('pinned-base rebuilds with identical files have distinct image IDs; cold and warm counters stay separate',t=>{
const {root}=imageFixture(t);let builds=0,id='sha256:'+'a'.repeat(64);
const fake=args=>{if(args[0]==='compose'){builds++;return 'fixture build';}return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:id,Os:'linux',Architecture:'amd64'});};
const fake=args=>{if(args[0]==='compose'){builds++;return 'fixture build';}return JSON.stringify({Id:id,Os:'linux',Architecture:'amd64'});};
const cold=image.prepareImage(root,{grant:'fixture-cold',tempRoot:root},fake);
assert.equal(cold.builds,1);assert.ok(cold.buildWallMs>=0);assert.equal(builds,1);
for(let i=0;i<3;i++)image.verifyImage(cold,root,()=>({Id:id,Os:'linux',Architecture:'amd64'}));
+9 -2
View File
@@ -60,8 +60,15 @@ scripts/testing/gate-lane.sh prepare-image --lane a --candidate FULL_SHA \
Preparation holds both lanes and the exclusive image lock. Its build log,
receipt, count and wall time are separate from warm gate timings. The current
Containerfile uses a mutable FROM tag and apt indexes. A missing base digest
refuses provenance publication. No credential file is copied or printed.
Containerfile uses one literal digest-pinned FROM reference; apt indexes remain
mutable. Preparation records that exact declared reference as `baseDigest`, and
verification refuses a different receipt reference. It never borrows the digest
of a cached mutable tag. Mutable, indirect and multistage FROM inputs refuse
before build dispatch. This binds source/build inputs, not an independently
measured base binary or a layer-level reconstruction. Admitted image validation
must still check the built image's Node version (`v24.22.2`) and provenance.
Retained registry config metadata and its limits are in
`docs/security/q284-node-base/`. No credential file is copied or printed.
Run records are immutable JSON files published by link after fsync. Every step
has a log, UTC/monotonic times, exit code, pass/fail/skip counts and log hash.
+11 -2
View File
@@ -3,6 +3,14 @@ import { readFileSync, readdirSync, lstatSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { refuse } from './lane-state.mjs';
// This is the declared build input, not an observation of a running binary.
function pinnedBase(checkout){
const lines=readFileSync(join(checkout,'Containerfile'),'utf8').split('\n').filter(line=>/^\s*FROM(?:\s|$)/i.test(line));
if(lines.length!==1)refuse('image requires exactly one pinned FROM');
const fields=lines[0].trim().split(/\s+/);
if(fields.length!==2||!/^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[a-f0-9]{64}$/.test(fields[1]))refuse('image requires one literal digest-pinned FROM');
return fields[1];
}
export function imageFingerprint(checkout){
const dockerfile=readFileSync(join(checkout,'Containerfile'),'utf8');
const copies=dockerfile.split('\n').filter(l=>/^\s*(COPY|ADD)\s/i.test(l)).map(l=>l.trim());
@@ -18,6 +26,7 @@ export function inspectImage(tag,docker=process.env.MOSAIC_GATE_REAL_DOCKER??'do
}
export function verifyImage(receipt,checkout,inspect=inspectImage){
if(!receipt||!/^sha256:[a-f0-9]{64}$/.test(receipt.imageId??'')||!/^.+@sha256:[a-f0-9]{64}$/.test(receipt.baseDigest??'')||!/^linux\/[a-z0-9_]+$/.test(receipt.platform??'')||!/^mosaic-poc-agent:[A-Za-z0-9_.-]+$/.test(receipt.tag??''))refuse('image provenance receipt invalid');
if(receipt.baseDigest!==pinnedBase(checkout))refuse('image base differs from pinned Containerfile');
if(receipt.inputDigest!==imageFingerprint(checkout))refuse('image inputs differ');
const actual=inspect(receipt.tag);
if(actual.Id!==receipt.imageId)refuse('pinned image ID differs');
@@ -29,6 +38,7 @@ export function verifyImage(receipt,checkout,inspect=inspectImage){
// Called by the CLI only while both lanes and the image lock are exclusive.
export function prepareImage(checkout,{grant,tempRoot,out},invoke){
if(!grant)refuse('missing image preparation grant');
const baseDigest=pinnedBase(checkout);
const inputDigest=imageFingerprint(checkout);
const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8'));
const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();
@@ -38,7 +48,6 @@ export function prepareImage(checkout,{grant,tempRoot,out},invoke){
const start=performance.now();let buildOutput;try{buildOutput=run(['compose','build']);}catch(e){if(out)writeFileSync(join(out,'image-build.log'),String(e.stdout??'')+String(e.stderr??''),{mode:0o600,flag:'wx'});throw e;}if(out)writeFileSync(join(out,'image-build.log'),buildOutput,{mode:0o600,flag:'wx'});const buildWallMs=performance.now()-start;
if(inputDigest!==imageFingerprint(checkout))refuse('image inputs changed while building');
const actual=JSON.parse(run(['image','inspect',tag,'--format','{{json .}}']));
const base=JSON.parse(run(['image','inspect','node:24-bookworm-slim','--format','{{json .}}']));
const receipt={inputDigest,imageId:actual.Id,tag,baseDigest:base.RepoDigests?.find(x=>/@sha256:[a-f0-9]{64}$/.test(x)),platform:`${actual.Os}/${actual.Architecture}`,grant,builds:1,buildWallMs,at:new Date().toISOString()};
const receipt={inputDigest,imageId:actual.Id,tag,baseDigest,platform:`${actual.Os}/${actual.Architecture}`,grant,builds:1,buildWallMs,at:new Date().toISOString()};
verifyImage(receipt,checkout,()=>actual);return receipt;
}