Skip to content

fix(sandbox): reject a symlinked corporate CA at managed startup - #8668

Merged
cv merged 2 commits into
mainfrom
fix/8650-corporate-ca-symlink
Aug 10, 2026
Merged

fix(sandbox): reject a symlinked corporate CA at managed startup#8668
cv merged 2 commits into
mainfrom
fix/8650-corporate-ca-symlink

Conversation

@Dongni-Yang

@Dongni-Yang Dongni-Yang commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

The managed startup entrypoints tested the baked corporate CA path with [ -s ] and then read it with cat. Both follow symlinks, so replacing /usr/local/share/nemoclaw/corporate-ca.pem with a symlink made startup merge the symlink target into the runtime trust bundle and report success. After this change, startup rejects a symlink at that path before any read and exits non-zero with a diagnostic that names the path only.

Related Issue

Closes #8650

Changes

  • scripts/nemoclaw-start.sh and agents/hermes/start.sh: reject a symlinked corporate CA in merge_corporate_proxy_ca before the first read.
  • test/corporate-ca-runtime-merge.test.ts: one case per entrypoint that plants a symlink to a non-certificate file, asserts the non-zero exit and the diagnostic, and asserts no merged bundle is produced.

This adds no abstraction, configuration, fallback, or compatibility layer. It adds one guard on one path.

Why this one fails closed

Every other failure in merge_corporate_proxy_ca warns and returns, leaving OpenShell-only trust intact. That is correct for those cases: a failed temp-file creation or a failed chmod means no corporate anchor is added, which is safe. A symlink at the baked path is different in kind. The image writes that path as a root-owned 0444 regular file (Dockerfile:673-674), so a symlink there is never a legitimate state, and continuing would merge attacker-chosen bytes into the bundle that curl, python, git, and node verify against. Failing closed cannot break a healthy sandbox, because a healthy sandbox never has a symlink there.

The merged-bundle output path already carried the equivalent guard, so this restores symmetry between the two ends of the same function.

Already the documented contract

docs/security/configure-corporate-ca-trust.mdx states: "Every imported source must be a regular, readable, non-symlink PEM file that is not group-writable or world-writable." The runtime did not enforce the non-symlink half. The TypeScript managed-startup reader already enforces it by opening with O_NOFOLLOW (src/lib/onboard/managed-startup/image-runtime.ts); only the two shell entrypoints were out of compliance.

Type of Change

  • Code change (feature, bug fix, or refactor)
  • Code change with doc updates
  • Doc only (prose changes, no code sample modifications)
  • Doc only (includes code sample changes)

Quality Gates

  • Tests added or updated for changed behavior
  • Existing tests cover changed behavior — justification:
  • Tests not applicable — justification:
  • Docs updated for user-facing behavior changes
  • Docs not applicable — justification: docs/security/configure-corporate-ca-trust.mdx already documents the non-symlink requirement this change enforces. No page documented the previous permissive behavior.
  • Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging)
  • Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: Requested. This changes sandbox trust-anchor handling and a contributor cannot self-approve it.
  • Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue:

Documentation Writer Review

  • Documentation writer subagent reviewed the completed changes
  • Result: no-docs-needed
  • Evidence: No documentation path changed. Reviewed docs/security/configure-corporate-ca-trust.mdx and docs/reference/troubleshooting.mdx. The corporate CA page already requires a regular, non-symlink PEM source, so this change makes the runtime match the published contract rather than altering it.
  • Agent: Claude Code

Verification

  • PR description includes a Signed-off-by: line and every commit appears as Verified in GitHub
  • Normal pre-commit, commit-msg, and pre-push hooks passed, or npm run validate:pr passed after refreshing origin/main when hooks were skipped or unavailable
  • Targeted behavior tests pass for the current change set, or tests are marked not applicable above — command/result or justification: npx vitest run --project integration test/corporate-ca-runtime-merge.test.ts — 12 passed. Both new cases fail on the unfixed scripts (expected [Function] to throw an error) and pass after. The seven suites that source these entrypoints pass together: 63 passed, 3 skipped.
  • Applicable broad gate passed — npm test for broad runtime/test-harness changes; npm run check for repo-wide validation/coverage changes — command/result:
  • Quality Gates section completed with required justifications or waivers
  • No secrets, API keys, or credentials committed
  • npm run docs builds without warnings (doc changes only)
  • Doc pages follow the style guide (doc changes only)
  • New doc pages include SPDX header and frontmatter (new pages only)

Notes for reviewers

Shell gates: shfmt -i 2 -ci -bn -d clean on both scripts. shellcheck output is byte-identical before and after this change (7 findings both ways, all pre-existing and outside the changed region) — verified by re-running against the stashed tree.

