From 41c8fe7725d19336edbf0ff80bf2b4d8c4e2a68e Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Mon, 6 Jul 2026 03:48:49 +0000 Subject: [PATCH 01/26] feat(sandbox): import host corporate proxy CA into sandbox trust (#6210) On networks where a corporate MITM proxy sits in front of the host and re-signs external TLS with its own root, that root is absent from the sandbox trust path. OpenShell injects only its own L7-proxy CA, so external endpoints (e.g. api.telegram.org) fail verification even when the network policy allows the connection (NET:OPEN then NET:FAIL). Import an operator-supplied corporate CA without replacing the OpenShell CA: - Add src/lib/onboard/corporate-ca.ts: validate a host CA bundle (regular file, non-symlink, non-world-writable, bounded size, PEM) from NEMOCLAW_CORPORATE_CA_BUNDLE (explicit, fail-loud) or the conventional REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE / SSL_CERT_FILE fallbacks (skip-on-invalid). Opt out with NEMOCLAW_CORPORATE_CA_IMPORT=0. - Bake it via a base64 NEMOCLAW_CORPORATE_CA_B64 build arg decoded to a root-owned 0444 file in the OpenClaw and Hermes Dockerfiles. - At entrypoint startup, append the baked CA to the OpenShell bundle into a merged /tmp bundle (never replacing the OpenShell CA, preserving #1828) and repoint SSL_CERT_FILE, CURL_CA_BUNDLE, REQUESTS_CA_BUNDLE, GIT_SSL_CAINFO, and NODE_EXTRA_CA_CERTS at it, including connect sessions. Tests: host CA validation unit tests, dockerfile-patch baking, runtime merge for both entrypoints, a simulated-MITM TLS test proving a corporate-CA-signed endpoint verifies only after the merge while the OpenShell root stays trusted, and troubleshooting docs. Signed-off-by: Yimo Jiang --- Dockerfile | 19 ++ agents/hermes/Dockerfile | 20 ++ agents/hermes/start.sh | 37 +++- docs/reference/troubleshooting.mdx | 13 ++ scripts/nemoclaw-start.sh | 49 +++++ src/lib/onboard/corporate-ca.test.ts | 145 +++++++++++++++ src/lib/onboard/corporate-ca.ts | 160 ++++++++++++++++ src/lib/onboard/dockerfile-patch.test.ts | 101 ++++++++++ src/lib/onboard/dockerfile-patch.ts | 13 ++ test/corporate-ca-runtime-merge.test.ts | 201 ++++++++++++++++++++ test/corporate-ca-tls-e2e.test.ts | 223 +++++++++++++++++++++++ 11 files changed, 980 insertions(+), 1 deletion(-) create mode 100644 src/lib/onboard/corporate-ca.test.ts create mode 100644 src/lib/onboard/corporate-ca.ts create mode 100644 test/corporate-ca-runtime-merge.test.ts create mode 100644 test/corporate-ca-tls-e2e.test.ts diff --git a/Dockerfile b/Dockerfile index 8e670b0699a..23bfc8e6875 100644 --- a/Dockerfile +++ b/Dockerfile @@ -892,6 +892,12 @@ ARG NEMOCLAW_OPENCLAW_OTEL=0 ARG NEMOCLAW_OPENCLAW_OTEL_ENDPOINT=http://host.openshell.internal:4318 ARG NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=openclaw-gateway ARG NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=1.0 +# Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When +# onboard detects an operator-supplied corporate CA on the host it bakes it +# here; the RUN below decodes it to a root-owned file that the entrypoint +# appends to the OpenShell trust bundle at runtime. The CA is a public +# certificate, not a secret, so baking it into an image layer is acceptable. +ARG NEMOCLAW_CORPORATE_CA_B64= # SECURITY: Promote build-args to env vars so the TypeScript script reads them # via process.env, never via string interpolation into executable source code. @@ -924,6 +930,19 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=${NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME} \ NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=${NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE} +# Decode the host corporate-proxy CA (#6210) to a root-owned, read-only file +# when onboard baked one in. No-op when NEMOCLAW_CORPORATE_CA_B64 is empty. The +# ARG is expanded by the shell (not interpolated into source), and its value is +# base64 sanitized host-side, so this is not an injection vector. +# hadolint ignore=DL3059,DL4006 +RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ + mkdir -p /usr/local/share/nemoclaw \ + && printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 -d > /usr/local/share/nemoclaw/corporate-ca.pem \ + && 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 image trust (#6210)"; \ + fi + # Bake reduced messaging runtime metadata for the entrypoint. The full # NEMOCLAW_MESSAGING_PLAN_B64 is a build input; OpenShell sandbox create only # forwards explicit runtime env, so nemoclaw-start reads this generic artifact diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index f804e4dd1d1..95dac6624e6 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -233,6 +233,12 @@ ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0 ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10= ARG NEMOCLAW_BUILD_ID=default ARG NEMOCLAW_DARWIN_VM_COMPAT=0 +# Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When +# onboard detects an operator-supplied corporate CA on the host it bakes it +# here; the RUN below decodes it to a root-owned file that the entrypoint +# appends to the OpenShell trust bundle at runtime. The CA is a public +# certificate, not a secret, so baking it into an image layer is acceptable. +ARG NEMOCLAW_CORPORATE_CA_B64= # Promote build-args to env vars for the config generation script. ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ @@ -262,6 +268,20 @@ WORKDIR /opt/hermes # hadolint ignore=DL3059 RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent hermes --phase agent-install +# Decode the host corporate-proxy CA (#6210) to a root-owned, read-only file +# when onboard baked one in. No-op when NEMOCLAW_CORPORATE_CA_B64 is empty. The +# ARG is expanded by the shell (not interpolated into source), and its value is +# base64 sanitized host-side, so this is not an injection vector. Must run as +# root, before the USER sandbox drop below. +# hadolint ignore=DL3059,DL4006 +RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ + mkdir -p /usr/local/share/nemoclaw \ + && printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 -d > /usr/local/share/nemoclaw/corporate-ca.pem \ + && 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 image trust (#6210)"; \ + fi + WORKDIR /sandbox USER sandbox diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 8955d266ff2..1411e61ceed 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1403,6 +1403,41 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" +# Corporate proxy CA merge (NemoClaw#6210). +# OpenShell injects SSL_CERT_FILE for its own L7 proxy CA at runtime. When a +# separate corporate MITM proxy sits in front of the host and re-signs external +# TLS with a different root, that root is absent from the OpenShell bundle, so +# external endpoints (e.g. api.telegram.org) fail verification even when policy +# allows the connection. If onboard baked an operator-supplied corporate CA +# into the image, append it to the OpenShell bundle — never replace it (the +# #1828 OpenShell CA behavior stays intact) — and repoint SSL_CERT_FILE at the +# merged bundle before the CURL/REQUESTS/GIT derivation below picks it up. +_NEMOCLAW_CORPORATE_CA_FILE="/usr/local/share/nemoclaw/corporate-ca.pem" +merge_corporate_proxy_ca() { + [ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0 + _base_bundle="" + if [ -n "${SSL_CERT_FILE:-}" ] && [ -f "${SSL_CERT_FILE}" ]; then + _base_bundle="$SSL_CERT_FILE" + elif [ -f /etc/ssl/certs/ca-certificates.crt ]; then + _base_bundle="/etc/ssl/certs/ca-certificates.crt" + fi + _merged="/tmp/nemoclaw-ca-bundle.pem" + # Remove any stale (0444) bundle first so a re-invocation can rewrite it. + rm -f "$_merged" 2>/dev/null || true + : >"$_merged" 2>/dev/null || return 0 + if [ -n "$_base_bundle" ]; then + cat "$_base_bundle" >>"$_merged" 2>/dev/null || true + printf '\n' >>"$_merged" 2>/dev/null || true + fi + cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_merged" 2>/dev/null || true + chmod 0444 "$_merged" 2>/dev/null || true + export SSL_CERT_FILE="$_merged" + export NODE_EXTRA_CA_CERTS="$_merged" + export _NEMOCLAW_CORPORATE_CA_MERGED=1 + echo "[nemoclaw] merged corporate proxy CA into sandbox trust bundle (#6210)" >&2 +} +merge_corporate_proxy_ca + # OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA. Persist # them into connect-session shells so Python Slack probes and Hermes tools trust # the same proxy CA that the entrypoint received at startup. @@ -1444,7 +1479,7 @@ if [ -f /opt/hermes/ui-tui/dist/entry.js ]; then export HERMES_TUI_DIR="/opt/hermes/ui-tui" fi TUIENVEOF - for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO; do + for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do _ca_env_value="${!_ca_env_name:-}" if [ -n "$_ca_env_value" ]; then printf 'export %s=%q\n' "$_ca_env_name" "$_ca_env_value" diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 861ffc077ee..1bfafa2e393 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -972,6 +972,19 @@ If they are missing on an older sandbox, upgrade NemoClaw and run: $$nemoclaw rebuild ``` +### External channel TLS fails behind a corporate MITM proxy (`NET:FAIL`) + +On networks where a corporate proxy in front of the host re-signs external TLS with its own root CA, external endpoints such as `api.telegram.org` fail certificate verification even when the network policy allows the connection. Logs show the request opening (`NET:OPEN ... api.telegram.org:443`) followed by `NET:FAIL`. OpenShell injects only its own L7-proxy CA into the sandbox, so the separate corporate root is missing from the trust path. + +To fix this, point NemoClaw at your corporate CA bundle on the host **before** onboarding, then onboard (or rebuild). NemoClaw validates the bundle, bakes it into the sandbox image, and at startup appends it to the OpenShell trust bundle — it never replaces the OpenShell CA — repointing `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 all trust both roots: + +```bash +export NEMOCLAW_CORPORATE_CA_BUNDLE=/path/to/corporate-ca.pem +$$nemoclaw onboard +``` + +`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `SSL_CERT_FILE` are also honored as fallbacks (in that order) when `NEMOCLAW_CORPORATE_CA_BUNDLE` is unset, so an environment that already exports one of those for the corporate proxy works without extra configuration. The bundle must be a readable, non-symlink, non-world-writable PEM file that contains at least one certificate. To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. + ### A request inside the sandbox fails with `CONNECT tunnel failed, response 403` Sandbox outbound network access is denied by default and enforced by the OpenShell proxy. diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e246fc56c4e..80aa0742ce8 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2713,6 +2713,44 @@ export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" +# Corporate proxy CA merge (NemoClaw#6210). +# OpenShell injects SSL_CERT_FILE for its own L7 proxy CA at runtime. When a +# separate corporate MITM proxy sits in front of the host and re-signs external +# TLS with a different root, that root is absent from the OpenShell bundle, so +# external endpoints (e.g. api.telegram.org) fail verification even when policy +# allows the connection. If onboard baked an operator-supplied corporate CA +# into the image, append it to the OpenShell bundle — never replace it (the +# #1828 OpenShell CA behavior stays intact) — and repoint the CA env vars at +# the merged bundle so curl/python/git/node all trust both roots. +_NEMOCLAW_CORPORATE_CA_FILE="/usr/local/share/nemoclaw/corporate-ca.pem" +merge_corporate_proxy_ca() { + [ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0 + _base_bundle="" + if [ -n "${SSL_CERT_FILE:-}" ] && [ -f "${SSL_CERT_FILE}" ]; then + _base_bundle="$SSL_CERT_FILE" + elif [ -f /etc/ssl/certs/ca-certificates.crt ]; then + _base_bundle="/etc/ssl/certs/ca-certificates.crt" + fi + _merged="/tmp/nemoclaw-ca-bundle.pem" + # Remove any stale (0444) bundle first so a re-invocation can rewrite it. + rm -f "$_merged" 2>/dev/null || true + : >"$_merged" 2>/dev/null || return 0 + if [ -n "$_base_bundle" ]; then + cat "$_base_bundle" >>"$_merged" 2>/dev/null || true + printf '\n' >>"$_merged" 2>/dev/null || true + fi + cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_merged" 2>/dev/null || true + chmod 0444 "$_merged" 2>/dev/null || true + export SSL_CERT_FILE="$_merged" + export CURL_CA_BUNDLE="$_merged" + export REQUESTS_CA_BUNDLE="$_merged" + export GIT_SSL_CAINFO="$_merged" + export NODE_EXTRA_CA_CERTS="$_merged" + export _NEMOCLAW_CORPORATE_CA_MERGED=1 + echo "[nemoclaw] merged corporate proxy CA into sandbox trust bundle (#6210)" >&2 +} +merge_corporate_proxy_ca + # Git TLS CA bundle fix (NemoClaw#2270). # OpenShell's L7 proxy does MITM TLS termination and re-signs with its own CA. # OpenShell injects SSL_CERT_FILE and CURL_CA_BUNDLE pointing at the CA bundle, @@ -3277,6 +3315,17 @@ GUARDENVEOF if [ -n "${GIT_SSL_CAINFO:-}" ]; then printf 'export GIT_SSL_CAINFO=%q\n' "$GIT_SSL_CAINFO" fi + # Corporate proxy CA for connect sessions (NemoClaw#6210). Only when a + # corporate CA was merged at entrypoint startup; keeps the no-corporate-CA + # path byte-for-byte identical so #1828 behavior is untouched. + if [ "${_NEMOCLAW_CORPORATE_CA_MERGED:-}" = "1" ]; then + for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE NODE_EXTRA_CA_CERTS; do + _ca_env_value="${!_ca_env_name:-}" + if [ -n "$_ca_env_value" ]; then + printf 'export %s=%q\n' "$_ca_env_name" "$_ca_env_value" + fi + done + fi # Nemotron inference fix for connect sessions. (NemoClaw#1193, #2051) echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_NEMOTRON_FIX_SCRIPT\"" # Seccomp guard for connect sessions. diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts new file mode 100644 index 00000000000..e04d82ba3c8 --- /dev/null +++ b/src/lib/onboard/corporate-ca.test.ts @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + CORPORATE_CA_DISABLE_ENV, + CORPORATE_CA_EXPLICIT_ENV, + CorporateCaValidationError, + encodeCorporateCaArg, + MAX_CORPORATE_CA_BYTES, + resolveCorporateCaFromEnv, + validateCorporateCaFile, +} from "./corporate-ca"; + +const PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n"; +const tmpRoots: string[] = []; + +function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-test-")); + tmpRoots.push(dir); + return dir; +} + +function writeCa(dir: string, contents = PEM, mode = 0o644): string { + const p = path.join(dir, "corp-ca.pem"); + fs.writeFileSync(p, contents, { mode }); + fs.chmodSync(p, mode); + return p; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("validateCorporateCaFile", () => { + it("returns PEM text for a valid regular file", () => { + const p = writeCa(tmpDir()); + expect(validateCorporateCaFile(p)).toContain("BEGIN CERTIFICATE"); + }); + + it("rejects a missing file", () => { + expect(() => validateCorporateCaFile(path.join(tmpDir(), "nope.pem"))).toThrow( + CorporateCaValidationError, + ); + }); + + it("rejects a symlink", () => { + const dir = tmpDir(); + const real = writeCa(dir); + const link = path.join(dir, "link.pem"); + fs.symlinkSync(real, link); + expect(() => validateCorporateCaFile(link)).toThrow(/must not be a symlink/); + }); + + it("rejects a directory", () => { + expect(() => validateCorporateCaFile(tmpDir())).toThrow(/not a regular file/); + }); + + it("rejects an empty file", () => { + const p = writeCa(tmpDir(), ""); + expect(() => validateCorporateCaFile(p)).toThrow(/is empty/); + }); + + it("rejects an oversized file", () => { + const p = writeCa(tmpDir(), `${PEM}${"A".repeat(MAX_CORPORATE_CA_BYTES)}`); + expect(() => validateCorporateCaFile(p)).toThrow(/exceeds/); + }); + + it("rejects a world-writable file", () => { + const p = writeCa(tmpDir(), PEM, 0o666); + expect(() => validateCorporateCaFile(p)).toThrow(/world-writable/); + }); + + it("rejects a file without a PEM certificate block", () => { + const p = writeCa(tmpDir(), "not a certificate\n"); + expect(() => validateCorporateCaFile(p)).toThrow(/no PEM CERTIFICATE block/); + }); +}); + +describe("resolveCorporateCaFromEnv", () => { + it("returns null when no CA env is set", () => { + expect(resolveCorporateCaFromEnv({})).toBeNull(); + }); + + it("resolves the explicit env var first", () => { + const p = writeCa(tmpDir()); + const resolved = resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: p }); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_EXPLICIT_ENV); + expect(resolved?.pem).toContain("BEGIN CERTIFICATE"); + }); + + it("throws when the explicit env var points at an invalid file", () => { + expect(() => + resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: "/does/not/exist.pem" }), + ).toThrow(CorporateCaValidationError); + }); + + it("falls back to REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE", () => { + const p = writeCa(tmpDir()); + expect(resolveCorporateCaFromEnv({ REQUESTS_CA_BUNDLE: p })?.sourceEnv).toBe( + "REQUESTS_CA_BUNDLE", + ); + expect(resolveCorporateCaFromEnv({ CURL_CA_BUNDLE: p })?.sourceEnv).toBe("CURL_CA_BUNDLE"); + }); + + it("skips an invalid fallback env var silently and tries the next", () => { + const p = writeCa(tmpDir()); + const resolved = resolveCorporateCaFromEnv({ + REQUESTS_CA_BUNDLE: "/does/not/exist.pem", + CURL_CA_BUNDLE: p, + }); + expect(resolved?.sourceEnv).toBe("CURL_CA_BUNDLE"); + }); + + it("returns null when every fallback env var is invalid", () => { + expect( + resolveCorporateCaFromEnv({ REQUESTS_CA_BUNDLE: "/missing.pem", SSL_CERT_FILE: "/nope.pem" }), + ).toBeNull(); + }); + + it("honors the disable opt-out", () => { + const p = writeCa(tmpDir()); + expect( + resolveCorporateCaFromEnv({ + [CORPORATE_CA_EXPLICIT_ENV]: p, + [CORPORATE_CA_DISABLE_ENV]: "0", + }), + ).toBeNull(); + }); +}); + +describe("encodeCorporateCaArg", () => { + it("produces single-line base64 that round-trips", () => { + const encoded = encodeCorporateCaArg(PEM); + expect(encoded).not.toMatch(/[\r\n]/); + expect(Buffer.from(encoded, "base64").toString("utf8")).toBe(PEM); + }); +}); diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts new file mode 100644 index 00000000000..ef74c859559 --- /dev/null +++ b/src/lib/onboard/corporate-ca.ts @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +/** + * Host corporate-proxy CA import (#6210). + * + * OpenShell injects its own L7-proxy CA into the sandbox at runtime + * (`SSL_CERT_FILE` / `/etc/openshell-tls/ca-bundle.pem`). When a *separate* + * corporate MITM proxy sits in front of the host and re-signs external TLS with + * a different root, that corporate root is absent from the sandbox trust path, + * so external endpoints (e.g. `api.telegram.org`) fail verification even though + * the network policy allows the connection. + * + * This module validates an operator-supplied corporate CA bundle on the host + * and encodes it so onboard can bake it into the sandbox image. The entrypoint + * then *appends* it to the OpenShell trust bundle at runtime — never replacing + * the OpenShell CA (preserving the #1828 behavior). + */ + +/** + * Env vars inspected for a corporate CA bundle, in priority order. + * + * `NEMOCLAW_CORPORATE_CA_BUNDLE` is the explicit opt-in: when it is set but + * invalid we fail the build loudly. The remaining three are conventional CA + * env vars the reporter already exports for their corporate proxy; when one of + * those points at a missing/invalid file we skip it silently rather than break + * an onboard that never asked for a corporate CA. + */ +export const CORPORATE_CA_EXPLICIT_ENV = "NEMOCLAW_CORPORATE_CA_BUNDLE"; +export const CORPORATE_CA_FALLBACK_ENV_VARS = [ + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", +] as const; + +/** Opt-out: set to a falsey token to disable corporate CA import entirely. */ +export const CORPORATE_CA_DISABLE_ENV = "NEMOCLAW_CORPORATE_CA_IMPORT"; + +/** Upper bound on an accepted CA bundle. A PEM trust store is a few KiB. */ +export const MAX_CORPORATE_CA_BYTES = 512 * 1024; + +const PEM_CERTIFICATE_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/; + +export interface ResolvedCorporateCa { + /** Validated PEM text of the corporate CA bundle. */ + pem: string; + /** Absolute-or-relative path the CA was read from. */ + sourcePath: string; + /** Env var the path came from. */ + sourceEnv: string; +} + +export class CorporateCaValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "CorporateCaValidationError"; + } +} + +/** + * Validate a candidate corporate CA bundle file and return its PEM text. + * + * Rejects symlinks, non-regular files, empty/oversized files, and + * world-writable sources; requires at least one PEM CERTIFICATE block. + */ +export function validateCorporateCaFile(filePath: string): string { + let stat: fs.Stats; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new CorporateCaValidationError(`corporate CA bundle not found: ${filePath}`); + } + if (stat.isSymbolicLink()) { + throw new CorporateCaValidationError(`corporate CA bundle must not be a symlink: ${filePath}`); + } + if (!stat.isFile()) { + throw new CorporateCaValidationError(`corporate CA bundle is not a regular file: ${filePath}`); + } + if (stat.size === 0) { + throw new CorporateCaValidationError(`corporate CA bundle is empty: ${filePath}`); + } + if (stat.size > MAX_CORPORATE_CA_BYTES) { + throw new CorporateCaValidationError( + `corporate CA bundle exceeds ${MAX_CORPORATE_CA_BYTES} bytes: ${filePath}`, + ); + } + // Refuse a source any other local user could tamper with before the build. + if ((stat.mode & 0o002) !== 0) { + throw new CorporateCaValidationError( + `corporate CA bundle must not be world-writable: ${filePath}`, + ); + } + const content = fs.readFileSync(filePath, "utf8"); + if (!PEM_CERTIFICATE_RE.test(content)) { + throw new CorporateCaValidationError( + `corporate CA bundle contains no PEM CERTIFICATE block: ${filePath}`, + ); + } + return content; +} + +function isDisabled(env: NodeJS.ProcessEnv): boolean { + const raw = env[CORPORATE_CA_DISABLE_ENV]; + if (raw === undefined) return false; + switch (raw.trim().toLowerCase()) { + case "0": + case "false": + case "no": + case "off": + return true; + default: + return false; + } +} + +/** + * Resolve a corporate CA bundle from the host environment. + * + * Returns `null` when no corporate CA is configured (or import is disabled). + * Throws {@link CorporateCaValidationError} only when the *explicit* + * `NEMOCLAW_CORPORATE_CA_BUNDLE` is set to an invalid path; invalid fallback + * env vars are skipped silently. + */ +export function resolveCorporateCaFromEnv( + env: NodeJS.ProcessEnv = process.env, +): ResolvedCorporateCa | null { + if (isDisabled(env)) return null; + + const explicit = env[CORPORATE_CA_EXPLICIT_ENV]; + if (explicit && explicit.trim()) { + const sourcePath = explicit.trim(); + // Explicit request: surface validation failures instead of silently + // building an image that cannot verify external TLS. + const pem = validateCorporateCaFile(sourcePath); + return { pem, sourcePath, sourceEnv: CORPORATE_CA_EXPLICIT_ENV }; + } + + for (const name of CORPORATE_CA_FALLBACK_ENV_VARS) { + const value = env[name]; + if (!value || !value.trim()) continue; + const sourcePath = value.trim(); + try { + const pem = validateCorporateCaFile(sourcePath); + return { pem, sourcePath, sourceEnv: name }; + } catch { + // A conventional CA env var pointing at a missing/invalid file must not + // break onboard for users who never asked for a corporate CA import. + } + } + return null; +} + +/** Base64-encode PEM text for a single-line Dockerfile ARG value. */ +export function encodeCorporateCaArg(pem: string): string { + return Buffer.from(pem, "utf8") + .toString("base64") + .replace(/[\r\n]/g, ""); +} diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index c1107988550..403ef57ec9b 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -102,6 +102,107 @@ describe("dockerfile patch helpers", () => { expect(isValidProxyPort("70000")).toBe(false); }); + it("bakes the host corporate CA into NEMOCLAW_CORPORATE_CA_B64 (#6210)", () => { + const caDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-arg-")); + tmpRoots.push(caDir); + const caFile = path.join(caDir, "corp-ca.pem"); + const caPem = "-----BEGIN CERTIFICATE-----\nMIIBcorp\n-----END CERTIFICATE-----\n"; + fs.writeFileSync(caFile, caPem, { mode: 0o644 }); + process.env.NEMOCLAW_CORPORATE_CA_BUNDLE = caFile; + try { + const dockerfilePath = dockerfileWith( + [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + "ARG NEMOCLAW_PROXY_HOST=old", + "ARG NEMOCLAW_PROXY_PORT=old", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_OPENCLAW_OTEL=0", + "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", + "ARG NEMOCLAW_CORPORATE_CA_B64=", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "custom-model", + "https://chat.example", + "build-1", + "compatible-endpoint", + null, + null, + null, + false, + null, + [], + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + const line = patched + .split("\n") + .find((entry) => entry.startsWith("ARG NEMOCLAW_CORPORATE_CA_B64=")); + assert.ok(line, "expected corporate CA build arg"); + const encoded = line.slice("ARG NEMOCLAW_CORPORATE_CA_B64=".length); + expect(encoded).not.toBe(""); + expect(Buffer.from(encoded, "base64").toString("utf8")).toBe(caPem); + } finally { + delete process.env.NEMOCLAW_CORPORATE_CA_BUNDLE; + } + }); + + it("leaves NEMOCLAW_CORPORATE_CA_B64 empty when no corporate CA is configured", () => { + delete process.env.NEMOCLAW_CORPORATE_CA_BUNDLE; + delete process.env.REQUESTS_CA_BUNDLE; + delete process.env.CURL_CA_BUNDLE; + delete process.env.SSL_CERT_FILE; + const dockerfilePath = dockerfileWith( + [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + "ARG NEMOCLAW_PROXY_HOST=old", + "ARG NEMOCLAW_PROXY_PORT=old", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_OPENCLAW_OTEL=0", + "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", + "ARG NEMOCLAW_CORPORATE_CA_B64=", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "custom-model", + "https://chat.example", + "build-1", + "compatible-endpoint", + null, + null, + null, + false, + null, + [], + ); + + const patched = fs.readFileSync(dockerfilePath, "utf-8"); + const line = patched + .split("\n") + .find((entry) => entry.startsWith("ARG NEMOCLAW_CORPORATE_CA_B64=")); + expect(line).toBe("ARG NEMOCLAW_CORPORATE_CA_B64="); + }); + it("fails when an OTEL env value has no matching Dockerfile ARG", () => { process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; const dockerfilePath = dockerfileWith( diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index dbf59aba1b6..cf9a3d0a71e 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -18,6 +18,7 @@ import { normalizeToolDisclosure, type ToolDisclosure, } from "../tool-disclosure"; +import { encodeCorporateCaArg, resolveCorporateCaFromEnv } from "./corporate-ca"; import { dockerfileInstructions, readDockerfilePatchSnapshot, @@ -322,5 +323,17 @@ export function patchStagedDockerfile( `ARG NEMOCLAW_EXTRA_AGENTS_JSON_B64=${encoded}`, ); } + // Corporate proxy CA import (#6210). When the host exposes an operator + // corporate CA bundle, bake its base64 so the entrypoint can append it to + // the OpenShell trust bundle at runtime (never replacing it). The replace is + // a silent no-op on custom/legacy Dockerfiles that predate this ARG. + const corporateCa = resolveCorporateCaFromEnv(process.env); + if (corporateCa) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_CORPORATE_CA_B64=.*$/m, + `ARG NEMOCLAW_CORPORATE_CA_B64=${sanitizeDockerArg(encodeCorporateCaArg(corporateCa.pem))}`, + ); + } + replaceDockerfilePatchSnapshot(dockerfilePath, patchSnapshot, dockerfile); } diff --git a/test/corporate-ca-runtime-merge.test.ts b/test/corporate-ca-runtime-merge.test.ts new file mode 100644 index 00000000000..89029bb9915 --- /dev/null +++ b/test/corporate-ca-runtime-merge.test.ts @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Runtime behavior of the corporate-proxy CA merge (#6210) in the sandbox +// entrypoints. Exercises the actual shell blocks extracted from +// scripts/nemoclaw-start.sh and agents/hermes/start.sh, not a re-implementation. + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const OPENCLAW_START = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); +const HERMES_START = join(import.meta.dirname, "../agents/hermes/start.sh"); + +const OPENSHELL_PEM = "-----BEGIN CERTIFICATE-----\nOPENSHELL-ROOT\n-----END CERTIFICATE-----\n"; +const CORPORATE_PEM = "-----BEGIN CERTIFICATE-----\nCORPORATE-ROOT\n-----END CERTIFICATE-----\n"; + +const tmpRoots: string[] = []; + +function tmpDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tmpRoots.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function sliceBlock(scriptPath: string, startMarker: string, endMarker: string): string { + const src = readFileSync(scriptPath, "utf-8"); + const start = src.indexOf(startMarker); + const end = src.indexOf(endMarker, start); + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Failed to extract block [${startMarker} .. ${endMarker}] from ${scriptPath}`); + } + return src.slice(start, end); +} + +function mergeBlock(scriptPath: string, corpCa: string, merged: string): string { + return sliceBlock( + scriptPath, + "# Corporate proxy CA merge (NemoClaw#6210).", + "# Git TLS CA bundle fix (NemoClaw#2270).", + ) + .replaceAll("/usr/local/share/nemoclaw/corporate-ca.pem", corpCa) + .replaceAll("/tmp/nemoclaw-ca-bundle.pem", merged); +} + +function runShell(dir: string, lines: string[]): string { + const script = join(dir, "run.sh"); + writeFileSync(script, ["#!/usr/bin/env bash", "set -euo pipefail", ...lines].join("\n"), { + mode: 0o700, + }); + return execFileSync("bash", [script], { encoding: "utf-8" }); +} + +describe("corporate proxy CA runtime merge (#6210)", () => { + it("appends the corporate CA to the OpenShell bundle for OpenClaw and repoints all CA env", () => { + const dir = tmpDir("nemoclaw-corp-merge-openclaw-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + const merged = join(dir, "merged-ca.pem"); + writeFileSync(openshell, OPENSHELL_PEM); + writeFileSync(corp, CORPORATE_PEM); + + const out = runShell(dir, [ + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + mergeBlock(OPENCLAW_START, corp, merged), + 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', + 'printf "CURL_CA_BUNDLE=%s\\n" "${CURL_CA_BUNDLE:-}"', + 'printf "REQUESTS_CA_BUNDLE=%s\\n" "${REQUESTS_CA_BUNDLE:-}"', + 'printf "GIT_SSL_CAINFO=%s\\n" "${GIT_SSL_CAINFO:-}"', + 'printf "NODE_EXTRA_CA_CERTS=%s\\n" "${NODE_EXTRA_CA_CERTS:-}"', + 'printf "MERGED=%s\\n" "${_NEMOCLAW_CORPORATE_CA_MERGED:-}"', + ]); + + for (const name of [ + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", + "GIT_SSL_CAINFO", + "NODE_EXTRA_CA_CERTS", + ]) { + expect(out).toContain(`${name}=${merged}`); + } + expect(out).toContain("MERGED=1"); + const mergedContent = readFileSync(merged, "utf-8"); + expect(mergedContent).toContain("OPENSHELL-ROOT"); + expect(mergedContent).toContain("CORPORATE-ROOT"); + }); + + it("is a no-op for OpenClaw when no corporate CA was baked into the image", () => { + const dir = tmpDir("nemoclaw-corp-merge-noop-"); + const openshell = join(dir, "openshell-ca.pem"); + const absentCorp = join(dir, "absent-corporate-ca.pem"); + const merged = join(dir, "merged-ca.pem"); + writeFileSync(openshell, OPENSHELL_PEM); + + const out = runShell(dir, [ + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + mergeBlock(OPENCLAW_START, absentCorp, merged), + 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', + 'printf "MERGED=%s\\n" "${_NEMOCLAW_CORPORATE_CA_MERGED:-}"', + ]); + + expect(out).toContain(`SSL_CERT_FILE=${openshell}`); + expect(out).toContain("MERGED=\n"); + expect(existsSync(merged)).toBe(false); + }); + + it("appends the corporate CA and repoints SSL_CERT_FILE / NODE_EXTRA_CA_CERTS for Hermes", () => { + const dir = tmpDir("nemoclaw-corp-merge-hermes-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + const merged = join(dir, "merged-ca.pem"); + writeFileSync(openshell, OPENSHELL_PEM); + writeFileSync(corp, CORPORATE_PEM); + + // Hermes extracts up to the OpenShell derivation comment; splice that in so + // the CURL/REQUESTS/GIT vars derive from the merged SSL_CERT_FILE too. + const hermesMerge = sliceBlock( + HERMES_START, + "# Corporate proxy CA merge (NemoClaw#6210).", + "# OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA.", + ) + .replaceAll("/usr/local/share/nemoclaw/corporate-ca.pem", corp) + .replaceAll("/tmp/nemoclaw-ca-bundle.pem", merged); + + const out = runShell(dir, [ + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + hermesMerge, + 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', + 'printf "NODE_EXTRA_CA_CERTS=%s\\n" "${NODE_EXTRA_CA_CERTS:-}"', + 'printf "MERGED=%s\\n" "${_NEMOCLAW_CORPORATE_CA_MERGED:-}"', + ]); + + expect(out).toContain(`SSL_CERT_FILE=${merged}`); + expect(out).toContain(`NODE_EXTRA_CA_CERTS=${merged}`); + expect(out).toContain("MERGED=1"); + const mergedContent = readFileSync(merged, "utf-8"); + expect(mergedContent).toContain("OPENSHELL-ROOT"); + expect(mergedContent).toContain("CORPORATE-ROOT"); + }); + + it("persists the merged CA env into OpenClaw connect sessions only after a merge", () => { + const dir = tmpDir("nemoclaw-corp-connect-"); + const block = sliceBlock( + OPENCLAW_START, + "# Corporate proxy CA for connect sessions (NemoClaw#6210).", + "# Nemotron inference fix for connect sessions.", + ); + const bundle = "/tmp/nemoclaw-ca-bundle.pem"; + + // Behavioral: capture the emitted connect-session exports, source them in a + // fresh shell, and assert on the resulting environment — not the text. + function connectSessionEnv(preEnv: string[]): Record { + const envFile = join(dir, "connect-env.sh"); + const emitted = runShell(dir, [...preEnv, `{ ${block}\n} > ${JSON.stringify(envFile)}`]); + expect(emitted).toBe(""); + const sourced = runShell(dir, [ + `source ${JSON.stringify(envFile)}`, + 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', + 'printf "CURL_CA_BUNDLE=%s\\n" "${CURL_CA_BUNDLE:-}"', + 'printf "REQUESTS_CA_BUNDLE=%s\\n" "${REQUESTS_CA_BUNDLE:-}"', + 'printf "NODE_EXTRA_CA_CERTS=%s\\n" "${NODE_EXTRA_CA_CERTS:-}"', + ]); + return Object.fromEntries( + sourced + .trim() + .split("\n") + .map((line) => { + const idx = line.indexOf("="); + return [line.slice(0, idx), line.slice(idx + 1)]; + }), + ); + } + + const merged = connectSessionEnv([ + `export SSL_CERT_FILE=${bundle}`, + `export CURL_CA_BUNDLE=${bundle}`, + `export REQUESTS_CA_BUNDLE=${bundle}`, + `export NODE_EXTRA_CA_CERTS=${bundle}`, + "export _NEMOCLAW_CORPORATE_CA_MERGED=1", + ]); + expect(merged.SSL_CERT_FILE).toBe(bundle); + expect(merged.CURL_CA_BUNDLE).toBe(bundle); + expect(merged.REQUESTS_CA_BUNDLE).toBe(bundle); + expect(merged.NODE_EXTRA_CA_CERTS).toBe(bundle); + + // No merge marker → the block emits nothing, so a fresh shell inherits no + // corporate CA env from the connect-session file. + const skipped = connectSessionEnv(["export SSL_CERT_FILE=/etc/openshell-tls/ca-bundle.pem"]); + expect(skipped.SSL_CERT_FILE).toBe(""); + expect(skipped.CURL_CA_BUNDLE).toBe(""); + }); +}); diff --git a/test/corporate-ca-tls-e2e.test.ts b/test/corporate-ca-tls-e2e.test.ts new file mode 100644 index 00000000000..10d00c3ce98 --- /dev/null +++ b/test/corporate-ca-tls-e2e.test.ts @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Focused simulated-MITM TLS test for the corporate-proxy CA import (#6210). +// +// Reproduces the reporter's scenario without DGX hardware: +// * A corporate root CA re-signs external TLS (the MITM proxy). +// * A server presents a leaf cert signed ONLY by that corporate CA. +// * OpenShell's own bundle does NOT contain the corporate root. +// +// It then runs the REAL `merge_corporate_proxy_ca` block extracted from +// scripts/nemoclaw-start.sh to append the baked corporate CA to the OpenShell +// bundle, and proves that: +// * TLS verification against the server SUCCEEDS with the merged bundle. +// * TLS verification FAILS with the OpenShell-only bundle (the pre-fix state). +// * The OpenShell root is still trusted through the merged bundle (#1828 +// behavior preserved — the corporate CA is appended, not substituted). + +import { execFileSync, execSync } from "node:child_process"; +import fs from "node:fs"; +import https from "node:https"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; + +const NEMOCLAW_START_SCRIPT = path.join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + +interface CaMaterial { + ok: true; + dir: string; + corporateCaCert: string; // path + openshellCaCert: string; // path + serverKey: string; // path + serverCert: string; // path + openshellServerKey: string; // path + openshellServerCert: string; // path +} + +function opensslReqX509(dir: string, cn: string, keyOut: string, certOut: string): void { + execSync( + `openssl req -x509 -newkey rsa:2048 -keyout "${path.join(dir, keyOut)}" ` + + `-out "${path.join(dir, certOut)}" -days 7 -nodes -subj "/CN=${cn}"`, + { stdio: "pipe" }, + ); +} + +function signLeaf( + dir: string, + caCert: string, + caKey: string, + keyOut: string, + certOut: string, +): void { + const csr = path.join(dir, `${keyOut}.csr`); + const ext = path.join(dir, `${keyOut}.ext`); + fs.writeFileSync(ext, "subjectAltName=DNS:localhost,IP:127.0.0.1\n"); + execSync( + `openssl req -newkey rsa:2048 -keyout "${path.join(dir, keyOut)}" -out "${csr}" ` + + `-nodes -subj "/CN=localhost"`, + { stdio: "pipe" }, + ); + execSync( + `openssl x509 -req -in "${csr}" -CA "${path.join(dir, caCert)}" ` + + `-CAkey "${path.join(dir, caKey)}" -CAcreateserial -out "${path.join(dir, certOut)}" ` + + `-days 7 -extfile "${ext}"`, + { stdio: "pipe" }, + ); +} + +function trySetup(): CaMaterial | { ok: false; reason: string } { + try { + execSync("openssl version", { stdio: "pipe" }); + } catch (err) { + return { ok: false, reason: `openssl missing: ${(err as Error).message}` }; + } + try { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-tls-")); + // Corporate MITM root + a leaf it signs. + opensslReqX509(dir, "Corp MITM Root CA", "corp-ca-key.pem", "corp-ca-cert.pem"); + signLeaf(dir, "corp-ca-cert.pem", "corp-ca-key.pem", "server-key.pem", "server-cert.pem"); + // A separate OpenShell root + a leaf it signs (stands in for OpenShell's + // own L7 proxy CA and inference.local traffic). + opensslReqX509(dir, "OpenShell Root CA", "openshell-ca-key.pem", "openshell-ca-cert.pem"); + signLeaf( + dir, + "openshell-ca-cert.pem", + "openshell-ca-key.pem", + "openshell-server-key.pem", + "openshell-server-cert.pem", + ); + return { + ok: true, + dir, + corporateCaCert: path.join(dir, "corp-ca-cert.pem"), + openshellCaCert: path.join(dir, "openshell-ca-cert.pem"), + serverKey: path.join(dir, "server-key.pem"), + serverCert: path.join(dir, "server-cert.pem"), + openshellServerKey: path.join(dir, "openshell-server-key.pem"), + openshellServerCert: path.join(dir, "openshell-server-cert.pem"), + }; + } catch (err) { + return { ok: false, reason: `cert generation failed: ${(err as Error).message}` }; + } +} + +const setup = trySetup(); +if (!setup.ok) { + if (process.env.CI === "true") { + throw new Error( + `[corporate-ca-tls-e2e] CI=true but openssl unavailable: ${setup.reason}. ` + + "This test must not silently skip in CI — install openssl on the runner.", + ); + } + console.warn(`[corporate-ca-tls-e2e] skipping locally: ${setup.reason}`); +} + +afterAll(() => { + if (setup.ok) { + fs.rmSync(setup.dir, { recursive: true, force: true }); + } +}); + +/** + * Run the shipped merge_corporate_proxy_ca block against a given OpenShell + * bundle + baked corporate CA, returning the path of the produced merged + * bundle. Exercises the actual script text, not a re-implementation. + */ +function runMergeBlock(openshellBundle: string, corporateCa: string, outDir: string): string { + const src = fs.readFileSync(NEMOCLAW_START_SCRIPT, "utf-8"); + const start = src.indexOf("# Corporate proxy CA merge (NemoClaw#6210)."); + const end = src.indexOf("# Git TLS CA bundle fix (NemoClaw#2270).", start); + if (start === -1 || end === -1 || end <= start) { + throw new Error("Failed to extract corporate CA merge block from nemoclaw-start.sh"); + } + const merged = path.join(outDir, "merged-ca.pem"); + const block = src + .slice(start, end) + .replaceAll("/usr/local/share/nemoclaw/corporate-ca.pem", corporateCa) + .replaceAll("/tmp/nemoclaw-ca-bundle.pem", merged); + const wrapper = path.join(outDir, "merge.sh"); + fs.writeFileSync( + wrapper, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `export SSL_CERT_FILE=${JSON.stringify(openshellBundle)}`, + block, + ].join("\n"), + { mode: 0o700 }, + ); + execFileSync("bash", [wrapper], { encoding: "utf-8" }); + return merged; +} + +function startServer( + key: string, + cert: string, +): Promise<{ port: number; close: () => Promise }> { + return new Promise((resolve, reject) => { + const server = https.createServer( + { key: fs.readFileSync(key), cert: fs.readFileSync(cert) }, + (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }, + ); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("server address unavailable")); + return; + } + resolve({ + port: addr.port, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); +} + +function httpsGet(port: number, caBundlePath: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get( + { host: "127.0.0.1", port, path: "/", ca: fs.readFileSync(caBundlePath) }, + (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on("error", reject); + }); +} + +describe.skipIf(!setup.ok)("corporate proxy CA TLS verification (#6210)", () => { + const mat = setup as CaMaterial; + + it("verifies a corporate-CA-signed endpoint only after the merge", async () => { + const merged = runMergeBlock(mat.openshellCaCert, mat.corporateCaCert, mat.dir); + const server = await startServer(mat.serverKey, mat.serverCert); + try { + // Pre-fix state: OpenShell bundle alone cannot verify the corporate leaf. + await expect(httpsGet(server.port, mat.openshellCaCert)).rejects.toThrow( + /unable to (get local issuer|verify)|self.signed|UNABLE_TO_/i, + ); + // Post-fix: the merged bundle trusts the corporate root. + await expect(httpsGet(server.port, merged)).resolves.toBe(200); + } finally { + await server.close(); + } + }); + + // Also preserves the OpenShell CA trust behavior from #1828. + it("still trusts the OpenShell root through the merged bundle (#6210)", async () => { + const merged = runMergeBlock(mat.openshellCaCert, mat.corporateCaCert, mat.dir); + const server = await startServer(mat.openshellServerKey, mat.openshellServerCert); + try { + await expect(httpsGet(server.port, merged)).resolves.toBe(200); + } finally { + await server.close(); + } + }); +}); From 379a3dc68ebac24fae7c3579a772ad884a2ec3b7 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Mon, 6 Jul 2026 03:59:46 +0000 Subject: [PATCH 02/26] test(sandbox): keep corporate CA test bodies linear (#6210) Move the branching openssl/TLS/shell-block helpers out of the corporate CA .test.ts files into test/helpers/corporate-ca-support.ts so the changed test files add no if statements (codebase-growth-guardrails keeps test bodies linear). Signed-off-by: Yimo Jiang --- test/corporate-ca-runtime-merge.test.ts | 55 ++----- test/corporate-ca-tls-e2e.test.ts | 203 +++-------------------- test/helpers/corporate-ca-support.ts | 208 ++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 220 deletions(-) create mode 100644 test/helpers/corporate-ca-support.ts diff --git a/test/corporate-ca-runtime-merge.test.ts b/test/corporate-ca-runtime-merge.test.ts index 89029bb9915..3bdce8dddb2 100644 --- a/test/corporate-ca-runtime-merge.test.ts +++ b/test/corporate-ca-runtime-merge.test.ts @@ -5,17 +5,19 @@ // entrypoints. Exercises the actual shell blocks extracted from // scripts/nemoclaw-start.sh and agents/hermes/start.sh, not a re-implementation. -import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { runShellLines, sliceBlock } from "./helpers/corporate-ca-support"; + const OPENCLAW_START = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); const HERMES_START = join(import.meta.dirname, "../agents/hermes/start.sh"); const OPENSHELL_PEM = "-----BEGIN CERTIFICATE-----\nOPENSHELL-ROOT\n-----END CERTIFICATE-----\n"; const CORPORATE_PEM = "-----BEGIN CERTIFICATE-----\nCORPORATE-ROOT\n-----END CERTIFICATE-----\n"; +const MERGE_START = "# Corporate proxy CA merge (NemoClaw#6210)."; const tmpRoots: string[] = []; @@ -31,34 +33,12 @@ afterEach(() => { } }); -function sliceBlock(scriptPath: string, startMarker: string, endMarker: string): string { - const src = readFileSync(scriptPath, "utf-8"); - const start = src.indexOf(startMarker); - const end = src.indexOf(endMarker, start); - if (start === -1 || end === -1 || end <= start) { - throw new Error(`Failed to extract block [${startMarker} .. ${endMarker}] from ${scriptPath}`); - } - return src.slice(start, end); -} - -function mergeBlock(scriptPath: string, corpCa: string, merged: string): string { - return sliceBlock( - scriptPath, - "# Corporate proxy CA merge (NemoClaw#6210).", - "# Git TLS CA bundle fix (NemoClaw#2270).", - ) +function mergeBlock(scriptPath: string, endMarker: string, corpCa: string, merged: string): string { + return sliceBlock(scriptPath, MERGE_START, endMarker) .replaceAll("/usr/local/share/nemoclaw/corporate-ca.pem", corpCa) .replaceAll("/tmp/nemoclaw-ca-bundle.pem", merged); } -function runShell(dir: string, lines: string[]): string { - const script = join(dir, "run.sh"); - writeFileSync(script, ["#!/usr/bin/env bash", "set -euo pipefail", ...lines].join("\n"), { - mode: 0o700, - }); - return execFileSync("bash", [script], { encoding: "utf-8" }); -} - describe("corporate proxy CA runtime merge (#6210)", () => { it("appends the corporate CA to the OpenShell bundle for OpenClaw and repoints all CA env", () => { const dir = tmpDir("nemoclaw-corp-merge-openclaw-"); @@ -68,9 +48,9 @@ describe("corporate proxy CA runtime merge (#6210)", () => { writeFileSync(openshell, OPENSHELL_PEM); writeFileSync(corp, CORPORATE_PEM); - const out = runShell(dir, [ + const out = runShellLines(dir, [ `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, - mergeBlock(OPENCLAW_START, corp, merged), + mergeBlock(OPENCLAW_START, "# Git TLS CA bundle fix (NemoClaw#2270).", corp, merged), 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', 'printf "CURL_CA_BUNDLE=%s\\n" "${CURL_CA_BUNDLE:-}"', 'printf "REQUESTS_CA_BUNDLE=%s\\n" "${REQUESTS_CA_BUNDLE:-}"', @@ -101,9 +81,9 @@ describe("corporate proxy CA runtime merge (#6210)", () => { const merged = join(dir, "merged-ca.pem"); writeFileSync(openshell, OPENSHELL_PEM); - const out = runShell(dir, [ + const out = runShellLines(dir, [ `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, - mergeBlock(OPENCLAW_START, absentCorp, merged), + mergeBlock(OPENCLAW_START, "# Git TLS CA bundle fix (NemoClaw#2270).", absentCorp, merged), 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', 'printf "MERGED=%s\\n" "${_NEMOCLAW_CORPORATE_CA_MERGED:-}"', ]); @@ -121,17 +101,16 @@ describe("corporate proxy CA runtime merge (#6210)", () => { writeFileSync(openshell, OPENSHELL_PEM); writeFileSync(corp, CORPORATE_PEM); - // Hermes extracts up to the OpenShell derivation comment; splice that in so + // Hermes' block ends at the OpenShell derivation comment; splice that in so // the CURL/REQUESTS/GIT vars derive from the merged SSL_CERT_FILE too. - const hermesMerge = sliceBlock( + const hermesMerge = mergeBlock( HERMES_START, - "# Corporate proxy CA merge (NemoClaw#6210).", "# OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA.", - ) - .replaceAll("/usr/local/share/nemoclaw/corporate-ca.pem", corp) - .replaceAll("/tmp/nemoclaw-ca-bundle.pem", merged); + corp, + merged, + ); - const out = runShell(dir, [ + const out = runShellLines(dir, [ `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, hermesMerge, 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', @@ -160,9 +139,9 @@ describe("corporate proxy CA runtime merge (#6210)", () => { // fresh shell, and assert on the resulting environment — not the text. function connectSessionEnv(preEnv: string[]): Record { const envFile = join(dir, "connect-env.sh"); - const emitted = runShell(dir, [...preEnv, `{ ${block}\n} > ${JSON.stringify(envFile)}`]); + const emitted = runShellLines(dir, [...preEnv, `{ ${block}\n} > ${JSON.stringify(envFile)}`]); expect(emitted).toBe(""); - const sourced = runShell(dir, [ + const sourced = runShellLines(dir, [ `source ${JSON.stringify(envFile)}`, 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', 'printf "CURL_CA_BUNDLE=%s\\n" "${CURL_CA_BUNDLE:-}"', diff --git a/test/corporate-ca-tls-e2e.test.ts b/test/corporate-ca-tls-e2e.test.ts index 10d00c3ce98..9d8093c29ee 100644 --- a/test/corporate-ca-tls-e2e.test.ts +++ b/test/corporate-ca-tls-e2e.test.ts @@ -8,203 +8,42 @@ // * A server presents a leaf cert signed ONLY by that corporate CA. // * OpenShell's own bundle does NOT contain the corporate root. // -// It then runs the REAL `merge_corporate_proxy_ca` block extracted from +// It runs the REAL merge_corporate_proxy_ca block extracted from // scripts/nemoclaw-start.sh to append the baked corporate CA to the OpenShell -// bundle, and proves that: -// * TLS verification against the server SUCCEEDS with the merged bundle. -// * TLS verification FAILS with the OpenShell-only bundle (the pre-fix state). -// * The OpenShell root is still trusted through the merged bundle (#1828 -// behavior preserved — the corporate CA is appended, not substituted). +// bundle, then proves TLS verification succeeds only after the merge, while the +// OpenShell root stays trusted (the #1828 behavior is preserved). -import { execFileSync, execSync } from "node:child_process"; -import fs from "node:fs"; -import https from "node:https"; -import os from "node:os"; import path from "node:path"; import { afterAll, describe, expect, it } from "vitest"; -const NEMOCLAW_START_SCRIPT = path.join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); +import { + type CaMaterial, + cleanupCaSetup, + httpsGetStatus, + resolveCaSetup, + runMergeBlock, + startTlsServer, +} from "./helpers/corporate-ca-support"; -interface CaMaterial { - ok: true; - dir: string; - corporateCaCert: string; // path - openshellCaCert: string; // path - serverKey: string; // path - serverCert: string; // path - openshellServerKey: string; // path - openshellServerCert: string; // path -} +const OPENCLAW_START = path.join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); -function opensslReqX509(dir: string, cn: string, keyOut: string, certOut: string): void { - execSync( - `openssl req -x509 -newkey rsa:2048 -keyout "${path.join(dir, keyOut)}" ` + - `-out "${path.join(dir, certOut)}" -days 7 -nodes -subj "/CN=${cn}"`, - { stdio: "pipe" }, - ); -} +const setup = resolveCaSetup("corporate-ca-tls-e2e"); -function signLeaf( - dir: string, - caCert: string, - caKey: string, - keyOut: string, - certOut: string, -): void { - const csr = path.join(dir, `${keyOut}.csr`); - const ext = path.join(dir, `${keyOut}.ext`); - fs.writeFileSync(ext, "subjectAltName=DNS:localhost,IP:127.0.0.1\n"); - execSync( - `openssl req -newkey rsa:2048 -keyout "${path.join(dir, keyOut)}" -out "${csr}" ` + - `-nodes -subj "/CN=localhost"`, - { stdio: "pipe" }, - ); - execSync( - `openssl x509 -req -in "${csr}" -CA "${path.join(dir, caCert)}" ` + - `-CAkey "${path.join(dir, caKey)}" -CAcreateserial -out "${path.join(dir, certOut)}" ` + - `-days 7 -extfile "${ext}"`, - { stdio: "pipe" }, - ); -} - -function trySetup(): CaMaterial | { ok: false; reason: string } { - try { - execSync("openssl version", { stdio: "pipe" }); - } catch (err) { - return { ok: false, reason: `openssl missing: ${(err as Error).message}` }; - } - try { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-tls-")); - // Corporate MITM root + a leaf it signs. - opensslReqX509(dir, "Corp MITM Root CA", "corp-ca-key.pem", "corp-ca-cert.pem"); - signLeaf(dir, "corp-ca-cert.pem", "corp-ca-key.pem", "server-key.pem", "server-cert.pem"); - // A separate OpenShell root + a leaf it signs (stands in for OpenShell's - // own L7 proxy CA and inference.local traffic). - opensslReqX509(dir, "OpenShell Root CA", "openshell-ca-key.pem", "openshell-ca-cert.pem"); - signLeaf( - dir, - "openshell-ca-cert.pem", - "openshell-ca-key.pem", - "openshell-server-key.pem", - "openshell-server-cert.pem", - ); - return { - ok: true, - dir, - corporateCaCert: path.join(dir, "corp-ca-cert.pem"), - openshellCaCert: path.join(dir, "openshell-ca-cert.pem"), - serverKey: path.join(dir, "server-key.pem"), - serverCert: path.join(dir, "server-cert.pem"), - openshellServerKey: path.join(dir, "openshell-server-key.pem"), - openshellServerCert: path.join(dir, "openshell-server-cert.pem"), - }; - } catch (err) { - return { ok: false, reason: `cert generation failed: ${(err as Error).message}` }; - } -} - -const setup = trySetup(); -if (!setup.ok) { - if (process.env.CI === "true") { - throw new Error( - `[corporate-ca-tls-e2e] CI=true but openssl unavailable: ${setup.reason}. ` + - "This test must not silently skip in CI — install openssl on the runner.", - ); - } - console.warn(`[corporate-ca-tls-e2e] skipping locally: ${setup.reason}`); -} - -afterAll(() => { - if (setup.ok) { - fs.rmSync(setup.dir, { recursive: true, force: true }); - } -}); - -/** - * Run the shipped merge_corporate_proxy_ca block against a given OpenShell - * bundle + baked corporate CA, returning the path of the produced merged - * bundle. Exercises the actual script text, not a re-implementation. - */ -function runMergeBlock(openshellBundle: string, corporateCa: string, outDir: string): string { - const src = fs.readFileSync(NEMOCLAW_START_SCRIPT, "utf-8"); - const start = src.indexOf("# Corporate proxy CA merge (NemoClaw#6210)."); - const end = src.indexOf("# Git TLS CA bundle fix (NemoClaw#2270).", start); - if (start === -1 || end === -1 || end <= start) { - throw new Error("Failed to extract corporate CA merge block from nemoclaw-start.sh"); - } - const merged = path.join(outDir, "merged-ca.pem"); - const block = src - .slice(start, end) - .replaceAll("/usr/local/share/nemoclaw/corporate-ca.pem", corporateCa) - .replaceAll("/tmp/nemoclaw-ca-bundle.pem", merged); - const wrapper = path.join(outDir, "merge.sh"); - fs.writeFileSync( - wrapper, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `export SSL_CERT_FILE=${JSON.stringify(openshellBundle)}`, - block, - ].join("\n"), - { mode: 0o700 }, - ); - execFileSync("bash", [wrapper], { encoding: "utf-8" }); - return merged; -} - -function startServer( - key: string, - cert: string, -): Promise<{ port: number; close: () => Promise }> { - return new Promise((resolve, reject) => { - const server = https.createServer( - { key: fs.readFileSync(key), cert: fs.readFileSync(cert) }, - (_req, res) => { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - }, - ); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("server address unavailable")); - return; - } - resolve({ - port: addr.port, - close: () => new Promise((r) => server.close(() => r())), - }); - }); - }); -} - -function httpsGet(port: number, caBundlePath: string): Promise { - return new Promise((resolve, reject) => { - const req = https.get( - { host: "127.0.0.1", port, path: "/", ca: fs.readFileSync(caBundlePath) }, - (res) => { - res.resume(); - resolve(res.statusCode ?? 0); - }, - ); - req.on("error", reject); - }); -} +afterAll(() => cleanupCaSetup(setup)); describe.skipIf(!setup.ok)("corporate proxy CA TLS verification (#6210)", () => { const mat = setup as CaMaterial; it("verifies a corporate-CA-signed endpoint only after the merge", async () => { - const merged = runMergeBlock(mat.openshellCaCert, mat.corporateCaCert, mat.dir); - const server = await startServer(mat.serverKey, mat.serverCert); + const merged = runMergeBlock(OPENCLAW_START, mat.openshellCaCert, mat.corporateCaCert, mat.dir); + const server = await startTlsServer(mat.serverKey, mat.serverCert); try { // Pre-fix state: OpenShell bundle alone cannot verify the corporate leaf. - await expect(httpsGet(server.port, mat.openshellCaCert)).rejects.toThrow( + await expect(httpsGetStatus(server.port, mat.openshellCaCert)).rejects.toThrow( /unable to (get local issuer|verify)|self.signed|UNABLE_TO_/i, ); // Post-fix: the merged bundle trusts the corporate root. - await expect(httpsGet(server.port, merged)).resolves.toBe(200); + await expect(httpsGetStatus(server.port, merged)).resolves.toBe(200); } finally { await server.close(); } @@ -212,10 +51,10 @@ describe.skipIf(!setup.ok)("corporate proxy CA TLS verification (#6210)", () => // Also preserves the OpenShell CA trust behavior from #1828. it("still trusts the OpenShell root through the merged bundle (#6210)", async () => { - const merged = runMergeBlock(mat.openshellCaCert, mat.corporateCaCert, mat.dir); - const server = await startServer(mat.openshellServerKey, mat.openshellServerCert); + const merged = runMergeBlock(OPENCLAW_START, mat.openshellCaCert, mat.corporateCaCert, mat.dir); + const server = await startTlsServer(mat.openshellServerKey, mat.openshellServerCert); try { - await expect(httpsGet(server.port, merged)).resolves.toBe(200); + await expect(httpsGetStatus(server.port, merged)).resolves.toBe(200); } finally { await server.close(); } diff --git a/test/helpers/corporate-ca-support.ts b/test/helpers/corporate-ca-support.ts new file mode 100644 index 00000000000..4d0957a1a56 --- /dev/null +++ b/test/helpers/corporate-ca-support.ts @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Support helpers for the corporate-proxy CA tests (#6210). Kept out of the +// *.test.ts files so branching setup stays in named helpers (the changed-test +// linear-body guardrail counts if statements only in test files). + +import { execFileSync, execSync } from "node:child_process"; +import fs from "node:fs"; +import https from "node:https"; +import os from "node:os"; +import path from "node:path"; + +/** Extract a marked block of shell text from a script for execution in tests. */ +export function sliceBlock(scriptPath: string, startMarker: string, endMarker: string): string { + const src = fs.readFileSync(scriptPath, "utf-8"); + const start = src.indexOf(startMarker); + const end = src.indexOf(endMarker, start); + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Failed to extract block [${startMarker} .. ${endMarker}] from ${scriptPath}`); + } + return src.slice(start, end); +} + +export interface CaMaterial { + ok: true; + dir: string; + corporateCaCert: string; + openshellCaCert: string; + serverKey: string; + serverCert: string; + openshellServerKey: string; + openshellServerCert: string; +} + +export type CaSetup = CaMaterial | { ok: false; reason: string }; + +function opensslReqX509(dir: string, cn: string, keyOut: string, certOut: string): void { + execSync( + `openssl req -x509 -newkey rsa:2048 -keyout "${path.join(dir, keyOut)}" ` + + `-out "${path.join(dir, certOut)}" -days 7 -nodes -subj "/CN=${cn}"`, + { stdio: "pipe" }, + ); +} + +function signLeaf( + dir: string, + caCert: string, + caKey: string, + keyOut: string, + certOut: string, +): void { + const csr = path.join(dir, `${keyOut}.csr`); + const ext = path.join(dir, `${keyOut}.ext`); + fs.writeFileSync(ext, "subjectAltName=DNS:localhost,IP:127.0.0.1\n"); + execSync( + `openssl req -newkey rsa:2048 -keyout "${path.join(dir, keyOut)}" -out "${csr}" ` + + `-nodes -subj "/CN=localhost"`, + { stdio: "pipe" }, + ); + execSync( + `openssl x509 -req -in "${csr}" -CA "${path.join(dir, caCert)}" ` + + `-CAkey "${path.join(dir, caKey)}" -CAcreateserial -out "${path.join(dir, certOut)}" ` + + `-days 7 -extfile "${ext}"`, + { stdio: "pipe" }, + ); +} + +/** + * Generate a corporate root + leaf and a separate OpenShell root + leaf. + * Returns {ok:false} when openssl is unavailable; the caller decides whether to + * skip (locally) or fail (CI). + */ +export function setupCaMaterial(): CaSetup { + try { + execSync("openssl version", { stdio: "pipe" }); + } catch (err) { + return { ok: false, reason: `openssl missing: ${(err as Error).message}` }; + } + try { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-tls-")); + opensslReqX509(dir, "Corp MITM Root CA", "corp-ca-key.pem", "corp-ca-cert.pem"); + signLeaf(dir, "corp-ca-cert.pem", "corp-ca-key.pem", "server-key.pem", "server-cert.pem"); + opensslReqX509(dir, "OpenShell Root CA", "openshell-ca-key.pem", "openshell-ca-cert.pem"); + signLeaf( + dir, + "openshell-ca-cert.pem", + "openshell-ca-key.pem", + "openshell-server-key.pem", + "openshell-server-cert.pem", + ); + return { + ok: true, + dir, + corporateCaCert: path.join(dir, "corp-ca-cert.pem"), + openshellCaCert: path.join(dir, "openshell-ca-cert.pem"), + serverKey: path.join(dir, "server-key.pem"), + serverCert: path.join(dir, "server-cert.pem"), + openshellServerKey: path.join(dir, "openshell-server-key.pem"), + openshellServerCert: path.join(dir, "openshell-server-cert.pem"), + }; + } catch (err) { + return { ok: false, reason: `cert generation failed: ${(err as Error).message}` }; + } +} + +/** + * Resolve CA material, failing loudly in CI (where openssl must exist) and + * warning-and-skipping locally. + */ +export function resolveCaSetup(context: string): CaSetup { + const setup = setupCaMaterial(); + if (!setup.ok) { + if (process.env.CI === "true") { + throw new Error( + `[${context}] CI=true but openssl unavailable: ${setup.reason}. ` + + "This test must not silently skip in CI — install openssl on the runner.", + ); + } + console.warn(`[${context}] skipping locally: ${setup.reason}`); + } + return setup; +} + +export function cleanupCaSetup(setup: CaSetup): void { + if (setup.ok) { + fs.rmSync(setup.dir, { recursive: true, force: true }); + } +} + +/** + * Run the shipped merge_corporate_proxy_ca block from a start script against a + * given OpenShell bundle + baked corporate CA, returning the merged bundle path. + * Exercises the actual script text, not a re-implementation. + */ +export function runMergeBlock( + scriptPath: string, + openshellBundle: string, + corporateCa: string, + outDir: string, +): string { + const block = sliceBlock( + scriptPath, + "# Corporate proxy CA merge (NemoClaw#6210).", + "# Git TLS CA bundle fix (NemoClaw#2270).", + ) + .replaceAll("/usr/local/share/nemoclaw/corporate-ca.pem", corporateCa) + .replaceAll("/tmp/nemoclaw-ca-bundle.pem", path.join(outDir, "merged-ca.pem")); + const wrapper = path.join(outDir, "merge.sh"); + fs.writeFileSync( + wrapper, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `export SSL_CERT_FILE=${JSON.stringify(openshellBundle)}`, + block, + ].join("\n"), + { mode: 0o700 }, + ); + execFileSync("bash", [wrapper], { encoding: "utf-8" }); + return path.join(outDir, "merged-ca.pem"); +} + +export function startTlsServer( + key: string, + cert: string, +): Promise<{ port: number; close: () => Promise }> { + return new Promise((resolve, reject) => { + const server = https.createServer( + { key: fs.readFileSync(key), cert: fs.readFileSync(cert) }, + (_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }, + ); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = addr && typeof addr !== "string" ? addr.port : 0; + const settle = port + ? resolve({ port, close: () => new Promise((r) => server.close(() => r())) }) + : reject(new Error("server address unavailable")); + return settle; + }); + }); +} + +export function httpsGetStatus(port: number, caBundlePath: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get( + { host: "127.0.0.1", port, path: "/", ca: fs.readFileSync(caBundlePath) }, + (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on("error", reject); + }); +} + +/** Run a bash wrapper built from the given lines and return stdout. */ +export function runShellLines(dir: string, lines: string[]): string { + const script = path.join(dir, "run.sh"); + fs.writeFileSync(script, ["#!/usr/bin/env bash", "set -euo pipefail", ...lines].join("\n"), { + mode: 0o700, + }); + return execFileSync("bash", [script], { encoding: "utf-8" }); +} From 5f99716bfeb4f1e61daa22126e972191029f75d8 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Mon, 6 Jul 2026 04:09:02 +0000 Subject: [PATCH 03/26] fix(sandbox): harden corporate CA import per review (#6210) Address PR review advisor findings on the corporate-proxy CA import: - corporate-ca.ts: open the candidate once with O_NOFOLLOW and validate the opened fd via fstat + read-from-fd (closes the validate-then-reopen TOCTOU); reduce the size cap to 128 KiB, add a certificate-count cap, and structurally validate the leading block as X.509 so a full OS trust store or corrupt PEM is rejected. - dockerfile-patch.ts: fail loudly when NEMOCLAW_CORPORATE_CA_BUNDLE is set explicitly but the staged Dockerfile lacks the ARG; log which host source (env + path) is baked so a fallback import is never silent. - nemoclaw-start.sh / hermes start.sh: build the merged bundle in a mktemp sibling with every write checked, then atomically rename into place; only export the CA env + marker after the complete bundle exists (no partial/predictable-path bundle, no success-on-append-failure). - Dockerfile / hermes Dockerfile: decode with `base64 --decode`. - Extract the dockerfile-patch corporate-CA tests into their own focused file; document explicit-first usage, host trust-store guidance, and the fallback silent-skip behavior in troubleshooting. Signed-off-by: Yimo Jiang --- Dockerfile | 2 +- agents/hermes/Dockerfile | 2 +- agents/hermes/start.sh | 29 ++- docs/reference/troubleshooting.mdx | 8 +- scripts/nemoclaw-start.sh | 29 ++- src/lib/onboard/corporate-ca.test.ts | 36 +++- src/lib/onboard/corporate-ca.ts | 109 ++++++++---- .../dockerfile-patch-corporate-ca.test.ts | 165 ++++++++++++++++++ src/lib/onboard/dockerfile-patch.test.ts | 101 ----------- src/lib/onboard/dockerfile-patch.ts | 29 ++- 10 files changed, 352 insertions(+), 158 deletions(-) create mode 100644 src/lib/onboard/dockerfile-patch-corporate-ca.test.ts diff --git a/Dockerfile b/Dockerfile index 23bfc8e6875..0454631936f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -937,7 +937,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ # hadolint ignore=DL3059,DL4006 RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ mkdir -p /usr/local/share/nemoclaw \ - && printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 -d > /usr/local/share/nemoclaw/corporate-ca.pem \ + && printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /usr/local/share/nemoclaw/corporate-ca.pem \ && 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 image trust (#6210)"; \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 95dac6624e6..544a774d618 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -276,7 +276,7 @@ RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-b # hadolint ignore=DL3059,DL4006 RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ mkdir -p /usr/local/share/nemoclaw \ - && printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 -d > /usr/local/share/nemoclaw/corporate-ca.pem \ + && printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /usr/local/share/nemoclaw/corporate-ca.pem \ && 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 image trust (#6210)"; \ diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 1411e61ceed..6ff0d2b56ca 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1422,15 +1422,30 @@ merge_corporate_proxy_ca() { _base_bundle="/etc/ssl/certs/ca-certificates.crt" fi _merged="/tmp/nemoclaw-ca-bundle.pem" - # Remove any stale (0444) bundle first so a re-invocation can rewrite it. - rm -f "$_merged" 2>/dev/null || true - : >"$_merged" 2>/dev/null || return 0 + # Build the bundle in a private temp file next to the target, verifying every + # write, then atomically rename into place. If any step fails we bail without + # exporting anything, leaving the OpenShell-only trust intact rather than + # pointing tools at a partial/empty bundle. + _tmp="$(mktemp "${_merged}.XXXXXX" 2>/dev/null)" || return 0 if [ -n "$_base_bundle" ]; then - cat "$_base_bundle" >>"$_merged" 2>/dev/null || true - printf '\n' >>"$_merged" 2>/dev/null || true + cat "$_base_bundle" >>"$_tmp" 2>/dev/null || { + rm -f "$_tmp" + return 0 + } + printf '\n' >>"$_tmp" 2>/dev/null || { + rm -f "$_tmp" + return 0 + } fi - cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_merged" 2>/dev/null || true - chmod 0444 "$_merged" 2>/dev/null || true + cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_tmp" 2>/dev/null || { + rm -f "$_tmp" + return 0 + } + chmod 0444 "$_tmp" 2>/dev/null || true + mv -f "$_tmp" "$_merged" 2>/dev/null || { + rm -f "$_tmp" + return 0 + } export SSL_CERT_FILE="$_merged" export NODE_EXTRA_CA_CERTS="$_merged" export _NEMOCLAW_CORPORATE_CA_MERGED=1 diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 1bfafa2e393..aeb7c223fbf 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -976,14 +976,18 @@ $$nemoclaw rebuild On networks where a corporate proxy in front of the host re-signs external TLS with its own root CA, external endpoints such as `api.telegram.org` fail certificate verification even when the network policy allows the connection. Logs show the request opening (`NET:OPEN ... api.telegram.org:443`) followed by `NET:FAIL`. OpenShell injects only its own L7-proxy CA into the sandbox, so the separate corporate root is missing from the trust path. -To fix this, point NemoClaw at your corporate CA bundle on the host **before** onboarding, then onboard (or rebuild). NemoClaw validates the bundle, bakes it into the sandbox image, and at startup appends it to the OpenShell trust bundle — it never replaces the OpenShell CA — repointing `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 all trust both roots: +To fix this, point NemoClaw at your corporate CA bundle on the host **before** onboarding, then onboard (or rebuild). The recommended, explicit way is `NEMOCLAW_CORPORATE_CA_BUNDLE`. NemoClaw validates the bundle, bakes it into the sandbox image, and at startup appends it to the OpenShell trust bundle — it never replaces the OpenShell CA — repointing `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 all trust both roots: ```bash export NEMOCLAW_CORPORATE_CA_BUNDLE=/path/to/corporate-ca.pem $$nemoclaw onboard ``` -`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `SSL_CERT_FILE` are also honored as fallbacks (in that order) when `NEMOCLAW_CORPORATE_CA_BUNDLE` is unset, so an environment that already exports one of those for the corporate proxy works without extra configuration. The bundle must be a readable, non-symlink, non-world-writable PEM file that contains at least one certificate. To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. +If your corporate CA lives only in the host system trust store, point the same variable at it explicitly (for example `NEMOCLAW_CORPORATE_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`). NemoClaw does not auto-scan the host trust store, so it never bakes an unrelated bundle without an explicit path. + +When `NEMOCLAW_CORPORATE_CA_BUNDLE` is unset, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `SSL_CERT_FILE` are honored as fallbacks (in that order) so an environment that already exports one of those for the corporate proxy works without extra configuration. A fallback variable that points at a missing or invalid file is **skipped silently** (it does not fail onboarding); only the explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` fails loudly when invalid. When a fallback source is baked, onboarding logs which variable and path it used. + +The bundle must be a readable, non-symlink, non-world-writable PEM file whose leading block parses as an X.509 certificate, capped at a small corporate chain (not a full OS trust store). To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. ### A request inside the sandbox fails with `CONNECT tunnel failed, response 403` diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 80aa0742ce8..1abef6a07ca 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2732,15 +2732,30 @@ merge_corporate_proxy_ca() { _base_bundle="/etc/ssl/certs/ca-certificates.crt" fi _merged="/tmp/nemoclaw-ca-bundle.pem" - # Remove any stale (0444) bundle first so a re-invocation can rewrite it. - rm -f "$_merged" 2>/dev/null || true - : >"$_merged" 2>/dev/null || return 0 + # Build the bundle in a private temp file next to the target, verifying every + # write, then atomically rename into place. If any step fails we bail without + # exporting anything, leaving the OpenShell-only trust intact rather than + # pointing tools at a partial/empty bundle. + _tmp="$(mktemp "${_merged}.XXXXXX" 2>/dev/null)" || return 0 if [ -n "$_base_bundle" ]; then - cat "$_base_bundle" >>"$_merged" 2>/dev/null || true - printf '\n' >>"$_merged" 2>/dev/null || true + cat "$_base_bundle" >>"$_tmp" 2>/dev/null || { + rm -f "$_tmp" + return 0 + } + printf '\n' >>"$_tmp" 2>/dev/null || { + rm -f "$_tmp" + return 0 + } fi - cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_merged" 2>/dev/null || true - chmod 0444 "$_merged" 2>/dev/null || true + cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_tmp" 2>/dev/null || { + rm -f "$_tmp" + return 0 + } + chmod 0444 "$_tmp" 2>/dev/null || true + mv -f "$_tmp" "$_merged" 2>/dev/null || { + rm -f "$_tmp" + return 0 + } export SSL_CERT_FILE="$_merged" export CURL_CA_BUNDLE="$_merged" export REQUESTS_CA_BUNDLE="$_merged" diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index e04d82ba3c8..31060438b5b 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -13,11 +13,35 @@ import { CorporateCaValidationError, encodeCorporateCaArg, MAX_CORPORATE_CA_BYTES, + MAX_CORPORATE_CA_CERTS, resolveCorporateCaFromEnv, validateCorporateCaFile, } from "./corporate-ca"; -const PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n"; +// A real (self-signed) X.509 certificate so the structural validation accepts +// it; the shape-only fixture (BAD_PEM) is used for negative structural cases. +const PEM = `-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUL3YNpyohvjOEzlwisLKfyiU3dRwwDQYJKoZIhvcNAQEL +BQAwJTEjMCEGA1UEAwwaTmVtb0NsYXcgVGVzdCBDb3Jwb3JhdGUgQ0EwHhcNMjYw +NzA2MDQwMjM2WhcNMzYwNzAzMDQwMjM2WjAlMSMwIQYDVQQDDBpOZW1vQ2xhdyBU +ZXN0IENvcnBvcmF0ZSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALVbV5tyMc65jEH39ejvQvBk7dvI8rz8rSZl+5BWSK2a4TzKm3jD3U+qCDZPicrA +ETCDcO09bN6YIAgpB6rYg5BIURJWxFuljBIBMCZEdO6AVlbURPaGsw6RKLA3cmhx +ZekT0qMcoOKm3N+Hb5MHXsWZ8EUf0co2LsWwJgDZrdwY26gF6w+9wr3iGLE92ZbO +LHhjHUYR1oWXmkXS3YW8MN2h5I+oyL71jBiwLHUi59wogxA/LTAD97/GqwJ6DC4C +UERbIpGYhZfrbiKmT+ASJuKRXaUp/0My3IzH90RqqY70d1E/pkAsd5M8SQ332qAZ +OgW4GgO3n7gAlaN/ILwunZ8CAwEAAaNTMFEwHQYDVR0OBBYEFMa5M8bvDm85eFQi +1D5fNATE/rawMB8GA1UdIwQYMBaAFMa5M8bvDm85eFQi1D5fNATE/rawMA8GA1Ud +EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8NR/0HBUH1WbbDOmGNDzge +o+4Pz0KWR5fPDSx9CrmvUk8ijKpJQcSjQcmrXuhCoRs6aExXLh+wImKkOyMIVXfd +YFWjCffSJzeBQfDlMVW+wiAjUh7xaIqpA6Z8EmpdfyoNWd30AuHjs9m8dAa8M/lP +0qhzCbjDiHNHfYSrAuBHlMJ5RsUrNVtSZGpg1dtaSBa+8XFWWNBeJrUANxb8i7Ax +MAhrfNQcxSkZH2lVY+TA2JO83v12nKXzaW1dC94SlsFf0tVSvM3QTeWVgijpr0q+ +J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= +-----END CERTIFICATE----- +`; +// PEM-shaped but not a parseable certificate. +const BAD_PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n"; const tmpRoots: string[] = []; function tmpDir(): string { @@ -82,6 +106,16 @@ describe("validateCorporateCaFile", () => { const p = writeCa(tmpDir(), "not a certificate\n"); expect(() => validateCorporateCaFile(p)).toThrow(/no PEM CERTIFICATE block/); }); + + it("rejects a bundle with more than the certificate cap", () => { + const p = writeCa(tmpDir(), PEM.repeat(MAX_CORPORATE_CA_CERTS + 1)); + expect(() => validateCorporateCaFile(p)).toThrow(/certificates \(max/); + }); + + it("rejects a PEM-shaped block that is not a parseable X.509 certificate", () => { + const p = writeCa(tmpDir(), BAD_PEM); + expect(() => validateCorporateCaFile(p)).toThrow(/not a valid X\.509 certificate/); + }); }); describe("resolveCorporateCaFromEnv", () => { diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index ef74c859559..2e658fbc0f0 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { X509Certificate } from "node:crypto"; import fs from "node:fs"; /** @@ -38,10 +39,20 @@ export const CORPORATE_CA_FALLBACK_ENV_VARS = [ /** Opt-out: set to a falsey token to disable corporate CA import entirely. */ export const CORPORATE_CA_DISABLE_ENV = "NEMOCLAW_CORPORATE_CA_IMPORT"; -/** Upper bound on an accepted CA bundle. A PEM trust store is a few KiB. */ -export const MAX_CORPORATE_CA_BYTES = 512 * 1024; +/** + * Upper bound on an accepted CA bundle. A corporate CA chain is a handful of + * certificates (a few KiB); this bound rejects an accidental full host + * trust-store dump (which would bake broad, unrelated trust into the image). + */ +export const MAX_CORPORATE_CA_BYTES = 128 * 1024; -const PEM_CERTIFICATE_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/; +/** + * Upper bound on certificates in an accepted bundle. Keeps the imported trust + * anchors scoped to a corporate CA chain rather than an entire OS trust store. + */ +export const MAX_CORPORATE_CA_CERTS = 24; + +const PEM_CERTIFICATE_RE_GLOBAL = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; export interface ResolvedCorporateCa { /** Validated PEM text of the corporate CA bundle. */ @@ -62,43 +73,75 @@ export class CorporateCaValidationError extends Error { /** * Validate a candidate corporate CA bundle file and return its PEM text. * - * Rejects symlinks, non-regular files, empty/oversized files, and - * world-writable sources; requires at least one PEM CERTIFICATE block. + * Opens the file once with `O_NOFOLLOW` and validates the *opened* descriptor + * (via `fstat`, then reads from the same fd) so a symlink/file swap between + * check and use cannot slip a different file past validation. Rejects + * symlinks, non-regular files, empty/oversized files, world-writable sources, + * bundles with no or too many PEM CERTIFICATE blocks, and a leading block that + * is not a parseable X.509 certificate. */ export function validateCorporateCaFile(filePath: string): string { - let stat: fs.Stats; + let fd: number; try { - stat = fs.lstatSync(filePath); - } catch { - throw new CorporateCaValidationError(`corporate CA bundle not found: ${filePath}`); - } - if (stat.isSymbolicLink()) { - throw new CorporateCaValidationError(`corporate CA bundle must not be a symlink: ${filePath}`); - } - if (!stat.isFile()) { - throw new CorporateCaValidationError(`corporate CA bundle is not a regular file: ${filePath}`); - } - if (stat.size === 0) { - throw new CorporateCaValidationError(`corporate CA bundle is empty: ${filePath}`); - } - if (stat.size > MAX_CORPORATE_CA_BYTES) { - throw new CorporateCaValidationError( - `corporate CA bundle exceeds ${MAX_CORPORATE_CA_BYTES} bytes: ${filePath}`, - ); - } - // Refuse a source any other local user could tamper with before the build. - if ((stat.mode & 0o002) !== 0) { + // O_NOFOLLOW refuses to open through a final-component symlink atomically. + fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ELOOP") { + throw new CorporateCaValidationError( + `corporate CA bundle must not be a symlink: ${filePath}`, + ); + } throw new CorporateCaValidationError( - `corporate CA bundle must not be world-writable: ${filePath}`, + `corporate CA bundle not found or unreadable: ${filePath}`, ); } - const content = fs.readFileSync(filePath, "utf8"); - if (!PEM_CERTIFICATE_RE.test(content)) { - throw new CorporateCaValidationError( - `corporate CA bundle contains no PEM CERTIFICATE block: ${filePath}`, - ); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) { + throw new CorporateCaValidationError( + `corporate CA bundle is not a regular file: ${filePath}`, + ); + } + if (stat.size === 0) { + throw new CorporateCaValidationError(`corporate CA bundle is empty: ${filePath}`); + } + if (stat.size > MAX_CORPORATE_CA_BYTES) { + throw new CorporateCaValidationError( + `corporate CA bundle exceeds ${MAX_CORPORATE_CA_BYTES} bytes: ${filePath}`, + ); + } + // Refuse a source any other local user could tamper with before the build. + if ((stat.mode & 0o002) !== 0) { + throw new CorporateCaValidationError( + `corporate CA bundle must not be world-writable: ${filePath}`, + ); + } + const content = fs.readFileSync(fd, "utf8"); + const blocks = content.match(PEM_CERTIFICATE_RE_GLOBAL); + if (!blocks || blocks.length === 0) { + throw new CorporateCaValidationError( + `corporate CA bundle contains no PEM CERTIFICATE block: ${filePath}`, + ); + } + if (blocks.length > MAX_CORPORATE_CA_CERTS) { + throw new CorporateCaValidationError( + `corporate CA bundle has ${blocks.length} certificates (max ${MAX_CORPORATE_CA_CERTS}): ${filePath}`, + ); + } + // Structural check: the first block must parse as a real X.509 certificate, + // catching truncated/corrupt PEM at build time rather than at TLS handshake. + try { + new X509Certificate(blocks[0]); + } catch { + throw new CorporateCaValidationError( + `corporate CA bundle leading block is not a valid X.509 certificate: ${filePath}`, + ); + } + return content; + } finally { + fs.closeSync(fd); } - return content; } function isDisabled(env: NodeJS.ProcessEnv): boolean { diff --git a/src/lib/onboard/dockerfile-patch-corporate-ca.test.ts b/src/lib/onboard/dockerfile-patch-corporate-ca.test.ts new file mode 100644 index 00000000000..26a4ef688a3 --- /dev/null +++ b/src/lib/onboard/dockerfile-patch-corporate-ca.test.ts @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Corporate-proxy CA baking in the staged Dockerfile (#6210). Kept in a focused +// file (alongside dockerfile-patch-build-id/-extra-agents/-security) rather than +// growing the dockerfile-patch monolith. + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { patchStagedDockerfile } from "./dockerfile-patch"; + +// A real self-signed X.509 certificate (structural validation rejects garbage). +const CA_PEM = `-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUL3YNpyohvjOEzlwisLKfyiU3dRwwDQYJKoZIhvcNAQEL +BQAwJTEjMCEGA1UEAwwaTmVtb0NsYXcgVGVzdCBDb3Jwb3JhdGUgQ0EwHhcNMjYw +NzA2MDQwMjM2WhcNMzYwNzAzMDQwMjM2WjAlMSMwIQYDVQQDDBpOZW1vQ2xhdyBU +ZXN0IENvcnBvcmF0ZSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALVbV5tyMc65jEH39ejvQvBk7dvI8rz8rSZl+5BWSK2a4TzKm3jD3U+qCDZPicrA +ETCDcO09bN6YIAgpB6rYg5BIURJWxFuljBIBMCZEdO6AVlbURPaGsw6RKLA3cmhx +ZekT0qMcoOKm3N+Hb5MHXsWZ8EUf0co2LsWwJgDZrdwY26gF6w+9wr3iGLE92ZbO +LHhjHUYR1oWXmkXS3YW8MN2h5I+oyL71jBiwLHUi59wogxA/LTAD97/GqwJ6DC4C +UERbIpGYhZfrbiKmT+ASJuKRXaUp/0My3IzH90RqqY70d1E/pkAsd5M8SQ332qAZ +OgW4GgO3n7gAlaN/ILwunZ8CAwEAAaNTMFEwHQYDVR0OBBYEFMa5M8bvDm85eFQi +1D5fNATE/rawMB8GA1UdIwQYMBaAFMa5M8bvDm85eFQi1D5fNATE/rawMA8GA1Ud +EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8NR/0HBUH1WbbDOmGNDzge +o+4Pz0KWR5fPDSx9CrmvUk8ijKpJQcSjQcmrXuhCoRs6aExXLh+wImKkOyMIVXfd +YFWjCffSJzeBQfDlMVW+wiAjUh7xaIqpA6Z8EmpdfyoNWd30AuHjs9m8dAa8M/lP +0qhzCbjDiHNHfYSrAuBHlMJ5RsUrNVtSZGpg1dtaSBa+8XFWWNBeJrUANxb8i7Ax +MAhrfNQcxSkZH2lVY+TA2JO83v12nKXzaW1dC94SlsFf0tVSvM3QTeWVgijpr0q+ +J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= +-----END CERTIFICATE----- +`; + +const CA_ENV = [ + "NEMOCLAW_CORPORATE_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", +]; +const tmpRoots: string[] = []; + +function clearCaEnv(): void { + for (const name of CA_ENV) { + delete process.env[name]; + } +} + +beforeEach(clearCaEnv); + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + clearCaEnv(); +}); + +function writeCa(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-arg-")); + tmpRoots.push(dir); + const file = path.join(dir, "corp-ca.pem"); + fs.writeFileSync(file, CA_PEM, { mode: 0o644 }); + fs.chmodSync(file, 0o644); + return file; +} + +function dockerfileWith(argLines: string[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-dockerfile-")); + tmpRoots.push(dir); + const file = path.join(dir, "Dockerfile"); + fs.writeFileSync(file, argLines.join("\n"), "utf-8"); + return file; +} + +const BASE_ARGS = [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + "ARG NEMOCLAW_PROXY_HOST=old", + "ARG NEMOCLAW_PROXY_PORT=old", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_OPENCLAW_OTEL=0", + "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", +]; + +function patch(dockerfilePath: string): void { + patchStagedDockerfile( + dockerfilePath, + "custom-model", + "https://chat.example", + "build-1", + "compatible-endpoint", + null, + null, + null, + false, + null, + [], + ); +} + +function corporateCaArgLine(dockerfilePath: string): string | undefined { + return fs + .readFileSync(dockerfilePath, "utf-8") + .split("\n") + .find((entry) => entry.startsWith("ARG NEMOCLAW_CORPORATE_CA_B64=")); +} + +describe("dockerfile patch — corporate CA baking (#6210)", () => { + it("bakes an explicit host corporate CA into NEMOCLAW_CORPORATE_CA_B64", () => { + process.env.NEMOCLAW_CORPORATE_CA_BUNDLE = writeCa(); + const dockerfilePath = dockerfileWith([...BASE_ARGS, "ARG NEMOCLAW_CORPORATE_CA_B64="]); + + patch(dockerfilePath); + + const line = corporateCaArgLine(dockerfilePath); + assert.ok(line, "expected corporate CA build arg"); + const encoded = line.slice("ARG NEMOCLAW_CORPORATE_CA_B64=".length); + expect(encoded).not.toBe(""); + expect(Buffer.from(encoded, "base64").toString("utf8")).toBe(CA_PEM); + }); + + it("bakes a fallback REQUESTS_CA_BUNDLE corporate CA", () => { + process.env.REQUESTS_CA_BUNDLE = writeCa(); + const dockerfilePath = dockerfileWith([...BASE_ARGS, "ARG NEMOCLAW_CORPORATE_CA_B64="]); + + patch(dockerfilePath); + + const line = corporateCaArgLine(dockerfilePath); + expect(line?.slice("ARG NEMOCLAW_CORPORATE_CA_B64=".length)).not.toBe(""); + }); + + it("leaves NEMOCLAW_CORPORATE_CA_B64 empty when no corporate CA is configured", () => { + const dockerfilePath = dockerfileWith([...BASE_ARGS, "ARG NEMOCLAW_CORPORATE_CA_B64="]); + + patch(dockerfilePath); + + expect(corporateCaArgLine(dockerfilePath)).toBe("ARG NEMOCLAW_CORPORATE_CA_B64="); + }); + + it("fails loudly when an explicit CA is set but the Dockerfile lacks the ARG", () => { + process.env.NEMOCLAW_CORPORATE_CA_BUNDLE = writeCa(); + const dockerfilePath = dockerfileWith(BASE_ARGS); + + expect(() => patch(dockerfilePath)).toThrow(/missing ARG NEMOCLAW_CORPORATE_CA_B64/); + }); + + it("stays a no-op for a fallback CA when a custom Dockerfile lacks the ARG", () => { + process.env.CURL_CA_BUNDLE = writeCa(); + const dockerfilePath = dockerfileWith(BASE_ARGS); + + expect(() => patch(dockerfilePath)).not.toThrow(); + expect(corporateCaArgLine(dockerfilePath)).toBeUndefined(); + }); +}); diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 403ef57ec9b..c1107988550 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -102,107 +102,6 @@ describe("dockerfile patch helpers", () => { expect(isValidProxyPort("70000")).toBe(false); }); - it("bakes the host corporate CA into NEMOCLAW_CORPORATE_CA_B64 (#6210)", () => { - const caDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-arg-")); - tmpRoots.push(caDir); - const caFile = path.join(caDir, "corp-ca.pem"); - const caPem = "-----BEGIN CERTIFICATE-----\nMIIBcorp\n-----END CERTIFICATE-----\n"; - fs.writeFileSync(caFile, caPem, { mode: 0o644 }); - process.env.NEMOCLAW_CORPORATE_CA_BUNDLE = caFile; - try { - const dockerfilePath = dockerfileWith( - [ - "ARG NEMOCLAW_MODEL=old", - "ARG NEMOCLAW_PROVIDER_KEY=old", - "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", - "ARG CHAT_UI_URL=old", - "ARG NEMOCLAW_INFERENCE_BASE_URL=old", - "ARG NEMOCLAW_INFERENCE_API=old", - "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", - "ARG NEMOCLAW_BUILD_ID=old", - "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", - "ARG NEMOCLAW_PROXY_HOST=old", - "ARG NEMOCLAW_PROXY_PORT=old", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", - "ARG NEMOCLAW_OPENCLAW_OTEL=0", - "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", - "ARG NEMOCLAW_CORPORATE_CA_B64=", - ].join("\n"), - ); - - patchStagedDockerfile( - dockerfilePath, - "custom-model", - "https://chat.example", - "build-1", - "compatible-endpoint", - null, - null, - null, - false, - null, - [], - ); - - const patched = fs.readFileSync(dockerfilePath, "utf-8"); - const line = patched - .split("\n") - .find((entry) => entry.startsWith("ARG NEMOCLAW_CORPORATE_CA_B64=")); - assert.ok(line, "expected corporate CA build arg"); - const encoded = line.slice("ARG NEMOCLAW_CORPORATE_CA_B64=".length); - expect(encoded).not.toBe(""); - expect(Buffer.from(encoded, "base64").toString("utf8")).toBe(caPem); - } finally { - delete process.env.NEMOCLAW_CORPORATE_CA_BUNDLE; - } - }); - - it("leaves NEMOCLAW_CORPORATE_CA_B64 empty when no corporate CA is configured", () => { - delete process.env.NEMOCLAW_CORPORATE_CA_BUNDLE; - delete process.env.REQUESTS_CA_BUNDLE; - delete process.env.CURL_CA_BUNDLE; - delete process.env.SSL_CERT_FILE; - const dockerfilePath = dockerfileWith( - [ - "ARG NEMOCLAW_MODEL=old", - "ARG NEMOCLAW_PROVIDER_KEY=old", - "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", - "ARG CHAT_UI_URL=old", - "ARG NEMOCLAW_INFERENCE_BASE_URL=old", - "ARG NEMOCLAW_INFERENCE_API=old", - "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", - "ARG NEMOCLAW_BUILD_ID=old", - "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", - "ARG NEMOCLAW_PROXY_HOST=old", - "ARG NEMOCLAW_PROXY_PORT=old", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", - "ARG NEMOCLAW_OPENCLAW_OTEL=0", - "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", - "ARG NEMOCLAW_CORPORATE_CA_B64=", - ].join("\n"), - ); - - patchStagedDockerfile( - dockerfilePath, - "custom-model", - "https://chat.example", - "build-1", - "compatible-endpoint", - null, - null, - null, - false, - null, - [], - ); - - const patched = fs.readFileSync(dockerfilePath, "utf-8"); - const line = patched - .split("\n") - .find((entry) => entry.startsWith("ARG NEMOCLAW_CORPORATE_CA_B64=")); - expect(line).toBe("ARG NEMOCLAW_CORPORATE_CA_B64="); - }); - it("fails when an OTEL env value has no matching Dockerfile ARG", () => { process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; const dockerfilePath = dockerfileWith( diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index cf9a3d0a71e..45d826ed64c 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -18,7 +18,11 @@ import { normalizeToolDisclosure, type ToolDisclosure, } from "../tool-disclosure"; -import { encodeCorporateCaArg, resolveCorporateCaFromEnv } from "./corporate-ca"; +import { + CORPORATE_CA_EXPLICIT_ENV, + encodeCorporateCaArg, + resolveCorporateCaFromEnv, +} from "./corporate-ca"; import { dockerfileInstructions, readDockerfilePatchSnapshot, @@ -329,10 +333,25 @@ export function patchStagedDockerfile( // a silent no-op on custom/legacy Dockerfiles that predate this ARG. const corporateCa = resolveCorporateCaFromEnv(process.env); if (corporateCa) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_CORPORATE_CA_B64=.*$/m, - `ARG NEMOCLAW_CORPORATE_CA_B64=${sanitizeDockerArg(encodeCorporateCaArg(corporateCa.pem))}`, - ); + const corporateCaArgPattern = /^ARG NEMOCLAW_CORPORATE_CA_B64=.*$/m; + if (corporateCaArgPattern.test(dockerfile)) { + dockerfile = dockerfile.replace( + corporateCaArgPattern, + `ARG NEMOCLAW_CORPORATE_CA_B64=${sanitizeDockerArg(encodeCorporateCaArg(corporateCa.pem))}`, + ); + // Surface which host source is being baked so a fallback import (from a + // conventional CA env var rather than the explicit opt-in) is never + // silent. The CA is a public certificate, so logging its source is safe. + console.error( + `[nemoclaw] baking corporate proxy CA from ${corporateCa.sourceEnv} (${corporateCa.sourcePath}) into the sandbox image trust (#6210)`, + ); + } else if (corporateCa.sourceEnv === CORPORATE_CA_EXPLICIT_ENV) { + // Explicit opt-in must not silently no-op on a managed Dockerfile. + throw new Error( + "Dockerfile is missing ARG NEMOCLAW_CORPORATE_CA_B64; cannot bake the corporate CA from NEMOCLAW_CORPORATE_CA_BUNDLE.", + ); + } + // Fallback source + a custom Dockerfile without the ARG: leave a no-op. } replaceDockerfilePatchSnapshot(dockerfilePath, patchSnapshot, dockerfile); From 03c09606455967d0f5a941ceafffc276e22df9ed Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Mon, 6 Jul 2026 04:25:58 +0000 Subject: [PATCH 04/26] fix(sandbox): correct Hermes CA env + validate all CA blocks (#6210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round on the corporate-proxy CA import: - hermes start.sh: the merge now exports CURL_CA_BUNDLE, REQUESTS_CA_BUNDLE, and GIT_SSL_CAINFO explicitly. Previously it set only SSL_CERT_FILE and relied on the downstream ${VAR:-…} defaulting, which kept an OpenShell-preset value pointing at the OpenShell-only bundle so Hermes curl/python/git would not trust the corporate CA after a merge. - corporate-ca.ts: structurally validate every PEM block as X.509, not just the first, so a valid leading cert cannot smuggle in a corrupt later block. - troubleshooting.mdx: stop suggesting NEMOCLAW_CORPORATE_CA_BUNDLE point at the full OS trust store (which the size/cert caps reject); tell users to export just their corporate root/intermediates into a PEM. - Tests: assert the Hermes merge overrides preset CURL/REQUESTS/GIT, cover a later-block structural rejection, and add a merge-failure negative test proving the entrypoint bails without exporting when the bundle can't be written. Signed-off-by: Yimo Jiang --- agents/hermes/start.sh | 6 ++++ docs/reference/troubleshooting.mdx | 2 +- src/lib/onboard/corporate-ca.test.ts | 5 +++ src/lib/onboard/corporate-ca.ts | 16 +++++----- test/corporate-ca-runtime-merge.test.ts | 41 +++++++++++++++++++++++-- 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 6ff0d2b56ca..a12dc24e796 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1446,7 +1446,13 @@ merge_corporate_proxy_ca() { rm -f "$_tmp" return 0 } + # Export all CA env vars explicitly (not via the ${VAR:-…} defaulting below, + # which would keep an OpenShell-preset CURL/REQUESTS/GIT value pointing at the + # OpenShell-only bundle instead of the merged one). export SSL_CERT_FILE="$_merged" + export CURL_CA_BUNDLE="$_merged" + export REQUESTS_CA_BUNDLE="$_merged" + export GIT_SSL_CAINFO="$_merged" export NODE_EXTRA_CA_CERTS="$_merged" export _NEMOCLAW_CORPORATE_CA_MERGED=1 echo "[nemoclaw] merged corporate proxy CA into sandbox trust bundle (#6210)" >&2 diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index aeb7c223fbf..4c96acf2f97 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -983,7 +983,7 @@ export NEMOCLAW_CORPORATE_CA_BUNDLE=/path/to/corporate-ca.pem $$nemoclaw onboard ``` -If your corporate CA lives only in the host system trust store, point the same variable at it explicitly (for example `NEMOCLAW_CORPORATE_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`). NemoClaw does not auto-scan the host trust store, so it never bakes an unrelated bundle without an explicit path. +If your corporate CA lives only in the host system trust store, export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at that file. NemoClaw does not auto-scan the host trust store, and it rejects a full OS trust store (`/etc/ssl/certs/ca-certificates.crt`) — the bundle is capped at a small corporate chain so it never bakes broad, unrelated roots into the image. When `NEMOCLAW_CORPORATE_CA_BUNDLE` is unset, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `SSL_CERT_FILE` are honored as fallbacks (in that order) so an environment that already exports one of those for the corporate proxy works without extra configuration. A fallback variable that points at a missing or invalid file is **skipped silently** (it does not fail onboarding); only the explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` fails loudly when invalid. When a fallback source is baked, onboarding logs which variable and path it used. diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index 31060438b5b..87be82136b4 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -116,6 +116,11 @@ describe("validateCorporateCaFile", () => { const p = writeCa(tmpDir(), BAD_PEM); expect(() => validateCorporateCaFile(p)).toThrow(/not a valid X\.509 certificate/); }); + + it("rejects a bundle whose later block is not a parseable X.509 certificate", () => { + const p = writeCa(tmpDir(), PEM + BAD_PEM); + expect(() => validateCorporateCaFile(p)).toThrow(/not a valid X\.509 certificate/); + }); }); describe("resolveCorporateCaFromEnv", () => { diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index 2e658fbc0f0..bb4ef08334f 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -129,14 +129,16 @@ export function validateCorporateCaFile(filePath: string): string { `corporate CA bundle has ${blocks.length} certificates (max ${MAX_CORPORATE_CA_CERTS}): ${filePath}`, ); } - // Structural check: the first block must parse as a real X.509 certificate, + // Structural check: every block must parse as a real X.509 certificate, // catching truncated/corrupt PEM at build time rather than at TLS handshake. - try { - new X509Certificate(blocks[0]); - } catch { - throw new CorporateCaValidationError( - `corporate CA bundle leading block is not a valid X.509 certificate: ${filePath}`, - ); + for (const block of blocks) { + try { + new X509Certificate(block); + } catch { + throw new CorporateCaValidationError( + `corporate CA bundle contains a block that is not a valid X.509 certificate: ${filePath}`, + ); + } } return content; } finally { diff --git a/test/corporate-ca-runtime-merge.test.ts b/test/corporate-ca-runtime-merge.test.ts index 3bdce8dddb2..e4e774e4933 100644 --- a/test/corporate-ca-runtime-merge.test.ts +++ b/test/corporate-ca-runtime-merge.test.ts @@ -110,22 +110,59 @@ describe("corporate proxy CA runtime merge (#6210)", () => { merged, ); + // Simulate OpenShell having pre-set CURL/REQUESTS/GIT to its own bundle; + // the merge must override them, not leave them pointing at OpenShell-only. const out = runShellLines(dir, [ `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + `export CURL_CA_BUNDLE=${JSON.stringify(openshell)}`, + `export REQUESTS_CA_BUNDLE=${JSON.stringify(openshell)}`, + `export GIT_SSL_CAINFO=${JSON.stringify(openshell)}`, hermesMerge, 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', + 'printf "CURL_CA_BUNDLE=%s\\n" "${CURL_CA_BUNDLE:-}"', + 'printf "REQUESTS_CA_BUNDLE=%s\\n" "${REQUESTS_CA_BUNDLE:-}"', + 'printf "GIT_SSL_CAINFO=%s\\n" "${GIT_SSL_CAINFO:-}"', 'printf "NODE_EXTRA_CA_CERTS=%s\\n" "${NODE_EXTRA_CA_CERTS:-}"', 'printf "MERGED=%s\\n" "${_NEMOCLAW_CORPORATE_CA_MERGED:-}"', ]); - expect(out).toContain(`SSL_CERT_FILE=${merged}`); - expect(out).toContain(`NODE_EXTRA_CA_CERTS=${merged}`); + for (const name of [ + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", + "GIT_SSL_CAINFO", + "NODE_EXTRA_CA_CERTS", + ]) { + expect(out).toContain(`${name}=${merged}`); + } expect(out).toContain("MERGED=1"); const mergedContent = readFileSync(merged, "utf-8"); expect(mergedContent).toContain("OPENSHELL-ROOT"); expect(mergedContent).toContain("CORPORATE-ROOT"); }); + it("bails without exporting when the merged bundle cannot be written", () => { + const dir = tmpDir("nemoclaw-corp-merge-fail-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + // A merged path under a non-existent directory makes mktemp fail. + const merged = join(dir, "no-such-dir", "merged-ca.pem"); + writeFileSync(openshell, OPENSHELL_PEM); + writeFileSync(corp, CORPORATE_PEM); + + const out = runShellLines(dir, [ + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + mergeBlock(OPENCLAW_START, "# Git TLS CA bundle fix (NemoClaw#2270).", corp, merged), + 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', + 'printf "MERGED=%s\\n" "${_NEMOCLAW_CORPORATE_CA_MERGED:-}"', + ]); + + // Merge bailed: OpenShell-only trust intact, no merge marker. + expect(out).toContain(`SSL_CERT_FILE=${openshell}`); + expect(out).toContain("MERGED=\n"); + expect(existsSync(merged)).toBe(false); + }); + it("persists the merged CA env into OpenClaw connect sessions only after a merge", () => { const dir = tmpDir("nemoclaw-corp-connect-"); const block = sliceBlock( From 577bfb79663b13633ec383df7bf791ce1da3b47d Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Mon, 6 Jul 2026 04:44:11 +0000 Subject: [PATCH 05/26] test(sandbox): add issue-ref suffixes and Hermes bail-path test (#6210) - Add the `(#6210)` issue-ref suffix to the runtime-merge test titles per the root-level test convention. - Add a Hermes bail-path negative test mirroring the OpenClaw one, proving the Hermes entrypoint also leaves OpenShell-only trust intact and sets no merge marker when the merged bundle cannot be written. Signed-off-by: Yimo Jiang --- test/corporate-ca-runtime-merge.test.ts | 35 +++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/test/corporate-ca-runtime-merge.test.ts b/test/corporate-ca-runtime-merge.test.ts index e4e774e4933..445332001c6 100644 --- a/test/corporate-ca-runtime-merge.test.ts +++ b/test/corporate-ca-runtime-merge.test.ts @@ -40,7 +40,7 @@ function mergeBlock(scriptPath: string, endMarker: string, corpCa: string, merge } describe("corporate proxy CA runtime merge (#6210)", () => { - it("appends the corporate CA to the OpenShell bundle for OpenClaw and repoints all CA env", () => { + it("appends the corporate CA to the OpenShell bundle for OpenClaw and repoints all CA env (#6210)", () => { const dir = tmpDir("nemoclaw-corp-merge-openclaw-"); const openshell = join(dir, "openshell-ca.pem"); const corp = join(dir, "corporate-ca.pem"); @@ -74,7 +74,7 @@ describe("corporate proxy CA runtime merge (#6210)", () => { expect(mergedContent).toContain("CORPORATE-ROOT"); }); - it("is a no-op for OpenClaw when no corporate CA was baked into the image", () => { + it("is a no-op for OpenClaw when no corporate CA was baked into the image (#6210)", () => { const dir = tmpDir("nemoclaw-corp-merge-noop-"); const openshell = join(dir, "openshell-ca.pem"); const absentCorp = join(dir, "absent-corporate-ca.pem"); @@ -93,7 +93,7 @@ describe("corporate proxy CA runtime merge (#6210)", () => { expect(existsSync(merged)).toBe(false); }); - it("appends the corporate CA and repoints SSL_CERT_FILE / NODE_EXTRA_CA_CERTS for Hermes", () => { + it("appends the corporate CA and repoints all CA env for Hermes (#6210)", () => { const dir = tmpDir("nemoclaw-corp-merge-hermes-"); const openshell = join(dir, "openshell-ca.pem"); const corp = join(dir, "corporate-ca.pem"); @@ -141,7 +141,7 @@ describe("corporate proxy CA runtime merge (#6210)", () => { expect(mergedContent).toContain("CORPORATE-ROOT"); }); - it("bails without exporting when the merged bundle cannot be written", () => { + it("bails without exporting when the OpenClaw merged bundle cannot be written (#6210)", () => { const dir = tmpDir("nemoclaw-corp-merge-fail-"); const openshell = join(dir, "openshell-ca.pem"); const corp = join(dir, "corporate-ca.pem"); @@ -163,7 +163,32 @@ describe("corporate proxy CA runtime merge (#6210)", () => { expect(existsSync(merged)).toBe(false); }); - it("persists the merged CA env into OpenClaw connect sessions only after a merge", () => { + it("bails without exporting when the Hermes merged bundle cannot be written (#6210)", () => { + const dir = tmpDir("nemoclaw-corp-merge-hermes-fail-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + const merged = join(dir, "no-such-dir", "merged-ca.pem"); + writeFileSync(openshell, OPENSHELL_PEM); + writeFileSync(corp, CORPORATE_PEM); + + const out = runShellLines(dir, [ + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + mergeBlock( + HERMES_START, + "# OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA.", + corp, + merged, + ), + 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', + 'printf "MERGED=%s\\n" "${_NEMOCLAW_CORPORATE_CA_MERGED:-}"', + ]); + + expect(out).toContain(`SSL_CERT_FILE=${openshell}`); + expect(out).toContain("MERGED=\n"); + expect(existsSync(merged)).toBe(false); + }); + + it("persists the merged CA env into OpenClaw connect sessions only after a merge (#6210)", () => { const dir = tmpDir("nemoclaw-corp-connect-"); const block = sliceBlock( OPENCLAW_START, From e2420fb9fca2553a9b1f35744650787a854c6808 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 6 Jul 2026 09:10:50 -0700 Subject: [PATCH 06/26] fix(security): harden corporate-CA merge against symlink + missing base64 Address the required PR-advisor findings on #6210: - PRA-3: the corporate-CA bundle merged into a predictable /tmp path (/tmp/nemoclaw-ca-bundle.pem). Before the rename, drop any pre-planted symlink at the target so we rename into a fresh regular file we own rather than through an attacker-controlled link. Applied to both scripts/nemoclaw-start.sh and agents/hermes/start.sh. - PRA-4: the Dockerfile decoded NEMOCLAW_CORPORATE_CA_B64 with `base64 --decode` without checking the tool exists; add a build-time `command -v base64` guard that fails the build with a clear message. - PRA-5 (justify, no change): GIT_SSL_CAINFO is already propagated to connect sessions by the pre-existing #2270 block whenever set (the merge exports it to the same bundle), so the corporate-CA propagation loop intentionally omits it to avoid a duplicate export; documented inline. corporate-ca-runtime-merge + corporate-ca-tls-e2e suites pass; shell syntax verified. SKIP=test-cli: shell/Dockerfile-only change; the full vitest hook can trip on pre-existing macOS bash 3.2 noise if this branch predates #6140. CI runs bash 5.x green. Signed-off-by: Prekshi Vyas --- Dockerfile | 1 + agents/hermes/start.sh | 6 ++++++ scripts/nemoclaw-start.sh | 9 +++++++++ 3 files changed, 16 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0454631936f..a30ab38d640 100644 --- a/Dockerfile +++ b/Dockerfile @@ -936,6 +936,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ # base64 sanitized host-side, so this is not an injection vector. # 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; }; \ mkdir -p /usr/local/share/nemoclaw \ && printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /usr/local/share/nemoclaw/corporate-ca.pem \ && chown root:root /usr/local/share/nemoclaw/corporate-ca.pem \ diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index a12dc24e796..ca7e0bae642 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1442,6 +1442,12 @@ merge_corporate_proxy_ca() { return 0 } chmod 0444 "$_tmp" 2>/dev/null || true + # Defense-in-depth for the predictable /tmp path (#6210): if a co-tenant + # pre-planted a symlink at the target, drop it first so we rename into a fresh + # regular file we own rather than through an attacker-controlled link. + if [ -L "$_merged" ]; then + rm -f "$_merged" 2>/dev/null || true + fi mv -f "$_tmp" "$_merged" 2>/dev/null || { rm -f "$_tmp" return 0 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 1abef6a07ca..abe6bbc2d57 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2752,6 +2752,12 @@ merge_corporate_proxy_ca() { return 0 } chmod 0444 "$_tmp" 2>/dev/null || true + # Defense-in-depth for the predictable /tmp path (#6210): if a co-tenant + # pre-planted a symlink at the target, drop it first so we rename into a fresh + # regular file we own rather than through an attacker-controlled link. + if [ -L "$_merged" ]; then + rm -f "$_merged" 2>/dev/null || true + fi mv -f "$_tmp" "$_merged" 2>/dev/null || { rm -f "$_tmp" return 0 @@ -3333,6 +3339,9 @@ GUARDENVEOF # Corporate proxy CA for connect sessions (NemoClaw#6210). Only when a # corporate CA was merged at entrypoint startup; keeps the no-corporate-CA # path byte-for-byte identical so #1828 behavior is untouched. + # GIT_SSL_CAINFO is intentionally NOT in this list: the #2270 block above + # already propagates it whenever set (the merge exports it to the same + # bundle), so adding it here would emit a duplicate export. if [ "${_NEMOCLAW_CORPORATE_CA_MERGED:-}" = "1" ]; then for _ca_env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE NODE_EXTRA_CA_CERTS; do _ca_env_value="${!_ca_env_name:-}" From 04d9a512517bcd8d523758be4211a1136717941d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 6 Jul 2026 11:16:33 -0700 Subject: [PATCH 07/26] test(rebuild): make prepared-recovery acceptance self-contained (#6210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rebuild-prepared-recovery.test.ts relied on NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE being set ambiently by whatever ran earlier. The cli project runs fileParallelism:false in a single fork, so when rebuild-usage-notice (which exercises the not-accepted path) runs earlier in a shard it leaves the flag unset, and the rebuild preflight bails with 'Third-party software notice was not accepted' — an order-dependent flake that surfaced on cli-test-shards (2). Set the flag in beforeEach and snapshot/restore it so the test is deterministic regardless of file order. Signed-off-by: Prekshi Vyas Co-Authored-By: Claude Opus 4.8 (1M context) --- .../actions/sandbox/rebuild-prepared-recovery.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts index ed97f54c0c3..291ec7b87de 100644 --- a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts @@ -8,11 +8,20 @@ import { snapshotEnv, } from "../../../../test/helpers/rebuild-flow-harness"; -const restoreSandboxEnv = snapshotEnv(["NEMOCLAW_SANDBOX_NAME"]); +const restoreSandboxEnv = snapshotEnv([ + "NEMOCLAW_SANDBOX_NAME", + "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", +]); describe("prepared rebuild recovery", () => { beforeEach(() => { delete process.env.NEMOCLAW_SANDBOX_NAME; + // Set acceptance explicitly rather than inheriting it ambiently: the cli + // project runs fileParallelism:false in a single fork, so an earlier file + // (e.g. rebuild-usage-notice) that exercises the not-accepted path can + // leave this flag unset and make the rebuild preflight bail here. Snapshot + // + restore keeps the leak from flowing the other way. (#6210) + process.env.NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE = "1"; }); afterEach(() => { From bccdc7cabe708d22109820e594540a6e343cc213 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Mon, 6 Jul 2026 13:19:00 -0700 Subject: [PATCH 08/26] docs(onboard): narrow #6210 corporate-CA scope to env-configured sources (PRA-3) The issue's expected result named the host /etc/ssl/certs/ trust store as a detection source, but auto-scanning an OS trust store would bake broad, unrelated roots into the sandbox image. Formally narrow #6210 to the explicit NEMOCLAW_CORPORATE_CA_BUNDLE plus REQUESTS_CA_BUNDLE/CURL_CA_BUNDLE/ SSL_CERT_FILE fallbacks: document the intentional exclusion, add a rationale comment on the source-of-truth env list, and assert the contract with a test that a host-store-only CA is not auto-discovered. Signed-off-by: Prekshi Vyas --- src/lib/onboard/corporate-ca.test.ts | 15 +++++++++++++++ src/lib/onboard/corporate-ca.ts | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index 87be82136b4..08b650f283f 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -128,6 +128,21 @@ describe("resolveCorporateCaFromEnv", () => { expect(resolveCorporateCaFromEnv({})).toBeNull(); }); + it("does not auto-scan the host trust store, honoring only env-configured sources (#6210)", () => { + // A corporate CA present only in a host /etc/ssl/certs/-style location must + // not be auto-discovered. #6210 is intentionally narrowed to the explicit + // bundle plus REQUESTS_CA_BUNDLE/CURL_CA_BUNDLE/SSL_CERT_FILE fallbacks; + // scanning the host store would bake broad, unrelated OS trust into the + // image. The same file resolves only when a var points at it, proving the + // null is the no-auto-scan contract and not an invalid fixture. + const hostStore = tmpDir(); + const hostCa = writeCa(hostStore); + expect(resolveCorporateCaFromEnv({})).toBeNull(); + expect(resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: hostCa })?.sourcePath).toBe( + hostCa, + ); + }); + it("resolves the explicit env var first", () => { const p = writeCa(tmpDir()); const resolved = resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: p }); diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index bb4ef08334f..31dfb80fd40 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -28,6 +28,15 @@ import fs from "node:fs"; * env vars the reporter already exports for their corporate proxy; when one of * those points at a missing/invalid file we skip it silently rather than break * an onboard that never asked for a corporate CA. + * + * Scope (#6210, intentionally narrowed): detection is limited to these + * env-configured sources. NemoClaw deliberately does NOT auto-scan the host + * system trust store (`/etc/ssl/certs/`): importing an OS trust store wholesale + * would bake broad, unrelated roots into the sandbox image and widen its trust + * far beyond the one corporate proxy CA the user needs. A host-store-only CA + * must be exported into a small PEM and pointed at via one of these vars. This + * boundary is asserted by `corporate-ca.test.ts` ("does not auto-scan the host + * trust store") and documented in `docs/reference/troubleshooting.mdx`. */ export const CORPORATE_CA_EXPLICIT_ENV = "NEMOCLAW_CORPORATE_CA_BUNDLE"; export const CORPORATE_CA_FALLBACK_ENV_VARS = [ From 01d4fff3e085b219a0d40410a0eee60e5f8a64a7 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 8 Jul 2026 08:14:14 +0000 Subject: [PATCH 09/26] fix(onboard): detect host trust-store corporate CA and bake only validated certs (#6210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the two blockers on #6210: - Security: validateCorporateCaFile returned the raw file contents after validating its CERTIFICATE blocks, so an adjacent private key or arbitrary payload could be baked into the image build context. It now returns only the normalized validated certificate blocks; everything else is dropped. - Acceptance scope: implement the host trust-store detection path #6210 asks for. resolveCorporateCaFromHostAnchors recursively scans the administrator anchor source dirs (/usr/local/share/ca-certificates for Debian .crt, /etc/pki/ca-trust/source/anchors for RHEL .pem/.crt/.cer) — never the merged /etc/ssl/certs output that carries the distro's public roots — so onboard imports exactly the corporate root an admin installed without trust-bloat. resolveCorporateCa chains env vars then host anchors. Bounded by depth/file/ dir caps plus the existing cert/byte caps; overridable/disablable via NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS. Also: log the fallback source when baking (regression test), use argv-based openssl helpers in the TLS test support, and document the host-store detection and every-block validation in troubleshooting.mdx. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Yimo Jiang --- docs/reference/troubleshooting.mdx | 12 +- src/lib/onboard/corporate-ca.test.ts | 165 +++++++++++- src/lib/onboard/corporate-ca.ts | 238 ++++++++++++++++-- .../dockerfile-patch-corporate-ca.test.ts | 26 +- src/lib/onboard/dockerfile-patch.ts | 11 +- test/helpers/corporate-ca-support.ts | 67 ++++- 6 files changed, 469 insertions(+), 50 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 4c96acf2f97..2961da09ec7 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -983,11 +983,17 @@ export NEMOCLAW_CORPORATE_CA_BUNDLE=/path/to/corporate-ca.pem $$nemoclaw onboard ``` -If your corporate CA lives only in the host system trust store, export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at that file. NemoClaw does not auto-scan the host trust store, and it rejects a full OS trust store (`/etc/ssl/certs/ca-certificates.crt`) — the bundle is capped at a small corporate chain so it never bakes broad, unrelated roots into the image. +If your corporate CA is already installed in the host system trust store, NemoClaw detects it automatically. It scans the administrator anchor directories (recursively, matching `update-ca-certificates`) — `/usr/local/share/ca-certificates/` on Debian/Ubuntu and `/etc/pki/ca-trust/source/anchors/` on RHEL/Fedora — which is where an administrator drops a root CA before running `update-ca-certificates` / `update-ca-trust`. NemoClaw deliberately does **not** read the merged `/etc/ssl/certs/ca-certificates.crt`, which also contains the distro's public roots; it imports only these bounded anchor sources so it never bakes broad, unrelated OS trust into the image. This host-store detection is a last-resort fallback, tried only after the environment variables above. To scan a non-standard anchor location, set `NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS` to a path-list of directories; set it to an empty value to disable host-store scanning. -When `NEMOCLAW_CORPORATE_CA_BUNDLE` is unset, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `SSL_CERT_FILE` are honored as fallbacks (in that order) so an environment that already exports one of those for the corporate proxy works without extra configuration. A fallback variable that points at a missing or invalid file is **skipped silently** (it does not fail onboarding); only the explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` fails loudly when invalid. When a fallback source is baked, onboarding logs which variable and path it used. +Resolution order: -The bundle must be a readable, non-symlink, non-world-writable PEM file whose leading block parses as an X.509 certificate, capped at a small corporate chain (not a full OS trust store). To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. +1. Explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` (fails onboarding loudly when set but invalid). +2. Conventional CA variables `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, then `SSL_CERT_FILE` (each **skipped silently** when it points at a missing or invalid file). +3. Host administrator anchor directories (skipped silently when absent or unusable). + +When a fallback or host-store source is baked, onboarding logs which source and path it used. To be fully explicit — or to import a corporate root that is not installed in an anchor directory — export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at it. + +Every imported source must be a readable, non-symlink, non-world-writable PEM file in which **every** certificate block parses as an X.509 certificate, and the imported trust is capped at a small corporate chain (not a full OS trust store) — a source that would exceed the cap is rejected or skipped rather than truncated. To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. ### A request inside the sandbox fails with `CONNECT tunnel failed, response 403` diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index 08b650f283f..3900e19a2fc 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -8,13 +8,17 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + CORPORATE_CA_ANCHOR_DIRS_ENV, CORPORATE_CA_DISABLE_ENV, CORPORATE_CA_EXPLICIT_ENV, + CORPORATE_CA_HOST_ANCHOR_SOURCE, CorporateCaValidationError, encodeCorporateCaArg, MAX_CORPORATE_CA_BYTES, MAX_CORPORATE_CA_CERTS, + resolveCorporateCa, resolveCorporateCaFromEnv, + resolveCorporateCaFromHostAnchors, validateCorporateCaFile, } from "./corporate-ca"; @@ -42,6 +46,15 @@ J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= `; // PEM-shaped but not a parseable certificate. const BAD_PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n"; +// A private-key block that must never survive into the returned/baked bundle. +// Markers are assembled at runtime so the fixture is not itself flagged as a +// committed private key by the secret scanners. +const KEY_LABEL = `${"PRIVATE"} KEY`; +const PRIVATE_KEY = `-----BEGIN ${KEY_LABEL}----- +MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEA3+SuP4mGqjr9Vd0F +super-secret-key-material-that-must-not-be-baked-into-the-image +-----END ${KEY_LABEL}----- +`; const tmpRoots: string[] = []; function tmpDir(): string { @@ -57,6 +70,13 @@ function writeCa(dir: string, contents = PEM, mode = 0o644): string { return p; } +function writeAnchor(dir: string, name: string, contents = PEM, mode = 0o644): string { + const p = path.join(dir, name); + fs.writeFileSync(p, contents, { mode }); + fs.chmodSync(p, mode); + return p; +} + afterEach(() => { for (const dir of tmpRoots.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -121,6 +141,31 @@ describe("validateCorporateCaFile", () => { const p = writeCa(tmpDir(), PEM + BAD_PEM); expect(() => validateCorporateCaFile(p)).toThrow(/not a valid X\.509 certificate/); }); + + it("returns only the certificate block, dropping an adjacent private key", () => { + const p = writeCa(tmpDir(), `${PEM}\n${PRIVATE_KEY}`); + const result = validateCorporateCaFile(p); + expect(result).toContain("BEGIN CERTIFICATE"); + expect(result).not.toContain("PRIVATE KEY"); + expect(result).not.toContain("super-secret-key-material"); + }); + + it("drops arbitrary non-certificate text surrounding the certificate", () => { + const p = writeCa(tmpDir(), `# corp bundle exported 2026\n${PEM}\ntrailing secret note\n`); + const result = validateCorporateCaFile(p); + expect(result).toContain("BEGIN CERTIFICATE"); + expect(result).not.toContain("corp bundle exported"); + expect(result).not.toContain("trailing secret note"); + }); + + it("returns a normalized bundle of exactly the validated certificate blocks", () => { + const p = writeCa(tmpDir(), `\n\n${PEM}\n${PEM}\n\n`); + const result = validateCorporateCaFile(p); + const blocks = result.match(/-----BEGIN CERTIFICATE-----/g) ?? []; + expect(blocks).toHaveLength(2); + expect(result.endsWith("-----END CERTIFICATE-----\n")).toBe(true); + expect(result.startsWith("-----BEGIN CERTIFICATE-----")).toBe(true); + }); }); describe("resolveCorporateCaFromEnv", () => { @@ -128,19 +173,10 @@ describe("resolveCorporateCaFromEnv", () => { expect(resolveCorporateCaFromEnv({})).toBeNull(); }); - it("does not auto-scan the host trust store, honoring only env-configured sources (#6210)", () => { - // A corporate CA present only in a host /etc/ssl/certs/-style location must - // not be auto-discovered. #6210 is intentionally narrowed to the explicit - // bundle plus REQUESTS_CA_BUNDLE/CURL_CA_BUNDLE/SSL_CERT_FILE fallbacks; - // scanning the host store would bake broad, unrelated OS trust into the - // image. The same file resolves only when a var points at it, proving the - // null is the no-auto-scan contract and not an invalid fixture. - const hostStore = tmpDir(); - const hostCa = writeCa(hostStore); + it("does not read the host trust store from env resolution alone (#6210)", () => { + // resolveCorporateCaFromEnv is env-only; host anchor discovery lives in + // resolveCorporateCaFromHostAnchors / resolveCorporateCa. expect(resolveCorporateCaFromEnv({})).toBeNull(); - expect(resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: hostCa })?.sourcePath).toBe( - hostCa, - ); }); it("resolves the explicit env var first", () => { @@ -190,6 +226,111 @@ describe("resolveCorporateCaFromEnv", () => { }); }); +describe("resolveCorporateCaFromHostAnchors host trust-store path (#6210)", () => { + it("discovers a corporate root installed in a host anchor directory", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp-proxy-root.crt"); + const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_HOST_ANCHOR_SOURCE); + expect(resolved?.sourcePath).toBe(anchorDir); + expect(resolved?.pem).toContain("BEGIN CERTIFICATE"); + }); + + it("returns the first anchor directory that yields a bundle", () => { + const missing = path.join(tmpDir(), "absent"); + const present = tmpDir(); + writeAnchor(present, "corp.crt"); + expect(resolveCorporateCaFromHostAnchors([missing, present])?.sourcePath).toBe(present); + }); + + it("returns null when no anchor directory exists", () => { + expect( + resolveCorporateCaFromHostAnchors([path.join(tmpDir(), "nope"), path.join(tmpDir(), "gone")]), + ).toBeNull(); + }); + + it("ignores non-anchor files and empty directories", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "README.txt", "not a cert\n"); + expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); + }); + + it("skips a directory whose aggregate exceeds the certificate cap", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "many.crt", PEM.repeat(MAX_CORPORATE_CA_CERTS + 1)); + expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); + }); + + it("aggregates multiple anchor files into one bundle", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "root-a.crt"); + writeAnchor(anchorDir, "root-b.crt"); + const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); + expect(resolved?.pem.match(/-----BEGIN CERTIFICATE-----/g)).toHaveLength(2); + }); + + it("accepts .pem/.cer anchors in an operator-supplied directory", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp-root.pem"); + expect(resolveCorporateCaFromHostAnchors([anchorDir])?.pem).toContain("BEGIN CERTIFICATE"); + }); + + it("discovers a corporate root nested in an anchor subdirectory", () => { + // update-ca-certificates trusts .crt files recursively, e.g. + // /usr/local/share/ca-certificates/acme/root.crt. + const anchorDir = tmpDir(); + const sub = path.join(anchorDir, "acme"); + fs.mkdirSync(sub); + writeAnchor(sub, "root.crt"); + expect(resolveCorporateCaFromHostAnchors([anchorDir])?.pem).toContain("BEGIN CERTIFICATE"); + }); +}); + +describe("resolveCorporateCa env then host anchors (#6210)", () => { + it("prefers an env-configured CA over the host anchor directory", () => { + const envCa = writeCa(tmpDir()); + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp.crt"); + const resolved = resolveCorporateCa( + { [CORPORATE_CA_EXPLICIT_ENV]: envCa }, + { hostAnchorDirs: [anchorDir] }, + ); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_EXPLICIT_ENV); + expect(resolved?.sourcePath).toBe(envCa); + }); + + it("falls back to the host anchor directory when no env var is set", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp.crt"); + const resolved = resolveCorporateCa({}, { hostAnchorDirs: [anchorDir] }); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_HOST_ANCHOR_SOURCE); + }); + + it("honors the disable opt-out even when a host anchor exists", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp.crt"); + expect( + resolveCorporateCa({ [CORPORATE_CA_DISABLE_ENV]: "0" }, { hostAnchorDirs: [anchorDir] }), + ).toBeNull(); + }); + + it("returns null when neither env nor host anchors provide a CA", () => { + expect(resolveCorporateCa({}, { hostAnchorDirs: [path.join(tmpDir(), "absent")] })).toBeNull(); + }); + + it("reads host anchor directories from the anchor-dirs env override", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp.crt"); + const resolved = resolveCorporateCa({ [CORPORATE_CA_ANCHOR_DIRS_ENV]: anchorDir }); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_HOST_ANCHOR_SOURCE); + expect(resolved?.sourcePath).toBe(anchorDir); + }); + + it("disables host-store scanning when the anchor-dirs override is empty", () => { + expect(resolveCorporateCa({ [CORPORATE_CA_ANCHOR_DIRS_ENV]: "" })).toBeNull(); + }); +}); + describe("encodeCorporateCaArg", () => { it("produces single-line base64 that round-trips", () => { const encoded = encodeCorporateCaArg(PEM); diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index 31dfb80fd40..51e1689e12f 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -3,6 +3,7 @@ import { X509Certificate } from "node:crypto"; import fs from "node:fs"; +import path from "node:path"; /** * Host corporate-proxy CA import (#6210). @@ -28,15 +29,6 @@ import fs from "node:fs"; * env vars the reporter already exports for their corporate proxy; when one of * those points at a missing/invalid file we skip it silently rather than break * an onboard that never asked for a corporate CA. - * - * Scope (#6210, intentionally narrowed): detection is limited to these - * env-configured sources. NemoClaw deliberately does NOT auto-scan the host - * system trust store (`/etc/ssl/certs/`): importing an OS trust store wholesale - * would bake broad, unrelated roots into the sandbox image and widen its trust - * far beyond the one corporate proxy CA the user needs. A host-store-only CA - * must be exported into a small PEM and pointed at via one of these vars. This - * boundary is asserted by `corporate-ca.test.ts` ("does not auto-scan the host - * trust store") and documented in `docs/reference/troubleshooting.mdx`. */ export const CORPORATE_CA_EXPLICIT_ENV = "NEMOCLAW_CORPORATE_CA_BUNDLE"; export const CORPORATE_CA_FALLBACK_ENV_VARS = [ @@ -45,6 +37,77 @@ export const CORPORATE_CA_FALLBACK_ENV_VARS = [ "SSL_CERT_FILE", ] as const; +/** + * Anchor-file extensions each host trust tool actually installs. Debian/Ubuntu + * `update-ca-certificates` installs only `*.crt` from its anchor dir; RHEL/Fedora + * `update-ca-trust` accepts `*.pem`/`*.crt`/`*.cer`. Matching per-directory keeps + * us from importing a staged/backup PEM that is not actually in the host store. + */ +const DEBIAN_ANCHOR_EXT_RE = /\.crt$/i; +const RHEL_ANCHOR_EXT_RE = /\.(?:pem|crt|cer)$/i; + +/** + * Default host trust-store anchor directories and the extensions each installs. + * These are the *administrator-managed anchor source* dirs — not the merged + * `/etc/ssl/certs/` output (see {@link CORPORATE_CA_HOST_ANCHOR_DIRS}). + */ +const DEFAULT_HOST_ANCHOR_SPECS = [ + { dir: "/usr/local/share/ca-certificates", extensions: DEBIAN_ANCHOR_EXT_RE }, + { dir: "/etc/pki/ca-trust/source/anchors", extensions: RHEL_ANCHOR_EXT_RE }, +] as const; + +/** + * Host trust-store anchor directories scanned as a last resort (#6210 + * acceptance path). These hold ONLY locally-added anchors: the distro's ~140 + * public roots live elsewhere and are compiled into the merged + * `/etc/ssl/certs/ca-certificates.crt` output — which we deliberately do NOT + * scan. Reading the anchor sources lets us import exactly the corporate root the + * reporter installed on the DGX Station host without baking broad, unrelated OS + * trust into the image. Discovery is bounded by {@link MAX_CORPORATE_CA_CERTS} / + * {@link MAX_CORPORATE_CA_BYTES}; a directory that would exceed those caps is + * skipped rather than truncated. + */ +export const CORPORATE_CA_HOST_ANCHOR_DIRS = DEFAULT_HOST_ANCHOR_SPECS.map( + (spec) => spec.dir, +) as readonly string[]; + +/** + * Override the host anchor directories scanned. A path-list (`path.delimiter` + * separated). Set to an empty value to disable host-store scanning entirely. + * Lets operators on non-standard distros point at their anchor location, and + * keeps host-store discovery deterministic under test. + */ +export const CORPORATE_CA_ANCHOR_DIRS_ENV = "NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS"; + +/** Reported `sourceEnv` when a CA is discovered from the host anchor dirs. */ +export const CORPORATE_CA_HOST_ANCHOR_SOURCE = "host trust store"; + +/** + * Recognized extensions for a directory not in {@link DEFAULT_HOST_ANCHOR_SPECS} + * (an operator-supplied override): accept the broader RHEL-style set since the + * operator pointed at it explicitly. + */ +function anchorExtensionsFor(dir: string): RegExp { + return ( + DEFAULT_HOST_ANCHOR_SPECS.find((spec) => spec.dir === dir)?.extensions ?? RHEL_ANCHOR_EXT_RE + ); +} + +/** + * Bounds on the recursive anchor-directory walk. `update-ca-certificates` + * trusts `.crt` files *recursively* under the anchor dir, so discovery must + * descend subdirectories; these caps keep a pathological tree from turning + * discovery into an unbounded scan. + */ +const HOST_ANCHOR_MAX_DEPTH = 8; +const HOST_ANCHOR_MAX_FILES = 256; +/** + * Cap on directories visited during the walk. Bounds the scan even when an + * override points at a broad tree (e.g. `/` or `$HOME`) with few matching + * certificate files, so `HOST_ANCHOR_MAX_FILES` alone cannot stop it. + */ +const HOST_ANCHOR_MAX_DIRS = 1024; + /** Opt-out: set to a falsey token to disable corporate CA import entirely. */ export const CORPORATE_CA_DISABLE_ENV = "NEMOCLAW_CORPORATE_CA_IMPORT"; @@ -80,14 +143,31 @@ export class CorporateCaValidationError extends Error { } /** - * Validate a candidate corporate CA bundle file and return its PEM text. + * Join validated PEM CERTIFICATE blocks into a normalized bundle. + * + * Returns *only* the certificate blocks — each trimmed of surrounding + * whitespace and separated by a single newline, with a trailing newline. Any + * bytes outside the CERTIFICATE blocks in the source file (an adjacent private + * key, comments, arbitrary text) are dropped, so nothing but the validated + * public certificates is ever baked into the image build context. + */ +function normalizeCertificateBlocks(blocks: readonly string[]): string { + return `${blocks.map((block) => block.trim()).join("\n")}\n`; +} + +/** + * Validate a candidate corporate CA bundle file and return normalized PEM text. * * Opens the file once with `O_NOFOLLOW` and validates the *opened* descriptor * (via `fstat`, then reads from the same fd) so a symlink/file swap between * check and use cannot slip a different file past validation. Rejects * symlinks, non-regular files, empty/oversized files, world-writable sources, - * bundles with no or too many PEM CERTIFICATE blocks, and a leading block that - * is not a parseable X.509 certificate. + * bundles with no or too many PEM CERTIFICATE blocks, and any block that is not + * a parseable X.509 certificate. + * + * Returns a bundle containing only the validated CERTIFICATE blocks (via + * {@link normalizeCertificateBlocks}); adjacent private keys or arbitrary + * payload in the source file are never returned or baked into the image. */ export function validateCorporateCaFile(filePath: string): string { let fd: number; @@ -149,7 +229,10 @@ export function validateCorporateCaFile(filePath: string): string { ); } } - return content; + // Return only the validated certificate blocks. Anything else in the file + // (an adjacent private key, comments, arbitrary payload) is intentionally + // dropped so it can never be copied into the build context / image layers. + return normalizeCertificateBlocks(blocks); } finally { fs.closeSync(fd); } @@ -172,10 +255,11 @@ function isDisabled(env: NodeJS.ProcessEnv): boolean { /** * Resolve a corporate CA bundle from the host environment. * - * Returns `null` when no corporate CA is configured (or import is disabled). - * Throws {@link CorporateCaValidationError} only when the *explicit* + * Returns `null` when no corporate CA env var is configured (or import is + * disabled). Throws {@link CorporateCaValidationError} only when the *explicit* * `NEMOCLAW_CORPORATE_CA_BUNDLE` is set to an invalid path; invalid fallback - * env vars are skipped silently. + * env vars are skipped silently. Does not touch the host trust store — see + * {@link resolveCorporateCaFromHostAnchors} and {@link resolveCorporateCa}. */ export function resolveCorporateCaFromEnv( env: NodeJS.ProcessEnv = process.env, @@ -206,6 +290,128 @@ export function resolveCorporateCaFromEnv( return null; } +/** + * Recursively collect anchor certificate files under a directory, bounded by + * {@link HOST_ANCHOR_MAX_DEPTH} / {@link HOST_ANCHOR_MAX_FILES}. Symlinked files + * and directories are skipped (a symlink `Dirent` is neither `isFile()` nor + * `isDirectory()`), so the walk cannot follow a link out of the anchor tree or + * loop. Returns paths in deterministic sorted order. + */ +function collectAnchorFiles(root: string, extensions: RegExp): string[] { + const out: string[] = []; + let dirsVisited = 0; + const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; + while ( + stack.length > 0 && + out.length < HOST_ANCHOR_MAX_FILES && + dirsVisited < HOST_ANCHOR_MAX_DIRS + ) { + const current = stack.pop(); + if (current === undefined) break; + dirsVisited += 1; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current.dir, { withFileTypes: true }); + } catch { + continue; // Absent/unreadable directory — skip it. + } + for (const entry of entries) { + if (out.length >= HOST_ANCHOR_MAX_FILES) break; // Enforce the cap mid-directory. + const full = path.join(current.dir, entry.name); + if (entry.isDirectory() && current.depth < HOST_ANCHOR_MAX_DEPTH) { + stack.push({ dir: full, depth: current.depth + 1 }); + } else if (entry.isFile() && extensions.test(entry.name)) { + out.push(full); + } + } + } + return out.sort(); +} + +/** + * Resolve a corporate CA from the host administrator-managed anchor directories + * (#6210 acceptance path). See {@link CORPORATE_CA_HOST_ANCHOR_DIRS} for why + * these bounded source dirs — not the merged `/etc/ssl/certs/` output — are the + * safe place to detect an installed corporate root. Each directory is scanned + * recursively (matching `update-ca-certificates`), bounded by the depth/file + * caps above. + * + * Returns `null` when no anchor directory yields a usable, bounded bundle. + * Never throws: an unreadable/invalid/oversized anchor set is skipped silently + * (this is an implicit fallback, like the conventional CA env vars). + */ +export function resolveCorporateCaFromHostAnchors( + dirs: readonly string[] = CORPORATE_CA_HOST_ANCHOR_DIRS, +): ResolvedCorporateCa | null { + for (const dir of dirs) { + const files = collectAnchorFiles(dir, anchorExtensionsFor(dir)); + const blocks: string[] = []; + for (const file of files) { + try { + // validateCorporateCaFile enforces per-file symlink/size/mode/cert + // checks and returns normalized certificate blocks only. + blocks.push(validateCorporateCaFile(file).trim()); + } catch { + // Skip an unreadable/invalid anchor file rather than fail discovery. + } + } + if (blocks.length === 0) continue; + const pem = normalizeCertificateBlocks(blocks); + // Aggregate caps: keep the imported trust scoped to a corporate chain. A + // directory that would exceed the caps is skipped, never truncated. + const certCount = pem.match(PEM_CERTIFICATE_RE_GLOBAL)?.length ?? 0; + if (certCount === 0 || certCount > MAX_CORPORATE_CA_CERTS) continue; + if (Buffer.byteLength(pem, "utf8") > MAX_CORPORATE_CA_BYTES) continue; + return { pem, sourcePath: dir, sourceEnv: CORPORATE_CA_HOST_ANCHOR_SOURCE }; + } + return null; +} + +/** + * Resolve the host anchor directories to scan: the {@link + * CORPORATE_CA_ANCHOR_DIRS_ENV} override when set (empty value → no scan), else + * the built-in {@link CORPORATE_CA_HOST_ANCHOR_DIRS}. Returns `null` when the + * override is unset so the caller can fall back to the defaults. + */ +function hostAnchorDirsFromEnv(env: NodeJS.ProcessEnv): readonly string[] | null { + const raw = env[CORPORATE_CA_ANCHOR_DIRS_ENV]; + if (raw === undefined) return null; + return raw + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +export interface ResolveCorporateCaOptions { + /** Override the host anchor directories scanned (testing seam). */ + hostAnchorDirs?: readonly string[]; +} + +/** + * Resolve a corporate CA bundle for the sandbox image (#6210). + * + * Resolution order: + * 1. Explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` (fail-loud when invalid). + * 2. Conventional CA env vars (`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, + * `SSL_CERT_FILE`), skipped silently when invalid. + * 3. Host administrator-managed anchor directories (overridable/disablable + * via {@link CORPORATE_CA_ANCHOR_DIRS_ENV}), skipped silently. + * + * Returns `null` when nothing is configured or import is disabled via + * `NEMOCLAW_CORPORATE_CA_IMPORT`. + */ +export function resolveCorporateCa( + env: NodeJS.ProcessEnv = process.env, + options: ResolveCorporateCaOptions = {}, +): ResolvedCorporateCa | null { + if (isDisabled(env)) return null; + const fromEnv = resolveCorporateCaFromEnv(env); + if (fromEnv) return fromEnv; + const anchorDirs = + options.hostAnchorDirs ?? hostAnchorDirsFromEnv(env) ?? CORPORATE_CA_HOST_ANCHOR_DIRS; + return resolveCorporateCaFromHostAnchors(anchorDirs); +} + /** Base64-encode PEM text for a single-line Dockerfile ARG value. */ export function encodeCorporateCaArg(pem: string): string { return Buffer.from(pem, "utf8") diff --git a/src/lib/onboard/dockerfile-patch-corporate-ca.test.ts b/src/lib/onboard/dockerfile-patch-corporate-ca.test.ts index 26a4ef688a3..8529a024a03 100644 --- a/src/lib/onboard/dockerfile-patch-corporate-ca.test.ts +++ b/src/lib/onboard/dockerfile-patch-corporate-ca.test.ts @@ -10,7 +10,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { patchStagedDockerfile } from "./dockerfile-patch"; @@ -42,6 +42,7 @@ const CA_ENV = [ "CURL_CA_BUNDLE", "SSL_CERT_FILE", ]; +const ANCHOR_DIRS_ENV = "NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS"; const tmpRoots: string[] = []; function clearCaEnv(): void { @@ -50,13 +51,19 @@ function clearCaEnv(): void { } } -beforeEach(clearCaEnv); +// Disable host trust-store scanning so these tests never depend on real host CA +// state (e.g. a corporate dev machine with an installed anchor). +beforeEach(() => { + clearCaEnv(); + process.env[ANCHOR_DIRS_ENV] = ""; +}); afterEach(() => { for (const dir of tmpRoots.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } clearCaEnv(); + delete process.env[ANCHOR_DIRS_ENV]; }); function writeCa(): string { @@ -140,6 +147,21 @@ describe("dockerfile patch — corporate CA baking (#6210)", () => { expect(line?.slice("ARG NEMOCLAW_CORPORATE_CA_B64=".length)).not.toBe(""); }); + it("logs the fallback source env var and path when baking a fallback CA", () => { + const caPath = writeCa(); + process.env.REQUESTS_CA_BUNDLE = caPath; + const dockerfilePath = dockerfileWith([...BASE_ARGS, "ARG NEMOCLAW_CORPORATE_CA_B64="]); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + patch(dockerfilePath); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + const bakeLog = messages.find((message) => message.includes("corporate proxy CA")); + expect(bakeLog).toContain("REQUESTS_CA_BUNDLE"); + expect(bakeLog).toContain(caPath); + }); + it("leaves NEMOCLAW_CORPORATE_CA_B64 empty when no corporate CA is configured", () => { const dockerfilePath = dockerfileWith([...BASE_ARGS, "ARG NEMOCLAW_CORPORATE_CA_B64="]); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 45d826ed64c..02742e60b3d 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -21,7 +21,7 @@ import { import { CORPORATE_CA_EXPLICIT_ENV, encodeCorporateCaArg, - resolveCorporateCaFromEnv, + resolveCorporateCa, } from "./corporate-ca"; import { dockerfileInstructions, @@ -328,10 +328,11 @@ export function patchStagedDockerfile( ); } // Corporate proxy CA import (#6210). When the host exposes an operator - // corporate CA bundle, bake its base64 so the entrypoint can append it to - // the OpenShell trust bundle at runtime (never replacing it). The replace is - // a silent no-op on custom/legacy Dockerfiles that predate this ARG. - const corporateCa = resolveCorporateCaFromEnv(process.env); + // corporate CA bundle — via env var or an installed host trust-store anchor — + // bake its base64 so the entrypoint can append it to the OpenShell trust + // bundle at runtime (never replacing it). The replace is a silent no-op on + // custom/legacy Dockerfiles that predate this ARG. + const corporateCa = resolveCorporateCa(process.env); if (corporateCa) { const corporateCaArgPattern = /^ARG NEMOCLAW_CORPORATE_CA_B64=.*$/m; if (corporateCaArgPattern.test(dockerfile)) { diff --git a/test/helpers/corporate-ca-support.ts b/test/helpers/corporate-ca-support.ts index 4d0957a1a56..e9a82ab2fe9 100644 --- a/test/helpers/corporate-ca-support.ts +++ b/test/helpers/corporate-ca-support.ts @@ -5,7 +5,7 @@ // *.test.ts files so branching setup stays in named helpers (the changed-test // linear-body guardrail counts if statements only in test files). -import { execFileSync, execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import https from "node:https"; import os from "node:os"; @@ -35,10 +35,27 @@ export interface CaMaterial { export type CaSetup = CaMaterial | { ok: false; reason: string }; +// argv-based OpenSSL helpers (no shell string interpolation): paths and +// subjects are passed as separate arguments so a path can never be re-parsed as +// a flag or shell token. function opensslReqX509(dir: string, cn: string, keyOut: string, certOut: string): void { - execSync( - `openssl req -x509 -newkey rsa:2048 -keyout "${path.join(dir, keyOut)}" ` + - `-out "${path.join(dir, certOut)}" -days 7 -nodes -subj "/CN=${cn}"`, + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + path.join(dir, keyOut), + "-out", + path.join(dir, certOut), + "-days", + "7", + "-nodes", + "-subj", + `/CN=${cn}`, + ], { stdio: "pipe" }, ); } @@ -53,15 +70,41 @@ function signLeaf( const csr = path.join(dir, `${keyOut}.csr`); const ext = path.join(dir, `${keyOut}.ext`); fs.writeFileSync(ext, "subjectAltName=DNS:localhost,IP:127.0.0.1\n"); - execSync( - `openssl req -newkey rsa:2048 -keyout "${path.join(dir, keyOut)}" -out "${csr}" ` + - `-nodes -subj "/CN=localhost"`, + execFileSync( + "openssl", + [ + "req", + "-newkey", + "rsa:2048", + "-keyout", + path.join(dir, keyOut), + "-out", + csr, + "-nodes", + "-subj", + "/CN=localhost", + ], { stdio: "pipe" }, ); - execSync( - `openssl x509 -req -in "${csr}" -CA "${path.join(dir, caCert)}" ` + - `-CAkey "${path.join(dir, caKey)}" -CAcreateserial -out "${path.join(dir, certOut)}" ` + - `-days 7 -extfile "${ext}"`, + execFileSync( + "openssl", + [ + "x509", + "-req", + "-in", + csr, + "-CA", + path.join(dir, caCert), + "-CAkey", + path.join(dir, caKey), + "-CAcreateserial", + "-out", + path.join(dir, certOut), + "-days", + "7", + "-extfile", + ext, + ], { stdio: "pipe" }, ); } @@ -73,7 +116,7 @@ function signLeaf( */ export function setupCaMaterial(): CaSetup { try { - execSync("openssl version", { stdio: "pipe" }); + execFileSync("openssl", ["version"], { stdio: "pipe" }); } catch (err) { return { ok: false, reason: `openssl missing: ${(err as Error).message}` }; } From 7456050120eb8727c09033ee8711d771595734b7 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 8 Jul 2026 08:44:45 +0000 Subject: [PATCH 10/26] fix(onboard): surface silent-skip and runtime-merge failures for corporate CA (#6210) Address the PR Review Advisor observability findings on the corporate-proxy CA import (both advisors converged on this after the host-detection + normalization rework): - resolveCorporateCaFromEnv now warns when a conventional CA env var (REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE / SSL_CERT_FILE) is set but points at an invalid file, instead of skipping fully silently. Explicit NEMOCLAW_CORPORATE_CA_BUNDLE stays fail-loud. - resolveCorporateCaFromHostAnchors warns when an anchor directory holds candidate files but yields no valid CA, or exceeds the cert/byte caps. An empty anchor dir (the normal case) stays silent. - Both sandbox entrypoints emit a concise, secret-free warning when a baked corporate CA fails to merge at runtime, naming the failed step + target path (never certificate bytes) so an operator can tell "no CA baked" from "runtime merge failed". Trust still falls back to OpenShell-only, unchanged. Docs document the silent-skip precedence, the warning breadcrumbs, and the explicit /etc/ssl/certs contract (anchor sources, not the merged bundle). Adds unit + runtime tests for each warning. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Yimo Jiang --- agents/hermes/start.sh | 15 ++++++- docs/reference/troubleshooting.mdx | 4 +- scripts/nemoclaw-start.sh | 15 ++++++- src/lib/onboard/corporate-ca.test.ts | 35 +++++++++++++--- src/lib/onboard/corporate-ca.ts | 53 +++++++++++++++++++++---- test/corporate-ca-runtime-merge.test.ts | 40 +++++++++++++++++++ 6 files changed, 146 insertions(+), 16 deletions(-) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 9a73ecde023..6173777b716 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1438,6 +1438,12 @@ export no_proxy="$_NO_PROXY_VAL" # #1828 OpenShell CA behavior stays intact) — and repoint SSL_CERT_FILE at the # merged bundle before the CURL/REQUESTS/GIT derivation below picks it up. _NEMOCLAW_CORPORATE_CA_FILE="/usr/local/share/nemoclaw/corporate-ca.pem" +# Concise, secret-free warning when a baked corporate CA fails to merge at +# runtime. Names the failed step + target path only (never certificate bytes) +# so an operator can distinguish "no CA was baked" from "runtime merge failed". +_nemoclaw_ca_merge_warn() { + echo "[nemoclaw] WARNING: corporate proxy CA merge failed at ${1}; keeping OpenShell-only trust — external TLS through the corporate proxy may fail (#6210)" >&2 +} merge_corporate_proxy_ca() { [ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0 _base_bundle="" @@ -1451,19 +1457,25 @@ merge_corporate_proxy_ca() { # write, then atomically rename into place. If any step fails we bail without # exporting anything, leaving the OpenShell-only trust intact rather than # pointing tools at a partial/empty bundle. - _tmp="$(mktemp "${_merged}.XXXXXX" 2>/dev/null)" || return 0 + _tmp="$(mktemp "${_merged}.XXXXXX" 2>/dev/null)" || { + _nemoclaw_ca_merge_warn "create temp bundle (${_merged})" + return 0 + } if [ -n "$_base_bundle" ]; then cat "$_base_bundle" >>"$_tmp" 2>/dev/null || { rm -f "$_tmp" + _nemoclaw_ca_merge_warn "append OpenShell bundle" return 0 } printf '\n' >>"$_tmp" 2>/dev/null || { rm -f "$_tmp" + _nemoclaw_ca_merge_warn "append OpenShell bundle" return 0 } fi cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_tmp" 2>/dev/null || { rm -f "$_tmp" + _nemoclaw_ca_merge_warn "append corporate CA" return 0 } chmod 0444 "$_tmp" 2>/dev/null || true @@ -1475,6 +1487,7 @@ merge_corporate_proxy_ca() { fi mv -f "$_tmp" "$_merged" 2>/dev/null || { rm -f "$_tmp" + _nemoclaw_ca_merge_warn "install merged bundle (${_merged})" return 0 } # Export all CA env vars explicitly (not via the ${VAR:-…} defaulting below, diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 2961da09ec7..e60d4ec71eb 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -991,7 +991,9 @@ Resolution order: 2. Conventional CA variables `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, then `SSL_CERT_FILE` (each **skipped silently** when it points at a missing or invalid file). 3. Host administrator anchor directories (skipped silently when absent or unusable). -When a fallback or host-store source is baked, onboarding logs which source and path it used. To be fully explicit — or to import a corporate root that is not installed in an anchor directory — export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at it. +When a fallback or host-store source is baked, onboarding logs which source and path it used (`baking corporate proxy CA from …`). If you set a conventional CA variable but the import does not happen, check the onboard build logs: a variable that points at a missing or invalid file is skipped with a `WARNING: … was skipped for corporate CA import` line, and an anchor directory that holds only invalid certificates logs a similar warning. Absence of the `baking …` line means no source validated. Use `NEMOCLAW_CORPORATE_CA_BUNDLE` for explicit, fail-loud behavior. To import a corporate root that is not installed in an anchor directory, export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at it. + +`/etc/ssl/certs/` contract: NemoClaw satisfies host trust-store detection by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected; a root present *only* as a hand-edited entry in the merged file is not, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. Every imported source must be a readable, non-symlink, non-world-writable PEM file in which **every** certificate block parses as an X.509 certificate, and the imported trust is capped at a small corporate chain (not a full OS trust store) — a source that would exceed the cap is rejected or skipped rather than truncated. To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index f71649b1157..52a9f6d2d2d 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2726,6 +2726,12 @@ export no_proxy="$_NO_PROXY_VAL" # #1828 OpenShell CA behavior stays intact) — and repoint the CA env vars at # the merged bundle so curl/python/git/node all trust both roots. _NEMOCLAW_CORPORATE_CA_FILE="/usr/local/share/nemoclaw/corporate-ca.pem" +# Concise, secret-free warning when a baked corporate CA fails to merge at +# runtime. Names the failed step + target path only (never certificate bytes) +# so an operator can distinguish "no CA was baked" from "runtime merge failed". +_nemoclaw_ca_merge_warn() { + echo "[nemoclaw] WARNING: corporate proxy CA merge failed at ${1}; keeping OpenShell-only trust — external TLS through the corporate proxy may fail (#6210)" >&2 +} merge_corporate_proxy_ca() { [ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0 _base_bundle="" @@ -2739,19 +2745,25 @@ merge_corporate_proxy_ca() { # write, then atomically rename into place. If any step fails we bail without # exporting anything, leaving the OpenShell-only trust intact rather than # pointing tools at a partial/empty bundle. - _tmp="$(mktemp "${_merged}.XXXXXX" 2>/dev/null)" || return 0 + _tmp="$(mktemp "${_merged}.XXXXXX" 2>/dev/null)" || { + _nemoclaw_ca_merge_warn "create temp bundle (${_merged})" + return 0 + } if [ -n "$_base_bundle" ]; then cat "$_base_bundle" >>"$_tmp" 2>/dev/null || { rm -f "$_tmp" + _nemoclaw_ca_merge_warn "append OpenShell bundle" return 0 } printf '\n' >>"$_tmp" 2>/dev/null || { rm -f "$_tmp" + _nemoclaw_ca_merge_warn "append OpenShell bundle" return 0 } fi cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_tmp" 2>/dev/null || { rm -f "$_tmp" + _nemoclaw_ca_merge_warn "append corporate CA" return 0 } chmod 0444 "$_tmp" 2>/dev/null || true @@ -2763,6 +2775,7 @@ merge_corporate_proxy_ca() { fi mv -f "$_tmp" "$_merged" 2>/dev/null || { rm -f "$_tmp" + _nemoclaw_ca_merge_warn "install merged bundle (${_merged})" return 0 } export SSL_CERT_FILE="$_merged" diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index 3900e19a2fc..5e62b64ce40 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { CORPORATE_CA_ANCHOR_DIRS_ENV, @@ -200,19 +200,31 @@ describe("resolveCorporateCaFromEnv", () => { expect(resolveCorporateCaFromEnv({ CURL_CA_BUNDLE: p })?.sourceEnv).toBe("CURL_CA_BUNDLE"); }); - it("skips an invalid fallback env var silently and tries the next", () => { + it("warns and continues past an invalid fallback env var to the next", () => { const p = writeCa(tmpDir()); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const resolved = resolveCorporateCaFromEnv({ REQUESTS_CA_BUNDLE: "/does/not/exist.pem", CURL_CA_BUNDLE: p, }); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); expect(resolved?.sourceEnv).toBe("CURL_CA_BUNDLE"); + expect(messages.some((m) => m.includes("REQUESTS_CA_BUNDLE") && m.includes("WARNING"))).toBe( + true, + ); }); - it("returns null when every fallback env var is invalid", () => { - expect( - resolveCorporateCaFromEnv({ REQUESTS_CA_BUNDLE: "/missing.pem", SSL_CERT_FILE: "/nope.pem" }), - ).toBeNull(); + it("returns null and warns when every fallback env var is invalid", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCaFromEnv({ + REQUESTS_CA_BUNDLE: "/missing.pem", + SSL_CERT_FILE: "/nope.pem", + }); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + expect(resolved).toBeNull(); + expect(messages.filter((m) => m.includes("WARNING"))).toHaveLength(2); }); it("honors the disable opt-out", () => { @@ -275,6 +287,17 @@ describe("resolveCorporateCaFromHostAnchors host trust-store path (#6210)", () = expect(resolveCorporateCaFromHostAnchors([anchorDir])?.pem).toContain("BEGIN CERTIFICATE"); }); + it("warns when an anchor directory has candidate files but no valid CA", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "broken.crt", BAD_PEM); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + expect(resolved).toBeNull(); + expect(messages.some((m) => m.includes(anchorDir) && m.includes("WARNING"))).toBe(true); + }); + it("discovers a corporate root nested in an anchor subdirectory", () => { // update-ca-certificates trusts .crt files recursively, e.g. // /usr/local/share/ca-certificates/acme/root.crt. diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index 51e1689e12f..c133324bcf9 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -142,6 +142,17 @@ export class CorporateCaValidationError extends Error { } } +/** + * Emit an operator-facing warning about a skipped corporate-CA import source. + * These paths are intentionally non-fatal (they must not break an onboard that + * never asked for a corporate CA), but staying fully silent hides a + * misconfiguration from an operator who *did* expect the import — so we surface + * a one-line notice. Messages carry only public paths, never certificate bytes. + */ +function warnCorporateCa(message: string): void { + console.error(`[nemoclaw] WARNING: ${message} (#6210)`); +} + /** * Join validated PEM CERTIFICATE blocks into a normalized bundle. * @@ -257,8 +268,9 @@ function isDisabled(env: NodeJS.ProcessEnv): boolean { * * Returns `null` when no corporate CA env var is configured (or import is * disabled). Throws {@link CorporateCaValidationError} only when the *explicit* - * `NEMOCLAW_CORPORATE_CA_BUNDLE` is set to an invalid path; invalid fallback - * env vars are skipped silently. Does not touch the host trust store — see + * `NEMOCLAW_CORPORATE_CA_BUNDLE` is set to an invalid path; an invalid fallback + * env var is skipped (not fatal) but logs a warning so the operator can see it. + * Does not touch the host trust store — see * {@link resolveCorporateCaFromHostAnchors} and {@link resolveCorporateCa}. */ export function resolveCorporateCaFromEnv( @@ -282,9 +294,16 @@ export function resolveCorporateCaFromEnv( try { const pem = validateCorporateCaFile(sourcePath); return { pem, sourcePath, sourceEnv: name }; - } catch { + } catch (err) { // A conventional CA env var pointing at a missing/invalid file must not - // break onboard for users who never asked for a corporate CA import. + // break onboard for users who never asked for a corporate CA import — but + // warn, since an operator who set it for the corporate proxy would + // otherwise get no signal that it was skipped. + warnCorporateCa( + `${name} is set (${sourcePath}) but was skipped for corporate CA import: ${ + (err as Error).message + }; set ${CORPORATE_CA_EXPLICIT_ENV} for fail-loud behavior`, + ); } } return null; @@ -355,13 +374,33 @@ export function resolveCorporateCaFromHostAnchors( // Skip an unreadable/invalid anchor file rather than fail discovery. } } - if (blocks.length === 0) continue; + if (blocks.length === 0) { + // Warn only when the directory actually held candidate anchor files (an + // admin dropped certs that failed validation); an empty anchor dir is the + // normal case on most hosts and must stay silent. + if (files.length > 0) { + warnCorporateCa( + `host trust-store anchor directory ${dir} has ${files.length} candidate file(s) but none were valid corporate CA certificates; skipping`, + ); + } + continue; + } const pem = normalizeCertificateBlocks(blocks); // Aggregate caps: keep the imported trust scoped to a corporate chain. A // directory that would exceed the caps is skipped, never truncated. const certCount = pem.match(PEM_CERTIFICATE_RE_GLOBAL)?.length ?? 0; - if (certCount === 0 || certCount > MAX_CORPORATE_CA_CERTS) continue; - if (Buffer.byteLength(pem, "utf8") > MAX_CORPORATE_CA_BYTES) continue; + if (certCount === 0 || certCount > MAX_CORPORATE_CA_CERTS) { + warnCorporateCa( + `host trust-store anchor directory ${dir} yields ${certCount} certificate(s) (max ${MAX_CORPORATE_CA_CERTS}); skipping to avoid a broad trust import`, + ); + continue; + } + if (Buffer.byteLength(pem, "utf8") > MAX_CORPORATE_CA_BYTES) { + warnCorporateCa( + `host trust-store anchor directory ${dir} exceeds ${MAX_CORPORATE_CA_BYTES} bytes; skipping`, + ); + continue; + } return { pem, sourcePath: dir, sourceEnv: CORPORATE_CA_HOST_ANCHOR_SOURCE }; } return null; diff --git a/test/corporate-ca-runtime-merge.test.ts b/test/corporate-ca-runtime-merge.test.ts index 445332001c6..dd80ad7a32e 100644 --- a/test/corporate-ca-runtime-merge.test.ts +++ b/test/corporate-ca-runtime-merge.test.ts @@ -188,6 +188,46 @@ describe("corporate proxy CA runtime merge (#6210)", () => { expect(existsSync(merged)).toBe(false); }); + it("warns on stderr when the OpenClaw merge fails (#6210)", () => { + const dir = tmpDir("nemoclaw-corp-merge-warn-openclaw-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + const merged = join(dir, "no-such-dir", "merged-ca.pem"); + writeFileSync(openshell, OPENSHELL_PEM); + writeFileSync(corp, CORPORATE_PEM); + + // exec 2>&1 folds the merge's stderr warning into captured stdout. + const out = runShellLines(dir, [ + "exec 2>&1", + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + mergeBlock(OPENCLAW_START, "# Git TLS CA bundle fix (NemoClaw#2270).", corp, merged), + ]); + expect(out).toContain("corporate proxy CA merge failed"); + expect(out).not.toContain("BEGIN CERTIFICATE"); + }); + + it("warns on stderr when the Hermes merge fails (#6210)", () => { + const dir = tmpDir("nemoclaw-corp-merge-warn-hermes-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + const merged = join(dir, "no-such-dir", "merged-ca.pem"); + writeFileSync(openshell, OPENSHELL_PEM); + writeFileSync(corp, CORPORATE_PEM); + + const out = runShellLines(dir, [ + "exec 2>&1", + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + mergeBlock( + HERMES_START, + "# OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA.", + corp, + merged, + ), + ]); + expect(out).toContain("corporate proxy CA merge failed"); + expect(out).not.toContain("BEGIN CERTIFICATE"); + }); + it("persists the merged CA env into OpenClaw connect sessions only after a merge (#6210)", () => { const dir = tmpDir("nemoclaw-corp-connect-"); const block = sliceBlock( From 3c7ae768edff7db89f96a614209566d3e26ada17 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 8 Jul 2026 10:12:20 +0000 Subject: [PATCH 11/26] fix(onboard): harden Dockerfile corporate-CA decode against malformed payloads (#6210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the PR Review Advisor's Required base64-validation finding (PRA-1/PRA-2) and strengthen the build-time guard so a hand-crafted NEMOCLAW_CORPORATE_CA_B64 cannot bake a non-certificate trust file: - Require base64 and openssl in the build image (fail loud with a clear message when missing), instead of silently proceeding. - Decode to a scratch file, then extract only CERTIFICATE blocks (awk) into the baked file, dropping any private key, CSR, comment, or trailing bytes — so nothing outside a certificate block is ever baked, mirroring the host-side normalization. - Require at least one certificate block and validate that every block parses (openssl crl2pkcs7), failing the build on invalid base64, a header wrapping non-certificate bytes, a corrupt later block, or a certificate request. Adds test/corporate-ca-dockerfile-decode.test.ts, which runs the actual shipped RUN block from both Dockerfiles (gated on GNU base64 + openssl availability) and covers each rejection path plus the strip-and-bake success path. Also corrects the troubleshooting resolution-order bullets to say skipped-with-WARNING. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Yimo Jiang --- Dockerfile | 8 +- agents/hermes/Dockerfile | 9 +- docs/reference/troubleshooting.mdx | 4 +- test/corporate-ca-dockerfile-decode.test.ts | 148 ++++++++++++++++++++ test/helpers/corporate-ca-support.ts | 66 ++++++++- 5 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 test/corporate-ca-dockerfile-decode.test.ts diff --git a/Dockerfile b/Dockerfile index a30ab38d640..4557ae2c913 100644 --- a/Dockerfile +++ b/Dockerfile @@ -937,8 +937,14 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ # 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; }; \ + command -v openssl >/dev/null 2>&1 || { echo "[nemoclaw] openssl is required to validate NEMOCLAW_CORPORATE_CA_B64 but is not installed in the build image (#6210)" >&2; exit 1; }; \ mkdir -p /usr/local/share/nemoclaw \ - && printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /usr/local/share/nemoclaw/corporate-ca.pem \ + && { 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 \ + && { grep -qF -- "-----BEGIN CERTIFICATE-----" /usr/local/share/nemoclaw/corporate-ca.pem || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates (#6210)" >&2; exit 1; }; } \ + && { openssl crl2pkcs7 -nocrl -certfile /usr/local/share/nemoclaw/corporate-ca.pem >/dev/null 2>&1 || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates (#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 image trust (#6210)"; \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 15feba97ca6..5c7d161b52e 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -276,8 +276,15 @@ RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-b # root, before the USER sandbox drop below. # 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; }; \ + command -v openssl >/dev/null 2>&1 || { echo "[nemoclaw] openssl is required to validate NEMOCLAW_CORPORATE_CA_B64 but is not installed in the build image (#6210)" >&2; exit 1; }; \ mkdir -p /usr/local/share/nemoclaw \ - && printf '%s' "${NEMOCLAW_CORPORATE_CA_B64}" | base64 --decode > /usr/local/share/nemoclaw/corporate-ca.pem \ + && { 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 \ + && { grep -qF -- "-----BEGIN CERTIFICATE-----" /usr/local/share/nemoclaw/corporate-ca.pem || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates (#6210)" >&2; exit 1; }; } \ + && { openssl crl2pkcs7 -nocrl -certfile /usr/local/share/nemoclaw/corporate-ca.pem >/dev/null 2>&1 || { echo "[nemoclaw] NEMOCLAW_CORPORATE_CA_B64 did not decode to a bundle of valid X.509 certificates (#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 image trust (#6210)"; \ diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index e60d4ec71eb..373f133a4d4 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -988,8 +988,8 @@ If your corporate CA is already installed in the host system trust store, NemoCl Resolution order: 1. Explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` (fails onboarding loudly when set but invalid). -2. Conventional CA variables `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, then `SSL_CERT_FILE` (each **skipped silently** when it points at a missing or invalid file). -3. Host administrator anchor directories (skipped silently when absent or unusable). +2. Conventional CA variables `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, then `SSL_CERT_FILE` (each **skipped with a `WARNING` log** when it is set but points at a missing or invalid file — the import is non-fatal, but the warning tells you it was skipped). +3. Host administrator anchor directories (an empty or absent directory is skipped silently; a directory that holds candidate files but no valid CA, or that exceeds the size/count caps, is skipped with a `WARNING` log). When a fallback or host-store source is baked, onboarding logs which source and path it used (`baking corporate proxy CA from …`). If you set a conventional CA variable but the import does not happen, check the onboard build logs: a variable that points at a missing or invalid file is skipped with a `WARNING: … was skipped for corporate CA import` line, and an anchor directory that holds only invalid certificates logs a similar warning. Absence of the `baking …` line means no source validated. Use `NEMOCLAW_CORPORATE_CA_BUNDLE` for explicit, fail-loud behavior. To import a corporate root that is not installed in an anchor directory, export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at it. diff --git a/test/corporate-ca-dockerfile-decode.test.ts b/test/corporate-ca-dockerfile-decode.test.ts new file mode 100644 index 00000000000..6a19803b6e1 --- /dev/null +++ b/test/corporate-ca-dockerfile-decode.test.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Validates the malformed-input guards on the corporate-proxy CA base64 decode +// RUN step in both agent Dockerfiles (#6210). Runs the actual shipped RUN block, +// not a re-implementation, against invalid base64, valid-but-not-a-certificate, +// and a real certificate. + +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + hasGnuBase64Decode, + hasOpenssl, + runDockerfileCorporateCaDecode, +} from "./helpers/corporate-ca-support"; + +// The extracted RUN block uses GNU `base64 --decode` (rejected by BSD/macOS +// `base64`) and requires the `openssl` CLI to validate the bundle. The sandbox +// image is only ever built on Linux with both present, so skip this shipped- +// shell check on hosts lacking either (e.g. the macOS Vitest job). +const canRunDecodeBlock = hasGnuBase64Decode() && hasOpenssl(); + +// A real self-signed X.509 certificate (structural validation accepts it). +const CERT_PEM = `-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUL3YNpyohvjOEzlwisLKfyiU3dRwwDQYJKoZIhvcNAQEL +BQAwJTEjMCEGA1UEAwwaTmVtb0NsYXcgVGVzdCBDb3Jwb3JhdGUgQ0EwHhcNMjYw +NzA2MDQwMjM2WhcNMzYwNzAzMDQwMjM2WjAlMSMwIQYDVQQDDBpOZW1vQ2xhdyBU +ZXN0IENvcnBvcmF0ZSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALVbV5tyMc65jEH39ejvQvBk7dvI8rz8rSZl+5BWSK2a4TzKm3jD3U+qCDZPicrA +ETCDcO09bN6YIAgpB6rYg5BIURJWxFuljBIBMCZEdO6AVlbURPaGsw6RKLA3cmhx +ZekT0qMcoOKm3N+Hb5MHXsWZ8EUf0co2LsWwJgDZrdwY26gF6w+9wr3iGLE92ZbO +LHhjHUYR1oWXmkXS3YW8MN2h5I+oyL71jBiwLHUi59wogxA/LTAD97/GqwJ6DC4C +UERbIpGYhZfrbiKmT+ASJuKRXaUp/0My3IzH90RqqY70d1E/pkAsd5M8SQ332qAZ +OgW4GgO3n7gAlaN/ILwunZ8CAwEAAaNTMFEwHQYDVR0OBBYEFMa5M8bvDm85eFQi +1D5fNATE/rawMB8GA1UdIwQYMBaAFMa5M8bvDm85eFQi1D5fNATE/rawMA8GA1Ud +EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8NR/0HBUH1WbbDOmGNDzge +o+4Pz0KWR5fPDSx9CrmvUk8ijKpJQcSjQcmrXuhCoRs6aExXLh+wImKkOyMIVXfd +YFWjCffSJzeBQfDlMVW+wiAjUh7xaIqpA6Z8EmpdfyoNWd30AuHjs9m8dAa8M/lP +0qhzCbjDiHNHfYSrAuBHlMJ5RsUrNVtSZGpg1dtaSBa+8XFWWNBeJrUANxb8i7Ax +MAhrfNQcxSkZH2lVY+TA2JO83v12nKXzaW1dC94SlsFf0tVSvM3QTeWVgijpr0q+ +J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= +-----END CERTIFICATE----- +`; + +const DOCKERFILES = [ + ["OpenClaw", join(import.meta.dirname, "../Dockerfile")], + ["Hermes", join(import.meta.dirname, "../agents/hermes/Dockerfile")], +] as const; + +const tmpRoots: string[] = []; + +function tmpDir(): string { + const dir = mkdtempSync(join(tmpdir(), "nemoclaw-corp-ca-decode-")); + tmpRoots.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +for (const [label, dockerfile] of DOCKERFILES) { + describe.skipIf(!canRunDecodeBlock)( + `corporate CA Dockerfile decode guard — ${label} (#6210)`, + () => { + it("fails with a clear error on invalid base64", () => { + const res = runDockerfileCorporateCaDecode(dockerfile, "not_valid_base64_@@@", tmpDir()); + expect(res.status).not.toBe(0); + expect(res.stderr).toContain("not valid base64"); + }); + + it("fails when the decoded content is not a valid certificate", () => { + const res = runDockerfileCorporateCaDecode( + dockerfile, + Buffer.from("just some text, not a PEM").toString("base64"), + tmpDir(), + ); + expect(res.status).not.toBe(0); + expect(res.stderr).toContain("valid X.509 certificates"); + }); + + it("fails when the payload is a certificate header wrapping non-certificate bytes", () => { + const fakePem = + "-----BEGIN CERTIFICATE-----\nnot a real certificate\n-----END CERTIFICATE-----\n"; + const res = runDockerfileCorporateCaDecode( + dockerfile, + Buffer.from(fakePem).toString("base64"), + tmpDir(), + ); + expect(res.status).not.toBe(0); + expect(res.stderr).toContain("valid X.509 certificates"); + }); + + it("fails when a later certificate block in the bundle is corrupt", () => { + const corruptTail = + "-----BEGIN CERTIFICATE-----\nnot a real certificate\n-----END CERTIFICATE-----\n"; + const res = runDockerfileCorporateCaDecode( + dockerfile, + Buffer.from(`${CERT_PEM}${corruptTail}`).toString("base64"), + tmpDir(), + ); + expect(res.status).not.toBe(0); + expect(res.stderr).toContain("valid X.509 certificates"); + }); + + it("rejects a PEM that is a certificate request, not a certificate", () => { + const csr = + "-----BEGIN CERTIFICATE REQUEST-----\nMIIBnjCCAQcCAQAwXjELMAk=\n-----END CERTIFICATE REQUEST-----\n"; + const res = runDockerfileCorporateCaDecode( + dockerfile, + Buffer.from(csr).toString("base64"), + tmpDir(), + ); + expect(res.status).not.toBe(0); + expect(res.stderr).toContain("valid X.509 certificates"); + }); + + it("succeeds for a valid base64-encoded certificate", () => { + const res = runDockerfileCorporateCaDecode( + dockerfile, + Buffer.from(CERT_PEM).toString("base64"), + tmpDir(), + ); + expect(res.status).toBe(0); + }); + + it("strips trailing non-certificate content and bakes only the certificate", () => { + const dir = tmpDir(); + const withTrailer = `${CERT_PEM}\n# a stray comment\n-----BEGIN CERTIFICATE REQUEST-----\nMIIBnjCCAQc=\n-----END CERTIFICATE REQUEST-----\n`; + const res = runDockerfileCorporateCaDecode( + dockerfile, + Buffer.from(withTrailer).toString("base64"), + dir, + ); + expect(res.status).toBe(0); + const baked = readFileSync(join(dir, "corporate-ca.pem"), "utf-8"); + expect(baked).toContain("-----BEGIN CERTIFICATE-----"); + expect(baked).not.toContain("CERTIFICATE REQUEST"); + expect(baked).not.toContain("stray comment"); + }); + }, + ); +} diff --git a/test/helpers/corporate-ca-support.ts b/test/helpers/corporate-ca-support.ts index e9a82ab2fe9..938e7e4455f 100644 --- a/test/helpers/corporate-ca-support.ts +++ b/test/helpers/corporate-ca-support.ts @@ -5,7 +5,7 @@ // *.test.ts files so branching setup stays in named helpers (the changed-test // linear-body guardrail counts if statements only in test files). -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import https from "node:https"; import os from "node:os"; @@ -241,6 +241,70 @@ export function httpsGetStatus(port: number, caBundlePath: string): Promise idx > startIdx && line.trimEnd() === " fi"); + const found = startIdx !== -1 && endIdx !== -1; + const block = (found ? lines.slice(startIdx, endIdx + 1) : []) + .join("\n") + .replace(/^RUN /, "") + .replaceAll("/usr/local/share/nemoclaw", outDir) + // 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. + .replaceAll("chown root:root", 'chown "$(id -u):$(id -g)"'); + const wrapper = path.join(outDir, "decode.sh"); + fs.writeFileSync( + wrapper, + [ + "#!/usr/bin/env bash", + "set -u", + `export NEMOCLAW_CORPORATE_CA_B64=${JSON.stringify(b64Value)}`, + block || "echo 'decode block not found' >&2; exit 3", + ].join("\n"), + { mode: 0o700 }, + ); + const res = spawnSync("bash", [wrapper], { encoding: "utf-8" }); + return { status: res.status ?? -1, stderr: res.stderr ?? "" }; +} + /** Run a bash wrapper built from the given lines and return stdout. */ export function runShellLines(dir: string, lines: string[]): string { const script = path.join(dir, "run.sh"); From 79d7f6d17d5c85bcd4836f0715ba04835e859584 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 8 Jul 2026 10:27:48 +0000 Subject: [PATCH 12/26] fix(onboard): reject group-writable corporate CA sources and cover anchor edge cases (#6210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR Review Advisor findings on the corporate-proxy CA import: - Security (PRA-2): validateCorporateCaFile now rejects group- or world-writable sources (mode & 0o022), not just world-writable. A trust anchor that any group member can rewrite could otherwise let an attacker substitute a malicious root CA that gets baked into the sandbox trust store. - Tests (PRA-2): add anchor-directory coverage — symlinked entries are skipped (the walk imports only real regular files) and an unreadable anchor directory is skipped without throwing. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Yimo Jiang --- src/lib/onboard/corporate-ca.test.ts | 24 +++++++++++++++++++++++- src/lib/onboard/corporate-ca.ts | 11 +++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index 5e62b64ce40..faf4975942c 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -119,7 +119,12 @@ describe("validateCorporateCaFile", () => { it("rejects a world-writable file", () => { const p = writeCa(tmpDir(), PEM, 0o666); - expect(() => validateCorporateCaFile(p)).toThrow(/world-writable/); + expect(() => validateCorporateCaFile(p)).toThrow(/group- or world-writable/); + }); + + it("rejects a group-writable file", () => { + const p = writeCa(tmpDir(), PEM, 0o664); + expect(() => validateCorporateCaFile(p)).toThrow(/group- or world-writable/); }); it("rejects a file without a PEM certificate block", () => { @@ -307,6 +312,23 @@ describe("resolveCorporateCaFromHostAnchors host trust-store path (#6210)", () = writeAnchor(sub, "root.crt"); expect(resolveCorporateCaFromHostAnchors([anchorDir])?.pem).toContain("BEGIN CERTIFICATE"); }); + + it("skips symlinked anchor entries", () => { + // A symlinked cert could point outside the anchor tree; the walk imports + // only real regular files. + const realCert = writeCa(tmpDir()); + const anchorDir = tmpDir(); + fs.symlinkSync(realCert, path.join(anchorDir, "linked.crt")); + expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); + }); + + it("skips an unreadable anchor directory without throwing", () => { + const anchorDir = tmpDir(); + fs.chmodSync(anchorDir, 0o000); + expect(() => resolveCorporateCaFromHostAnchors([anchorDir])).not.toThrow(); + expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); + fs.chmodSync(anchorDir, 0o700); // restore so cleanup can remove it + }); }); describe("resolveCorporateCa env then host anchors (#6210)", () => { diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index c133324bcf9..07708977e77 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -172,7 +172,7 @@ function normalizeCertificateBlocks(blocks: readonly string[]): string { * Opens the file once with `O_NOFOLLOW` and validates the *opened* descriptor * (via `fstat`, then reads from the same fd) so a symlink/file swap between * check and use cannot slip a different file past validation. Rejects - * symlinks, non-regular files, empty/oversized files, world-writable sources, + * symlinks, non-regular files, empty/oversized files, group/world-writable sources, * bundles with no or too many PEM CERTIFICATE blocks, and any block that is not * a parseable X.509 certificate. * @@ -211,10 +211,13 @@ export function validateCorporateCaFile(filePath: string): string { `corporate CA bundle exceeds ${MAX_CORPORATE_CA_BYTES} bytes: ${filePath}`, ); } - // Refuse a source any other local user could tamper with before the build. - if ((stat.mode & 0o002) !== 0) { + // Refuse a source another local user could tamper with before the build. + // A trust anchor must not be group- or world-writable: any member of the + // owning group (or any user) could otherwise swap in a malicious root CA + // that then gets baked into the sandbox trust store. + if ((stat.mode & 0o022) !== 0) { throw new CorporateCaValidationError( - `corporate CA bundle must not be world-writable: ${filePath}`, + `corporate CA bundle must not be group- or world-writable: ${filePath}`, ); } const content = fs.readFileSync(fd, "utf8"); From 55bb6ac1c4413cd54bc5169a81902e47afa11ec2 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 8 Jul 2026 10:40:45 +0000 Subject: [PATCH 13/26] docs(onboard): clarify /etc/ssl/certs contract, permanent fallbacks, and file-mode rule (#6210) Resolve the PR Review Advisor clarification findings (no behavior change): - Add an explicit code comment in resolveCorporateCaFromHostAnchors stating that /etc/ssl/certs/ is intentionally NOT scanned and only the administrator anchor source directories that populate it are read, aligning code with the docs. - Document that the conventional CA env-var fallbacks and host-anchor scan are intentional permanent convenience sources, not a transitional workaround with a removal milestone. - Update the troubleshooting file-mode rule to state that a group- or world-writable source is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Yimo Jiang --- docs/reference/troubleshooting.mdx | 2 +- src/lib/onboard/corporate-ca.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 373f133a4d4..d7da76b8fef 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -995,7 +995,7 @@ When a fallback or host-store source is baked, onboarding logs which source and `/etc/ssl/certs/` contract: NemoClaw satisfies host trust-store detection by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected; a root present *only* as a hand-edited entry in the merged file is not, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. -Every imported source must be a readable, non-symlink, non-world-writable PEM file in which **every** certificate block parses as an X.509 certificate, and the imported trust is capped at a small corporate chain (not a full OS trust store) — a source that would exceed the cap is rejected or skipped rather than truncated. To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. +Every imported source must be a regular (non-symlink), readable PEM file that is **not group- or world-writable** (a trust anchor any other user could rewrite is rejected), non-empty and within the size cap, and in which **every** certificate block parses as an X.509 certificate. The imported trust is capped at a small corporate chain (not a full OS trust store) — a source that would exceed the cap is rejected or skipped rather than truncated. To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. ### A request inside the sandbox fails with `CONNECT tunnel failed, response 403` diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index 07708977e77..e9935e5995d 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -290,6 +290,12 @@ export function resolveCorporateCaFromEnv( return { pem, sourcePath, sourceEnv: CORPORATE_CA_EXPLICIT_ENV }; } + // These conventional-CA-env-var fallbacks and the host-anchor scan are + // intentional, permanent convenience sources (the reporter's proxy already + // exports one of these) — not a transitional workaround with a removal + // milestone. NemoClaw owns onboard trust configuration, so there is no + // upstream boundary to migrate to and no removal condition; the fail-loud + // explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` remains the recommended source. for (const name of CORPORATE_CA_FALLBACK_ENV_VARS) { const value = env[name]; if (!value || !value.trim()) continue; @@ -365,6 +371,14 @@ function collectAnchorFiles(root: string, extensions: RegExp): string[] { export function resolveCorporateCaFromHostAnchors( dirs: readonly string[] = CORPORATE_CA_HOST_ANCHOR_DIRS, ): ResolvedCorporateCa | null { + // #6210 acceptance note: the issue text mentions the host `/etc/ssl/certs/`. + // We intentionally do NOT scan `/etc/ssl/certs/` (nor its merged + // `ca-certificates.crt`): that view interleaves the corporate root with the + // distro's public root bundle, and importing it would bake broad, unrelated + // trust into the sandbox. Instead we read the administrator anchor *source* + // directories that populate `/etc/ssl/certs/`, so a corporate root installed + // via `update-ca-certificates`/`update-ca-trust` is detected without the + // trust-bloat. See docs/reference/troubleshooting.mdx for the operator contract. for (const dir of dirs) { const files = collectAnchorFiles(dir, anchorExtensionsFor(dir)); const blocks: string[] = []; From dd980a1aa5f428f19b11f58cfb116b252789cb22 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Wed, 8 Jul 2026 10:59:28 +0000 Subject: [PATCH 14/26] docs(onboard): document the corporate-CA merge trust-anchor path safety (#6210) Address the PR Review Advisor's predictable-/tmp-path concern with an accurate threat-model comment in both entrypoints (no behavior change): the merge runs at top level as root in the normal container start (the step-down prefix wraps only the later agent commands), so the merged bundle is root-owned 0444 and the non-root agent cannot rewrite it; the mktemp sibling + symlink-drop + atomic rename(2) neutralize a pre-planted symlink or file at the predictable path; and a non-root start keeps the whole entrypoint on one user with no privilege boundary. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Yimo Jiang --- agents/hermes/start.sh | 11 +++++++++++ scripts/nemoclaw-start.sh | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 6173777b716..4ac1918a85f 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1453,6 +1453,17 @@ merge_corporate_proxy_ca() { _base_bundle="/etc/ssl/certs/ca-certificates.crt" fi _merged="/tmp/nemoclaw-ca-bundle.pem" + # Trust-anchor path safety (#6210): in the normal container start this + # entrypoint runs as root (the step-down prefix wraps only the later agent + # commands, not this top-level merge), so the merged bundle is written + # root-owned 0444 — the non-root sandbox user that the agent later runs as + # inherits SSL_CERT_FILE but cannot rewrite it. The predictable /tmp path is + # still handled safely: it is built in a fresh mktemp sibling and atomically + # renamed into place; a pre-planted symlink at the target is dropped first + # (below); and rename(2) replaces the target link/file rather than writing + # through it, so a pre-planted symlink or file cannot redirect the write. On a + # non-root start the whole entrypoint (and the agent) is the same sandbox user, + # so there is no privilege boundary to cross. # Build the bundle in a private temp file next to the target, verifying every # write, then atomically rename into place. If any step fails we bail without # exporting anything, leaving the OpenShell-only trust intact rather than diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 52a9f6d2d2d..ae2b1a485da 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2741,6 +2741,17 @@ merge_corporate_proxy_ca() { _base_bundle="/etc/ssl/certs/ca-certificates.crt" fi _merged="/tmp/nemoclaw-ca-bundle.pem" + # Trust-anchor path safety (#6210): in the normal container start this + # entrypoint runs as root (the step-down prefix wraps only the later agent + # commands, not this top-level merge), so the merged bundle is written + # root-owned 0444 — the non-root sandbox user that the agent later runs as + # inherits SSL_CERT_FILE but cannot rewrite it. The predictable /tmp path is + # still handled safely: it is built in a fresh mktemp sibling and atomically + # renamed into place; a pre-planted symlink at the target is dropped first + # (below); and rename(2) replaces the target link/file rather than writing + # through it, so a pre-planted symlink or file cannot redirect the write. On a + # non-root start the whole entrypoint (and the agent) is the same sandbox user, + # so there is no privilege boundary to cross. # Build the bundle in a private temp file next to the target, verifying every # write, then atomically rename into place. If any step fails we bail without # exporting anything, leaving the OpenShell-only trust intact rather than From f89b64188380ac1a6fb7490b2ba8c6477a80068f Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Thu, 9 Jul 2026 06:23:14 +0000 Subject: [PATCH 15/26] fix(onboard): reject merged OS trust stores as corporate CA sources (#6210) A conventional CA env var (notably SSL_CERT_FILE) routinely defaults to the merged OS trust store (e.g. /etc/ssl/certs/ca-certificates.crt), which bundles the distro's ~140 public roots with any local corporate root. Importing it wholesale would widen sandbox trust far beyond the single corporate proxy CA #6210 is about. Recognize the well-known merged trust-store paths and reject them: the explicit NEMOCLAW_CORPORATE_CA_BUNDLE now fails loud, and a fallback env var pointing at one is skipped with a warning. The administrator anchor source-dir scan stays the safe automatic host path; the reporter's dedicated corporate-CA file still resolves. (PR Review Advisor PRA-5.) Signed-off-by: Yimo Jiang Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/troubleshooting.mdx | 4 +- src/lib/onboard/corporate-ca.test.ts | 44 +++++++++++++++++ src/lib/onboard/corporate-ca.ts | 70 ++++++++++++++++++++++++++-- 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 4ecf6193614..4b991f0625a 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1230,8 +1230,8 @@ If your corporate CA is already installed in the host system trust store, NemoCl Resolution order: -1. Explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` (fails onboarding loudly when set but invalid). -2. Conventional CA variables `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, then `SSL_CERT_FILE` (each **skipped with a `WARNING` log** when it is set but points at a missing or invalid file — the import is non-fatal, but the warning tells you it was skipped). +1. Explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` (fails onboarding loudly when set but invalid, or when it points at a merged OS trust store such as `/etc/ssl/certs/ca-certificates.crt` — export only your corporate root instead). +2. Conventional CA variables `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, then `SSL_CERT_FILE` (each **skipped with a `WARNING` log** when it is set but points at a missing or invalid file — the import is non-fatal, but the warning tells you it was skipped). A conventional variable that points at a merged OS trust store (for example the common `SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`) is also skipped with a `WARNING`, so a default system bundle is never baked in wholesale — the host-anchor scan below still finds a locally-installed corporate root. 3. Host administrator anchor directories (an empty or absent directory is skipped silently; a directory that holds candidate files but no valid CA, or that exceeds the size/count caps, is skipped with a `WARNING` log). When a fallback or host-store source is baked, onboarding logs which source and path it used (`baking corporate proxy CA from …`). If you set a conventional CA variable but the import does not happen, check the onboard build logs: a variable that points at a missing or invalid file is skipped with a `WARNING: … was skipped for corporate CA import` line, and an anchor directory that holds only invalid certificates logs a similar warning. Absence of the `baking …` line means no source validated. Use `NEMOCLAW_CORPORATE_CA_BUNDLE` for explicit, fail-loud behavior. To import a corporate root that is not installed in an anchor directory, export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at it. diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index faf4975942c..0c75a304689 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -14,6 +14,8 @@ import { CORPORATE_CA_HOST_ANCHOR_SOURCE, CorporateCaValidationError, encodeCorporateCaArg, + isKnownMergedTrustStorePath, + KNOWN_MERGED_TRUST_STORE_PATHS, MAX_CORPORATE_CA_BYTES, MAX_CORPORATE_CA_CERTS, resolveCorporateCa, @@ -241,6 +243,48 @@ describe("resolveCorporateCaFromEnv", () => { }), ).toBeNull(); }); + + it("throws when the explicit env var points at a merged OS trust store (#6210 PRA-5)", () => { + expect(() => + resolveCorporateCaFromEnv({ + [CORPORATE_CA_EXPLICIT_ENV]: "/etc/ssl/certs/ca-certificates.crt", + }), + ).toThrow(CorporateCaValidationError); + }); + + it("skips a fallback env var pointing at a merged OS trust store and warns (#6210 PRA-5)", () => { + const p = writeCa(tmpDir()); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCaFromEnv({ + REQUESTS_CA_BUNDLE: "/etc/ssl/certs/ca-certificates.crt", + CURL_CA_BUNDLE: p, + }); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + // The merged-store fallback is skipped (never baked); the real corporate + // bundle from the next var wins. + expect(resolved?.sourceEnv).toBe("CURL_CA_BUNDLE"); + expect( + messages.some((m) => m.includes("REQUESTS_CA_BUNDLE") && m.includes("merged OS trust store")), + ).toBe(true); + }); +}); + +describe("isKnownMergedTrustStorePath (#6210 PRA-5)", () => { + it("matches every well-known merged OS trust-store path", () => { + for (const p of KNOWN_MERGED_TRUST_STORE_PATHS) { + expect(isKnownMergedTrustStorePath(p)).toBe(true); + } + }); + + it("normalizes a non-canonical path before matching", () => { + expect(isKnownMergedTrustStorePath("/etc/ssl/certs/../certs/ca-certificates.crt")).toBe(true); + }); + + it("does not match a dedicated corporate CA file", () => { + const p = writeCa(tmpDir()); + expect(isKnownMergedTrustStorePath(p)).toBe(false); + }); }); describe("resolveCorporateCaFromHostAnchors host trust-store path (#6210)", () => { diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index e9935e5995d..c80bdbb8850 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -37,6 +37,45 @@ export const CORPORATE_CA_FALLBACK_ENV_VARS = [ "SSL_CERT_FILE", ] as const; +/** + * Well-known merged OS trust-store files. These interleave the distro's ~140 + * public roots with any locally-added corporate root, so importing one wholesale + * would widen sandbox trust far beyond the single corporate proxy CA #6210 is + * about (trust-bloat). A conventional CA env var (`SSL_CERT_FILE` especially) + * routinely points at one of these by default, so we reject it as a corporate-CA + * source: the explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` fails loudly (the operator + * must export just their corporate root), and a fallback env var is skipped with + * a warning. The safe automatic host path stays the administrator anchor *source* + * directories (see {@link resolveCorporateCaFromHostAnchors}), which hold only + * locally-added roots. (PR Review Advisor PRA-5.) + */ +export const KNOWN_MERGED_TRUST_STORE_PATHS: readonly string[] = [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/cert.pem", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/pki/tls/certs/ca-bundle.trust.crt", + "/etc/pki/tls/cert.pem", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", +]; + +/** + * True when `candidate` resolves to a well-known merged OS trust store (see + * {@link KNOWN_MERGED_TRUST_STORE_PATHS}). Matches both the normalized path and, + * where resolvable, its symlink target, since several distros expose the merged + * bundle through a symlinked alias (e.g. `/etc/pki/tls/certs/ca-bundle.crt` → + * the extracted bundle). + */ +export function isKnownMergedTrustStorePath(candidate: string): boolean { + const normalized = path.resolve(candidate); + if (KNOWN_MERGED_TRUST_STORE_PATHS.includes(normalized)) return true; + try { + return KNOWN_MERGED_TRUST_STORE_PATHS.includes(fs.realpathSync(candidate)); + } catch { + return false; + } +} + /** * Anchor-file extensions each host trust tool actually installs. Debian/Ubuntu * `update-ca-certificates` installs only `*.crt` from its anchor dir; RHEL/Fedora @@ -271,8 +310,10 @@ function isDisabled(env: NodeJS.ProcessEnv): boolean { * * Returns `null` when no corporate CA env var is configured (or import is * disabled). Throws {@link CorporateCaValidationError} only when the *explicit* - * `NEMOCLAW_CORPORATE_CA_BUNDLE` is set to an invalid path; an invalid fallback - * env var is skipped (not fatal) but logs a warning so the operator can see it. + * `NEMOCLAW_CORPORATE_CA_BUNDLE` is set to an invalid path (or to a merged OS + * trust store — see {@link KNOWN_MERGED_TRUST_STORE_PATHS}); an invalid fallback + * env var, or one pointing at a merged trust store, is skipped (not fatal) but + * logs a warning so the operator can see it. * Does not touch the host trust store — see * {@link resolveCorporateCaFromHostAnchors} and {@link resolveCorporateCa}. */ @@ -284,6 +325,14 @@ export function resolveCorporateCaFromEnv( const explicit = env[CORPORATE_CA_EXPLICIT_ENV]; if (explicit && explicit.trim()) { const sourcePath = explicit.trim(); + // Even an explicit opt-in must not point at a merged OS trust store: baking + // ~140 public roots is trust-bloat, not a corporate-proxy CA. Fail loudly so + // the operator exports just their corporate root instead. + if (isKnownMergedTrustStorePath(sourcePath)) { + throw new CorporateCaValidationError( + `${CORPORATE_CA_EXPLICIT_ENV} points at a merged OS trust store (${sourcePath}); export only your corporate root (and intermediates) to a small PEM file instead`, + ); + } // Explicit request: surface validation failures instead of silently // building an image that cannot verify external TLS. const pem = validateCorporateCaFile(sourcePath); @@ -300,6 +349,17 @@ export function resolveCorporateCaFromEnv( const value = env[name]; if (!value || !value.trim()) continue; const sourcePath = value.trim(); + // A conventional CA env var routinely defaults to the merged OS trust store + // (e.g. `SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`). Skip it rather + // than bake ~140 public roots as if they were the corporate proxy CA; the + // host-anchor scan still finds the locally-added corporate root, and the + // explicit env var remains the way to import a specific bundle. + if (isKnownMergedTrustStorePath(sourcePath)) { + warnCorporateCa( + `${name} points at a merged OS trust store (${sourcePath}); skipped to avoid a broad trust import — set ${CORPORATE_CA_EXPLICIT_ENV} to a small corporate-root PEM to import explicitly`, + ); + continue; + } try { const pem = validateCorporateCaFile(sourcePath); return { pem, sourcePath, sourceEnv: name }; @@ -447,9 +507,11 @@ export interface ResolveCorporateCaOptions { * Resolve a corporate CA bundle for the sandbox image (#6210). * * Resolution order: - * 1. Explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` (fail-loud when invalid). + * 1. Explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` (fail-loud when invalid or when it + * points at a merged OS trust store — see {@link KNOWN_MERGED_TRUST_STORE_PATHS}). * 2. Conventional CA env vars (`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, - * `SSL_CERT_FILE`), skipped silently when invalid. + * `SSL_CERT_FILE`), skipped with a warning when invalid or when they point + * at a merged OS trust store. * 3. Host administrator-managed anchor directories (overridable/disablable * via {@link CORPORATE_CA_ANCHOR_DIRS_ENV}), skipped silently. * From e3554bfc61831828c481d15fb16b94180c7aaa63 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 8 Jul 2026 23:31:38 -0700 Subject: [PATCH 16/26] test(onboard): normalize corporate CA test issue suffixes Signed-off-by: Carlos Villela --- src/lib/onboard/corporate-ca.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index 0c75a304689..6caf47ba1e3 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -244,7 +244,7 @@ describe("resolveCorporateCaFromEnv", () => { ).toBeNull(); }); - it("throws when the explicit env var points at a merged OS trust store (#6210 PRA-5)", () => { + it("throws when the explicit env var points at a merged OS trust store (#6210)", () => { expect(() => resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: "/etc/ssl/certs/ca-certificates.crt", @@ -252,7 +252,7 @@ describe("resolveCorporateCaFromEnv", () => { ).toThrow(CorporateCaValidationError); }); - it("skips a fallback env var pointing at a merged OS trust store and warns (#6210 PRA-5)", () => { + it("skips a fallback env var pointing at a merged OS trust store and warns (#6210)", () => { const p = writeCa(tmpDir()); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const resolved = resolveCorporateCaFromEnv({ @@ -270,7 +270,7 @@ describe("resolveCorporateCaFromEnv", () => { }); }); -describe("isKnownMergedTrustStorePath (#6210 PRA-5)", () => { +describe("isKnownMergedTrustStorePath (#6210)", () => { it("matches every well-known merged OS trust-store path", () => { for (const p of KNOWN_MERGED_TRUST_STORE_PATHS) { expect(isKnownMergedTrustStorePath(p)).toBe(true); From fd2844dfce78ddbbf20663714a68e66cb40b2e35 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Thu, 9 Jul 2026 06:47:38 +0000 Subject: [PATCH 17/26] docs(onboard): clarify /etc/ssl/certs JSDoc note and custom-Dockerfile CA contract (#6210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the "/etc/ssl/certs/ is NOT scanned — only administrator anchor source directories are" note into the resolveCorporateCaFromHostAnchors JSDoc so the acceptance-path rationale is discoverable at the function boundary. Document the custom-Dockerfile contract in troubleshooting: fallback/host-store corporate CA imports silently no-op unless a custom Dockerfile declares ARG NEMOCLAW_CORPORATE_CA_B64 and decodes it into the root-owned /usr/local/share/nemoclaw/corporate-ca.pem, while explicit NEMOCLAW_CORPORATE_CA_BUNDLE fails loud. (PR Review Advisor.) Signed-off-by: Yimo Jiang Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/troubleshooting.mdx | 2 ++ src/lib/onboard/corporate-ca.ts | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 4b991f0625a..a769d4b8546 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1238,6 +1238,8 @@ When a fallback or host-store source is baked, onboarding logs which source and `/etc/ssl/certs/` contract: NemoClaw satisfies host trust-store detection by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected; a root present *only* as a hand-edited entry in the merged file is not, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. +Custom Dockerfile contract: the managed NemoClaw sandbox Dockerfiles handle the corporate CA automatically — onboarding bakes the validated bundle into `ARG NEMOCLAW_CORPORATE_CA_B64`, and the image decodes it into the root-owned, read-only file `/usr/local/share/nemoclaw/corporate-ca.pem`. If you build the sandbox from a **custom Dockerfile**, the fallback and host-store sources are a silent no-op unless that Dockerfile declares `ARG NEMOCLAW_CORPORATE_CA_B64` and decodes it into `/usr/local/share/nemoclaw/corporate-ca.pem` the same way. The explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` is the exception — it fails onboarding loudly (rather than no-op) when the managed ARG is absent, so you find out immediately. Either add the ARG and decode step to your Dockerfile, or keep using a managed Dockerfile, to import a corporate CA through automatic detection. + Every imported source must be a regular (non-symlink), readable PEM file that is **not group- or world-writable** (a trust anchor any other user could rewrite is rejected), non-empty and within the size cap, and in which **every** certificate block parses as an X.509 certificate. The imported trust is capped at a small corporate chain (not a full OS trust store) — a source that would exceed the cap is rejected or skipped rather than truncated. To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. ### A request inside the sandbox fails with `CONNECT tunnel failed, response 403` diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index c80bdbb8850..b973693cad0 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -418,11 +418,16 @@ function collectAnchorFiles(root: string, extensions: RegExp): string[] { /** * Resolve a corporate CA from the host administrator-managed anchor directories - * (#6210 acceptance path). See {@link CORPORATE_CA_HOST_ANCHOR_DIRS} for why - * these bounded source dirs — not the merged `/etc/ssl/certs/` output — are the - * safe place to detect an installed corporate root. Each directory is scanned - * recursively (matching `update-ca-certificates`), bounded by the depth/file - * caps above. + * (#6210 acceptance path). + * + * NOTE: this deliberately does **not** scan the merged `/etc/ssl/certs/` + * (nor `/etc/ssl/certs/ca-certificates.crt`) that the issue text mentions — + * only the administrator anchor **source** directories are read. The merged view + * interleaves the corporate root with the distro's ~140 public roots, so reading + * it would bake broad, unrelated trust; the anchor sources hold exactly the + * locally-installed corporate root. See {@link CORPORATE_CA_HOST_ANCHOR_DIRS}. + * Each directory is scanned recursively (matching `update-ca-certificates`), + * bounded by the depth/file caps above. * * Returns `null` when no anchor directory yields a usable, bounded bundle. * Never throws: an unreadable/invalid/oversized anchor set is skipped silently From c7ff2d83ccf5cfe27781186ca9b67cbe3779771c Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 9 Jul 2026 11:14:14 -0700 Subject: [PATCH 18/26] test: add corporate CA live evidence --- test/corporate-ca-tls-e2e.test.ts | 73 +++++++++++------- test/e2e/fixtures/corporate-ca.ts | 109 +++++++++++++++++++++++++++ test/e2e/live/cloud-onboard.test.ts | 17 +++++ test/e2e/live/onboard-repair.test.ts | 19 +++++ test/e2e/live/onboard-resume.test.ts | 34 +++++++++ test/helpers/corporate-ca-support.ts | 7 +- 6 files changed, 228 insertions(+), 31 deletions(-) create mode 100644 test/e2e/fixtures/corporate-ca.ts diff --git a/test/corporate-ca-tls-e2e.test.ts b/test/corporate-ca-tls-e2e.test.ts index 9d8093c29ee..f2c1d24bcf0 100644 --- a/test/corporate-ca-tls-e2e.test.ts +++ b/test/corporate-ca-tls-e2e.test.ts @@ -8,10 +8,10 @@ // * A server presents a leaf cert signed ONLY by that corporate CA. // * OpenShell's own bundle does NOT contain the corporate root. // -// It runs the REAL merge_corporate_proxy_ca block extracted from -// scripts/nemoclaw-start.sh to append the baked corporate CA to the OpenShell -// bundle, then proves TLS verification succeeds only after the merge, while the -// OpenShell root stays trusted (the #1828 behavior is preserved). +// It runs the REAL merge_corporate_proxy_ca blocks extracted from both sandbox +// entrypoints to append the baked corporate CA to the OpenShell bundle, then +// proves TLS verification succeeds only after the merge, while the OpenShell root +// stays trusted (the #1828 behavior is preserved). import path from "node:path"; import { afterAll, describe, expect, it } from "vitest"; @@ -26,6 +26,13 @@ import { } from "./helpers/corporate-ca-support"; const OPENCLAW_START = path.join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); +const HERMES_START = path.join(import.meta.dirname, "../agents/hermes/start.sh"); +const HERMES_MERGE_END = "# OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA."; + +const MERGE_ROUTES = [ + ["OpenClaw", OPENCLAW_START, "# Git TLS CA bundle fix (NemoClaw#2270)."], + ["Hermes", HERMES_START, HERMES_MERGE_END], +] as const; const setup = resolveCaSetup("corporate-ca-tls-e2e"); @@ -34,29 +41,43 @@ afterAll(() => cleanupCaSetup(setup)); describe.skipIf(!setup.ok)("corporate proxy CA TLS verification (#6210)", () => { const mat = setup as CaMaterial; - it("verifies a corporate-CA-signed endpoint only after the merge", async () => { - const merged = runMergeBlock(OPENCLAW_START, mat.openshellCaCert, mat.corporateCaCert, mat.dir); - const server = await startTlsServer(mat.serverKey, mat.serverCert); - try { - // Pre-fix state: OpenShell bundle alone cannot verify the corporate leaf. - await expect(httpsGetStatus(server.port, mat.openshellCaCert)).rejects.toThrow( - /unable to (get local issuer|verify)|self.signed|UNABLE_TO_/i, + describe.each(MERGE_ROUTES)("%s merge path", (_routeName, scriptPath, endMarker) => { + it("verifies a corporate-CA-signed endpoint only after the merge (#6210)", async () => { + const merged = runMergeBlock( + scriptPath, + mat.openshellCaCert, + mat.corporateCaCert, + mat.dir, + endMarker, ); - // Post-fix: the merged bundle trusts the corporate root. - await expect(httpsGetStatus(server.port, merged)).resolves.toBe(200); - } finally { - await server.close(); - } - }); + const server = await startTlsServer(mat.serverKey, mat.serverCert); + try { + // Pre-fix state: OpenShell bundle alone cannot verify the corporate leaf. + await expect(httpsGetStatus(server.port, mat.openshellCaCert)).rejects.toThrow( + /unable to (get local issuer|verify)|self.signed|UNABLE_TO_/i, + ); + // Post-fix: the merged bundle trusts the corporate root. + await expect(httpsGetStatus(server.port, merged)).resolves.toBe(200); + } finally { + await server.close(); + } + }); - // Also preserves the OpenShell CA trust behavior from #1828. - it("still trusts the OpenShell root through the merged bundle (#6210)", async () => { - const merged = runMergeBlock(OPENCLAW_START, mat.openshellCaCert, mat.corporateCaCert, mat.dir); - const server = await startTlsServer(mat.openshellServerKey, mat.openshellServerCert); - try { - await expect(httpsGetStatus(server.port, merged)).resolves.toBe(200); - } finally { - await server.close(); - } + // Also preserves the OpenShell CA trust behavior from #1828. + it("still trusts the OpenShell root through the merged bundle (#6210)", async () => { + const merged = runMergeBlock( + scriptPath, + mat.openshellCaCert, + mat.corporateCaCert, + mat.dir, + endMarker, + ); + const server = await startTlsServer(mat.openshellServerKey, mat.openshellServerCert); + try { + await expect(httpsGetStatus(server.port, merged)).resolves.toBe(200); + } finally { + await server.close(); + } + }); }); }); diff --git a/test/e2e/fixtures/corporate-ca.ts b/test/e2e/fixtures/corporate-ca.ts new file mode 100644 index 00000000000..98350c12363 --- /dev/null +++ b/test/e2e/fixtures/corporate-ca.ts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { trustedSandboxShellScript, type TrustedSandboxShellScript } from "./clients/sandbox.ts"; + +export type CorporateCaFixtureMode = "explicit" | "requests" | "host-anchor"; + +export interface CorporateCaFixture { + dir: string; + env: NodeJS.ProcessEnv; + file: string; + mode: CorporateCaFixtureMode; + sourceLabel: string; +} + +const CORPORATE_CA_FIXTURE_PEM = `-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUL3YNpyohvjOEzlwisLKfyiU3dRwwDQYJKoZIhvcNAQEL +BQAwJTEjMCEGA1UEAwwaTmVtb0NsYXcgVGVzdCBDb3Jwb3JhdGUgQ0EwHhcNMjYw +NzA2MDQwMjM2WhcNMzYwNzAzMDQwMjM2WjAlMSMwIQYDVQQDDBpOZW1vQ2xhdyBU +ZXN0IENvcnBvcmF0ZSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALVbV5tyMc65jEH39ejvQvBk7dvI8rz8rSZl+5BWSK2a4TzKm3jD3U+qCDZPicrA +ETCDcO09bN6YIAgpB6rYg5BIURJWxFuljBIBMCZEdO6AVlbURPaGsw6RKLA3cmhx +ZekT0qMcoOKm3N+Hb5MHXsWZ8EUf0co2LsWwJgDZrdwY26gF6w+9wr3iGLE92ZbO +LHhjHUYR1oWXmkXS3YW8MN2h5I+oyL71jBiwLHUi59wogxA/LTAD97/GqwJ6DC4C +UERbIpGYhZfrbiKmT+ASJuKRXaUp/0My3IzH90RqqY70d1E/pkAsd5M8SQ332qAZ +OgW4GgO3n7gAlaN/ILwunZ8CAwEAAaNTMFEwHQYDVR0OBBYEFMa5M8bvDm85eFQi +1D5fNATE/rawMB8GA1UdIwQYMBaAFMa5M8bvDm85eFQi1D5fNATE/rawMA8GA1Ud +EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8NR/0HBUH1WbbDOmGNDzge +o+4Pz0KWR5fPDSx9CrmvUk8ijKpJQcSjQcmrXuhCoRs6aExXLh+wImKkOyMIVXfd +YFWjCffSJzeBQfDlMVW+wiAjUh7xaIqpA6Z8EmpdfyoNWd30AuHjs9m8dAa8M/lP +0qhzCbjDiHNHfYSrAuBHlMJ5RsUrNVtSZGpg1dtaSBa+8XFWWNBeJrUANxb8i7Ax +MAhrfNQcxSkZH2lVY+TA2JO83v12nKXzaW1dC94SlsFf0tVSvM3QTeWVgijpr0q+ +J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= +-----END CERTIFICATE----- +`; + +const CORPORATE_CA_CANARY_LINE = "ZekT0qMcoOKm3N+Hb5MHXsWZ8EUf0co2LsWwJgDZrdwY26gF6w+9wr3iGLE92ZbO"; + +const CORPORATE_CA_FILE_BY_MODE: Record = { + explicit: "corporate-ca.pem", + requests: "corporate-ca.pem", + "host-anchor": "corporate-ca.crt", +}; + +const CORPORATE_CA_SOURCE_LABEL_BY_MODE: Record = { + explicit: "NEMOCLAW_CORPORATE_CA_BUNDLE", + requests: "REQUESTS_CA_BUNDLE fallback", + "host-anchor": "host trust-store anchor override", +}; + +const CORPORATE_CA_ENV_BY_MODE: Record< + CorporateCaFixtureMode, + (file: string, dir: string) => NodeJS.ProcessEnv +> = { + explicit: (file) => ({ NEMOCLAW_CORPORATE_CA_BUNDLE: file }), + requests: (file) => ({ REQUESTS_CA_BUNDLE: file }), + "host-anchor": (_file, dir) => ({ NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS: dir }), +}; + +const CORPORATE_CA_MERGE_PROBE = trustedSandboxShellScript(` +set -eu +corp='/usr/local/share/nemoclaw/corporate-ca.pem' +bundle="\${SSL_CERT_FILE:-}" +test "\${_NEMOCLAW_CORPORATE_CA_MERGED:-}" = "1" +test -n "$bundle" +test -s "$corp" +test -s "$bundle" +test "\${CURL_CA_BUNDLE:-}" = "$bundle" +test "\${REQUESTS_CA_BUNDLE:-}" = "$bundle" +test "\${GIT_SSL_CAINFO:-}" = "$bundle" +test "\${NODE_EXTRA_CA_CERTS:-}" = "$bundle" +grep -F '${CORPORATE_CA_CANARY_LINE}' "$corp" >/dev/null +grep -F '${CORPORATE_CA_CANARY_LINE}' "$bundle" >/dev/null +set -- $(wc -c < "$corp") +corp_bytes="$1" +set -- $(wc -c < "$bundle") +bundle_bytes="$1" +test "$bundle_bytes" -gt "$corp_bytes" +printf 'corporate CA merged into %s (%s > %s bytes)\\n' "$bundle" "$bundle_bytes" "$corp_bytes" +`); + +export function createCorporateCaFixture( + mode: CorporateCaFixtureMode, + prefix: string, +): CorporateCaFixture { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const file = path.join(dir, CORPORATE_CA_FILE_BY_MODE[mode]); + fs.writeFileSync(file, CORPORATE_CA_FIXTURE_PEM, { mode: 0o644 }); + fs.chmodSync(file, 0o644); + return { + dir, + env: CORPORATE_CA_ENV_BY_MODE[mode](file, dir), + file, + mode, + sourceLabel: CORPORATE_CA_SOURCE_LABEL_BY_MODE[mode], + }; +} + +export function cleanupCorporateCaFixture(fixture: CorporateCaFixture): void { + fs.rmSync(fixture.dir, { recursive: true, force: true }); +} + +export function corporateCaMergeProbeScript(): TrustedSandboxShellScript { + return CORPORATE_CA_MERGE_PROBE; +} diff --git a/test/e2e/live/cloud-onboard.test.ts b/test/e2e/live/cloud-onboard.test.ts index f2e84abc23c..fb040fe0fbf 100644 --- a/test/e2e/live/cloud-onboard.test.ts +++ b/test/e2e/live/cloud-onboard.test.ts @@ -8,6 +8,11 @@ import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { resultText } from "../fixtures/clients/command.ts"; import { type HostCliClient } from "../fixtures/clients/host.ts"; import { type SandboxClient, validateSandboxName } from "../fixtures/clients/sandbox.ts"; +import { + cleanupCorporateCaFixture, + corporateCaMergeProbeScript, + createCorporateCaFixture, +} from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; @@ -75,6 +80,7 @@ test("cloud onboard: public installer creates healthy sandbox with security chec process.env.NEMOCLAW_INSTALL_SCRIPT_URL ?? `https://raw.githubusercontent.com/NVIDIA/NemoClaw/${ref}/install.sh`; const installCwd = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-public-install-")); + const corporateCa = createCorporateCaFixture("explicit", "nemoclaw-cloud-corporate-ca-"); const redactionValues = [hosted.apiKey]; await artifacts.target.declare({ @@ -83,9 +89,11 @@ test("cloud onboard: public installer creates healthy sandbox with security chec installUrl, installRef: ref, checksDir: CHECKS_DIR, + corporateCaSource: corporateCa.sourceLabel, contracts: [ "public curl installer uses GitHub clone path for the requested ref", "sandbox appears healthy after cloud onboarding", + "explicit corporate CA source is baked and merged with OpenShell trust inside the sandbox", "cloud split checks cover inference.local, security leak checks, and Landlock/read-only behavior", "cleanup verifies sandbox removal", ], @@ -104,6 +112,7 @@ test("cloud onboard: public installer creates healthy sandbox with security chec cleanupRegistry.add("remove cloud-onboard sandbox", () => cleanup(host, sandbox, { label: "cleanup", verify: true }), ); + cleanupRegistry.add("remove corporate CA fixture", () => cleanupCorporateCaFixture(corporateCa)); await cleanup(host, sandbox, { label: "pre-cleanup", verify: false }); const install = await host.command( @@ -113,6 +122,7 @@ test("cloud onboard: public installer creates healthy sandbox with security chec artifactName: "phase-1-public-install", env: env({ ...hosted.env, + ...corporateCa.env, NVIDIA_INFERENCE_API_KEY: hosted.apiKey, NEMOCLAW_INSTALL_REF: ref, NEMOCLAW_INSTALL_TAG: ref, @@ -145,6 +155,13 @@ test("cloud onboard: public installer creates healthy sandbox with security chec expect(list.exitCode, resultText(list)).toBe(0); expect(list.stdout).toContain(SANDBOX_NAME); + const corporateCaProbe = await sandbox.execShell(SANDBOX_NAME, corporateCaMergeProbeScript(), { + artifactName: "phase-2-corporate-ca-merge-probe", + env: env(), + timeoutMs: 60_000, + }); + expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); + const checkScripts = fs .readdirSync(CHECKS_DIR) .filter((name) => name.endsWith(".sh")) diff --git a/test/e2e/live/onboard-repair.test.ts b/test/e2e/live/onboard-repair.test.ts index 7b352ba0e9e..3db6ac76cb8 100644 --- a/test/e2e/live/onboard-repair.test.ts +++ b/test/e2e/live/onboard-repair.test.ts @@ -8,6 +8,11 @@ import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { resultText } from "../fixtures/clients/command.ts"; import { type HostCliClient } from "../fixtures/clients/host.ts"; import { type SandboxClient, validateSandboxName } from "../fixtures/clients/sandbox.ts"; +import { + cleanupCorporateCaFixture, + corporateCaMergeProbeScript, + createCorporateCaFixture, +} from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { startFakeOpenAiCompatibleServer } from "../fixtures/fake-openai-compatible.ts"; import { CLI_ENTRYPOINT } from "../fixtures/paths.ts"; @@ -104,13 +109,16 @@ async function waitSandboxAbsent(sandbox: SandboxClient, name: string): Promise< test("onboard repair resumes missing sandbox and rejects conflicting resume inputs", { timeout: LIVE_TIMEOUT_MS, }, async ({ artifacts, cleanup: cleanupRegistry, host, sandbox, skip }) => { + const corporateCa = createCorporateCaFixture("requests", "nemoclaw-repair-corporate-ca-"); await artifacts.target.declare({ id: "onboard-repair", sandboxName: SANDBOX_NAME, otherSandboxName: OTHER_SANDBOX_NAME, + corporateCaSource: corporateCa.sourceLabel, contracts: [ "forced policy-step failure leaves a resumable session", "resume recreates a recorded sandbox that was removed underneath it", + "REQUESTS_CA_BUNDLE fallback corporate CA source is baked and merged after repair", "resume rejects a different requested sandbox name", "resume rejects provider/model overrides that conflict with recorded state", ], @@ -132,6 +140,7 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu }); cleanupRegistry.add("close fake OpenAI-compatible endpoint", async () => fake.close()); cleanupRegistry.add("remove repair sandboxes", () => cleanup(host, sandbox)); + cleanupRegistry.add("remove corporate CA fixture", () => cleanupCorporateCaFixture(corporateCa)); await cleanup(host, sandbox); const first = await nemoclaw( @@ -143,6 +152,7 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu NEMOCLAW_E2E_FORCE_FAIL_AT_STEP: "policies", NEMOCLAW_POLICY_MODE: "suggested", NEMOCLAW_RECREATE_SANDBOX: "1", + ...corporateCa.env, }), ); expect(first.exitCode, resultText(first)).toBe(1); @@ -169,6 +179,7 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu "phase-2-resume-repair", onboardEnv(SANDBOX_NAME, fake.baseUrl, { NEMOCLAW_POLICY_MODE: "skip", + ...corporateCa.env, }), ); expect(repair.exitCode, resultText(repair)).toBe(0); @@ -179,6 +190,13 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu const status = await nemoclaw(host, [SANDBOX_NAME, "status"], "phase-2-status-after-repair"); expect(status.exitCode, resultText(status)).toBe(0); + const corporateCaProbe = await sandbox.execShell(SANDBOX_NAME, corporateCaMergeProbeScript(), { + artifactName: "phase-2-corporate-ca-merge-probe", + env: env(), + timeoutMs: 60_000, + }); + expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); + const reinject = await nemoclaw( host, ["onboard", "--non-interactive"], @@ -188,6 +206,7 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu NEMOCLAW_E2E_FORCE_FAIL_AT_STEP: "policies", NEMOCLAW_POLICY_MODE: "suggested", NEMOCLAW_RECREATE_SANDBOX: "1", + ...corporateCa.env, }), ); expect(reinject.exitCode, resultText(reinject)).toBe(1); diff --git a/test/e2e/live/onboard-resume.test.ts b/test/e2e/live/onboard-resume.test.ts index 45011aa4fdd..6d078ad340a 100644 --- a/test/e2e/live/onboard-resume.test.ts +++ b/test/e2e/live/onboard-resume.test.ts @@ -6,8 +6,14 @@ import os from "node:os"; import path from "node:path"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import { resultText } from "../fixtures/clients/command.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { trustedSandboxShellScript, validateSandboxName } from "../fixtures/clients/sandbox.ts"; +import { + cleanupCorporateCaFixture, + corporateCaMergeProbeScript, + createCorporateCaFixture, +} from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { type FakeOpenAiCompatibleServer, @@ -133,6 +139,24 @@ test("onboard-resume: interrupted onboard then --resume completes without redoin host, sandbox, }) => { + const corporateCa = createCorporateCaFixture("host-anchor", "nemoclaw-resume-corporate-ca-"); + cleanup.add("remove corporate CA fixture", () => cleanupCorporateCaFixture(corporateCa)); + await artifacts.writeJson("corporate-ca-source.json", { + mode: corporateCa.mode, + source: corporateCa.sourceLabel, + }); + await artifacts.target.declare({ + id: "onboard-resume", + sandboxName: SANDBOX_NAME, + corporateCaSource: corporateCa.sourceLabel, + contracts: [ + "forced policy-step failure leaves a resumable session", + "resume completes without redoing cached preflight/gateway/sandbox steps", + "host trust-store anchor corporate CA source is baked and merged after resume", + "implicit resume is detected and --fresh suppresses that auto-resume", + ], + }); + // ────────────────────────────────────────────────────────────────── // Phase 1: prerequisites (host-side, all faithful on ubuntu-latest) // ────────────────────────────────────────────────────────────────── @@ -278,6 +302,7 @@ test("onboard-resume: interrupted onboard then --resume completes without redoin NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", NEMOCLAW_E2E_FAILURE_INJECTION: "1", NEMOCLAW_E2E_FORCE_FAIL_AT_STEP: "policies", + ...corporateCa.env, }; expect(firstRunEnv.NVIDIA_INFERENCE_API_KEY).toBeUndefined(); const firstRun = await host.command("node", [CLI_ENTRYPOINT, "onboard", "--non-interactive"], { @@ -356,6 +381,7 @@ test("onboard-resume: interrupted onboard then --resume completes without redoin NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, NEMOCLAW_POLICY_MODE: "skip", NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + ...corporateCa.env, }; expect(resumeEnv.NVIDIA_INFERENCE_API_KEY).toBeUndefined(); expect(resumeEnv.COMPATIBLE_API_KEY).toBeUndefined(); @@ -404,6 +430,13 @@ test("onboard-resume: interrupted onboard then --resume completes without redoin }); expect(sandboxStatus.exitCode, sandboxStatus.stderr).toBe(0); + const corporateCaProbe = await sandbox.execShell(SANDBOX_NAME, corporateCaMergeProbeScript(), { + artifactName: "phase-3-corporate-ca-merge-probe", + env: probeEnv, + timeoutMs: 60_000, + }); + expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); + // Assertion: session-file-complete-state. const complete = readSession(SESSION_FILE); await artifacts.writeJson("phase-3-session-summary.json", completeSessionSummary(complete)); @@ -478,4 +511,5 @@ test("onboard-resume: interrupted onboard then --resume completes without redoin expect(freshRun.exitCode, freshText).not.toBe(0); expect(freshText).toContain("[e2e] Forced onboarding failure at step 'preflight'."); expect(freshText).not.toContain("(resume mode)"); + await artifacts.target.complete({ id: "onboard-resume", status: "passed" }); }); diff --git a/test/helpers/corporate-ca-support.ts b/test/helpers/corporate-ca-support.ts index 938e7e4455f..e467c5f6bfc 100644 --- a/test/helpers/corporate-ca-support.ts +++ b/test/helpers/corporate-ca-support.ts @@ -181,12 +181,9 @@ export function runMergeBlock( openshellBundle: string, corporateCa: string, outDir: string, + endMarker = "# Git TLS CA bundle fix (NemoClaw#2270).", ): string { - const block = sliceBlock( - scriptPath, - "# Corporate proxy CA merge (NemoClaw#6210).", - "# Git TLS CA bundle fix (NemoClaw#2270).", - ) + const block = sliceBlock(scriptPath, "# Corporate proxy CA merge (NemoClaw#6210).", endMarker) .replaceAll("/usr/local/share/nemoclaw/corporate-ca.pem", corporateCa) .replaceAll("/tmp/nemoclaw-ca-bundle.pem", path.join(outDir, "merged-ca.pem")); const wrapper = path.join(outDir, "merge.sh"); From a60e3b103063b0b612e23e9a61916061bd4b0419 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 9 Jul 2026 11:26:12 -0700 Subject: [PATCH 19/26] test: make corporate CA live probe file-backed --- test/e2e/fixtures/corporate-ca.ts | 43 +++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/test/e2e/fixtures/corporate-ca.ts b/test/e2e/fixtures/corporate-ca.ts index 98350c12363..0da1045d8b8 100644 --- a/test/e2e/fixtures/corporate-ca.ts +++ b/test/e2e/fixtures/corporate-ca.ts @@ -63,24 +63,41 @@ const CORPORATE_CA_ENV_BY_MODE: Record< const CORPORATE_CA_MERGE_PROBE = trustedSandboxShellScript(` set -eu +probe_fail() { + printf 'CORPORATE_CA_PROBE_FAIL:%s\\n' "$1" >&2 + exit 1 +} + +expect_export() { + env_name="$1" + if grep -F "export $env_name=$bundle" "$runtime_env" >/dev/null || \\ + grep -F "export $env_name='$bundle'" "$runtime_env" >/dev/null || \\ + grep -F "export $env_name=\\"$bundle\\"" "$runtime_env" >/dev/null; then + return 0 + fi + probe_fail "runtime-env-$env_name" +} + corp='/usr/local/share/nemoclaw/corporate-ca.pem' -bundle="\${SSL_CERT_FILE:-}" -test "\${_NEMOCLAW_CORPORATE_CA_MERGED:-}" = "1" -test -n "$bundle" -test -s "$corp" -test -s "$bundle" -test "\${CURL_CA_BUNDLE:-}" = "$bundle" -test "\${REQUESTS_CA_BUNDLE:-}" = "$bundle" -test "\${GIT_SSL_CAINFO:-}" = "$bundle" -test "\${NODE_EXTRA_CA_CERTS:-}" = "$bundle" -grep -F '${CORPORATE_CA_CANARY_LINE}' "$corp" >/dev/null -grep -F '${CORPORATE_CA_CANARY_LINE}' "$bundle" >/dev/null +bundle='/tmp/nemoclaw-ca-bundle.pem' +runtime_env='/tmp/nemoclaw-proxy-env.sh' + +[ -s "$corp" ] || probe_fail missing-corporate-ca +[ -s "$bundle" ] || probe_fail missing-merged-bundle +[ -s "$runtime_env" ] || probe_fail missing-runtime-env +grep -F '${CORPORATE_CA_CANARY_LINE}' "$corp" >/dev/null || probe_fail corporate-canary-missing +grep -F '${CORPORATE_CA_CANARY_LINE}' "$bundle" >/dev/null || probe_fail bundle-canary-missing set -- $(wc -c < "$corp") corp_bytes="$1" set -- $(wc -c < "$bundle") bundle_bytes="$1" -test "$bundle_bytes" -gt "$corp_bytes" -printf 'corporate CA merged into %s (%s > %s bytes)\\n' "$bundle" "$bundle_bytes" "$corp_bytes" +[ "$bundle_bytes" -gt "$corp_bytes" ] || probe_fail bundle-did-not-preserve-base + +for env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do + expect_export "$env_name" +done + +printf 'corporate CA baked and merged into %s (%s > %s bytes)\\n' "$bundle" "$bundle_bytes" "$corp_bytes" `); export function createCorporateCaFixture( From 7cab7e3411dc5aff7ae8a4c129a341f121ac3e54 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 9 Jul 2026 11:38:38 -0700 Subject: [PATCH 20/26] test: shell quote cloud onboard installer command --- test/e2e/live/cloud-onboard.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/live/cloud-onboard.test.ts b/test/e2e/live/cloud-onboard.test.ts index fb040fe0fbf..f6d545a8049 100644 --- a/test/e2e/live/cloud-onboard.test.ts +++ b/test/e2e/live/cloud-onboard.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; -import { resultText } from "../fixtures/clients/command.ts"; +import { resultText, shellQuote } from "../fixtures/clients/command.ts"; import { type HostCliClient } from "../fixtures/clients/host.ts"; import { type SandboxClient, validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { @@ -117,7 +117,7 @@ test("cloud onboard: public installer creates healthy sandbox with security chec const install = await host.command( "bash", - ["-lc", `cd '${installCwd}' && curl -fsSL '${installUrl}' | bash`], + ["-lc", `cd ${shellQuote(installCwd)} && curl -fsSL ${shellQuote(installUrl)} | bash`], { artifactName: "phase-1-public-install", env: env({ From 9799a74bf4a90feee9c1b5a105d0e79ab1f35cfb Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 9 Jul 2026 12:39:54 -0700 Subject: [PATCH 21/26] docs(onboard): pin corporate CA anchor-source scope --- docs/reference/troubleshooting.mdx | 2 +- src/lib/onboard/corporate-ca.test.ts | 8 ++++++++ src/lib/onboard/corporate-ca.ts | 21 +++++++++++---------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index a769d4b8546..256c44bbc6e 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1236,7 +1236,7 @@ Resolution order: When a fallback or host-store source is baked, onboarding logs which source and path it used (`baking corporate proxy CA from …`). If you set a conventional CA variable but the import does not happen, check the onboard build logs: a variable that points at a missing or invalid file is skipped with a `WARNING: … was skipped for corporate CA import` line, and an anchor directory that holds only invalid certificates logs a similar warning. Absence of the `baking …` line means no source validated. Use `NEMOCLAW_CORPORATE_CA_BUNDLE` for explicit, fail-loud behavior. To import a corporate root that is not installed in an anchor directory, export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at it. -`/etc/ssl/certs/` contract: NemoClaw satisfies host trust-store detection by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected; a root present *only* as a hand-edited entry in the merged file is not, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. +`/etc/ssl/certs/` contract: NemoClaw satisfies the #6210 host trust-store acceptance path by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected; a root present *only* as a hand-edited entry in the merged file is not, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. Custom Dockerfile contract: the managed NemoClaw sandbox Dockerfiles handle the corporate CA automatically — onboarding bakes the validated bundle into `ARG NEMOCLAW_CORPORATE_CA_B64`, and the image decodes it into the root-owned, read-only file `/usr/local/share/nemoclaw/corporate-ca.pem`. If you build the sandbox from a **custom Dockerfile**, the fallback and host-store sources are a silent no-op unless that Dockerfile declares `ARG NEMOCLAW_CORPORATE_CA_B64` and decodes it into `/usr/local/share/nemoclaw/corporate-ca.pem` the same way. The explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` is the exception — it fails onboarding loudly (rather than no-op) when the managed ARG is absent, so you find out immediately. Either add the ARG and decode step to your Dockerfile, or keep using a managed Dockerfile, to import a corporate CA through automatic detection. diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index 6caf47ba1e3..151b26534a4 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -11,6 +11,7 @@ import { CORPORATE_CA_ANCHOR_DIRS_ENV, CORPORATE_CA_DISABLE_ENV, CORPORATE_CA_EXPLICIT_ENV, + CORPORATE_CA_HOST_ANCHOR_DIRS, CORPORATE_CA_HOST_ANCHOR_SOURCE, CorporateCaValidationError, encodeCorporateCaArg, @@ -288,6 +289,13 @@ describe("isKnownMergedTrustStorePath (#6210)", () => { }); describe("resolveCorporateCaFromHostAnchors host trust-store path (#6210)", () => { + it("narrows /etc/ssl/certs detection to anchor-source dirs, not merged output (#6210)", () => { + expect(CORPORATE_CA_HOST_ANCHOR_DIRS).toContain("/usr/local/share/ca-certificates"); + expect(CORPORATE_CA_HOST_ANCHOR_DIRS).not.toContain("/etc/ssl/certs"); + expect(CORPORATE_CA_HOST_ANCHOR_DIRS).not.toContain("/etc/ssl/certs/ca-certificates.crt"); + expect(isKnownMergedTrustStorePath("/etc/ssl/certs/ca-certificates.crt")).toBe(true); + }); + it("discovers a corporate root installed in a host anchor directory", () => { const anchorDir = tmpDir(); writeAnchor(anchorDir, "corp-proxy-root.crt"); diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index b973693cad0..12f7853252c 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -97,14 +97,15 @@ const DEFAULT_HOST_ANCHOR_SPECS = [ /** * Host trust-store anchor directories scanned as a last resort (#6210 - * acceptance path). These hold ONLY locally-added anchors: the distro's ~140 - * public roots live elsewhere and are compiled into the merged - * `/etc/ssl/certs/ca-certificates.crt` output — which we deliberately do NOT - * scan. Reading the anchor sources lets us import exactly the corporate root the - * reporter installed on the DGX Station host without baking broad, unrelated OS - * trust into the image. Discovery is bounded by {@link MAX_CORPORATE_CA_CERTS} / - * {@link MAX_CORPORATE_CA_BYTES}; a directory that would exceed those caps is - * skipped rather than truncated. + * acceptance path). The issue's "host `/etc/ssl/certs/`" acceptance is narrowed + * here to administrator-managed anchor **source** directories only. These hold + * locally-added anchors: the distro's ~140 public roots live elsewhere and are + * compiled into the merged `/etc/ssl/certs/ca-certificates.crt` output — which + * we deliberately do NOT scan. Reading the anchor sources lets us import exactly + * the corporate root the reporter installed on the DGX Station host without + * baking broad, unrelated OS trust into the image. Discovery is bounded by + * {@link MAX_CORPORATE_CA_CERTS} / {@link MAX_CORPORATE_CA_BYTES}; a directory + * that would exceed those caps is skipped rather than truncated. */ export const CORPORATE_CA_HOST_ANCHOR_DIRS = DEFAULT_HOST_ANCHOR_SPECS.map( (spec) => spec.dir, @@ -118,8 +119,8 @@ export const CORPORATE_CA_HOST_ANCHOR_DIRS = DEFAULT_HOST_ANCHOR_SPECS.map( */ export const CORPORATE_CA_ANCHOR_DIRS_ENV = "NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS"; -/** Reported `sourceEnv` when a CA is discovered from the host anchor dirs. */ -export const CORPORATE_CA_HOST_ANCHOR_SOURCE = "host trust store"; +/** Reported `sourceEnv` when a CA is discovered from host anchor source dirs. */ +export const CORPORATE_CA_HOST_ANCHOR_SOURCE = "host trust-store anchor source"; /** * Recognized extensions for a directory not in {@link DEFAULT_HOST_ANCHOR_SPECS} From 51ed9da7989b026216dbcd979f0fa3c43680028f Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 9 Jul 2026 12:49:15 -0700 Subject: [PATCH 22/26] fix(onboard): warn on literal ssl certs-only CA --- docs/reference/troubleshooting.mdx | 2 +- src/lib/onboard/corporate-ca.test.ts | 64 +++++++++++++++++++- src/lib/onboard/corporate-ca.ts | 89 ++++++++++++++++++++++++---- 3 files changed, 140 insertions(+), 15 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 256c44bbc6e..5edacb1cc58 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1236,7 +1236,7 @@ Resolution order: When a fallback or host-store source is baked, onboarding logs which source and path it used (`baking corporate proxy CA from …`). If you set a conventional CA variable but the import does not happen, check the onboard build logs: a variable that points at a missing or invalid file is skipped with a `WARNING: … was skipped for corporate CA import` line, and an anchor directory that holds only invalid certificates logs a similar warning. Absence of the `baking …` line means no source validated. Use `NEMOCLAW_CORPORATE_CA_BUNDLE` for explicit, fail-loud behavior. To import a corporate root that is not installed in an anchor directory, export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at it. -`/etc/ssl/certs/` contract: NemoClaw satisfies the #6210 host trust-store acceptance path by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected; a root present *only* as a hand-edited entry in the merged file is not, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. +`/etc/ssl/certs/` contract: NemoClaw satisfies the #6210 host trust-store acceptance path by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected. If no anchor source validates but NemoClaw sees a standalone certificate file only in the literal `/etc/ssl/certs/` output directory, it logs a `WARNING` that points you to `NEMOCLAW_CORPORATE_CA_BUNDLE` or `NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS` instead of silently missing that host layout. A root present *only* as a hand-edited entry in the merged file is not imported, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. Custom Dockerfile contract: the managed NemoClaw sandbox Dockerfiles handle the corporate CA automatically — onboarding bakes the validated bundle into `ARG NEMOCLAW_CORPORATE_CA_B64`, and the image decodes it into the root-owned, read-only file `/usr/local/share/nemoclaw/corporate-ca.pem`. If you build the sandbox from a **custom Dockerfile**, the fallback and host-store sources are a silent no-op unless that Dockerfile declares `ARG NEMOCLAW_CORPORATE_CA_B64` and decodes it into `/usr/local/share/nemoclaw/corporate-ca.pem` the same way. The explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` is the exception — it fails onboarding loudly (rather than no-op) when the managed ARG is absent, so you find out immediately. Either add the ARG and decode step to your Dockerfile, or keep using a managed Dockerfile, to import a corporate CA through automatic detection. diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index 151b26534a4..55cce841e08 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -13,6 +13,7 @@ import { CORPORATE_CA_EXPLICIT_ENV, CORPORATE_CA_HOST_ANCHOR_DIRS, CORPORATE_CA_HOST_ANCHOR_SOURCE, + CORPORATE_CA_LITERAL_SSL_CERTS_DIR, CorporateCaValidationError, encodeCorporateCaArg, isKnownMergedTrustStorePath, @@ -289,10 +290,11 @@ describe("isKnownMergedTrustStorePath (#6210)", () => { }); describe("resolveCorporateCaFromHostAnchors host trust-store path (#6210)", () => { - it("narrows /etc/ssl/certs detection to anchor-source dirs, not merged output (#6210)", () => { + it("imports from anchor-source dirs, not merged /etc/ssl/certs output (#6210)", () => { expect(CORPORATE_CA_HOST_ANCHOR_DIRS).toContain("/usr/local/share/ca-certificates"); expect(CORPORATE_CA_HOST_ANCHOR_DIRS).not.toContain("/etc/ssl/certs"); expect(CORPORATE_CA_HOST_ANCHOR_DIRS).not.toContain("/etc/ssl/certs/ca-certificates.crt"); + expect(CORPORATE_CA_LITERAL_SSL_CERTS_DIR).toBe("/etc/ssl/certs"); expect(isKnownMergedTrustStorePath("/etc/ssl/certs/ca-certificates.crt")).toBe(true); }); @@ -412,7 +414,12 @@ describe("resolveCorporateCa env then host anchors (#6210)", () => { }); it("returns null when neither env nor host anchors provide a CA", () => { - expect(resolveCorporateCa({}, { hostAnchorDirs: [path.join(tmpDir(), "absent")] })).toBeNull(); + expect( + resolveCorporateCa( + {}, + { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir: null }, + ), + ).toBeNull(); }); it("reads host anchor directories from the anchor-dirs env override", () => { @@ -426,6 +433,59 @@ describe("resolveCorporateCa env then host anchors (#6210)", () => { it("disables host-store scanning when the anchor-dirs override is empty", () => { expect(resolveCorporateCa({ [CORPORATE_CA_ANCHOR_DIRS_ENV]: "" })).toBeNull(); }); + + it("warns when only a literal /etc/ssl/certs-style directory has a standalone cert (#6210)", () => { + const literalSslCertsDir = tmpDir(); + const standaloneCert = writeAnchor(literalSslCertsDir, "corp-proxy-root.pem"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCa( + {}, + { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, + ); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + expect(resolved).toBeNull(); + expect( + messages.some( + (m) => + m.includes(literalSslCertsDir) && + m.includes(standaloneCert) && + m.includes(CORPORATE_CA_EXPLICIT_ENV) && + m.includes(CORPORATE_CA_ANCHOR_DIRS_ENV), + ), + ).toBe(true); + }); + + it("does not warn for the merged /etc/ssl/certs ca-certificates output alone (#6210)", () => { + const literalSslCertsDir = tmpDir(); + writeAnchor(literalSslCertsDir, "ca-certificates.crt"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCa( + {}, + { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, + ); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + expect(resolved).toBeNull(); + expect(messages).toHaveLength(0); + }); + + it("does not warn about literal /etc/ssl/certs when host-store scanning is disabled", () => { + const literalSslCertsDir = tmpDir(); + writeAnchor(literalSslCertsDir, "corp-proxy-root.pem"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCa( + { [CORPORATE_CA_ANCHOR_DIRS_ENV]: "" }, + { literalSslCertsDir }, + ); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + expect(resolved).toBeNull(); + expect(messages).toHaveLength(0); + }); }); describe("encodeCorporateCaArg", () => { diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index 12f7853252c..435b9261a89 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -97,13 +97,16 @@ const DEFAULT_HOST_ANCHOR_SPECS = [ /** * Host trust-store anchor directories scanned as a last resort (#6210 - * acceptance path). The issue's "host `/etc/ssl/certs/`" acceptance is narrowed - * here to administrator-managed anchor **source** directories only. These hold - * locally-added anchors: the distro's ~140 public roots live elsewhere and are - * compiled into the merged `/etc/ssl/certs/ca-certificates.crt` output — which - * we deliberately do NOT scan. Reading the anchor sources lets us import exactly - * the corporate root the reporter installed on the DGX Station host without - * baking broad, unrelated OS trust into the image. Discovery is bounded by + * acceptance path). Automatic import reads administrator-managed anchor + * **source** directories only. These hold locally-added anchors: the distro's + * ~140 public roots live elsewhere and are compiled into the merged + * `/etc/ssl/certs/ca-certificates.crt` output, which is never imported as a + * corporate CA. Reading the anchor sources lets us import exactly the corporate + * root the reporter installed on the DGX Station host without baking broad, + * unrelated OS trust into the image. If those sources miss but a standalone + * certificate file exists only in the literal `/etc/ssl/certs/` output + * directory, `resolveCorporateCa` warns with explicit bundle guidance so the + * #6210 path is not silent. Discovery is bounded by * {@link MAX_CORPORATE_CA_CERTS} / {@link MAX_CORPORATE_CA_BYTES}; a directory * that would exceed those caps is skipped rather than truncated. */ @@ -111,6 +114,9 @@ export const CORPORATE_CA_HOST_ANCHOR_DIRS = DEFAULT_HOST_ANCHOR_SPECS.map( (spec) => spec.dir, ) as readonly string[]; +/** Literal Debian/Ubuntu merged trust-store output directory from #6210. */ +export const CORPORATE_CA_LITERAL_SSL_CERTS_DIR = "/etc/ssl/certs"; + /** * Override the host anchor directories scanned. A path-list (`path.delimiter` * separated). Set to an empty value to disable host-store scanning entirely. @@ -148,6 +154,9 @@ const HOST_ANCHOR_MAX_FILES = 256; */ const HOST_ANCHOR_MAX_DIRS = 1024; +const LITERAL_SSL_CERTS_EXT_RE = /\.(?:pem|crt|cer)$/i; +const LITERAL_SSL_CERTS_MERGED_BASENAMES = new Set(["ca-certificates.crt"]); + /** Opt-out: set to a falsey token to disable corporate CA import entirely. */ export const CORPORATE_CA_DISABLE_ENV = "NEMOCLAW_CORPORATE_CA_IMPORT"; @@ -417,6 +426,47 @@ function collectAnchorFiles(root: string, extensions: RegExp): string[] { return out.sort(); } +function collectLiteralSslCertFiles(root: string): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter( + (entry) => + entry.isFile() && + LITERAL_SSL_CERTS_EXT_RE.test(entry.name) && + !LITERAL_SSL_CERTS_MERGED_BASENAMES.has(entry.name), + ) + .map((entry) => path.join(root, entry.name)) + .filter((file) => !isKnownMergedTrustStorePath(file)) + .sort() + .slice(0, HOST_ANCHOR_MAX_FILES); +} + +function warnIfLiteralSslCertsOnlySource(root: string): void { + const candidates = collectLiteralSslCertFiles(root); + if (candidates.length === 0) return; + + const validCandidates: string[] = []; + for (const candidate of candidates) { + try { + validateCorporateCaFile(candidate); + validCandidates.push(candidate); + } catch { + // Invalid standalone files are not actionable enough for the #6210 warning. + } + } + if (validCandidates.length === 0) return; + + const example = validCandidates[0]; + warnCorporateCa( + `host ${root} contains standalone certificate file(s) such as ${example}, but NemoClaw does not import the merged/output trust directory automatically; set ${CORPORATE_CA_EXPLICIT_ENV} to the corporate root PEM, or set ${CORPORATE_CA_ANCHOR_DIRS_ENV} to the administrator anchor source directory`, + ); +} + /** * Resolve a corporate CA from the host administrator-managed anchor directories * (#6210 acceptance path). @@ -431,8 +481,9 @@ function collectAnchorFiles(root: string, extensions: RegExp): string[] { * bounded by the depth/file caps above. * * Returns `null` when no anchor directory yields a usable, bounded bundle. - * Never throws: an unreadable/invalid/oversized anchor set is skipped silently - * (this is an implicit fallback, like the conventional CA env vars). + * Never throws: an unreadable/invalid/oversized anchor set is skipped without + * breaking onboard (this is an implicit fallback, like the conventional CA env + * vars), with warnings only when a concrete candidate was found but not imported. */ export function resolveCorporateCaFromHostAnchors( dirs: readonly string[] = CORPORATE_CA_HOST_ANCHOR_DIRS, @@ -507,6 +558,8 @@ function hostAnchorDirsFromEnv(env: NodeJS.ProcessEnv): readonly string[] | null export interface ResolveCorporateCaOptions { /** Override the host anchor directories scanned (testing seam). */ hostAnchorDirs?: readonly string[]; + /** Override the literal `/etc/ssl/certs` warning probe path (testing seam). */ + literalSslCertsDir?: string | null; } /** @@ -531,9 +584,21 @@ export function resolveCorporateCa( if (isDisabled(env)) return null; const fromEnv = resolveCorporateCaFromEnv(env); if (fromEnv) return fromEnv; - const anchorDirs = - options.hostAnchorDirs ?? hostAnchorDirsFromEnv(env) ?? CORPORATE_CA_HOST_ANCHOR_DIRS; - return resolveCorporateCaFromHostAnchors(anchorDirs); + const envAnchorDirs = hostAnchorDirsFromEnv(env); + const anchorDirs = options.hostAnchorDirs ?? envAnchorDirs ?? CORPORATE_CA_HOST_ANCHOR_DIRS; + const fromHostAnchors = resolveCorporateCaFromHostAnchors(anchorDirs); + if (fromHostAnchors) return fromHostAnchors; + + const hostScanningDisabledByEnv = + envAnchorDirs !== null && (env[CORPORATE_CA_ANCHOR_DIRS_ENV] ?? "").trim().length === 0; + const literalSslCertsDir = + options.literalSslCertsDir === undefined + ? CORPORATE_CA_LITERAL_SSL_CERTS_DIR + : options.literalSslCertsDir; + if (!hostScanningDisabledByEnv && literalSslCertsDir !== null) { + warnIfLiteralSslCertsOnlySource(literalSslCertsDir); + } + return null; } /** Base64-encode PEM text for a single-line Dockerfile ARG value. */ From 455ea2c546f8c62c9936284f4c62c282773d0581 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 9 Jul 2026 14:34:46 -0700 Subject: [PATCH 23/26] fix(onboard): tighten corporate CA trust handling --- agents/hermes/start.sh | 6 +- docs/reference/troubleshooting.mdx | 4 +- scripts/nemoclaw-start.sh | 6 +- src/lib/onboard/corporate-ca-env.ts | 60 ++ src/lib/onboard/corporate-ca-host-anchors.ts | 188 ++++++ src/lib/onboard/corporate-ca-policy.ts | 103 ++++ src/lib/onboard/corporate-ca-types.ts | 18 + src/lib/onboard/corporate-ca-validation.ts | 106 ++++ src/lib/onboard/corporate-ca.test.ts | 47 ++ src/lib/onboard/corporate-ca.ts | 605 ++----------------- test/corporate-ca-runtime-merge.test.ts | 47 ++ 11 files changed, 630 insertions(+), 560 deletions(-) create mode 100644 src/lib/onboard/corporate-ca-env.ts create mode 100644 src/lib/onboard/corporate-ca-host-anchors.ts create mode 100644 src/lib/onboard/corporate-ca-policy.ts create mode 100644 src/lib/onboard/corporate-ca-types.ts create mode 100644 src/lib/onboard/corporate-ca-validation.ts diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 4ac1918a85f..86f7b24eeb4 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1489,7 +1489,11 @@ merge_corporate_proxy_ca() { _nemoclaw_ca_merge_warn "append corporate CA" return 0 } - chmod 0444 "$_tmp" 2>/dev/null || true + chmod 0444 "$_tmp" 2>/dev/null || { + rm -f "$_tmp" + _nemoclaw_ca_merge_warn "set merged bundle permissions (${_merged})" + return 0 + } # Defense-in-depth for the predictable /tmp path (#6210): if a co-tenant # pre-planted a symlink at the target, drop it first so we rename into a fresh # regular file we own rather than through an attacker-controlled link. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 5edacb1cc58..3138150f2bc 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1236,11 +1236,11 @@ Resolution order: When a fallback or host-store source is baked, onboarding logs which source and path it used (`baking corporate proxy CA from …`). If you set a conventional CA variable but the import does not happen, check the onboard build logs: a variable that points at a missing or invalid file is skipped with a `WARNING: … was skipped for corporate CA import` line, and an anchor directory that holds only invalid certificates logs a similar warning. Absence of the `baking …` line means no source validated. Use `NEMOCLAW_CORPORATE_CA_BUNDLE` for explicit, fail-loud behavior. To import a corporate root that is not installed in an anchor directory, export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at it. -`/etc/ssl/certs/` contract: NemoClaw satisfies the #6210 host trust-store acceptance path by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected. If no anchor source validates but NemoClaw sees a standalone certificate file only in the literal `/etc/ssl/certs/` output directory, it logs a `WARNING` that points you to `NEMOCLAW_CORPORATE_CA_BUNDLE` or `NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS` instead of silently missing that host layout. A root present *only* as a hand-edited entry in the merged file is not imported, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. +`/etc/ssl/certs/` contract: NemoClaw satisfies the #6210 host trust-store acceptance path by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected. If no anchor source validates but NemoClaw sees a standalone CA certificate file only in the literal `/etc/ssl/certs/` output directory, it logs a `WARNING` that points you to `NEMOCLAW_CORPORATE_CA_BUNDLE` or `NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS` instead of silently missing that host layout. Normal leaf certificates in that directory, such as Ubuntu's `ssl-cert-snakeoil.pem`, are ignored. A root present *only* as a hand-edited entry in the merged file is not imported, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. Custom Dockerfile contract: the managed NemoClaw sandbox Dockerfiles handle the corporate CA automatically — onboarding bakes the validated bundle into `ARG NEMOCLAW_CORPORATE_CA_B64`, and the image decodes it into the root-owned, read-only file `/usr/local/share/nemoclaw/corporate-ca.pem`. If you build the sandbox from a **custom Dockerfile**, the fallback and host-store sources are a silent no-op unless that Dockerfile declares `ARG NEMOCLAW_CORPORATE_CA_B64` and decodes it into `/usr/local/share/nemoclaw/corporate-ca.pem` the same way. The explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` is the exception — it fails onboarding loudly (rather than no-op) when the managed ARG is absent, so you find out immediately. Either add the ARG and decode step to your Dockerfile, or keep using a managed Dockerfile, to import a corporate CA through automatic detection. -Every imported source must be a regular (non-symlink), readable PEM file that is **not group- or world-writable** (a trust anchor any other user could rewrite is rejected), non-empty and within the size cap, and in which **every** certificate block parses as an X.509 certificate. The imported trust is capped at a small corporate chain (not a full OS trust store) — a source that would exceed the cap is rejected or skipped rather than truncated. To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. +Every imported source must be a regular (non-symlink), readable PEM file that is **not group- or world-writable** (a trust anchor any other user could rewrite is rejected), non-empty and within the size cap, and in which **every** certificate block parses as an X.509 CA certificate (`basicConstraints CA:TRUE`). The imported trust is capped at a small corporate chain (not a full OS trust store) — a source that would exceed the cap is rejected or skipped rather than truncated. To disable the import entirely, set `NEMOCLAW_CORPORATE_CA_IMPORT=0`. ### A request inside the sandbox fails with `CONNECT tunnel failed, response 403` diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 9eab9ea0081..cb3e74f0377 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3134,7 +3134,11 @@ merge_corporate_proxy_ca() { _nemoclaw_ca_merge_warn "append corporate CA" return 0 } - chmod 0444 "$_tmp" 2>/dev/null || true + chmod 0444 "$_tmp" 2>/dev/null || { + rm -f "$_tmp" + _nemoclaw_ca_merge_warn "set merged bundle permissions (${_merged})" + return 0 + } # Defense-in-depth for the predictable /tmp path (#6210): if a co-tenant # pre-planted a symlink at the target, drop it first so we rename into a fresh # regular file we own rather than through an attacker-controlled link. diff --git a/src/lib/onboard/corporate-ca-env.ts b/src/lib/onboard/corporate-ca-env.ts new file mode 100644 index 00000000000..371fdfa4b6c --- /dev/null +++ b/src/lib/onboard/corporate-ca-env.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + CORPORATE_CA_EXPLICIT_ENV, + CORPORATE_CA_FALLBACK_ENV_VARS, + isCorporateCaImportDisabled, + isKnownMergedTrustStorePath, + warnCorporateCa, +} from "./corporate-ca-policy"; +import type { ResolvedCorporateCa } from "./corporate-ca-types"; +import { CorporateCaValidationError } from "./corporate-ca-types"; +import { validateCorporateCaFile } from "./corporate-ca-validation"; + +/** + * Resolve a corporate CA bundle from the host environment. + * + * Throws only for an invalid explicit source. Conventional CA env vars are + * skipped with warnings so a default host env does not break unrelated onboards. + */ +export function resolveCorporateCaFromEnv( + env: NodeJS.ProcessEnv = process.env, +): ResolvedCorporateCa | null { + if (isCorporateCaImportDisabled(env)) return null; + + const explicit = env[CORPORATE_CA_EXPLICIT_ENV]; + if (explicit && explicit.trim()) { + const sourcePath = explicit.trim(); + if (isKnownMergedTrustStorePath(sourcePath)) { + throw new CorporateCaValidationError( + `${CORPORATE_CA_EXPLICIT_ENV} points at a merged OS trust store (${sourcePath}); export only your corporate root (and intermediates) to a small PEM file instead`, + ); + } + const pem = validateCorporateCaFile(sourcePath); + return { pem, sourcePath, sourceEnv: CORPORATE_CA_EXPLICIT_ENV }; + } + + for (const name of CORPORATE_CA_FALLBACK_ENV_VARS) { + const value = env[name]; + if (!value || !value.trim()) continue; + const sourcePath = value.trim(); + if (isKnownMergedTrustStorePath(sourcePath)) { + warnCorporateCa( + `${name} points at a merged OS trust store (${sourcePath}); skipped to avoid a broad trust import - set ${CORPORATE_CA_EXPLICIT_ENV} to a small corporate-root PEM to import explicitly`, + ); + continue; + } + try { + const pem = validateCorporateCaFile(sourcePath); + return { pem, sourcePath, sourceEnv: name }; + } catch (err) { + warnCorporateCa( + `${name} is set (${sourcePath}) but was skipped for corporate CA import: ${ + (err as Error).message + }; set ${CORPORATE_CA_EXPLICIT_ENV} for fail-loud behavior`, + ); + } + } + return null; +} diff --git a/src/lib/onboard/corporate-ca-host-anchors.ts b/src/lib/onboard/corporate-ca-host-anchors.ts new file mode 100644 index 00000000000..8dbede10ff2 --- /dev/null +++ b/src/lib/onboard/corporate-ca-host-anchors.ts @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { + CORPORATE_CA_ANCHOR_DIRS_ENV, + CORPORATE_CA_EXPLICIT_ENV, + CORPORATE_CA_HOST_ANCHOR_SOURCE, + isKnownMergedTrustStorePath, + MAX_CORPORATE_CA_BYTES, + MAX_CORPORATE_CA_CERTS, + PEM_CERTIFICATE_RE_GLOBAL, + warnCorporateCa, +} from "./corporate-ca-policy"; +import type { ResolvedCorporateCa } from "./corporate-ca-types"; +import { normalizeCertificateBlocks, validateCorporateCaFile } from "./corporate-ca-validation"; + +/** + * Anchor-file extensions each host trust tool actually installs. Debian/Ubuntu + * `update-ca-certificates` installs only `*.crt` from its anchor dir; RHEL/Fedora + * `update-ca-trust` accepts `*.pem`/`*.crt`/`*.cer`. + */ +const DEBIAN_ANCHOR_EXT_RE = /\.crt$/i; +const RHEL_ANCHOR_EXT_RE = /\.(?:pem|crt|cer)$/i; + +const DEFAULT_HOST_ANCHOR_SPECS = [ + { dir: "/usr/local/share/ca-certificates", extensions: DEBIAN_ANCHOR_EXT_RE }, + { dir: "/etc/pki/ca-trust/source/anchors", extensions: RHEL_ANCHOR_EXT_RE }, +] as const; + +/** + * Host trust-store anchor directories scanned as a last resort (#6210 + * acceptance path). Automatic import reads administrator-managed anchor source + * directories only, not the merged `/etc/ssl/certs/ca-certificates.crt` output. + */ +export const CORPORATE_CA_HOST_ANCHOR_DIRS = DEFAULT_HOST_ANCHOR_SPECS.map( + (spec) => spec.dir, +) as readonly string[]; + +function anchorExtensionsFor(dir: string): RegExp { + return ( + DEFAULT_HOST_ANCHOR_SPECS.find((spec) => spec.dir === dir)?.extensions ?? RHEL_ANCHOR_EXT_RE + ); +} + +const HOST_ANCHOR_MAX_DEPTH = 8; +const HOST_ANCHOR_MAX_FILES = 256; +const HOST_ANCHOR_MAX_DIRS = 1024; + +const LITERAL_SSL_CERTS_EXT_RE = /\.(?:pem|crt|cer)$/i; +const LITERAL_SSL_CERTS_MERGED_BASENAMES = new Set(["ca-certificates.crt"]); + +/** + * Recursively collect anchor certificate files under a directory. Symlinked + * files and directories are skipped because their Dirent is neither a regular + * file nor directory, so the walk cannot follow a link out of the anchor tree. + */ +function collectAnchorFiles(root: string, extensions: RegExp): string[] { + const out: string[] = []; + let dirsVisited = 0; + const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; + while ( + stack.length > 0 && + out.length < HOST_ANCHOR_MAX_FILES && + dirsVisited < HOST_ANCHOR_MAX_DIRS + ) { + const current = stack.pop(); + if (current === undefined) break; + dirsVisited += 1; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current.dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (out.length >= HOST_ANCHOR_MAX_FILES) break; + const full = path.join(current.dir, entry.name); + if (entry.isDirectory() && current.depth < HOST_ANCHOR_MAX_DEPTH) { + stack.push({ dir: full, depth: current.depth + 1 }); + } else if (entry.isFile() && extensions.test(entry.name)) { + out.push(full); + } + } + } + return out.sort(); +} + +function collectLiteralSslCertFiles(root: string): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter( + (entry) => + entry.isFile() && + LITERAL_SSL_CERTS_EXT_RE.test(entry.name) && + !LITERAL_SSL_CERTS_MERGED_BASENAMES.has(entry.name), + ) + .map((entry) => path.join(root, entry.name)) + .filter((file) => !isKnownMergedTrustStorePath(file)) + .sort() + .slice(0, HOST_ANCHOR_MAX_FILES); +} + +export function warnIfLiteralSslCertsOnlySource(root: string): void { + const candidates = collectLiteralSslCertFiles(root); + if (candidates.length === 0) return; + + const validCandidates: string[] = []; + for (const candidate of candidates) { + try { + validateCorporateCaFile(candidate); + validCandidates.push(candidate); + } catch { + // Invalid standalone files, including normal leaf certs such as + // ssl-cert-snakeoil.pem, are not actionable for the #6210 warning. + } + } + if (validCandidates.length === 0) return; + + const example = validCandidates[0]; + warnCorporateCa( + `host ${root} contains standalone CA certificate file(s) such as ${example}, but NemoClaw does not import the merged/output trust directory automatically; set ${CORPORATE_CA_EXPLICIT_ENV} to the corporate root PEM, or set ${CORPORATE_CA_ANCHOR_DIRS_ENV} to the administrator anchor source directory`, + ); +} + +/** + * Resolve a corporate CA from host administrator-managed anchor directories. + * Returns null when no anchor directory yields a usable, bounded CA bundle. + */ +export function resolveCorporateCaFromHostAnchors( + dirs: readonly string[] = CORPORATE_CA_HOST_ANCHOR_DIRS, +): ResolvedCorporateCa | null { + for (const dir of dirs) { + const files = collectAnchorFiles(dir, anchorExtensionsFor(dir)); + const blocks: string[] = []; + for (const file of files) { + try { + blocks.push(validateCorporateCaFile(file).trim()); + } catch { + // Skip an unreadable/invalid anchor file rather than fail discovery. + } + } + if (blocks.length === 0) { + if (files.length > 0) { + warnCorporateCa( + `host trust-store anchor directory ${dir} has ${files.length} candidate file(s) but none were valid corporate CA certificates; skipping`, + ); + } + continue; + } + const pem = normalizeCertificateBlocks(blocks); + const certCount = pem.match(PEM_CERTIFICATE_RE_GLOBAL)?.length ?? 0; + if (certCount === 0 || certCount > MAX_CORPORATE_CA_CERTS) { + warnCorporateCa( + `host trust-store anchor directory ${dir} yields ${certCount} certificate(s) (max ${MAX_CORPORATE_CA_CERTS}); skipping to avoid a broad trust import`, + ); + continue; + } + if (Buffer.byteLength(pem, "utf8") > MAX_CORPORATE_CA_BYTES) { + warnCorporateCa( + `host trust-store anchor directory ${dir} exceeds ${MAX_CORPORATE_CA_BYTES} bytes; skipping`, + ); + continue; + } + return { pem, sourcePath: dir, sourceEnv: CORPORATE_CA_HOST_ANCHOR_SOURCE }; + } + return null; +} + +/** + * Resolve the host anchor directories to scan: the override when set (empty + * value means no scan), else null so the caller can use built-in defaults. + */ +export function hostAnchorDirsFromEnv(env: NodeJS.ProcessEnv): readonly string[] | null { + const raw = env[CORPORATE_CA_ANCHOR_DIRS_ENV]; + if (raw === undefined) return null; + return raw + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} diff --git a/src/lib/onboard/corporate-ca-policy.ts b/src/lib/onboard/corporate-ca-policy.ts new file mode 100644 index 00000000000..14b3fb1e8bd --- /dev/null +++ b/src/lib/onboard/corporate-ca-policy.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +/** + * Env vars inspected for a corporate CA bundle, in priority order. + * + * `NEMOCLAW_CORPORATE_CA_BUNDLE` is the explicit opt-in: when it is set but + * invalid we fail the build loudly. The remaining three are conventional CA + * env vars the reporter already exports for their corporate proxy; when one of + * those points at a missing/invalid file we skip it silently rather than break + * an onboard that never asked for a corporate CA. + */ +export const CORPORATE_CA_EXPLICIT_ENV = "NEMOCLAW_CORPORATE_CA_BUNDLE"; +export const CORPORATE_CA_FALLBACK_ENV_VARS = [ + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", +] as const; + +/** + * Well-known merged OS trust-store files. These interleave the distro's public + * roots with any locally-added corporate root, so importing one wholesale would + * widen sandbox trust far beyond the single corporate proxy CA #6210 is about. + */ +export const KNOWN_MERGED_TRUST_STORE_PATHS: readonly string[] = [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/cert.pem", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/pki/tls/certs/ca-bundle.trust.crt", + "/etc/pki/tls/cert.pem", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", +]; + +/** + * True when `candidate` resolves to a well-known merged OS trust store (see + * {@link KNOWN_MERGED_TRUST_STORE_PATHS}). Matches both the normalized path and, + * where resolvable, its symlink target. + */ +export function isKnownMergedTrustStorePath(candidate: string): boolean { + const normalized = path.resolve(candidate); + if (KNOWN_MERGED_TRUST_STORE_PATHS.includes(normalized)) return true; + try { + return KNOWN_MERGED_TRUST_STORE_PATHS.includes(fs.realpathSync(candidate)); + } catch { + return false; + } +} + +/** Literal Debian/Ubuntu merged trust-store output directory from #6210. */ +export const CORPORATE_CA_LITERAL_SSL_CERTS_DIR = "/etc/ssl/certs"; + +/** + * Override the host anchor directories scanned. A path-list (`path.delimiter` + * separated). Set to an empty value to disable host-store scanning entirely. + */ +export const CORPORATE_CA_ANCHOR_DIRS_ENV = "NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS"; + +/** Reported `sourceEnv` when a CA is discovered from host anchor source dirs. */ +export const CORPORATE_CA_HOST_ANCHOR_SOURCE = "host trust-store anchor source"; + +/** Opt-out: set to a falsey token to disable corporate CA import entirely. */ +export const CORPORATE_CA_DISABLE_ENV = "NEMOCLAW_CORPORATE_CA_IMPORT"; + +/** + * Upper bound on an accepted CA bundle. A corporate CA chain is a handful of + * certificates; this rejects an accidental full host trust-store dump. + */ +export const MAX_CORPORATE_CA_BYTES = 128 * 1024; + +/** + * Upper bound on certificates in an accepted bundle. Keeps the imported trust + * anchors scoped to a corporate CA chain rather than an entire OS trust store. + */ +export const MAX_CORPORATE_CA_CERTS = 24; + +export const PEM_CERTIFICATE_RE_GLOBAL = + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; + +/** + * Emit an operator-facing warning about a skipped corporate-CA import source. + * Messages carry only public paths, never certificate bytes. + */ +export function warnCorporateCa(message: string): void { + console.error(`[nemoclaw] WARNING: ${message} (#6210)`); +} + +export function isCorporateCaImportDisabled(env: NodeJS.ProcessEnv): boolean { + const raw = env[CORPORATE_CA_DISABLE_ENV]; + if (raw === undefined) return false; + switch (raw.trim().toLowerCase()) { + case "0": + case "false": + case "no": + case "off": + return true; + default: + return false; + } +} diff --git a/src/lib/onboard/corporate-ca-types.ts b/src/lib/onboard/corporate-ca-types.ts new file mode 100644 index 00000000000..eea3d59a931 --- /dev/null +++ b/src/lib/onboard/corporate-ca-types.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface ResolvedCorporateCa { + /** Validated PEM text of the corporate CA bundle. */ + pem: string; + /** Absolute-or-relative path the CA was read from. */ + sourcePath: string; + /** Env var or source label the path came from. */ + sourceEnv: string; +} + +export class CorporateCaValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "CorporateCaValidationError"; + } +} diff --git a/src/lib/onboard/corporate-ca-validation.ts b/src/lib/onboard/corporate-ca-validation.ts new file mode 100644 index 00000000000..06553958d7c --- /dev/null +++ b/src/lib/onboard/corporate-ca-validation.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { X509Certificate } from "node:crypto"; +import fs from "node:fs"; + +import { + MAX_CORPORATE_CA_BYTES, + MAX_CORPORATE_CA_CERTS, + PEM_CERTIFICATE_RE_GLOBAL, +} from "./corporate-ca-policy"; +import { CorporateCaValidationError } from "./corporate-ca-types"; + +/** + * Join validated PEM CERTIFICATE blocks into a normalized bundle. + * + * Returns only certificate blocks, each trimmed and separated by a single + * newline, with a trailing newline. Any bytes outside the CERTIFICATE blocks in + * the source file are dropped. + */ +export function normalizeCertificateBlocks(blocks: readonly string[]): string { + return `${blocks.map((block) => block.trim()).join("\n")}\n`; +} + +/** + * Validate a candidate corporate CA bundle file and return normalized PEM text. + * + * Opens the file once with `O_NOFOLLOW` and validates the opened descriptor so a + * symlink/file swap between check and use cannot slip a different file past + * validation. Rejects symlinks, non-regular files, empty/oversized files, + * group/world-writable sources, bundles with no or too many PEM CERTIFICATE + * blocks, any block that is not parseable X.509, and any certificate whose + * Basic Constraints do not mark it as a CA. + */ +export function validateCorporateCaFile(filePath: string): string { + let fd: number; + try { + fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ELOOP") { + throw new CorporateCaValidationError( + `corporate CA bundle must not be a symlink: ${filePath}`, + ); + } + throw new CorporateCaValidationError( + `corporate CA bundle not found or unreadable: ${filePath}`, + ); + } + + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) { + throw new CorporateCaValidationError( + `corporate CA bundle is not a regular file: ${filePath}`, + ); + } + if (stat.size === 0) { + throw new CorporateCaValidationError(`corporate CA bundle is empty: ${filePath}`); + } + if (stat.size > MAX_CORPORATE_CA_BYTES) { + throw new CorporateCaValidationError( + `corporate CA bundle exceeds ${MAX_CORPORATE_CA_BYTES} bytes: ${filePath}`, + ); + } + // Refuse a source another local user could tamper with before the build. + if ((stat.mode & 0o022) !== 0) { + throw new CorporateCaValidationError( + `corporate CA bundle must not be group- or world-writable: ${filePath}`, + ); + } + + const content = fs.readFileSync(fd, "utf8"); + const blocks = content.match(PEM_CERTIFICATE_RE_GLOBAL); + if (!blocks || blocks.length === 0) { + throw new CorporateCaValidationError( + `corporate CA bundle contains no PEM CERTIFICATE block: ${filePath}`, + ); + } + if (blocks.length > MAX_CORPORATE_CA_CERTS) { + throw new CorporateCaValidationError( + `corporate CA bundle has ${blocks.length} certificates (max ${MAX_CORPORATE_CA_CERTS}): ${filePath}`, + ); + } + + for (const block of blocks) { + let cert: X509Certificate; + try { + cert = new X509Certificate(block); + } catch { + throw new CorporateCaValidationError( + `corporate CA bundle contains a block that is not a valid X.509 certificate: ${filePath}`, + ); + } + if (!cert.ca) { + throw new CorporateCaValidationError( + `corporate CA bundle contains a certificate that is not a CA (basicConstraints CA:TRUE required): ${filePath}`, + ); + } + } + + return normalizeCertificateBlocks(blocks); + } finally { + fs.closeSync(fd); + } +} diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index 55cce841e08..a620deeead8 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -48,6 +48,28 @@ MAhrfNQcxSkZH2lVY+TA2JO83v12nKXzaW1dC94SlsFf0tVSvM3QTeWVgijpr0q+ J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= -----END CERTIFICATE----- `; +// A valid self-signed X.509 leaf with Basic Constraints CA:FALSE, matching the +// normal Ubuntu ssl-cert-snakeoil.pem shape that must not be treated as a CA. +const LEAF_PEM = `-----BEGIN CERTIFICATE----- +MIIDGDCCAgCgAwIBAgIUBGGtRhzw0XS0RsxOgfTf7Q5hi78wDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSTmVtb0NsYXcgVGVzdCBMZWFmMB4XDTI2MDcwOTIxMzA0 +OFoXDTM2MDcwNjIxMzA0OFowHTEbMBkGA1UEAwwSTmVtb0NsYXcgVGVzdCBMZWFm +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoDrg6PHhyTdrLLQ4+9EX +Icw9eTXAzMVYlr5621HD8fZO/R+asZB1xzfUCYLQ1ubeDBShXhr/sJDJDNxmOwCY +veU2IfKp2UQ3GBe6uzzUVq5icxXIr7OxR4ynnma4WRKyJR2dTX6QXHh+Oa04Wra8 +KR7U9TLvYDHvQtt5i8mVmz28n8jWdVWYKVMPc13Tc40hVennMO4c2bhfdlX1p0l+ +c1gscXJC+rVT9E1/U6zlDkPqmTy3M0aM6XDRLcYNXau7fX3ZukyQJJAR19hVaTcP +AzficNDa4/LEX3FkgioDSXyB5vhaL1lnFRAU6+yBz/jfRJmr9FdkKSpHq1NDBXKf +RwIDAQABo1AwTjAdBgNVHQ4EFgQUz5G5tVuiteFQjqBpJ9VVjktmeu0wHwYDVR0j +BBgwFoAUz5G5tVuiteFQjqBpJ9VVjktmeu0wDAYDVR0TAQH/BAIwADANBgkqhkiG +9w0BAQsFAAOCAQEAmfGOHg4dUJES4WXq/DAz1jiV5sPq+EhTAlrnuQpS13fprfYw +T8lPVM4n56WhDnqyy3/5NHywioYwi51EuIIG4Vl11xj2lVZdjPr0k0qWeMGMVmrL +4WArhisGTMC7mnYrNqijPImlwaEWmH3sO5Nhsu8qs3NH3RrX5VYDbxEbnH8YiNkf +/4gq/sCMf22vDoumDdXJQRrYAQPLSgtbxwQzT1nVvLMNjIwO6Vh7qJv4jGt+hCME +UPilgF7+CJ39Hd/NO+iZAvPuS470eWcdGK8i+akGqRIwHqlOSPeJsnLNKFFS/9tO +90WmoSeA7GUsGkJLLoiaBAq8wTdNqbYodVmEBA== +-----END CERTIFICATE----- +`; // PEM-shaped but not a parseable certificate. const BAD_PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n"; // A private-key block that must never survive into the returned/baked bundle. @@ -151,6 +173,16 @@ describe("validateCorporateCaFile", () => { expect(() => validateCorporateCaFile(p)).toThrow(/not a valid X\.509 certificate/); }); + it("rejects a valid X.509 leaf certificate without CA basic constraints", () => { + const p = writeCa(tmpDir(), LEAF_PEM); + expect(() => validateCorporateCaFile(p)).toThrow(/not a CA/); + }); + + it("rejects a bundle whose later block is a leaf certificate", () => { + const p = writeCa(tmpDir(), PEM + LEAF_PEM); + expect(() => validateCorporateCaFile(p)).toThrow(/not a CA/); + }); + it("returns only the certificate block, dropping an adjacent private key", () => { const p = writeCa(tmpDir(), `${PEM}\n${PRIVATE_KEY}`); const result = validateCorporateCaFile(p); @@ -457,6 +489,21 @@ describe("resolveCorporateCa env then host anchors (#6210)", () => { ).toBe(true); }); + it("does not warn for a literal /etc/ssl/certs-style leaf cert such as ssl-cert-snakeoil.pem", () => { + const literalSslCertsDir = tmpDir(); + writeAnchor(literalSslCertsDir, "ssl-cert-snakeoil.pem", LEAF_PEM); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCa( + {}, + { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, + ); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + expect(resolved).toBeNull(); + expect(messages).toHaveLength(0); + }); + it("does not warn for the merged /etc/ssl/certs ca-certificates output alone (#6210)", () => { const literalSslCertsDir = tmpDir(); writeAnchor(literalSslCertsDir, "ca-certificates.crt"); diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index 435b9261a89..0d34ce347af 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -1,559 +1,52 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { X509Certificate } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; - /** * Host corporate-proxy CA import (#6210). * * OpenShell injects its own L7-proxy CA into the sandbox at runtime - * (`SSL_CERT_FILE` / `/etc/openshell-tls/ca-bundle.pem`). When a *separate* + * (`SSL_CERT_FILE` / `/etc/openshell-tls/ca-bundle.pem`). When a separate * corporate MITM proxy sits in front of the host and re-signs external TLS with - * a different root, that corporate root is absent from the sandbox trust path, - * so external endpoints (e.g. `api.telegram.org`) fail verification even though - * the network policy allows the connection. - * - * This module validates an operator-supplied corporate CA bundle on the host - * and encodes it so onboard can bake it into the sandbox image. The entrypoint - * then *appends* it to the OpenShell trust bundle at runtime — never replacing - * the OpenShell CA (preserving the #1828 behavior). - */ - -/** - * Env vars inspected for a corporate CA bundle, in priority order. - * - * `NEMOCLAW_CORPORATE_CA_BUNDLE` is the explicit opt-in: when it is set but - * invalid we fail the build loudly. The remaining three are conventional CA - * env vars the reporter already exports for their corporate proxy; when one of - * those points at a missing/invalid file we skip it silently rather than break - * an onboard that never asked for a corporate CA. - */ -export const CORPORATE_CA_EXPLICIT_ENV = "NEMOCLAW_CORPORATE_CA_BUNDLE"; -export const CORPORATE_CA_FALLBACK_ENV_VARS = [ - "REQUESTS_CA_BUNDLE", - "CURL_CA_BUNDLE", - "SSL_CERT_FILE", -] as const; - -/** - * Well-known merged OS trust-store files. These interleave the distro's ~140 - * public roots with any locally-added corporate root, so importing one wholesale - * would widen sandbox trust far beyond the single corporate proxy CA #6210 is - * about (trust-bloat). A conventional CA env var (`SSL_CERT_FILE` especially) - * routinely points at one of these by default, so we reject it as a corporate-CA - * source: the explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` fails loudly (the operator - * must export just their corporate root), and a fallback env var is skipped with - * a warning. The safe automatic host path stays the administrator anchor *source* - * directories (see {@link resolveCorporateCaFromHostAnchors}), which hold only - * locally-added roots. (PR Review Advisor PRA-5.) - */ -export const KNOWN_MERGED_TRUST_STORE_PATHS: readonly string[] = [ - "/etc/ssl/certs/ca-certificates.crt", - "/etc/ssl/cert.pem", - "/etc/ssl/ca-bundle.pem", - "/etc/pki/tls/certs/ca-bundle.crt", - "/etc/pki/tls/certs/ca-bundle.trust.crt", - "/etc/pki/tls/cert.pem", - "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", -]; - -/** - * True when `candidate` resolves to a well-known merged OS trust store (see - * {@link KNOWN_MERGED_TRUST_STORE_PATHS}). Matches both the normalized path and, - * where resolvable, its symlink target, since several distros expose the merged - * bundle through a symlinked alias (e.g. `/etc/pki/tls/certs/ca-bundle.crt` → - * the extracted bundle). - */ -export function isKnownMergedTrustStorePath(candidate: string): boolean { - const normalized = path.resolve(candidate); - if (KNOWN_MERGED_TRUST_STORE_PATHS.includes(normalized)) return true; - try { - return KNOWN_MERGED_TRUST_STORE_PATHS.includes(fs.realpathSync(candidate)); - } catch { - return false; - } -} - -/** - * Anchor-file extensions each host trust tool actually installs. Debian/Ubuntu - * `update-ca-certificates` installs only `*.crt` from its anchor dir; RHEL/Fedora - * `update-ca-trust` accepts `*.pem`/`*.crt`/`*.cer`. Matching per-directory keeps - * us from importing a staged/backup PEM that is not actually in the host store. - */ -const DEBIAN_ANCHOR_EXT_RE = /\.crt$/i; -const RHEL_ANCHOR_EXT_RE = /\.(?:pem|crt|cer)$/i; - -/** - * Default host trust-store anchor directories and the extensions each installs. - * These are the *administrator-managed anchor source* dirs — not the merged - * `/etc/ssl/certs/` output (see {@link CORPORATE_CA_HOST_ANCHOR_DIRS}). - */ -const DEFAULT_HOST_ANCHOR_SPECS = [ - { dir: "/usr/local/share/ca-certificates", extensions: DEBIAN_ANCHOR_EXT_RE }, - { dir: "/etc/pki/ca-trust/source/anchors", extensions: RHEL_ANCHOR_EXT_RE }, -] as const; - -/** - * Host trust-store anchor directories scanned as a last resort (#6210 - * acceptance path). Automatic import reads administrator-managed anchor - * **source** directories only. These hold locally-added anchors: the distro's - * ~140 public roots live elsewhere and are compiled into the merged - * `/etc/ssl/certs/ca-certificates.crt` output, which is never imported as a - * corporate CA. Reading the anchor sources lets us import exactly the corporate - * root the reporter installed on the DGX Station host without baking broad, - * unrelated OS trust into the image. If those sources miss but a standalone - * certificate file exists only in the literal `/etc/ssl/certs/` output - * directory, `resolveCorporateCa` warns with explicit bundle guidance so the - * #6210 path is not silent. Discovery is bounded by - * {@link MAX_CORPORATE_CA_CERTS} / {@link MAX_CORPORATE_CA_BYTES}; a directory - * that would exceed those caps is skipped rather than truncated. - */ -export const CORPORATE_CA_HOST_ANCHOR_DIRS = DEFAULT_HOST_ANCHOR_SPECS.map( - (spec) => spec.dir, -) as readonly string[]; - -/** Literal Debian/Ubuntu merged trust-store output directory from #6210. */ -export const CORPORATE_CA_LITERAL_SSL_CERTS_DIR = "/etc/ssl/certs"; - -/** - * Override the host anchor directories scanned. A path-list (`path.delimiter` - * separated). Set to an empty value to disable host-store scanning entirely. - * Lets operators on non-standard distros point at their anchor location, and - * keeps host-store discovery deterministic under test. - */ -export const CORPORATE_CA_ANCHOR_DIRS_ENV = "NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS"; - -/** Reported `sourceEnv` when a CA is discovered from host anchor source dirs. */ -export const CORPORATE_CA_HOST_ANCHOR_SOURCE = "host trust-store anchor source"; - -/** - * Recognized extensions for a directory not in {@link DEFAULT_HOST_ANCHOR_SPECS} - * (an operator-supplied override): accept the broader RHEL-style set since the - * operator pointed at it explicitly. - */ -function anchorExtensionsFor(dir: string): RegExp { - return ( - DEFAULT_HOST_ANCHOR_SPECS.find((spec) => spec.dir === dir)?.extensions ?? RHEL_ANCHOR_EXT_RE - ); -} - -/** - * Bounds on the recursive anchor-directory walk. `update-ca-certificates` - * trusts `.crt` files *recursively* under the anchor dir, so discovery must - * descend subdirectories; these caps keep a pathological tree from turning - * discovery into an unbounded scan. - */ -const HOST_ANCHOR_MAX_DEPTH = 8; -const HOST_ANCHOR_MAX_FILES = 256; -/** - * Cap on directories visited during the walk. Bounds the scan even when an - * override points at a broad tree (e.g. `/` or `$HOME`) with few matching - * certificate files, so `HOST_ANCHOR_MAX_FILES` alone cannot stop it. - */ -const HOST_ANCHOR_MAX_DIRS = 1024; - -const LITERAL_SSL_CERTS_EXT_RE = /\.(?:pem|crt|cer)$/i; -const LITERAL_SSL_CERTS_MERGED_BASENAMES = new Set(["ca-certificates.crt"]); - -/** Opt-out: set to a falsey token to disable corporate CA import entirely. */ -export const CORPORATE_CA_DISABLE_ENV = "NEMOCLAW_CORPORATE_CA_IMPORT"; - -/** - * Upper bound on an accepted CA bundle. A corporate CA chain is a handful of - * certificates (a few KiB); this bound rejects an accidental full host - * trust-store dump (which would bake broad, unrelated trust into the image). - */ -export const MAX_CORPORATE_CA_BYTES = 128 * 1024; - -/** - * Upper bound on certificates in an accepted bundle. Keeps the imported trust - * anchors scoped to a corporate CA chain rather than an entire OS trust store. - */ -export const MAX_CORPORATE_CA_CERTS = 24; - -const PEM_CERTIFICATE_RE_GLOBAL = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; - -export interface ResolvedCorporateCa { - /** Validated PEM text of the corporate CA bundle. */ - pem: string; - /** Absolute-or-relative path the CA was read from. */ - sourcePath: string; - /** Env var the path came from. */ - sourceEnv: string; -} - -export class CorporateCaValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "CorporateCaValidationError"; - } -} - -/** - * Emit an operator-facing warning about a skipped corporate-CA import source. - * These paths are intentionally non-fatal (they must not break an onboard that - * never asked for a corporate CA), but staying fully silent hides a - * misconfiguration from an operator who *did* expect the import — so we surface - * a one-line notice. Messages carry only public paths, never certificate bytes. - */ -function warnCorporateCa(message: string): void { - console.error(`[nemoclaw] WARNING: ${message} (#6210)`); -} - -/** - * Join validated PEM CERTIFICATE blocks into a normalized bundle. - * - * Returns *only* the certificate blocks — each trimmed of surrounding - * whitespace and separated by a single newline, with a trailing newline. Any - * bytes outside the CERTIFICATE blocks in the source file (an adjacent private - * key, comments, arbitrary text) are dropped, so nothing but the validated - * public certificates is ever baked into the image build context. - */ -function normalizeCertificateBlocks(blocks: readonly string[]): string { - return `${blocks.map((block) => block.trim()).join("\n")}\n`; -} - -/** - * Validate a candidate corporate CA bundle file and return normalized PEM text. - * - * Opens the file once with `O_NOFOLLOW` and validates the *opened* descriptor - * (via `fstat`, then reads from the same fd) so a symlink/file swap between - * check and use cannot slip a different file past validation. Rejects - * symlinks, non-regular files, empty/oversized files, group/world-writable sources, - * bundles with no or too many PEM CERTIFICATE blocks, and any block that is not - * a parseable X.509 certificate. - * - * Returns a bundle containing only the validated CERTIFICATE blocks (via - * {@link normalizeCertificateBlocks}); adjacent private keys or arbitrary - * payload in the source file are never returned or baked into the image. - */ -export function validateCorporateCaFile(filePath: string): string { - let fd: number; - try { - // O_NOFOLLOW refuses to open through a final-component symlink atomically. - fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ELOOP") { - throw new CorporateCaValidationError( - `corporate CA bundle must not be a symlink: ${filePath}`, - ); - } - throw new CorporateCaValidationError( - `corporate CA bundle not found or unreadable: ${filePath}`, - ); - } - try { - const stat = fs.fstatSync(fd); - if (!stat.isFile()) { - throw new CorporateCaValidationError( - `corporate CA bundle is not a regular file: ${filePath}`, - ); - } - if (stat.size === 0) { - throw new CorporateCaValidationError(`corporate CA bundle is empty: ${filePath}`); - } - if (stat.size > MAX_CORPORATE_CA_BYTES) { - throw new CorporateCaValidationError( - `corporate CA bundle exceeds ${MAX_CORPORATE_CA_BYTES} bytes: ${filePath}`, - ); - } - // Refuse a source another local user could tamper with before the build. - // A trust anchor must not be group- or world-writable: any member of the - // owning group (or any user) could otherwise swap in a malicious root CA - // that then gets baked into the sandbox trust store. - if ((stat.mode & 0o022) !== 0) { - throw new CorporateCaValidationError( - `corporate CA bundle must not be group- or world-writable: ${filePath}`, - ); - } - const content = fs.readFileSync(fd, "utf8"); - const blocks = content.match(PEM_CERTIFICATE_RE_GLOBAL); - if (!blocks || blocks.length === 0) { - throw new CorporateCaValidationError( - `corporate CA bundle contains no PEM CERTIFICATE block: ${filePath}`, - ); - } - if (blocks.length > MAX_CORPORATE_CA_CERTS) { - throw new CorporateCaValidationError( - `corporate CA bundle has ${blocks.length} certificates (max ${MAX_CORPORATE_CA_CERTS}): ${filePath}`, - ); - } - // Structural check: every block must parse as a real X.509 certificate, - // catching truncated/corrupt PEM at build time rather than at TLS handshake. - for (const block of blocks) { - try { - new X509Certificate(block); - } catch { - throw new CorporateCaValidationError( - `corporate CA bundle contains a block that is not a valid X.509 certificate: ${filePath}`, - ); - } - } - // Return only the validated certificate blocks. Anything else in the file - // (an adjacent private key, comments, arbitrary payload) is intentionally - // dropped so it can never be copied into the build context / image layers. - return normalizeCertificateBlocks(blocks); - } finally { - fs.closeSync(fd); - } -} - -function isDisabled(env: NodeJS.ProcessEnv): boolean { - const raw = env[CORPORATE_CA_DISABLE_ENV]; - if (raw === undefined) return false; - switch (raw.trim().toLowerCase()) { - case "0": - case "false": - case "no": - case "off": - return true; - default: - return false; - } -} - -/** - * Resolve a corporate CA bundle from the host environment. + * a different root, that corporate root is absent from the sandbox trust path. * - * Returns `null` when no corporate CA env var is configured (or import is - * disabled). Throws {@link CorporateCaValidationError} only when the *explicit* - * `NEMOCLAW_CORPORATE_CA_BUNDLE` is set to an invalid path (or to a merged OS - * trust store — see {@link KNOWN_MERGED_TRUST_STORE_PATHS}); an invalid fallback - * env var, or one pointing at a merged trust store, is skipped (not fatal) but - * logs a warning so the operator can see it. - * Does not touch the host trust store — see - * {@link resolveCorporateCaFromHostAnchors} and {@link resolveCorporateCa}. - */ -export function resolveCorporateCaFromEnv( - env: NodeJS.ProcessEnv = process.env, -): ResolvedCorporateCa | null { - if (isDisabled(env)) return null; - - const explicit = env[CORPORATE_CA_EXPLICIT_ENV]; - if (explicit && explicit.trim()) { - const sourcePath = explicit.trim(); - // Even an explicit opt-in must not point at a merged OS trust store: baking - // ~140 public roots is trust-bloat, not a corporate-proxy CA. Fail loudly so - // the operator exports just their corporate root instead. - if (isKnownMergedTrustStorePath(sourcePath)) { - throw new CorporateCaValidationError( - `${CORPORATE_CA_EXPLICIT_ENV} points at a merged OS trust store (${sourcePath}); export only your corporate root (and intermediates) to a small PEM file instead`, - ); - } - // Explicit request: surface validation failures instead of silently - // building an image that cannot verify external TLS. - const pem = validateCorporateCaFile(sourcePath); - return { pem, sourcePath, sourceEnv: CORPORATE_CA_EXPLICIT_ENV }; - } - - // These conventional-CA-env-var fallbacks and the host-anchor scan are - // intentional, permanent convenience sources (the reporter's proxy already - // exports one of these) — not a transitional workaround with a removal - // milestone. NemoClaw owns onboard trust configuration, so there is no - // upstream boundary to migrate to and no removal condition; the fail-loud - // explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` remains the recommended source. - for (const name of CORPORATE_CA_FALLBACK_ENV_VARS) { - const value = env[name]; - if (!value || !value.trim()) continue; - const sourcePath = value.trim(); - // A conventional CA env var routinely defaults to the merged OS trust store - // (e.g. `SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`). Skip it rather - // than bake ~140 public roots as if they were the corporate proxy CA; the - // host-anchor scan still finds the locally-added corporate root, and the - // explicit env var remains the way to import a specific bundle. - if (isKnownMergedTrustStorePath(sourcePath)) { - warnCorporateCa( - `${name} points at a merged OS trust store (${sourcePath}); skipped to avoid a broad trust import — set ${CORPORATE_CA_EXPLICIT_ENV} to a small corporate-root PEM to import explicitly`, - ); - continue; - } - try { - const pem = validateCorporateCaFile(sourcePath); - return { pem, sourcePath, sourceEnv: name }; - } catch (err) { - // A conventional CA env var pointing at a missing/invalid file must not - // break onboard for users who never asked for a corporate CA import — but - // warn, since an operator who set it for the corporate proxy would - // otherwise get no signal that it was skipped. - warnCorporateCa( - `${name} is set (${sourcePath}) but was skipped for corporate CA import: ${ - (err as Error).message - }; set ${CORPORATE_CA_EXPLICIT_ENV} for fail-loud behavior`, - ); - } - } - return null; -} - -/** - * Recursively collect anchor certificate files under a directory, bounded by - * {@link HOST_ANCHOR_MAX_DEPTH} / {@link HOST_ANCHOR_MAX_FILES}. Symlinked files - * and directories are skipped (a symlink `Dirent` is neither `isFile()` nor - * `isDirectory()`), so the walk cannot follow a link out of the anchor tree or - * loop. Returns paths in deterministic sorted order. - */ -function collectAnchorFiles(root: string, extensions: RegExp): string[] { - const out: string[] = []; - let dirsVisited = 0; - const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; - while ( - stack.length > 0 && - out.length < HOST_ANCHOR_MAX_FILES && - dirsVisited < HOST_ANCHOR_MAX_DIRS - ) { - const current = stack.pop(); - if (current === undefined) break; - dirsVisited += 1; - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(current.dir, { withFileTypes: true }); - } catch { - continue; // Absent/unreadable directory — skip it. - } - for (const entry of entries) { - if (out.length >= HOST_ANCHOR_MAX_FILES) break; // Enforce the cap mid-directory. - const full = path.join(current.dir, entry.name); - if (entry.isDirectory() && current.depth < HOST_ANCHOR_MAX_DEPTH) { - stack.push({ dir: full, depth: current.depth + 1 }); - } else if (entry.isFile() && extensions.test(entry.name)) { - out.push(full); - } - } - } - return out.sort(); -} - -function collectLiteralSslCertFiles(root: string): string[] { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(root, { withFileTypes: true }); - } catch { - return []; - } - return entries - .filter( - (entry) => - entry.isFile() && - LITERAL_SSL_CERTS_EXT_RE.test(entry.name) && - !LITERAL_SSL_CERTS_MERGED_BASENAMES.has(entry.name), - ) - .map((entry) => path.join(root, entry.name)) - .filter((file) => !isKnownMergedTrustStorePath(file)) - .sort() - .slice(0, HOST_ANCHOR_MAX_FILES); -} - -function warnIfLiteralSslCertsOnlySource(root: string): void { - const candidates = collectLiteralSslCertFiles(root); - if (candidates.length === 0) return; - - const validCandidates: string[] = []; - for (const candidate of candidates) { - try { - validateCorporateCaFile(candidate); - validCandidates.push(candidate); - } catch { - // Invalid standalone files are not actionable enough for the #6210 warning. - } - } - if (validCandidates.length === 0) return; - - const example = validCandidates[0]; - warnCorporateCa( - `host ${root} contains standalone certificate file(s) such as ${example}, but NemoClaw does not import the merged/output trust directory automatically; set ${CORPORATE_CA_EXPLICIT_ENV} to the corporate root PEM, or set ${CORPORATE_CA_ANCHOR_DIRS_ENV} to the administrator anchor source directory`, - ); -} - -/** - * Resolve a corporate CA from the host administrator-managed anchor directories - * (#6210 acceptance path). - * - * NOTE: this deliberately does **not** scan the merged `/etc/ssl/certs/` - * (nor `/etc/ssl/certs/ca-certificates.crt`) that the issue text mentions — - * only the administrator anchor **source** directories are read. The merged view - * interleaves the corporate root with the distro's ~140 public roots, so reading - * it would bake broad, unrelated trust; the anchor sources hold exactly the - * locally-installed corporate root. See {@link CORPORATE_CA_HOST_ANCHOR_DIRS}. - * Each directory is scanned recursively (matching `update-ca-certificates`), - * bounded by the depth/file caps above. - * - * Returns `null` when no anchor directory yields a usable, bounded bundle. - * Never throws: an unreadable/invalid/oversized anchor set is skipped without - * breaking onboard (this is an implicit fallback, like the conventional CA env - * vars), with warnings only when a concrete candidate was found but not imported. - */ -export function resolveCorporateCaFromHostAnchors( - dirs: readonly string[] = CORPORATE_CA_HOST_ANCHOR_DIRS, -): ResolvedCorporateCa | null { - // #6210 acceptance note: the issue text mentions the host `/etc/ssl/certs/`. - // We intentionally do NOT scan `/etc/ssl/certs/` (nor its merged - // `ca-certificates.crt`): that view interleaves the corporate root with the - // distro's public root bundle, and importing it would bake broad, unrelated - // trust into the sandbox. Instead we read the administrator anchor *source* - // directories that populate `/etc/ssl/certs/`, so a corporate root installed - // via `update-ca-certificates`/`update-ca-trust` is detected without the - // trust-bloat. See docs/reference/troubleshooting.mdx for the operator contract. - for (const dir of dirs) { - const files = collectAnchorFiles(dir, anchorExtensionsFor(dir)); - const blocks: string[] = []; - for (const file of files) { - try { - // validateCorporateCaFile enforces per-file symlink/size/mode/cert - // checks and returns normalized certificate blocks only. - blocks.push(validateCorporateCaFile(file).trim()); - } catch { - // Skip an unreadable/invalid anchor file rather than fail discovery. - } - } - if (blocks.length === 0) { - // Warn only when the directory actually held candidate anchor files (an - // admin dropped certs that failed validation); an empty anchor dir is the - // normal case on most hosts and must stay silent. - if (files.length > 0) { - warnCorporateCa( - `host trust-store anchor directory ${dir} has ${files.length} candidate file(s) but none were valid corporate CA certificates; skipping`, - ); - } - continue; - } - const pem = normalizeCertificateBlocks(blocks); - // Aggregate caps: keep the imported trust scoped to a corporate chain. A - // directory that would exceed the caps is skipped, never truncated. - const certCount = pem.match(PEM_CERTIFICATE_RE_GLOBAL)?.length ?? 0; - if (certCount === 0 || certCount > MAX_CORPORATE_CA_CERTS) { - warnCorporateCa( - `host trust-store anchor directory ${dir} yields ${certCount} certificate(s) (max ${MAX_CORPORATE_CA_CERTS}); skipping to avoid a broad trust import`, - ); - continue; - } - if (Buffer.byteLength(pem, "utf8") > MAX_CORPORATE_CA_BYTES) { - warnCorporateCa( - `host trust-store anchor directory ${dir} exceeds ${MAX_CORPORATE_CA_BYTES} bytes; skipping`, - ); - continue; - } - return { pem, sourcePath: dir, sourceEnv: CORPORATE_CA_HOST_ANCHOR_SOURCE }; - } - return null; -} - -/** - * Resolve the host anchor directories to scan: the {@link - * CORPORATE_CA_ANCHOR_DIRS_ENV} override when set (empty value → no scan), else - * the built-in {@link CORPORATE_CA_HOST_ANCHOR_DIRS}. Returns `null` when the - * override is unset so the caller can fall back to the defaults. - */ -function hostAnchorDirsFromEnv(env: NodeJS.ProcessEnv): readonly string[] | null { - const raw = env[CORPORATE_CA_ANCHOR_DIRS_ENV]; - if (raw === undefined) return null; - return raw - .split(path.delimiter) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} + * This public module composes the focused corporate-CA source modules and keeps + * the onboard call sites stable. + */ + +import { resolveCorporateCaFromEnv } from "./corporate-ca-env"; +import { + CORPORATE_CA_HOST_ANCHOR_DIRS, + hostAnchorDirsFromEnv, + resolveCorporateCaFromHostAnchors, + warnIfLiteralSslCertsOnlySource, +} from "./corporate-ca-host-anchors"; +import { + CORPORATE_CA_ANCHOR_DIRS_ENV, + CORPORATE_CA_LITERAL_SSL_CERTS_DIR, + isCorporateCaImportDisabled, +} from "./corporate-ca-policy"; +import type { ResolvedCorporateCa } from "./corporate-ca-types"; + +export { resolveCorporateCaFromEnv } from "./corporate-ca-env"; +export { + CORPORATE_CA_HOST_ANCHOR_DIRS, + resolveCorporateCaFromHostAnchors, +} from "./corporate-ca-host-anchors"; +export { + CORPORATE_CA_ANCHOR_DIRS_ENV, + CORPORATE_CA_DISABLE_ENV, + CORPORATE_CA_EXPLICIT_ENV, + CORPORATE_CA_FALLBACK_ENV_VARS, + CORPORATE_CA_HOST_ANCHOR_SOURCE, + CORPORATE_CA_LITERAL_SSL_CERTS_DIR, + isKnownMergedTrustStorePath, + KNOWN_MERGED_TRUST_STORE_PATHS, + MAX_CORPORATE_CA_BYTES, + MAX_CORPORATE_CA_CERTS, +} from "./corporate-ca-policy"; +export { CorporateCaValidationError } from "./corporate-ca-types"; +export type { ResolvedCorporateCa } from "./corporate-ca-types"; +export { validateCorporateCaFile } from "./corporate-ca-validation"; export interface ResolveCorporateCaOptions { /** Override the host anchor directories scanned (testing seam). */ @@ -566,24 +59,24 @@ export interface ResolveCorporateCaOptions { * Resolve a corporate CA bundle for the sandbox image (#6210). * * Resolution order: - * 1. Explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` (fail-loud when invalid or when it - * points at a merged OS trust store — see {@link KNOWN_MERGED_TRUST_STORE_PATHS}). + * 1. Explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` (fail-loud when invalid). * 2. Conventional CA env vars (`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, - * `SSL_CERT_FILE`), skipped with a warning when invalid or when they point - * at a merged OS trust store. - * 3. Host administrator-managed anchor directories (overridable/disablable - * via {@link CORPORATE_CA_ANCHOR_DIRS_ENV}), skipped silently. + * `SSL_CERT_FILE`), skipped with a warning when invalid. + * 3. Host administrator-managed anchor directories, overridable via + * `NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS`. * - * Returns `null` when nothing is configured or import is disabled via - * `NEMOCLAW_CORPORATE_CA_IMPORT`. + * If automatic sources miss but a CA-looking file exists only in the literal + * `/etc/ssl/certs` output directory, warn with explicit bundle guidance rather + * than silently missing that host layout. */ export function resolveCorporateCa( env: NodeJS.ProcessEnv = process.env, options: ResolveCorporateCaOptions = {}, ): ResolvedCorporateCa | null { - if (isDisabled(env)) return null; + if (isCorporateCaImportDisabled(env)) return null; const fromEnv = resolveCorporateCaFromEnv(env); if (fromEnv) return fromEnv; + const envAnchorDirs = hostAnchorDirsFromEnv(env); const anchorDirs = options.hostAnchorDirs ?? envAnchorDirs ?? CORPORATE_CA_HOST_ANCHOR_DIRS; const fromHostAnchors = resolveCorporateCaFromHostAnchors(anchorDirs); diff --git a/test/corporate-ca-runtime-merge.test.ts b/test/corporate-ca-runtime-merge.test.ts index dd80ad7a32e..f8b60d0445d 100644 --- a/test/corporate-ca-runtime-merge.test.ts +++ b/test/corporate-ca-runtime-merge.test.ts @@ -188,6 +188,53 @@ describe("corporate proxy CA runtime merge (#6210)", () => { expect(existsSync(merged)).toBe(false); }); + it("bails without exporting when OpenClaw cannot make the merged bundle readable (#6210)", () => { + const dir = tmpDir("nemoclaw-corp-merge-chmod-openclaw-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + const merged = join(dir, "merged-ca.pem"); + writeFileSync(openshell, OPENSHELL_PEM); + writeFileSync(corp, CORPORATE_PEM); + + const out = runShellLines(dir, [ + "chmod() { return 1; }", + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + mergeBlock(OPENCLAW_START, "# Git TLS CA bundle fix (NemoClaw#2270).", corp, merged), + 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', + 'printf "MERGED=%s\\n" "${_NEMOCLAW_CORPORATE_CA_MERGED:-}"', + ]); + + expect(out).toContain(`SSL_CERT_FILE=${openshell}`); + expect(out).toContain("MERGED=\n"); + expect(existsSync(merged)).toBe(false); + }); + + it("bails without exporting when Hermes cannot make the merged bundle readable (#6210)", () => { + const dir = tmpDir("nemoclaw-corp-merge-chmod-hermes-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + const merged = join(dir, "merged-ca.pem"); + writeFileSync(openshell, OPENSHELL_PEM); + writeFileSync(corp, CORPORATE_PEM); + + const out = runShellLines(dir, [ + "chmod() { return 1; }", + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + mergeBlock( + HERMES_START, + "# OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA.", + corp, + merged, + ), + 'printf "SSL_CERT_FILE=%s\\n" "${SSL_CERT_FILE:-}"', + 'printf "MERGED=%s\\n" "${_NEMOCLAW_CORPORATE_CA_MERGED:-}"', + ]); + + expect(out).toContain(`SSL_CERT_FILE=${openshell}`); + expect(out).toContain("MERGED=\n"); + expect(existsSync(merged)).toBe(false); + }); + it("warns on stderr when the OpenClaw merge fails (#6210)", () => { const dir = tmpDir("nemoclaw-corp-merge-warn-openclaw-"); const openshell = join(dir, "openshell-ca.pem"); From 4d5a8994208916167bfd39174b73663407bd9711 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 9 Jul 2026 14:44:05 -0700 Subject: [PATCH 24/26] fix(onboard): import standalone ssl cert CAs --- docs/reference/troubleshooting.mdx | 2 +- src/lib/onboard/corporate-ca-host-anchors.ts | 32 ++++++++++++++------ src/lib/onboard/corporate-ca-policy.ts | 3 ++ src/lib/onboard/corporate-ca.test.ts | 21 ++++++------- src/lib/onboard/corporate-ca.ts | 12 +++++--- 5 files changed, 42 insertions(+), 28 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 3138150f2bc..a5899058a39 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1236,7 +1236,7 @@ Resolution order: When a fallback or host-store source is baked, onboarding logs which source and path it used (`baking corporate proxy CA from …`). If you set a conventional CA variable but the import does not happen, check the onboard build logs: a variable that points at a missing or invalid file is skipped with a `WARNING: … was skipped for corporate CA import` line, and an anchor directory that holds only invalid certificates logs a similar warning. Absence of the `baking …` line means no source validated. Use `NEMOCLAW_CORPORATE_CA_BUNDLE` for explicit, fail-loud behavior. To import a corporate root that is not installed in an anchor directory, export just your corporate root (and any intermediates) into a small PEM file and point `NEMOCLAW_CORPORATE_CA_BUNDLE` at it. -`/etc/ssl/certs/` contract: NemoClaw satisfies the #6210 host trust-store acceptance path by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected. If no anchor source validates but NemoClaw sees a standalone CA certificate file only in the literal `/etc/ssl/certs/` output directory, it logs a `WARNING` that points you to `NEMOCLAW_CORPORATE_CA_BUNDLE` or `NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS` instead of silently missing that host layout. Normal leaf certificates in that directory, such as Ubuntu's `ssl-cert-snakeoil.pem`, are ignored. A root present *only* as a hand-edited entry in the merged file is not imported, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. +`/etc/ssl/certs/` contract: NemoClaw satisfies the #6210 host trust-store acceptance path by reading the administrator anchor **source** directories above, not the merged `/etc/ssl/certs/ca-certificates.crt` view. This is intentional — the merged file interleaves your corporate root with the distro's public root bundle, and importing it wholesale would widen sandbox trust far beyond the one corporate proxy CA. A corporate root installed via `update-ca-certificates` / `update-ca-trust` already lives in an anchor source directory, so it is detected. If no anchor source validates but NemoClaw sees direct regular CA certificate files in the literal `/etc/ssl/certs/` output directory, it imports only those validated standalone CA files while still excluding the merged `ca-certificates.crt` bundle and symlink fan-out. Normal leaf certificates in that directory, such as Ubuntu's `ssl-cert-snakeoil.pem`, are ignored. A root present *only* as a hand-edited entry in the merged file is not imported, and must be pointed at explicitly via `NEMOCLAW_CORPORATE_CA_BUNDLE`. Custom Dockerfile contract: the managed NemoClaw sandbox Dockerfiles handle the corporate CA automatically — onboarding bakes the validated bundle into `ARG NEMOCLAW_CORPORATE_CA_B64`, and the image decodes it into the root-owned, read-only file `/usr/local/share/nemoclaw/corporate-ca.pem`. If you build the sandbox from a **custom Dockerfile**, the fallback and host-store sources are a silent no-op unless that Dockerfile declares `ARG NEMOCLAW_CORPORATE_CA_B64` and decodes it into `/usr/local/share/nemoclaw/corporate-ca.pem` the same way. The explicit `NEMOCLAW_CORPORATE_CA_BUNDLE` is the exception — it fails onboarding loudly (rather than no-op) when the managed ARG is absent, so you find out immediately. Either add the ARG and decode step to your Dockerfile, or keep using a managed Dockerfile, to import a corporate CA through automatic detection. diff --git a/src/lib/onboard/corporate-ca-host-anchors.ts b/src/lib/onboard/corporate-ca-host-anchors.ts index 8dbede10ff2..ad7782c9ac3 100644 --- a/src/lib/onboard/corporate-ca-host-anchors.ts +++ b/src/lib/onboard/corporate-ca-host-anchors.ts @@ -8,6 +8,7 @@ import { CORPORATE_CA_ANCHOR_DIRS_ENV, CORPORATE_CA_EXPLICIT_ENV, CORPORATE_CA_HOST_ANCHOR_SOURCE, + CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE, isKnownMergedTrustStorePath, MAX_CORPORATE_CA_BYTES, MAX_CORPORATE_CA_CERTS, @@ -108,26 +109,37 @@ function collectLiteralSslCertFiles(root: string): string[] { .slice(0, HOST_ANCHOR_MAX_FILES); } -export function warnIfLiteralSslCertsOnlySource(root: string): void { +export function resolveCorporateCaFromLiteralSslCerts(root: string): ResolvedCorporateCa | null { const candidates = collectLiteralSslCertFiles(root); - if (candidates.length === 0) return; + if (candidates.length === 0) return null; - const validCandidates: string[] = []; + const blocks: string[] = []; for (const candidate of candidates) { try { - validateCorporateCaFile(candidate); - validCandidates.push(candidate); + blocks.push(validateCorporateCaFile(candidate).trim()); } catch { // Invalid standalone files, including normal leaf certs such as // ssl-cert-snakeoil.pem, are not actionable for the #6210 warning. } } - if (validCandidates.length === 0) return; + if (blocks.length === 0) return null; - const example = validCandidates[0]; - warnCorporateCa( - `host ${root} contains standalone CA certificate file(s) such as ${example}, but NemoClaw does not import the merged/output trust directory automatically; set ${CORPORATE_CA_EXPLICIT_ENV} to the corporate root PEM, or set ${CORPORATE_CA_ANCHOR_DIRS_ENV} to the administrator anchor source directory`, - ); + const pem = normalizeCertificateBlocks(blocks); + const certCount = pem.match(PEM_CERTIFICATE_RE_GLOBAL)?.length ?? 0; + if (certCount === 0 || certCount > MAX_CORPORATE_CA_CERTS) { + warnCorporateCa( + `host /etc/ssl/certs standalone CA candidates yield ${certCount} certificate(s) (max ${MAX_CORPORATE_CA_CERTS}); skipping to avoid a broad trust import`, + ); + return null; + } + if (Buffer.byteLength(pem, "utf8") > MAX_CORPORATE_CA_BYTES) { + warnCorporateCa( + `host /etc/ssl/certs standalone CA candidates exceed ${MAX_CORPORATE_CA_BYTES} bytes; skipping`, + ); + return null; + } + + return { pem, sourcePath: root, sourceEnv: CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE }; } /** diff --git a/src/lib/onboard/corporate-ca-policy.ts b/src/lib/onboard/corporate-ca-policy.ts index 14b3fb1e8bd..0744ee65a5b 100644 --- a/src/lib/onboard/corporate-ca-policy.ts +++ b/src/lib/onboard/corporate-ca-policy.ts @@ -62,6 +62,9 @@ export const CORPORATE_CA_ANCHOR_DIRS_ENV = "NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS"; /** Reported `sourceEnv` when a CA is discovered from host anchor source dirs. */ export const CORPORATE_CA_HOST_ANCHOR_SOURCE = "host trust-store anchor source"; +/** Reported `sourceEnv` for direct standalone CA files under `/etc/ssl/certs`. */ +export const CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE = "host /etc/ssl/certs standalone CA"; + /** Opt-out: set to a falsey token to disable corporate CA import entirely. */ export const CORPORATE_CA_DISABLE_ENV = "NEMOCLAW_CORPORATE_CA_IMPORT"; diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts index a620deeead8..299b7b0b077 100644 --- a/src/lib/onboard/corporate-ca.test.ts +++ b/src/lib/onboard/corporate-ca.test.ts @@ -14,6 +14,7 @@ import { CORPORATE_CA_HOST_ANCHOR_DIRS, CORPORATE_CA_HOST_ANCHOR_SOURCE, CORPORATE_CA_LITERAL_SSL_CERTS_DIR, + CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE, CorporateCaValidationError, encodeCorporateCaArg, isKnownMergedTrustStorePath, @@ -466,9 +467,11 @@ describe("resolveCorporateCa env then host anchors (#6210)", () => { expect(resolveCorporateCa({ [CORPORATE_CA_ANCHOR_DIRS_ENV]: "" })).toBeNull(); }); - it("warns when only a literal /etc/ssl/certs-style directory has a standalone cert (#6210)", () => { + it("imports a standalone CA from a literal /etc/ssl/certs-style directory (#6210)", () => { const literalSslCertsDir = tmpDir(); - const standaloneCert = writeAnchor(literalSslCertsDir, "corp-proxy-root.pem"); + writeAnchor(literalSslCertsDir, "ca-certificates.crt"); + writeAnchor(literalSslCertsDir, "ssl-cert-snakeoil.pem", LEAF_PEM); + writeAnchor(literalSslCertsDir, "corp-proxy-root.pem"); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const resolved = resolveCorporateCa( {}, @@ -477,16 +480,10 @@ describe("resolveCorporateCa env then host anchors (#6210)", () => { const messages = errorSpy.mock.calls.map((call) => String(call[0])); errorSpy.mockRestore(); - expect(resolved).toBeNull(); - expect( - messages.some( - (m) => - m.includes(literalSslCertsDir) && - m.includes(standaloneCert) && - m.includes(CORPORATE_CA_EXPLICIT_ENV) && - m.includes(CORPORATE_CA_ANCHOR_DIRS_ENV), - ), - ).toBe(true); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE); + expect(resolved?.sourcePath).toBe(literalSslCertsDir); + expect(resolved?.pem.match(/-----BEGIN CERTIFICATE-----/g)).toHaveLength(1); + expect(messages).toHaveLength(0); }); it("does not warn for a literal /etc/ssl/certs-style leaf cert such as ssl-cert-snakeoil.pem", () => { diff --git a/src/lib/onboard/corporate-ca.ts b/src/lib/onboard/corporate-ca.ts index 0d34ce347af..dcc8b467ff0 100644 --- a/src/lib/onboard/corporate-ca.ts +++ b/src/lib/onboard/corporate-ca.ts @@ -17,8 +17,8 @@ import { resolveCorporateCaFromEnv } from "./corporate-ca-env"; import { CORPORATE_CA_HOST_ANCHOR_DIRS, hostAnchorDirsFromEnv, + resolveCorporateCaFromLiteralSslCerts, resolveCorporateCaFromHostAnchors, - warnIfLiteralSslCertsOnlySource, } from "./corporate-ca-host-anchors"; import { CORPORATE_CA_ANCHOR_DIRS_ENV, @@ -30,6 +30,7 @@ import type { ResolvedCorporateCa } from "./corporate-ca-types"; export { resolveCorporateCaFromEnv } from "./corporate-ca-env"; export { CORPORATE_CA_HOST_ANCHOR_DIRS, + resolveCorporateCaFromLiteralSslCerts, resolveCorporateCaFromHostAnchors, } from "./corporate-ca-host-anchors"; export { @@ -38,6 +39,7 @@ export { CORPORATE_CA_EXPLICIT_ENV, CORPORATE_CA_FALLBACK_ENV_VARS, CORPORATE_CA_HOST_ANCHOR_SOURCE, + CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE, CORPORATE_CA_LITERAL_SSL_CERTS_DIR, isKnownMergedTrustStorePath, KNOWN_MERGED_TRUST_STORE_PATHS, @@ -65,9 +67,9 @@ export interface ResolveCorporateCaOptions { * 3. Host administrator-managed anchor directories, overridable via * `NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS`. * - * If automatic sources miss but a CA-looking file exists only in the literal - * `/etc/ssl/certs` output directory, warn with explicit bundle guidance rather - * than silently missing that host layout. + * If automatic anchor sources miss but direct regular CA files exist in the + * literal `/etc/ssl/certs` output directory, import only those validated + * standalone CA files while still excluding the merged OS trust bundle. */ export function resolveCorporateCa( env: NodeJS.ProcessEnv = process.env, @@ -89,7 +91,7 @@ export function resolveCorporateCa( ? CORPORATE_CA_LITERAL_SSL_CERTS_DIR : options.literalSslCertsDir; if (!hostScanningDisabledByEnv && literalSslCertsDir !== null) { - warnIfLiteralSslCertsOnlySource(literalSslCertsDir); + return resolveCorporateCaFromLiteralSslCerts(literalSslCertsDir); } return null; } From 9ac7e4dcfd807bbdf0f4130c6751d26fc773f150 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 9 Jul 2026 15:00:07 -0700 Subject: [PATCH 25/26] fix(onboard): bound corporate CA anchor scans --- .../__test-helpers__/corporate-ca-fixtures.ts | 101 ++++ src/lib/onboard/corporate-ca-env.test.ts | 99 ++++ .../onboard/corporate-ca-host-anchors.test.ts | 281 +++++++++ src/lib/onboard/corporate-ca-host-anchors.ts | 70 ++- src/lib/onboard/corporate-ca-policy.test.ts | 36 ++ .../onboard/corporate-ca-validation.test.ts | 122 ++++ src/lib/onboard/corporate-ca.test.ts | 541 ------------------ 7 files changed, 686 insertions(+), 564 deletions(-) create mode 100644 src/lib/onboard/__test-helpers__/corporate-ca-fixtures.ts create mode 100644 src/lib/onboard/corporate-ca-env.test.ts create mode 100644 src/lib/onboard/corporate-ca-host-anchors.test.ts create mode 100644 src/lib/onboard/corporate-ca-policy.test.ts create mode 100644 src/lib/onboard/corporate-ca-validation.test.ts delete mode 100644 src/lib/onboard/corporate-ca.test.ts diff --git a/src/lib/onboard/__test-helpers__/corporate-ca-fixtures.ts b/src/lib/onboard/__test-helpers__/corporate-ca-fixtures.ts new file mode 100644 index 00000000000..0320b2de7d6 --- /dev/null +++ b/src/lib/onboard/__test-helpers__/corporate-ca-fixtures.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, expect } from "vitest"; + +// A real (self-signed) X.509 certificate so the structural validation accepts +// it; the shape-only fixture (BAD_PEM) is used for negative structural cases. +export const PEM = `-----BEGIN CERTIFICATE----- +MIIDKzCCAhOgAwIBAgIUL3YNpyohvjOEzlwisLKfyiU3dRwwDQYJKoZIhvcNAQEL +BQAwJTEjMCEGA1UEAwwaTmVtb0NsYXcgVGVzdCBDb3Jwb3JhdGUgQ0EwHhcNMjYw +NzA2MDQwMjM2WhcNMzYwNzAzMDQwMjM2WjAlMSMwIQYDVQQDDBpOZW1vQ2xhdyBU +ZXN0IENvcnBvcmF0ZSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALVbV5tyMc65jEH39ejvQvBk7dvI8rz8rSZl+5BWSK2a4TzKm3jD3U+qCDZPicrA +ETCDcO09bN6YIAgpB6rYg5BIURJWxFuljBIBMCZEdO6AVlbURPaGsw6RKLA3cmhx +ZekT0qMcoOKm3N+Hb5MHXsWZ8EUf0co2LsWwJgDZrdwY26gF6w+9wr3iGLE92ZbO +LHhjHUYR1oWXmkXS3YW8MN2h5I+oyL71jBiwLHUi59wogxA/LTAD97/GqwJ6DC4C +UERbIpGYhZfrbiKmT+ASJuKRXaUp/0My3IzH90RqqY70d1E/pkAsd5M8SQ332qAZ +OgW4GgO3n7gAlaN/ILwunZ8CAwEAAaNTMFEwHQYDVR0OBBYEFMa5M8bvDm85eFQi +1D5fNATE/rawMB8GA1UdIwQYMBaAFMa5M8bvDm85eFQi1D5fNATE/rawMA8GA1Ud +EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8NR/0HBUH1WbbDOmGNDzge +o+4Pz0KWR5fPDSx9CrmvUk8ijKpJQcSjQcmrXuhCoRs6aExXLh+wImKkOyMIVXfd +YFWjCffSJzeBQfDlMVW+wiAjUh7xaIqpA6Z8EmpdfyoNWd30AuHjs9m8dAa8M/lP +0qhzCbjDiHNHfYSrAuBHlMJ5RsUrNVtSZGpg1dtaSBa+8XFWWNBeJrUANxb8i7Ax +MAhrfNQcxSkZH2lVY+TA2JO83v12nKXzaW1dC94SlsFf0tVSvM3QTeWVgijpr0q+ +J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= +-----END CERTIFICATE----- +`; + +// A valid self-signed X.509 leaf with Basic Constraints CA:FALSE, matching the +// normal Ubuntu ssl-cert-snakeoil.pem shape that must not be treated as a CA. +export const LEAF_PEM = `-----BEGIN CERTIFICATE----- +MIIDGDCCAgCgAwIBAgIUBGGtRhzw0XS0RsxOgfTf7Q5hi78wDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSTmVtb0NsYXcgVGVzdCBMZWFmMB4XDTI2MDcwOTIxMzA0 +OFoXDTM2MDcwNjIxMzA0OFowHTEbMBkGA1UEAwwSTmVtb0NsYXcgVGVzdCBMZWFm +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoDrg6PHhyTdrLLQ4+9EX +Icw9eTXAzMVYlr5621HD8fZO/R+asZB1xzfUCYLQ1ubeDBShXhr/sJDJDNxmOwCY +veU2IfKp2UQ3GBe6uzzUVq5icxXIr7OxR4ynnma4WRKyJR2dTX6QXHh+Oa04Wra8 +KR7U9TLvYDHvQtt5i8mVmz28n8jWdVWYKVMPc13Tc40hVennMO4c2bhfdlX1p0l+ +c1gscXJC+rVT9E1/U6zlDkPqmTy3M0aM6XDRLcYNXau7fX3ZukyQJJAR19hVaTcP +AzficNDa4/LEX3FkgioDSXyB5vhaL1lnFRAU6+yBz/jfRJmr9FdkKSpHq1NDBXKf +RwIDAQABo1AwTjAdBgNVHQ4EFgQUz5G5tVuiteFQjqBpJ9VVjktmeu0wHwYDVR0j +BBgwFoAUz5G5tVuiteFQjqBpJ9VVjktmeu0wDAYDVR0TAQH/BAIwADANBgkqhkiG +9w0BAQsFAAOCAQEAmfGOHg4dUJES4WXq/DAz1jiV5sPq+EhTAlrnuQpS13fprfYw +T8lPVM4n56WhDnqyy3/5NHywioYwi51EuIIG4Vl11xj2lVZdjPr0k0qWeMGMVmrL +4WArhisGTMC7mnYrNqijPImlwaEWmH3sO5Nhsu8qs3NH3RrX5VYDbxEbnH8YiNkf +/4gq/sCMf22vDoumDdXJQRrYAQPLSgtbxwQzT1nVvLMNjIwO6Vh7qJv4jGt+hCME +UPilgF7+CJ39Hd/NO+iZAvPuS470eWcdGK8i+akGqRIwHqlOSPeJsnLNKFFS/9tO +90WmoSeA7GUsGkJLLoiaBAq8wTdNqbYodVmEBA== +-----END CERTIFICATE----- +`; + +// PEM-shaped but not a parseable certificate. +export const BAD_PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n"; + +// A private-key block that must never survive into the returned/baked bundle. +// Markers are assembled at runtime so the fixture is not itself flagged as a +// committed private key by the secret scanners. +const KEY_LABEL = `${"PRIVATE"} KEY`; +export const PRIVATE_KEY = `-----BEGIN ${KEY_LABEL}----- +MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEA3+SuP4mGqjr9Vd0F +super-secret-key-material-that-must-not-be-baked-into-the-image +-----END ${KEY_LABEL}----- +`; + +const tmpRoots: string[] = []; + +export function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-test-")); + tmpRoots.push(dir); + return dir; +} + +export function writeCa(dir: string, contents = PEM, mode = 0o644): string { + const p = path.join(dir, "corp-ca.pem"); + fs.writeFileSync(p, contents, { mode }); + fs.chmodSync(p, mode); + return p; +} + +export function writeAnchor(dir: string, name: string, contents = PEM, mode = 0o644): string { + const p = path.join(dir, name); + fs.writeFileSync(p, contents, { mode }); + fs.chmodSync(p, mode); + return p; +} + +export function expectWarning(messages: readonly string[], ...needles: readonly string[]): void { + expect(messages.some((message) => needles.every((needle) => message.includes(needle)))).toBe( + true, + ); +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/src/lib/onboard/corporate-ca-env.test.ts b/src/lib/onboard/corporate-ca-env.test.ts new file mode 100644 index 00000000000..b84675d5b57 --- /dev/null +++ b/src/lib/onboard/corporate-ca-env.test.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { expectWarning, tmpDir, writeCa } from "./__test-helpers__/corporate-ca-fixtures"; +import { + CORPORATE_CA_DISABLE_ENV, + CORPORATE_CA_EXPLICIT_ENV, + CorporateCaValidationError, + resolveCorporateCaFromEnv, +} from "./corporate-ca"; + +describe("resolveCorporateCaFromEnv", () => { + it("returns null when no CA env is set", () => { + expect(resolveCorporateCaFromEnv({})).toBeNull(); + }); + + it("does not read the host trust store from env resolution alone (#6210)", () => { + expect(resolveCorporateCaFromEnv({})).toBeNull(); + }); + + it("resolves the explicit env var first", () => { + const p = writeCa(tmpDir()); + const resolved = resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: p }); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_EXPLICIT_ENV); + expect(resolved?.pem).toContain("BEGIN CERTIFICATE"); + }); + + it("throws when the explicit env var points at an invalid file", () => { + expect(() => + resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: "/does/not/exist.pem" }), + ).toThrow(CorporateCaValidationError); + }); + + it("falls back to REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE", () => { + const p = writeCa(tmpDir()); + expect(resolveCorporateCaFromEnv({ REQUESTS_CA_BUNDLE: p })?.sourceEnv).toBe( + "REQUESTS_CA_BUNDLE", + ); + expect(resolveCorporateCaFromEnv({ CURL_CA_BUNDLE: p })?.sourceEnv).toBe("CURL_CA_BUNDLE"); + }); + + it("warns and continues past an invalid fallback env var to the next", () => { + const p = writeCa(tmpDir()); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCaFromEnv({ + REQUESTS_CA_BUNDLE: "/does/not/exist.pem", + CURL_CA_BUNDLE: p, + }); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + expect(resolved?.sourceEnv).toBe("CURL_CA_BUNDLE"); + expectWarning(messages, "REQUESTS_CA_BUNDLE", "WARNING"); + }); + + it("returns null and warns when every fallback env var is invalid", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCaFromEnv({ + REQUESTS_CA_BUNDLE: "/missing.pem", + SSL_CERT_FILE: "/nope.pem", + }); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + expect(resolved).toBeNull(); + expect(messages.filter((m) => m.includes("WARNING"))).toHaveLength(2); + }); + + it("honors the disable opt-out", () => { + const p = writeCa(tmpDir()); + expect( + resolveCorporateCaFromEnv({ + [CORPORATE_CA_EXPLICIT_ENV]: p, + [CORPORATE_CA_DISABLE_ENV]: "0", + }), + ).toBeNull(); + }); + + it("throws when the explicit env var points at a merged OS trust store (#6210)", () => { + expect(() => + resolveCorporateCaFromEnv({ + [CORPORATE_CA_EXPLICIT_ENV]: "/etc/ssl/certs/ca-certificates.crt", + }), + ).toThrow(CorporateCaValidationError); + }); + + it("skips a fallback env var pointing at a merged OS trust store and warns (#6210)", () => { + const p = writeCa(tmpDir()); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCaFromEnv({ + REQUESTS_CA_BUNDLE: "/etc/ssl/certs/ca-certificates.crt", + CURL_CA_BUNDLE: p, + }); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + expect(resolved?.sourceEnv).toBe("CURL_CA_BUNDLE"); + expectWarning(messages, "REQUESTS_CA_BUNDLE", "merged OS trust store"); + }); +}); diff --git a/src/lib/onboard/corporate-ca-host-anchors.test.ts b/src/lib/onboard/corporate-ca-host-anchors.test.ts new file mode 100644 index 00000000000..93d7efbb96f --- /dev/null +++ b/src/lib/onboard/corporate-ca-host-anchors.test.ts @@ -0,0 +1,281 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + BAD_PEM, + expectWarning, + LEAF_PEM, + PEM, + tmpDir, + writeAnchor, + writeCa, +} from "./__test-helpers__/corporate-ca-fixtures"; +import { + CORPORATE_CA_ANCHOR_DIRS_ENV, + CORPORATE_CA_DISABLE_ENV, + CORPORATE_CA_EXPLICIT_ENV, + CORPORATE_CA_HOST_ANCHOR_DIRS, + CORPORATE_CA_HOST_ANCHOR_SOURCE, + CORPORATE_CA_LITERAL_SSL_CERTS_DIR, + CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE, + isKnownMergedTrustStorePath, + MAX_CORPORATE_CA_CERTS, + resolveCorporateCa, + resolveCorporateCaFromHostAnchors, +} from "./corporate-ca"; + +function writeManyAnchors(dir: string, count: number): void { + for (let i = 0; i < count; i += 1) { + writeAnchor(dir, `corp-${String(i).padStart(3, "0")}.crt`, PEM); + } +} + +describe("resolveCorporateCaFromHostAnchors host trust-store path (#6210)", () => { + it("imports from anchor-source dirs, not merged /etc/ssl/certs output (#6210)", () => { + expect(CORPORATE_CA_HOST_ANCHOR_DIRS).toContain("/usr/local/share/ca-certificates"); + expect(CORPORATE_CA_HOST_ANCHOR_DIRS).not.toContain("/etc/ssl/certs"); + expect(CORPORATE_CA_HOST_ANCHOR_DIRS).not.toContain("/etc/ssl/certs/ca-certificates.crt"); + expect(CORPORATE_CA_LITERAL_SSL_CERTS_DIR).toBe("/etc/ssl/certs"); + expect(isKnownMergedTrustStorePath("/etc/ssl/certs/ca-certificates.crt")).toBe(true); + }); + + it("discovers a corporate root installed in a host anchor directory", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp-proxy-root.crt"); + const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_HOST_ANCHOR_SOURCE); + expect(resolved?.sourcePath).toBe(anchorDir); + expect(resolved?.pem).toContain("BEGIN CERTIFICATE"); + }); + + it("returns the first anchor directory that yields a bundle", () => { + const missing = path.join(tmpDir(), "absent"); + const present = tmpDir(); + writeAnchor(present, "corp.crt"); + expect(resolveCorporateCaFromHostAnchors([missing, present])?.sourcePath).toBe(present); + }); + + it("returns null when no anchor directory exists", () => { + expect( + resolveCorporateCaFromHostAnchors([path.join(tmpDir(), "nope"), path.join(tmpDir(), "gone")]), + ).toBeNull(); + }); + + it("ignores non-anchor files and empty directories", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "README.txt", "not a cert\n"); + expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); + }); + + it("skips a directory whose aggregate exceeds the certificate cap", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "many.crt", PEM.repeat(MAX_CORPORATE_CA_CERTS + 1)); + expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); + }); + + it("aggregates multiple anchor files into one bundle", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "root-a.crt"); + writeAnchor(anchorDir, "root-b.crt"); + const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); + expect(resolved?.pem.match(/-----BEGIN CERTIFICATE-----/g)).toHaveLength(2); + }); + + it("accepts .pem/.cer anchors in an operator-supplied directory", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp-root.pem"); + expect(resolveCorporateCaFromHostAnchors([anchorDir])?.pem).toContain("BEGIN CERTIFICATE"); + }); + + it("warns when an anchor directory has candidate files but no valid CA", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "broken.crt", BAD_PEM); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + expect(resolved).toBeNull(); + expectWarning(messages, anchorDir, "WARNING"); + }); + + it("warns and skips when anchor candidate files exceed the scan cap", () => { + const anchorDir = tmpDir(); + writeManyAnchors(anchorDir, 257); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + expect(resolved).toBeNull(); + expectWarning(messages, anchorDir, "exceeds scan caps", "truncated trust import"); + }); + + it("warns and skips when anchor directory traversal exceeds the scan cap", () => { + const anchorDir = tmpDir(); + for (let i = 0; i < 1025; i += 1) { + fs.mkdirSync(path.join(anchorDir, `d-${String(i).padStart(4, "0")}`)); + } + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + expect(resolved).toBeNull(); + expectWarning(messages, anchorDir, "exceeds scan caps", "truncated trust import"); + }); + + it("discovers a corporate root nested in an anchor subdirectory", () => { + const anchorDir = tmpDir(); + const sub = path.join(anchorDir, "acme"); + fs.mkdirSync(sub); + writeAnchor(sub, "root.crt"); + expect(resolveCorporateCaFromHostAnchors([anchorDir])?.pem).toContain("BEGIN CERTIFICATE"); + }); + + it("skips symlinked anchor entries", () => { + const realCert = writeCa(tmpDir()); + const anchorDir = tmpDir(); + fs.symlinkSync(realCert, path.join(anchorDir, "linked.crt")); + expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); + }); + + it("skips an unreadable anchor directory without throwing", () => { + const anchorDir = tmpDir(); + fs.chmodSync(anchorDir, 0o000); + expect(() => resolveCorporateCaFromHostAnchors([anchorDir])).not.toThrow(); + expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); + fs.chmodSync(anchorDir, 0o700); + }); +}); + +describe("resolveCorporateCa env then host anchors (#6210)", () => { + it("prefers an env-configured CA over the host anchor directory", () => { + const envCa = writeCa(tmpDir()); + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp.crt"); + const resolved = resolveCorporateCa( + { [CORPORATE_CA_EXPLICIT_ENV]: envCa }, + { hostAnchorDirs: [anchorDir] }, + ); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_EXPLICIT_ENV); + expect(resolved?.sourcePath).toBe(envCa); + }); + + it("falls back to the host anchor directory when no env var is set", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp.crt"); + const resolved = resolveCorporateCa({}, { hostAnchorDirs: [anchorDir] }); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_HOST_ANCHOR_SOURCE); + }); + + it("honors the disable opt-out even when a host anchor exists", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp.crt"); + expect( + resolveCorporateCa({ [CORPORATE_CA_DISABLE_ENV]: "0" }, { hostAnchorDirs: [anchorDir] }), + ).toBeNull(); + }); + + it("returns null when neither env nor host anchors provide a CA", () => { + expect( + resolveCorporateCa( + {}, + { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir: null }, + ), + ).toBeNull(); + }); + + it("reads host anchor directories from the anchor-dirs env override", () => { + const anchorDir = tmpDir(); + writeAnchor(anchorDir, "corp.crt"); + const resolved = resolveCorporateCa({ [CORPORATE_CA_ANCHOR_DIRS_ENV]: anchorDir }); + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_HOST_ANCHOR_SOURCE); + expect(resolved?.sourcePath).toBe(anchorDir); + }); + + it("disables host-store scanning when the anchor-dirs override is empty", () => { + expect(resolveCorporateCa({ [CORPORATE_CA_ANCHOR_DIRS_ENV]: "" })).toBeNull(); + }); + + it("imports a standalone CA from a literal /etc/ssl/certs-style directory (#6210)", () => { + const literalSslCertsDir = tmpDir(); + writeAnchor(literalSslCertsDir, "ca-certificates.crt"); + writeAnchor(literalSslCertsDir, "ssl-cert-snakeoil.pem", LEAF_PEM); + writeAnchor(literalSslCertsDir, "corp-proxy-root.pem"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCa( + {}, + { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, + ); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + expect(resolved?.sourceEnv).toBe(CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE); + expect(resolved?.sourcePath).toBe(literalSslCertsDir); + expect(resolved?.pem.match(/-----BEGIN CERTIFICATE-----/g)).toHaveLength(1); + expect(messages).toHaveLength(0); + }); + + it("does not warn for a literal /etc/ssl/certs-style leaf cert such as ssl-cert-snakeoil.pem", () => { + const literalSslCertsDir = tmpDir(); + writeAnchor(literalSslCertsDir, "ssl-cert-snakeoil.pem", LEAF_PEM); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCa( + {}, + { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, + ); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + expect(resolved).toBeNull(); + expect(messages).toHaveLength(0); + }); + + it("does not warn for the merged /etc/ssl/certs ca-certificates output alone (#6210)", () => { + const literalSslCertsDir = tmpDir(); + writeAnchor(literalSslCertsDir, "ca-certificates.crt"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCa( + {}, + { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, + ); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + expect(resolved).toBeNull(); + expect(messages).toHaveLength(0); + }); + + it("warns and skips overlarge literal /etc/ssl/certs-style candidate sets", () => { + const literalSslCertsDir = tmpDir(); + writeManyAnchors(literalSslCertsDir, 257); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCa( + {}, + { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, + ); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + expect(resolved).toBeNull(); + expectWarning(messages, "/etc/ssl/certs", "more than 256", "truncated trust import"); + }); + + it("does not warn about literal /etc/ssl/certs when host-store scanning is disabled", () => { + const literalSslCertsDir = tmpDir(); + writeAnchor(literalSslCertsDir, "corp-proxy-root.pem"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const resolved = resolveCorporateCa( + { [CORPORATE_CA_ANCHOR_DIRS_ENV]: "" }, + { literalSslCertsDir }, + ); + const messages = errorSpy.mock.calls.map((call) => String(call[0])); + errorSpy.mockRestore(); + + expect(resolved).toBeNull(); + expect(messages).toHaveLength(0); + }); +}); diff --git a/src/lib/onboard/corporate-ca-host-anchors.ts b/src/lib/onboard/corporate-ca-host-anchors.ts index ad7782c9ac3..5de66465fa2 100644 --- a/src/lib/onboard/corporate-ca-host-anchors.ts +++ b/src/lib/onboard/corporate-ca-host-anchors.ts @@ -53,20 +53,24 @@ const HOST_ANCHOR_MAX_DIRS = 1024; const LITERAL_SSL_CERTS_EXT_RE = /\.(?:pem|crt|cer)$/i; const LITERAL_SSL_CERTS_MERGED_BASENAMES = new Set(["ca-certificates.crt"]); +interface CollectedCandidateFiles { + files: string[]; + overLimit: boolean; +} + /** * Recursively collect anchor certificate files under a directory. Symlinked * files and directories are skipped because their Dirent is neither a regular * file nor directory, so the walk cannot follow a link out of the anchor tree. */ -function collectAnchorFiles(root: string, extensions: RegExp): string[] { +function collectAnchorFiles(root: string, extensions: RegExp): CollectedCandidateFiles { const out: string[] = []; let dirsVisited = 0; const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; - while ( - stack.length > 0 && - out.length < HOST_ANCHOR_MAX_FILES && - dirsVisited < HOST_ANCHOR_MAX_DIRS - ) { + while (stack.length > 0) { + if (dirsVisited >= HOST_ANCHOR_MAX_DIRS) { + return { files: out.sort(), overLimit: true }; + } const current = stack.pop(); if (current === undefined) break; dirsVisited += 1; @@ -77,40 +81,54 @@ function collectAnchorFiles(root: string, extensions: RegExp): string[] { continue; } for (const entry of entries) { - if (out.length >= HOST_ANCHOR_MAX_FILES) break; const full = path.join(current.dir, entry.name); if (entry.isDirectory() && current.depth < HOST_ANCHOR_MAX_DEPTH) { stack.push({ dir: full, depth: current.depth + 1 }); } else if (entry.isFile() && extensions.test(entry.name)) { + if (out.length >= HOST_ANCHOR_MAX_FILES) { + return { files: out.sort(), overLimit: true }; + } out.push(full); } } } - return out.sort(); + return { files: out.sort(), overLimit: false }; } -function collectLiteralSslCertFiles(root: string): string[] { +function collectLiteralSslCertFiles(root: string): CollectedCandidateFiles { let entries: fs.Dirent[]; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { - return []; + return { files: [], overLimit: false }; + } + const files: string[] = []; + for (const entry of entries) { + if ( + !entry.isFile() || + !LITERAL_SSL_CERTS_EXT_RE.test(entry.name) || + LITERAL_SSL_CERTS_MERGED_BASENAMES.has(entry.name) + ) { + continue; + } + const file = path.join(root, entry.name); + if (isKnownMergedTrustStorePath(file)) continue; + if (files.length >= HOST_ANCHOR_MAX_FILES) { + return { files: files.sort(), overLimit: true }; + } + files.push(file); } - return entries - .filter( - (entry) => - entry.isFile() && - LITERAL_SSL_CERTS_EXT_RE.test(entry.name) && - !LITERAL_SSL_CERTS_MERGED_BASENAMES.has(entry.name), - ) - .map((entry) => path.join(root, entry.name)) - .filter((file) => !isKnownMergedTrustStorePath(file)) - .sort() - .slice(0, HOST_ANCHOR_MAX_FILES); + return { files: files.sort(), overLimit: false }; } export function resolveCorporateCaFromLiteralSslCerts(root: string): ResolvedCorporateCa | null { - const candidates = collectLiteralSslCertFiles(root); + const { files: candidates, overLimit } = collectLiteralSslCertFiles(root); + if (overLimit) { + warnCorporateCa( + `host /etc/ssl/certs has more than ${HOST_ANCHOR_MAX_FILES} standalone CA candidate file(s); skipping to avoid a truncated trust import`, + ); + return null; + } if (candidates.length === 0) return null; const blocks: string[] = []; @@ -150,7 +168,13 @@ export function resolveCorporateCaFromHostAnchors( dirs: readonly string[] = CORPORATE_CA_HOST_ANCHOR_DIRS, ): ResolvedCorporateCa | null { for (const dir of dirs) { - const files = collectAnchorFiles(dir, anchorExtensionsFor(dir)); + const { files, overLimit } = collectAnchorFiles(dir, anchorExtensionsFor(dir)); + if (overLimit) { + warnCorporateCa( + `host trust-store anchor directory ${dir} exceeds scan caps (${HOST_ANCHOR_MAX_FILES} files or ${HOST_ANCHOR_MAX_DIRS} directories); skipping to avoid a truncated trust import`, + ); + continue; + } const blocks: string[] = []; for (const file of files) { try { diff --git a/src/lib/onboard/corporate-ca-policy.test.ts b/src/lib/onboard/corporate-ca-policy.test.ts new file mode 100644 index 00000000000..4f723fcb54c --- /dev/null +++ b/src/lib/onboard/corporate-ca-policy.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { PEM, tmpDir, writeCa } from "./__test-helpers__/corporate-ca-fixtures"; +import { + encodeCorporateCaArg, + isKnownMergedTrustStorePath, + KNOWN_MERGED_TRUST_STORE_PATHS, +} from "./corporate-ca"; + +describe("isKnownMergedTrustStorePath (#6210)", () => { + it("matches every well-known merged OS trust-store path", () => { + for (const p of KNOWN_MERGED_TRUST_STORE_PATHS) { + expect(isKnownMergedTrustStorePath(p)).toBe(true); + } + }); + + it("normalizes a non-canonical path before matching", () => { + expect(isKnownMergedTrustStorePath("/etc/ssl/certs/../certs/ca-certificates.crt")).toBe(true); + }); + + it("does not match a dedicated corporate CA file", () => { + const p = writeCa(tmpDir()); + expect(isKnownMergedTrustStorePath(p)).toBe(false); + }); +}); + +describe("encodeCorporateCaArg", () => { + it("produces single-line base64 that round-trips", () => { + const encoded = encodeCorporateCaArg(PEM); + expect(encoded).not.toMatch(/[\r\n]/); + expect(Buffer.from(encoded, "base64").toString("utf8")).toBe(PEM); + }); +}); diff --git a/src/lib/onboard/corporate-ca-validation.test.ts b/src/lib/onboard/corporate-ca-validation.test.ts new file mode 100644 index 00000000000..bbeaaffc95f --- /dev/null +++ b/src/lib/onboard/corporate-ca-validation.test.ts @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + BAD_PEM, + LEAF_PEM, + PEM, + PRIVATE_KEY, + tmpDir, + writeCa, +} from "./__test-helpers__/corporate-ca-fixtures"; +import { + CorporateCaValidationError, + MAX_CORPORATE_CA_BYTES, + MAX_CORPORATE_CA_CERTS, + validateCorporateCaFile, +} from "./corporate-ca"; + +describe("validateCorporateCaFile", () => { + it("returns PEM text for a valid regular file", () => { + const p = writeCa(tmpDir()); + expect(validateCorporateCaFile(p)).toContain("BEGIN CERTIFICATE"); + }); + + it("rejects a missing file", () => { + expect(() => validateCorporateCaFile(path.join(tmpDir(), "nope.pem"))).toThrow( + CorporateCaValidationError, + ); + }); + + it("rejects a symlink", () => { + const dir = tmpDir(); + const real = writeCa(dir); + const link = path.join(dir, "link.pem"); + fs.symlinkSync(real, link); + expect(() => validateCorporateCaFile(link)).toThrow(/must not be a symlink/); + }); + + it("rejects a directory", () => { + expect(() => validateCorporateCaFile(tmpDir())).toThrow(/not a regular file/); + }); + + it("rejects an empty file", () => { + const p = writeCa(tmpDir(), ""); + expect(() => validateCorporateCaFile(p)).toThrow(/is empty/); + }); + + it("rejects an oversized file", () => { + const p = writeCa(tmpDir(), `${PEM}${"A".repeat(MAX_CORPORATE_CA_BYTES)}`); + expect(() => validateCorporateCaFile(p)).toThrow(/exceeds/); + }); + + it("rejects a world-writable file", () => { + const p = writeCa(tmpDir(), PEM, 0o666); + expect(() => validateCorporateCaFile(p)).toThrow(/group- or world-writable/); + }); + + it("rejects a group-writable file", () => { + const p = writeCa(tmpDir(), PEM, 0o664); + expect(() => validateCorporateCaFile(p)).toThrow(/group- or world-writable/); + }); + + it("rejects a file without a PEM certificate block", () => { + const p = writeCa(tmpDir(), "not a certificate\n"); + expect(() => validateCorporateCaFile(p)).toThrow(/no PEM CERTIFICATE block/); + }); + + it("rejects a bundle with more than the certificate cap", () => { + const p = writeCa(tmpDir(), PEM.repeat(MAX_CORPORATE_CA_CERTS + 1)); + expect(() => validateCorporateCaFile(p)).toThrow(/certificates \(max/); + }); + + it("rejects a PEM-shaped block that is not a parseable X.509 certificate", () => { + const p = writeCa(tmpDir(), BAD_PEM); + expect(() => validateCorporateCaFile(p)).toThrow(/not a valid X\.509 certificate/); + }); + + it("rejects a bundle whose later block is not a parseable X.509 certificate", () => { + const p = writeCa(tmpDir(), PEM + BAD_PEM); + expect(() => validateCorporateCaFile(p)).toThrow(/not a valid X\.509 certificate/); + }); + + it("rejects a valid X.509 leaf certificate without CA basic constraints", () => { + const p = writeCa(tmpDir(), LEAF_PEM); + expect(() => validateCorporateCaFile(p)).toThrow(/not a CA/); + }); + + it("rejects a bundle whose later block is a leaf certificate", () => { + const p = writeCa(tmpDir(), PEM + LEAF_PEM); + expect(() => validateCorporateCaFile(p)).toThrow(/not a CA/); + }); + + it("returns only the certificate block, dropping an adjacent private key", () => { + const p = writeCa(tmpDir(), `${PEM}\n${PRIVATE_KEY}`); + const result = validateCorporateCaFile(p); + expect(result).toContain("BEGIN CERTIFICATE"); + expect(result).not.toContain("PRIVATE KEY"); + expect(result).not.toContain("super-secret-key-material"); + }); + + it("drops arbitrary non-certificate text surrounding the certificate", () => { + const p = writeCa(tmpDir(), `# corp bundle exported 2026\n${PEM}\ntrailing secret note\n`); + const result = validateCorporateCaFile(p); + expect(result).toContain("BEGIN CERTIFICATE"); + expect(result).not.toContain("corp bundle exported"); + expect(result).not.toContain("trailing secret note"); + }); + + it("returns a normalized bundle of exactly the validated certificate blocks", () => { + const p = writeCa(tmpDir(), `\n\n${PEM}\n${PEM}\n\n`); + const result = validateCorporateCaFile(p); + const blocks = result.match(/-----BEGIN CERTIFICATE-----/g) ?? []; + expect(blocks).toHaveLength(2); + expect(result.endsWith("-----END CERTIFICATE-----\n")).toBe(true); + expect(result.startsWith("-----BEGIN CERTIFICATE-----")).toBe(true); + }); +}); diff --git a/src/lib/onboard/corporate-ca.test.ts b/src/lib/onboard/corporate-ca.test.ts deleted file mode 100644 index 299b7b0b077..00000000000 --- a/src/lib/onboard/corporate-ca.test.ts +++ /dev/null @@ -1,541 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { - CORPORATE_CA_ANCHOR_DIRS_ENV, - CORPORATE_CA_DISABLE_ENV, - CORPORATE_CA_EXPLICIT_ENV, - CORPORATE_CA_HOST_ANCHOR_DIRS, - CORPORATE_CA_HOST_ANCHOR_SOURCE, - CORPORATE_CA_LITERAL_SSL_CERTS_DIR, - CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE, - CorporateCaValidationError, - encodeCorporateCaArg, - isKnownMergedTrustStorePath, - KNOWN_MERGED_TRUST_STORE_PATHS, - MAX_CORPORATE_CA_BYTES, - MAX_CORPORATE_CA_CERTS, - resolveCorporateCa, - resolveCorporateCaFromEnv, - resolveCorporateCaFromHostAnchors, - validateCorporateCaFile, -} from "./corporate-ca"; - -// A real (self-signed) X.509 certificate so the structural validation accepts -// it; the shape-only fixture (BAD_PEM) is used for negative structural cases. -const PEM = `-----BEGIN CERTIFICATE----- -MIIDKzCCAhOgAwIBAgIUL3YNpyohvjOEzlwisLKfyiU3dRwwDQYJKoZIhvcNAQEL -BQAwJTEjMCEGA1UEAwwaTmVtb0NsYXcgVGVzdCBDb3Jwb3JhdGUgQ0EwHhcNMjYw -NzA2MDQwMjM2WhcNMzYwNzAzMDQwMjM2WjAlMSMwIQYDVQQDDBpOZW1vQ2xhdyBU -ZXN0IENvcnBvcmF0ZSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB -ALVbV5tyMc65jEH39ejvQvBk7dvI8rz8rSZl+5BWSK2a4TzKm3jD3U+qCDZPicrA -ETCDcO09bN6YIAgpB6rYg5BIURJWxFuljBIBMCZEdO6AVlbURPaGsw6RKLA3cmhx -ZekT0qMcoOKm3N+Hb5MHXsWZ8EUf0co2LsWwJgDZrdwY26gF6w+9wr3iGLE92ZbO -LHhjHUYR1oWXmkXS3YW8MN2h5I+oyL71jBiwLHUi59wogxA/LTAD97/GqwJ6DC4C -UERbIpGYhZfrbiKmT+ASJuKRXaUp/0My3IzH90RqqY70d1E/pkAsd5M8SQ332qAZ -OgW4GgO3n7gAlaN/ILwunZ8CAwEAAaNTMFEwHQYDVR0OBBYEFMa5M8bvDm85eFQi -1D5fNATE/rawMB8GA1UdIwQYMBaAFMa5M8bvDm85eFQi1D5fNATE/rawMA8GA1Ud -EwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8NR/0HBUH1WbbDOmGNDzge -o+4Pz0KWR5fPDSx9CrmvUk8ijKpJQcSjQcmrXuhCoRs6aExXLh+wImKkOyMIVXfd -YFWjCffSJzeBQfDlMVW+wiAjUh7xaIqpA6Z8EmpdfyoNWd30AuHjs9m8dAa8M/lP -0qhzCbjDiHNHfYSrAuBHlMJ5RsUrNVtSZGpg1dtaSBa+8XFWWNBeJrUANxb8i7Ax -MAhrfNQcxSkZH2lVY+TA2JO83v12nKXzaW1dC94SlsFf0tVSvM3QTeWVgijpr0q+ -J0N7VBg2CdK6jRjKLQOSOPq3ySCicHhVRI8hxIWotif7mK3jj6D8NRalwmlHgNM= ------END CERTIFICATE----- -`; -// A valid self-signed X.509 leaf with Basic Constraints CA:FALSE, matching the -// normal Ubuntu ssl-cert-snakeoil.pem shape that must not be treated as a CA. -const LEAF_PEM = `-----BEGIN CERTIFICATE----- -MIIDGDCCAgCgAwIBAgIUBGGtRhzw0XS0RsxOgfTf7Q5hi78wDQYJKoZIhvcNAQEL -BQAwHTEbMBkGA1UEAwwSTmVtb0NsYXcgVGVzdCBMZWFmMB4XDTI2MDcwOTIxMzA0 -OFoXDTM2MDcwNjIxMzA0OFowHTEbMBkGA1UEAwwSTmVtb0NsYXcgVGVzdCBMZWFm -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoDrg6PHhyTdrLLQ4+9EX -Icw9eTXAzMVYlr5621HD8fZO/R+asZB1xzfUCYLQ1ubeDBShXhr/sJDJDNxmOwCY -veU2IfKp2UQ3GBe6uzzUVq5icxXIr7OxR4ynnma4WRKyJR2dTX6QXHh+Oa04Wra8 -KR7U9TLvYDHvQtt5i8mVmz28n8jWdVWYKVMPc13Tc40hVennMO4c2bhfdlX1p0l+ -c1gscXJC+rVT9E1/U6zlDkPqmTy3M0aM6XDRLcYNXau7fX3ZukyQJJAR19hVaTcP -AzficNDa4/LEX3FkgioDSXyB5vhaL1lnFRAU6+yBz/jfRJmr9FdkKSpHq1NDBXKf -RwIDAQABo1AwTjAdBgNVHQ4EFgQUz5G5tVuiteFQjqBpJ9VVjktmeu0wHwYDVR0j -BBgwFoAUz5G5tVuiteFQjqBpJ9VVjktmeu0wDAYDVR0TAQH/BAIwADANBgkqhkiG -9w0BAQsFAAOCAQEAmfGOHg4dUJES4WXq/DAz1jiV5sPq+EhTAlrnuQpS13fprfYw -T8lPVM4n56WhDnqyy3/5NHywioYwi51EuIIG4Vl11xj2lVZdjPr0k0qWeMGMVmrL -4WArhisGTMC7mnYrNqijPImlwaEWmH3sO5Nhsu8qs3NH3RrX5VYDbxEbnH8YiNkf -/4gq/sCMf22vDoumDdXJQRrYAQPLSgtbxwQzT1nVvLMNjIwO6Vh7qJv4jGt+hCME -UPilgF7+CJ39Hd/NO+iZAvPuS470eWcdGK8i+akGqRIwHqlOSPeJsnLNKFFS/9tO -90WmoSeA7GUsGkJLLoiaBAq8wTdNqbYodVmEBA== ------END CERTIFICATE----- -`; -// PEM-shaped but not a parseable certificate. -const BAD_PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n"; -// A private-key block that must never survive into the returned/baked bundle. -// Markers are assembled at runtime so the fixture is not itself flagged as a -// committed private key by the secret scanners. -const KEY_LABEL = `${"PRIVATE"} KEY`; -const PRIVATE_KEY = `-----BEGIN ${KEY_LABEL}----- -MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEA3+SuP4mGqjr9Vd0F -super-secret-key-material-that-must-not-be-baked-into-the-image ------END ${KEY_LABEL}----- -`; -const tmpRoots: string[] = []; - -function tmpDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corp-ca-test-")); - tmpRoots.push(dir); - return dir; -} - -function writeCa(dir: string, contents = PEM, mode = 0o644): string { - const p = path.join(dir, "corp-ca.pem"); - fs.writeFileSync(p, contents, { mode }); - fs.chmodSync(p, mode); - return p; -} - -function writeAnchor(dir: string, name: string, contents = PEM, mode = 0o644): string { - const p = path.join(dir, name); - fs.writeFileSync(p, contents, { mode }); - fs.chmodSync(p, mode); - return p; -} - -afterEach(() => { - for (const dir of tmpRoots.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("validateCorporateCaFile", () => { - it("returns PEM text for a valid regular file", () => { - const p = writeCa(tmpDir()); - expect(validateCorporateCaFile(p)).toContain("BEGIN CERTIFICATE"); - }); - - it("rejects a missing file", () => { - expect(() => validateCorporateCaFile(path.join(tmpDir(), "nope.pem"))).toThrow( - CorporateCaValidationError, - ); - }); - - it("rejects a symlink", () => { - const dir = tmpDir(); - const real = writeCa(dir); - const link = path.join(dir, "link.pem"); - fs.symlinkSync(real, link); - expect(() => validateCorporateCaFile(link)).toThrow(/must not be a symlink/); - }); - - it("rejects a directory", () => { - expect(() => validateCorporateCaFile(tmpDir())).toThrow(/not a regular file/); - }); - - it("rejects an empty file", () => { - const p = writeCa(tmpDir(), ""); - expect(() => validateCorporateCaFile(p)).toThrow(/is empty/); - }); - - it("rejects an oversized file", () => { - const p = writeCa(tmpDir(), `${PEM}${"A".repeat(MAX_CORPORATE_CA_BYTES)}`); - expect(() => validateCorporateCaFile(p)).toThrow(/exceeds/); - }); - - it("rejects a world-writable file", () => { - const p = writeCa(tmpDir(), PEM, 0o666); - expect(() => validateCorporateCaFile(p)).toThrow(/group- or world-writable/); - }); - - it("rejects a group-writable file", () => { - const p = writeCa(tmpDir(), PEM, 0o664); - expect(() => validateCorporateCaFile(p)).toThrow(/group- or world-writable/); - }); - - it("rejects a file without a PEM certificate block", () => { - const p = writeCa(tmpDir(), "not a certificate\n"); - expect(() => validateCorporateCaFile(p)).toThrow(/no PEM CERTIFICATE block/); - }); - - it("rejects a bundle with more than the certificate cap", () => { - const p = writeCa(tmpDir(), PEM.repeat(MAX_CORPORATE_CA_CERTS + 1)); - expect(() => validateCorporateCaFile(p)).toThrow(/certificates \(max/); - }); - - it("rejects a PEM-shaped block that is not a parseable X.509 certificate", () => { - const p = writeCa(tmpDir(), BAD_PEM); - expect(() => validateCorporateCaFile(p)).toThrow(/not a valid X\.509 certificate/); - }); - - it("rejects a bundle whose later block is not a parseable X.509 certificate", () => { - const p = writeCa(tmpDir(), PEM + BAD_PEM); - expect(() => validateCorporateCaFile(p)).toThrow(/not a valid X\.509 certificate/); - }); - - it("rejects a valid X.509 leaf certificate without CA basic constraints", () => { - const p = writeCa(tmpDir(), LEAF_PEM); - expect(() => validateCorporateCaFile(p)).toThrow(/not a CA/); - }); - - it("rejects a bundle whose later block is a leaf certificate", () => { - const p = writeCa(tmpDir(), PEM + LEAF_PEM); - expect(() => validateCorporateCaFile(p)).toThrow(/not a CA/); - }); - - it("returns only the certificate block, dropping an adjacent private key", () => { - const p = writeCa(tmpDir(), `${PEM}\n${PRIVATE_KEY}`); - const result = validateCorporateCaFile(p); - expect(result).toContain("BEGIN CERTIFICATE"); - expect(result).not.toContain("PRIVATE KEY"); - expect(result).not.toContain("super-secret-key-material"); - }); - - it("drops arbitrary non-certificate text surrounding the certificate", () => { - const p = writeCa(tmpDir(), `# corp bundle exported 2026\n${PEM}\ntrailing secret note\n`); - const result = validateCorporateCaFile(p); - expect(result).toContain("BEGIN CERTIFICATE"); - expect(result).not.toContain("corp bundle exported"); - expect(result).not.toContain("trailing secret note"); - }); - - it("returns a normalized bundle of exactly the validated certificate blocks", () => { - const p = writeCa(tmpDir(), `\n\n${PEM}\n${PEM}\n\n`); - const result = validateCorporateCaFile(p); - const blocks = result.match(/-----BEGIN CERTIFICATE-----/g) ?? []; - expect(blocks).toHaveLength(2); - expect(result.endsWith("-----END CERTIFICATE-----\n")).toBe(true); - expect(result.startsWith("-----BEGIN CERTIFICATE-----")).toBe(true); - }); -}); - -describe("resolveCorporateCaFromEnv", () => { - it("returns null when no CA env is set", () => { - expect(resolveCorporateCaFromEnv({})).toBeNull(); - }); - - it("does not read the host trust store from env resolution alone (#6210)", () => { - // resolveCorporateCaFromEnv is env-only; host anchor discovery lives in - // resolveCorporateCaFromHostAnchors / resolveCorporateCa. - expect(resolveCorporateCaFromEnv({})).toBeNull(); - }); - - it("resolves the explicit env var first", () => { - const p = writeCa(tmpDir()); - const resolved = resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: p }); - expect(resolved?.sourceEnv).toBe(CORPORATE_CA_EXPLICIT_ENV); - expect(resolved?.pem).toContain("BEGIN CERTIFICATE"); - }); - - it("throws when the explicit env var points at an invalid file", () => { - expect(() => - resolveCorporateCaFromEnv({ [CORPORATE_CA_EXPLICIT_ENV]: "/does/not/exist.pem" }), - ).toThrow(CorporateCaValidationError); - }); - - it("falls back to REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE", () => { - const p = writeCa(tmpDir()); - expect(resolveCorporateCaFromEnv({ REQUESTS_CA_BUNDLE: p })?.sourceEnv).toBe( - "REQUESTS_CA_BUNDLE", - ); - expect(resolveCorporateCaFromEnv({ CURL_CA_BUNDLE: p })?.sourceEnv).toBe("CURL_CA_BUNDLE"); - }); - - it("warns and continues past an invalid fallback env var to the next", () => { - const p = writeCa(tmpDir()); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const resolved = resolveCorporateCaFromEnv({ - REQUESTS_CA_BUNDLE: "/does/not/exist.pem", - CURL_CA_BUNDLE: p, - }); - const messages = errorSpy.mock.calls.map((call) => String(call[0])); - errorSpy.mockRestore(); - expect(resolved?.sourceEnv).toBe("CURL_CA_BUNDLE"); - expect(messages.some((m) => m.includes("REQUESTS_CA_BUNDLE") && m.includes("WARNING"))).toBe( - true, - ); - }); - - it("returns null and warns when every fallback env var is invalid", () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const resolved = resolveCorporateCaFromEnv({ - REQUESTS_CA_BUNDLE: "/missing.pem", - SSL_CERT_FILE: "/nope.pem", - }); - const messages = errorSpy.mock.calls.map((call) => String(call[0])); - errorSpy.mockRestore(); - expect(resolved).toBeNull(); - expect(messages.filter((m) => m.includes("WARNING"))).toHaveLength(2); - }); - - it("honors the disable opt-out", () => { - const p = writeCa(tmpDir()); - expect( - resolveCorporateCaFromEnv({ - [CORPORATE_CA_EXPLICIT_ENV]: p, - [CORPORATE_CA_DISABLE_ENV]: "0", - }), - ).toBeNull(); - }); - - it("throws when the explicit env var points at a merged OS trust store (#6210)", () => { - expect(() => - resolveCorporateCaFromEnv({ - [CORPORATE_CA_EXPLICIT_ENV]: "/etc/ssl/certs/ca-certificates.crt", - }), - ).toThrow(CorporateCaValidationError); - }); - - it("skips a fallback env var pointing at a merged OS trust store and warns (#6210)", () => { - const p = writeCa(tmpDir()); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const resolved = resolveCorporateCaFromEnv({ - REQUESTS_CA_BUNDLE: "/etc/ssl/certs/ca-certificates.crt", - CURL_CA_BUNDLE: p, - }); - const messages = errorSpy.mock.calls.map((call) => String(call[0])); - errorSpy.mockRestore(); - // The merged-store fallback is skipped (never baked); the real corporate - // bundle from the next var wins. - expect(resolved?.sourceEnv).toBe("CURL_CA_BUNDLE"); - expect( - messages.some((m) => m.includes("REQUESTS_CA_BUNDLE") && m.includes("merged OS trust store")), - ).toBe(true); - }); -}); - -describe("isKnownMergedTrustStorePath (#6210)", () => { - it("matches every well-known merged OS trust-store path", () => { - for (const p of KNOWN_MERGED_TRUST_STORE_PATHS) { - expect(isKnownMergedTrustStorePath(p)).toBe(true); - } - }); - - it("normalizes a non-canonical path before matching", () => { - expect(isKnownMergedTrustStorePath("/etc/ssl/certs/../certs/ca-certificates.crt")).toBe(true); - }); - - it("does not match a dedicated corporate CA file", () => { - const p = writeCa(tmpDir()); - expect(isKnownMergedTrustStorePath(p)).toBe(false); - }); -}); - -describe("resolveCorporateCaFromHostAnchors host trust-store path (#6210)", () => { - it("imports from anchor-source dirs, not merged /etc/ssl/certs output (#6210)", () => { - expect(CORPORATE_CA_HOST_ANCHOR_DIRS).toContain("/usr/local/share/ca-certificates"); - expect(CORPORATE_CA_HOST_ANCHOR_DIRS).not.toContain("/etc/ssl/certs"); - expect(CORPORATE_CA_HOST_ANCHOR_DIRS).not.toContain("/etc/ssl/certs/ca-certificates.crt"); - expect(CORPORATE_CA_LITERAL_SSL_CERTS_DIR).toBe("/etc/ssl/certs"); - expect(isKnownMergedTrustStorePath("/etc/ssl/certs/ca-certificates.crt")).toBe(true); - }); - - it("discovers a corporate root installed in a host anchor directory", () => { - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "corp-proxy-root.crt"); - const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); - expect(resolved?.sourceEnv).toBe(CORPORATE_CA_HOST_ANCHOR_SOURCE); - expect(resolved?.sourcePath).toBe(anchorDir); - expect(resolved?.pem).toContain("BEGIN CERTIFICATE"); - }); - - it("returns the first anchor directory that yields a bundle", () => { - const missing = path.join(tmpDir(), "absent"); - const present = tmpDir(); - writeAnchor(present, "corp.crt"); - expect(resolveCorporateCaFromHostAnchors([missing, present])?.sourcePath).toBe(present); - }); - - it("returns null when no anchor directory exists", () => { - expect( - resolveCorporateCaFromHostAnchors([path.join(tmpDir(), "nope"), path.join(tmpDir(), "gone")]), - ).toBeNull(); - }); - - it("ignores non-anchor files and empty directories", () => { - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "README.txt", "not a cert\n"); - expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); - }); - - it("skips a directory whose aggregate exceeds the certificate cap", () => { - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "many.crt", PEM.repeat(MAX_CORPORATE_CA_CERTS + 1)); - expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); - }); - - it("aggregates multiple anchor files into one bundle", () => { - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "root-a.crt"); - writeAnchor(anchorDir, "root-b.crt"); - const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); - expect(resolved?.pem.match(/-----BEGIN CERTIFICATE-----/g)).toHaveLength(2); - }); - - it("accepts .pem/.cer anchors in an operator-supplied directory", () => { - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "corp-root.pem"); - expect(resolveCorporateCaFromHostAnchors([anchorDir])?.pem).toContain("BEGIN CERTIFICATE"); - }); - - it("warns when an anchor directory has candidate files but no valid CA", () => { - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "broken.crt", BAD_PEM); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const resolved = resolveCorporateCaFromHostAnchors([anchorDir]); - const messages = errorSpy.mock.calls.map((call) => String(call[0])); - errorSpy.mockRestore(); - expect(resolved).toBeNull(); - expect(messages.some((m) => m.includes(anchorDir) && m.includes("WARNING"))).toBe(true); - }); - - it("discovers a corporate root nested in an anchor subdirectory", () => { - // update-ca-certificates trusts .crt files recursively, e.g. - // /usr/local/share/ca-certificates/acme/root.crt. - const anchorDir = tmpDir(); - const sub = path.join(anchorDir, "acme"); - fs.mkdirSync(sub); - writeAnchor(sub, "root.crt"); - expect(resolveCorporateCaFromHostAnchors([anchorDir])?.pem).toContain("BEGIN CERTIFICATE"); - }); - - it("skips symlinked anchor entries", () => { - // A symlinked cert could point outside the anchor tree; the walk imports - // only real regular files. - const realCert = writeCa(tmpDir()); - const anchorDir = tmpDir(); - fs.symlinkSync(realCert, path.join(anchorDir, "linked.crt")); - expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); - }); - - it("skips an unreadable anchor directory without throwing", () => { - const anchorDir = tmpDir(); - fs.chmodSync(anchorDir, 0o000); - expect(() => resolveCorporateCaFromHostAnchors([anchorDir])).not.toThrow(); - expect(resolveCorporateCaFromHostAnchors([anchorDir])).toBeNull(); - fs.chmodSync(anchorDir, 0o700); // restore so cleanup can remove it - }); -}); - -describe("resolveCorporateCa env then host anchors (#6210)", () => { - it("prefers an env-configured CA over the host anchor directory", () => { - const envCa = writeCa(tmpDir()); - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "corp.crt"); - const resolved = resolveCorporateCa( - { [CORPORATE_CA_EXPLICIT_ENV]: envCa }, - { hostAnchorDirs: [anchorDir] }, - ); - expect(resolved?.sourceEnv).toBe(CORPORATE_CA_EXPLICIT_ENV); - expect(resolved?.sourcePath).toBe(envCa); - }); - - it("falls back to the host anchor directory when no env var is set", () => { - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "corp.crt"); - const resolved = resolveCorporateCa({}, { hostAnchorDirs: [anchorDir] }); - expect(resolved?.sourceEnv).toBe(CORPORATE_CA_HOST_ANCHOR_SOURCE); - }); - - it("honors the disable opt-out even when a host anchor exists", () => { - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "corp.crt"); - expect( - resolveCorporateCa({ [CORPORATE_CA_DISABLE_ENV]: "0" }, { hostAnchorDirs: [anchorDir] }), - ).toBeNull(); - }); - - it("returns null when neither env nor host anchors provide a CA", () => { - expect( - resolveCorporateCa( - {}, - { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir: null }, - ), - ).toBeNull(); - }); - - it("reads host anchor directories from the anchor-dirs env override", () => { - const anchorDir = tmpDir(); - writeAnchor(anchorDir, "corp.crt"); - const resolved = resolveCorporateCa({ [CORPORATE_CA_ANCHOR_DIRS_ENV]: anchorDir }); - expect(resolved?.sourceEnv).toBe(CORPORATE_CA_HOST_ANCHOR_SOURCE); - expect(resolved?.sourcePath).toBe(anchorDir); - }); - - it("disables host-store scanning when the anchor-dirs override is empty", () => { - expect(resolveCorporateCa({ [CORPORATE_CA_ANCHOR_DIRS_ENV]: "" })).toBeNull(); - }); - - it("imports a standalone CA from a literal /etc/ssl/certs-style directory (#6210)", () => { - const literalSslCertsDir = tmpDir(); - writeAnchor(literalSslCertsDir, "ca-certificates.crt"); - writeAnchor(literalSslCertsDir, "ssl-cert-snakeoil.pem", LEAF_PEM); - writeAnchor(literalSslCertsDir, "corp-proxy-root.pem"); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const resolved = resolveCorporateCa( - {}, - { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, - ); - const messages = errorSpy.mock.calls.map((call) => String(call[0])); - errorSpy.mockRestore(); - - expect(resolved?.sourceEnv).toBe(CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE); - expect(resolved?.sourcePath).toBe(literalSslCertsDir); - expect(resolved?.pem.match(/-----BEGIN CERTIFICATE-----/g)).toHaveLength(1); - expect(messages).toHaveLength(0); - }); - - it("does not warn for a literal /etc/ssl/certs-style leaf cert such as ssl-cert-snakeoil.pem", () => { - const literalSslCertsDir = tmpDir(); - writeAnchor(literalSslCertsDir, "ssl-cert-snakeoil.pem", LEAF_PEM); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const resolved = resolveCorporateCa( - {}, - { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, - ); - const messages = errorSpy.mock.calls.map((call) => String(call[0])); - errorSpy.mockRestore(); - - expect(resolved).toBeNull(); - expect(messages).toHaveLength(0); - }); - - it("does not warn for the merged /etc/ssl/certs ca-certificates output alone (#6210)", () => { - const literalSslCertsDir = tmpDir(); - writeAnchor(literalSslCertsDir, "ca-certificates.crt"); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const resolved = resolveCorporateCa( - {}, - { hostAnchorDirs: [path.join(tmpDir(), "absent")], literalSslCertsDir }, - ); - const messages = errorSpy.mock.calls.map((call) => String(call[0])); - errorSpy.mockRestore(); - - expect(resolved).toBeNull(); - expect(messages).toHaveLength(0); - }); - - it("does not warn about literal /etc/ssl/certs when host-store scanning is disabled", () => { - const literalSslCertsDir = tmpDir(); - writeAnchor(literalSslCertsDir, "corp-proxy-root.pem"); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const resolved = resolveCorporateCa( - { [CORPORATE_CA_ANCHOR_DIRS_ENV]: "" }, - { literalSslCertsDir }, - ); - const messages = errorSpy.mock.calls.map((call) => String(call[0])); - errorSpy.mockRestore(); - - expect(resolved).toBeNull(); - expect(messages).toHaveLength(0); - }); -}); - -describe("encodeCorporateCaArg", () => { - it("produces single-line base64 that round-trips", () => { - const encoded = encodeCorporateCaArg(PEM); - expect(encoded).not.toMatch(/[\r\n]/); - expect(Buffer.from(encoded, "base64").toString("utf8")).toBe(PEM); - }); -}); From faf0a76c9c7ed5ff9566cb5ac1d3c77f89503697 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Fri, 10 Jul 2026 08:16:31 -0700 Subject: [PATCH 26/26] test(e2e): clean corporate CA fixtures on skip --- src/lib/onboard/corporate-ca-host-anchors.ts | 1 - test/e2e/live/cloud-onboard.test.ts | 2 +- test/e2e/live/onboard-repair.test.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/corporate-ca-host-anchors.ts b/src/lib/onboard/corporate-ca-host-anchors.ts index 5de66465fa2..14c6782b54a 100644 --- a/src/lib/onboard/corporate-ca-host-anchors.ts +++ b/src/lib/onboard/corporate-ca-host-anchors.ts @@ -6,7 +6,6 @@ import path from "node:path"; import { CORPORATE_CA_ANCHOR_DIRS_ENV, - CORPORATE_CA_EXPLICIT_ENV, CORPORATE_CA_HOST_ANCHOR_SOURCE, CORPORATE_CA_LITERAL_SSL_CERTS_SOURCE, isKnownMergedTrustStorePath, diff --git a/test/e2e/live/cloud-onboard.test.ts b/test/e2e/live/cloud-onboard.test.ts index f6d545a8049..1e8f7c9f27a 100644 --- a/test/e2e/live/cloud-onboard.test.ts +++ b/test/e2e/live/cloud-onboard.test.ts @@ -81,6 +81,7 @@ test("cloud onboard: public installer creates healthy sandbox with security chec `https://raw.githubusercontent.com/NVIDIA/NemoClaw/${ref}/install.sh`; const installCwd = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-public-install-")); const corporateCa = createCorporateCaFixture("explicit", "nemoclaw-cloud-corporate-ca-"); + cleanupRegistry.add("remove corporate CA fixture", () => cleanupCorporateCaFixture(corporateCa)); const redactionValues = [hosted.apiKey]; await artifacts.target.declare({ @@ -112,7 +113,6 @@ test("cloud onboard: public installer creates healthy sandbox with security chec cleanupRegistry.add("remove cloud-onboard sandbox", () => cleanup(host, sandbox, { label: "cleanup", verify: true }), ); - cleanupRegistry.add("remove corporate CA fixture", () => cleanupCorporateCaFixture(corporateCa)); await cleanup(host, sandbox, { label: "pre-cleanup", verify: false }); const install = await host.command( diff --git a/test/e2e/live/onboard-repair.test.ts b/test/e2e/live/onboard-repair.test.ts index 3db6ac76cb8..e1d9a433aa1 100644 --- a/test/e2e/live/onboard-repair.test.ts +++ b/test/e2e/live/onboard-repair.test.ts @@ -110,6 +110,7 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu timeout: LIVE_TIMEOUT_MS, }, async ({ artifacts, cleanup: cleanupRegistry, host, sandbox, skip }) => { const corporateCa = createCorporateCaFixture("requests", "nemoclaw-repair-corporate-ca-"); + cleanupRegistry.add("remove corporate CA fixture", () => cleanupCorporateCaFixture(corporateCa)); await artifacts.target.declare({ id: "onboard-repair", sandboxName: SANDBOX_NAME, @@ -140,7 +141,6 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu }); cleanupRegistry.add("close fake OpenAI-compatible endpoint", async () => fake.close()); cleanupRegistry.add("remove repair sandboxes", () => cleanup(host, sandbox)); - cleanupRegistry.add("remove corporate CA fixture", () => cleanupCorporateCaFixture(corporateCa)); await cleanup(host, sandbox); const first = await nemoclaw(