Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
41c8fe7
feat(sandbox): import host corporate proxy CA into sandbox trust (#6210)
yimoj Jul 6, 2026
379a3dc
test(sandbox): keep corporate CA test bodies linear (#6210)
yimoj Jul 6, 2026
5f99716
fix(sandbox): harden corporate CA import per review (#6210)
yimoj Jul 6, 2026
03c0960
fix(sandbox): correct Hermes CA env + validate all CA blocks (#6210)
yimoj Jul 6, 2026
577bfb7
test(sandbox): add issue-ref suffixes and Hermes bail-path test (#6210)
yimoj Jul 6, 2026
e2420fb
fix(security): harden corporate-CA merge against symlink + missing ba…
prekshivyas Jul 6, 2026
78c4e99
Merge remote-tracking branch 'upstream/main' into pr6292m
prekshivyas Jul 6, 2026
d76dc2b
Merge remote-tracking branch 'upstream/main' into pr6292fix
prekshivyas Jul 6, 2026
04d9a51
test(rebuild): make prepared-recovery acceptance self-contained (#6210)
prekshivyas Jul 6, 2026
bccdc7c
docs(onboard): narrow #6210 corporate-CA scope to env-configured sour…
prekshivyas Jul 6, 2026
b849a52
Merge remote-tracking branch 'origin/main' into fix/6210-corporate-pr…
prekshivyas Jul 6, 2026
800d5b2
Merge branch 'main' into fix/6210-corporate-proxy-ca
prekshivyas Jul 6, 2026
01d4fff
fix(onboard): detect host trust-store corporate CA and bake only vali…
yimoj Jul 8, 2026
7456050
fix(onboard): surface silent-skip and runtime-merge failures for corp…
yimoj Jul 8, 2026
3c7ae76
fix(onboard): harden Dockerfile corporate-CA decode against malformed…
yimoj Jul 8, 2026
79d7f6d
fix(onboard): reject group-writable corporate CA sources and cover an…
yimoj Jul 8, 2026
55bb6ac
docs(onboard): clarify /etc/ssl/certs contract, permanent fallbacks, …
yimoj Jul 8, 2026
dd980a1
docs(onboard): document the corporate-CA merge trust-anchor path safe…
yimoj Jul 8, 2026
4c5f6c7
merge(main): sync PR #6292 with latest main
cv Jul 9, 2026
f89b641
fix(onboard): reject merged OS trust stores as corporate CA sources (…
yimoj Jul 9, 2026
e3554bf
test(onboard): normalize corporate CA test issue suffixes
cv Jul 9, 2026
fd2844d
docs(onboard): clarify /etc/ssl/certs JSDoc note and custom-Dockerfil…
yimoj Jul 9, 2026
c7ff2d8
test: add corporate CA live evidence
prekshivyas Jul 9, 2026
a60e3b1
test: make corporate CA live probe file-backed
prekshivyas Jul 9, 2026
7cab7e3
test: shell quote cloud onboard installer command
prekshivyas Jul 9, 2026
9799a74
docs(onboard): pin corporate CA anchor-source scope
prekshivyas Jul 9, 2026
51ed9da
fix(onboard): warn on literal ssl certs-only CA
prekshivyas Jul 9, 2026
455ea2c
fix(onboard): tighten corporate CA trust handling
prekshivyas Jul 9, 2026
81cf13f
Merge remote-tracking branch 'upstream/main' into pr-6292-corp-ca
prekshivyas Jul 9, 2026
4d5a899
fix(onboard): import standalone ssl cert CAs
prekshivyas Jul 9, 2026
9ac7e4d
fix(onboard): bound corporate CA anchor scans
prekshivyas Jul 9, 2026
16a8e53
Merge branch 'main' into fix/6210-corporate-proxy-ca
prekshivyas Jul 9, 2026
faf0a76
test(e2e): clean corporate CA fixtures on skip
prekshivyas Jul 10, 2026
7ddde82
Merge remote-tracking branch 'upstream/main' into pr-6292-corp-ca
prekshivyas Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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} \
Expand Down Expand Up @@ -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

Expand Down
37 changes: 36 additions & 1 deletion agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
# 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.
Expand Down Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,19 @@ If they are missing on an older sandbox, upgrade NemoClaw and run:
$$nemoclaw <name> 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.
Expand Down
49 changes: 49 additions & 0 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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,
Expand Down Expand Up @@ -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.
Expand Down
145 changes: 145 additions & 0 deletions src/lib/onboard/corporate-ca.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading