{"record":{"id":"1cf08fa423df6545","repo":"ruvnet/ruflo","slug":"refusing-symlink-file","errorCode":null,"errorMessage":"refusing symlink: ${file}","messagePattern":"refusing symlink: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"v3/@claude-flow/cli/src/services/flywheel-transaction.ts","lineNumber":183,"sourceCode":"function stateDir(root: string): string {\n  return path.join(root, ...STATE_DIR);\n}\n\nfunction statePath(root: string): string {\n  return path.join(stateDir(root), STATE_FILE);\n}\n\nfunction lockPath(root: string): string {\n  return path.join(stateDir(root), LOCK_FILE);\n}\n\nfunction receiptDir(root: string): string {\n  return path.join(stateDir(root), RECEIPTS_DIR);\n}\n\nfunction assertSafeFile(file: string): void {\n  try {\n    if (fs.lstatSync(file).isSymbolicLink()) throw new Error(`refusing symlink: ${file}`);\n  } catch (error) {\n    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;\n  }\n}\n\nfunction ensureDir(root: string): void {\n  fs.mkdirSync(receiptDir(root), { recursive: true, mode: 0o700 });\n  assertSafeFile(statePath(root));\n  assertSafeFile(lockPath(root));\n}\n\nfunction emptyState(): FlywheelTransactionState {\n  return {\n    version: STATE_VERSION,\n    activeChampionRef: null,\n    activePolicy: null,\n    activeGateVersion: null,\n    activePolicySchemaVersion: null,","sourceCodeStart":165,"sourceCodeEnd":201,"githubUrl":"https://github.com/ruvnet/ruflo/blob/5234333c3462640ab348363ba4a142945fd2bc47/v3/@claude-flow/cli/src/services/flywheel-transaction.ts#L165-L201","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"# before: state shared between checkouts via symlink\nln -s ~/shared/flywheel-state.json .claude-flow/flywheel-v1/transaction-state.json\n\n# after: real per-project file; share by copying instead\nrm .claude-flow/flywheel-v1/transaction-state.json\ncp ~/shared/flywheel-state.json .claude-flow/flywheel-v1/transaction-state.json","handlingStrategy":"validation","validationCode":"import { lstatSync } from 'node:fs';\nimport { join } from 'node:path';\n\n// Run before any flywheel transaction API; mirrors assertSafeFile.\nfunction assertFlywheelStateClean(root: string): void {\n  const dir = join(root, '.claude-flow', 'flywheel-v1');\n  const paths = [join(dir, 'transaction-state.json'), join(dir, 'transaction-state.lock')];\n  for (const p of paths) {\n    try {\n      if (lstatSync(p).isSymbolicLink()) throw new Error(`symlink present, refusing to continue: ${p}`);\n    } catch (e: any) {\n      if (e?.code !== 'ENOENT') throw e;\n    }\n  }\n}","typeGuard":null,"tryCatchPattern":"try {\n  await openFlywheelTransaction(root);\n} catch (e) {\n  if (e instanceof Error && e.message.startsWith('refusing symlink')) {\n    // Security guard tripped: surface the path from the message and STOP.\n    // Never retry, never delete the target blind — inspect it manually.\n    throw new Error(`Flywheel state is symlinked (possible tampering): ${e.message}`);\n  }\n  throw e;\n}","preventionTips":["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"],"tags":["security","symlink","filesystem","flywheel","state"],"backgroundTag":"symlink-security-check","analyzedSha":"5234333c3462640ab348363ba4a142945fd2bc47","analyzedAt":"2026-08-18T21:34:22.708Z","contentChangedAt":"2026-08-18T21:34:22.708Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}