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

  1. Run `ls -la ~/.claude-flow/` and `readlink` the offending file, then remove the link so the CLI recreates a regular file
  2. Keep `~/.claude-flow` a real directory; if you version it, use a copy-based tool rather than symlink-based stowing
  3. Do not share the budget ledger across machines — the fuse is intentionally per-user-account
  4. 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

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-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)