ruvnet/ruflo · error · Error
AI budget file is a symlink (refusing)
Error message
AI budget file is a symlink (refusing): ${path} What it means
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.
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
Example fix
# before: dotfiles-style symlink mv ~/.claude-flow ~/dotfiles/claude-flow && ln -s ~/dotfiles/claude-flow ~/.claude-flow # after: real directory, copy what you need to version rm ~/.claude-flow && mkdir -m 700 ~/.claude-flow && cp ~/dotfiles/claude-flow/* ~/.claude-flow/
Defensive patterns
Strategy: validation
Validate before calling
import { lstatSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
// Run before daemon startup; mirrors invariant 9.
function assertBudgetRegistryClean(): void {
const dir = join(homedir(), '.claude-flow');
for (const name of ['ai-budget.json', 'ai-budget.lock', 'ai-budget-receipts.jsonl']) {
const p = join(dir, name);
try {
if (lstatSync(p).isSymbolicLink()) throw new Error(`AI budget registry file is a symlink: ${p}`);
} catch (e: any) {
if (e?.code !== 'ENOENT') throw e;
}
}
} Try / catch
try {
const permit = await budget.acquire(request);
} catch (e) {
if (e instanceof Error && e.message.includes('AI budget file is a symlink')) {
// fail fast: the fuse is tamper-evident by design; fix the home dir manually
throw new Error(`Refusing to launch: budget registry tampered — ${e.message}`);
}
throw e;
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- AI job registry is a symlink (refusing)
- Path traversal blocked
- Path traversal blocked
- refusing symlink
- Repo-supervisor file is a symlink (refusing)
AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18).
Data as JSON: /api/errors/45dcd8e2a7646573.
Report an issue: GitHub.
Appendix: source
Thrown at v3/@claude-flow/cli/src/services/global-ai-budget.ts:126
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : undefined;
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/** Invariant 9: registry files must never be symlinks. */
function assertNotSymlink(path: string): void {
try {
const st = fs.lstatSync(path);
if (st.isSymbolicLink()) {
throw new Error(`AI budget file is a symlink (refusing): ${path}`);
}
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return;
throw e;
}
}
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
export class GlobalAiBudget {
private readonly dir: string;
private readonly ledgerFile: string;
private readonly lockFile: string;
private readonly receiptsFile: string;
private readonly limits: AiBudgetLimits;
View on GitHub (pinned to fa13ee4ad6)