Skip to content

fix(security): the sensitive-folder guard refuses links, case variants and \\?\ paths - #2929

Merged
colbymchenry merged 1 commit into
mainfrom
claude/fix-sensitive-path-canonical
Oct 27, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/fix-sensitive-path-canonical

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

CodeGraph refuses to open ~/.ssh, ~/.gnupg, ~/.aws, ~/.config and system folders (/etc, C:\Windows, …) as a project. That guard compared the path as typed, so it could be sidestepped:

  • through a junction or symlink that leads to one of those folders;
  • by a different capitalization on Windows and macOS (~/.SSH, ~/.CONFIG, an upper-cased home);
  • by a \\?\ or \\.\ spelling on Windows (\\?\C:\Windows);
  • and, at the MCP entry point, by a path to a missing folder inside one (~/.config/no-such-dir): the guard only runs on a path that exists, and the handler then walks up from it into the folder's own index.

The guard gates an MCP projectPath, codegraph ui, and every file the viewer reads, so each of these served what was inside (verified below with a fake token).

Root cause

validateProjectPath (src/utils.ts) checked path.resolve(dirPath) only: an exact match against the system set (lower-cased), and a case-sensitive startsWith against path.join(os.homedir(), dir). Nothing followed links, folded case for the home folders, or understood Win32 namespace prefixes. macOS's /etc is really /private/etc, so the system set never matched a real path there either. Separately, ToolHandler.getCodeGraph (src/mcp/tools.ts) skips the check for a path that doesn't exist (so a not-yet-created sub-path can resolve up to its project, #238) and never checked the root the walk landed on.

Fix

  • validateProjectPath checks two candidates: the path as spelled (after stripping a \\?\, \\.\ or \\?\UNC\ prefix), and its fs.realpathSync.native form. The native form follows symlinks, junctions and subst drives, and returns the stored case and long names. If the path can't be resolved (most often because it doesn't exist), only the lexical check runs.
  • Both candidates are compared with:
    • the system set as before;
    • the system folders' own real paths (/private/etc on macOS);
    • the home folders three ways: as spelled, under the home's real path (a linked home), and by each folder's own real path (a ~/.ssh that is itself a link into a dotfiles checkout).
  • Comparisons fold case on win32 and darwin, as unsafeIndexRootReason already does.
  • It stays at one realpath per call, since the viewer calls it for every file it reads. The system folders' real paths are read once per process, and the home folders' real paths are cached for two seconds, so a folder linked somewhere new is noticed soon.
  • The refusal names both spellings: …\config-junction (resolves to C:\Users\me\.config).
  • getCodeGraph also runs the guard on the root its up-walk lands on.

Every refusal main makes still fires: the old lexical checks run unchanged on the spelled path, which only differs from main's when a namespace prefix was stripped. The existence check still stats the path exactly as main did.

Validation

Windows 11 x64, Node 22.22. A private home (USERPROFILE/HOME set to a kit folder) holds a fake ~/.config with a FAKE-TOKEN file and a fake ~/.ssh. The fake ~/.config is indexed, and a junction and a directory symlink point at the fake folders.

