{"record":{"id":"ffc615d734017f44","repo":"ruvnet/ruflo","slug":"flywheel-anchor-symlink-escapes-project-root","errorCode":null,"errorMessage":"flywheel anchor symlink escapes project root","messagePattern":"flywheel anchor symlink escapes project root","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"v3/@claude-flow/cli/src/services/harness-project-anchor.ts","lineNumber":80,"sourceCode":"  // Comparing a realpath'd root against a NON-realpath'd candidate (as this\n  // did) makes every such project look like an escape, so a project anchored\n  // anywhere under a symlink was rejected outright. Compare like with like:\n  // the lexical guard accepts either spelling of the root, and the symlink\n  // guard below still resolves the target and re-checks it physically.\n  const rootLexical = resolve(projectRoot);\n  const rootPhysical = realpathSync(rootLexical);\n  const absolute = isAbsolute(requested) ? resolve(requested) : resolve(rootLexical, requested);\n  const escapes = (base: string): boolean => {\n    const rel = relative(base, absolute);\n    return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);\n  };\n  if (escapes(rootLexical) && escapes(rootPhysical)) {\n    throw new Error('flywheel anchor path must stay inside project root');\n  }\n  const actual = realpathSync(absolute);\n  const physical = relative(rootPhysical, actual);\n  if (physical === '..' || physical.startsWith(`..${sep}`) || isAbsolute(physical)) {\n    throw new Error('flywheel anchor symlink escapes project root');\n  }\n  return actual;\n}\n\nfunction parseTasks(path: string): { version: string; tasks: HumanEvalTask[] } {\n  const parsed = JSON.parse(readFileSync(path, 'utf8')) as {\n    schemaVersion?: string;\n    version?: string;\n    tasks?: HumanEvalTask[];\n  };\n  if (parsed.schemaVersion && parsed.schemaVersion !== PROJECT_ANCHOR_SCHEMA) {\n    throw new Error(`unsupported flywheel anchor schema: ${parsed.schemaVersion}`);\n  }\n  if (!Array.isArray(parsed.tasks) || parsed.tasks.length < 4) {\n    throw new Error('project flywheel anchor requires at least 4 labelled tasks');\n  }\n  const ids = new Set<string>();\n  for (const [index, task] of parsed.tasks.entries()) {","sourceCodeStart":62,"sourceCodeEnd":98,"githubUrl":"https://github.com/ruvnet/ruflo/blob/2602b642d92234c710ffbe96bfb33007d481ceab/v3/@claude-flow/cli/src/services/harness-project-anchor.ts#L62-L98","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"# before: shared eval set linked into the repo\n.claude/eval/tasks.json -> /srv/shared/eval-tasks.json\n\n# after: real vendored copy\nrm .claude/eval/tasks.json && cp /srv/shared/eval-tasks.json .claude/eval/tasks.json","handlingStrategy":"validation","validationCode":"import { lstatSync, realpathSync } from 'node:fs';\nimport { isAbsolute, relative, resolve, sep } from 'node:path';\n\n// Mirrors the physical (post-realpath) half of containedPath().\nfunction resolvesInsideRoot(projectRoot: string, requested: string): boolean {\n  const root = realpathSync(resolve(projectRoot));\n  const absolute = isAbsolute(requested) ? resolve(requested) : resolve(root, requested);\n  let actual: string;\n  try {\n    actual = realpathSync(absolute);\n  } catch {\n    return true; // non-existent paths are fine here; let the library handle ENOENT\n  }\n  const rel = relative(root, actual);\n  return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);\n}\n\nif (!resolvesInsideRoot(root, opts.anchorPath)) {\n  throw new Error(`anchor path is a symlink escaping the project root: ${opts.anchorPath}`);\n}","typeGuard":null,"tryCatchPattern":"try {\n  return loadEffectiveFlywheelAnchor(root, opts);\n} catch (e) {\n  if (e?.message === 'flywheel anchor symlink escapes project root') {\n    // materialize the file (cp -L), update the pinned hash, then retry\n    throw new Error(`Anchor '${opts.anchorPath}' is a symlink outside the project. Replace it with a real copy.`);\n  }\n  throw e;\n}","preventionTips":["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"],"tags":["security","symlink","path-containment","anchor"],"backgroundTag":"symlink-escape","analyzedSha":"2602b642d92234c710ffbe96bfb33007d481ceab","analyzedAt":"2026-09-15T22:58:14.805Z","contentChangedAt":"2026-09-15T22:58:14.805Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}