{"record":{"id":"6db3c96012ca308b","repo":"ruvnet/ruflo","slug":"flywheel-anchor-path-must-stay-inside-project-root","errorCode":null,"errorMessage":"flywheel anchor path must stay inside project root","messagePattern":"flywheel anchor path must stay inside project root","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"v3/@claude-flow/cli/src/services/harness-project-anchor.ts","lineNumber":75,"sourceCode":"}\n\nfunction containedPath(projectRoot: string, requested: string): string {\n  // The project root has two equally valid spellings when its path crosses a\n  // symlink — on macOS `/tmp/x` and `/private/tmp/x` name the same directory.\n  // 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  }","sourceCodeStart":57,"sourceCodeEnd":93,"githubUrl":"https://github.com/ruvnet/ruflo/blob/2602b642d92234c710ffbe96bfb33007d481ceab/v3/@claude-flow/cli/src/services/harness-project-anchor.ts#L57-L93","documentation":"`containedPath` is the containment guard for project-local flywheel anchors (#2840): it resolves the real project root, resolves the requested path against it, and rejects any path whose *lexical* relative form escapes the root (`..` prefix, or an absolute path resolving elsewhere). Its purpose is that only files inside the project may serve as labelled anchor tasks, so foreign or system files cannot be silently evaluated against.","triggerScenarios":"Calling `loadEffectiveFlywheelAnchor(root, { anchorPath: '../shared/tasks.json' })`, passing an absolute path outside the project, or passing a wrong `projectRoot` (e.g. cwd one level up) so a legitimately-inside path resolves outside.","commonSituations":"Monorepo users pointing at a sibling package's tasks file; CI configs feeding absolute paths; callers computing projectRoot from `process.cwd()` when the project lives elsewhere; Windows drive-letter absolute paths.","solutions":["Put the tasks file inside the project root and pass a project-relative path (or an absolute path that resolves within the root)","Fix the `projectRoot` argument so it names the actual project directory","If the tasks live elsewhere, copy them into the repo (e.g. `.claude/eval/`) and pin them via a manifest or anchorHash"],"exampleFix":"// before: anchor outside the project\nloadEffectiveFlywheelAnchor(root, { anchorPath: '../shared/eval-tasks.json', anchorHash: h });\n\n// after: tasks vendored into the project\nloadEffectiveFlywheelAnchor(root, { anchorPath: '.claude/eval/tasks.json', anchorHash: h });","handlingStrategy":"validation","validationCode":"import { isAbsolute, relative, resolve, realpathSync } from 'node:path';\n\n// Mirrors the lexical half of containedPath().\nfunction isContainedLexically(projectRoot: string, requested: string): boolean {\n  const root = realpathSync(resolve(projectRoot));\n  const absolute = isAbsolute(requested) ? resolve(requested) : resolve(root, requested);\n  const rel = relative(root, absolute);\n  return rel !== '..' && !rel.startsWith(`..${require('node:path').sep}`) && !isAbsolute(rel);\n}\n\nif (!isContainedLexically(root, opts.anchorPath)) {\n  throw new Error(`anchor path escapes project root: ${opts.anchorPath}`);\n}","typeGuard":null,"tryCatchPattern":"try {\n  return loadEffectiveFlywheelAnchor(root, opts);\n} catch (e) {\n  if (e?.message === 'flywheel anchor path must stay inside project root') {\n    // resolve or vendor the file inside root, fix projectRoot, then retry\n    throw new Error(`Anchor path '${opts.anchorPath}' is outside project '${root}'. Copy the tasks into the repo or fix projectRoot.`);\n  }\n  throw e;\n}","preventionTips":["Always pass anchor paths relative to the project root","Double-check projectRoot when the caller is a daemon or CLI running from another cwd","Vendor shared task files into `.claude/eval/` instead of reaching outside the repo"],"tags":["security","path-containment","anchor","validation"],"backgroundTag":"path-traversal-guard","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"}