mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +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
@@ -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
@@ -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
@@ -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