paperclipai/paperclip · error · Error

Refusing to prune unsafe install-store path ${sourceRoot}.

Error message

Refusing to prune unsafe install-store path ${sourceRoot}.

What it means

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.

Source

Thrown at cli/src/install-store.ts:337

    .slice(0, 2);

  return { schemaVersion: INSTALL_MANIFEST_VERSION, ...record, previous };
}

export function pruneInstallPayloads(
  manifest: InstallManifest,
  paths = resolveInstallStorePaths(),
): string[] {
  const retained = new Set(
    [manifest, ...manifest.previous].map((record) => path.resolve(record.payloadPath)),
  );
  const removed: string[] = [];
  for (const source of ["npm", "git"] as const) {
    const sourceRoot = path.join(paths.installsRoot, source);
    if (!fs.existsSync(sourceRoot)) continue;
    const sourceStat = fs.lstatSync(sourceRoot);
    if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
      throw new Error(`Refusing to prune unsafe install-store path ${sourceRoot}.`);
    }
    for (const entry of fs.readdirSync(sourceRoot)) {
      if (entry.startsWith(".")) continue;
      const candidate = path.join(sourceRoot, entry);
      if (!retained.has(path.resolve(candidate))) {
        fs.rmSync(candidate, { recursive: true, force: true });
        removed.push(candidate);
      }
    }
  }
  return removed;
}

export function assertManagedShimWritable(paths = resolveInstallStorePaths()): void {
  const homeDir = path.dirname(path.dirname(path.dirname(paths.shimPath)));
  for (const directoryPath of [homeDir, path.join(homeDir, ".local"), path.dirname(paths.shimPath)]) {
    if (!fs.existsSync(directoryPath)) continue;
    const directoryStat = fs.lstatSync(directoryPath);

View on GitHub (pinned to 67001ec6eb)

Solutions

  1. Inspect both source directories: 'ls -la <installsRoot>/npm' and 'ls -la <installsRoot>/git'.
  2. Remove the offending symlink and recreate a real directory: 'rm <sourceRoot> && mkdir -p <sourceRoot>'.
  3. Verify the installsRoot tree contains only real directories managed by the installer.
  4. Re-run pruneInstallPayloads after the directory structure is corrected.

Example fix

// before: installsRoot/npm is a symlink to /shared/installs
// lstat shows isSymbolicLink() === true

// after: replace symlink with real directory
const stat = fs.lstatSync(sourceRoot);
if (stat.isSymbolicLink()) {
  fs.rmSync(sourceRoot);
  fs.mkdirSync(sourceRoot, { recursive: true, mode: 0o700 });
}
Defensive patterns

Strategy: validation

Validate before calling

import fs from 'node:fs';

function validateSourceRootsSafe(paths: { installsRoot: string }): boolean {
  for (const source of ['npm', 'git']) {
    const sourceRoot = path.join(paths.installsRoot, source);
    if (!fs.existsSync(sourceRoot)) continue;
    const stat = fs.lstatSync(sourceRoot);
    if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
  }
  return true;
}

// Call before pruneInstallPayloads:
if (!validateSourceRootsSafe(paths)) {
  throw new Error('Source root is unsafe (symlink or non-directory); refusing to prune.');
}

Try / catch

try {
  pruneInstallPayloads(manifest, paths);
} catch (error) {
  if (error instanceof Error && error.message.includes('Refusing to prune unsafe')) {
    // Source root was replaced with a symlink; inspect and fix before retrying
    console.error('Install store source root is unsafe');
  }
  throw error;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12). Data as JSON: /api/errors/0ad5697ce751239e. Report an issue: GitHub.