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
- 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.
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
- 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.
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
- Refusing to activate payload that resolves outside ${paths.i
- Refusing to use unsafe shim directory ${directoryPath}.
- Refusing to use non-directory install-store path ${directory
- Refusing to modify path not owned by the current user: ${tar
- Refusing to use unsafe install-store marker ${paths.markerPa
AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12).
Data as JSON: /api/errors/0ad5697ce751239e.
Report an issue: GitHub.