diff --git a/src/lib/agent/base-image.test.ts b/src/lib/agent/base-image.test.ts index e55fcc09dd6..c7d1280f366 100644 --- a/src/lib/agent/base-image.test.ts +++ b/src/lib/agent/base-image.test.ts @@ -357,7 +357,8 @@ describe("agent base image provisioning", () => { "/test/root/agents/langchain-deepagents-code/requirements.lock", ], validateImage: expect.any(Function), - validationDescription: "deepagents-code==0.1.34", + validationDescription: + "deepagents-code==0.1.34 and the immutable security package inventory", }), ); }); diff --git a/src/lib/agent/deep-agents-code-base-image.test.ts b/src/lib/agent/deep-agents-code-base-image.test.ts index 84969d444ca..5326c94042e 100644 --- a/src/lib/agent/deep-agents-code-base-image.test.ts +++ b/src/lib/agent/deep-agents-code-base-image.test.ts @@ -39,18 +39,54 @@ describe("Deep Agents Code base image compatibility", () => { }), "/test/root/agents/langchain-deepagents-code/Dockerfile.base", ); - mocks.dockerCapture.mockReturnValue("9.8.7"); + mocks.dockerCapture + .mockReturnValueOnce("9.8.7") + .mockReturnValueOnce("nemoclaw-security-inventory-ok"); expect(options).toMatchObject({ inputPaths: [ "/test/root/agents/langchain-deepagents-code/manifest.yaml", "/test/root/agents/langchain-deepagents-code/requirements.lock", ], - validationDescription: "deepagents-code==9.8.7", + validationDescription: "deepagents-code==9.8.7 and the immutable security package inventory", }); expect(options?.validateImage?.("dcode-base:manifest-version")).toBe(true); }); + it("rejects a matching distribution from a base with an old security inventory (#7809)", () => { + const options = createDeepAgentsCodeBaseImageResolutionOptions( + makeAgent({ + name: "langchain-deepagents-code", + displayName: "LangChain Deep Agents Code", + expectedVersion: "0.1.34", + }), + "/test/root/agents/langchain-deepagents-code/Dockerfile.base", + ); + mocks.dockerCapture.mockReturnValueOnce("0.1.34").mockReturnValueOnce(""); + + expect(options?.validateImage?.("dcode-base:v0.0.96")).toBe(false); + expect(mocks.dockerCapture).toHaveBeenCalledTimes(2); + expect(mocks.dockerCapture.mock.calls[1]?.[0]).toEqual( + expect.arrayContaining([ + "run", + "--network", + "none", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--entrypoint", + "/bin/sh", + "dcode-base:v0.0.96", + "-c", + ]), + ); + expect(mocks.dockerCapture.mock.calls[1]?.[0].at(-1)).toContain( + `cmp -s - "$security_inventory"`, + ); + }); + it("runs the version probe in a locked-down container (#6456)", () => { mocks.dockerCapture.mockReturnValue("0.1.34"); diff --git a/src/lib/agent/deep-agents-code-base-image.ts b/src/lib/agent/deep-agents-code-base-image.ts index 05a03cbd64f..ae4d79ff2fc 100644 --- a/src/lib/agent/deep-agents-code-base-image.ts +++ b/src/lib/agent/deep-agents-code-base-image.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { dockerCapture } from "../adapters/docker"; import type { ResolveBaseImageOptions } from "../sandbox-base-image"; +import { sandboxBaseImageHasSecurityInventory } from "../sandbox-base-image/security-inventory"; import type { AgentDefinition } from "./defs"; const DEEPAGENTS_CODE_DISTRIBUTION = "deepagents-code"; @@ -73,7 +74,11 @@ export function createDeepAgentsCodeBaseImageResolutionOptions( // Retain the resolver's pre-existing global inputs alongside these agent // inputs. Per-agent cache-policy isolation is a separate cross-agent change. inputPaths: [path.join(agentRoot, "manifest.yaml"), path.join(agentRoot, "requirements.lock")], - validateImage: (imageRef) => deepAgentsCodeBaseImageMatchesVersion(imageRef, expectedVersion), - validationDescription: `${DEEPAGENTS_CODE_DISTRIBUTION}==${expectedVersion}`, + validateImage: (imageRef) => + deepAgentsCodeBaseImageMatchesVersion(imageRef, expectedVersion) && + sandboxBaseImageHasSecurityInventory(imageRef), + validationDescription: + `${DEEPAGENTS_CODE_DISTRIBUTION}==${expectedVersion} and ` + + "the immutable security package inventory", }; } diff --git a/src/lib/onboard/base-image.ts b/src/lib/onboard/base-image.ts index 8234e59336c..eae69ab6b51 100644 --- a/src/lib/onboard/base-image.ts +++ b/src/lib/onboard/base-image.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { dockerCapture } from "../adapters/docker"; import { ROOT } from "../runner"; import { buildLocalBaseTag, @@ -10,47 +9,16 @@ import { OPENCLAW_SANDBOX_BASE_IMAGE as SANDBOX_BASE_IMAGE, type SandboxBaseImageResolutionMetadata, } from "../sandbox-base-image"; +import { sandboxBaseImageHasSecurityInventory } from "../sandbox-base-image/security-inventory"; import { getInstalledOpenshellVersion } from "./openshell-version"; -const OPENCLAW_SECURITY_INVENTORY_PROBE_OK = "nemoclaw-security-inventory-ok"; - /** * Reject a published or cached OpenClaw base that predates the immutable * security package inventory consumed by the completed-image verification. * Accepting that base only defers the mismatch to the last Dockerfile layer, * after the expensive final image has already been built. */ -export function openClawBaseImageHasSecurityInventory(imageRef: string): boolean { - const output = dockerCapture( - [ - "run", - "--rm", - "--network", - "none", - "--cap-drop", - "ALL", - "--security-opt", - "no-new-privileges", - "--read-only", - "--entrypoint", - "/bin/sh", - imageRef, - "-c", - [ - "set -eu", - "security_inventory=/usr/local/share/nemoclaw/security-packages.txt", - 'arch="$(dpkg --print-architecture)"', - 'test -f "$security_inventory"', - 'test ! -L "$security_inventory"', - `test "$(stat -c '%u:%g:%a' "$security_inventory")" = "0:0:444"`, - `printf '%s\\n' "architecture=$arch" "libexpat1=2.8.2-1" "libonig5=6.9.9-1+b1" "libjq1=1.8.2-1" "jq=1.8.2-1" "vim-common=2:9.2.0782-1" "vim-tiny=2:9.2.0782-1" "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" | cmp -s - "$security_inventory"`, - `printf '%s\\n' "${OPENCLAW_SECURITY_INVENTORY_PROBE_OK}"`, - ].join("; "), - ], - { ignoreError: true, timeout: 20_000 }, - ); - return output.trim() === OPENCLAW_SECURITY_INVENTORY_PROBE_OK; -} +export const openClawBaseImageHasSecurityInventory = sandboxBaseImageHasSecurityInventory; /** * Resolve a compatible sandbox-base image and pin it to a repo digest when diff --git a/src/lib/sandbox-base-image/security-inventory.ts b/src/lib/sandbox-base-image/security-inventory.ts new file mode 100644 index 00000000000..7063f3f5bf5 --- /dev/null +++ b/src/lib/sandbox-base-image/security-inventory.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { dockerCapture } from "../adapters/docker"; + +const SECURITY_INVENTORY_PROBE_OK = "nemoclaw-security-inventory-ok"; + +/** + * Reject a published or cached base that predates the immutable security + * package inventory consumed by completed-image verification. + */ +export function sandboxBaseImageHasSecurityInventory(imageRef: string): boolean { + const output = dockerCapture( + [ + "run", + "--rm", + "--network", + "none", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--entrypoint", + "/bin/sh", + imageRef, + "-c", + [ + "set -eu", + "security_inventory=/usr/local/share/nemoclaw/security-packages.txt", + 'arch="$(dpkg --print-architecture)"', + 'test -f "$security_inventory"', + 'test ! -L "$security_inventory"', + `test "$(stat -c '%u:%g:%a' "$security_inventory")" = "0:0:444"`, + `printf '%s\\n' "architecture=$arch" "libexpat1=2.8.2-1" "libonig5=6.9.9-1+b1" "libjq1=1.8.2-1" "jq=1.8.2-1" "vim-common=2:9.2.0782-1" "vim-tiny=2:9.2.0782-1" "libssh2-1t64=1.11.1-1+deb13u1+nemoclaw1" "nemoclaw-python3.13-htmlparser-fix=3.13.5-2+deb13u4+nemoclaw1" | cmp -s - "$security_inventory"`, + `printf '%s\\n' "${SECURITY_INVENTORY_PROBE_OK}"`, + ].join("; "), + ], + { ignoreError: true, timeout: 20_000 }, + ); + return output.trim() === SECURITY_INVENTORY_PROBE_OK; +} diff --git a/test/dcode-base-image-workflow.test.ts b/test/dcode-base-image-workflow.test.ts index fcf0790351f..a691017019c 100644 --- a/test/dcode-base-image-workflow.test.ts +++ b/test/dcode-base-image-workflow.test.ts @@ -472,6 +472,8 @@ describe("base-image publication behavior", () => { expect(lock).toMatch(/^deepagents-code==[^\s\\]+\s+\\\n\s+--hash=sha256:[0-9a-f]{64}/m); expect(lockedVersion).toBeDefined(); expect(agent.expectedVersion).toBe(lockedVersion); - expect(resolution?.validationDescription).toBe(`deepagents-code==${lockedVersion}`); + expect(resolution?.validationDescription).toBe( + `deepagents-code==${lockedVersion} and the immutable security package inventory`, + ); }); });