diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 0e495aa360a..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 @@ -18,7 +24,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..7fe9a4cccbc 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -41,6 +41,51 @@ 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(); + }); + + 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", () => {