diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 004fc7b1faa..788ef50d6e4 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -15,7 +15,17 @@ ENV NPM_CONFIG_AUDIT=false \ NPM_CONFIG_UPDATE_NOTIFIER=false WORKDIR /opt/mcp-tool-discovery-runtime COPY tools/mcp-tool-discovery-runtime/package.json tools/mcp-tool-discovery-runtime/package-lock.json tools/mcp-tool-discovery-runtime/tsconfig.json tools/mcp-tool-discovery-runtime/install-reviewed-runtime.sh tools/mcp-tool-discovery-runtime/*.ts ./ -RUN ./install-reviewed-runtime.sh \ +# hadolint ignore=DL4006 +RUN set -eu; \ + if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ + { printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /tmp/nemoclaw-corporate-ca.pem 2>/dev/null \ + || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 is not valid base64; expected a single-line base64-encoded PEM (#6210)" >&2; exit 1; }; }; \ + node -e 'const fs = require("node:fs"); const { X509Certificate } = require("node:crypto"); const pem = fs.readFileSync(process.argv[1], "utf8"); const certificates = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); if (!certificates?.length) process.exit(1); for (const certificate of certificates) if (!new X509Certificate(certificate).ca) process.exit(1);' /tmp/nemoclaw-corporate-ca.pem \ + || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates with basicConstraints CA:TRUE (#6210)" >&2; exit 1; }; \ + export NODE_EXTRA_CA_CERTS=/tmp/nemoclaw-corporate-ca.pem; \ + fi; \ + ./install-reviewed-runtime.sh \ + && rm -f /tmp/nemoclaw-corporate-ca.pem \ && rm -f ./install-reviewed-runtime.sh RUN chown -R root:root /opt/mcp-tool-discovery-runtime \ && chmod -R a=rX /opt/mcp-tool-discovery-runtime @@ -23,6 +33,26 @@ RUN chown -R root:root /opt/mcp-tool-discovery-runtime \ # hadolint ignore=DL3006 FROM ${BASE_IMAGE} +ARG NEMOCLAW_CORPORATE_CA_B64 + +# Decode the host corporate-proxy CA (#6210) for runtime trust when onboarding +# includes one in the final DCode image. Published or cached bases may not carry +# the host-specific CA, so decode the argument again when it is present. +# hadolint ignore=DL3059,DL4006 +RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ + command -v base64 >/dev/null 2>&1 || { echo "[nemoclaw] base64 is required to decode NEMOCLAW_CORPORATE_CA_B64 but is not installed in the build image" >&2; exit 1; }; \ + install -d -o root -g root -m 0755 /usr/local/share/nemoclaw \ + && { printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /tmp/nemoclaw-corporate-ca.decoded 2>/dev/null \ + || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 is not valid base64; expected a single-line base64-encoded PEM (#6210)" >&2; exit 1; }; } \ + && awk '/-----BEGIN CERTIFICATE-----/{f=1} f{print} /-----END CERTIFICATE-----/{f=0}' /tmp/nemoclaw-corporate-ca.decoded > /usr/local/share/nemoclaw/corporate-ca.pem \ + && rm -f /tmp/nemoclaw-corporate-ca.decoded \ + && { node -e 'const fs = require("node:fs"); const { X509Certificate } = require("node:crypto"); const pem = fs.readFileSync(process.argv[1], "utf8"); const certificates = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); if (!certificates?.length) process.exit(1); for (const certificate of certificates) if (!new X509Certificate(certificate).ca) process.exit(1);' /usr/local/share/nemoclaw/corporate-ca.pem \ + || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates with basicConstraints CA:TRUE (#6210)" >&2; exit 1; }; } \ + && chown root:root /usr/local/share/nemoclaw/corporate-ca.pem \ + && chmod 0444 /usr/local/share/nemoclaw/corporate-ca.pem \ + && echo "[nemoclaw] baked host corporate-proxy CA into DCode image trust (#6210)"; \ + fi + COPY --from=mcp-tool-discovery-runtime /opt/mcp-tool-discovery-runtime/dist/ /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/ RUN discovery_contract="$(node /usr/local/lib/nemoclaw/mcp-tool-discovery-runtime/mcp-tool-discovery.mjs)" \ && node -e 'const result = JSON.parse(process.argv[1]); if (result.protocol !== 1 || result.ok !== false || result.detail !== "tool discovery received invalid runtime arguments") process.exit(1);' "$discovery_contract" \ diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index c4396054206..0518a51ea6b 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -11,8 +11,13 @@ ARG PERL_VERSION=5.44.0 ARG PERL_SHA256=505cf43912e9480495c344c70260452e32aa2a73c546a026b3f100053b23ce91 ARG PERL_PACKAGE_REVISION=1nemoclaw1 +ARG NEMOCLAW_CORPORATE_CA_B64= + FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba AS native-security-builder +ARG NEMOCLAW_CORPORATE_CA_B64 + +# hadolint ignore=DL4006 RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential=12.12 \ ca-certificates=20250419 \ @@ -22,6 +27,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ openssh-server=1:10.0p1-7+deb13u4 \ xz-utils=5.8.1-1+deb13u1 \ zlib1g-dev=1:1.3.dfsg+really1.3.1-1+b1 \ + && if [ -n "${NEMOCLAW_CORPORATE_CA_B64:-}" ]; then \ + install -d -o root -g root -m 0755 /usr/local/share/nemoclaw /usr/local/share/ca-certificates; \ + { printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /usr/local/share/nemoclaw/corporate-ca.pem 2>/dev/null \ + || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 is not valid base64; expected a single-line base64-encoded PEM (#6210)" >&2; exit 1; }; }; \ + node -e 'const fs = require("node:fs"); const { X509Certificate } = require("node:crypto"); const pemPath = process.argv[1]; const anchorDir = process.argv[2]; const pem = fs.readFileSync(pemPath, "utf8"); const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); if (!blocks?.length) process.exit(1); fs.writeFileSync(pemPath, blocks.map((block) => block.trim()).join("\n") + "\n"); blocks.forEach((block, index) => { if (!new X509Certificate(block).ca) process.exit(1); const name = anchorDir + "/nemoclaw-corporate-ca-" + String(index + 1).padStart(2, "0") + ".crt"; fs.writeFileSync(name, block.trim() + "\n"); });' /usr/local/share/nemoclaw/corporate-ca.pem /usr/local/share/ca-certificates \ + || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates with basicConstraints CA:TRUE (#6210)" >&2; exit 1; }; \ + chown root:root /usr/local/share/nemoclaw/corporate-ca.pem /usr/local/share/ca-certificates/nemoclaw-corporate-ca-*.crt; \ + chmod 0444 /usr/local/share/nemoclaw/corporate-ca.pem /usr/local/share/ca-certificates/nemoclaw-corporate-ca-*.crt; \ + update-ca-certificates; \ + echo "[nemoclaw] trusted host corporate-proxy CA for native security builders (#6210)"; \ + fi \ && rm -rf /var/lib/apt/lists/* COPY scripts/security/build-native-security-packages.sh /scripts/security/build-native-security-packages.sh @@ -47,6 +63,8 @@ RUN bash /scripts/security/build-perl-security-packages.sh \ FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba +ARG NEMOCLAW_CORPORATE_CA_B64 + COPY --from=perl-builder /out /tmp/nemoclaw-native-security COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts @@ -76,6 +94,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ e2fsprogs=1.47.2-3+b11 \ openssh-sftp-server=1:10.0p1-7+deb13u4 \ ripgrep=14.1.1-1+b4 \ + && if [ -n "${NEMOCLAW_CORPORATE_CA_B64:-}" ]; then \ + command -v base64 >/dev/null 2>&1 || { echo "[nemoclaw] base64 is required to decode NEMOCLAW_CORPORATE_CA_B64 but is not installed in the base build image" >&2; exit 1; }; \ + install -d -o root -g root -m 0755 /usr/local/share/nemoclaw /usr/local/share/ca-certificates; \ + { printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /usr/local/share/nemoclaw/corporate-ca.pem 2>/dev/null \ + || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 is not valid base64; expected a single-line base64-encoded PEM (#6210)" >&2; exit 1; }; }; \ + node -e 'const fs = require("node:fs"); const { X509Certificate } = require("node:crypto"); const pemPath = process.argv[1]; const anchorDir = process.argv[2]; const pem = fs.readFileSync(pemPath, "utf8"); const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); if (!blocks?.length) process.exit(1); fs.writeFileSync(pemPath, blocks.map((block) => block.trim()).join("\n") + "\n"); blocks.forEach((block, index) => { if (!new X509Certificate(block).ca) process.exit(1); const name = anchorDir + "/nemoclaw-corporate-ca-" + String(index + 1).padStart(2, "0") + ".crt"; fs.writeFileSync(name, block.trim() + "\n"); });' /usr/local/share/nemoclaw/corporate-ca.pem /usr/local/share/ca-certificates \ + || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates with basicConstraints CA:TRUE (#6210)" >&2; exit 1; }; \ + chown root:root /usr/local/share/nemoclaw/corporate-ca.pem /usr/local/share/ca-certificates/nemoclaw-corporate-ca-*.crt; \ + chmod 0444 /usr/local/share/nemoclaw/corporate-ca.pem /usr/local/share/ca-certificates/nemoclaw-corporate-ca-*.crt; \ + update-ca-certificates; \ + echo "[nemoclaw] baked host corporate-proxy CA into base image trust (#6210)"; \ + fi \ && arch="$(dpkg --print-architecture)" \ && case "$arch" in \ amd64) \ diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index fd0e5a9da21..b85ee0a3c64 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -76,6 +76,21 @@ "test": "decodes the CA and exports NODE_EXTRA_CA_CERTS before the reinstall audit-signatures step", "category": "security" }, + { + "file": "test/corporate-ca-build-tls-anchor.test.ts", + "test": "accepts the corporate CA build arg in the DCode base image before HTTPS fetches", + "category": "security" + }, + { + "file": "test/corporate-ca-build-tls-anchor.test.ts", + "test": "decodes the corporate CA again in the DCode final image", + "category": "security" + }, + { + "file": "test/corporate-ca-build-tls-anchor.test.ts", + "test": "trusts the corporate CA before the DCode discovery runtime npm install", + "category": "security" + }, { "file": "test/dcode-base-image-workflow.test.ts", "test": "accepts every discovered publisher and rejects supply-chain mutations", diff --git a/docs/index.yml b/docs/index.yml index ea0ad6939e0..8a6ee291620 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -675,6 +675,9 @@ navigation: - page: "Security Best Practices" path: _build/agent-variants/security/best-practices.deepagents.generated.mdx slug: best-practices + - page: "Configure Corporate CA Trust" + path: _build/agent-variants/security/configure-corporate-ca-trust.deepagents.generated.mdx + slug: configure-corporate-ca-trust - page: "Credential Storage" path: _build/agent-variants/security/credential-storage.deepagents.generated.mdx slug: credential-storage diff --git a/docs/security/configure-corporate-ca-trust.mdx b/docs/security/configure-corporate-ca-trust.mdx index 0f0291aa0df..201d9808465 100644 --- a/docs/security/configure-corporate-ca-trust.mdx +++ b/docs/security/configure-corporate-ca-trust.mdx @@ -8,7 +8,7 @@ description-agent: "Configures corporate proxy CA trust for runtime TLS and supp keywords: ["nemoclaw corporate ca", "corporate proxy tls", "certificate verification"] content: type: "how_to" -agent-variants: ["openclaw", "hermes"] +agent-variants: ["openclaw", "hermes", "deepagents"] --- Configure a corporate Certificate Authority (CA) before onboarding when an enterprise proxy re-signs external TLS with a root that OpenShell does not provide. @@ -43,6 +43,13 @@ The Hermes Dockerfile decodes the bundle after its managed build-time dependency + + +Deep Agents Code applies the corporate CA to its local base-image build before HTTPS dependency fetches and decodes it again in the final sandbox image. +This supports cold builds on hosts where the base image is not cached. + + + At runtime, NemoClaw appends the corporate CA to the OpenShell trust bundle instead of replacing it. It points `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `REQUESTS_CA_BUNDLE`, `GIT_SSL_CAINFO`, and `NODE_EXTRA_CA_CERTS` at the merged bundle so curl, Python, Git, and Node.js trust both roots. @@ -91,6 +98,10 @@ Set `NEMOCLAW_CORPORATE_CA_IMPORT=0` to disable corporate CA import entirely. ## Related Topics + + - [Troubleshooting](../reference/troubleshooting#external-channel-tls-fails-behind-a-corporate-mitm-proxy-netfail) for `NET:FAIL` symptoms and certificate diagnostics. + + - [Security Best Practices](best-practices) for the broader sandbox trust model. - [Credential Storage](credential-storage) for the OpenShell provider credential boundary. diff --git a/src/lib/adapters/docker/image.ts b/src/lib/adapters/docker/image.ts index 743b9ec3765..a979f8adde3 100644 --- a/src/lib/adapters/docker/image.ts +++ b/src/lib/adapters/docker/image.ts @@ -11,6 +11,8 @@ import { } from "./run"; export type DockerBuildOptions = DockerRunOptions & { + buildArgs?: Record; + labels?: Record; quiet?: boolean; }; @@ -21,7 +23,7 @@ export function dockerBuild( contextDir: string = ROOT, opts: DockerBuildOptions = {}, ): DockerRunResult { - const { labels, quiet, ...rest } = opts; + const { buildArgs, labels, quiet, ...rest } = opts; // Dockerfile.base relies on `RUN --mount=type=bind`, which is BuildKit-only. // Hosts whose Docker daemon defaults to the legacy builder (e.g. fresh // Debian/Ubuntu Docker 29 without /etc/docker/daemon.json) abort the @@ -33,6 +35,10 @@ export function dockerBuild( const args = [ "build", ...(quiet ? ["--quiet"] : []), + + ...Object.entries(buildArgs ?? {}) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]), ...Object.entries(labels ?? {}) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .flatMap(([key, value]) => ["--label", `${key}=${value}`]), diff --git a/src/lib/adapters/docker/index.test.ts b/src/lib/adapters/docker/index.test.ts index a54f7774cae..4a1dde9a318 100644 --- a/src/lib/adapters/docker/index.test.ts +++ b/src/lib/adapters/docker/index.test.ts @@ -84,6 +84,30 @@ describe("docker helpers", () => { ); }); + it("adds sorted build args to dockerBuild argv and drops them from options", () => { + dockerBuild("Dockerfile.base", "sandbox-base:latest", "/repo/root", { + buildArgs: { Z_ARG: "last", A_ARG: "first" }, + ignoreError: true, + }); + + expect(runMock).toHaveBeenCalledWith( + [ + "docker", + "build", + "--build-arg", + "A_ARG=first", + "--build-arg", + "Z_ARG=last", + "-f", + "Dockerfile.base", + "-t", + "sandbox-base:latest", + "/repo/root", + ], + { ignoreError: true, env: { DOCKER_BUILDKIT: "1" } }, + ); + }); + it("adds sorted image labels to dockerBuild argv and drops them from options", () => { dockerBuild("Dockerfile.base", "sandbox-base:latest", "/repo/root", { labels: { "com.example.z": "last", "com.example.a": "first" }, diff --git a/src/lib/agent/base-image.test.ts b/src/lib/agent/base-image.test.ts index 4cfa05d850c..eb2d2375e74 100644 --- a/src/lib/agent/base-image.test.ts +++ b/src/lib/agent/base-image.test.ts @@ -6,6 +6,8 @@ import fs from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { makeAgent, withMockedDocker } from "../../../test/helpers/base-image-test-harness"; + +import { tmpDir, writeCa } from "../onboard/__test-helpers__/corporate-ca-fixtures"; import { testTimeout } from "../../../test/helpers/timeouts"; import { createSandboxBaseImageBuildProvenanceKey, @@ -50,6 +52,7 @@ function makeDifferingImageInspection( describe("agent base image provisioning", () => { beforeEach(() => { + vi.stubEnv("NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS", ""); vi.restoreAllMocks(); }); @@ -369,6 +372,67 @@ describe("agent base image provisioning", () => { }); }); + it("passes the resolved corporate CA into local agent base image builds (#8119)", () => { + vi.stubEnv("NEMOCLAW_CORPORATE_CA_BUNDLE", writeCa(tmpDir())); + withMockedDocker(({ ensureAgentBaseImage, dockerBuildMock, resolveSandboxBaseImageMock }) => { + resolveSandboxBaseImageMock.mockReturnValue({ + ref: "nemoclaw-dcode-sandbox-base-local:compatible", + digest: null, + source: "local", + glibcVersion: "2.41", + }); + + ensureAgentBaseImage( + makeAgent({ + name: "langchain-deepagents-code", + displayName: "LangChain Deep Agents Code", + expectedVersion: "0.1.34", + dockerfileBasePath: "/test/root/agents/langchain-deepagents-code/Dockerfile.base", + dockerfilePath: "/test/root/agents/langchain-deepagents-code/Dockerfile", + }), + { forceBaseImageRebuild: true }, + ); + + const options = dockerBuildMock.mock.calls[0]?.[3] as { + buildArgs?: Record; + }; + const encoded = options.buildArgs?.NEMOCLAW_CORPORATE_CA_B64; + expect(encoded).toBeTypeOf("string"); + expect(Buffer.from(encoded ?? "", "base64").toString("utf8")).toContain("BEGIN CERTIFICATE"); + }); + }); + + it("omits corporate CA build inputs when corporate CA import is disabled (#8119)", () => { + vi.stubEnv("NEMOCLAW_CORPORATE_CA_BUNDLE", writeCa(tmpDir())); + vi.stubEnv("NEMOCLAW_CORPORATE_CA_IMPORT", "0"); + withMockedDocker(({ ensureAgentBaseImage, dockerBuildMock, resolveSandboxBaseImageMock }) => { + resolveSandboxBaseImageMock.mockReturnValue({ + ref: "nemoclaw-dcode-sandbox-base-local:compatible", + digest: null, + source: "local", + glibcVersion: "2.41", + }); + + ensureAgentBaseImage( + makeAgent({ + name: "langchain-deepagents-code", + displayName: "LangChain Deep Agents Code", + expectedVersion: "0.1.34", + dockerfileBasePath: "/test/root/agents/langchain-deepagents-code/Dockerfile.base", + dockerfilePath: "/test/root/agents/langchain-deepagents-code/Dockerfile", + }), + { forceBaseImageRebuild: true }, + ); + + expect(resolveSandboxBaseImageMock).toHaveBeenCalledWith( + expect.objectContaining({ buildArgs: undefined }), + ); + expect(dockerBuildMock.mock.calls[0]?.[3]).toEqual( + expect.objectContaining({ buildArgs: undefined }), + ); + }); + }); + it("fails closed when the Deep Agents Code manifest omits its base-image version", () => { withMockedDocker(({ ensureAgentBaseImage, resolveSandboxBaseImageMock }) => { expect(() => diff --git a/src/lib/agent/base-image.ts b/src/lib/agent/base-image.ts index 8a6cd8d0083..cf196552dae 100644 --- a/src/lib/agent/base-image.ts +++ b/src/lib/agent/base-image.ts @@ -15,6 +15,8 @@ import { dockerTag, } from "../adapters/docker"; import { createCustomBuildContextFilter } from "../onboard/custom-build-context"; + +import { encodeCorporateCaArg, resolveCorporateCa } from "../onboard/corporate-ca"; import { ROOT } from "../runner"; import { SANDBOX_BUILD_CONTEXT_PREFIX } from "../sandbox/build-context"; import { @@ -42,6 +44,15 @@ import { import { createDeepAgentsCodeBaseImageResolutionOptions } from "./deep-agents-code-base-image"; import type { AgentDefinition } from "./defs"; +function corporateCaBuildArgs( + env: NodeJS.ProcessEnv = process.env, +): Record | undefined { + const corporateCa = resolveCorporateCa(env); + return corporateCa + ? { NEMOCLAW_CORPORATE_CA_B64: encodeCorporateCaArg(corporateCa.pem) } + : undefined; +} + const HERMES_MCP_RUNTIME_PROBE_OK = "nemoclaw-hermes-mcp-runtime-ok"; // Matches the official Hermes base repository for both Dockerfile manifest-list // pins and Docker-normalized platform manifest digests. @@ -263,6 +274,7 @@ function createAgentBaseImageResolutionOptions( return { imageName, dockerfilePath, + buildArgs: agent.name === "langchain-deepagents-code" ? corporateCaBuildArgs() : undefined, localTag: buildLocalBaseTag(`nemoclaw-${agent.name}-sandbox-base-local`, ROOT), envVar: getAgentSandboxBaseImageEnvVar(agent.name), label: `${agent.displayName} sandbox base image`, @@ -501,6 +513,8 @@ export function ensureAgentBaseImage( const buildProvenance = localBaseImageBuildProvenance(resolutionOptions); console.log(` Rebuilding ${agent.displayName} base image...`); const buildResult = dockerBuild(baseDockerfile, forceBuildTag, ROOT, { + buildArgs: resolutionOptions.buildArgs, + ignoreError: true, labels: buildProvenance.labels, stdio: ["ignore", "inherit", "inherit"], @@ -610,6 +624,8 @@ export function ensureAgentBaseImage( console.log(` Building ${agent.displayName} base image (first time only)...`); const buildProvenance = localBaseImageBuildProvenance(resolutionOptions); const buildResult = dockerBuild(baseDockerfile, baseImageTag, ROOT, { + buildArgs: resolutionOptions.buildArgs, + ignoreError: true, labels: buildProvenance.labels, stdio: ["ignore", "inherit", "inherit"], diff --git a/src/lib/sandbox-base-image-resolution.test.ts b/src/lib/sandbox-base-image-resolution.test.ts index 16cff52cf58..c47a12de865 100644 --- a/src/lib/sandbox-base-image-resolution.test.ts +++ b/src/lib/sandbox-base-image-resolution.test.ts @@ -71,6 +71,29 @@ function resolutionOptions() { }; } +function mockLocalFallback( + options: ReturnType, + provenance: string, +): void { + dockerMocks.imageInspect.mockImplementation((imageRef: string) => ({ + status: imageRef === options.localTag ? 0 : 1, + })); + dockerMocks.imageInspectFormat.mockReturnValue( + JSON.stringify({ + Id: IMAGE_ID, + RepoDigests: [], + Os: "linux", + Architecture: "amd64", + Config: { + Labels: { + [SANDBOX_BASE_BUILD_PROVENANCE_LABEL]: provenance, + }, + }, + }), + ); + dockerMocks.pull.mockReturnValue({ status: 1 }); +} + describe("sandbox base-image warm resolution", () => { beforeEach(() => { vi.clearAllMocks(); @@ -503,6 +526,86 @@ describe("sandbox base-image warm resolution", () => { }); }); + it("rebuilds a local fallback when corporate CA build inputs change (#8119)", () => { + const buildArgs = { NEMOCLAW_CORPORATE_CA_B64: "second-public-ca" }; + const options = { + ...resolutionOptions(), + buildArgs, + env: { + ...resolutionOptions().env, + NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD: "1", + }, + validateImage: () => true, + }; + const previousProvenance = `${createSandboxBaseImageBuildProvenanceKey({ + ...options, + buildArgs: { NEMOCLAW_CORPORATE_CA_B64: "first-public-ca" }, + })}.${"c".repeat(64)}`; + mockLocalFallback(options, previousProvenance); + dockerMocks.build.mockReturnValue({ status: 0 }); + + expect(resolveSandboxBaseImage(options)).toMatchObject({ source: "local" }); + expect(dockerMocks.build).toHaveBeenCalledWith( + options.dockerfilePath, + options.localTag, + options.rootDir, + expect.objectContaining({ + buildArgs, + labels: { + [SANDBOX_BASE_BUILD_PROVENANCE_LABEL]: expect.stringMatching( + new RegExp(`^${createSandboxBaseImageBuildProvenanceKey(options)}\\.[0-9a-f]{64}$`), + ), + }, + }), + ); + }); + + it("rebuilds a local fallback when the current build omits the previous corporate CA input (#8119)", () => { + const options = { + ...resolutionOptions(), + env: { + ...resolutionOptions().env, + NEMOCLAW_SANDBOX_BASE_LOCAL_BUILD: "1", + }, + validateImage: () => true, + }; + const previousProvenance = `${createSandboxBaseImageBuildProvenanceKey({ + ...options, + buildArgs: { NEMOCLAW_CORPORATE_CA_B64: "first-public-ca" }, + })}.${"c".repeat(64)}`; + mockLocalFallback(options, previousProvenance); + dockerMocks.build.mockReturnValue({ status: 0 }); + + expect(resolveSandboxBaseImage(options)).toMatchObject({ source: "local" }); + expect(dockerMocks.build).toHaveBeenCalledWith( + options.dockerfilePath, + options.localTag, + options.rootDir, + expect.objectContaining({ + buildArgs: undefined, + labels: { + [SANDBOX_BASE_BUILD_PROVENANCE_LABEL]: expect.stringMatching( + new RegExp(`^${createSandboxBaseImageBuildProvenanceKey(options)}\\.[0-9a-f]{64}$`), + ), + }, + }), + ); + }); + + it("reuses a local fallback with current build provenance (#8119)", () => { + const options = { + ...resolutionOptions(), + buildArgs: { NEMOCLAW_CORPORATE_CA_B64: "current-public-ca" }, + validateImage: () => true, + }; + const provenance = `${createSandboxBaseImageBuildProvenanceKey(options)}.${"c".repeat(64)}`; + mockLocalFallback(options, provenance); + + expect(resolveSandboxBaseImage(options)).toMatchObject({ source: "local" }); + expect(dockerMocks.build).not.toHaveBeenCalled(); + expect(traceMocks.add).toHaveBeenCalledWith("nemoclaw.sandbox_base_image.local_fallback_reuse"); + }); + it("fails closed instead of trusting an existing local tag when base inputs are dirty (#4680)", () => { sourceMocks.inputsDirty.mockReturnValue(true); dockerMocks.imageInspect.mockReturnValue({ status: 0 }); diff --git a/src/lib/sandbox-base-image.ts b/src/lib/sandbox-base-image.ts index fd786439179..d628490687c 100644 --- a/src/lib/sandbox-base-image.ts +++ b/src/lib/sandbox-base-image.ts @@ -95,6 +95,24 @@ function localBuildAllowed(env: NodeJS.ProcessEnv = process.env): boolean { return env.NODE_ENV !== "test" && env.VITEST !== "true"; } +function hasCurrentLocalBuildProvenance( + imageRef: string, + options: ResolveBaseImageOptions, +): boolean { + const inspected = inspectLocalImageMetadata(imageRef); + const labels = + inspected?.Config?.Labels && typeof inspected.Config.Labels === "object" + ? (inspected.Config.Labels as Record) + : {}; + const provenance = labels[SANDBOX_BASE_BUILD_PROVENANCE_LABEL]; + const expectedProvenance = createSandboxBaseImageBuildProvenanceKey(options); + return ( + typeof provenance === "string" && + provenance.startsWith(`${expectedProvenance}.`) && + /^[0-9a-f]{64}\.[0-9a-f]{64}$/.test(provenance) + ); +} + function getRepoDigest( imageName: string, imageRef: string, @@ -353,7 +371,7 @@ function resolveLocalCandidate( const imageRef = options.localTag; if (!forceBuild) { const inspectResult = dockerImageInspect(imageRef, { ignoreError: true, suppressOutput: true }); - if (inspectResult.status === 0) { + if (inspectResult.status === 0 && hasCurrentLocalBuildProvenance(imageRef, options)) { const check = options.requireOpenshellSandboxAbi ? imageMeetsMinimumGlibc(imageRef, options.minGlibcVersion || OPENSHELL_SANDBOX_MIN_GLIBC) : { ok: true, version: null }; @@ -378,6 +396,8 @@ function resolveLocalCandidate( // useful diagnostic. const buildResult = withLocalBuildHeartbeat(() => dockerBuild(options.dockerfilePath, imageRef, options.rootDir || ROOT, { + buildArgs: options.buildArgs, + labels: { [SANDBOX_BASE_BUILD_PROVENANCE_LABEL]: createSandboxBaseImageBuildProvenance(options), }, diff --git a/src/lib/sandbox-base-image/resolution-key.test.ts b/src/lib/sandbox-base-image/resolution-key.test.ts index a150c4785b1..0437283b450 100644 --- a/src/lib/sandbox-base-image/resolution-key.test.ts +++ b/src/lib/sandbox-base-image/resolution-key.test.ts @@ -64,6 +64,21 @@ describe("sandbox base-image resolution key", () => { expect(createSandboxBaseImageResolutionKey(options(root))).not.toBe(before); }); + it("isolates build args without exposing their values (#8119)", () => { + const root = fixture(); + const base = options(root); + const first = createSandboxBaseImageResolutionKey({ + ...base, + buildArgs: { NEMOCLAW_CORPORATE_CA_B64: "first-public-ca" }, + }); + const second = createSandboxBaseImageResolutionKey({ + ...base, + buildArgs: { NEMOCLAW_CORPORATE_CA_B64: "second-public-ca" }, + }); + + expect(second).not.toBe(first); + }); + it("changes when a Dockerfile-copied runtime helper changes", () => { const root = fixture(); const helper = path.join(root, "scripts", "lib", "sandbox-rlimits.sh"); diff --git a/src/lib/sandbox-base-image/resolution-key.ts b/src/lib/sandbox-base-image/resolution-key.ts index ffbdfcd6f58..dce8be9cd8e 100644 --- a/src/lib/sandbox-base-image/resolution-key.ts +++ b/src/lib/sandbox-base-image/resolution-key.ts @@ -40,6 +40,20 @@ function hashBaseImageInputs( return hash.digest("hex"); } +function hashBuildArgs(buildArgs: Record | undefined): string | null { + if (!buildArgs || Object.keys(buildArgs).length === 0) return null; + const hash = crypto.createHash("sha256"); + for (const [key, value] of Object.entries(buildArgs).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + )) { + hash.update(key); + hash.update("\0"); + hash.update(value); + hash.update("\0"); + } + return hash.digest("hex"); +} + function dockerPlatform(): string { const reported = dockerInfoFormat("{{.OSType}}/{{.Architecture}}", { ignoreError: true, @@ -56,6 +70,8 @@ export function createSandboxBaseImageBuildProvenanceKey(options: ResolveBaseIma imageName: options.imageName, sourceRevisions: getSourceRevisionIds(rootDir, env), inputFingerprint: hashBaseImageInputs(rootDir, options.dockerfilePath, options.inputPaths), + + buildArgsFingerprint: hashBuildArgs(options.buildArgs), }; return crypto.createHash("sha256").update(JSON.stringify(material)).digest("hex"); } @@ -79,6 +95,8 @@ export function createSandboxBaseImageResolutionKey(options: ResolveBaseImageOpt sourceTags: getSourceShortShaTags(rootDir, env), localTag: options.localTag, inputFingerprint: hashBaseImageInputs(rootDir, options.dockerfilePath, options.inputPaths), + + buildArgsFingerprint: hashBuildArgs(options.buildArgs), platform: dockerPlatform(), requireOpenshellSandboxAbi: options.requireOpenshellSandboxAbi === true, minGlibcVersion: options.minGlibcVersion || OPENSHELL_SANDBOX_MIN_GLIBC, diff --git a/src/lib/sandbox-base-image/types.ts b/src/lib/sandbox-base-image/types.ts index b84b31c00dd..4f81f6faa8d 100644 --- a/src/lib/sandbox-base-image/types.ts +++ b/src/lib/sandbox-base-image/types.ts @@ -40,6 +40,8 @@ export type ResolveBaseImageOptions = { imageName: string; dockerfilePath: string; inputPaths?: string[]; + + buildArgs?: Record; localTag: string; envVar?: string; label?: string; diff --git a/test/corporate-ca-build-tls-anchor.test.ts b/test/corporate-ca-build-tls-anchor.test.ts index 5f37b482ff0..9fb48520bd5 100644 --- a/test/corporate-ca-build-tls-anchor.test.ts +++ b/test/corporate-ca-build-tls-anchor.test.ts @@ -38,3 +38,114 @@ describe("corporate proxy CA build-time TLS anchor (#6839)", () => { expect(anchorIndex).toBeLessThan(auditSignaturesIndex); }); }); + +describe("DCode corporate proxy CA cold-build trust (#8119)", () => { + const baseDockerfile = readFileSync( + join(import.meta.dirname, "../agents/langchain-deepagents-code/Dockerfile.base"), + "utf-8", + ); + const finalDockerfile = readFileSync( + join(import.meta.dirname, "../agents/langchain-deepagents-code/Dockerfile"), + "utf-8", + ); + + // source-shape-contract: security -- DCode cold base builds must establish corporate CA trust before HTTPS dependency fetches + it("accepts the corporate CA build arg in the DCode base image before HTTPS fetches", () => { + const argIndex = baseDockerfile.indexOf("ARG NEMOCLAW_CORPORATE_CA_B64="); + const nativeBuilderIndex = baseDockerfile.indexOf("AS native-security-builder", argIndex); + const nativeArgIndex = baseDockerfile.indexOf( + "ARG NEMOCLAW_CORPORATE_CA_B64", + nativeBuilderIndex, + ); + const nativeTrustIndex = baseDockerfile.indexOf("update-ca-certificates", nativeArgIndex); + const nativeFetchIndex = baseDockerfile.indexOf( + "build-native-security-packages.sh /out", + nativeTrustIndex, + ); + const perlFetchIndex = baseDockerfile.indexOf( + "RUN bash /scripts/security/build-perl-security-packages.sh", + nativeFetchIndex, + ); + const finalFromIndex = baseDockerfile.indexOf("FROM node:22-trixie-slim", perlFetchIndex); + const finalArgIndex = baseDockerfile.indexOf("ARG NEMOCLAW_CORPORATE_CA_B64", finalFromIndex); + const finalTrustIndex = baseDockerfile.indexOf("update-ca-certificates", finalArgIndex); + const firstSnapshotCurlIndex = baseDockerfile.indexOf( + "https://snapshot.debian.org/archive", + finalTrustIndex, + ); + + for (const [name, index] of Object.entries({ + argIndex, + nativeBuilderIndex, + nativeArgIndex, + nativeTrustIndex, + nativeFetchIndex, + perlFetchIndex, + finalFromIndex, + finalArgIndex, + finalTrustIndex, + firstSnapshotCurlIndex, + })) { + expect(index, name).toBeGreaterThan(-1); + } + expect(nativeArgIndex).toBeLessThan(nativeTrustIndex); + expect(nativeTrustIndex).toBeLessThan(nativeFetchIndex); + expect(nativeFetchIndex).toBeLessThan(perlFetchIndex); + expect(finalArgIndex).toBeLessThan(finalTrustIndex); + expect(finalTrustIndex).toBeLessThan(firstSnapshotCurlIndex); + }); + + // source-shape-contract: security -- DCode discovery npm installs must trust the host corporate CA before registry access + it("trusts the corporate CA before the DCode discovery runtime npm install", () => { + const discoveryStageIndex = finalDockerfile.indexOf("AS mcp-tool-discovery-runtime"); + const discoveryArgIndex = finalDockerfile.indexOf( + "ARG NEMOCLAW_CORPORATE_CA_B64", + discoveryStageIndex, + ); + const discoveryTrustIndex = finalDockerfile.indexOf( + "export NODE_EXTRA_CA_CERTS=/tmp/nemoclaw-corporate-ca.pem", + discoveryArgIndex, + ); + const discoveryInstallIndex = finalDockerfile.indexOf( + "./install-reviewed-runtime.sh", + discoveryTrustIndex, + ); + + expect(discoveryStageIndex).toBeGreaterThan(-1); + expect(discoveryArgIndex).toBeGreaterThan(discoveryStageIndex); + expect(discoveryTrustIndex).toBeGreaterThan(discoveryArgIndex); + expect(discoveryInstallIndex).toBeGreaterThan(discoveryTrustIndex); + }); + + // source-shape-contract: security -- DCode final images must decode the sandbox-specific corporate CA even when the base is reused + it("decodes the corporate CA again in the DCode final image", () => { + const finalFromIndex = finalDockerfile.indexOf("FROM ${BASE_IMAGE}"); + const finalArgIndex = finalDockerfile.indexOf("ARG NEMOCLAW_CORPORATE_CA_B64", finalFromIndex); + const finalDecodeIndex = finalDockerfile.indexOf( + 'RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then', + finalArgIndex, + ); + const trustDirectoryIndex = finalDockerfile.indexOf( + "install -d -o root -g root -m 0755 /usr/local/share/nemoclaw", + finalDecodeIndex, + ); + const runtimeProbeIndex = finalDockerfile.indexOf( + "mcp-tool-discovery-runtime", + finalDecodeIndex, + ); + + for (const [name, index] of Object.entries({ + finalFromIndex, + finalArgIndex, + finalDecodeIndex, + trustDirectoryIndex, + runtimeProbeIndex, + })) { + expect(index, name).toBeGreaterThan(-1); + } + expect(finalFromIndex).toBeLessThan(finalArgIndex); + expect(finalArgIndex).toBeLessThan(finalDecodeIndex); + expect(finalDecodeIndex).toBeLessThan(trustDirectoryIndex); + expect(trustDirectoryIndex).toBeLessThan(runtimeProbeIndex); + }); +}); diff --git a/test/corporate-ca-dockerfile-decode.test.ts b/test/corporate-ca-dockerfile-decode.test.ts index 6a19803b6e1..2cc6e2eec2f 100644 --- a/test/corporate-ca-dockerfile-decode.test.ts +++ b/test/corporate-ca-dockerfile-decode.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { LEAF_PEM } from "../src/lib/onboard/__test-helpers__/corporate-ca-fixtures"; import { hasGnuBase64Decode, hasOpenssl, @@ -48,6 +49,12 @@ J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= const DOCKERFILES = [ ["OpenClaw", join(import.meta.dirname, "../Dockerfile")], ["Hermes", join(import.meta.dirname, "../agents/hermes/Dockerfile")], + ["Deep Agents Code", join(import.meta.dirname, "../agents/langchain-deepagents-code/Dockerfile")], +] as const; + +const DEEP_AGENTS_DOCKERFILES = [ + join(import.meta.dirname, "../agents/langchain-deepagents-code/Dockerfile"), + join(import.meta.dirname, "../agents/langchain-deepagents-code/Dockerfile.base"), ] as const; const tmpRoots: string[] = []; @@ -64,6 +71,30 @@ afterEach(() => { } }); +describe("Deep Agents Code Dockerfile corporate CA validation", () => { + it("requires CA:TRUE at every X.509 certificate parser (#8119)", () => { + for (const dockerfile of DEEP_AGENTS_DOCKERFILES) { + const source = readFileSync(dockerfile, "utf8"); + const parsedCertificates = source.match(/new X509Certificate\(/g) ?? []; + const caChecks = source.match(/new X509Certificate\([^)]*\)\.ca/g) ?? []; + expect(parsedCertificates.length).toBeGreaterThan(0); + expect(caChecks).toHaveLength(parsedCertificates.length); + } + }); +}); + +describe.skipIf(!canRunDecodeBlock)("Deep Agents Code corporate CA constraint", () => { + it("rejects a valid certificate without CA:TRUE (#8119)", () => { + const res = runDockerfileCorporateCaDecode( + DEEP_AGENTS_DOCKERFILES[0], + Buffer.from(LEAF_PEM).toString("base64"), + tmpDir(), + ); + expect(res.status).not.toBe(0); + expect(res.stderr).toContain("basicConstraints CA:TRUE"); + }); +}); + for (const [label, dockerfile] of DOCKERFILES) { describe.skipIf(!canRunDecodeBlock)( `corporate CA Dockerfile decode guard — ${label} (#6210)`, diff --git a/test/helpers/corporate-ca-support.ts b/test/helpers/corporate-ca-support.ts index e467c5f6bfc..4eebb6dc8c8 100644 --- a/test/helpers/corporate-ca-support.ts +++ b/test/helpers/corporate-ca-support.ts @@ -283,9 +283,13 @@ export function runDockerfileCorporateCaDecode( // Redirect the fixed /tmp decode scratch path into the per-test dir so // concurrent test runs never collide. .replaceAll("/tmp/nemoclaw-corporate-ca.decoded", path.join(outDir, "decoded")) - // Root ownership requires root; the test only exercises the base64/cert - // guards, so chown to the current user keeps the shipped fail-fast `&&` - // chain intact while running unprivileged. + // Root ownership requires root. The contract test preserves the exact + // production command; this extracted-script test substitutes the current + // user and group so the certificate guards run unprivileged. + .replaceAll( + "install -d -o root -g root -m 0755", + 'install -d -o "$(id -u)" -g "$(id -g)" -m 0755', + ) .replaceAll("chown root:root", 'chown "$(id -u):$(id -g)"'); const wrapper = path.join(outDir, "decode.sh"); fs.writeFileSync(