diff --git a/agents/langchain-deepagents-code/dcode-launcher.sh b/agents/langchain-deepagents-code/dcode-launcher.sh index 429583fb6eb..1806ef3b1dd 100755 --- a/agents/langchain-deepagents-code/dcode-launcher.sh +++ b/agents/langchain-deepagents-code/dcode-launcher.sh @@ -14,6 +14,7 @@ unset _nemoclaw_auto_approval_env readonly MANAGED_DCODE_WRAPPER="/usr/local/lib/nemoclaw/dcode-wrapper.sh" readonly MANAGED_EXEC_LAUNCHER="/usr/local/lib/nemoclaw/dcode-managed-exec" readonly MANAGED_OBSERVABILITY_MARKER="/sandbox/.deepagents/.nemoclaw-observability-enabled" +readonly MANAGED_FETCH_CA_BUNDLE_FILE="/etc/openshell-tls/ca-bundle.pem" readonly MANAGED_SESSION_SUPERVISOR="/usr/local/lib/nemoclaw/dcode-session-supervisor.py" export HOME=/sandbox export PATH="/usr/local/bin:/opt/venv/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -98,10 +99,50 @@ read_managed_proxy_value() { printf '%s' "$value" } +managed_fetch_ca_bundle_metadata() { + local file="$1" + local metadata + if metadata="$(stat -c '%u:%a:%s' "$file" 2>/dev/null)"; then + printf '%s' "$metadata" + else + stat -f '%u:%Lp:%z' "$file" 2>/dev/null + fi +} + +validate_managed_fetch_ca_bundle() { + local file="$MANAGED_FETCH_CA_BUNDLE_FILE" + local metadata owner mode size extra + if [ -L "$file" ]; then + printf '%s\n' 'Missing or unsafe managed fetch CA bundle file.' >&2 + return 1 + fi + [ -e "$file" ] || return 0 + if [ ! -f "$file" ] || [ ! -r "$file" ]; then + printf '%s\n' 'Missing or unsafe managed fetch CA bundle file.' >&2 + return 1 + fi + metadata="$(managed_fetch_ca_bundle_metadata "$file")" || { + printf '%s\n' 'Cannot inspect managed fetch CA bundle file.' >&2 + return 1 + } + IFS=: read -r owner mode size extra <<<"$metadata" + if [ -n "${extra:-}" ] \ + || [[ ! "$owner" =~ ^[0-9]+$ ]] \ + || [[ ! "$mode" =~ ^[0-7]{3,4}$ ]] \ + || [[ ! "$size" =~ ^[0-9]+$ ]] \ + || [ "$owner" != "$MANAGED_PROXY_OWNER_UID" ] \ + || [ "$size" -le 0 ] \ + || (((8#$mode & 0022) != 0)); then + printf '%s\n' 'Unsafe ownership or mode on managed fetch CA bundle file.' >&2 + return 1 + fi +} + # Onboard validates the build args and the Dockerfile stores them in root-owned # files. Runtime env is untrusted and cannot override those image-baked values. PROXY_HOST="$(read_managed_proxy_value "$MANAGED_PROXY_HOST_FILE" "host")" PROXY_PORT="$(read_managed_proxy_value "$MANAGED_PROXY_PORT_FILE" "port")" +validate_managed_fetch_ca_bundle unset NEMOCLAW_PROXY_HOST NEMOCLAW_PROXY_PORT # Generic proxy fallbacks are outside the managed dcode contract and may carry # host credentials even after the scheme-specific proxy values are normalized. diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index 719a756e6fc..6a6c113c0de 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -71,6 +71,7 @@ unset _NEMOCLAW_SANDBOX_RLIMITS # or when dcode no longer uses inference.local. readonly MANAGED_PROXY_HOST_FILE="/usr/local/share/nemoclaw/dcode-proxy-host" readonly MANAGED_PROXY_PORT_FILE="/usr/local/share/nemoclaw/dcode-proxy-port" +readonly MANAGED_FETCH_CA_BUNDLE_FILE="/etc/openshell-tls/ca-bundle.pem" readonly MANAGED_PROXY_OWNER_UID=0 managed_proxy_file_metadata() { @@ -104,10 +105,50 @@ read_managed_proxy_value() { printf '%s' "$value" } +managed_fetch_ca_bundle_metadata() { + local file="$1" + local metadata + if metadata="$(stat -c '%u:%a:%s' "$file" 2>/dev/null)"; then + printf '%s' "$metadata" + else + stat -f '%u:%Lp:%z' "$file" 2>/dev/null + fi +} + +validate_managed_fetch_ca_bundle() { + local file="$MANAGED_FETCH_CA_BUNDLE_FILE" + local metadata owner mode size extra + if [ -L "$file" ]; then + printf '%s\n' 'Missing or unsafe managed fetch CA bundle file.' >&2 + return 1 + fi + [ -e "$file" ] || return 0 + if [ ! -f "$file" ] || [ ! -r "$file" ]; then + printf '%s\n' 'Missing or unsafe managed fetch CA bundle file.' >&2 + return 1 + fi + metadata="$(managed_fetch_ca_bundle_metadata "$file")" || { + printf '%s\n' 'Cannot inspect managed fetch CA bundle file.' >&2 + return 1 + } + IFS=: read -r owner mode size extra <<<"$metadata" + if [ -n "${extra:-}" ] \ + || [[ ! "$owner" =~ ^[0-9]+$ ]] \ + || [[ ! "$mode" =~ ^[0-7]{3,4}$ ]] \ + || [[ ! "$size" =~ ^[0-9]+$ ]] \ + || [ "$owner" != "$MANAGED_PROXY_OWNER_UID" ] \ + || [ "$size" -le 0 ] \ + || (((8#$mode & 0022) != 0)); then + printf '%s\n' 'Unsafe ownership or mode on managed fetch CA bundle file.' >&2 + return 1 + fi +} + # Fail closed if the root-owned image contract is missing. Process-level # NEMOCLAW_PROXY_* values are not a trusted runtime routing source. PROXY_HOST="$(read_managed_proxy_value "$MANAGED_PROXY_HOST_FILE" "host")" PROXY_PORT="$(read_managed_proxy_value "$MANAGED_PROXY_PORT_FILE" "port")" +validate_managed_fetch_ca_bundle unset NEMOCLAW_PROXY_HOST NEMOCLAW_PROXY_PORT # Generic proxy fallbacks are outside the managed dcode contract and may carry # host credentials even after the scheme-specific proxy values are normalized. @@ -200,6 +241,46 @@ prepare_runtime_env() { write_export_if_set http_proxy write_export_if_set https_proxy write_export_if_set no_proxy + printf '_nemoclaw_dcode_ca_bundle=%q\n' "$MANAGED_FETCH_CA_BUNDLE_FILE" + printf '_nemoclaw_dcode_ca_owner_uid=%q\n' "$MANAGED_PROXY_OWNER_UID" + cat <<'NEMOCLAW_DCODE_CA_GUARD' +if [ -L "$_nemoclaw_dcode_ca_bundle" ]; then + printf "%s\n" "Missing or unsafe managed fetch CA bundle file." >&2 + return 1 2>/dev/null || exit 1 +elif [ -e "$_nemoclaw_dcode_ca_bundle" ]; then + if [ ! -f "$_nemoclaw_dcode_ca_bundle" ] || [ ! -r "$_nemoclaw_dcode_ca_bundle" ]; then + printf "%s\n" "Missing or unsafe managed fetch CA bundle file." >&2 + return 1 2>/dev/null || exit 1 + fi + _nemoclaw_dcode_ca_meta="$(stat -c "%u:%a:%s" "$_nemoclaw_dcode_ca_bundle" 2>/dev/null || stat -f "%u:%Lp:%z" "$_nemoclaw_dcode_ca_bundle" 2>/dev/null || true)" + IFS=: read -r _nemoclaw_dcode_ca_owner _nemoclaw_dcode_ca_mode _nemoclaw_dcode_ca_size _nemoclaw_dcode_ca_extra <&2 + return 1 2>/dev/null || exit 1 + fi +fi +unset _nemoclaw_dcode_ca_bundle _nemoclaw_dcode_ca_owner_uid _nemoclaw_dcode_ca_meta _nemoclaw_dcode_ca_owner _nemoclaw_dcode_ca_mode _nemoclaw_dcode_ca_size _nemoclaw_dcode_ca_extra _nemoclaw_dcode_ca_valid +NEMOCLAW_DCODE_CA_GUARD write_export_if_set SSL_CERT_FILE write_export_if_set REQUESTS_CA_BUNDLE write_export_if_set NODE_EXTRA_CA_CERTS diff --git a/test/helpers/langchain-deepagents-code-headless.ts b/test/helpers/langchain-deepagents-code-headless.ts index 47c620c47be..4c1b4428f71 100644 --- a/test/helpers/langchain-deepagents-code-headless.ts +++ b/test/helpers/langchain-deepagents-code-headless.ts @@ -53,6 +53,7 @@ export function makeStartScriptFixture( const rlimitLib = path.join(tempDir, "sandbox-rlimits.sh"); const hostFile = path.join(tempDir, "trusted-proxy-host"); const portFile = path.join(tempDir, "trusted-proxy-port"); + const caFile = path.join(tempDir, "trusted-ca-bundle.pem"); const markerDir = path.join(tempDir, "persistent-dcode-state"); expect(original).toContain("local target=/tmp/nemoclaw-proxy-env.sh"); expect(original).toContain('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"'); @@ -67,6 +68,10 @@ export function makeStartScriptFixture( 'readonly MANAGED_PROXY_PORT_FILE="/usr/local/share/nemoclaw/dcode-proxy-port"', `readonly MANAGED_PROXY_PORT_FILE="${portFile}"`, ) + .replace( + 'readonly MANAGED_FETCH_CA_BUNDLE_FILE="/etc/openshell-tls/ca-bundle.pem"', + `readonly MANAGED_FETCH_CA_BUNDLE_FILE="${caFile}"`, + ) .replace( "readonly MANAGED_PROXY_OWNER_UID=0", `readonly MANAGED_PROXY_OWNER_UID=${process.getuid?.() ?? 0}`, @@ -85,6 +90,7 @@ export function makeStartScriptFixture( expect(fixture).not.toContain("local marker_dir=/sandbox/.deepagents"); fs.writeFileSync(hostFile, "10.200.0.1\n", "utf8"); fs.writeFileSync(portFile, "3128\n", "utf8"); + fs.writeFileSync(caFile, "trusted CA bundle\n", "utf8"); fs.writeFileSync( rlimitLib, "harden_resource_limits() { :; }\nverify_resource_limits_exact() { :; }\n", @@ -92,6 +98,7 @@ export function makeStartScriptFixture( ); fs.chmodSync(hostFile, 0o444); fs.chmodSync(portFile, 0o444); + fs.chmodSync(caFile, 0o444); fs.writeFileSync(scriptPath, fixture, "utf8"); fs.chmodSync(scriptPath, 0o755); return { envFile, scriptPath }; diff --git a/test/langchain-deepagents-code-proxy-launcher.test.ts b/test/langchain-deepagents-code-proxy-launcher.test.ts index e97066b0650..14a6d4264a2 100644 --- a/test/langchain-deepagents-code-proxy-launcher.test.ts +++ b/test/langchain-deepagents-code-proxy-launcher.test.ts @@ -40,12 +40,16 @@ function writeManagedProxyFiles( ): void { const hostFile = path.join(tempDir, "trusted-proxy-host"); const portFile = path.join(tempDir, "trusted-proxy-port"); + const caFile = path.join(tempDir, "trusted-ca-bundle.pem"); fs.rmSync(hostFile, { force: true }); fs.rmSync(portFile, { force: true }); + fs.rmSync(caFile, { force: true }); fs.writeFileSync(hostFile, `${managedProxy.host}\n`); fs.writeFileSync(portFile, `${managedProxy.port}\n`); + fs.writeFileSync(caFile, "trusted CA bundle\n"); fs.chmodSync(hostFile, 0o444); fs.chmodSync(portFile, 0o444); + fs.chmodSync(caFile, 0o444); } function replaceManagedProxyFileConstants(source: string, tempDir: string): string { @@ -69,6 +73,10 @@ function replaceManagedProxyFileConstants(source: string, tempDir: string): stri 'readonly MANAGED_PROXY_PORT_FILE="/usr/local/share/nemoclaw/dcode-proxy-port"', `readonly MANAGED_PROXY_PORT_FILE="${path.join(tempDir, "trusted-proxy-port")}"`, ) + .replace( + 'readonly MANAGED_FETCH_CA_BUNDLE_FILE="/etc/openshell-tls/ca-bundle.pem"', + `readonly MANAGED_FETCH_CA_BUNDLE_FILE="${path.join(tempDir, "trusted-ca-bundle.pem")}"`, + ) .replace( "readonly MANAGED_PROXY_OWNER_UID=0", `readonly MANAGED_PROXY_OWNER_UID=${TEST_OWNER_UID}`, @@ -439,6 +447,11 @@ describe("Deep Agents Code direct-exec proxy launcher", () => { expect(launcherResult.status, launcherResult.stderr).toBe(0); expect(startResult.status, startResult.stderr).toBe(0); const envFileText = fs.readFileSync(envFile, "utf8"); + const posixSourceResult = spawnSync("sh", ["-c", '. "$1"', "sh", envFile], { + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + encoding: "utf8", + }); + expect(posixSourceResult.status, posixSourceResult.stderr).toBe(0); const launcherNoProxy = launcherResult.stdout.match(/^LAUNCHER_NO_PROXY=(.*)$/m)?.[1]; const startNoProxy = startResult.stdout.match(/^START_PROXY=[^|]*\|([^|]*)\|/m)?.[1]; expect(fs.statSync(envFile).mode & 0o777).toBe(0o444); @@ -517,6 +530,103 @@ describe("Deep Agents Code direct-exec proxy launcher", () => { ); }); + const expectManagedCaBundleRejection = ({ + expected, + mutate, + }: { + expected: string; + mutate: (caFile: string) => void; + }): void => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-ca-bundle-")); + const launcherPath = makeLauncherProxyProbeFixture(tempDir); + const { envFile, scriptPath } = makeStartProxyProbeFixture(tempDir); + const caFile = path.join(tempDir, "trusted-ca-bundle.pem"); + + const safeStart = spawnSync("bash", [scriptPath, "true"], { + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + encoding: "utf8", + }); + expect(safeStart.status, safeStart.stderr).toBe(0); + expect(fs.existsSync(envFile)).toBe(true); + + mutate(caFile); + const launcherResult = runLauncher(launcherPath, ["-n", "PONG"], {}); + const startResult = spawnSync("bash", [scriptPath, "true"], { + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + encoding: "utf8", + }); + const connectSourceResult = spawnSync("sh", ["-c", '. "$1"', "sh", envFile], { + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + encoding: "utf8", + }); + + expect(launcherResult.status).not.toBe(0); + expect(startResult.status).not.toBe(0); + expect(connectSourceResult.status).not.toBe(0); + expect(launcherResult.stderr).toContain(expected); + expect(startResult.stderr).toContain(expected); + expect(connectSourceResult.stderr).toContain(expected); + const combined = `${launcherResult.stderr}\n${startResult.stderr}\n${connectSourceResult.stderr}`; + expect(combined).not.toContain(caFile); + }; + + it.each([ + { + condition: "writable", + expected: "Unsafe ownership or mode on managed fetch CA bundle file", + mutate: (caFile: string) => fs.chmodSync(caFile, 0o666), + }, + { + condition: "empty", + expected: "Unsafe ownership or mode on managed fetch CA bundle file", + mutate: (caFile: string) => { + fs.chmodSync(caFile, 0o600); + fs.truncateSync(caFile, 0); + fs.chmodSync(caFile, 0o444); + }, + }, + { + condition: "non-regular", + expected: "Missing or unsafe managed fetch CA bundle file", + mutate: (caFile: string) => { + fs.rmSync(caFile); + fs.mkdirSync(caFile); + }, + }, + { + condition: "regular-file symlink", + expected: "Missing or unsafe managed fetch CA bundle file", + mutate: (caFile: string) => { + const target = `${caFile}.target`; + fs.renameSync(caFile, target); + fs.symlinkSync(target, caFile); + }, + }, + { + condition: "dangling symlink", + expected: "Missing or unsafe managed fetch CA bundle file", + mutate: (caFile: string) => { + fs.rmSync(caFile); + fs.symlinkSync(`${caFile}.missing`, caFile); + }, + }, + ])("rejects $condition managed fetch CA bundles in start, connect, and direct dcode paths (#6636)", ({ + expected, + mutate, + }) => { + expectManagedCaBundleRejection({ expected, mutate }); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "rejects an unreadable managed fetch CA bundle in start, connect, and direct dcode paths (#6636)", + () => { + expectManagedCaBundleRejection({ + expected: "Missing or unsafe managed fetch CA bundle file", + mutate: (caFile: string) => fs.chmodSync(caFile, 0o000), + }); + }, + ); + it("keeps dcode shell proxy validators aligned with onboard validation (#6191)", () => { const start = readAgentFile("start.sh"); const launcher = readAgentFile("dcode-launcher.sh"); diff --git a/test/support/dcode-start-script-fixture.ts b/test/support/dcode-start-script-fixture.ts index 7ae222bfd57..42e881bd8d6 100644 --- a/test/support/dcode-start-script-fixture.ts +++ b/test/support/dcode-start-script-fixture.ts @@ -23,6 +23,7 @@ export function makeStartScriptFixture(tempDir: string): { const rlimitLib = path.join(tempDir, "sandbox-rlimits.sh"); const hostFile = path.join(tempDir, "trusted-proxy-host"); const portFile = path.join(tempDir, "trusted-proxy-port"); + const caFile = path.join(tempDir, "trusted-ca-bundle.pem"); const original = fs.readFileSync(START_SCRIPT, "utf8"); assert.ok(original.includes("local target=/tmp/nemoclaw-proxy-env.sh")); assert.ok(original.includes('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"')); @@ -36,6 +37,10 @@ export function makeStartScriptFixture(tempDir: string): { 'readonly MANAGED_PROXY_PORT_FILE="/usr/local/share/nemoclaw/dcode-proxy-port"', `readonly MANAGED_PROXY_PORT_FILE="${portFile}"`, ) + .replace( + 'readonly MANAGED_FETCH_CA_BUNDLE_FILE="/etc/openshell-tls/ca-bundle.pem"', + `readonly MANAGED_FETCH_CA_BUNDLE_FILE="${caFile}"`, + ) .replace( "readonly MANAGED_PROXY_OWNER_UID=0", `readonly MANAGED_PROXY_OWNER_UID=${process.getuid?.() ?? 0}`, @@ -51,6 +56,7 @@ export function makeStartScriptFixture(tempDir: string): { assert.ok(!fixture.includes('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"')); fs.writeFileSync(hostFile, "10.200.0.1\n", "utf8"); fs.writeFileSync(portFile, "3128\n", "utf8"); + fs.writeFileSync(caFile, "trusted CA bundle\n", "utf8"); fs.writeFileSync( rlimitLib, "harden_resource_limits() { :; }\nverify_resource_limits_exact() { :; }\n", @@ -58,6 +64,7 @@ export function makeStartScriptFixture(tempDir: string): { ); fs.chmodSync(hostFile, 0o444); fs.chmodSync(portFile, 0o444); + fs.chmodSync(caFile, 0o444); fs.writeFileSync(scriptPath, fixture, "utf8"); fs.chmodSync(scriptPath, 0o755); return { envFile, scriptPath };