{"record":{"id":"45dcd8e2a7646573","repo":"ruvnet/ruflo","slug":"ai-budget-file-is-a-symlink-refusing-path","errorCode":null,"errorMessage":"AI budget file is a symlink (refusing): ${path}","messagePattern":"AI budget file is a symlink \\(refusing\\): (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"v3/@claude-flow/cli/src/services/global-ai-budget.ts","lineNumber":126,"sourceCode":"  const n = Number.parseInt(raw, 10);\n  return Number.isFinite(n) && n >= 0 ? n : undefined;\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: 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 budget 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\nfunction delay(ms: number): Promise<void> {\n  return new Promise((r) => setTimeout(r, ms));\n}\n\nexport class GlobalAiBudget {\n  private readonly dir: string;\n  private readonly ledgerFile: string;\n  private readonly lockFile: string;\n  private readonly receiptsFile: string;\n  private readonly limits: AiBudgetLimits;\n","sourceCodeStart":108,"sourceCodeEnd":144,"githubUrl":"https://github.com/ruvnet/ruflo/blob/fa13ee4ad60ac2090b1480656eb233521790d640/v3/@claude-flow/cli/src/services/global-ai-budget.ts#L108-L144","documentation":"GlobalAiBudget (#2661) is a user-wide AI launch-cost fuse shared by every ruflo daemon; its registry lives in `~/.claude-flow/` (`ai-budget.json`, `ai-budget.lock`, `ai-budget-receipts.jsonl`), all owner-only. Invariant 9 requires these registry files to never be symlinks — `assertNotSymlink` lstats them on every ledger read/write so budget state cannot be redirected or spoofed through a link. ENOENT is fine (first run); a symlink is not.","triggerScenarios":"Any budget check or launch acquire after `~/.claude-flow/ai-budget.json` (or the lock/receipts sibling files) was replaced by a symlink — commonly `~/.claude-flow` itself being pointed into a dotfiles repo or a cloud-synced folder.","commonSituations":"Users symlink `~/.claude-flow` to a versioned dotfiles directory; multi-machine setups trying to share one budget ledger via links; tools like GNU stow creating links in `$HOME`; in adversarial cases, tampering with the home directory to hide launches.","solutions":["Run `ls -la ~/.claude-flow/` and `readlink` the offending file, then remove the link so the CLI recreates a regular file","Keep `~/.claude-flow` a real directory; if you version it, use a copy-based tool rather than symlink-based stowing","Do not share the budget ledger across machines — the fuse is intentionally per-user-account","If the link is unexplained, investigate for tampering before re-running"],"exampleFix":"# before: dotfiles-style symlink\nmv ~/.claude-flow ~/dotfiles/claude-flow && ln -s ~/dotfiles/claude-flow ~/.claude-flow\n\n# after: real directory, copy what you need to version\nrm ~/.claude-flow && mkdir -m 700 ~/.claude-flow && cp ~/dotfiles/claude-flow/* ~/.claude-flow/","handlingStrategy":"validation","validationCode":"import { lstatSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n// Run before daemon startup; mirrors invariant 9.\nfunction assertBudgetRegistryClean(): void {\n  const dir = join(homedir(), '.claude-flow');\n  for (const name of ['ai-budget.json', 'ai-budget.lock', 'ai-budget-receipts.jsonl']) {\n    const p = join(dir, name);\n    try {\n      if (lstatSync(p).isSymbolicLink()) throw new Error(`AI budget registry file is a symlink: ${p}`);\n    } catch (e: any) {\n      if (e?.code !== 'ENOENT') throw e;\n    }\n  }\n}","typeGuard":null,"tryCatchPattern":"try {\n  const permit = await budget.acquire(request);\n} catch (e) {\n  if (e instanceof Error && e.message.includes('AI budget file is a symlink')) {\n    // fail fast: the fuse is tamper-evident by design; fix the home dir manually\n    throw new Error(`Refusing to launch: budget registry tampered — ${e.message}`);\n  }\n  throw e;\n}","preventionTips":["Keep `~/.claude-flow` a real directory — never symlink it into dotfiles or sync folders","Use copy-based dotfile management (or stow with --copy semantics) for this directory","Do not attempt to share one budget ledger across machines via links"],"tags":["security","symlink","ai-budget","filesystem"],"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"}