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
+1
View File
@@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Security
- CodeGraph's refusal to use a sensitive folder such as `~/.ssh`, `~/.config`, `/etc` or `C:\Windows` as a project can no longer be sidestepped: a link or junction that leads to one, a differently capitalized name on Windows and macOS, a `\\?\` spelling on Windows, and a path to a missing folder inside one are now refused like the folder itself. Before, any of these let an AI agent's query read the files inside. Thanks @mixxer for finding it.
- The MCP server no longer uses a sensitive folder such as `~/.ssh`, `~/.config` or `/etc` as a session's default project, whether the folder came from your AI agent's workspace, the folder the server was started in, or `--path`. Before, only a folder an agent asked for by path was refused, so a session started in one that held an index answered from the files inside and kept it synced; now that session has no default project, and the agent can still query any other project by path.
### Fixes
+315 -1
View File
@@ -8,12 +8,17 @@
* - Atomic writes
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import { once } from 'events';
import { pathToFileURL } from 'url';
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
import { FileLock, validateProjectPath, validatePathWithinRoot, lexicalPathWithinRoot } from '../src/utils';
import CodeGraph from '../src/index';
import { resolveServerRoot } from '../src/directory';
import { ToolHandler, tools, __setLoadCodeGraphForTests } from '../src/mcp/tools';
import { scanDirectory, isSourceFile } from '../src/extraction';
import { DatabaseConnection, getDatabasePath } from '../src/db';
@@ -628,6 +633,315 @@ describe('MCP projectPath into a sensitive folder (#1930)', () => {
});
});
// The MCP server's default project comes from the client (initialize's rootUri
// or workspaceFolders, or roots/list), the launch cwd, or --path. It gets the
// refusal an agent's projectPath gets: a sensitive folder is not opened,
// watched or synced, and the session runs with no default project.
describe('MCP default project in a sensitive folder', () => {
const { state, link } = sensitiveFixture();
// The built engine and session. Under vitest the src engine can't load the
// CodeGraph chain it opens a default project with, so it would open none at
// all and pass these tests with the guard missing.
const DIST = path.resolve(__dirname, '../dist');
const { MCPEngine } = require(path.join(DIST, 'mcp', 'engine.js')) as typeof import('../src/mcp/engine');
const { MCPSession } = require(path.join(DIST, 'mcp', 'session.js')) as typeof import('../src/mcp/session');
const TOKEN = 'FAKE-TOKEN-DEFAULT-ROOT';
const NO_DEFAULT_INSTRUCTIONS = 'per-project; pass projectPath';
type Engine = InstanceType<typeof MCPEngine>;
type Reply = { isError?: boolean; content: Array<{ text: string }>; instructions?: string };
const engines: Engine[] = [];
const children: ChildProcessWithoutNullStreams[] = [];
let config: string;
let project: string;
let stderr: string;
const text = (reply: Reply): string => reply.content.map((c) => c.text).join('\n');
beforeEach(async () => {
config = path.join(state.home, '.config');
fs.mkdirSync(path.join(config, 'tool'));
fs.writeFileSync(
path.join(config, 'tool', 'secrets.ts'),
`export function readToken(): string { return '${TOKEN}'; }\n`
);
(await CodeGraph.init(config, { index: true })).close();
project = path.join(state.tmp, 'project');
fs.writeFileSync(path.join(project, 'app.ts'), 'export function serveApp(): number { return 1; }\n');
(await CodeGraph.init(project, { index: true })).close();
stderr = '';
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write);
}, 60_000);
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(engines.splice(0).map((engine) => engine.stop()));
for (const child of children.splice(0)) {
if (child.exitCode === null && child.signalCode === null) {
const closed = once(child, 'close');
child.kill('SIGKILL');
await closed;
}
}
// A daemon a launcher started for the folder holds it open (only ever
// without the guard): stop it before the folder is removed.
let daemonPid = 0;
try {
daemonPid = JSON.parse(fs.readFileSync(path.join(config, '.codegraph', 'daemon.pid'), 'utf8')).pid;
} catch { /* no daemon */ }
if (daemonPid > 0) {
try { process.kill(daemonPid, 'SIGKILL'); } catch { /* already gone */ }
for (let i = 0; i < 200; i++) {
try { process.kill(daemonPid, 0); } catch { break; }
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}, 60_000);
/** An MCP client in this process, on a new session of `engine`; roots/list answers `roots`. */
function connect(engine: Engine, roots: string[] = []) {
let deliver: ((message: object) => Promise<void>) | null = null;
const replies = new Map<number, Reply>();
const asked: string[] = [];
const transport = {
start: (handler: (message: object) => Promise<void>) => { deliver = handler; },
stop: () => {},
send: () => {},
notify: () => {},
request: async (method: string) => {
asked.push(method);
return method === 'roots/list'
? { roots: roots.map((root) => ({ uri: pathToFileURL(root).href, name: path.basename(root) })) }
: {};
},
sendResult: (id: number, result: Reply) => { replies.set(id, result); },
sendError: (id: number, _code: number, message: string) => {
replies.set(id, { isError: true, content: [{ text: message }] });
},
};
new MCPSession(transport as never, engine).start();
let nextId = 0;
const call = async (method: string, params: object): Promise<Reply> => {
const id = nextId++;
await deliver!({ jsonrpc: '2.0', id, method, params });
return replies.get(id)!;
};
const initialize = (params: object = {}) =>
call('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '0' }, ...params });
const node = (symbol: string) =>
call('tools/call', { name: 'codegraph_node', arguments: { symbol, includeCode: true } });
return { call, initialize, node, asked };
}
it('does not open, watch or sync a sensitive folder as the default project', async () => {
const viaLink = link(config, path.join(state.tmp, 'elsewhere', 'config'));
// The folder, one inside it, a missing one under it (the up-walk lands on
// the folder's index), and a link to it.
for (const searchFrom of [config, path.join(config, 'tool'), path.join(config, 'no-such-dir'), viaLink]) {
const engine = new MCPEngine({ watch: true });
engines.push(engine);
await engine.ensureInitialized(searchFrom);
engine.retryInitializeSync(searchFrom); // what each tool call retries
expect(engine.hasDefaultCodeGraph(), searchFrom).toBe(false);
// The no-default-project guidance: success-shaped, never isError.
const bare = await engine.getToolHandler().execute('codegraph_node', { symbol: 'readToken', includeCode: true });
expect(bare.isError, searchFrom).toBeFalsy();
expect(text(bare), searchFrom).toContain('No CodeGraph project is loaded');
expect(text(bare), searchFrom).toMatch(/sensitive directory/i);
expect(text(bare), searchFrom).toContain('projectPath');
expect(text(bare), searchFrom).not.toContain(TOKEN);
// Naming the folder is refused as before.
const named = await engine.getToolHandler().execute('codegraph_node', {
symbol: 'readToken',
includeCode: true,
projectPath: config,
});
expect(named.isError, searchFrom).toBe(true);
expect(text(named), searchFrom).toMatch(/sensitive directory/i);
expect(text(named), searchFrom).not.toContain(TOKEN);
}
// Nothing took the folder's writer slot or watched it, and the log says why.
expect(fs.existsSync(path.join(config, '.codegraph', 'writer.pid'))).toBe(false);
expect(stderr).not.toContain('File watcher active');
expect(stderr).toMatch(/sensitive directory.*no default project/i);
}, 60_000);
it('still opens, watches and syncs an ordinary project as the default project', async () => {
const engine = new MCPEngine({ watch: true });
engines.push(engine);
await engine.ensureInitialized(project);
expect(engine.hasDefaultCodeGraph()).toBe(true);
const result = await engine.getToolHandler().execute('codegraph_node', { symbol: 'serveApp', includeCode: true });
expect(result.isError).toBeFalsy();
expect(text(result)).toContain('serveApp');
// The lifecycle the refused folder skips: this engine took the writer slot.
expect(fs.existsSync(path.join(project, '.codegraph', 'writer.pid'))).toBe(true);
}, 60_000);
it('sends the no-default-project instructions for a sensitive rootUri, workspaceFolders or cwd', async () => {
const uri = pathToFileURL(config).href;
const cases: Array<[string, object, string]> = [
['rootUri', { rootUri: uri }, path.join(state.tmp, 'elsewhere')],
['workspaceFolders', { workspaceFolders: [{ uri, name: 'config' }] }, path.join(state.tmp, 'elsewhere')],
['cwd', {}, config],
];
for (const [source, params, cwd] of cases) {
const restoreCwd = vi.spyOn(process, 'cwd').mockReturnValue(cwd);
try {
const engine = new MCPEngine({ watch: true });
engines.push(engine);
const client = connect(engine);
const init = await client.initialize(params);
expect(init.instructions, source).toContain(NO_DEFAULT_INSTRUCTIONS);
const result = await client.node('readToken');
expect(result.isError, source).toBeFalsy();
expect(text(result), source).toContain('No CodeGraph project is loaded');
expect(text(result), source).not.toContain(TOKEN);
expect(engine.hasDefaultCodeGraph(), source).toBe(false);
} finally {
restoreCwd.mockRestore();
}
}
// A client naming an ordinary project still gets it as the default.
const engine = new MCPEngine({ watch: true });
engines.push(engine);
const client = connect(engine);
const init = await client.initialize({ rootUri: pathToFileURL(project).href });
expect(init.instructions).not.toContain(NO_DEFAULT_INSTRUCTIONS);
expect(text(await client.node('serveApp'))).toContain('serveApp');
}, 60_000);
it('does not adopt a sensitive folder that roots/list names', async () => {
// The fallback after roots/list is the cwd: keep it off any real index.
const restoreCwd = vi.spyOn(process, 'cwd').mockReturnValue(path.join(state.tmp, 'elsewhere'));
try {
const engine = new MCPEngine({ watch: true });
engines.push(engine);
const client = connect(engine, [config]);
await client.initialize({ capabilities: { roots: {} } });
const result = await client.node('readToken');
expect(client.asked).toContain('roots/list');
expect(result.isError).toBeFalsy();
expect(text(result)).toContain('No CodeGraph project is loaded');
expect(text(result)).not.toContain(TOKEN);
expect(engine.hasDefaultCodeGraph()).toBe(false);
// roots/list naming an ordinary project still makes it the default.
const ordinary = new MCPEngine({ watch: true });
engines.push(ordinary);
const other = connect(ordinary, [project]);
await other.initialize({ capabilities: { roots: {} } });
expect(text(await other.node('serveApp'))).toContain('serveApp');
expect(ordinary.hasDefaultCodeGraph()).toBe(true);
} finally {
restoreCwd.mockRestore();
}
}, 60_000);
it('neither adopts nor lists the indexed sub-projects of a sensitive folder', async () => {
/** A workspace (it has a .git) whose indexes live in its sub-folders. */
const workspace = async (base: string, children: string[]): Promise<string> => {
fs.mkdirSync(path.join(base, '.git'), { recursive: true });
for (const child of children) {
fs.mkdirSync(path.join(base, child));
fs.writeFileSync(path.join(base, child, 'a.ts'), 'export const a = 1;\n');
(await CodeGraph.init(path.join(base, child))).close();
}
return base;
};
// Nothing indexed at or above these, so the down-scan below them decides.
const one = await workspace(path.join(state.home, '.aws', 'one'), ['only']);
const two = await workspace(path.join(state.home, '.aws', 'two'), ['a', 'b']);
expect(resolveServerRoot(one)).toMatchObject({ root: null, candidates: [], refusal: expect.stringMatching(/sensitive directory/i) });
expect(resolveServerRoot(two)).toMatchObject({ root: null, candidates: [] });
// The same workspaces elsewhere: the single sub-project is adopted, two are listed.
const okOne = await workspace(path.join(state.tmp, 'elsewhere', 'one'), ['only']);
const okTwo = await workspace(path.join(state.tmp, 'elsewhere', 'two'), ['a', 'b']);
expect(resolveServerRoot(okOne)).toMatchObject({ root: path.join(okOne, 'only'), viaSubScan: true, refusal: null });
expect(resolveServerRoot(okTwo).candidates).toHaveLength(2);
}, 60_000);
// The launcher keys its shared daemon on the root it resolves from the cwd or
// --path, so this runs the real `serve --mcp` with daemon sharing on.
it('starts no daemon for a launch from a sensitive cwd or --path, and serves no default project', async () => {
const elsewhere = path.join(state.tmp, 'elsewhere');
const launches: Array<[string, string, string[]]> = [
['cwd', config, []],
['--path', elsewhere, ['--path', config]],
];
for (const [source, cwd, args] of launches) {
const env: NodeJS.ProcessEnv = {
...process.env,
CODEGRAPH_WASM_RELAUNCHED: '1',
CODEGRAPH_NO_UPDATE_CHECK: '1',
// Should a daemon start anyway, it leaves soon after its client does.
CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '1000',
};
delete env.CODEGRAPH_NO_DAEMON;
delete env.CODEGRAPH_DAEMON_INTERNAL;
const child = spawn(
process.execPath,
[...WASM_RUNTIME_FLAGS, path.join(DIST, 'bin', 'codegraph.js'), 'serve', '--mcp', ...args],
{ cwd, stdio: ['pipe', 'pipe', 'pipe'], env }
) as ChildProcessWithoutNullStreams;
children.push(child);
const messages: Array<{ id?: number; result?: Reply }> = [];
let out = '';
let err = '';
child.stdout.on('data', (chunk) => {
out += chunk.toString('utf8');
let newline: number;
while ((newline = out.indexOf('\n')) !== -1) {
const line = out.slice(0, newline).trim();
out = out.slice(newline + 1);
try { if (line) messages.push(JSON.parse(line)); } catch { /* not JSON-RPC */ }
}
});
child.stderr.on('data', (chunk) => { err += chunk.toString('utf8'); });
const send = (message: object): void => { child.stdin.write(JSON.stringify(message) + '\n'); };
const reply = async (id: number): Promise<Reply> => {
const deadline = Date.now() + 30_000;
for (;;) {
const hit = messages.find((m) => m.id === id && m.result);
if (hit) return hit.result!;
if (Date.now() > deadline) throw new Error(`${source}: no reply ${id}; stderr: ${err}`);
await new Promise((resolve) => setTimeout(resolve, 25));
}
};
send({
jsonrpc: '2.0', id: 0, method: 'initialize',
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '0' } },
});
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
send({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'codegraph_node', arguments: { symbol: 'readToken', includeCode: true } },
});
const init = await reply(0);
const result = await reply(1);
expect(init.instructions, source).toContain(NO_DEFAULT_INSTRUCTIONS);
expect(result.isError, source).toBeFalsy();
expect(text(result), source).toContain('No CodeGraph project is loaded');
expect(text(result), source).not.toContain(TOKEN);
// No daemon lock, no writer slot: nothing was set up in the folder.
expect(fs.existsSync(path.join(config, '.codegraph', 'daemon.pid')), source).toBe(false);
expect(fs.existsSync(path.join(config, '.codegraph', 'writer.pid')), source).toBe(false);
expect(err, source).toMatch(/sensitive directory/i);
const closed = once(child, 'close');
child.kill('SIGKILL');
await closed;
}
}, 120_000);
});
describe('MCP Input Validation', () => {
let testDir: string;
let cg: CodeGraph;
+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;
}