paperclipai/paperclip · error · Error
ACPX snapshot escaped its package
Error message
ACPX snapshot escaped its package
What it means
During snapshot copy, for non-symlink entries the library verifies via realpath that the source resolves within the admitted package root; if not, the snapshot 'escaped its package' and it throws. This blocks path traversal — entries that escape the root via symlinks resolved as real directories or other aliasing — from being inducted into the private snapshot.
Source
Thrown at packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts:109
: null;
};
const copy = async (
source: string,
target: string,
root: string,
): Promise<void> => {
if (++filesCopied > 30_000)
throw new Error("ACPX package snapshot exceeds its file bound");
const before = await lstat(source, { bigint: true });
if (before.isSymbolicLink()) {
const canonical = await realpath(source);
const mapped = mapPath(canonical);
// Package-manager links to unqualified packages do not grant import authority.
if (mapped) await symlink(mapped, target);
return;
}
if (!within(root, await realpath(source)))
throw new Error("ACPX snapshot escaped its package");
if (before.isDirectory()) {
await mkdir(target, { mode: 0o700 });
directories.push(target);
for (const entry of await readdir(source))
await copy(join(source, entry), join(target, entry), root);
if (!same(before, await lstat(source, { bigint: true })))
throw new Error("ACPX package directory changed during snapshot");
return;
}
if (!before.isFile() || before.size > 16n * 1024n * 1024n)
throw new Error("ACPX module must be a bounded regular file");
bytesCopied += Number(before.size);
if (bytesCopied > MAX_PACKAGE_SNAPSHOT_BYTES)
throw new Error("ACPX package snapshot exceeds its byte bound");
const handle = await open(
source,
constants.O_RDONLY | constants.O_NOFOLLOW,
);View on GitHub (pinned to 01ad858492)
Solutions
- Verify the package root is the true canonical parent of all contents (no entries resolving outside it)
- Use a real (non-symlinked) package directory, or an install layout where packages own their files (npm's isolated layout rather than symlinked hoisting)
- Check for concurrent modification racing the lstat/realpath window and re-run when the tree is stable
- Audit package contents for outbound symlinks before admitting the root
Example fix
// before: root is a symlinked hoist location roots: ['/project/node_modules/pkg'] // after: root is the real package store path roots: ['/project/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg']
Defensive patterns
Strategy: validation
Validate before calling
const real = await fs.realpath(source);
if (!real.startsWith(await fs.realpath(root) + path.sep)) {
throw new Error(`entry escapes package root: ${source} -> ${real}`);
} Try / catch
try {
await createAcpxPrivateSnapshot({ roots: [root] });
} catch (e) {
if (e.message.includes('escaped its package')) {
throw new Error(`Root ${root} contains entries resolving outside it; use the real package path`);
}
throw e;
} Prevention
- Audit packages for outbound symlinks before admitting their roots
- Use install layouts where packages own real files (avoid symlink-heavy hoisting when snapshotting)
- Resolve the root with realpath before admitting it so it matches canonical entry paths
When it happens
Trigger: copy() encounters a directory/file whose realpath(source) is outside the `root` package root — e.g. a hard-linked tree, a bind-mounted path, or a directory symlink resolved before the isSymbolicLink branch (variants where lstat reports a real dir but the canonical path leaves the root), or a symlink swapped in between lstat and realpath.
Common situations: A package containing symlinks that lstat sees as directories due to a race; copy-on-write/hardlink-style installs (pnpm) where entries resolve outside the claimed root; misconfigured package root that doesn't actually contain the entry.
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
- Trusted viewer must not use symlinks
- native_runtime_context_entry_outside_bundle
- Invalid canonical workspace path
- Access denied
- UI parser path escapes package directory — skipping
AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10).
Data as JSON: /api/errors/6805802da5a0c93f.
Report an issue: GitHub.