npm run checks:repository passes, including the source-shape test budget and the test file size budget.

I verified the defect by execution rather than by reading: the two new cases are red on unmodified main because the merge currently succeeds on a symlinked source, and green after. No sandbox build or GPU is needed, because test/corporate-ca-runtime-merge.test.ts slices the real merge block out of both entrypoints and runs it under bash.

The reproduction in #8650 used --agent openclaw, which routes to scripts/nemoclaw-start.sh. While confirming that, I found agents/hermes/start.sh carried the identical [ -s ] + cat pattern, so both are fixed and both are covered. agents/langchain-deepagents-code/start.sh has no corporate CA merge and needed no change.

Signed-off-by: Dongni Yang dongniy@nvidia.com

Summary by CodeRabbit

  • Bug Fixes
    • Corporate certificate configuration now rejects symbolic links and non-regular files before processing.
    • Certificate paths swapped to symbolic links during processing are also rejected, preventing unintended certificate data from being read or merged.
    • Regular certificate files continue to be processed as expected.
    • Unsafe certificate paths now stop startup, while ordinary certificate read failures retain warning-and-continue behavior.
    • Improved diagnostics are provided when an unsafe certificate path is detected.

The managed startup entrypoints tested the baked corporate CA path with
`[ -s ]` and then read it with `cat`. Both follow symlinks, so replacing
/usr/local/share/nemoclaw/corporate-ca.pem with a symlink made startup merge the
symlink target into the runtime trust bundle and report success. Untrusted
trust-anchor content reached the bundle that curl, python, git, and node then
verify against.

The image bakes that path as a root-owned 0444 regular file, so a symlink there
is never a legitimate state. Reject it before any read. This fails closed
instead of warning, unlike the recoverable merge failures below it, because
continuing would consume attacker-chosen bytes rather than fall back to
OpenShell-only trust.

The documented contract already required this: "Every imported source must be a
regular, readable, non-symlink PEM file that is not group-writable or
world-writable." The runtime did not enforce it. Both the OpenClaw and Hermes
entrypoints carried the defect; the merged-bundle output path already had the
equivalent guard.

Closes #8650

Signed-off-by: Dongni Yang <dongniy@nvidia.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Both startup scripts now reject symlinked or non-regular corporate CA paths. Descriptor-based reads prevent symlink replacement during merging. Parameterized tests cover OpenClaw and Hermes, including diagnostics and merged-bundle absence.

Changes

Corporate CA merge protection

Layer / File(s) Summary
Secure corporate CA merging
agents/hermes/start.sh, scripts/nemoclaw-start.sh
Both startup scripts fail closed for symlinked or non-regular corporate CA paths. Descriptor-based O_NOFOLLOW reads prevent symlink traversal during merging. Ordinary read failures retain warning-and-fallback behavior.
Validate symlink rejection
test/corporate-ca-runtime-merge.test.ts
Parameterized tests cover existing symlinks and file replacement during validation. Tests verify unsuccessful startup, safe diagnostics, and no merged bundle.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested labels: area: sandbox

Suggested reviewers: cv

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: rejecting symlinked corporate CA files during sandbox startup.
Linked Issues check ✅ Passed The changes satisfy issue #8650 by rejecting symlinked CA paths, preventing target reads, exiting non-zero, and avoiding untrusted bundle creation.
Out of Scope Changes check ✅ Passed The changes remain within scope by securing both managed startup entrypoints and adding focused regression tests for the linked issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/8650-corporate-ca-symlink

Comment @coderabbitai help to get the list of available commands.

@github-code-quality

github-code-quality Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: TypeScript

TypeScript / code-coverage/plugin

The overall coverage in commit c859dc4 in the fix/8650-corporate-c... branch remains at 96%, unchanged from commit ea39cc4 in the main branch.


