{"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":"Error","httpStatus":null,"severity":"critical","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/6b01dc5a687b26b3e218f796de45ec51f8fa9e8c/v3/@claude-flow/cli/src/services/ai-job-dedup.ts#L51-L87","documentation":"assertNotSymlink enforces Invariant 9: the AI job dedup registry file (~/.claude-flow/ai-jobs.json by default, or under RUFLO_AI_BUDGET_DIR) must never be a symbolic link. This blocks symlink-attack vectors where a low-privilege process points the registry at a privileged file to corrupt or read it. lstatSync is used deliberately so the link itself (not its target) is inspected; ENOENT is treated as safe (no file yet).","triggerScenarios":"Someone (or a setup script) created ~/.claude-flow/ai-jobs.json as a symlink, e.g., `ln -s /etc/something ~/.claude-flow/ai-jobs.json`, then the registry tries to read or write it. Also triggered by a compromised or misconfigured shared home directory.","commonSituations":"dotfile managers that symlink config files into $HOME; container setups that bind-mount configs via symlinks; an attacker attempting to redirect the AI budget registry to overwrite a sensitive file; moving the data dir and leaving a symlink behind.","solutions":["Replace the symlink with a real file: `rm ~/.claude-flow/ai-jobs.json && touch ~/.claude-flow/ai-jobs.json`.","Point RUFLO_AI_BUDGET_DIR at a directory whose ai-jobs.json is a regular file.","Audit who created the symlink (lastlog / container layer diff) — treat as a potential intrusion if unexpected.","If using a dotfile manager, exclude ai-jobs.json from symlinking."],"exampleFix":"# before: registry is a symlink\nls -l ~/.claude-flow/ai-jobs.json\n# ai-jobs.json -> /etc/ai-budget.json   <- throws\n\n# after: regular file owned by the runtime user\nrm ~/.claude-flow/ai-jobs.json\ntouch ~/.claude-flow/ai-jobs.json\nchown $(whoami) ~/.claude-flow/ai-jobs.json\nchmod 600 ~/.claude-flow/ai-jobs.json","handlingStrategy":"try-catch","validationCode":"import fs from 'node:fs';\nfunction ensureRegularFile(path) {\n  try {\n    const st = fs.lstatSync(path);\n    if (st.isSymbolicLink()) throw new Error(`refusing symlink at ${path}`);\n  } catch (e) { if (e.code !== 'ENOENT') throw e; }\n}","typeGuard":null,"tryCatchPattern":"try {\n  registry.read();\n} catch (e) {\n  if (String(e.message).includes('is a symlink')) {\n    // quarantine: move the symlink aside and recreate a regular file\n    fs.renameSync(registryPath, registryPath + '.symlink.bak');\n    fs.writeFileSync(registryPath, '{}', { mode: 0o600 });\n  } else throw e;\n}","preventionTips":["Treat this error as a security signal — audit who created the symlink before deleting it.","Set RUFLO_AI_BUDGET_DIR to a directory you own and chmod 700.","Exclude the registry file from dotfile-manager symlinking.","Run container builds so the data dir is a real directory, not a symlinked volume."],"tags":["security","symlink","filesystem","invariant","ai-budget"],"backgroundTag":null,"analyzedSha":"6b01dc5a687b26b3e218f796de45ec51f8fa9e8c","analyzedAt":"2026-08-12T13:20:50.148Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}