mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 21:13:11 +08:00
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:
co-authored by
Claude Opus 5.5
parent
86c85cff02
commit
e7c9ffd068
+30
-5
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user