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

  1. Replace the symlink with a real copy (`cp -L` the file into the repo) and commit it
  2. If linking must stay, point the link at a file that also lives inside the project root
  3. 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

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


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)