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

  1. Replace the symlink with a real file: rm <symlink> && touch <file>.
  2. Audit ~/.claude-flow (or RUFLO_AI_BUDGET_DIR) for symlinks.
  3. Ensure the budget directory is a real directory, not a symlink to one.
  4. 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

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


AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12). Data as JSON: /api/errors/45dcd8e2a7646573. Report an issue: GitHub.