santifer/career-ops · error · Error
refusing to hash symlink
Error message
refusing to hash symlink: ${childRel} What it means
During tree hashing, `walk` uses lstatSync (not stat) on every entry so symlinks are detected rather than followed. If an entry is a symbolic link, hashing is aborted with this error. This is a deliberate security control: hashing through a symlink could (a) let a plugin inflate or swap content outside its directory, invalidating the integrity manifest, and (b) enable escape/loop attacks, so the library refuses instead.
Solutions
- Replace the symlink with a real copy of the target content inside the plugin directory (cp -rL, then remove the link).
- Remove the symlink if it is not needed for the plugin to function.
- If the whole plugin is a symlink to a dev folder, copy it into place or point the tool at the real directory.
- Find the offending entry from the message's childRel path (`find <plugins-dir> -type l`) and fix it before re-running.
Example fix
// before: symlinked shared asset breaks hashing $ ln -s /usr/share/lib/vendor.css plugins/myplugin/assets/vendor.css // after: copy real content into the tree $ cp -L /usr/share/lib/vendor.css plugins/myplugin/assets/vendor.css $ rm plugins/myplugin/assets/vendor.css.link # if a stale link remains
Defensive patterns
Strategy: validation
Validate before calling
import { readdirSync, lstatSync } from 'fs';
import path from 'path';
function findSymlinks(dir, rel = '') {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name === '.git') continue;
const childRel = rel ? `${rel}/${e.name}` : e.name;
const st = lstatSync(path.join(dir, e.name));
if (st.isSymbolicLink()) out.push(childRel);
else if (st.isDirectory()) out.push(...findSymlinks(path.join(dir, e.name), childRel));
}
return out;
}
// before calling: findSymlinks(pluginDir).length === 0 Try / catch
try {
hashPluginTree(pluginDir);
} catch (err) {
if (err.message.startsWith('refusing to hash symlink: ')) {
console.error(`Replace the symlink with a real copy: ${err.message}`);
}
throw err;
} Prevention
- Install plugins by copying files, never by symlinking dev folders.
- Scan the plugin tree for symlinks (find <dir> -type l) during install.
- Extract plugin archives with symlink entries rejected or materialized as copies.
- Document that the integrity hash requires a plain file/dir tree.
When it happens
Trigger: Calling hashPluginTree(dir) when any file or subdirectory inside the plugin tree (except node_modules/.git) is a symlink — e.g. a plugin that symlinks a shared asset, a package manager that created symlinked binaries, or a user who symlinked their plugin folder into the plugins directory.
Common situations: Installing a plugin via `ln -s` to a dev copy instead of copying it; npm/pnpm-style symlinked node_modules leaking into the tree (though node_modules is skipped, sibling symlinks are not); extracting archives containing symlinks; CI checkouts configured with symlinked hooks.
Understand the failure class
Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.
Related errors
- refusing to hash non-regular file
- cannot read
- escapes the tracker workspace
- a 40-hex commit --sha is required
- a16z-speedrun-talent: URL must use HTTPS
AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16).
Data as JSON: /api/errors/283d54b0c73345fa.
Report an issue: GitHub.
Appendix: 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 aac998c7ed)