ruvnet/ruflo · critical · Error

AI job registry is a symlink (refusing): ${path}

Error message

AI job registry is a symlink (refusing): ${path}

What it means

assertNotSymlink enforces Invariant 9: the AI job dedup registry file (~/.claude-flow/ai-jobs.json by default, or under RUFLO_AI_BUDGET_DIR) must never be a symbolic link. This blocks symlink-attack vectors where a low-privilege process points the registry at a privileged file to corrupt or read it. lstatSync is used deliberately so the link itself (not its target) is inspected; ENOENT is treated as safe (no file yet).

Source

Thrown at v3/@claude-flow/cli/src/services/ai-job-dedup.ts:69

}

/** Stable hash of an arbitrary config object (key-sorted JSON). */
export function hashWorkerConfig(config: unknown): string {
  const canonical = JSON.stringify(config, (_k, v) => {
    if (v && typeof v === 'object' && !Array.isArray(v)) {
      return Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)));
    }
    return v;
  });
  return createHash('sha256').update(canonical ?? 'null').digest('hex');
}

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

export class AiJobDedupRegistry {
  private readonly dir: string;
  private readonly file: string;

  constructor(options?: { baseDir?: string }) {
    this.dir = options?.baseDir
      ?? process.env.RUFLO_AI_BUDGET_DIR
      ?? join(homedir(), '.claude-flow');
    this.file = join(this.dir, 'ai-jobs.json');
  }

View on GitHub (pinned to 6b01dc5a68)

Solutions

  1. Replace the symlink with a real file: `rm ~/.claude-flow/ai-jobs.json && touch ~/.claude-flow/ai-jobs.json`.
  2. Point RUFLO_AI_BUDGET_DIR at a directory whose ai-jobs.json is a regular file.
  3. Audit who created the symlink (lastlog / container layer diff) — treat as a potential intrusion if unexpected.
  4. If using a dotfile manager, exclude ai-jobs.json from symlinking.

Example fix

# before: registry is a symlink
ls -l ~/.claude-flow/ai-jobs.json
# ai-jobs.json -> /etc/ai-budget.json   <- throws

# after: regular file owned by the runtime user
rm ~/.claude-flow/ai-jobs.json
touch ~/.claude-flow/ai-jobs.json
chown $(whoami) ~/.claude-flow/ai-jobs.json
chmod 600 ~/.claude-flow/ai-jobs.json
Defensive patterns

Strategy: try-catch

Validate before calling

import fs from 'node:fs';
function ensureRegularFile(path) {
  try {
    const st = fs.lstatSync(path);
    if (st.isSymbolicLink()) throw new Error(`refusing symlink at ${path}`);
  } catch (e) { if (e.code !== 'ENOENT') throw e; }
}

Try / catch

try {
  registry.read();
} catch (e) {
  if (String(e.message).includes('is a symlink')) {
    // quarantine: move the symlink aside and recreate a regular file
    fs.renameSync(registryPath, registryPath + '.symlink.bak');
    fs.writeFileSync(registryPath, '{}', { mode: 0o600 });
  } else throw e;
}

Prevention

When it happens

Trigger: Someone (or a setup script) created ~/.claude-flow/ai-jobs.json as a symlink, e.g., `ln -s /etc/something ~/.claude-flow/ai-jobs.json`, then the registry tries to read or write it. Also triggered by a compromised or misconfigured shared home directory.

Common situations: dotfile managers that symlink config files into $HOME; container setups that bind-mount configs via symlinks; an attacker attempting to redirect the AI budget registry to overwrite a sensitive file; moving the data dir and leaving a symlink behind.

Related errors


AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12). Data as JSON: /api/errors/dd697ec3705e15d9. Report an issue: GitHub.