Probe (validateProjectPath, codegraph_node with a projectPath, the viewer's resolveProjectFile):

main b6379e6 this PR
sensitive spellings allowed by the guard (of 16) 11 0
MCP projectPath shapes that served the token (of 7) 6 0
viewer roots that served the token (of 3) 2 0
controls still allowed: ordinary folder, its upper-case and \\?\ spellings, the home itself 4/4 4/4

codegraph ui end to end (CODEGRAPH_UI=1, real CLI; a served viewer was asked for /api/source?file=nvim/init.lua):

spelling main this PR
~/.config refused refused
junction to it served the token refused
directory symlink to it served the token refused
junction + missing sub-folder served the token refused
~/.CONFIG served the token refused
\\?\ + ~/.config viewer started (source read 403) refused

Tests (red→green: main's src/utils.ts and src/mcp/tools.ts put back together, then restored):

  • security.test.ts: links to a sensitive home folder and folders under it; a credential folder that is itself a link; a linked home; case variants (win32 + darwin); \\?\, \\.\ and //?/ spellings and a junction to C:\Windows (win32); a symlink to /etc (POSIX: this also covers macOS's /private/etc through the system set's real paths); /private/etc itself (darwin). Plus an MCP case against a real index: a junction to the folder, a missing folder under it, and a missing folder under the junction must come back as a refusal, with no token in the output.
  • ui-server.test.ts: a viewer root that is a link to a sensitive folder is refused.
  • cli-ui-command.test.ts: codegraph ui <link to ~/.config> is refused.
  • On main's source the 9 new tests that run on Windows fail (113 others pass). With the fix: 122 passed, 8 skipped across the three files. Main's tools.ts alone, with the fixed utils.ts, still fails the missing-sub-folder case, so both changes are needed.
  • The POSIX and macOS cases are gated and were not run here (Windows box, no Docker/WSL). Please run them on the Mac.

Cost (median of 5 rounds × 2,000 calls, 3 runs): validateProjectPath 48 → 130 µs per call; the viewer's per-file resolveProjectFile 700 → 780 µs.

Full suite: 760 files passed, 6 skipped; 9,051 tests passed, 0 failed, 85 skipped, with the kernel built (CODEGRAPH_KERNEL_EXPECT=1) and the daemon allowed. It ran on ad277047, this change on 4eb8c2a5. After rebasing onto 7ff2841e (#2927, resolution files only), I reran this PR's three test files and the three #2927 changed on the pushed commit: 175 passed, 8 skipped.

Left out

  • A loopback SMB share of a sensitive folder (\\localhost\c$\Users\me\.ssh) still passes. realpathSync.native returns UNC paths as they are, and mapping a share back to its local folder needs share enumeration. That needs an admin share or a share the user made of their own folder.
  • Other drive letters and a Windows installed off C: are not added to the system set; this PR only makes the existing entries hold.

Changelog: the agent side is under [Unreleased] → ### Security. The codegraph ui side is in docs/viewer-launch-changelog.md, because the viewer isn't released yet (AGENTS.md).

Reported in #1930 by @mixxer; this reimplements the fix independently. Thanks to them for finding the link bypass and its POSIX shapes; the Windows case, \\?\ and missing-folder shapes are added here.

🤖 Generated with Claude Code

…s and \\?\ paths

The guard that keeps ~/.ssh, ~/.gnupg, ~/.aws, ~/.config and system
folders such as /etc and C:\Windows from being opened as a project
compared the path as typed. A junction or symlink to one of them, a
different capitalization on Windows or macOS (~/.SSH), or a \\?\ or \\.\
spelling on Windows passed, so an MCP projectPath, `codegraph ui` and the
viewer's per-file reads all served what was inside. The MCP gate also
skipped the guard for a path that doesn't exist and then walked up from
it, so ~/.config/no-such-dir reached an index of ~/.config.

validateProjectPath now checks the path as spelled (with a Win32
namespace prefix stripped) and its fs.realpathSync.native form. Both are
compared with the system set and the system folders' own real paths
(macOS /private/etc), and with the home folders as spelled, under the
home's real path and by their own real paths (a linked ~/.ssh). Case is
folded on win32 and darwin. A path that can't be resolved keeps the
lexical check. It stays at one realpath per call: the system folders'
real paths are read once and the home folders' are cached for two
seconds. The MCP handler also checks the root its up-walk lands on.

Validation on Windows 11, with a private home holding a fake token:
- Probe: on main 11 of 16 sensitive spellings were allowed, and the
  token was served through 6 MCP projectPath shapes and 2 viewer roots.
  With the fix all are refused. Controls (an ordinary folder, its
  upper-case and \\?\ spellings, the home itself) are still allowed.
- codegraph ui end to end: main served the token for 4 of 6 spellings
  and started the viewer for a fifth; the fix refuses all 6 at startup.
- Tests: the 9 new tests that run on Windows fail on main's source and
  pass with the fix. The POSIX and macOS cases are gated and were not
  run here.
- Cost: validateProjectPath 48 -> 130 us per call, the viewer's per-file
  resolveProjectFile 700 -> 780 us (medians of 5 rounds, 3 runs).

Reported in PR #1930 by @mixxer; this reimplements the fix independently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant