ruvnet/ruflo · error · Error

refusing symlink: ${file}

Error message

refusing symlink: ${file}

What it means

Thrown by assertSafeFile() when a file in the flywheel transaction state directory (.claude-flow/flywheel-v1/) is a symbolic link. This is a deliberate security hardening: an attacker who can plant a symlink can redirect atomic writes (state, lock, receipts) to an arbitrary file, corrupting it or exfiltrating data. The check uses lstatSync so it catches symlinks even when the target does not exist.

Source

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

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

Solutions

  1. Remove the offending symlink: rm <path> and let the code recreate a real file.
  2. Audit .claude-flow/flywheel-v1/ for unexpected symlinks (find . -type l).
  3. Ensure the state directory is on a trusted filesystem with restrictive permissions (mode 0o700, set by ensureDir).
  4. If symlinking was intentional for a shared workspace, stop — the invariant is load-bearing; use a shared volume or copy instead.

Example fix

# before: .claude-flow/flywheel-v1/transaction-state.json -> /tmp/state.json (symlink)
rm .claude-flow/flywheel-v1/transaction-state.json
# let the next transaction recreate a real file
Defensive patterns

Strategy: validation

Validate before calling

function assertNoSymlinksIn(dir: string): void {
  for (const name of fs.readdirSync(dir)) {
    const full = path.join(dir, name);
    if (fs.lstatSync(full).isSymbolicLink()) {
      throw new Error(`unexpected symlink in flywheel state dir: ${full}`);
    }
  }
}
// run before any transaction operation
assertNoSymlinksIn(path.join(root, '.claude-flow', 'flywheel-v1'));

Type guard

const isRegularFile = (p: string): boolean => {
  try { return fs.lstatSync(p).isFile(); } catch { return false; }
};

Try / catch

try {
  readFlywheelTransactionState(root);
} catch (e) {
  if (e instanceof Error && /refusing symlink/.test(e.message)) {
    // quarantine the dir and alert — do NOT auto-delete, it may be an attack
    throw new Error(`security: flywheel state contains a symlink — investigate ${e.message}`);
  }
  throw e;
}

Prevention

When it happens

Trigger: The transaction-state.json, transaction-state.lock, or a receipt .json file under .claude-flow/flywheel-v1/ (or its receipts/ subdir) is a symlink. Triggered on every read/write/commit path via assertSafeFile(), ensureDir(), atomicWriteJson(), readFlywheelTransactionState(), and readFlywheelReceipt().

Common situations: A compromised or misconfigured environment where someone symlinked the state dir for sharing across projects; a backup/restore that created symlinks; a test fixture that used symlinks; an actual attack attempt.

Related errors


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