santifer/career-ops · error · Error
refusing to hash symlink: ${childRel}
Error message
refusing to hash symlink: ${childRel} What it means
Thrown by `hashPluginTree` (plugins/_lock.mjs:49) when `lstatSync` reports a symbolic link anywhere in the plugin tree. The walker uses `lstat` (not `stat`) deliberately so a symlink is detected and never followed: a symlink could pass the content hash while pointing to an arbitrary target, letting a rug-pull mutate the real file without changing the recorded hash. This is a fail-closed integrity guard for plugins.lock.
Source
Thrown at plugins/_lock.mjs:49
* a rug-pull mutate an un-hashed file. Rejects symlinks (a symlinked file would
* pass the hash while pointing elsewhere). Excludes node_modules + .git.
*
* @param {string} dir absolute plugin directory
* @returns {{ files: Record<string,string>, integrity: string }}
*/
export function hashPluginTree(dir) {
const files = {};
const walk = (abs, rel) => {
let entries;
try { entries = readdirSync(abs, { withFileTypes: true }); }
catch (err) { throw new Error(`cannot read ${rel || '.'}: ${err.message}`); }
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (e.name === 'node_modules' || e.name === '.git') continue;
const childAbs = path.join(abs, e.name);
const childRel = rel ? `${rel}/${e.name}` : e.name;
// lstat (not stat) so a symlink is detected, never followed.
const st = lstatSync(childAbs);
if (st.isSymbolicLink()) throw new Error(`refusing to hash symlink: ${childRel}`);
if (st.isDirectory()) walk(childAbs, childRel);
else if (st.isFile()) files[childRel] = sha256(readFileSync(childAbs));
else throw new Error(`refusing to hash non-regular file: ${childRel}`);
}
};
walk(dir, '');
// Aggregate integrity = sha256 over the deterministic sorted "rel:hash" join.
const aggregate = Object.keys(files).sort().map(k => `${k}:${files[k]}`).join('\n');
return { files, integrity: sha256(Buffer.from(aggregate)) };
}
/** Read plugins.lock (fail-open to an empty lock — like the rest of the engine). */
export function readLock(root) {
const file = lockPath(root);
if (!existsSync(file)) return { lockfileVersion: LOCK_VERSION, plugins: {} };
try {
const parsed = JSON.parse(readFileSync(file, 'utf8'));
if (!parsed || typeof parsed !== 'object' || typeof parsed.plugins !== 'object') return { lockfileVersion: LOCK_VERSION, plugins: {} };View on GitHub (pinned to 9b17a8ac97)
Solutions
- Replace the symlink with a real copy of the file/dir, then re-run the lock operation.
- If the symlink is for node_modules, note that hashPluginTree already skips node_modules entirely — a symlinked node_modules is fine ONLY if it is literally named 'node_modules'; rename any other symlinked dep dir to 'node_modules' so it is skipped.
- Remove the offending symlink if it is not needed for the plugin to function.
- Run `find plugins/<name> -type l` to locate every symlink before re-hashing.
Example fix
# before: plugins/myplugin/config.yml -> ../../shared/config.yml (symlink) find plugins/myplugin -type l # after: copy the real file in cp ../../shared/config.yml plugins/myplugin/config.yml
Defensive patterns
Strategy: validation
Validate before calling
import { lstatSync } from 'node:fs';
import path from 'node:path';
// Reject symlinks before hashing so the error is caught upstream.
function findSymlinks(root) {
const found = [];
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name === '.git') continue;
const p = path.join(dir, e.name);
if (lstatSync(p).isSymbolicLink()) found.push(p);
else if (lstatSync(p).isDirectory()) walk(p);
}
};
walk(root);
return found;
}
const links = findSymlinks(pluginDir);
if (links.length) throw new Error(`Resolve symlinks first: ${links.join(', ')}`); Try / catch
try {
hashPluginTree(pluginDir);
} catch (err) {
if (/refusing to hash symlink/.test(err.message)) {
console.error(`Replace the symlink with a real file: ${err.message}`);
} else throw err;
} Prevention
- Use real copies, not symlinks, inside plugin trees.
- Run `find plugins -type l` in CI to catch stray symlinks before lock generation.
When it happens
Trigger: A plugin directory contains a symlink — e.g. a developer symlinked `plugins/myplugin/node_modules` to a shared location, or symlinked config files, or a plugin was installed via `ln -s`. The hash walk encounters the symlinked entry and refuses to continue.
Common situations: Symlinking node_modules to save disk in a multi-plugin dev setup; a plugin scaffolded with `npm link`; a config symlinked from elsewhere in the repo; macOS/Windows creating symlinks during unzip of a downloaded plugin archive.
Related errors
- cannot read ${rel || '.'}: ${err.message}
- refusing to hash non-regular file: ${childRel}
- plugin egress to ${hostname} is blocked (private/loopback/me
- plugin egress: ${hostname} resolves to a blocked address (${
- local-parser: path escapes the project root: ${rawPath}
AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13).
Data as JSON: /api/errors/283d54b0c73345fa.
Report an issue: GitHub.