{"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":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"v3/@claude-flow/cli/src/services/harness-project-anchor.ts","lineNumber":69,"sourceCode":"  manifestPath?: string;\n}\n\nfunction normalizeHash(value: string): string {\n  const trimmed = value.trim().toLowerCase();\n  return trimmed.startsWith('sha256:') ? trimmed : `sha256:${trimmed}`;\n}\n\nfunction containedPath(projectRoot: string, requested: string): string {\n  const root = realpathSync(resolve(projectRoot));\n  const absolute = isAbsolute(requested) ? resolve(requested) : resolve(root, requested);\n  const lexical = relative(root, absolute);\n  if (lexical === '..' || lexical.startsWith(`..${sep}`) || isAbsolute(lexical)) {\n    throw new Error('flywheel anchor path must stay inside project root');\n  }\n  const actual = realpathSync(absolute);\n  const physical = relative(root, 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":51,"sourceCodeEnd":87,"githubUrl":"https://github.com/ruvnet/ruflo/blob/6b01dc5a687b26b3e218f796de45ec51f8fa9e8c/v3/@claude-flow/cli/src/services/harness-project-anchor.ts#L51-L87","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before: .claude/eval/anchor.json -> /shared/anchor.json (escapes root)\nrm .claude/eval/anchor.json\ncp /shared/anchor.json .claude/eval/anchor.json\n# now a real file inside the root","handlingStrategy":"validation","validationCode":"import { realpathSync, relative, sep, isAbsolute } from 'node:path';\nfunction assertNoSymlinkEscape(root: string, requested: string): void {\n  const realRoot = realpathSync(root);\n  const realTarget = realpathSync(requested);\n  const rel = relative(realRoot, realTarget);\n  if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {\n    throw new Error(`anchor symlink escapes project root: ${requested} -> ${realTarget}`);\n  }\n}\nassertNoSymlinkEscape(root, resolvedAnchorPath);","typeGuard":"const isSymlinkContained = (root: string, requested: string): boolean => {\n  try {\n    const rel = relative(realpathSync(root), realpathSync(requested));\n    return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);\n  } catch { return false; }\n};","tryCatchPattern":"try {\n  loadEffectiveFlywheelAnchor(root, { anchorPath, anchorHash });\n} catch (e) {\n  if (e instanceof Error && /symlink escapes project root/.test(e.message)) {\n    throw new Error(`security: anchor symlink escapes root — replace with a real file`);\n  }\n  throw e;\n}","preventionTips":["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."],"tags":["security","symlink","path-traversal","filesystem","anchor"],"backgroundTag":null,"analyzedSha":"6b01dc5a687b26b3e218f796de45ec51f8fa9e8c","analyzedAt":"2026-08-12T13:20:50.148Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}