fix(security): an MCP session no longer opens a sensitive folder as its 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 commit is contained in:
Colby Mchenry
2026-10-29 17:23:06 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 86c85cff02
commit e7c9ffd068
8 changed files with 438 additions and 25 deletions
+30 -5
View File
@@ -9,6 +9,7 @@ import * as os from 'os';
import * as path from 'path';
import { isWslWindowsDrive } from './sync/watch-policy';
import { loadNodeSqlite } from './db/node-sqlite';
import { sensitiveDirectoryRefusal } from './utils';
/** The default per-project data directory name. */
export const DEFAULT_CODEGRAPH_DIR = '.codegraph';
@@ -453,6 +454,12 @@ export interface ServerRootResolution {
* what IS reachable (#1607).
*/
candidates: string[];
/**
* Why the root that was found is not served: it, or the path the search
* started from, is a sensitive folder (`~/.ssh`, `~/.config`, `/etc`, …),
* refused as a tool call's `projectPath` is. `root` is then null.
*/
refusal: string | null;
}
/**
@@ -484,19 +491,37 @@ function eligibleForSubprojectScan(base: string): boolean {
*
* `opts.subprojectScan: false` skips the down-scan entirely (the per-tool-call
* retry path throttles it; the up-walk always runs).
*
* The default project gets the sensitive-folder refusal a tool call's
* `projectPath` gets: on the path searched from when it exists, and on the
* root the search lands on. A refused root resolves to null, with the reason
* in `refusal`: the server has no default project, so nothing there is opened,
* watched or synced. Sub-projects below a refused path aren't listed either;
* a tool call would refuse them.
*/
export function resolveServerRoot(
searchFrom: string,
opts: { subprojectScan?: boolean } = {},
): ServerRootResolution {
const found = findServerRoot(searchFrom, opts);
if (!found.root && found.candidates.length === 0) return found;
const startRefusal = fs.existsSync(searchFrom) ? sensitiveDirectoryRefusal(searchFrom) : null;
if (!found.root) return startRefusal ? { ...found, candidates: [] } : found;
const refusal = startRefusal ?? sensitiveDirectoryRefusal(found.root);
return refusal ? { root: null, viaSubScan: false, candidates: [], refusal } : found;
}
/** {@link resolveServerRoot} before the sensitive-folder refusal. */
function findServerRoot(searchFrom: string, opts: { subprojectScan?: boolean }): ServerRootResolution {
const none: ServerRootResolution = { root: null, viaSubScan: false, candidates: [], refusal: null };
const up = findNearestCodeGraphRoot(searchFrom);
if (up) return { root: up, viaSubScan: false, candidates: [] };
if (opts.subprojectScan === false) return { root: null, viaSubScan: false, candidates: [] };
if (up) return { root: up, viaSubScan: false, candidates: [], refusal: null };
if (opts.subprojectScan === false) return none;
const base = path.resolve(searchFrom);
if (!eligibleForSubprojectScan(base)) return { root: null, viaSubScan: false, candidates: [] };
if (!eligibleForSubprojectScan(base)) return none;
const subs = findIndexedSubprojectRoots(base);
if (subs.length === 1) return { root: subs[0]!, viaSubScan: true, candidates: subs };
return { root: null, viaSubScan: false, candidates: subs };
if (subs.length === 1) return { root: subs[0]!, viaSubScan: true, candidates: subs, refusal: null };
return { ...none, candidates: subs };
}
/**
+26 -3
View File
@@ -91,6 +91,9 @@ export class MCPEngine {
// Throttle for the retry path's sub-project down-scan (#1606) — the scan is
// bounded but shouldn't run on every tool call in the no-default state.
private lastRetrySubScanAt = 0;
// The default-root refusal last written to stderr, so the retry on every
// tool call doesn't repeat it.
private loggedRootRefusal: string | null = null;
private watcherStarted = false;
/** Set when this engine holds writer.pid (#1740). */
private writerLockRoot: string | null = null;
@@ -247,6 +250,8 @@ export class MCPEngine {
this.lastRetrySubScanAt = Date.now();
if (!res.root) this.toolHandler.setKnownSubprojects(res.candidates, searchFrom);
}
// An up-walk alone can't clear a refusal the throttled down-scan found.
if (scanDue || res.refusal) this.noteRootRefusal(res.refusal);
const resolvedRoot = res.root;
if (!resolvedRoot) return;
if (res.viaSubScan) this.logSubprojectAdoption(searchFrom, resolvedRoot);
@@ -373,15 +378,18 @@ export class MCPEngine {
// only by knowing to look for a missing ~/.codegraph/daemons/ entry.
const res = resolveServerRoot(searchFrom);
const resolvedRoot = res.root;
this.noteRootRefusal(res.refusal);
if (!resolvedRoot) {
// Sessions may still discover a project later via roots/list, and the
// per-call retry re-resolves — this state is recoverable, hence stderr
// (not a failure) + candidates surfaced through the tool-call error.
this.projectPath = searchFrom;
this.toolHandler.setKnownSubprojects(res.candidates, searchFrom);
process.stderr.write(
`[CodeGraph MCP] No .codegraph/ at or above ${searchFrom}: no default project, live sync disabled until an indexed project is accessed via projectPath.\n`
);
if (!res.refusal) {
process.stderr.write(
`[CodeGraph MCP] No .codegraph/ at or above ${searchFrom}: no default project, live sync disabled until an indexed project is accessed via projectPath.\n`
);
}
if (res.candidates.length > 0) {
const rels = res.candidates.map((c) => path.relative(searchFrom, c) || '.');
process.stderr.write(
@@ -411,6 +419,21 @@ export class MCPEngine {
}
}
/**
* Record why the default project is refused (a sensitive folder, #2929's
* guard), or clear that. Tool calls without a projectPath say why there is
* no default project, and stderr says it once per reason, since every such
* call re-resolves.
*/
private noteRootRefusal(refusal: string | null): void {
this.toolHandler.setDefaultRootRefusal(refusal);
if (!refusal || refusal === this.loggedRootRefusal) return;
this.loggedRootRefusal = refusal;
process.stderr.write(
`[CodeGraph MCP] ${refusal} — no default project, live sync disabled. Tool calls can still pass projectPath for a project elsewhere.\n`
);
}
/** One stderr line when the default project came from the down-scan (#1606). */
private logSubprojectAdoption(searchFrom: string, root: string): void {
const rel = path.relative(searchFrom, root) || root;
+12
View File
@@ -304,6 +304,10 @@ export function watchdogProgressPaths(root: string | null): { progressPaths?: st
* spawned `process.cwd()` is already realpath'd) and one that passed a
* symlinked `rootUri` would otherwise hash to different sockets and silently
* fail to share the daemon.
*
* A root in a sensitive folder (`~/.ssh`, `~/.config`, `/etc`, …) resolves to
* null like a missing one: the session runs in-process with no default
* project, and no daemon, lock or socket is set up in that folder.
*/
function resolveDaemonRoot(explicitPath: string | null): string | null {
const candidate = explicitPath ?? process.cwd();
@@ -651,6 +655,14 @@ export class MCPServer {
// In daemon mode stderr IS `.codegraph/daemon.log`; stamp every line so
// kills/restarts can be placed in time (#1431 — the log was undatable).
timestampStderrLines();
// A launcher never starts a daemon for a sensitive folder (see
// resolveDaemonRoot). One started by hand for such a folder leaves rather
// than take the folder's locks and serve it.
const refusal = resolveServerRoot(this.projectPath ?? process.cwd()).refusal;
if (refusal) {
process.stderr.write(`[CodeGraph daemon] ${refusal}; exiting.\n`);
process.exit(0);
}
const root = resolveDaemonRoot(this.projectPath) ?? this.projectPath ?? process.cwd();
// Read once and dropped, so nothing this daemon spawns inherits it.
const handoverPid = Number(process.env[DAEMON_HANDOVER_ENV]);
+2
View File
@@ -246,6 +246,8 @@ export class MCPSession {
// sub-projects indexed) and never surfaced the tools after a mid-session
// `codegraph init`. When no explicit path is known yet (roots/list dance
// pending), cwd is the best predictor of where the default will resolve.
// A root in a sensitive folder resolves to null here as in the engine, so
// that session gets the no-default-project variant too.
const indexed = resolveServerRoot(explicitPath ?? process.cwd()).root !== null;
// Respond to the handshake BEFORE doing any heavy init — see issue #172.
+28 -6
View File
@@ -2040,6 +2040,10 @@ export class ToolHandler {
// (#995), or an index that exists but could not be opened (SQLite's own
// failure, or no node:sqlite). Engine-maintained; cleared by a successful open.
private defaultOpenFailure: WslSharedIndexError | DatabaseOpenError | NodeSqliteUnavailableError | null = null;
// Why the default project was refused: the folder it resolved to, or was
// searched for from, is a sensitive one (the guard a `projectPath` gets).
// Engine-maintained; cleared by a successful open.
private defaultRootRefusal: string | null = null;
// Per-start-path cache of the git worktree/index mismatch (issue #155). The
// mismatch is a fixed property of (where the request came from → which
// .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
@@ -2092,6 +2096,17 @@ export class ToolHandler {
setDefaultCodeGraph(cg: CodeGraph): void {
this.cg = cg;
this.defaultOpenFailure = null;
this.defaultRootRefusal = null;
}
/**
* Engine-only: record why the default project was refused as a sensitive
* folder, so a call without a projectPath says why there is none. `null`
* clears it. A refused root was never opened, so no open failure stands.
*/
setDefaultRootRefusal(refusal: string | null): void {
this.defaultRootRefusal = refusal;
if (refusal) this.defaultOpenFailure = null;
}
/**
@@ -2327,14 +2342,21 @@ export class ToolHandler {
if (!this.cg) {
if (this.defaultOpenFailure) throw this.defaultOpenFailure;
const searched = this.defaultProjectHint ?? process.cwd();
// A refused default is the same "pass projectPath" state, success-shaped
// like the rest: the agent can still query every other project.
const why = this.defaultRootRefusal
? `${this.defaultRootRefusal}. CodeGraph never uses a credential, config or system folder ` +
'as a project, so this session has no default project. To query a project outside such ' +
'folders, target it explicitly:\n'
: `Searched for a .codegraph/ directory starting from: ${searched}\n` +
this.formatKnownSubprojects() +
'Either the server root has no index of its own (e.g. a monorepo where only ' +
"sub-projects are indexed), or the MCP client launched the server outside your " +
'project without reporting the workspace root. Either way, target the project ' +
'explicitly:\n';
throw new NotIndexedError(
'No CodeGraph project is loaded for this session.\n' +
`Searched for a .codegraph/ directory starting from: ${searched}\n` +
this.formatKnownSubprojects() +
'Either the server root has no index of its own (e.g. a monorepo where only ' +
"sub-projects are indexed), or the MCP client launched the server outside your " +
'project without reporting the workspace root. Either way, target the project ' +
'explicitly:\n' +
why +
' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project" ' +
'(any project that has a .codegraph/ — including a sub-project of a monorepo)\n' +
' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
+24 -10
View File
@@ -318,6 +318,30 @@ function sensitiveHomeTrees(): string[] {
* @returns An error message if invalid, or null if valid
*/
export function validateProjectPath(dirPath: string): string | null {
const refusal = sensitiveDirectoryRefusal(dirPath);
if (refusal) return refusal;
// Verify it's a real directory
const resolved = path.resolve(dirPath);
try {
const stats = fs.statSync(resolved);
if (!stats.isDirectory()) {
return `Path is not a directory: ${resolved}`;
}
} catch {
return `Path does not exist or is not accessible: ${resolved}`;
}
return null;
}
/**
* The sensitive-folder half of {@link validateProjectPath}: why `dirPath` is,
* or is inside, a folder CodeGraph never uses as a project, or null. Unlike
* that function it doesn't require the path to exist or be a directory, so it
* can vet a path a project root is searched for from.
*/
export function sensitiveDirectoryRefusal(dirPath: string): string | null {
const resolved = path.resolve(dirPath);
const lexical = path.resolve(stripWin32NamespacePrefix(dirPath));
const real = nativeRealPath(lexical);
@@ -347,16 +371,6 @@ export function validateProjectPath(dirPath: string): string | null {
}
}
// Verify it's a real directory
try {
const stats = fs.statSync(resolved);
if (!stats.isDirectory()) {
return `Path is not a directory: ${resolved}`;
}
} catch {
return `Path does not exist or is not accessible: ${resolved}`;
}
return null;
}