Skip to content

fix(security): validate symlinks in snapshot create and rollback paths#2436

Merged
cv merged 5 commits into
mainfrom
fix/snapshot-symlink-validation
Apr 24, 2026
Merged

fix(security): validate symlinks in snapshot create and rollback paths#2436
cv merged 5 commits into
mainfrom
fix/snapshot-symlink-validation

Conversation

@ericksoa

@ericksoa ericksoa commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds lstatSync-based symlink rejection to createSnapshot() and rollbackFromSnapshot() in nemoclaw/src/blueprint/snapshot.ts, closing two attack surfaces reported by an external contributor doing Yocto integration:
    1. createSnapshot() calls cpSync(OPENCLAW_DIR, dest) with no lstatSync check on the source — if ~/.openclaw is a symlink (e.g. to /etc), cpSync follows it and copies arbitrary files into the snapshot
    2. rollbackFromSnapshot() calls cpSync(source, OPENCLAW_DIR) without verifying the destination isn't a symlink — an attacker-planted symlink could redirect restored content to an arbitrary directory
  • Updates collectFiles() to detect symlinks via isSymbolicLink() and record them in the snapshot manifest (previously symlinks were silently omitted)
  • Follows the rejectSymlinksOnPath() pattern established in PR fix(security): reject symlinks on config directory path #2290 for the ~/.nemoclaw credentials directory
  • Also guards the CLI code path (src/lib/sandbox-state.ts::backupSandboxState()) that nemoclaw <sandbox> snapshot create actually uses — the blueprint helper is a separate implementation not called from the CLI
  • rollbackFromSnapshot() symlink rejection returns false (preserving the boolean-return contract) rather than throwing

Severity

Low-Medium. Both attacks require the attacker to already have user-level write access to $HOME to plant the symlink, and the sandbox's Landlock / chattr +i / read-only mount protections prevent in-sandbox exploitation. However, this is the same symlink attack class fixed in #2290 (different code paths: cpSync + directory walker vs. mkdirSync), and defense-in-depth warrants closing the gap.

Test plan

  • New test: createSnapshot rejects when ~/.openclaw is a symlink
  • New test: createSnapshot rejects when an ancestor of ~/.nemoclaw is a symlink
  • New test: createSnapshot records symlinks in manifest when present in tree
  • New test: rollbackFromSnapshot returns false (not throws) when ~/.openclaw is a symlink
  • All existing snapshot tests pass (no regressions)
  • Full plugin test suite: 362 passed
  • CLI test suite: 1959/1961 passed (2 pre-existing flaky installer timeouts)
  • TypeScript build clean (tsc --noEmit)
  • All pre-commit and pre-push linters/formatters pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Snapshots now detect and record symbolic links in the snapshot manifest and refuse snapshot creation or restoration when critical config directories or their ancestor paths are symlinks.
    • Backup destination paths are validated to prevent writing into locations with symlinked ancestor paths.
  • Tests

    • Added tests covering symlink detection, manifest recording, rejection scenarios, and rollback behavior when config paths are symlinks.

Add lstatSync-based symlink rejection to createSnapshot() and
rollbackFromSnapshot(), closing two attack surfaces where cpSync would
follow a symlinked ~/.openclaw or write restored content through one.

Also update collectFiles() to detect symlinks via isSymbolicLink() and
record them in the snapshot manifest for visibility.

Same attack class as the ~/.nemoclaw credential-dir fix in PR #2290,
different code paths (cpSync + directory walker vs mkdirSync).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds symlink-attack hardening and symlink-aware snapshot/backup behavior: path validation rejecting symlink ancestors, collection and recording of symlinks in snapshots, tests for symlink scenarios, and guards in backup/snapshot operations to fail when ancestry contains symlinks.

Changes

