{"record":{"id":"0ad5697ce751239e","repo":"paperclipai/paperclip","slug":"refusing-to-prune-unsafe-install-store-path-sour","errorCode":null,"errorMessage":"Refusing to prune unsafe install-store path ${sourceRoot}.","messagePattern":"Refusing to prune unsafe install-store path (.+?)\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"cli/src/install-store.ts","lineNumber":337,"sourceCode":"    .slice(0, 2);\n\n  return { schemaVersion: INSTALL_MANIFEST_VERSION, ...record, previous };\n}\n\nexport function pruneInstallPayloads(\n  manifest: InstallManifest,\n  paths = resolveInstallStorePaths(),\n): string[] {\n  const retained = new Set(\n    [manifest, ...manifest.previous].map((record) => path.resolve(record.payloadPath)),\n  );\n  const removed: string[] = [];\n  for (const source of [\"npm\", \"git\"] as const) {\n    const sourceRoot = path.join(paths.installsRoot, source);\n    if (!fs.existsSync(sourceRoot)) continue;\n    const sourceStat = fs.lstatSync(sourceRoot);\n    if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {\n      throw new Error(`Refusing to prune unsafe install-store path ${sourceRoot}.`);\n    }\n    for (const entry of fs.readdirSync(sourceRoot)) {\n      if (entry.startsWith(\".\")) continue;\n      const candidate = path.join(sourceRoot, entry);\n      if (!retained.has(path.resolve(candidate))) {\n        fs.rmSync(candidate, { recursive: true, force: true });\n        removed.push(candidate);\n      }\n    }\n  }\n  return removed;\n}\n\nexport function assertManagedShimWritable(paths = resolveInstallStorePaths()): void {\n  const homeDir = path.dirname(path.dirname(path.dirname(paths.shimPath)));\n  for (const directoryPath of [homeDir, path.join(homeDir, \".local\"), path.dirname(paths.shimPath)]) {\n    if (!fs.existsSync(directoryPath)) continue;\n    const directoryStat = fs.lstatSync(directoryPath);","sourceCodeStart":319,"sourceCodeEnd":355,"githubUrl":"https://github.com/paperclipai/paperclip/blob/67001ec6eb96ae601aa27bc91d9b2415d665334a/cli/src/install-store.ts#L319-L355","documentation":"Thrown by pruneInstallPayloads when the sourceRoot directory (installsRoot/npm or installsRoot/git) is not a real directory or is a symbolic link. Because pruneInstallPayloads calls fs.rmSync with { recursive: true } on entries inside these directories, a symlink at this level could cause recursive deletion of files outside the install store. This guard is a fail-closed TOCTOU defense against symlink substitution.","triggerScenarios":"Calling pruneInstallPayloads when paths.installsRoot/npm or paths.installsRoot/git has been replaced with a symlink or is not a directory (e.g., a regular file or device node). The check runs after fs.existsSync returns true, so it fires specifically when the path exists but is the wrong type.","commonSituations":"An attacker or misconfigured tool symlinked the npm or git source directory to an external location. The install store was partially restored from a backup that preserved symlinks instead of resolving them. A user tried to share install payloads across machines via symlinks.","solutions":["Inspect both source directories: 'ls -la <installsRoot>/npm' and 'ls -la <installsRoot>/git'.","Remove the offending symlink and recreate a real directory: 'rm <sourceRoot> && mkdir -p <sourceRoot>'.","Verify the installsRoot tree contains only real directories managed by the installer.","Re-run pruneInstallPayloads after the directory structure is corrected."],"exampleFix":"// before: installsRoot/npm is a symlink to /shared/installs\n// lstat shows isSymbolicLink() === true\n\n// after: replace symlink with real directory\nconst stat = fs.lstatSync(sourceRoot);\nif (stat.isSymbolicLink()) {\n  fs.rmSync(sourceRoot);\n  fs.mkdirSync(sourceRoot, { recursive: true, mode: 0o700 });\n}","handlingStrategy":"validation","validationCode":"import fs from 'node:fs';\n\nfunction validateSourceRootsSafe(paths: { installsRoot: string }): boolean {\n  for (const source of ['npm', 'git']) {\n    const sourceRoot = path.join(paths.installsRoot, source);\n    if (!fs.existsSync(sourceRoot)) continue;\n    const stat = fs.lstatSync(sourceRoot);\n    if (!stat.isDirectory() || stat.isSymbolicLink()) return false;\n  }\n  return true;\n}\n\n// Call before pruneInstallPayloads:\nif (!validateSourceRootsSafe(paths)) {\n  throw new Error('Source root is unsafe (symlink or non-directory); refusing to prune.');\n}","typeGuard":null,"tryCatchPattern":"try {\n  pruneInstallPayloads(manifest, paths);\n} catch (error) {\n  if (error instanceof Error && error.message.includes('Refusing to prune unsafe')) {\n    // Source root was replaced with a symlink; inspect and fix before retrying\n    console.error('Install store source root is unsafe');\n  }\n  throw error;\n}","preventionTips":["Never symlink the npm/ or git/ subdirectories inside the installs root.","Before pruning, verify source directories with 'ls -la' to confirm they are real directories.","Use the installer's own prune function rather than manually deleting install payloads."],"tags":["security","install-store","prune","symlink","cli"],"backgroundTag":null,"analyzedSha":"67001ec6eb96ae601aa27bc91d9b2415d665334a","analyzedAt":"2026-08-12T12:05:45.408Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}