{"record":{"id":"30eaa69b6dcaa8c7","repo":"ruvnet/ruflo","slug":"repo-supervisor-file-is-a-symlink-refusing-pa","errorCode":null,"errorMessage":"Repo-supervisor file is a symlink (refusing): ${path}","messagePattern":"Repo-supervisor file is a symlink \\(refusing\\): (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"v3/@claude-flow/cli/src/services/repo-supervisor.ts","lineNumber":72,"sourceCode":"function delay(ms: number): Promise<void> {\n  return new Promise((r) => setTimeout(r, ms));\n}\n\nfunction isProcessAlive(pid: number): boolean {\n  try {\n    process.kill(pid, 0);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/** Invariant 9 (#2661): registry files must never be symlinks. */\nfunction assertNotSymlink(path: string): void {\n  try {\n    const st = fs.lstatSync(path);\n    if (st.isSymbolicLink()) {\n      throw new Error(`Repo-supervisor file is a symlink (refusing): ${path}`);\n    }\n  } catch (e) {\n    if ((e as NodeJS.ErrnoException).code === 'ENOENT') return;\n    throw e;\n  }\n}\n\nexport class RepoSupervisorRegistry {\n  private readonly dir: string;\n\n  constructor(options?: { baseDir?: string }) {\n    this.dir = options?.baseDir\n      ?? process.env.RUFLO_AI_BUDGET_DIR\n      ?? join(homedir(), '.claude-flow');\n  }\n\n  private fileFor(repositoryId: string): string {\n    return join(this.dir, 'supervisors', `${repositoryId}.json`);","sourceCodeStart":54,"sourceCodeEnd":90,"githubUrl":"https://github.com/ruvnet/ruflo/blob/fa13ee4ad60ac2090b1480656eb233521790d640/v3/@claude-flow/cli/src/services/repo-supervisor.ts#L54-L90","documentation":"Thrown by assertNotSymlink() (Invariant 9, issue #2661) whenever a repo-supervisor registry file — ~/.claude-flow/supervisors/<repositoryId>.json (or under RUFLO_AI_BUDGET_DIR) — is a symbolic link. The supervisor registry elects one daemon per repository, so a symlinked record could redirect reads/writes outside the registry directory; the module refuses to touch it. ENOENT is allowed (no file yet), any other lstat error propagates.","triggerScenarios":"Someone replaced the supervisor JSON (or its path resolves to a symlink) — e.g. a dotfiles manager symlinking ~/.claude-flow to a synced folder, RUFLO_AI_BUDGET_DIR pointing at a symlinked path where the record itself was linked, or an attacker/copy script creating links between worktree registries to share election state.","commonSituations":"Users syncing ~/.claude-flow across machines with stow/symlink-based dotfile managers; teams sharing a network home where someone linked the registry file to a shared copy; a restore-from-backup that recreated files as links; genuinely hostile pre-creation of symlinks before a privileged write (the attack this guard exists for).","solutions":["Inspect the path in the error with ls -l and readlink; decide whether the link is benign (dotfile sync) or unexpected (investigate who created it before deleting anything).","Replace the symlink with the real file/directory: copy the target's contents to a real ~/.claude-flow and remove the link — the guard checks the file itself, so also ensure the record file is a regular file.","If you need a custom location, point RUFLO_AI_BUDGET_DIR at a real (non-symlinked-file) directory on local disk.","If the link is unexplained, treat it as a security signal: audit the machine/worktrees for the same pattern before re-running the daemon."],"exampleFix":"# before: ~/.claude-flow is a symlink, record resolves through it\n$ ls -l ~/.claude-flow/supervisors/9f3a….json\nlrwxrwxrwx … 9f3a….json -> /shared/state/9f3a….json\n# daemon throws: Repo-supervisor file is a symlink (refusing): …\n\n# after: materialize real files, drop the link\n$ mkdir -p ~/.claude-flow-real && cp -L -r ~/.claude-flow/. ~/.claude-flow-real/\n$ rm ~/.claude-flow && mv ~/.claude-flow-real ~/.claude-flow","handlingStrategy":"validation","validationCode":"import * as fs from 'fs';\nimport { join } from 'path';\n\nfunction registryFileIsSafe(baseDir: string, repositoryId: string): boolean {\n  const file = join(baseDir, 'supervisors', `${repositoryId}.json`);\n  try { return !fs.lstatSync(file).isSymbolicLink(); }\n  catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT'; } // absent is fine\n}\n\nif (!registryFileIsSafe(process.env.RUFLO_AI_BUDGET_DIR ?? join(homedir(), '.claude-flow'), repoId)) {\n  throw new Error('Supervisor registry file is a symlink — refusing to start (security)');\n}","typeGuard":null,"tryCatchPattern":"try {\n  return await registry.tryAcquireSupervision(worktreeRoot);\n} catch (e) {\n  if (e instanceof Error && e.message.includes('is a symlink (refusing)')) {\n    // Stop and investigate: who created the link? Copy target contents to a real\n    // file/dir, remove the link, then retry on the next daemon tick.\n    logger.error('Security invariant tripped — refusing supervisor ops until registry is a real file:', e.message);\n    return { isSupervisor: false, record: null };\n  }\n  throw e;\n}","preventionTips":["Never symlink files inside ~/.claude-flow — dotfile managers should manage configs, not the runtime registry directory's files.","If you relocate the registry, point RUFLO_AI_BUDGET_DIR at a real directory on local disk.","Audit after restores/migrations: ls -la ~/.claude-flow/supervisors/ should show regular files (-), not l.","Treat this error as a tamper signal, not a nuisance: identify the link's origin before deleting anything."],"tags":["security","symlink","filesystem","repo-supervisor","hardening"],"backgroundTag":"symlink-security-check","analyzedSha":"fa13ee4ad60ac2090b1480656eb233521790d640","analyzedAt":"2026-08-18T21:34:22.708Z","schemaVersion":2},"datasetVersion":"2026-08-22T09:17:25.309Z"}