paperclipai/paperclip · error
CreateOS archive destination cannot be a symlink.
Error message
CreateOS archive destination cannot be a symlink.
What it means
When extracting an outbound archive into a local directory, the plugin checks that the destination directory itself is not a symlink before untarring. This blocks archive-extraction attacks where an attacker-controlled sandbox replaces the target path with a symlink pointing elsewhere on the host. It is a security guard for host-side path integrity.
Solutions
- Remove the symlink at the target path and let the plugin create a real directory, then retry the transfer.
- Audit the host directory for unexpected symlinks — this can indicate a compromised sandbox or archive (tar.x runs with strict:true and preservePaths:false, but the destination check is separate).
- Point the mapping's local target at a fresh, plugin-owned directory rather than a shared or user-controlled path.
Example fix
// before (host state) /work/out -> /etc // symlink // after: clear it so a real directory is created rm /work/out && mkdir /work/out // or delete the symlink and re-run syncFiles
Defensive patterns
Strategy: validation
Validate before calling
import fs from "node:fs/promises";
const st = await fs.lstat(localTarget).catch(() => null);
if (st?.isSymbolicLink()) throw new Error(`target is a symlink: ${localTarget}`); Type guard
const isRealDir = async (p: string) => {
try { return (await fs.lstat(p)).isDirectory() && !(await fs.lstat(p)).isSymbolicLink(); }
catch { return false; }
}; Try / catch
try {
await syncFiles({ direction: "out", operations });
} catch (err) {
if (err.message.includes("symlink")) {
// inspect for tampering; remove symlink with a real dir and retry once
} else throw err;
} Prevention
- Extract archives only into fresh plugin-owned directories
- Audit shared temp dirs for stale symlinks before runs
- Treat this error as a possible compromise signal and investigate
When it happens
Trigger: download() creates/copies a path at the mapping's local target, and a prior state (or a malicious archive/remote content) leaves `local` as a symlink when lstat runs, i.e. `(await fs.lstat(local)).isSymbolicLink()` is true after mkdir.
Common situations: A previous sync or an attacker planted a symlink at the extraction directory; the target path deliberately points at a symlinked directory the user expected to follow; repeated runs on shared temp directories with stale state.
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
- Access denied
- Project repository escaped its workspace
- Trusted viewer must not use symlinks
- ACPX runtime executable must be a real regular file
- ACPX runtime executable escapes its package
AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18).
Data as JSON: /api/errors/631b30d00cff029e.
Report an issue: GitHub.
Appendix: source
Thrown at packages/plugins/sandbox-providers/createos/src/file-sync.ts:150
filesTransferred += mapping.kind === "file" ? 1 : await countArchiveFiles(source);
bytesTransferred += (await fs.stat(source)).size;
} else {
const excludeArgs = (mapping.exclude ?? []).map((pattern) => `--exclude=${shellQuote(pattern)}`).join(" ");
await run(mapping.kind === "file"
? `${remoteGuard(remote)} && test -f "$resolved" && cp -- "$resolved" ${shellQuote(scratch)}`
: `${remoteGuard(remote)} && test -d "$resolved" && tar ${mapping.followSymlinks ? "-h " : ""}${excludeArgs} -cf ${shellQuote(scratch)} -C "$resolved" .`);
await download(scratch, transferFile, mapping.mode ?? 0o600);
bytesTransferred += (await fs.stat(transferFile)).size;
if (mapping.kind === "file") {
// Apply exact requested mode before promotion, including under a
// restrictive umask. Never expose secret bytes at the final path first.
await fs.chmod(transferFile, mapping.mode ?? 0o600);
await fs.rename(transferFile, local);
filesTransferred++;
} else {
filesTransferred += await validateArchive(transferFile);
await fs.mkdir(local, { recursive: true, mode: mapping.mode ?? 0o700 });
if ((await fs.lstat(local)).isSymbolicLink()) throw new Error("CreateOS archive destination cannot be a symlink.");
// tar rejects traversal through existing symlink parents. Validate
// all archive entries first so an unsafe archive never partly lands.
await tar.x({ file: transferFile, cwd: local, strict: true, preservePaths: false });
if (mapping.mode != null) await fs.chmod(local, mapping.mode);
}
}
} finally {
await fs.rm(temp, { recursive: true, force: true });
await client.json(`/sandboxes/${id}/exec`, "POST", { cmd: "/bin/rm", args: ["-f", "--", scratch] }).catch(() => undefined);
}
}
for (const command of operation.postUploadCommands ?? []) {
await run(remoteGuard(command.cwd ?? ROOT));
await run(command.command, command.cwd ?? ROOT, command.timeoutMs);
}
operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
}
return { operations };View on GitHub (pinned to 3f1d897a7c)