From 8a9df4182ac217a68708e0d8a7b93dc914f1e6d5 Mon Sep 17 00:00:00 2001 From: ColinM-sys Date: Wed, 8 Apr 2026 23:28:05 -0400 Subject: [PATCH 1/2] fix(security): pin sandbox image by digest in blueprint.yaml The blueprint referenced the sandbox image by the mutable :latest tag: image: "ghcr.io/nvidia/openshell-community/sandboxes/openclaw:latest" A registry compromise or accidental force-push to :latest would silently swap the sandbox image without any blueprint-side change, defeating the top-level 'digest:' field's stated purpose. The reported issue (#1438) identifies this as a supply-chain risk. Switch to a digest-pinned reference: image: "ghcr.io/nvidia/openshell-community/sandboxes/openclaw@sha256:..." The digest below was the resolved sha256 of the :latest tag at the time of this commit, fetched directly from ghcr.io. Release tooling should bump this digest together with the top-level 'digest:' field on every release. Adds a regression test in test/validate-blueprint.test.ts that walks the parsed blueprint, finds components.sandbox.image, and asserts: - the image string contains '@sha256:' (digest pin present) - the image string does NOT match a ':latest' suffix - the digest is a 64-hex-char sha256 Verified: the new test fails on an unfixed blueprint and passes with the digest pin in place. Closes #1438 Signed-off-by: ColinM-sys --- nemoclaw-blueprint/blueprint.yaml | 7 ++++++- test/validate-blueprint.test.ts | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 0e495aa360a..acc8e18a18c 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -18,7 +18,12 @@ description: | components: sandbox: - image: "ghcr.io/nvidia/openshell-community/sandboxes/openclaw:latest" + # Pinned to a specific image digest so a future registry compromise or an + # accidental :latest force-push cannot silently swap the sandbox image. + # Ref: #1438. The digest below was the resolved sha256 of the :latest tag + # at the time of this commit; release tooling should bump it together with + # the top-level `digest:` field on every release. + image: "ghcr.io/nvidia/openshell-community/sandboxes/openclaw@sha256:b3d832b596ab6b7184a9dcb4ae93337ca32851a4f93b00765cc12de26baa3a9a" name: "openclaw" forward_ports: - 18789 diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index 921c59733c7..574e90c1740 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -41,6 +41,26 @@ describe("blueprint.yaml", () => { expect(Object.keys(defined!).length).toBeGreaterThan(0); }); + it("regression #1438: sandbox image is pinned by digest, not by mutable tag", () => { + // The blueprint MUST NOT pull a sandbox image by a mutable tag like + // ":latest" — a registry compromise or accidental force-push could + // silently swap the image. Pin via @sha256:... so the image cannot + // change without a corresponding blueprint update. + const sandbox = (bp.components as Record | undefined)?.sandbox as + | { image?: unknown } + | undefined; + const image = typeof sandbox?.image === "string" ? sandbox.image : ""; + expect(image.length).toBeGreaterThan(0); + expect(image).toContain("@sha256:"); + // Belt and braces: explicitly forbid the ":latest" tag form even if the + // image string has been rearranged. + expect(image).not.toMatch(/:latest$/); + expect(image).not.toMatch(/:latest@/); + // The digest itself must be a 64-hex sha256. + const digestMatch = image.match(/@sha256:([0-9a-f]{64})$/); + expect(digestMatch).not.toBeNull(); + }); + for (const name of declared) { describe(`profile '${name}'`, () => { it("has a definition", () => { From b6b47db83b2002b7f976f7cdf9327e1e72dfbffc Mon Sep 17 00:00:00 2001 From: ColinM-sys Date: Wed, 8 Apr 2026 23:46:18 -0400 Subject: [PATCH 2/2] fix(security): populate top-level blueprint digest to mirror image manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit on #1655 flagged that the top-level 'digest:' field at the top of blueprint.yaml was still empty after the image-pinning change, leaving the issue (#1438) only partially addressed: image pinning was fixed but the documented blueprint-level integrity field was still 'Computed at release time' with no actual value. Mirror the components.sandbox.image manifest digest into the top-level field so: - A downstream consumer (or release tooling) can read a single field to know what sandbox image this blueprint pins to, without having to parse the components tree. - A future contributor who bumps the pinned image but forgets the top-level field is caught at CI time by the new test below — the trivially-bypassable 'image moved but digest stayed stale' scenario is now blocked. - The release tooling that bumps the pinned image only has to also rewrite the top-level field (one matching value, not two unrelated ones). The comment block on the digest field calls this out. Adds a second regression test in test/validate-blueprint.test.ts that asserts both: 1. The top-level blueprint.digest is a non-empty 'sha256:<64-hex>' string. 2. The top-level digest exactly matches the @sha256: portion of the pinned components.sandbox.image string. Both regression tests verified by stashing the YAML fix and re-running: the new tests fail on an unfixed blueprint and pass with the fix in place. Addresses CodeRabbit major flag on #1655. Still part of #1438. Signed-off-by: ColinM-sys --- nemoclaw-blueprint/blueprint.yaml | 8 +++++++- test/validate-blueprint.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index acc8e18a18c..adda03360bf 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -4,7 +4,13 @@ version: "0.1.0" min_openshell_version: "0.0.24" min_openclaw_version: "2026.3.0" -digest: "" # Computed at release time +# Mirrors the components.sandbox.image manifest digest below. Lets a +# downstream consumer (or release tooling) verify the blueprint declares +# a specific sandbox image without parsing the components tree, and +# blocks the trivially-bypassable case where someone bumps the pinned +# image but forgets the top-level field. Release tooling should rewrite +# both fields together. See #1438. +digest: "sha256:b3d832b596ab6b7184a9dcb4ae93337ca32851a4f93b00765cc12de26baa3a9a" profiles: - default diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index 574e90c1740..7fe9a4cccbc 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -61,6 +61,31 @@ describe("blueprint.yaml", () => { expect(digestMatch).not.toBeNull(); }); + it("regression #1438: top-level digest field is populated and matches the image digest", () => { + // The top-level `digest:` field at the top of blueprint.yaml is + // documented as "Computed at release time" and was empty on main, + // which left blueprint-level integrity unverifiable. Mirror the + // sandbox image manifest digest into the top-level field so any + // consumer can read a single field to know what's pinned, and so + // a future contributor can't bump one without bumping the other. + const topLevelDigest = typeof bp.digest === "string" ? bp.digest : ""; + expect(topLevelDigest.length).toBeGreaterThan(0); + // Must be a sha256:<64-hex> string. + expect(topLevelDigest).toMatch(/^sha256:[0-9a-f]{64}$/); + + const sandbox = (bp.components as Record | undefined)?.sandbox as + | { image?: unknown } + | undefined; + const image = typeof sandbox?.image === "string" ? sandbox.image : ""; + const imageDigestMatch = image.match(/@sha256:([0-9a-f]{64})$/); + expect(imageDigestMatch).not.toBeNull(); + const imageDigest = `sha256:${imageDigestMatch?.[1] ?? ""}`; + + // The two digests must agree. If a future bump touches one but not + // the other, this assertion catches it before merge. + expect(topLevelDigest).toBe(imageDigest); + }); + for (const name of declared) { describe(`profile '${name}'`, () => { it("has a definition", () => {