ruvnet/ruflo · critical · Error

Workspace lease file is a symlink (refusing): ${path}

Error message

Workspace lease file is a symlink (refusing): ${path}

What it means

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.

Source

Thrown at v3/@claude-flow/cli/src/services/workspace-lease.ts:61

function delay(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}

function isProcessAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

/** Invariant 9 (#2661): registry files must never be symlinks. */
function assertNotSymlink(path: string): void {
  try {
    const st = fs.lstatSync(path);
    if (st.isSymbolicLink()) {
      throw new Error(`Workspace lease file is a symlink (refusing): ${path}`);
    }
  } catch (e) {
    if ((e as NodeJS.ErrnoException).code === 'ENOENT') return;
    throw e;
  }
}

function leaseKey(worktreeRoot: string): string {
  return createHash('sha256').update(worktreeRoot).digest('hex').slice(0, 16);
}

export class WorkspaceLeaseRegistry {
  private readonly dir: string;

  constructor(options?: { baseDir?: string }) {
    this.dir = options?.baseDir
      ?? process.env.RUFLO_AI_BUDGET_DIR // shares the same override as global-ai-budget for test isolation
      ?? join(homedir(), '.claude-flow');

View on GitHub (pinned to fa13ee4ad6)

Solutions

  1. Remove the symlink and restore the registry path as a real file or directory (ls -l to confirm), then retry the lease operation
  2. If you moved the state dir, move it physically (mv) instead of leaving a symlink behind
  3. Audit how the symlink got there — if you did not create it, treat it as a security incident on that worktree
  4. Keep symlinks out of the workspace/state directories this registry manages

Example fix

# before: registry path is a symlink
ls -l .claude-flow/workspace-leases.json
# -> .claude-flow/workspace-leases.json -> /etc/sensitive

# after: replace with a real file
rm .claude-flow/workspace-leases.json
touch .claude-flow/workspace-leases.json  # registry recreates structured content on next write
Defensive patterns

Strategy: validation

Validate before calling

import * as fs from 'node:fs';

function assertRegistryFileReal(path: string): void {
  let st;
  try { st = fs.lstatSync(path); } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return; throw e; }
  if (st.isSymbolicLink()) {
    throw new Error(`Refusing to operate: ${path} is a symlink — replace it with a real file first`);
  }
}
assertRegistryFileReal(registryFilePath); // run before acquiring leases

Try / catch

try {
  await registry.acquire(worktree, owner);
} catch (e) {
  if (/is a symlink \(refusing\)/.test(String(e?.message))) {
    // operational hazard, not transient: surface to the user to remove the link manually
    throw new Error(`Security invariant violated: ${e.message}. Remove the symlink and investigate how it appeared.`);
  }
  throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/c57b62dfe56bc877. Report an issue: GitHub.