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
- Ensure nothing else mutates the staging slot during staging (no concurrent writers, no symlinks in the staging dir)
- Re-run staging from scratch — the error is a safety abort; a fresh call recreates a clean confined slot
- 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
- Do not create symlinks or hard links inside staging directories
- Use the registry lock for all staging; never bypass it with direct writes
- Keep the workspace root path symlink-free and stable for the process lifetime
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
- ACPX runtime executable must be a real regular file
- ACPX provider package issuer for
- Trusted viewer must not use symlinks
- A trusted viewer build is required for public chat reports
- Access denied
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)