From 69d90f04036d7e07d6b43b15e3a162d50868341c Mon Sep 17 00:00:00 2001 From: burnlife001 Date: Sat, 13 Jun 2026 03:04:07 +0800 Subject: [PATCH] feat(claude-local): resolve @ file references in agent instructions Add resolveAtReferences() to expand @path/to/file.md patterns in AGENTS.md before the prompt reaches Claude Code. Supports 5 syntax variants (bare, backtick-wrapped, parenthesis-wrapped, double-quoted, Windows backslash), optional whitespace between @ and path, recursive expansion with cycle detection, and a security boundary confined to the managed instructions root (widened 2 levels for shared files). Co-Authored-By: Claude Opus 4.7 --- .../claude-local/src/server/execute.ts | 143 +++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index a265606183..789ae01296 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -127,6 +127,133 @@ function isBedrockAuth(env: Record): boolean { ); } +/** + * Resolve @ file references in agent instructions content. + * + * Expands patterns like `@../../shared_all.md` by reading the referenced file + * and inlining its content. Supports recursive expansion (nested @ refs in + * referenced files) with cycle detection and a depth limit. + * + * Security: only resolves files within `instructionsRoot`. Paths that escape + * beyond that root (via excessive `../`) are silently skipped. + */ +async function resolveAtReferences( + content: string, + baseDir: string, + instructionsRoot: string, + visited: Set = new Set(), + depth: number = 0, +): Promise { + const MAX_DEPTH = 5; + if (depth > MAX_DEPTH) return content; + + // Allow optional horizontal whitespace between @ and the path so that + // @ ./path.md @`./path.md` @(./path.md) @"./path.md" + // all match (not just the no-space forms). + const AT_REF_RE = /@[ \t]*(\S+\.md)/gi; + + // First pass: find all @ references and resolve their absolute paths. + // We collect replacement targets without mutating the string yet so that + // match positions stay valid. + const replacements: Array<{ + full: string; + resolvedPath: string; + start: number; + end: number; + }> = []; + + AT_REF_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = AT_REF_RE.exec(content)) !== null) { + const rawPath = match[1].trim(); + // Strip markdown / prose wrappers so that all of these resolve + // to the same ./Test.md: + // @(./Test.md) @`./Test.md` @"./Test.md" @./Test.md + const matchedPath = rawPath + .replace(/^[`"'()]+|[`"'()]+$/g, "") + // Normalize Windows backslash separators to forward slashes. + .replace(/\\/g, "/"); + const matchStart = match.index; + let matchEnd = matchStart + match[0].length; + // The regex \S+\.md stops at .md, so a closing delimiter after the + // path is left unconsumed. Eat one trailing char that looks like a + // paired wrapper so the replacement removes the whole construct. + let fullMatch = match[0]; + if (/^[`"'()]/.test(rawPath)) { + const trailing = content.charAt(matchEnd); + if (trailing === "`" || trailing === '"' || trailing === "'" || trailing === ")") { + matchEnd += 1; + fullMatch = content.slice(matchStart, matchEnd); + } + } + const resolvedPath = path.resolve(baseDir, matchedPath); + const normalizedRoot = path.resolve(instructionsRoot) + path.sep; + + // Security: reject paths that escape the instructions root. + if ( + !resolvedPath.startsWith(normalizedRoot) && + resolvedPath !== path.resolve(instructionsRoot) + ) { + continue; + } + + // Verify the target is a readable file. + try { + const stat = await fs.stat(resolvedPath); + if (!stat.isFile()) continue; + } catch { + continue; + } + + replacements.push({ + full: fullMatch, + resolvedPath, + start: matchStart, + end: matchEnd, + }); + } + + // Second pass: apply replacements in reverse order so earlier string + // positions remain valid after later (higher-index) substitutions. + replacements.sort((a, b) => b.start - a.start); + + // Cache already-read file contents so that multiple @ references to the + // same file (e.g. the 4 syntax variants all pointing to Test.md) are all + // inlined rather than having the later ones skipped by the visited guard. + const contentCache = new Map(); + + for (const { full, resolvedPath, start, end } of replacements) { + if (visited.has(resolvedPath)) { + // Still inline the file content even if already visited — multiple + // @ references to the same file in the same document must all resolve. + const cached = contentCache.get(resolvedPath); + if (cached !== undefined) { + content = content.slice(0, start) + cached + content.slice(end); + } + continue; + } + visited.add(resolvedPath); + + try { + let refContent = await fs.readFile(resolvedPath, "utf-8"); + // Recursively expand @ references in the referenced file. + refContent = await resolveAtReferences( + refContent, + path.dirname(resolvedPath), + instructionsRoot, + visited, + depth + 1, + ); + contentCache.set(resolvedPath, refContent); + content = content.slice(0, start) + refContent + content.slice(end); + } catch { + // Leave @ reference as-is when the file cannot be read. + } + } + + return content; +} + function resolveClaudeBillingType(env: Record): "api" | "subscription" | "metered_api" { if (isBedrockAuth(env)) return "metered_api"; return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription"; @@ -442,7 +569,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise/instructions/) and + // widened by 2 levels to encompass shared files at the …/agents/ level. + const configuredRoot = asString(config.instructionsRootPath, "").trim(); + const instructionsRoot = configuredRoot || path.dirname(instructionsFilePath); + const instructionsBoundary = configuredRoot + ? path.resolve(instructionsRoot, "..", "..") + : instructionsRoot; + instructionsContent = await resolveAtReferences( + instructionsContent, + path.dirname(instructionsFilePath), + instructionsBoundary, + ); const pathDirective = `\nThe above agent instructions were loaded from ${instructionsFilePath}. ` + `Resolve any relative file references from ${instructionsFileDir}. ` +