Cohort / File(s) Summary
Snapshot tests
nemoclaw/src/blueprint/snapshot.test.ts
Extend in-memory fs mock to model symlinks (symlink, target); add lstatSync, readlinkSync, and readdirSync withFileTypes behavior; add tests asserting createSnapshot/rollbackFromSnapshot behavior when symlinks exist and that snapshot.json includes a symlinks list.
Snapshot logic
nemoclaw/src/blueprint/snapshot.ts
Add rejectSymlinksOnPath to validate source and snapshot destination ancestry; update tree walk to return files and symlinks; produce snapshot.json with files/file_count and optional symlinks; validate target path during rollback.
Sandbox backup
src/lib/sandbox-state.ts
Introduce resolved HOME_DIR usage and rejectSymlinksOnPath(); invoke guard before and after backup directory creation to prevent backups when any ancestor is a symlink.
Comment-only
nemoclaw-blueprint/scripts/ws-proxy-fix.js
Clarify inline comment about WebSocket upgrade host handling; no behavioral changes.

Sequence Diagram(s)

sequenceDiagram
  participant Caller as "Caller"
  participant Snapshot as "snapshot.ts\n(createSnapshot / rollback)"
  participant FS as "Filesystem\n(realpath, lstat, readlink, readdir, copy)"
  participant Storage as "Snapshot Dir / Backup"

  Caller->>Snapshot: request createSnapshot()
  Snapshot->>FS: realpath(home), resolve target path
  Snapshot->>FS: lstat ancestors up to $HOME (reject on symlink)
  alt ancestors clean
    Snapshot->>FS: walk tree -> collect files + symlinks
    Snapshot->>Storage: validate snapshot dir ancestors (reject symlink)
    Snapshot->>FS: copy files -> Storage
    Snapshot->>Storage: write snapshot.json (includes files, optional symlinks)
    Snapshot-->>Caller: success
  else symlink found
    FS-->>Snapshot: symlink ancestor detected
    Snapshot-->>Caller: throw / fail
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

I nibble paths with careful paws,
I sniff each link and mark its cause.
No sneaky loop can trick my trail,
I guard the snapshot without fail. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: adding symlink validation to snapshot create and rollback paths for security hardening.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/snapshot-symlink-validation

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

@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

🧹 Nitpick comments (1)
nemoclaw/src/blueprint/snapshot.ts (1)

119-128: Expose the new symlinks field in the typed manifest API.

createSnapshot() now persists manifest.symlinks, but the exported manifest shape in this file still omits it and listSnapshots() drops it on read. That leaves the TypeScript contract behind the on-disk schema and hides the new security-relevant metadata from callers.

