ruvnet/ruflo · error
flywheel anchor symlink escapes project root
Error message
flywheel anchor symlink escapes project root
What it means
The second half of `containedPath`: after the lexical check passes, `realpathSync` resolves symlinks and the *physical* path is re-checked for containment. A path inside the root that is a symlink pointing outside the project is rejected — closing the classic symlink-escape hole that defeats purely lexical path-traversal defenses.
Solutions
- Replace the symlink with a real copy (`cp -L` the file into the repo) and commit it
- If linking must stay, point the link at a file that also lives inside the project root
- After materializing the file, update the pinned hash if contents changed
Example fix
# before: shared eval set linked into the repo .claude/eval/tasks.json -> /srv/shared/eval-tasks.json # after: real vendored copy rm .claude/eval/tasks.json && cp /srv/shared/eval-tasks.json .claude/eval/tasks.json
Defensive patterns
Strategy: validation
Validate before calling
import { lstatSync, realpathSync } from 'node:fs';
import { isAbsolute, relative, resolve, sep } from 'node:path';
// Mirrors the physical (post-realpath) half of containedPath().
function resolvesInsideRoot(projectRoot: string, requested: string): boolean {
const root = realpathSync(resolve(projectRoot));
const absolute = isAbsolute(requested) ? resolve(requested) : resolve(root, requested);
let actual: string;
try {
actual = realpathSync(absolute);
} catch {
return true; // non-existent paths are fine here; let the library handle ENOENT
}
const rel = relative(root, actual);
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
}
if (!resolvesInsideRoot(root, opts.anchorPath)) {
throw new Error(`anchor path is a symlink escaping the project root: ${opts.anchorPath}`);
} Try / catch
try {
return loadEffectiveFlywheelAnchor(root, opts);
} catch (e) {
if (e?.message === 'flywheel anchor symlink escapes project root') {
// materialize the file (cp -L), update the pinned hash, then retry
throw new Error(`Anchor '${opts.anchorPath}' is a symlink outside the project. Replace it with a real copy.`);
}
throw e;
} Prevention
- Commit anchor task files as real files, never symlinks to shared locations
- Check intermediate directories too — any link in the path can escape
- Run `find .claude -type l` as a CI preflight to catch planted links
When it happens
Trigger: The anchor tasks file inside the repo (or any path component leading to it) is a symlink to a location outside the project root — shared eval sets, dotfiles-style links, or chains of links that eventually leave the root.
Common situations: Monorepos or teams sharing one eval set via symlinks; developers linking a personal tasks file into `.claude/eval/`; CI checkouts that materialize some files as links to a shared cache.
Related errors
- flywheel anchor path must stay inside project root
- AI budget file is a symlink (refusing)
- AI job registry is a symlink (refusing)
- build input escapes repository
- extracted binary path failed validation
AI-assisted analysis of ruvnet/ruflo@2602b642d9 (2026-09-15).
Data as JSON: /api/errors/ffc615d734017f44.
Report an issue: GitHub.
Appendix: source
Thrown at v3/@claude-flow/cli/src/services/harness-project-anchor.ts:80
// Comparing a realpath'd root against a NON-realpath'd candidate (as this
// did) makes every such project look like an escape, so a project anchored
// anywhere under a symlink was rejected outright. Compare like with like:
// the lexical guard accepts either spelling of the root, and the symlink
// guard below still resolves the target and re-checks it physically.
const rootLexical = resolve(projectRoot);
const rootPhysical = realpathSync(rootLexical);
const absolute = isAbsolute(requested) ? resolve(requested) : resolve(rootLexical, requested);
const escapes = (base: string): boolean => {
const rel = relative(base, absolute);
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
};
if (escapes(rootLexical) && escapes(rootPhysical)) {
throw new Error('flywheel anchor path must stay inside project root');
}
const actual = realpathSync(absolute);
const physical = relative(rootPhysical, 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 2602b642d9)