paperclipai/paperclip · error · Error

paperclip_runner_attachment_staging_path_denied

paperclip_runner_attachment_staging_path_denied

Error message

paperclip_runner_attachment_staging_path_denied

What it means

Pre-write integrity gate when staging runner attachment bytes into a confined workspace slot. Before truncating/writing, the runtime verifies the descriptor path is a regular file (nlink===1), inside workspaceRoot, resolves to the same real path as destination, is not a symlink, and retains the same file identity as when opened. Any violation (TOCTOU swap, symlink injection, path escape) aborts with this coded error to protect the staging slot from path-traversal attacks.

Solutions

  1. Ensure nothing else mutates the staging slot during staging (no concurrent writers, no symlinks in the staging dir)
  2. Re-run staging from scratch — the error is a safety abort; a fresh call recreates a clean confined slot
  3. Verify the workspace root path is stable and fully resolved (no symlinked parents)
Defensive patterns

Strategy: try-catch

Validate before calling

const st = await fs.lstat(dest);
if (!st.isFile() || st.nlink !== 1 || path.relative(workspaceRoot, await fs.realpath(dest)).startsWith("..")) {
  throw new Error("staging destination pre-check failed");
}

Type guard

const safeStagingSlot = (st, realDest, workspaceRoot) => st.isFile() && st.nlink === 1 && !st.isSymbolicLink() && isWithin(workspaceRoot, realDest);

Try / catch

try { await stageNativeRunnerAttachmentBytes(input); } catch (e) { if (e.message === "paperclip_runner_attachment_staging_path_denied") { /* recreate a clean slot and retry once; investigate concurrent writers */ } else throw e; }

Prevention

When it happens

Trigger: stageNativeRunnerAttachmentBytes pipeline where, between open and write, the destination was replaced by a symlink or different inode; descriptorPath resolved outside workspaceRoot; realpath(destination) diverges from descriptorPath; the file has hard links (nlink!==1).

Common situations: Concurrent process swapped the staging path; attacker-manipulated symlink in the staging directory; workspace root moved/renamed mid-stage so realpath differs.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/c107247919794d1a. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/native-runtime/native-runner-file-handoff.ts:682

        (constants.O_NOFOLLOW ?? 0),
      0o600,
    );
  }
  let keepOpen = false;
  let safeToClear = false;
  try {
    const descriptorPath = await openedFilePath(handle.fd);
    const before = await handle.stat();
    const pathBefore = await lstat(input.destination);
    if (
      !before.isFile() ||
      before.nlink !== 1 ||
      !isWithin(input.workspaceRoot, descriptorPath) ||
      descriptorPath !== (await realpath(input.destination)) ||
      pathBefore.isSymbolicLink() ||
      !sameFileIdentity(before, pathBefore)
    ) {
      throw new Error("paperclip_runner_attachment_staging_path_denied");
    }
    safeToClear = true;
    await handle.truncate(0);
    await handle.write(input.body, 0, input.body.length, 0);
    await handle.sync();
    const after = await handle.stat();
    const pathAfter = await lstat(input.destination);
    await assertNoSymlinkComponents(
      input.workspaceRoot,
      path.relative(input.workspaceRoot, input.destination),
    );
    if (
      after.size !== input.body.length ||
      after.nlink !== 1 ||
      pathAfter.isSymbolicLink() ||
      !sameFileIdentity(after, pathAfter) ||
      descriptorPath !== (await realpath(input.destination))
    ) {

View on GitHub (pinned to 3f1d897a7c)