Repository navigation
fix(security): an MCP session no longer opens a sensitive folder as its default project - #3012
Merged
Merged
Conversation
…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
force-pushed
the
claude/mcp-default-project-guard
branch
from
October 29, 2026 16:59
6f3a5cd to
4c93c37
Compare
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
The MCP server's default project (the one a tool call without
projectPathanswers from) now gets the same sensitive-folder refusal that #2929 gave an agent'sprojectPathargument. A session whose default would be~/.ssh,~/.gnupg,~/.awsor~/.config(or anything inside them), or a system folder such as/etcorC:\Windows, now runs with no default project. Nothing there is opened, watched or synced, and no daemon, lock or socket is set up in the folder.Root cause
The default project comes from the client's
initialize(rootUri, or the firstworkspaceFoldersentry), the client'sroots/listanswer, the launch cwd, or--path. Each goes throughresolveServerRoot, and none of those paths calledvalidateProjectPath:MCPEngine.doInitialize(and the per-tool-callretryInitializeSync) opened whatever rootresolveServerRootfound, then started a file watcher and a catch-up sync on it.resolveDaemonRootkeyed the shared daemon on that root. A launch from a sensitive cwd (or with--path) spawned a detached daemon that took the folder's writer slot and served it.initializehandshake sent the full single-project instructions for it.Only the
projectPathargument was checked (ToolHandler.getCodeGraph). Thecodegraph uicommand and the viewer's server do check their root. On main, a warm query pool happened to refuse a bare call withisError(the worker re-checks the default root it is handed asprojectPath); before the pool warmed up, the main thread served the folder. Over stdio the client is the user's own agent, so the risk was low; an HTTP transport would widen it.Fix
resolveServerRoot(src/directory.ts). Its only callers are the MCP default-project resolutions, so the guard lives here and the handshake, engine, retry and launcher can't drift apart. It applies the refusal aprojectPathgets: on the path searched from when it exists, and on the root the search lands on (by up-walk or by the MCP server ignores indexed sub-projects below cwd #1606 sub-project down-scan). A refused root resolves tonull, with the reason in a newrefusalfield. Sub-projects below a refused folder are no longer listed as candidates, since a call naming them would be refused.MCPEngine. A refused root means "no default project": it logs the reason once on stderr, passes it to theToolHandler, and starts the query pool without a root, as when there is no index.ToolHandler. A call withoutprojectPathgets the existing success-shaped "pass projectPath" guidance (neverisError), with the refusal in place of the "searched from…" explanation. The ordinary not-indexed wording is unchanged byte for byte. A call that names a sensitive folder keeps itsPathRefusalError.initializesends the existing no-default-project instructions variant, because the handshake reads the same guarded resolution.server-instructions.tsis unchanged.src/mcp/index.ts). A refused root gives no daemon root, so the session runs in-process (direct mode) with no default project, and nodaemon.pid,writer.pidor socket is created in the folder. A daemon process started by hand for such a folder exits.src/utils.ts. The sensitive-folder half ofvalidateProjectPathis split out assensitiveDirectoryRefusal. UnlikevalidateProjectPath, it doesn't require the path to exist or be a directory, so a client naming a file inside an ordinary project isn't refused.validateProjectPathbehaves exactly as before.--pathis refused the same way, ascodegraph uirefuses its root, with one deliberate difference:codegraph uiexits, whileserve --mcpstays up with no default project and logs why. An MCP server that exits shows as failed in the host and takes codegraph away from every other project in the session, which is the abandonment the house rules warn about.Validation (Windows 11)
__tests__/security.test.ts(MCP default project in a sensitive folder), following fix(security): the sensitive-folder guard refuses links, case variants and \\?\ paths #2929's private-home fixture with a fake token in an indexed~/.config. They load the built engine and session, because under vitest the src engine can't open any default project and would pass with the guard missing.~/.configfrom the folder, from a folder inside it, from a missing folder under it, and through a junction to it, served the token and tookwriter.pid;rootUri,workspaceFolders, cwd androots/listsessions served the token, and the first three got the full instructions;serve --mcplaunched from the folder (daemon sharing on) started a daemon there, withdaemon.pidandwriter.pidin the folder, and served the token. The--pathlaunch got the full instructions and anisErrorrefusal from the warm pool.rootUriorroots/listnaming it gets it as the default.security.test.ts70 passed, 4 skipped (POSIX and macOS gates). 51 MCP, daemon, proxy and directory suites: 646 passed, 0 failed.serve --mcpagainst a private HOME: from a sensitive cwd, and with a sensitive--path, the server sent the no-default instructions and returned the guidance instead of the token, with nodaemon.pidorwriter.pid. It loggedRefusing to operate on sensitive directory: …\home\.config — no default project, live sync disabled. Tool calls can still pass projectPath for a project elsewhere.An ordinary project was unchanged (full instructions, explore answers, watcher active).resolveServerRootwent from 141 to 377 µs per call on an ordinary project (median of 7 rounds of 2,000 calls). It runs at the handshake, the engine open and the launcher, and on each tool call only while a session has no default project.4c93c377, rebased onto main86c85cff; the rebases brought only Kotlin resolution changes), with the native kernel built (CODEGRAPH_KERNEL_EXPECT=1) andCODEGRAPH_NO_DAEMONunset: 781 files (775 passed, 6 skipped); 9,339 tests: 9,254 passed, 0 failed, 85 skipped. No reruns were needed.security.test.tswas also re-run alone on this commit: 70 passed, 4 skipped.Not included
codegraph explore,query,init, …) still open a project from the cwd without this check. They run in the user's own terminal, or in an agent's shell, which can read those files anyway. Onlycodegraph ui, the viewer and the MCP server serve a project to a client.projectPathnaming an existing file inside an ordinary project is still refused bygetCodeGraph("Path is not a directory"), as on main. The default-project guard does not copy that quirk.~/.config/<something>that holds an index (an indexed Neovim config, say) no longer has it as its default project. That matches whatprojectPathandcodegraph uialready do for the same folder since fix(security): the sensitive-folder guard refuses links, case variants and \\?\ paths #2929.This is a follow-up to #2929. CHANGELOG: one bullet under
[Unreleased]→### Security, next to #2929's.🤖 Generated with Claude Code