ruvnet/ruflo · error · Error
AI budget file is a symlink (refusing): ${path}
Error message
AI budget file is a symlink (refusing): ${path} What it means
Thrown by assertNotSymlink() inside GlobalAiBudget when the AI budget ledger file (~/.claude-flow/ai-budget.json) or receipts file (ai-budget-receipts.jsonl) is a symbolic link. This enforces 'Invariant 9: registry files must never be symlinks.' The AI budget fuse fails closed — writing through a symlink would let an attacker redirect or duplicate the launch accounting, defeating the concurrency/budget limits.
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 6b01dc5a68)
Solutions
- Replace the symlink with a real file: rm <symlink> && touch <file>.
- Audit ~/.claude-flow (or RUFLO_AI_BUDGET_DIR) for symlinks.
- Ensure the budget directory is a real directory, not a symlink to one.
- If you genuinely need a shared budget dir, mount it as a real filesystem rather than symlinking individual files.
Example fix
# before: ~/.claude-flow/ai-budget.json -> /shared/ai-budget.json rm ~/.claude-flow/ai-budget.json # recreate as real file (the code re-initializes it)
Defensive patterns
Strategy: validation
Validate before calling
function assertBudgetDirClean(dir: string): void {
for (const f of ['ai-budget.json', 'ai-budget-receipts.jsonl']) {
const full = path.join(dir, f);
try {
if (fs.lstatSync(full).isSymbolicLink()) {
throw new Error(`security: ${full} is a symlink — replace with a real file`);
}
} catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
}
} Type guard
const isRegularBudgetFile = (p: string): boolean => {
try { return fs.lstatSync(p).isFile(); } catch { return false; }
}; Try / catch
try {
await budget.reserve(req);
} catch (e) {
if (e instanceof Error && /AI budget file is a symlink/.test(e.message)) {
throw new Error('security: AI budget registry tampered with — refusing to launch');
}
throw e;
} Prevention
- Keep ~/.claude-flow as a real directory on local storage.
- Audit the budget dir for symlinks in CI and on deploy.
- Do not let dotfile managers symlink individual budget files.
When it happens
Trigger: Calling GlobalAiBudget.reserve()/release()/getStatus() when ai-budget.json or ai-budget-receipts.jsonl (in the configured RUFLO_AI_BUDGET_DIR or ~/.claude-flow) is a symlink. assertNotSymlink runs on every readLedger() and writeLedger().
Common situations: A shared-home setup where ~/.claude-flow is symlinked to a network share; a dotfile-manager that symlinks config files; a tampering attempt to point the ledger at /dev/null or a duplicate; an accidental ln -s during setup.
Related errors
- refusing symlink: ${file}
- AI job registry is a symlink (refusing): ${path}
- flywheel anchor symlink escapes project root
- timed out acquiring ai-budget lock
- flywheel anchor path must stay inside project root
AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12).
Data as JSON: /api/errors/45dcd8e2a7646573.
Report an issue: GitHub.