{"record":{"id":"c57b62dfe56bc877","repo":"ruvnet/ruflo","slug":"workspace-lease-file-is-a-symlink-refusing-pa","errorCode":null,"errorMessage":"Workspace lease file is a symlink (refusing): ${path}","messagePattern":"Workspace lease file is a symlink \\(refusing\\): (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"v3/@claude-flow/cli/src/services/workspace-lease.ts","lineNumber":61,"sourceCode":"function delay(ms: number): Promise<void> {\n  return new Promise((r) => setTimeout(r, ms));\n}\n\nfunction isProcessAlive(pid: number): boolean {\n  try {\n    process.kill(pid, 0);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/** Invariant 9 (#2661): registry files must never be symlinks. */\nfunction assertNotSymlink(path: string): void {\n  try {\n    const st = fs.lstatSync(path);\n    if (st.isSymbolicLink()) {\n      throw new Error(`Workspace lease file is a symlink (refusing): ${path}`);\n    }\n  } catch (e) {\n    if ((e as NodeJS.ErrnoException).code === 'ENOENT') return;\n    throw e;\n  }\n}\n\nfunction leaseKey(worktreeRoot: string): string {\n  return createHash('sha256').update(worktreeRoot).digest('hex').slice(0, 16);\n}\n\nexport class WorkspaceLeaseRegistry {\n  private readonly dir: string;\n\n  constructor(options?: { baseDir?: string }) {\n    this.dir = options?.baseDir\n      ?? process.env.RUFLO_AI_BUDGET_DIR // shares the same override as global-ai-budget for test isolation\n      ?? join(homedir(), '.claude-flow');","sourceCodeStart":43,"sourceCodeEnd":79,"githubUrl":"https://github.com/ruvnet/ruflo/blob/fa13ee4ad60ac2090b1480656eb233521790d640/v3/@claude-flow/cli/src/services/workspace-lease.ts#L43-L79","documentation":"WorkspaceLeaseRegistry (Invariant 9, issue #2661) hard-refuses to operate on any registry file that is a symbolic link. assertNotSymlink() lstat()s the lease file before every read/write and throws if st.isSymbolicLink(). This blocks symlink-swap attacks where an attacker replaces the lease registry with a link to redirect writes or read privileged data. ENOENT is allowed (a not-yet-created file is fine); every other lstat error propagates.","triggerScenarios":"A symlink existing at the lease registry path (e.g. .claude-flow registry file in the worktree root) when any lease acquire/release/read runs; dotfile managers or backup tools that replace state files with symlinks; a compromised or misconfigured workspace planting a link before the daemon takes a lease.","commonSituations":"Developers sharing state dirs via symlinks (moving ~/.claude-flow or the repo state dir to another volume); CI setups that symlink cache/state directories; security tooling testing the invariant; genuinely hostile worktrees where a checkout included a symlink at the registry path.","solutions":["Remove the symlink and restore the registry path as a real file or directory (ls -l to confirm), then retry the lease operation","If you moved the state dir, move it physically (mv) instead of leaving a symlink behind","Audit how the symlink got there — if you did not create it, treat it as a security incident on that worktree","Keep symlinks out of the workspace/state directories this registry manages"],"exampleFix":"# before: registry path is a symlink\nls -l .claude-flow/workspace-leases.json\n# -> .claude-flow/workspace-leases.json -> /etc/sensitive\n\n# after: replace with a real file\nrm .claude-flow/workspace-leases.json\ntouch .claude-flow/workspace-leases.json  # registry recreates structured content on next write","handlingStrategy":"validation","validationCode":"import * as fs from 'node:fs';\n\nfunction assertRegistryFileReal(path: string): void {\n  let st;\n  try { st = fs.lstatSync(path); } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return; throw e; }\n  if (st.isSymbolicLink()) {\n    throw new Error(`Refusing to operate: ${path} is a symlink — replace it with a real file first`);\n  }\n}\nassertRegistryFileReal(registryFilePath); // run before acquiring leases","typeGuard":null,"tryCatchPattern":"try {\n  await registry.acquire(worktree, owner);\n} catch (e) {\n  if (/is a symlink \\(refusing\\)/.test(String(e?.message))) {\n    // operational hazard, not transient: surface to the user to remove the link manually\n    throw new Error(`Security invariant violated: ${e.message}. Remove the symlink and investigate how it appeared.`);\n  }\n  throw e;\n}","preventionTips":["Never replace claude-flow state/registry files with symlinks — move the directory physically instead","Exclude claude-flow state dirs from dotfile managers and backup tools that recreate files as links","Treat an unexpected symlink in the registry location as a possible compromise of that worktree, not as a nuisance error"],"tags":["security","symlink","workspace-lease","hardening","invariant"],"backgroundTag":"symlink-security-check","analyzedSha":"fa13ee4ad60ac2090b1480656eb233521790d640","analyzedAt":"2026-08-18T21:34:22.708Z","schemaVersion":2},"datasetVersion":"2026-08-22T09:17:25.309Z"}