Files
codegraph/__tests__/mcp-projectpath-file.test.ts
T
Max HsuandClaude Opus 5.5 1c40e34141 fix(mcp): a projectPath naming a file resolves through its directory
An agent that passed the file it was looking at as `projectPath` got
`isError: true` ("Path is not a directory"), while a path that doesn't
exist at all walked up to the project's `.codegraph/` fine. An error teaches
an agent to stop calling codegraph, and this mistake is recoverable.

`execute()` now takes an existing file to stand for its directory, right
after the length check, and rewrites `args.projectPath` there, so the
project gate, the worker dispatch, the worktree notice and getCodeGraph all
see that directory. A path that can't be stat'd walks up as before.

Path safety: the directory goes through the same checks as any
projectPath, the sensitive-folder refusal on the path itself and on the
root its up-walk lands on, so a file reaches exactly what its directory
reaches and never more. `/etc/hosts` is refused as `/etc` is, a file in
`~/.ssh` as `~/.ssh` is, and `win.ini` in the Windows folder as that folder
is. A symlink inside a project that points at a file in a sensitive folder
is answered from the link's own folder, as a symlinked folder already is;
nothing in the sensitive folder is opened.

Validation on main de0a4eb3 (Windows 11):
- Red/green: the PR's 2 tests (a file in an indexed project, and a file in
  a linked worktree, which keeps its worktree notice) and 1 of ours fail
  with main's tools.ts and pass with the fix. Ours asks for a file and for
  its directory in 4 cases, an indexed project, `~/.ssh`, a folder in no
  project, and the Windows folder's `win.ini`, and requires the same
  answer for both, refusals included.
- A real `serve --mcp` session over stdio with a private HOME: on main,
  codegraph_search and codegraph_status for `<project>/src/a.ts` both
  returned isError "Path is not a directory"; with the fix both answer from
  the project, and `win.ini` is refused as a sensitive system directory.

Original PR #2996 by @maxmilian.
Fixes #2982

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:00:53 +00:00

95 lines
3.8 KiB
TypeScript

/**
* A `projectPath` that names a file inside an indexed project (#2982).
*
* Passing the file it is looking at is an easy agent mistake. It used to be
* refused with `isError: true` ("Path is not a directory") while a path that
* doesn't exist at all walked up to the project's `.codegraph/` fine. A file
* now resolves through its directory like any other path inside the project,
* and the sensitive-folder refusal still applies to a file inside one.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { ToolHandler, __setLoadCodeGraphForTests } from '../src/mcp/tools';
describe('projectPath naming a file (#2982)', { timeout: 30_000 }, () => {
let project: string;
let handler: ToolHandler | undefined;
beforeAll(async () => {
project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-2982-')));
fs.mkdirSync(path.join(project, 'src'));
fs.writeFileSync(path.join(project, 'src', 'a.ts'), 'export function fileTarget() { return 1; }\n');
const cg = await CodeGraph.init(project, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();
cg.close();
__setLoadCodeGraphForTests(CodeGraph);
});
afterAll(async () => {
__setLoadCodeGraphForTests(null);
if (handler) await handler.closeAll();
fs.rmSync(project, { recursive: true, force: true });
});
it('resolves a file inside the project to that project', async () => {
handler = new ToolHandler(null);
const res = await handler.execute('codegraph_search', {
query: 'fileTarget',
projectPath: path.join(project, 'src', 'a.ts'),
});
expect(res.isError).toBeUndefined();
expect(res.content[0]!.text).toMatch(/fileTarget/);
});
it.runIf(process.platform !== 'win32')('still refuses a file inside a sensitive folder', async () => {
const res = await new ToolHandler(null).execute('codegraph_search', {
query: 'anything',
projectPath: '/etc/hosts',
});
expect(res.isError).toBe(true);
expect(res.content[0]!.text).toMatch(/sensitive/);
});
// A file never reaches more than the folder holding it: whatever the folder's
// answer is (the project's results, a sensitive-folder refusal, the
// not-indexed guidance), the file gets that same answer.
it('answers a file exactly as it answers the folder holding it', async () => {
const ask = async (projectPath: string) => {
const h = new ToolHandler(null);
try {
const res = await h.execute('codegraph_search', { query: 'fileTarget', projectPath });
return { isError: res.isError, text: res.content[0]!.text };
} finally {
await h.closeAll();
}
};
const ssh = path.join(os.homedir(), '.ssh');
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-2982-outside-')));
try {
fs.mkdirSync(ssh, { recursive: true });
fs.writeFileSync(path.join(ssh, 'known_hosts'), 'example.com ssh-ed25519 AAAA\n');
fs.writeFileSync(path.join(outside, 'notes.txt'), 'hi\n');
const files = [
path.join(project, 'src', 'a.ts'),
path.join(ssh, 'known_hosts'),
path.join(outside, 'notes.txt'),
...(process.platform === 'win32' && fs.existsSync('C:\\Windows\\win.ini') ? ['C:\\Windows\\win.ini'] : []),
];
for (const file of files) {
expect({ file, ...(await ask(file)) }).toEqual({ file, ...(await ask(path.dirname(file))) });
}
const refused = await ask(path.join(ssh, 'known_hosts'));
expect(refused.isError).toBe(true);
expect(refused.text).toMatch(/sensitive/);
} finally {
fs.rmSync(ssh, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});