Repository navigation
fix(security): the sensitive-folder guard refuses links, case variants and \\?\ paths - #2929
Merged
Merged
Conversation
…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>
This was referenced Oct 27, 2026
Merged
colbymchenry
added a commit
that referenced
this pull request
Oct 29, 2026
…ts default project The MCP server's default project comes from the client (initialize's rootUri or first workspaceFolders entry, or its roots/list answer), the launch cwd, or --path. Only an agent's projectPath argument went through the sensitive-folder guard of PR #2929: the default root was opened with no check, and opening it also started a file watcher and a catch-up sync, and from the launcher a shared daemon keyed on that folder. A session whose default was ~/.config, ~/.ssh or a system folder holding an index served the files inside to the agent. (On main a warm query pool happened to refuse such a call with isError; before it warmed, the main thread served it.) resolveServerRoot, which every default-project resolution goes through (the engine's open and its per-call retry, the initialize handshake and the launcher's daemon root), now applies the refusal a projectPath gets: on the path searched from when it exists, and on the root it lands on, by up-walk or by the sub-project down-scan. A refused root resolves to null with the reason, so the session has no default project. Nothing there is opened, watched or synced; no daemon, lock or socket is set up in the folder; initialize sends the no-default-project instructions; and a call without projectPath gets the usual success-shaped "pass projectPath" guidance, saying why. A call that names the folder keeps its PathRefusalError, and sub-projects below a refused folder aren't listed. --path is refused the same way, as `codegraph ui` refuses its root, but the server stays up with no default project instead of exiting, so the agent keeps codegraph for its other projects. A daemon started by hand for such a folder exits. The sensitive-folder half of validateProjectPath is split out as sensitiveDirectoryRefusal; validateProjectPath behaves as before. Validation on Windows 11: - 6 new tests in security.test.ts. On main's sources every entry point is red: the engine opened ~/.config from the folder, a folder inside it, a missing folder under it and a junction to it, serving a fake token and taking writer.pid; rootUri, workspaceFolders, cwd and roots/list sessions served the token; the down-scan adopted or listed sub-projects; a launch from the cwd started a daemon in the folder. The ordinary-project control passes on both. All 6 pass with the fix. - security.test.ts: 70 passed, 4 skipped (POSIX and macOS gates). 51 MCP, daemon, proxy and directory suites: 646 passed, 0 failed. - The real serve --mcp against a private HOME, from a sensitive cwd and with a sensitive --path: no-default instructions, the guidance instead of the token, no daemon.pid or writer.pid. An ordinary project is unchanged. - Cost: resolveServerRoot 141 -> 377 us per call on an ordinary project (median of 7 rounds of 2000 calls). It runs at the handshake, the engine open and the launcher, and per tool call only while a session has no default project. Follow-up to PR #2929. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry
added a commit
that referenced
this pull request
Oct 29, 2026
…ts default project (#3012) The MCP server's default project comes from the client (initialize's rootUri or first workspaceFolders entry, or its roots/list answer), the launch cwd, or --path. Only an agent's projectPath argument went through the sensitive-folder guard of PR #2929: the default root was opened with no check, and opening it also started a file watcher and a catch-up sync, and from the launcher a shared daemon keyed on that folder. A session whose default was ~/.config, ~/.ssh or a system folder holding an index served the files inside to the agent. (On main a warm query pool happened to refuse such a call with isError; before it warmed, the main thread served it.) resolveServerRoot, which every default-project resolution goes through (the engine's open and its per-call retry, the initialize handshake and the launcher's daemon root), now applies the refusal a projectPath gets: on the path searched from when it exists, and on the root it lands on, by up-walk or by the sub-project down-scan. A refused root resolves to null with the reason, so the session has no default project. Nothing there is opened, watched or synced; no daemon, lock or socket is set up in the folder; initialize sends the no-default-project instructions; and a call without projectPath gets the usual success-shaped "pass projectPath" guidance, saying why. A call that names the folder keeps its PathRefusalError, and sub-projects below a refused folder aren't listed. --path is refused the same way, as `codegraph ui` refuses its root, but the server stays up with no default project instead of exiting, so the agent keeps codegraph for its other projects. A daemon started by hand for such a folder exits. The sensitive-folder half of validateProjectPath is split out as sensitiveDirectoryRefusal; validateProjectPath behaves as before. Validation on Windows 11: - 6 new tests in security.test.ts. On main's sources every entry point is red: the engine opened ~/.config from the folder, a folder inside it, a missing folder under it and a junction to it, serving a fake token and taking writer.pid; rootUri, workspaceFolders, cwd and roots/list sessions served the token; the down-scan adopted or listed sub-projects; a launch from the cwd started a daemon in the folder. The ordinary-project control passes on both. All 6 pass with the fix. - security.test.ts: 70 passed, 4 skipped (POSIX and macOS gates). 51 MCP, daemon, proxy and directory suites: 646 passed, 0 failed. - The real serve --mcp against a private HOME, from a sensitive cwd and with a sensitive --path: no-default instructions, the guidance instead of the token, no daemon.pid or writer.pid. An ordinary project is unchanged. - Cost: resolveServerRoot 141 -> 377 us per call on an ordinary project (median of 7 rounds of 2000 calls). It runs at the handshake, the engine open and the launcher, and per tool call only while a session has no default project. Follow-up to PR #2929. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CodeGraph refuses to open
~/.ssh,~/.gnupg,~/.aws,~/.configand system folders (/etc,C:\Windows, …) as a project. That guard compared the path as typed, so it could be sidestepped:~/.SSH,~/.CONFIG, an upper-cased home);\\?\or\\.\spelling on Windows (\\?\C:\Windows);~/.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) checkedpath.resolve(dirPath)only: an exact match against the system set (lower-cased), and a case-sensitivestartsWithagainstpath.join(os.homedir(), dir). Nothing followed links, folded case for the home folders, or understood Win32 namespace prefixes. macOS's/etcis 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
validateProjectPathchecks two candidates: the path as spelled (after stripping a\\?\,\\.\or\\?\UNC\prefix), and itsfs.realpathSync.nativeform. The native form follows symlinks, junctions andsubstdrives, 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./private/etcon macOS);~/.sshthat is itself a link into a dotfiles checkout).unsafeIndexRootReasonalready does.…\config-junction (resolves to C:\Users\me\.config).getCodeGraphalso 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/HOMEset to a kit folder) holds a fake~/.configwith aFAKE-TOKENfile and a fake~/.ssh. The fake~/.configis indexed, and a junction and a directory symlink point at the fake folders.Probe (
validateProjectPath,codegraph_nodewith aprojectPath, the viewer'sresolveProjectFile):projectPathshapes that served the token (of 7)\\?\spellings, the home itselfcodegraph uiend to end (CODEGRAPH_UI=1, real CLI; a served viewer was asked for/api/source?file=nvim/init.lua):~/.config~/.CONFIG\\?\+~/.configTests (red→green: main's
src/utils.tsandsrc/mcp/tools.tsput 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 toC:\Windows(win32); a symlink to/etc(POSIX: this also covers macOS's/private/etcthrough the system set's real paths);/private/etcitself (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.tools.tsalone, with the fixedutils.ts, still fails the missing-sub-folder case, so both changes are needed.Cost (median of 5 rounds × 2,000 calls, 3 runs):
validateProjectPath48 → 130 µs per call; the viewer's per-fileresolveProjectFile700 → 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 onad277047, this change on4eb8c2a5. After rebasing onto7ff2841e(#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
\\localhost\c$\Users\me\.ssh) still passes.realpathSync.nativereturns 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.C:are not added to the system set; this PR only makes the existing entries hold.Changelog: the agent side is under
[Unreleased]→### Security. Thecodegraph uiside is indocs/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