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 <noreply@anthropic.com>
This commit is contained in:
burnlife001 2026-06-13 03:04:07 +08:00
parent c21f70ef1c
commit 69d90f0403
1 changed files with 142 additions and 1 deletions

View File

@ -127,6 +127,133 @@ function isBedrockAuth(env: Record<string, string>): 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<string> = new Set(),
depth: number = 0,
): Promise<string> {
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<string, string>();
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<string, string>): "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<AdapterExec
let combinedInstructionsContents: string | null = null;
if (instructionsFilePath) {
try {
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
let instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
// Resolve @ file references (e.g. @../../shared_all.md) before passing
// to Claude Code. The security boundary is derived from the managed
// instructions root (which points at …/agents/<id>/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}. ` +