diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a703784..238ff6659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 1048c7613..7bd5b1260 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -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; + 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) | null = null; + const replies = new Map(); + const asked: string[] = []; + const transport = { + start: (handler: (message: object) => Promise) => { 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 => { + 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 => { + 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 => { + 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; diff --git a/src/directory.ts b/src/directory.ts index 2e7445d38..56277ee1a 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -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 }; } /** diff --git a/src/mcp/engine.ts b/src/mcp/engine.ts index 876462046..54745f5c8 100644 --- a/src/mcp/engine.ts +++ b/src/mcp/engine.ts @@ -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; diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 6535649f7..603e1ecac 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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]); diff --git a/src/mcp/session.ts b/src/mcp/session.ts index 1d5bd79c3..d4b54ff79 100644 --- a/src/mcp/session.ts +++ b/src/mcp/session.ts @@ -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. diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 89fa53101..cbb902b99 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -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' + diff --git a/src/utils.ts b/src/utils.ts index 0a7bca020..a99180949 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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; }