{"record":{"id":"dd697ec3705e15d9","repo":"ruvnet/ruflo","slug":"ai-job-registry-is-a-symlink-refusing-path","errorCode":null,"errorMessage":"AI job registry is a symlink (refusing): ${path}","messagePattern":"AI job registry is a symlink \\(refusing\\): (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"v3/@claude-flow/cli/src/services/ai-job-dedup.ts","lineNumber":69,"sourceCode":"}\n\n/** Stable hash of an arbitrary config object (key-sorted JSON). */\nexport function hashWorkerConfig(config: unknown): string {\n  const canonical = JSON.stringify(config, (_k, v) => {\n    if (v && typeof v === 'object' && !Array.isArray(v)) {\n      return Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)));\n    }\n    return v;\n  });\n  return createHash('sha256').update(canonical ?? 'null').digest('hex');\n}\n\n/** Invariant 9: 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(`AI job registry 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 AiJobDedupRegistry {\n  private readonly dir: string;\n  private readonly file: 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    this.file = join(this.dir, 'ai-jobs.json');\n  }\n","sourceCodeStart":51,"sourceCodeEnd":87,"githubUrl":"https://github.com/ruvnet/ruflo/blob/fa13ee4ad60ac2090b1480656eb233521790d640/v3/@claude-flow/cli/src/services/ai-job-dedup.ts#L51-L87","documentation":"AiJobDedupRegistry enforces 'Invariant 9: registry files must never be symlinks'. Before touching the AI job registry file it lstat()s the path and refuses to proceed if it is a symbolic link. This is a deliberate anti-tampering guard: a symlinked registry lets another user/process swap the file underneath (or point it at a sensitive path), so the registry aborts rather than read or write through the link. ENOENT (file does not exist yet) is allowed; every other stat error is rethrown.","triggerScenarios":"Constructing AiJobDedupRegistry (or any flow that instantiates it) when the registry file under its baseDir (options.baseDir or the default) is a symlink — e.g. ~/.claude-flow/ai-jobs or a nested registry.json managed by GNU stow, dotfiles repos, syncthing/cloud-sync, or a manual 'ln -s' to shared storage.","commonSituations":"Home directory managed with dotfiles tooling that symlinks config/data paths, multi-agent setups sharing one registry across machines via a symlinked network folder, or a security-conscious environment where the guard is tripping on purpose because something replaced the file.","solutions":["Inspect the path from the error message with ls -l <path> and confirm it is a symlink","Replace the symlink with a real file (cp -L <link> <tmp> && rm <link> && mv <tmp> <path>) or remove it so the registry recreates a regular file","If sharing across projects/machines was the goal, point options.baseDir at a real directory on the target filesystem instead of symlinking the file","Do not bypass the check — it is a security invariant; if the symlink was unexpected, investigate who created it"],"exampleFix":"# before\n~/.claude-flow/ai-jobs/registry.json -> /shared/ai-jobs.json   (symlink)\n# constructing AiJobDedupRegistry throws\n\n# after\nmkdir -p ~/.claude-flow/ai-jobs\ncp -L /shared/ai-jobs.json ~/.claude-flow/ai-jobs/registry.json.tmp 2>/dev/null || true\nrm ~/.claude-flow/ai-jobs/registry.json\nmv ~/.claude-flow/ai-jobs/registry.json.tmp ~/.claude-flow/ai-jobs/registry.json","handlingStrategy":"validation","validationCode":"const registryPath = path.join(baseDir, 'ai-jobs', 'registry.json'); // path used by AiJobDedupRegistry\nconst st = fs.lstatSync(registryPath); // throws ENOENT if absent — that case is fine\nif (st.isSymbolicLink()) {\n  throw new Error(`${registryPath} is a symlink; replace it with a real file before enabling AI job dedup`);\n}","typeGuard":"const isRealFile = (p: string): boolean => {\n  try { return fs.lstatSync(p).isFile(); } catch (e) { return (e as NodeJS.ErrnoException).code === 'ENOENT'; }\n};","tryCatchPattern":"try {\n  registry = new AiJobDedupRegistry({ baseDir });\n} catch (e) {\n  if (e instanceof Error && e.message.includes('is a symlink')) {\n    // surface to the operator: replace the symlink with a real file; do NOT auto-delete — it may be an attack indicator\n  } else throw e;\n}","preventionTips":["Exclude registry/data files from dotfiles managers and sync tools; keep them real files on local disk","In setup scripts, assert !fs.lstatSync(p).isSymbolicLink() before first use","If this fires unexpectedly, treat it as a security signal and audit who created the link"],"tags":["security","symlink","filesystem","ai-job-dedup","hardening"],"backgroundTag":"symlink-security-check","analyzedSha":"fa13ee4ad60ac2090b1480656eb233521790d640","analyzedAt":"2026-08-18T21:34:22.708Z","contentChangedAt":"2026-08-18T21:34:22.708Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}