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

  1. Remove the escaping symlink and replace it with a real file inside the project root.
  2. If referencing shared content, copy it into the repo rather than symlinking out.
  3. Audit in-repo symlinks for targets escaping the root: find . -type l -exec sh -c 'readlink -f "$1" | grep -v "^$(pwd)/"' _ {} \;
  4. 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

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


AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12). Data as JSON: /api/errors/ffc615d734017f44. Report an issue: GitHub.