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
- Remove the offending symlink: rm <path> and let the code recreate a real file.
- Audit .claude-flow/flywheel-v1/ for unexpected symlinks (find . -type l).
- Ensure the state directory is on a trusted filesystem with restrictive permissions (mode 0o700, set by ensureDir).
- 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
- Keep .claude-flow/flywheel-v1/ on a trusted local filesystem with mode 0o700.
- Never symlink state/lock/receipt files for sharing.
- Periodically audit the state dir for symlinks in CI.
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 budget file is a symlink (refusing): ${path}
- AI job registry is a symlink (refusing): ${path}
- flywheel anchor symlink escapes project root
- invalid receipt ID
- flywheel anchor path must stay inside project root
AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12).
Data as JSON: /api/errors/1cf08fa423df6545.
Report an issue: GitHub.