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
- Replace the symlink with a real file: `rm ~/.claude-flow/ai-jobs.json && touch ~/.claude-flow/ai-jobs.json`.
- Point RUFLO_AI_BUDGET_DIR at a directory whose ai-jobs.json is a regular file.
- Audit who created the symlink (lastlog / container layer diff) — treat as a potential intrusion if unexpected.
- 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
- Treat this error as a security signal — audit who created the symlink before deleting it.
- Set RUFLO_AI_BUDGET_DIR to a directory you own and chmod 700.
- Exclude the registry file from dotfile-manager symlinking.
- Run container builds so the data dir is a real directory, not a symlinked volume.
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
- refusing symlink: ${file}
- AI budget file is a symlink (refusing): ${path}
- flywheel anchor symlink escapes project root
- flywheel anchor path must stay inside project root
- SSRF guard: invalid URL — ${rawUrl}
AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12).
Data as JSON: /api/errors/dd697ec3705e15d9.
Report an issue: GitHub.