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
- 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
- Stop sharing flywheel state via symlinks — copy the state file instead, or give each checkout its own `.claude-flow/flywheel-v1`
- If you did not create the symlink yourself, treat the workspace as tampered and re-clone before re-running
- 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
- Never symlink `.claude-flow/flywheel-v1` files between projects; copy them instead
- Add a CI preflight that lstats the state files for symlinks before flywheel commands
- Audit dotfile managers and cache-restore steps that recreate files as links
- Treat an unexplained symlink here as a security incident, not a nuisance
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 budget file is a symlink (refusing)
- AI job registry is a symlink (refusing)
- Path traversal blocked
- Path traversal blocked
- Repo-supervisor file is a symlink (refusing)
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)