Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
316 changes: 315 additions & 1 deletion __tests__/security.test.ts

Large diffs are not rendered by default.

35 changes: 30 additions & 5 deletions src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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 };
}

/**
Expand Down
29 changes: 26 additions & 3 deletions src/mcp/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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]);
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 28 additions & 6 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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' +
Expand Down
34 changes: 24 additions & 10 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down