ruvnet/ruflo · error · Error
flywheel anchor symlink escapes project root
Error message
flywheel anchor symlink escapes project root
What it means
Thrown by containedPath() during the PHYSICAL path-containment check (after realpathSync). The lexical path looked contained, but the resolved physical target — after following any symlinks — lies outside the project root. This catches symlink-based escapes that the lexical check (error 378) cannot detect, e.g. an in-repo symlink pointing to /etc.
Source
Thrown at v3/@claude-flow/cli/src/services/harness-project-anchor.ts:69
manifestPath?: string;
}
function normalizeHash(value: string): string {
const trimmed = value.trim().toLowerCase();
return trimmed.startsWith('sha256:') ? trimmed : `sha256:${trimmed}`;
}
function containedPath(projectRoot: string, requested: string): string {
const root = realpathSync(resolve(projectRoot));
const absolute = isAbsolute(requested) ? resolve(requested) : resolve(root, requested);
const lexical = relative(root, absolute);
if (lexical === '..' || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) {
throw new Error('flywheel anchor path must stay inside project root');
}
const actual = realpathSync(absolute);
const physical = relative(root, actual);
if (physical === '..' || physical.startsWith(`..${sep}`) || isAbsolute(physical)) {
throw new Error('flywheel anchor symlink escapes project root');
}
return actual;
}
function parseTasks(path: string): { version: string; tasks: HumanEvalTask[] } {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
schemaVersion?: string;
version?: string;
tasks?: HumanEvalTask[];
};
if (parsed.schemaVersion && parsed.schemaVersion !== PROJECT_ANCHOR_SCHEMA) {
throw new Error(`unsupported flywheel anchor schema: ${parsed.schemaVersion}`);
}
if (!Array.isArray(parsed.tasks) || parsed.tasks.length < 4) {
throw new Error('project flywheel anchor requires at least 4 labelled tasks');
}
const ids = new Set<string>();
for (const [index, task] of parsed.tasks.entries()) {View on GitHub (pinned to 6b01dc5a68)
Solutions
- Remove the escaping symlink and replace it with a real file inside the project root.
- If referencing shared content, copy it into the repo rather than symlinking out.
- Audit in-repo symlinks for targets escaping the root: find . -type l -exec sh -c 'readlink -f "$1" | grep -v "^$(pwd)/"' _ {} \;
- Ensure the anchor file is a regular file before calling loadEffectiveFlywheelAnchor.
Example fix
# before: .claude/eval/anchor.json -> /shared/anchor.json (escapes root) rm .claude/eval/anchor.json cp /shared/anchor.json .claude/eval/anchor.json # now a real file inside the root
Defensive patterns
Strategy: validation
Validate before calling
import { realpathSync, relative, sep, isAbsolute } from 'node:path';
function assertNoSymlinkEscape(root: string, requested: string): void {
const realRoot = realpathSync(root);
const realTarget = realpathSync(requested);
const rel = relative(realRoot, realTarget);
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new Error(`anchor symlink escapes project root: ${requested} -> ${realTarget}`);
}
}
assertNoSymlinkEscape(root, resolvedAnchorPath); Type guard
const isSymlinkContained = (root: string, requested: string): boolean => {
try {
const rel = relative(realpathSync(root), realpathSync(requested));
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
} catch { return false; }
}; Try / catch
try {
loadEffectiveFlywheelAnchor(root, { anchorPath, anchorHash });
} catch (e) {
if (e instanceof Error && /symlink escapes project root/.test(e.message)) {
throw new Error(`security: anchor symlink escapes root — replace with a real file`);
}
throw e;
} Prevention
- Do not symlink anchor files to targets outside the project root.
- Copy shared anchors into the repo instead of symlinking out.
- Audit in-repo symlinks in CI: find . -type l -exec test ! -r "$(pwd)/$(readlink -f {})" \;.
- Treat a symlink-escape error as a potential security incident, not a convenience failure.
When it happens
Trigger: An anchor file inside the project root (e.g. .claude/eval/anchor.json) is a symlink whose target is outside the project root (e.g. ln -s /etc/passwd .claude/eval/anchor.json). realpathSync resolves it, relative() shows '..' , and the guard fires.
Common situations: A developer symlinked the anchor to a shared external file for convenience; a compromised setup where an attacker planted a symlink to read/overwrite files outside the repo; a dotfile manager that symlinks eval assets from outside.
Related errors
- flywheel anchor path must stay inside project root
- AI job registry is a symlink (refusing): ${path}
- refusing symlink: ${file}
- AI budget file is a symlink (refusing): ${path}
- ${label} contains null bytes
AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12).
Data as JSON: /api/errors/ffc615d734017f44.
Report an issue: GitHub.