Merge PR #1837: Pin the worker to the patched immutable Node24 base (#470)

This commit is contained in:
jason.woltje committed 2026-11-02 12:31:19 -06:00
commit a7a433e271
15 files changed
+259 -12

No files matched your search

+3
View File
@@ -5833,3 +5833,6 @@ Before: agents can read the library; supporting-path and push-credential depende
2026-10-29T00:29:45-05:00 | Vikunja-19 FIX-156 R3 review repair BEFORE | Independent review report 12437B/SHA256 70daa15fdddfc83654821c99149dc84210a9bd7078614b60ff8e630e4fcb8a77 read in full. Branch is clean at published ee151685e9e15511407904397d73e4d82c37c85b, original base/merge-base 09a847edaa25b609bac7728ff83f224708a29399. F1 source trace confirms only the session-fork file bind is selected from inside a worker-writable directory and remains daemon-resolution TOCTOU; task/mission already use host-owned current-run write-once snapshots. Focused baseline19/19. Controls will require no-follow bounded descriptor copying to same-run evidence, original/foreign source refusal, immutable bytes after original substitution, and probe unsafe-user refusals; no native/container/full gate, install, credential, API, commit/push/amend or review dispatch.
2026-10-29T00:33:07-05:00 | Vikunja-19 FIX-156 R3 review repair AFTER/source-verified | F1 fixed without a new schema: newest session fork is opened O_NOFOLLOW/O_NONBLOCK, fstat regular/current-uid checked, capped at 16777216 bytes before allocation, copied from that descriptor only after stable size/mtime/ctime checks, and published `wx` as current `runs/<id>/session-fork.jsonl`; `workerLaunch` accepts only that exact same-run snapshot and refuses shared-session/foreign-run sources. Real fixture substitution after snapshot cannot change mounted bytes; newest symlink copies/mounts no target; oversize refuses before publication/Docker. F2 documented honest unselected-tree boundary and retained selected managed-business/discord namespace collision; parent follow-up must choose dedicated legacy prefix or explicit collision refusals/migration. F3 probe symlink/nonfile/colon refusals documented and fixture-covered. F4 exact deferred #660 list now includes `scripts/test-darkwing-launch.mjs`; list not run. INFO dispositions: I1 stale comment and unused Docker-client MOSAIC_DATA_ROOT removed with 9pass/1RED then10/10; I2 raw basename comparison eliminated by same-run slot; I3 accepted RO control metadata unchanged; I4 worker-owned private prompt unchanged; I5 gated placeholders unchanged; I6 incomplete predispatch run pattern unchanged; I7 external-path Pi fork remains native #660 evidence; I8 pre-existing Claude adapter env gap unrelated/unchanged. Baseline19/19; principal RED18pass/5intendedfail after one fixture-only setup correction; F1 GREEN23/23; final23/23, four Node checks and diff check rc0. No full package, shell suite, Darkwing compatibility, build, Docker/Podman/bwrap/native/live/provider/network/credential/API/PR/review/commit/amend/push/merge/release action; #660/native/cross-family gates remain.
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.
+4 -1
View File
@@ -1,7 +1,10 @@
# Minimal Mosaic Stack POC agent image.
# Base: maintained Node.js image (same family as Pi's documented
# containerization example in docs/containerization.md).
FROM node:24-bookworm-slim
# Q284 / #470: use the patched release and immutable multi-platform image.
# Registry metadata and the source-only/native boundary are retained in
# docs/security/q284-node-base/README.md.
FROM node:24.22.2-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c
# Tools Pi's documented container image expects (bash, CA certs, git, ripgrep).
RUN apt-get update \
+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/private/system-prompt.md`, private to that container). 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
+3
View File
@@ -1886,3 +1886,6 @@ are never rewritten or removed; corrections are new entries.
2026-10-28 | Vikunja-19 | FIX-156 isolated legacy worker per-run mounts on work/vikunja-19-156-q284-worker-mounts at exact09a847e | Source/test/docs review-ready; focused18/18 plus prompt/syntax/diff checks green, no native/full gate or commit/push/PR/API; parent owns integration and CI.
2026-10-28 | Vikunja-19 | FIX-156 atomic-control continuation on same uncommitted branch/base | Replaced inode-pinning processing file launch bind with narrow read-only control-directory view; source RED10/11 then GREEN19/19 plus syntax/diff checks, native/container CI still required and parent-owned.
2026-10-29 | Vikunja-19 | FIX-156 R3 repair on published ee151685e without commit/amend | Replaced worker-writable session-fork bind with bounded no-follow current-run write-once snapshot; disposed F2-F4 and I1-I8, RED18/5 then focused23/23, #660/native/review gates remain parent-owned.
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
}
+45
View File
@@ -0,0 +1,45 @@
# Q284 / #470 — patched Node worker base
The worker base is Node24.22.2 Bookworm slim, pinned to the official Docker
Library Node multi-platform index digest. The previous mutable Node24 tag
could resolve to, or reuse a cached copy of, an older vulnerable release.
The Node project identifies the October2026 Brotli decoder memory corruption
as CVE-2026-111497, fixed in24.22.1. Version24.22.2 was released on2026-10-28.
Primary release/advisory sources:
- https://nodejs.org/en/blog/vulnerability/october-2026-security-releases
- https://nodejs.org/en/blog/release/v24.22.2
`index.json` is the exact official registry response read2026-10-29T03:30:27Z.
Its SHA256 equals the registry Docker-Content-Digest and the Containerfile pin.
The included Linux amd64 manifest is selected by its platform and independently
matches its own digest. `RECEIPT.json` gives both identities plus config/layers.
Only registry metadata was fetched: no layer pull, image build, binary, test,
container, native or privileged operation was performed for this source change.
This does not establish an installed/deployed version, binary provenance beyond
the selected official registry asset, a full-CI result, or a completed #470
image rebuild. The retained #470 runtime measurements name other deployed
api/orchestrator/web images; no binding from those images to this worker was
observed. Each existing deployment still needs its repository's authorized CI,
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.
+42
View File
@@ -0,0 +1,42 @@
{
"at": "2026-10-29T03:30:27.241Z",
"method": "public registry manifest/config reads only; no image pull/build/execution",
"repo": "library/node",
"tag": "24.22.2-bookworm-slim",
"rows": [
{
"name": "index.json",
"ref": "24.22.2-bookworm-slim",
"status": 200,
"bytes": 3929,
"sha256": "c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c",
"digest": "sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c",
"mediaType": "application/vnd.oci.image.index.v1+json"
},
{
"name": "linux-amd64.json",
"ref": "sha256:a43f7fc3e52e87d12a905f46eea0275def84ca8f2981a5b53fed44d7fb398ee0",
"status": 200,
"bytes": 1929,
"sha256": "a43f7fc3e52e87d12a905f46eea0275def84ca8f2981a5b53fed44d7fb398ee0",
"digest": "sha256:a43f7fc3e52e87d12a905f46eea0275def84ca8f2981a5b53fed44d7fb398ee0",
"mediaType": "application/vnd.oci.image.manifest.v1+json"
}
],
"selected": {
"digest": "sha256:a43f7fc3e52e87d12a905f46eea0275def84ca8f2981a5b53fed44d7fb398ee0",
"platform": {
"architecture": "amd64",
"os": "linux"
},
"configDigest": "sha256:9564952982df837e05d23c77e319715650fafc77b1abce39a504b920fda46a44",
"layerDigests": [
"sha256:3c5439d0ae62993af4cc4e21b8c777297e81bc40e200d4633c85a3e0f977a3bc",
"sha256:71cba37e8e979730332c9470b41e1d978d0fa9253c78769c62916bf084227639",
"sha256:c82672318514ade64c9d9475b8887e67ae5ec0e018566b79795c23c22542e486",
"sha256:e95aa4763e735fd31ba05431a12c6b7ec236a771aa6bda23f4be91af7c10b1c3",
"sha256:fc18cf7369573eeb5968534eb64b13d045bc5ca15d698ae3ef86d7e763138183"
],
"note": "metadata binding only; binaries and runtime unobserved"
}
}
+1
View File
@@ -0,0 +1 @@
{"manifests":[{"annotations":{"com.docker.official-images.bashbrew.arch":"amd64","org.opencontainers.image.base.digest":"sha256:a3bba7dc7dbcc1f37c2856c51fc9a7fac96608f50cb87b1b4e4d8b8c85ae988e","org.opencontainers.image.base.name":"debian:bookworm-slim","org.opencontainers.image.created":"2026-10-28T20:55:58Z","org.opencontainers.image.revision":"e994dfe115899a82684caa7533380aa7d439aae1","org.opencontainers.image.source":"https:\/\/github.com\/nodejs\/docker-node.git#e994dfe115899a82684caa7533380aa7d439aae1:24\/bookworm-slim","org.opencontainers.image.url":"https:\/\/hub.docker.com\/_\/node","org.opencontainers.image.version":"24-bookworm-slim"},"digest":"sha256:a43f7fc3e52e87d12a905f46eea0275def84ca8f2981a5b53fed44d7fb398ee0","mediaType":"application\/vnd.oci.image.manifest.v1+json","platform":{"architecture":"amd64","os":"linux"},"size":1929},{"annotations":{"com.docker.official-images.bashbrew.arch":"amd64","vnd.docker.reference.digest":"sha256:a43f7fc3e52e87d12a905f46eea0275def84ca8f2981a5b53fed44d7fb398ee0","vnd.docker.reference.type":"attestation-manifest"},"digest":"sha256:6f551b5567b5479a82dd248f8c8fafcbb51745bcfdf3db5d7a4e17a7479efcd6","mediaType":"application\/vnd.oci.image.manifest.v1+json","platform":{"architecture":"unknown","os":"unknown"},"size":841},{"annotations":{"com.docker.official-images.bashbrew.arch":"arm64v8","org.opencontainers.image.base.digest":"sha256:7ccb8b5b60c7654f5b795d94ee7d751cd0811500803f0b09cefcd555e755a55a","org.opencontainers.image.base.name":"debian:bookworm-slim","org.opencontainers.image.created":"2026-10-28T20:55:43Z","org.opencontainers.image.revision":"e994dfe115899a82684caa7533380aa7d439aae1","org.opencontainers.image.source":"https:\/\/github.com\/nodejs\/docker-node.git#e994dfe115899a82684caa7533380aa7d439aae1:24\/bookworm-slim","org.opencontainers.image.url":"https:\/\/hub.docker.com\/_\/node","org.opencontainers.image.version":"24-bookworm-slim"},"digest":"sha256:e80dee4ed3709cf1f8004e34ecfb90df71b7eac8ba0d7d6222ba238a823d1eea","mediaType":"application\/vnd.oci.image.manifest.v1+json","platform":{"architecture":"arm64","os":"linux","variant":"v8"},"size":1931},{"annotations":{"com.docker.official-images.bashbrew.arch":"arm64v8","vnd.docker.reference.digest":"sha256:e80dee4ed3709cf1f8004e34ecfb90df71b7eac8ba0d7d6222ba238a823d1eea","vnd.docker.reference.type":"attestation-manifest"},"digest":"sha256:73b8f2bfa2a316cbc6da625364f1b714e978e96edf03381d9a260d92620b4ed6","mediaType":"application\/vnd.oci.image.manifest.v1+json","platform":{"architecture":"unknown","os":"unknown"},"size":841},{"annotations":{"com.docker.official-images.bashbrew.arch":"ppc64le","org.opencontainers.image.base.digest":"sha256:197ef431a1e3c2086f70d65bf5c7d47217c2c25e8591c9a31e86a5b926077971","org.opencontainers.image.base.name":"debian:bookworm-slim","org.opencontainers.image.created":"2026-10-28T21:20:08Z","org.opencontainers.image.revision":"e994dfe115899a82684caa7533380aa7d439aae1","org.opencontainers.image.source":"https:\/\/github.com\/nodejs\/docker-node.git#e994dfe115899a82684caa7533380aa7d439aae1:24\/bookworm-slim","org.opencontainers.image.url":"https:\/\/hub.docker.com\/_\/node","org.opencontainers.image.version":"24-bookworm-slim"},"digest":"sha256:6baef4d2f00c27c530d270dc1a4b76d04113c378a4fc3bd3b242bd35d6ac7f18","mediaType":"application\/vnd.oci.image.manifest.v1+json","platform":{"architecture":"ppc64le","os":"linux"},"size":1931},{"annotations":{"com.docker.official-images.bashbrew.arch":"ppc64le","vnd.docker.reference.digest":"sha256:6baef4d2f00c27c530d270dc1a4b76d04113c378a4fc3bd3b242bd35d6ac7f18","vnd.docker.reference.type":"attestation-manifest"},"digest":"sha256:1c0f17fca393ddff67c244d9f15bd671636f8466fd360616f149e43a37e6a1ff","mediaType":"application\/vnd.oci.image.manifest.v1+json","platform":{"architecture":"unknown","os":"unknown"},"size":841}],"mediaType":"application\/vnd.oci.image.index.v1+json","schemaVersion":2}
@@ -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,46 @@
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:9564952982df837e05d23c77e319715650fafc77b1abce39a504b920fda46a44",
"size": 6707
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:3c5439d0ae62993af4cc4e21b8c777297e81bc40e200d4633c85a3e0f977a3bc",
"size": 28247437
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:71cba37e8e979730332c9470b41e1d978d0fa9253c78769c62916bf084227639",
"size": 3315
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:c82672318514ade64c9d9475b8887e67ae5ec0e018566b79795c23c22542e486",
"size": 51082629
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:e95aa4763e735fd31ba05431a12c6b7ec236a771aa6bda23f4be91af7c10b1c3",
"size": 1713038
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:fc18cf7369573eeb5968534eb64b13d045bc5ca15d698ae3ef86d7e763138183",
"size": 449
}
],
"annotations": {
"com.docker.official-images.bashbrew.arch": "amd64",
"org.opencontainers.image.base.digest": "sha256:a3bba7dc7dbcc1f37c2856c51fc9a7fac96608f50cb87b1b4e4d8b8c85ae988e",
"org.opencontainers.image.base.name": "debian:bookworm-slim",
"org.opencontainers.image.created": "2026-10-28T20:55:58Z",
"org.opencontainers.image.revision": "e994dfe115899a82684caa7533380aa7d439aae1",
"org.opencontainers.image.source": "https://github.com/nodejs/docker-node.git#e994dfe115899a82684caa7533380aa7d439aae1:24/bookworm-slim",
"org.opencontainers.image.url": "https://hub.docker.com/_/node",
"org.opencontainers.image.version": "24-bookworm-slim"
}
}
@@ -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;
}