paperclipai/paperclip · critical · Error

Refusing to activate payload outside ${paths.installsRoot}.

Error message

Refusing to activate payload outside ${paths.installsRoot}.

What it means

Thrown by assertPayloadPath() (used by flipCurrentAtomic) when the payload path, resolved and made relative to installsRoot, is empty, starts with '..', or is absolute. This prevents activating (symlinking 'current' to) a payload that lives outside the managed installs directory, blocking path-traversal attacks that could point the CLI entrypoint at arbitrary code.

Source

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

export function writeInstallManifestAtomic(
  manifest: InstallManifest,
  paths = resolveInstallStorePaths(),
): void {
  ensurePrivateDirectory(paths.cliRoot);
  const temporaryPath = `${paths.manifestPath}.tmp-${process.pid}-${Date.now()}`;
  try {
    fs.writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
    fs.renameSync(temporaryPath, paths.manifestPath);
  } finally {
    fs.rmSync(temporaryPath, { force: true });
  }
}

function assertPayloadPath(payloadPath: string, paths: InstallStorePaths): void {
  const relative = path.relative(paths.installsRoot, path.resolve(payloadPath));
  if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
    throw new Error(`Refusing to activate payload outside ${paths.installsRoot}.`);
  }
  const stat = fs.lstatSync(payloadPath);
  if (!stat.isDirectory() || stat.isSymbolicLink()) {
    throw new Error(`Refusing to activate non-directory payload ${payloadPath}.`);
  }
  const installsRealPath = fs.realpathSync(paths.installsRoot);
  const payloadRealPath = fs.realpathSync(payloadPath);
  if (!payloadRealPath.startsWith(`${installsRealPath}${path.sep}`)) {
    throw new Error(`Refusing to activate payload that resolves outside ${paths.installsRoot}.`);
  }
}

export function flipCurrentAtomic(
  payloadPath: string,
  paths = resolveInstallStorePaths(),
  hooks: { beforeRename?: () => void } = {},
): void {
  assertPayloadPath(payloadPath, paths);

View on GitHub (pinned to 67001ec6eb)

Solutions

  1. Ensure payloadPath is always derived from payloadPathFor(paths, source, id) which produces a path under installsRoot, rather than constructed from raw input.
  2. If constructing manually, verify path.resolve(payloadPath) starts with path.resolve(installsRoot) + path.sep before calling flipCurrentAtomic.
  3. If the manifest's payloadPath is wrong, remove the store and reinstall.
  4. Never pass user-supplied absolute paths to flipCurrentAtomic.

Example fix

// before
flipCurrentAtomic("/opt/suspicious/payload", paths); // throws

// after
const id = "1.2.3";
const payloadPath = payloadPathFor(paths, "npm", id); // under installsRoot
flipCurrentAtomic(payloadPath, paths);
Defensive patterns

Strategy: validation

Validate before calling

import path from "node:path";
import { resolveInstallStorePaths } from "./install-store.js";

function payloadPathWithinInstalls(payloadPath: string, paths = resolveInstallStorePaths()): boolean {
  const rel = path.relative(paths.installsRoot, path.resolve(payloadPath));
  return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
}

// Before flipCurrentAtomic:
if (!payloadPathWithinInstalls(payload, paths)) throw new Error("payload outside installs root");

Type guard

import path from "node:path";

function isWithinBase(base: string, target: string): boolean {
  const rel = path.relative(base, path.resolve(target));
  return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
}

Try / catch

try {
  flipCurrentAtomic(payloadPath, paths);
} catch (err) {
  if (err instanceof Error && err.message.startsWith("Refusing to activate payload outside")) {
    // payload path was constructed incorrectly; recompute via payloadPathFor
    throw err;
  }
  throw err;
}

Prevention

When it happens

Trigger: Called flipCurrentAtomic(payloadPath, paths) where payloadPath resolves outside paths.installsRoot — e.g. an absolute path like '/opt/evil', a relative escape like '../../../tmp/x', or empty. assertPayloadPath computes path.relative and detects the escape before any symlink is created.

Common situations: 1) A caller computed payloadPath from untrusted input without constraining it under installsRoot. 2) Manifest corruption pointing payloadPath outside the store. 3) A bug in install orchestration passing the wrong base path. 4. Tampering attempt to redirect the 'current' symlink at arbitrary code.

Related errors


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