fix(security): validate symlinks in snapshot create and rollback paths#2436
Conversation
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>
📝 WalkthroughWalkthroughAdds 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
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
nemoclaw/src/blueprint/snapshot.ts (1)
119-128: Expose the newsymlinksfield in the typed manifest API.
createSnapshot()now persistsmanifest.symlinks, but the exported manifest shape in this file still omits it andlistSnapshots()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
📒 Files selected for processing (2)
nemoclaw/src/blueprint/snapshot.test.tsnemoclaw/src/blueprint/snapshot.ts
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>
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
nemoclaw/src/blueprint/snapshot.test.tsnemoclaw/src/blueprint/snapshot.tssrc/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
There was a problem hiding this comment.
🧹 Nitpick comments (2)
nemoclaw/src/blueprint/snapshot.ts (2)
119-128: Prefer a typed manifest object overRecord<string, unknown>.Using
Record<string, unknown>weakens schema guarantees right where the manifest is written. A narrow type with optionalsymlinkskeeps 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 fromsrc/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
📒 Files selected for processing (2)
nemoclaw/src/blueprint/snapshot.tssrc/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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/lib/sandbox-state.ts (1)
174-207: Consider consolidating withconfig-io.tsimplementation.This function duplicates
rejectSymlinksOnPath()fromsrc/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.tsversion 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
📒 Files selected for processing (2)
nemoclaw-blueprint/scripts/ws-proxy-fix.jssrc/lib/sandbox-state.ts
✅ Files skipped from review due to trivial changes (1)
- nemoclaw-blueprint/scripts/ws-proxy-fix.js
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>
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>
Summary
lstatSync-based symlink rejection tocreateSnapshot()androllbackFromSnapshot()innemoclaw/src/blueprint/snapshot.ts, closing two attack surfaces reported by an external contributor doing Yocto integration:createSnapshot()callscpSync(OPENCLAW_DIR, dest)with nolstatSynccheck on the source — if~/.openclawis a symlink (e.g. to/etc),cpSyncfollows it and copies arbitrary files into the snapshotrollbackFromSnapshot()callscpSync(source, OPENCLAW_DIR)without verifying the destination isn't a symlink — an attacker-planted symlink could redirect restored content to an arbitrary directorycollectFiles()to detect symlinks viaisSymbolicLink()and record them in the snapshot manifest (previously symlinks were silently omitted)rejectSymlinksOnPath()pattern established in PR fix(security): reject symlinks on config directory path #2290 for the~/.nemoclawcredentials directorysrc/lib/sandbox-state.ts::backupSandboxState()) thatnemoclaw <sandbox> snapshot createactually uses — the blueprint helper is a separate implementation not called from the CLIrollbackFromSnapshot()symlink rejection returnsfalse(preserving the boolean-return contract) rather than throwingSeverity
Low-Medium. Both attacks require the attacker to already have user-level write access to
$HOMEto 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
createSnapshotrejects when~/.openclawis a symlinkcreateSnapshotrejects when an ancestor of~/.nemoclawis a symlinkcreateSnapshotrecords symlinks in manifest when present in treerollbackFromSnapshotreturns false (not throws) when~/.openclawis a symlinktsc --noEmit)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests