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
+8 -4
View File
@@ -1245,7 +1245,10 @@ export class ExtractionOrchestrator {
const fileContents = await Promise.all(
batch.map(async (fp) => {
try {
const fullPath = validatePathWithinRoot(this.rootDir, fp);
// Indexing read: follow in-root symlinks the directory walk already
// descended into (the `../` guard still applies) so files reached
// via an in-root symlink-to-outside still index (#935).
const fullPath = validatePathWithinRoot(this.rootDir, fp, { allowSymlinkEscape: true });
if (!fullPath) {
logWarn('Path traversal blocked in batch reader', { filePath: fp });
return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: new Error('Path traversal blocked') };
@@ -1551,7 +1554,8 @@ export class ExtractionOrchestrator {
* Index a single file
*/
async indexFile(relativePath: string): Promise<ExtractionResult> {
const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
// Indexing read: follow in-root symlinks (the `../` guard still applies), #935.
const fullPath = validatePathWithinRoot(this.rootDir, relativePath, { allowSymlinkEscape: true });
if (!fullPath) {
return {
@@ -1598,8 +1602,8 @@ export class ExtractionOrchestrator {
content: string,
stats: fs.Stats
): Promise<ExtractionResult> {
// Prevent path traversal
const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
// Prevent `../` traversal; follow in-root symlinks like the directory walk (#935).
const fullPath = validatePathWithinRoot(this.rootDir, relativePath, { allowSymlinkEscape: true });
if (!fullPath) {
logWarn('Path traversal blocked in indexFileWithContent', { relativePath });
return {