ruvnet/ruflo · error · Error

refusing symlink

Error message

refusing symlink: ${file}

What it means

The flywheel transaction service (ADR-322A) keeps all authoritative promotion state in `.claude-flow/flywheel-v1/` inside the project — `transaction-state.json`, `transaction-state.lock`, and a `receipts/` directory. Before touching any of these paths, `assertSafeFile` runs `fs.lstatSync` and refuses to operate when the path is a symbolic link, so state reads and writes cannot be redirected through a symlink to arbitrary files. ENOENT is tolerated (paths may not exist yet); any other stat error propagates.

Solutions

  1. Inspect the path named in the message with `ls -la` / `readlink` and delete the symlink (`rm <path>`); the CLI recreates a regular file on the next run
  2. Stop sharing flywheel state via symlinks — copy the state file instead, or give each checkout its own `.claude-flow/flywheel-v1`
  3. If you did not create the symlink yourself, treat the workspace as tampered and re-clone before re-running
  4. Exclude the `.claude-flow` state directory from backup/restore and cache tools that materialize files as links

Example fix

# before: state shared between checkouts via symlink
ln -s ~/shared/flywheel-state.json .claude-flow/flywheel-v1/transaction-state.json

# after: real per-project file; share by copying instead
rm .claude-flow/flywheel-v1/transaction-state.json
cp ~/shared/flywheel-state.json .claude-flow/flywheel-v1/transaction-state.json
Defensive patterns

Strategy: validation

Validate before calling

import { lstatSync } from 'node:fs';
import { join } from 'node:path';

// Run before any flywheel transaction API; mirrors assertSafeFile.
function assertFlywheelStateClean(root: string): void {
  const dir = join(root, '.claude-flow', 'flywheel-v1');
  const paths = [join(dir, 'transaction-state.json'), join(dir, 'transaction-state.lock')];
  for (const p of paths) {
    try {
      if (lstatSync(p).isSymbolicLink()) throw new Error(`symlink present, refusing to continue: ${p}`);
    } catch (e: any) {
      if (e?.code !== 'ENOENT') throw e;
    }
  }
}

Try / catch

try {
  await openFlywheelTransaction(root);
} catch (e) {
  if (e instanceof Error && e.message.startsWith('refusing symlink')) {
    // Security guard tripped: surface the path from the message and STOP.
    // Never retry, never delete the target blind — inspect it manually.
    throw new Error(`Flywheel state is symlinked (possible tampering): ${e.message}`);
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling any flywheel transaction API (opening a promotion transaction, reading or writing a receipt) when `.claude-flow/flywheel-v1/transaction-state.json`, `transaction-state.lock`, or a receipt JSON is a symlink — typically because someone linked the flywheel state into a shared or synced directory.

Common situations: Dotfile managers, Syncthing/Dropbox setups, or backup/restore tools that recreate files as symlinks; CI caches that preserve links; sharing one flywheel state between multiple checkouts via `ln -s`; in the worst case a tampered clone where an attacker planted a symlink so the CLI would overwrite an arbitrary file when committing state.

Related errors


AI-assisted analysis of ruvnet/ruflo@5234333c34 (2026-08-18). Data as JSON: /api/errors/1cf08fa423df6545. Report an issue: GitHub.

Appendix: source

Thrown at v3/@claude-flow/cli/src/services/flywheel-transaction.ts:183

function stateDir(root: string): string {
  return path.join(root, ...STATE_DIR);
}

function statePath(root: string): string {
  return path.join(stateDir(root), STATE_FILE);
}

function lockPath(root: string): string {
  return path.join(stateDir(root), LOCK_FILE);
}

function receiptDir(root: string): string {
  return path.join(stateDir(root), RECEIPTS_DIR);
}

function assertSafeFile(file: string): void {
  try {
    if (fs.lstatSync(file).isSymbolicLink()) throw new Error(`refusing symlink: ${file}`);
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
  }
}

function ensureDir(root: string): void {
  fs.mkdirSync(receiptDir(root), { recursive: true, mode: 0o700 });
  assertSafeFile(statePath(root));
  assertSafeFile(lockPath(root));
}

function emptyState(): FlywheelTransactionState {
  return {
    version: STATE_VERSION,
    activeChampionRef: null,
    activePolicy: null,
    activeGateVersion: null,
    activePolicySchemaVersion: null,

View on GitHub (pinned to 5234333c34)