Skip to content

fix(security): an MCP session no longer opens a sensitive folder as its default project - #3012

Merged
colbymchenry merged 1 commit into
mainfrom
claude/mcp-default-project-guard
Oct 29, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/mcp-default-project-guard

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

The MCP server's default project (the one a tool call without projectPath answers from) now gets the same sensitive-folder refusal that #2929 gave an agent's projectPath argument. A session whose default would be ~/.ssh, ~/.gnupg, ~/.aws or ~/.config (or anything inside them), or a system folder such as /etc or C:\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 first workspaceFolders entry), the client's roots/list answer, the launch cwd, or --path. Each goes through resolveServerRoot, and none of those paths called validateProjectPath:

  • MCPEngine.doInitialize (and the per-tool-call retryInitializeSync) opened whatever root resolveServerRoot found, then started a file watcher and a catch-up sync on it.
  • The launcher's resolveDaemonRoot keyed 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.
  • The initialize handshake sent the full single-project instructions for it.

Only the projectPath argument was checked (ToolHandler.getCodeGraph). The codegraph ui command and the viewer's server do check their root. On main, a warm query pool happened to refuse a bare call with isError (the worker re-checks the default root it is handed as projectPath); 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 a projectPath gets: 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 to null, with the reason in a new refusal field. 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 the ToolHandler, and starts the query pool without a root, as when there is no index.
  • ToolHandler. A call without projectPath gets the existing success-shaped "pass projectPath" guidance (never isError), 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 its PathRefusalError.
  • initialize sends the existing no-default-project instructions variant, because the handshake reads the same guarded resolution. server-instructions.ts is unchanged.
  • Launcher (src/mcp/index.ts). A refused root gives no daemon root, so the session runs in-process (direct mode) with no default project, and no daemon.pid, writer.pid or socket is created in the folder. A daemon process started by hand for such a folder exits.
  • src/utils.ts. The sensitive-folder half of validateProjectPath is split out as sensitiveDirectoryRefusal. Unlike validateProjectPath, 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. validateProjectPath behaves exactly as before.

--path is refused the same way, as codegraph ui refuses its root, with one deliberate difference: codegraph ui exits, while serve --mcp stays 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)

  • 6 new tests in __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.
    • Red on main's sources, every entry point (a run with soft assertions so each case reports):
      • the engine opened ~/.config from the folder, from a folder inside it, from a missing folder under it, and through a junction to it, served the token and took writer.pid;
      • rootUri, workspaceFolders, cwd and roots/list sessions served the token, and the first three got the full instructions;
      • the down-scan adopted one sub-project, or listed two;
      • a real serve --mcp launched from the folder (daemon sharing on) started a daemon there, with daemon.pid and writer.pid in the folder, and served the token. The --path launch got the full instructions and an isError refusal from the warm pool.
    • The ordinary-project controls pass on both main and the fix: the engine opens and watches the project, and a rootUri or roots/list naming it gets it as the default.
    • Green with the fix: all 6 pass.
  • Related suites: security.test.ts 70 passed, 4 skipped (POSIX and macOS gates). 51 MCP, daemon, proxy and directory suites: 646 passed, 0 failed.
  • Real serve --mcp against 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 no daemon.pid or writer.pid. It logged Refusing 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).
  • Cost: resolveServerRoot went 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.
  • Full suite on this commit (4c93c377, rebased onto main 86c85cff; the rebases brought only Kotlin resolution changes), with the native kernel built (CODEGRAPH_KERNEL_EXPECT=1) and CODEGRAPH_NO_DAEMON unset: 781 files (775 passed, 6 skipped); 9,339 tests: 9,254 passed, 0 failed, 85 skipped. No reruns were needed. security.test.ts was also re-run alone on this commit: 70 passed, 4 skipped.

Not included

  • Other CLI commands (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. Only codegraph ui, the viewer and the MCP server serve a project to a client.
  • A projectPath naming an existing file inside an ordinary project is still refused by getCodeGraph ("Path is not a directory"), as on main. The default-project guard does not copy that quirk.
  • A behavior change to be aware of: a session started inside ~/.config/<something> that holds an index (an indexed Neovim config, say) no longer has it as its default project. That matches what projectPath and codegraph ui already 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

…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
colbymchenry force-pushed the claude/mcp-default-project-guard branch from 6f3a5cd to 4c93c37 Compare October 29, 2026 16:59
@colbymchenry
colbymchenry merged commit e7c9ffd into main Oct 29, 2026
@colbymchenry
colbymchenry deleted the claude/mcp-default-project-guard branch October 29, 2026 17:23
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