fix(extraction): index files reached through in-root symlinks that point outside the repo (#935) (#956)

The directory walk deliberately follows an in-root symlink whose target
lives outside the repo root (the standard Dota custom-game layout, where
`game/` and `content/` link into the SDK tree) and enumerates the files
under it. But the read path then rejected every one of them via the
strict symlink-escape guard, logging `Path traversal blocked in batch
reader` and indexing nothing — discovery and the reader disagreed.

Add an opt-in `allowSymlinkEscape` to validatePathWithinRoot that waives
only the realpath-escape rejection (the lexical `../` guard still
applies) and pass it at the three indexing read sites (batch reader,
indexFile, indexFileWithContent). The content-serving sinks
(ContextBuilder, MCP tools) keep the strict guard, so this stays inside
the #527 model: indexing now follows the symlink, getCode still refuses
to serve out-of-root contents.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-22 17:23:17 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d1121e46f0
commit 6459ead6aa
4 changed files with 79 additions and 6 deletions
+46
View File
@@ -232,6 +232,26 @@ describe('Symlink escape prevention (#527)', () => {
expect(validatePathWithinRoot(root, 'src/inlink.ts')).not.toBeNull();
});
// The INDEXING read path opts into following in-root symlinks the directory
// walk already descended into — discovery and the reader must agree, or files
// reached via an in-root symlink-to-outside fail to index (#935). The lexical
// `../` guard is NOT waived, and content-serving sinks never pass the flag.
it('allowSymlinkEscape follows an in-repo symlink to an out-of-root FILE (indexing read)', () => {
if (!link(path.join(root, 'escape'), path.join(outside, 'pkg', 'secret.txt'))) return;
expect(validatePathWithinRoot(root, 'escape', { allowSymlinkEscape: true })).not.toBeNull();
});
it('allowSymlinkEscape follows a path through an in-repo out-of-root DIR symlink (indexing read)', () => {
if (!link(path.join(root, 'escapedir'), path.join(outside, 'pkg'))) return;
expect(validatePathWithinRoot(root, 'escapedir/secret.txt', { allowSymlinkEscape: true })).not.toBeNull();
});
it('allowSymlinkEscape STILL rejects a lexical ../ traversal (guard not waived)', () => {
expect(
validatePathWithinRoot(root, `../${path.basename(outside)}/pkg/secret.txt`, { allowSymlinkEscape: true })
).toBeNull();
});
it('end-to-end: getCode never serves an out-of-root file reached via a dir symlink', async () => {
fs.writeFileSync(path.join(outside, 'pkg', 'leak.ts'),
'export function leaked() { return "LEAKED-ZZZ-9"; }\n');
@@ -250,6 +270,32 @@ describe('Symlink escape prevention (#527)', () => {
cg.close();
}
});
it('end-to-end (#935): indexes source reached through an in-root dir symlink to outside', async () => {
// The Dota custom-game layout symlinks `game/` and `content/` into an SDK
// tree outside the repo. Before #935 the batch reader's strict symlink-escape
// guard blocked every file under them, so nothing indexed — even though the
// directory walk deliberately followed the symlink to enumerate them. The
// reader now agrees with discovery: the file indexes.
fs.writeFileSync(path.join(outside, 'pkg', 'vendored.ts'),
'export function vendoredHelper() { return "LEAKED-ZZZ-9"; }\n');
if (!link(path.join(root, 'game'), path.join(outside, 'pkg'))) return;
const cg = CodeGraph.initSync(root, { config: { include: ['**/*.ts'], exclude: [] } });
try {
await cg.indexAll();
// The symlinked-in file is now part of the graph...
const names = cg.getNodesByKind('function').map((n) => n.name);
expect(names).toContain('vendoredHelper');
// ...but its out-of-root contents are STILL never served (the #527 sink
// stays strict — indexing relaxes only the read path, not getCode).
for (const n of cg.getNodesByKind('function')) {
expect((await cg.getCode(n.id)) ?? '').not.toContain('LEAKED-ZZZ-9');
}
} finally {
cg.close();
}
});
});
describe('validateProjectPath — sensitive directory blocking', () => {