Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 48 additions & 2 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1664,6 +1664,17 @@ _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() {
# Trust-anchor tampering (#8650): replacing the baked corporate CA file with a
# symlink makes the merge below read the link target instead, adding
# attacker-selected bytes to the trust bundle that curl, python, git, and node
# verify against. The image bakes this path as a root-owned 0444 regular file,
# so a symlink here is never a legitimate state. This is not the recoverable
# "merge failed" case below, which safely keeps OpenShell-only trust, so it
# fails closed instead of warning.
if [ -L "$_NEMOCLAW_CORPORATE_CA_FILE" ]; then
echo "[nemoclaw] refusing symlinked corporate CA at ${_NEMOCLAW_CORPORATE_CA_FILE}; expected a regular file (#8650)" >&2
exit 1
fi
[ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0
_base_bundle=""
if [ -n "${SSL_CERT_FILE:-}" ] && [ -f "${SSL_CERT_FILE}" ]; then
Expand Down Expand Up @@ -1703,11 +1714,46 @@ merge_corporate_proxy_ca() {
return 0
}
fi
cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_tmp" 2>/dev/null || {
# Append through a descriptor opened with O_NOFOLLOW and verified as a regular
# file (#8650). The check above rejects a planted symlink; this rejects one
# swapped in afterwards, because the type check and the read share one
# descriptor and no path is resolved twice. Status 2 means the source was
# rejected as a trust anchor; any other non-zero status is an ordinary read
# failure that keeps the existing warn-and-continue behavior.
_ca_append_status=0
python3 -I - "$_NEMOCLAW_CORPORATE_CA_FILE" "$_tmp" <<'PY_APPEND_CORPORATE_CA' || _ca_append_status=$?
import errno
import os
import stat
import sys

source, target = sys.argv[1], sys.argv[2]
try:
descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as error:
raise SystemExit(2 if error.errno == errno.ELOOP else 3)
try:
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
raise SystemExit(2)
with open(target, "ab") as merged:
while True:
chunk = os.read(descriptor, 65536)
if not chunk:
break
merged.write(chunk)
finally:
os.close(descriptor)
PY_APPEND_CORPORATE_CA
if [ "$_ca_append_status" -eq 2 ]; then
rm -f "$_tmp"
echo "[nemoclaw] refusing corporate CA at ${_NEMOCLAW_CORPORATE_CA_FILE}; expected a regular file, not a symlink (#8650)" >&2
exit 1
fi
if [ "$_ca_append_status" -ne 0 ]; then
rm -f "$_tmp"
_nemoclaw_ca_merge_warn "append corporate CA"
return 0
}
fi
chmod 0444 "$_tmp" 2>/dev/null || {
rm -f "$_tmp"
_nemoclaw_ca_merge_warn "set merged bundle permissions (${_merged})"
Expand Down
50 changes: 48 additions & 2 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3188,6 +3188,17 @@ _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() {
# Trust-anchor tampering (#8650): replacing the baked corporate CA file with a
# symlink makes the merge below read the link target instead, adding
# attacker-selected bytes to the trust bundle that curl, python, git, and node
# verify against. The image bakes this path as a root-owned 0444 regular file,
# so a symlink here is never a legitimate state. This is not the recoverable
# "merge failed" case below, which safely keeps OpenShell-only trust, so it
# fails closed instead of warning.
if [ -L "$_NEMOCLAW_CORPORATE_CA_FILE" ]; then
echo "[nemoclaw] refusing symlinked corporate CA at ${_NEMOCLAW_CORPORATE_CA_FILE}; expected a regular file (#8650)" >&2
exit 1
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
[ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0
_base_bundle=""
if [ -n "${SSL_CERT_FILE:-}" ] && [ -f "${SSL_CERT_FILE}" ]; then
Expand Down Expand Up @@ -3227,11 +3238,46 @@ merge_corporate_proxy_ca() {
return 0
}
fi
cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_tmp" 2>/dev/null || {
# Append through a descriptor opened with O_NOFOLLOW and verified as a regular
# file (#8650). The check above rejects a planted symlink; this rejects one
# swapped in afterwards, because the type check and the read share one
# descriptor and no path is resolved twice. Status 2 means the source was
# rejected as a trust anchor; any other non-zero status is an ordinary read
# failure that keeps the existing warn-and-continue behavior.
_ca_append_status=0
python3 -I - "$_NEMOCLAW_CORPORATE_CA_FILE" "$_tmp" <<'PY_APPEND_CORPORATE_CA' || _ca_append_status=$?
import errno
import os
import stat
import sys

source, target = sys.argv[1], sys.argv[2]
try:
descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as error:
raise SystemExit(2 if error.errno == errno.ELOOP else 3)
try:
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
raise SystemExit(2)
with open(target, "ab") as merged:
while True:
chunk = os.read(descriptor, 65536)
if not chunk:
break
merged.write(chunk)
finally:
os.close(descriptor)
PY_APPEND_CORPORATE_CA
if [ "$_ca_append_status" -eq 2 ]; then
rm -f "$_tmp"
echo "[nemoclaw] refusing corporate CA at ${_NEMOCLAW_CORPORATE_CA_FILE}; expected a regular file, not a symlink (#8650)" >&2
exit 1
fi
if [ "$_ca_append_status" -ne 0 ]; then
rm -f "$_tmp"
_nemoclaw_ca_merge_warn "append corporate CA"
return 0
}
fi
chmod 0444 "$_tmp" 2>/dev/null || {
rm -f "$_tmp"
_nemoclaw_ca_merge_warn "set merged bundle permissions (${_merged})"
Expand Down
81 changes: 80 additions & 1 deletion test/corporate-ca-runtime-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// entrypoints. Exercises the actual shell blocks extracted from
// scripts/nemoclaw-start.sh and agents/hermes/start.sh, not a re-implementation.

import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -39,6 +39,85 @@ function mergeBlock(scriptPath: string, endMarker: string, corpCa: string, merge
.replaceAll("/tmp/nemoclaw-ca-bundle.pem", merged);
}

const OPENCLAW_END = "# Git TLS CA bundle fix (NemoClaw#2270).";
const HERMES_END = "# OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA.";

/**
* Run a merge block whose stderr is captured to a file, and return that
* diagnostic. The block exits non-zero on a rejected trust anchor, so the
* caller asserts the throw and then reads the diagnostic this leaves behind.
*/
function mergeDiagnostic(dir: string, block: string, lines: string[] = []): string {
const errFile = join(dir, "merge-stderr.txt");
expect(() =>
runShellLines(dir, [`exec 2>${JSON.stringify(errFile)}`, ...lines, block]),
).toThrow();
return readFileSync(errFile, "utf-8");
}

describe("corporate proxy CA trust-anchor rejection (#8650)", () => {
it.each([
{ agent: "OpenClaw", script: OPENCLAW_START, end: OPENCLAW_END },
{ agent: "Hermes", script: HERMES_START, end: HERMES_END },
])("rejects a symlinked corporate CA before reading it for $agent (#8650)", ({ script, end }) => {
const dir = tmpDir("nemoclaw-corp-symlink-");
const openshell = join(dir, "openshell-ca.pem");
const corp = join(dir, "corporate-ca.pem");
const merged = join(dir, "merged-ca.pem");
const planted = join(dir, "not-a-certificate");
writeFileSync(openshell, OPENSHELL_PEM);
// The baked path is a root-owned 0444 regular file in the image, so a
// symlink here is tampering. Point it at content that is not a certificate.
writeFileSync(planted, "PLANTED-NOT-A-CERTIFICATE\n");
symlinkSync(planted, corp);

const diagnostic = mergeDiagnostic(dir, mergeBlock(script, end, corp, merged), [
`export SSL_CERT_FILE=${JSON.stringify(openshell)}`,
]);

expect(diagnostic).toContain("corporate CA");
expect(diagnostic).not.toContain("PLANTED-NOT-A-CERTIFICATE");
expect(existsSync(merged)).toBe(false);
});

it.each([
{ agent: "OpenClaw", script: OPENCLAW_START, end: OPENCLAW_END },
{ agent: "Hermes", script: HERMES_START, end: HERMES_END },
])("rejects a corporate CA swapped to a symlink after the path check for $agent (#8650)", ({
script,
end,
}) => {
const dir = tmpDir("nemoclaw-corp-swap-");
const openshell = join(dir, "openshell-ca.pem");
const corp = join(dir, "corporate-ca.pem");
const merged = join(dir, "merged-ca.pem");
const planted = join(dir, "not-a-certificate");
writeFileSync(openshell, OPENSHELL_PEM);
writeFileSync(planted, "PLANTED-NOT-A-CERTIFICATE\n");
writeFileSync(corp, CORPORATE_PEM);

// Stand in for a process that replaces the path between the `-L` check and
// the read. Swapping the file for a symlink right after the check means
// only the O_NOFOLLOW read can still reject it.
const swap = [
`rm -f ${JSON.stringify(corp)}`,
`ln -s ${JSON.stringify(planted)} ${JSON.stringify(corp)}`,
].join("\n");
const raced = mergeBlock(script, end, corp, merged).replace(
`[ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0`,
`[ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0\n${swap}`,
);

const diagnostic = mergeDiagnostic(dir, raced, [
`export SSL_CERT_FILE=${JSON.stringify(openshell)}`,
]);

expect(diagnostic).toContain("corporate CA");
expect(diagnostic).not.toContain("PLANTED-NOT-A-CERTIFICATE");
expect(existsSync(merged)).toBe(false);
});
});

describe("corporate proxy CA runtime merge (#6210)", () => {
it("appends the corporate CA to the OpenShell bundle for OpenClaw and repoints all CA env (#6210)", () => {
const dir = tmpDir("nemoclaw-corp-merge-openclaw-");
Expand Down
Loading