From 69d90f04036d7e07d6b43b15e3a162d50868341c Mon Sep 17 00:00:00 2001 From: burnlife001 Date: Sat, 13 Jun 2026 03:04:07 +0800 Subject: [PATCH 1/2] 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}. ` + From 489ea52ab04d52e4748b6e52addd2b0c59089c08 Mon Sep 17 00:00:00 2001 From: burnlife001 Date: Sat, 13 Jun 2026 03:19:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(claude-local):=20address=20Greptile=20r?= =?UTF-8?q?eview=20=E2=80=94=20shared=20cache,=20tighter=20regex,=20manage?= =?UTF-8?q?d=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from PR review: 1. Share contentCache across recursive calls instead of allocating a fresh Map per invocation. This fixes the bug where a file referenced both from the root AGENTS.md and from a nested sub-file would silently drop the nested occurrence because the inner call's empty cache had no entry. 2. Tighten regex from /@\S+\.md/ to /@\S+\/\S*\.md/ — require at least one "/" in the matched path so that bare @user.md mentions and email-like docs@company.md are never treated as file references. All genuine file refs carry a directory separator. 3. Only widen the security boundary by 2 levels when the bundle mode is explicitly "managed". External (user-configured) bundles keep their exact configured root as the boundary. Co-Authored-By: Claude Opus 4.7 --- .../claude-local/src/server/execute.ts | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 789ae01296..c960a25746 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -141,16 +141,19 @@ async function resolveAtReferences( content: string, baseDir: string, instructionsRoot: string, + contentCache: Map = new Map(), 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; + // Match @ followed by an optional path-like reference to a .md file. + // Require at least one "/" in the path so that bare words like @user.md + // or email-like strings (docs@company.md) are not treated as file refs. + // All genuine file refs carry a directory separator: @./file.md, + // @../../shared.md, @path/to/file.md. + const AT_REF_RE = /@[ \t]*(\S+\/\S*\.md)/gi; // First pass: find all @ references and resolve their absolute paths. // We collect replacement targets without mutating the string yet so that @@ -217,15 +220,12 @@ async function resolveAtReferences( // 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. + // contentCache is shared across recursive calls so nested refs that + // point to a file already expanded at a higher depth are also served. const cached = contentCache.get(resolvedPath); if (cached !== undefined) { content = content.slice(0, start) + cached + content.slice(end); @@ -241,6 +241,7 @@ async function resolveAtReferences( refContent, path.dirname(resolvedPath), instructionsRoot, + contentCache, visited, depth + 1, ); @@ -571,14 +572,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise/instructions/) and - // widened by 2 levels to encompass shared files at the …/agents/ level. + // to Claude Code. + // + // The security boundary is the instructions root directory. For managed + // bundles the root points at …/agents//instructions/ and shared + // files live at the …/agents/ level, so we widen the boundary by 2 + // directory levels to allow @../../shared_all.md to resolve. External + // (user-configured) bundles keep their exact root as the boundary — the + // operator chose that directory deliberately. const configuredRoot = asString(config.instructionsRootPath, "").trim(); + const bundleMode = asString(config.instructionsBundleMode, "").trim(); const instructionsRoot = configuredRoot || path.dirname(instructionsFilePath); - const instructionsBoundary = configuredRoot - ? path.resolve(instructionsRoot, "..", "..") - : instructionsRoot; + const instructionsBoundary = + configuredRoot && bundleMode === "managed" + ? path.resolve(instructionsRoot, "..", "..") + : instructionsRoot; instructionsContent = await resolveAtReferences( instructionsContent, path.dirname(instructionsFilePath),