Suggested fix
 export interface BlueprintSnapshotManifest {
   timestamp: string;
   source: string;
   file_count: number;
   contents: string[];
+  symlinks?: string[];
   path: string;
 }
       snapshots.push({
         timestamp: obj.timestamp,
         source: typeof obj.source === "string" ? obj.source : "",
         file_count: typeof obj.file_count === "number" ? obj.file_count : 0,
         contents: Array.isArray(obj.contents) ? (obj.contents as string[]) : [],
+        symlinks: Array.isArray(obj.symlinks) ? (obj.symlinks as string[]) : undefined,
         path: snapDir,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/blueprint/snapshot.ts` around lines 119 - 128, The manifest type
exported from this module must include the new symlinks field so on-disk
manifests (created by createSnapshot()) match the TypeScript contract and
listSnapshots() preserves that data; update the exported manifest/interface
shape to add symlinks?: Array<...> (same element type used by
collectFiles/manifest contents), update createSnapshot()'s manifest construction
to satisfy the typed shape (it already writes manifest.symlinks) and update
listSnapshots() (the reader/JSON-to-typed mapping) to read and pass through
manifest.symlinks instead of omitting it so callers receive the
security-relevant metadata.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@nemoclaw/src/blueprint/snapshot.ts`:
- Around line 226-229: rollbackFromSnapshot currently calls
rejectSymlinksOnPath(OPENCLAW_DIR) outside its try/catch, so a symlink check
throws an exception instead of returning false like other failure paths; fix by
invoking rejectSymlinksOnPath inside the existing try block (or wrap that call
with its own try/catch that returns false on failure) so any error from
rejectSymlinksOnPath is caught and rollbackFromSnapshot preserves its
boolean-false failure contract; reference function name: rollbackFromSnapshot
and helper rejectSymlinksOnPath to locate the change.

---

Nitpick comments:
In `@nemoclaw/src/blueprint/snapshot.ts`:
- Around line 119-128: The manifest type exported from this module must include
the new symlinks field so on-disk manifests (created by createSnapshot()) match
the TypeScript contract and listSnapshots() preserves that data; update the
exported manifest/interface shape to add symlinks?: Array<...> (same element
type used by collectFiles/manifest contents), update createSnapshot()'s manifest
construction to satisfy the typed shape (it already writes manifest.symlinks)
and update listSnapshots() (the reader/JSON-to-typed mapping) to read and pass
through manifest.symlinks instead of omitting it so callers receive the
security-relevant metadata.
🪄 Autofix (Beta)

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: c93d6103-7358-4814-9b3a-9ee96eaa03f2

📥 Commits

Reviewing files that changed from the base of the PR and between 99b72c4 and fcdc3dd.

📒 Files selected for processing (2)
  • nemoclaw/src/blueprint/snapshot.test.ts
  • nemoclaw/src/blueprint/snapshot.ts

Comment thread nemoclaw/src/blueprint/snapshot.ts Outdated
Address two issues from PR review:

1. Move rejectSymlinksOnPath() inside rollbackFromSnapshot()'s try/catch
   so it returns false (preserving the boolean contract) instead of
   throwing an unhandled exception.

2. Add rejectSymlinksOnPath() to sandbox-state.ts backupSandboxState()
   — the code path the CLI `nemoclaw <sandbox> snapshot create` actually
   uses. The prior commit only guarded the blueprint helper, which is
   not called from the CLI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/sandbox-state.ts`:
- Around line 138-143: The symlink validation in rejectSymlinksOnPath uses
process.env.HOME || os.homedir(), while REBUILD_BACKUPS_DIR uses
process.env.HOME || "/tmp", which can let validation be skipped when HOME is
unset; fix by unifying the HOME fallback and reusing the same value everywhere
(e.g., compute a single HOME_BASE = process.env.HOME || "/tmp" or export/reuse
the existing REBUILD_BACKUPS_DIR's base fallback) and update
rejectSymlinksOnPath to use that same HOME_BASE (or reference the
REBUILD_BACKUPS_DIR-derived path) so the symlink guard and backup dir logic
operate against the identical resolved home path.
🪄 Autofix (Beta)

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: 9b37a50a-52cf-4b05-a00d-442108303610

📥 Commits

Reviewing files that changed from the base of the PR and between fcdc3dd and b10b34e.

📒 Files selected for processing (3)
  • nemoclaw/src/blueprint/snapshot.test.ts
  • nemoclaw/src/blueprint/snapshot.ts
  • src/lib/sandbox-state.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • nemoclaw/src/blueprint/snapshot.test.ts
  • nemoclaw/src/blueprint/snapshot.ts

Comment thread src/lib/sandbox-state.ts Outdated
Comment thread src/lib/sandbox-state.ts

@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)
nemoclaw/src/blueprint/snapshot.ts (2)

119-128: Prefer a typed manifest object over Record<string, unknown>.

Using Record<string, unknown> weakens schema guarantees right where the manifest is written. A narrow type with optional symlinks keeps this safer and easier to evolve.

Suggested typing refinement
+type SnapshotWriteManifest = {
+  timestamp: string;
+  source: string;
+  file_count: number;
+  contents: string[];
+  symlinks?: string[];
+};

-  const manifest: Record<string, unknown> = {
+  const manifest: SnapshotWriteManifest = {
     timestamp,
     source: OPENCLAW_DIR,
     file_count: files.length,
     contents: files,
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/blueprint/snapshot.ts` around lines 119 - 128, The manifest is
currently typed as Record<string, unknown>, weakening schema guarantees; define
a concrete Manifest interface (e.g., interface Manifest { timestamp: number;
source: string; file_count: number; contents: Array<YourFileType>; symlinks?:
Array<YourSymlinkType> }) and replace the manifest variable's type with
Manifest, using the actual types returned by collectFiles for files and
symlinks; keep the existing conditional assignment (if (symlinks.length > 0)
manifest.symlinks = symlinks) so symlinks remains optional and use OPENCLAW_DIR
and timestamp values as before.

44-76: Consider centralizing symlink-path validation to avoid security drift.

rejectSymlinksOnPath() here is effectively duplicated from src/lib/config-io.ts (same attack class, similar traversal). A shared utility would reduce divergence risk for future hardening changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/blueprint/snapshot.ts` around lines 44 - 76, The
rejectSymlinksOnPath implementation is duplicated and should be centralized:
extract the logic currently in rejectSymlinksOnPath into a single exported
utility (e.g., export function rejectSymlinksOnPath from a new shared module in
lib, keeping the same signature and error message/behavior), update this file to
import and call that shared utility instead of its local copy, and update
src/lib/config-io.ts to consume the same exported function; ensure you preserve
the exact traversal, ENOENT handling, and thrown Error message so existing
behavior/tests remain unchanged and add/adjust unit tests to cover the shared
utility.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@nemoclaw/src/blueprint/snapshot.ts`:
- Around line 119-128: The manifest is currently typed as Record<string,
unknown>, weakening schema guarantees; define a concrete Manifest interface
(e.g., interface Manifest { timestamp: number; source: string; file_count:
number; contents: Array<YourFileType>; symlinks?: Array<YourSymlinkType> }) and
replace the manifest variable's type with Manifest, using the actual types
returned by collectFiles for files and symlinks; keep the existing conditional
assignment (if (symlinks.length > 0) manifest.symlinks = symlinks) so symlinks
remains optional and use OPENCLAW_DIR and timestamp values as before.
- Around line 44-76: The rejectSymlinksOnPath implementation is duplicated and
should be centralized: extract the logic currently in rejectSymlinksOnPath into
a single exported utility (e.g., export function rejectSymlinksOnPath from a new
shared module in lib, keeping the same signature and error message/behavior),
update this file to import and call that shared utility instead of its local
copy, and update src/lib/config-io.ts to consume the same exported function;
ensure you preserve the exact traversal, ENOENT handling, and thrown Error
message so existing behavior/tests remain unchanged and add/adjust unit tests to
cover the shared utility.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 6a112f1b-bbe5-4980-be70-5a7aad740f67

📥 Commits

Reviewing files that changed from the base of the PR and between b10b34e and 693d0d3.

📒 Files selected for processing (2)
  • nemoclaw/src/blueprint/snapshot.ts
  • src/lib/sandbox-state.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/sandbox-state.ts

Address remaining CodeRabbit review comments:

1. Extract shared HOME_DIR constant so REBUILD_BACKUPS_DIR and
   rejectSymlinksOnPath use the same fallback (os.homedir instead of
   "/tmp"). Previously, if $HOME was unset, backup writes landed under
   /tmp while the symlink guard scoped to os.homedir(), silently
   skipping validation.

2. Re-run rejectSymlinksOnPath immediately after mkdirSync to narrow
   the TOCTOU race window between the pre-check and directory creation.

@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 (1)
src/lib/sandbox-state.ts (1)

174-207: Consider consolidating with config-io.ts implementation.

This function duplicates rejectSymlinksOnPath() from src/lib/config-io.ts (see context snippet lines 107-140). The implementations differ slightly in error messaging and type checking approach. Consolidating into a shared utility (e.g., src/lib/symlink-guard.ts) would:

  • Reduce maintenance burden when the pattern needs updates
  • Ensure consistent error messages across CLI and blueprint paths

The config-io.ts version also includes a helpful shell command in the error message (rm ${shellQuote(current)}), which could benefit users here as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/sandbox-state.ts` around lines 174 - 207, The two implementations of
rejectSymlinksOnPath (in sandbox-state.ts and config-io.ts) should be
consolidated into a single exported helper (e.g., create
src/lib/symlink-guard.ts with an exported function rejectSymlinksOnPath) that
contains the canonical logic: resolve path, bail out if outside $HOME, iterate
ancestors using lstatSync/readlinkSync, treat ENOENT as non-fatal, and throw a
consistent Error message that includes the linked target and a suggested shell
fix (e.g., `rm ${shellQuote(current)}`) as used in config-io.ts; then replace
the local functions in sandbox-state.ts and config-io.ts with imports of the new
helper so both use identical behavior and error text while preserving the
NodeJS.ErrnoException code check and types.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/lib/sandbox-state.ts`:
- Around line 174-207: The two implementations of rejectSymlinksOnPath (in
sandbox-state.ts and config-io.ts) should be consolidated into a single exported
helper (e.g., create src/lib/symlink-guard.ts with an exported function
rejectSymlinksOnPath) that contains the canonical logic: resolve path, bail out
if outside $HOME, iterate ancestors using lstatSync/readlinkSync, treat ENOENT
as non-fatal, and throw a consistent Error message that includes the linked
target and a suggested shell fix (e.g., `rm ${shellQuote(current)}`) as used in
config-io.ts; then replace the local functions in sandbox-state.ts and
config-io.ts with imports of the new helper so both use identical behavior and
error text while preserving the NodeJS.ErrnoException code check and types.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 752391c7-6af4-4348-b791-3a75d96d458b

📥 Commits

Reviewing files that changed from the base of the PR and between 693d0d3 and e1f67da.

📒 Files selected for processing (2)
  • nemoclaw-blueprint/scripts/ws-proxy-fix.js
  • src/lib/sandbox-state.ts
✅ Files skipped from review due to trivial changes (1)
  • nemoclaw-blueprint/scripts/ws-proxy-fix.js

@cv cv merged commit 8866f34 into main Apr 24, 2026
16 checks passed
DemianHeyGen pushed a commit to DemianHeyGen/NemoClaw that referenced this pull request Apr 30, 2026
NVIDIA#2436)

## Summary

- Adds `lstatSync`-based symlink rejection to `createSnapshot()` and
`rollbackFromSnapshot()` in `nemoclaw/src/blueprint/snapshot.ts`,
closing two attack surfaces reported by an external contributor doing
Yocto integration:
1. **`createSnapshot()`** calls `cpSync(OPENCLAW_DIR, dest)` with no
`lstatSync` check on the source — if `~/.openclaw` is a symlink (e.g. to
`/etc`), `cpSync` follows it and copies arbitrary files into the
snapshot
2. **`rollbackFromSnapshot()`** calls `cpSync(source, OPENCLAW_DIR)`
without verifying the destination isn't a symlink — an attacker-planted
symlink could redirect restored content to an arbitrary directory
- Updates `collectFiles()` to detect symlinks via `isSymbolicLink()` and
record them in the snapshot manifest (previously symlinks were silently
omitted)
- Follows the `rejectSymlinksOnPath()` pattern established in PR NVIDIA#2290
for the `~/.nemoclaw` credentials directory
- **Also guards the CLI code path**
(`src/lib/sandbox-state.ts::backupSandboxState()`) that `nemoclaw
<sandbox> snapshot create` actually uses — the blueprint helper is a
separate implementation not called from the CLI
- `rollbackFromSnapshot()` symlink rejection returns `false` (preserving
the boolean-return contract) rather than throwing

### Severity

Low-Medium. Both attacks require the attacker to already have user-level
write access to `$HOME` to plant the symlink, and the sandbox's Landlock
/ `chattr +i` / read-only mount protections prevent in-sandbox
exploitation. However, this is the same symlink attack class fixed in
NVIDIA#2290 (different code paths: `cpSync` + directory walker vs.
`mkdirSync`), and defense-in-depth warrants closing the gap.

## Test plan

- [x] New test: `createSnapshot` rejects when `~/.openclaw` is a symlink
- [x] New test: `createSnapshot` rejects when an ancestor of
`~/.nemoclaw` is a symlink
- [x] New test: `createSnapshot` records symlinks in manifest when
present in tree
- [x] New test: `rollbackFromSnapshot` returns false (not throws) when
`~/.openclaw` is a symlink
- [x] All existing snapshot tests pass (no regressions)
- [x] Full plugin test suite: 362 passed
- [x] CLI test suite: 1959/1961 passed (2 pre-existing flaky installer
timeouts)
- [x] TypeScript build clean (`tsc --noEmit`)
- [x] All pre-commit and pre-push linters/formatters pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Snapshots now detect and record symbolic links in the snapshot
manifest and refuse snapshot creation or restoration when critical
config directories or their ancestor paths are symlinks.
* Backup destination paths are validated to prevent writing into
locations with symlinked ancestor paths.

* **Tests**
* Added tests covering symlink detection, manifest recording, rejection
scenarios, and rollback behavior when config paths are symlinks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ksapru pushed a commit to ksapru/NemoClaw that referenced this pull request May 12, 2026
NVIDIA#2436)

## Summary

- Adds `lstatSync`-based symlink rejection to `createSnapshot()` and
`rollbackFromSnapshot()` in `nemoclaw/src/blueprint/snapshot.ts`,
closing two attack surfaces reported by an external contributor doing
Yocto integration:
1. **`createSnapshot()`** calls `cpSync(OPENCLAW_DIR, dest)` with no
`lstatSync` check on the source — if `~/.openclaw` is a symlink (e.g. to
`/etc`), `cpSync` follows it and copies arbitrary files into the
snapshot
2. **`rollbackFromSnapshot()`** calls `cpSync(source, OPENCLAW_DIR)`
without verifying the destination isn't a symlink — an attacker-planted
symlink could redirect restored content to an arbitrary directory
- Updates `collectFiles()` to detect symlinks via `isSymbolicLink()` and
record them in the snapshot manifest (previously symlinks were silently
omitted)
- Follows the `rejectSymlinksOnPath()` pattern established in PR NVIDIA#2290
for the `~/.nemoclaw` credentials directory
- **Also guards the CLI code path**
(`src/lib/sandbox-state.ts::backupSandboxState()`) that `nemoclaw
<sandbox> snapshot create` actually uses — the blueprint helper is a
separate implementation not called from the CLI
- `rollbackFromSnapshot()` symlink rejection returns `false` (preserving
the boolean-return contract) rather than throwing

### Severity

Low-Medium. Both attacks require the attacker to already have user-level
write access to `$HOME` to plant the symlink, and the sandbox's Landlock
/ `chattr +i` / read-only mount protections prevent in-sandbox
exploitation. However, this is the same symlink attack class fixed in
NVIDIA#2290 (different code paths: `cpSync` + directory walker vs.
`mkdirSync`), and defense-in-depth warrants closing the gap.

## Test plan

- [x] New test: `createSnapshot` rejects when `~/.openclaw` is a symlink
- [x] New test: `createSnapshot` rejects when an ancestor of
`~/.nemoclaw` is a symlink
- [x] New test: `createSnapshot` records symlinks in manifest when
present in tree
- [x] New test: `rollbackFromSnapshot` returns false (not throws) when
`~/.openclaw` is a symlink
- [x] All existing snapshot tests pass (no regressions)
- [x] Full plugin test suite: 362 passed
- [x] CLI test suite: 1959/1961 passed (2 pre-existing flaky installer
timeouts)
- [x] TypeScript build clean (`tsc --noEmit`)
- [x] All pre-commit and pre-push linters/formatters pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Snapshots now detect and record symbolic links in the snapshot
manifest and refuse snapshot creation or restoration when critical
config directories or their ancestor paths are symlinks.
* Backup destination paths are validated to prevent writing into
locations with symlinked ancestor paths.

* **Tests**
* Added tests covering symlink detection, manifest recording, rejection
scenarios, and rollback behavior when config paths are symlinks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@wscurran wscurran added the bug-fix PR fixes a bug or regression label Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug-fix PR fixes a bug or regression

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants