Skip to content

fix(security): reject symlinks on ~/.nemoclaw to prevent credential hijack - #1996

Closed
ericksoa wants to merge 2 commits into
mainfrom
fix/symlink-attack-nemoclaw-dir
Closed

fix(security): reject symlinks on ~/.nemoclaw to prevent credential hijack#1996
ericksoa wants to merge 2 commits into
mainfrom
fix/symlink-attack-nemoclaw-dir

Conversation

@ericksoa

@ericksoa ericksoa commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add safeMkdirSync() utility that checks lstat() before mkdirSync() and throws if the target path is a symbolic link
  • Apply to all ~/.nemoclaw directory creation paths across both CLI and plugin (8 vulnerable locations)
  • Also fix remaining process.env.HOME||"/tmp" patterns with os.homedir()

Why

mkdirSync(~/.nemoclaw, { recursive: true }) does not check whether the path already exists as a symlink. An attacker who creates ~/.nemoclaw as a symlink to an attacker-controlled directory before the user first runs NemoClaw causes credentials, state, and config to be written to the attacker's location. Combined with plaintext credential storage, API keys are directly readable.

What changed

  • New: src/lib/safe-dir.ts and nemoclaw/src/lib/safe-dir.tssafeMkdirSync() utility (lstat check + mkdirSync)
  • CLI side: config-io.ts, onboard-session.ts, onboard.ts, usage-notice.ts — replaced bare mkdirSync with safeMkdirSync
  • Plugin side: state.ts, config.ts, runner.ts, snapshot.ts — replaced bare mkdirSync with safeMkdirSync
  • Bonus: registry.ts and onboard-session.ts — fixed process.env.HOME||"/tmp"os.homedir()

Test plan

  • npm run build (plugin) — compiles cleanly
  • npm run typecheck:cli — passes
  • vitest run --project plugin — 334 tests pass
  • All pre-commit, commit-msg, and pre-push hooks pass
  • Manual: ln -s /tmp/test ~/.nemoclaw && nemoclaw onboard — verify error: "path is a symbolic link"

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Strengthened directory creation to reject unsafe/symlinked paths, reducing risk of directory traversal or symlink attacks.
    • Standardized use of the OS home directory for config, state, session, and registry locations instead of environment fallbacks.
    • More consistent, reliable path handling and tighter error handling during initialization and onboarding.

…rite hijack

mkdirSync(~/.nemoclaw, { recursive: true }) does not check whether the
path already exists as a symlink. An attacker who creates ~/.nemoclaw as
a symlink to an attacker-controlled directory before the user first runs
NemoClaw causes credentials and state to be written outside the intended
directory.

Add safeMkdirSync() utility that checks lstat() before mkdirSync() and
throws if the target is a symbolic link. Apply it to all ~/.nemoclaw
directory creation paths across both the CLI (config-io, onboard-session,
onboard, usage-notice) and plugin (state, config, runner, snapshot).

Also fix remaining process.env.HOME||"/tmp" patterns (registry.ts,
onboard-session.ts) with os.homedir().

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

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Replaced direct fs.mkdirSync uses with a new safeMkdirSync helper that rejects symlinked path components before creating directories; concurrently switched several default user-path resolutions from environment-based HOME lookups to os.homedir().

Changes

Cohort / File(s) Summary
Safe Directory Utility
nemoclaw/src/lib/safe-dir.ts, src/lib/safe-dir.ts
Added safeMkdirSync(dirPath, options?) which verifies path components (via lstatSync) up to the user's home directory to reject symlinks, then calls mkdirSync(..., { recursive: true, ... }).
Blueprint: runner & snapshot
nemoclaw/src/blueprint/runner.ts, nemoclaw/src/blueprint/snapshot.ts
Replaced mkdirSync(..., { recursive: true }) with safeMkdirSync(...) and adjusted imports accordingly; runtime write flows unchanged.
State & Onboard config
nemoclaw/src/blueprint/state.ts, nemoclaw/src/onboard/config.ts
Switched HOME resolution to os.homedir() and replaced directory creation with safeMkdirSync, tightening error handling around symlink-related failures.
Core libs: config, session, usage
src/lib/config-io.ts, src/lib/onboard-session.ts, src/lib/usage-notice.ts
Replaced direct fs.mkdirSync calls with safeMkdirSync(...) (preserving mode where used) and updated SESSION_DIR default to use os.homedir().
Registry & Onboard runtime
src/lib/registry.ts, src/lib/onboard.ts
Changed registry/config path resolution to os.homedir() and used safeMkdirSync for proxy/state directory creation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I nibble at paths, both near and far,
I sniff each link, reject the charred star,
Homedir trusted, no tricks in the ground,
Safe holes I dig where true dirs are found,
Hooray—secure nests all around! 🌱

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main security fix: rejecting symlinks on ~/.nemoclaw to prevent credential hijacking attacks.

✏️ 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/symlink-attack-nemoclaw-dir

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: 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 `@nemoclaw/src/onboard/config.ts`:
- Around line 76-84: Existing path checks skip symlink validation and the broad
catch hides symlink errors: ensure you validate an existing configDir with
fs.lstatSync and reject if lstat.isSymbolicLink() or if it's not a directory
(referencing configDir and safeMkdirSync). Always call safeMkdirSync when
creating the directory, and replace the broad catch with error handling that
only falls back for expected ENOENT/permission cases; rethrow or fail hard on
symlink-related errors so we don't silently switch to tmpdir. Ensure validation
happens both for the existing-path branch and after a failed mkdir attempt so
symlinks are never accepted.

In `@src/lib/safe-dir.ts`:
- Around line 16-29: The current rejectSymlink only checks the final path
component, letting writes follow symlinked ancestors; update rejectSymlink to
resolve dirPath and iterate up through each ancestor (using path.resolve and
path.dirname) calling lstatSync on each existing component and throwing if any
isSymbolicLink; on lstatSync ENOENT for a component just continue up (ancestors
above may exist) and stop when dirname equals the current path (root); keep
existing behavior of rethrowing non-ENOENT errors. This will ensure
safeMkdirSync/mkdirSync recursive creates cannot traverse into symlinked
ancestor directories.
🪄 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: Pro Plus

Run ID: 43291f7a-53b6-4c57-9331-bbb7a100564e

📥 Commits

Reviewing files that changed from the base of the PR and between 2368927 and b6bc1f0.

📒 Files selected for processing (11)
  • nemoclaw/src/blueprint/runner.ts
  • nemoclaw/src/blueprint/snapshot.ts
  • nemoclaw/src/blueprint/state.ts
  • nemoclaw/src/lib/safe-dir.ts
  • nemoclaw/src/onboard/config.ts
  • src/lib/config-io.ts
  • src/lib/onboard-session.ts
  • src/lib/onboard.ts
  • src/lib/registry.ts
  • src/lib/safe-dir.ts
  • src/lib/usage-notice.ts

Comment thread nemoclaw/src/onboard/config.ts Outdated
Comment on lines 76 to 84
if (!existsSync(configDir)) {
try {
mkdirSync(configDir, { recursive: true });
safeMkdirSync(configDir);
} catch {
configDir = join(tmpdir(), ".nemoclaw");
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
safeMkdirSync(configDir);
}
}

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.

⚠️ Potential issue | 🔴 Critical

Symlink protection is currently bypassed in the existing-path flow.

At Line 76, an existing ~/.nemoclaw path skips safeMkdirSync, so a pre-created symlink is not validated/rejected. Also, the broad catch at Line 79 masks a symlink error by falling back to /tmp, which contradicts the PR’s expected hard failure behavior.

🔧 Proposed fix
 function ensureConfigDir(): void {
   if (configDirCreated) return;
-  if (!existsSync(configDir)) {
-    try {
-      safeMkdirSync(configDir);
-    } catch {
-      configDir = join(tmpdir(), ".nemoclaw");
-      if (!existsSync(configDir)) {
-        safeMkdirSync(configDir);
-      }
-    }
-  }
+  try {
+    // Always validate/create the configured path so existing symlinks are rejected.
+    safeMkdirSync(configDir);
+  } catch (error: unknown) {
+    const err = error as NodeJS.ErrnoException;
+    const message = error instanceof Error ? error.message : "";
+    // Never downgrade symlink violations to a tmp fallback.
+    if (/symbolic link/i.test(message)) {
+      throw error;
+    }
+    // Keep legacy tmp fallback only for permission/filesystem access failures.
+    if (!["EACCES", "EPERM", "EROFS"].includes(err.code ?? "")) {
+      throw error;
+    }
+    configDir = join(tmpdir(), ".nemoclaw");
+    safeMkdirSync(configDir);
+  }
   configDirCreated = true;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nemoclaw/src/onboard/config.ts` around lines 76 - 84, Existing path checks
skip symlink validation and the broad catch hides symlink errors: ensure you
validate an existing configDir with fs.lstatSync and reject if
lstat.isSymbolicLink() or if it's not a directory (referencing configDir and
safeMkdirSync). Always call safeMkdirSync when creating the directory, and
replace the broad catch with error handling that only falls back for expected
ENOENT/permission cases; rethrow or fail hard on symlink-related errors so we
don't silently switch to tmpdir. Ensure validation happens both for the
existing-path branch and after a failed mkdir attempt so symlinks are never
accepted.

Comment thread src/lib/safe-dir.ts
…mlink errors

Address CodeRabbit findings:

1. rejectSymlink() now walks from the target path upward to $HOME,
   checking each component with lstatSync(). If a component is a
   symlink, it throws. If it exists as a real directory, it stops
   (trusted anchor). Stops at $HOME because system-level symlinks
   above it (e.g. /tmp -> /private/tmp, /var -> /private/var on macOS)
   are trusted OS infrastructure, not attack vectors.

2. config.ts ensureConfigDir() no longer swallows symlink errors in its
   broad catch — symlink violations are always rethrown. Only
   EACCES/EPERM/EROFS trigger the tmpdir fallback.

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.

🧹 Nitpick comments (1)
nemoclaw/src/onboard/config.ts (1)

79-85: Consider a typed symlink error contract instead of message regex matching.

/symbolic link/i works, but it is brittle. A dedicated error class/code from safe-dir would make this check more durable.

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

In `@nemoclaw/src/onboard/config.ts` around lines 79 - 85, Replace the brittle
/symbolic link/i message check with a typed contract exported by the safe-dir
package: import the specific error class or canonical error code from safe-dir
(e.g., SafeDirSymlinkError or SAFE_DIR_SYMLINK_CODE) and change the conditional
to throw when (error instanceof SafeDirSymlinkError) or when (error && (error as
NodeJS.ErrnoException).code === SAFE_DIR_SYMLINK_CODE); keep the original regex
only as a documented fallback if the safe-dir API lacks a concrete symbol, and
update the comment to reference the safe-dir contract; change the conditional
around the existing error variable in this block accordingly.
🤖 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/onboard/config.ts`:
- Around line 79-85: Replace the brittle /symbolic link/i message check with a
typed contract exported by the safe-dir package: import the specific error class
or canonical error code from safe-dir (e.g., SafeDirSymlinkError or
SAFE_DIR_SYMLINK_CODE) and change the conditional to throw when (error
instanceof SafeDirSymlinkError) or when (error && (error as
NodeJS.ErrnoException).code === SAFE_DIR_SYMLINK_CODE); keep the original regex
only as a documented fallback if the safe-dir API lacks a concrete symbol, and
update the comment to reference the safe-dir contract; change the conditional
around the existing error variable in this block accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 45a6f720-57b4-4d4b-aa8d-8bb7c0a9f0f1

📥 Commits

Reviewing files that changed from the base of the PR and between b6bc1f0 and 0abd347.

📒 Files selected for processing (3)
  • nemoclaw/src/lib/safe-dir.ts
  • nemoclaw/src/onboard/config.ts
  • src/lib/safe-dir.ts
✅ Files skipped from review due to trivial changes (1)
  • nemoclaw/src/lib/safe-dir.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/safe-dir.ts

@ericksoa

Copy link
Copy Markdown
Contributor Author

Superseded by #2290, which merged on 2026-04-23 and fixes the same symlink credential hijack vulnerability (NVBug 6079246).

@ericksoa ericksoa closed this Apr 24, 2026
@cv cv added v0.0.25 and removed v0.0.25 labels Apr 24, 2026
@wscurran wscurran added bug-fix PR fixes a bug or regression and removed priority: high labels Jun 3, 2026
@cv
cv deleted the fix/symlink-attack-nemoclaw-dir branch June 28, 2026 00:24
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