{"record":{"id":"283d54b0c73345fa","repo":"santifer/career-ops","slug":"refusing-to-hash-symlink-childrel","errorCode":null,"errorMessage":"refusing to hash symlink: ${childRel}","messagePattern":"refusing to hash symlink: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"plugins/_lock.mjs","lineNumber":49,"sourceCode":" * a rug-pull mutate an un-hashed file. Rejects symlinks (a symlinked file would\n * pass the hash while pointing elsewhere). Excludes node_modules + .git.\n *\n * @param {string} dir absolute plugin directory\n * @returns {{ files: Record<string,string>, integrity: string }}\n */\nexport function hashPluginTree(dir) {\n  const files = {};\n  const walk = (abs, rel) => {\n    let entries;\n    try { entries = readdirSync(abs, { withFileTypes: true }); }\n    catch (err) { throw new Error(`cannot read ${rel || '.'}: ${err.message}`); }\n    for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {\n      if (e.name === 'node_modules' || e.name === '.git') continue;\n      const childAbs = path.join(abs, e.name);\n      const childRel = rel ? `${rel}/${e.name}` : e.name;\n      // lstat (not stat) so a symlink is detected, never followed.\n      const st = lstatSync(childAbs);\n      if (st.isSymbolicLink()) throw new Error(`refusing to hash symlink: ${childRel}`);\n      if (st.isDirectory()) walk(childAbs, childRel);\n      else if (st.isFile()) files[childRel] = sha256(readFileSync(childAbs));\n      else throw new Error(`refusing to hash non-regular file: ${childRel}`);\n    }\n  };\n  walk(dir, '');\n  // Aggregate integrity = sha256 over the deterministic sorted \"rel:hash\" join.\n  const aggregate = Object.keys(files).sort().map(k => `${k}:${files[k]}`).join('\\n');\n  return { files, integrity: sha256(Buffer.from(aggregate)) };\n}\n\n/** Read plugins.lock (fail-open to an empty lock — like the rest of the engine). */\nexport function readLock(root) {\n  const file = lockPath(root);\n  if (!existsSync(file)) return { lockfileVersion: LOCK_VERSION, plugins: {} };\n  try {\n    const parsed = JSON.parse(readFileSync(file, 'utf8'));\n    if (!parsed || typeof parsed !== 'object' || typeof parsed.plugins !== 'object') return { lockfileVersion: LOCK_VERSION, plugins: {} };","sourceCodeStart":31,"sourceCodeEnd":67,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/plugins/_lock.mjs#L31-L67","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: symlinked shared asset breaks hashing\n$ ln -s /usr/share/lib/vendor.css plugins/myplugin/assets/vendor.css\n\n// after: copy real content into the tree\n$ cp -L /usr/share/lib/vendor.css plugins/myplugin/assets/vendor.css\n$ rm plugins/myplugin/assets/vendor.css.link  # if a stale link remains","handlingStrategy":"validation","validationCode":"import { readdirSync, lstatSync } from 'fs';\nimport path from 'path';\nfunction findSymlinks(dir, rel = '') {\n  const out = [];\n  for (const e of readdirSync(dir, { withFileTypes: true })) {\n    if (e.name === 'node_modules' || e.name === '.git') continue;\n    const childRel = rel ? `${rel}/${e.name}` : e.name;\n    const st = lstatSync(path.join(dir, e.name));\n    if (st.isSymbolicLink()) out.push(childRel);\n    else if (st.isDirectory()) out.push(...findSymlinks(path.join(dir, e.name), childRel));\n  }\n  return out;\n}\n// before calling: findSymlinks(pluginDir).length === 0","typeGuard":null,"tryCatchPattern":"try {\n  hashPluginTree(pluginDir);\n} catch (err) {\n  if (err.message.startsWith('refusing to hash symlink: ')) {\n    console.error(`Replace the symlink with a real copy: ${err.message}`);\n  }\n  throw err;\n}","preventionTips":["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."],"tags":["symlink","security","plugin-integrity","hashing"],"backgroundTag":"path-traversal-blocked","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}