Updated August 10, 2026 01:30 UTC

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/nemoclaw-start.sh`:
- Around line 3196-3199: Replace the separate symlink check and later read in
scripts/nemoclaw-start.sh:3196-3199 and agents/hermes/start.sh:1672-1675 with a
no-follow file-descriptor read or equivalent atomic validation/read operation,
preserving rejection of symlinks and preventing replacement races. In
test/corporate-ca-runtime-merge.test.ts:58-81, add a deterministic
replacement-after-validation test that verifies the merged CA bundle is not
created.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 2258fe0f-d8d8-489a-bc34-eb0c56f224d2

📥 Commits

Reviewing files that changed from the base of the PR and between ea39cc4 and 894f586.

📒 Files selected for processing (3)
  • agents/hermes/start.sh
  • scripts/nemoclaw-start.sh
  • test/corporate-ca-runtime-merge.test.ts

Comment thread scripts/nemoclaw-start.sh
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

PR Review Advisor — No blocking findings reported

Advisor assessment: No blocking advisor findings reported
Next action: No advisor follow-up needed.
Findings: 0 blockers · 0 warnings · 0 suggestions

Model lanes

  • GPT-5.6 Terra (primary): Completed · high confidence · 0 blockers · 0 warnings · 0 suggestions
  • Nemotron 3 Ultra (second opinion): Completed · high confidence · 0 blockers · 0 warnings · 0 suggestions
  • Model comparison: normalized findings match; normalized terminology decisions differ; normalized E2E selections match; severity counts match.
2 terminology differences from the second opinion

Advisory only. These are normalized differences from the primary terminology receipt.

  • O_NOFOLLOW at agents/hermes/start.sh:1717: selected only by the second-opinion lane as established.
  • trust anchor at agents/hermes/start.sh:1667: selected only by the second-opinion lane as established.

Second-opinion terminology and E2E selections are advisory. Live E2E does not run automatically for pull requests.

2 semantic terminology decisions

Terminology decisions are advisory. They affect the assessment only when a separate finding identifies concrete semantic impact.

  • define — trust-anchor tampering at agents/hermes/start.sh:1667: Define the term at first use through the existing comment's description of the symlink substitution.
  • established — OpenShell-only trust at agents/hermes/start.sh:1672: Keep the established term for the fallback state.

E2E guidance

Advisory only. A maintainer can dispatch the default E2E suite against this exact revision.

Recommended E2E: full-e2e, hermes-e2e, hermes-inference-switch, managed-image-multiarch-startup, security-posture, bedrock-runtime-compatible-anthropic, channels-stop-start, dashboard-remote-bind, hermes-shields-config

Workflow run details

This automated review informs maintainers. Warnings and suggestions do not require a response. A maintainer decides whether to merge.

The path check added for #8650 rejects a symlink planted before startup, but it
resolves the path a second time when `cat` reads the file. A process that
replaces the pathname between the two operations redirects the read through a
new symlink, so attacker-selected bytes still reach the trust bundle.

Append through a descriptor opened with O_NOFOLLOW and verified as a regular
file. The type check and the read now share one descriptor, so no second path
resolution exists to race. This matches how the TypeScript managed-startup
reader already opens managed files.

An ordinary read failure keeps the existing warn-and-continue behavior; only a
rejected trust anchor fails closed, so a permission or I/O error still falls
back to OpenShell-only trust rather than stopping the sandbox.

Refs #8650

Signed-off-by: Dongni Yang <dongniy@nvidia.com>
@Dongni-Yang

Copy link
Copy Markdown
Contributor Author

PRA-1 is valid and is now fixed in c859dc4.

The -L check resolved the pathname, and cat resolved it again, so a process that replaced the pathname between them redirected the read. The check rejected a symlink planted before startup but not one swapped in during it.

The corporate CA is now appended through a descriptor opened with O_NOFOLLOW and verified as a regular file with fstat, so the type check and the read share one descriptor and there is no second path resolution to race. This is the same primitive readStableRegularFileSnapshot uses in src/lib/onboard/managed-startup/image-runtime.ts:771-779, which the finding cited. Both entrypoints changed.

Failure handling stays split so this does not widen what stops a sandbox:

  • ELOOP, or a descriptor that is not a regular file, is a rejected trust anchor and fails closed.
  • Any other read error keeps the existing warn-and-continue behavior, so a permission or I/O problem still falls back to OpenShell-only trust.

Test coverage is the extracted-entrypoint test the finding asked for. It swaps the CA file for a symlink immediately after the path check, so only the nofollow read can still reject it, and asserts a non-zero exit, that the diagnostic never echoes the link target's contents, and that no merged bundle is produced.

That test is red on the previous commit and green on this one, which isolates the new defense: 894f586 already contained the -L check, and against it the two swap cases fail while the two planted-symlink cases pass.

$ git stash push -- scripts/nemoclaw-start.sh agents/hermes/start.sh
$ npx vitest run --project integration test/corporate-ca-runtime-merge.test.ts -t 8650
  × rejects a corporate CA swapped to a symlink after the path check for 'OpenClaw' (#8650)
  × rejects a corporate CA swapped to a symlink after the path check for 'Hermes' (#8650)
  Tests  2 failed | 2 passed | 10 skipped (14)

With the fix restored, the file is 14 passed, and the 7 suites that source these entrypoints are 65 passed / 3 skipped. shfmt -i 2 -ci -bn -d reports no diff. shellcheck reports the same 7 pre-existing findings before and after, so this introduces none. npm run checks:repository passes.

I also took the define — trust-anchor tampering terminology note: the comment on both guards now names the symlink replacement and states that the link target's bytes would otherwise reach the trust bundle that curl, python, git, and node verify against.

Still unverified by me: I reproduced this only at the shell-block level through the existing harness that slices the real merge_corporate_proxy_ca block out of both entrypoints. I have not reproduced the race in a live container, and the sensitive-path review for sandbox trust-anchor handling still needs a maintainer.

Signed-off-by: Dongni Yang dongniy@nvidia.com

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
test/corporate-ca-runtime-merge.test.ts (1)

106-109: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the swap injection fail loudly if the anchor line changes.

.replace() returns the input unchanged when the literal [ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0 is not found. Any reformatting of that line in either script turns the race setup into a no-op. The test then exercises the ordinary success path instead of the swap.

Assert that the replacement applied.

♻️ Proposed guard
+    const anchor = `[ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0`;
+    const block = mergeBlock(script, end, corp, merged);
+    expect(block).toContain(anchor);
-    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 raced = block.replace(anchor, `${anchor}\n${swap}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/corporate-ca-runtime-merge.test.ts` around lines 106 - 109, Update the
swap injection in the test around mergeBlock so it verifies that the anchor
replacement actually occurred. Assert that the result differs from the original
merged script, causing the test to fail loudly when the corporate CA anchor line
changes or is absent.

Source: Path instructions

scripts/nemoclaw-start.sh (1)

3241-3270: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

The corporate CA merge logic is now duplicated verbatim across both entrypoints. merge_corporate_proxy_ca is byte-identical in both scripts, and this PR adds about 30 more duplicated lines, including a security-critical embedded Python program. Any future correction to the trust-anchor check must be applied twice, and a partial fix would leave one entrypoint weaker than the other. The ELOOP-only errno mapping is one concrete example that would need editing in both copies.

  • scripts/nemoclaw-start.sh#L3241-L3270: extract the embedded Python appender into a shared file, for example scripts/lib/append-corporate-ca.py, and invoke it with python3 -I <path> "$_NEMOCLAW_CORPORATE_CA_FILE" "$_tmp".
  • agents/hermes/start.sh#L1724-L1746: invoke the same shared file instead of carrying a second copy of the program.

If the two entrypoints cannot share a file because they are baked into separate images, add a check that fails CI when the two merge_corporate_proxy_ca bodies diverge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/nemoclaw-start.sh` around lines 3241 - 3270, The corporate CA
appender is duplicated across both entrypoints. Extract the embedded Python
program from scripts/nemoclaw-start.sh lines 3241-3270 into a shared
scripts/lib/append-corporate-ca.py and invoke it with python3 -I and the
source/target arguments; update agents/hermes/start.sh lines 1724-1746 to use
the same file instead of embedding its own copy. If sharing is impossible, add
CI validation that both merge_corporate_proxy_ca bodies remain identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@scripts/nemoclaw-start.sh`:
- Around line 3241-3270: The corporate CA appender is duplicated across both
entrypoints. Extract the embedded Python program from scripts/nemoclaw-start.sh
lines 3241-3270 into a shared scripts/lib/append-corporate-ca.py and invoke it
with python3 -I and the source/target arguments; update agents/hermes/start.sh
lines 1724-1746 to use the same file instead of embedding its own copy. If
sharing is impossible, add CI validation that both merge_corporate_proxy_ca
bodies remain identical.

In `@test/corporate-ca-runtime-merge.test.ts`:
- Around line 106-109: Update the swap injection in the test around mergeBlock
so it verifies that the anchor replacement actually occurred. Assert that the
result differs from the original merged script, causing the test to fail loudly
when the corporate CA anchor line changes or is absent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d3dd28e8-0682-436c-95c7-7551071c1752

📥 Commits

Reviewing files that changed from the base of the PR and between 894f586 and c859dc4.

📒 Files selected for processing (3)
  • agents/hermes/start.sh
  • scripts/nemoclaw-start.sh
  • test/corporate-ca-runtime-merge.test.ts

@cv
cv merged commit 352680c into main Aug 10, 2026
84 of 91 checks passed
@cv
cv deleted the fix/8650-corporate-ca-symlink branch August 10, 2026 02:31
@wscurran wscurran added bug-fix PR fixes a bug or regression area: sandbox OpenShell sandbox lifecycle, runtime, config, or recovery area: security Security controls, permissions, secrets, or hardening security labels Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: sandbox OpenShell sandbox lifecycle, runtime, config, or recovery area: security Security controls, permissions, secrets, or hardening bug-fix PR fixes a bug or regression security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Ubuntu 24.04][Security] Corporate CA symlink is merged into sandbox trust bundle instead of rejected

3 participants