paperclipai/paperclip · critical
Project repository escaped its workspace
Error message
Project repository escaped its workspace
What it means
When restoring project repositories into a workspace, the runtime verifies each repository's resolved real path equals the expected path inside the workspace's real path. A mismatch means a symlink or path trickery would place the repository outside the workspace root, which would let sandboxed reads/writes escape the intended directory. The runtime aborts the operation to preserve containment.
Solutions
- Remove symlinks inside the workspace so each repository path is a real directory under workspaceLocalDir.
- Ensure repository.path is a plain relative path with no '..' segments or absolute components.
- Re-clone/resture the workspace so repositories live physically inside it.
- Point the runtime at a workspace directory that is itself a real path (resolve workspaceLocalDir before passing it in).
Defensive patterns
Strategy: validation
Validate before calling
const localDir = path.join(workspaceLocalDir, repo.path);
if (await fs.realpath(localDir) !== path.join(await fs.realpath(workspaceLocalDir), repo.path)) {
throw new Error(`repository ${repo.path} escapes workspace`);
} Type guard
const isInsideWorkspace = async (root: string, rel: string) => {
const realRoot = await fs.realpath(root);
const real = await fs.realpath(path.join(root, rel));
return real === path.join(realRoot, rel) || real.startsWith(realRoot + path.sep);
}; Try / catch
try { await restoreRepositories(repos); } catch (e) {
if (e.message === "Project repository escaped its workspace") { await reCloneWorkspace(); }
throw e;
} Prevention
- Never symlink repository directories to outside the workspace
- Validate repository.path is relative and free of '..' segments
- Re-clone workspaces rather than hand-relocating repository folders
- Run the realpath containment check before any sandbox restore
When it happens
Trigger: During child repository restore, fs.realpath(localDir) !== path.join(realpath(workspaceLocalDir), repository.path) — typically because repository.path or localDir contains a symlink pointing elsewhere.
Common situations: Workspace checked out via a symlinked subdirectory; repository.path containing '..' or an absolute symlink created by a prior run or bad config; users binding repository dirs to locations outside the workspace (e.g. shared caches).
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
- CreateOS archive destination cannot be a symlink.
- 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/78c7e2b0d6bdc1d6.
Report an issue: GitHub.
Appendix: source
Thrown at packages/adapter-utils/src/sandbox-managed-runtime.ts:1684
// The task exports the sandbox git history (git-backed workspace), reads
// the sandbox workspace back, and merges it into the host workspace root.
// The merge is the only outbound write inside the host workspace root.
// Every other task writes a disjoint host target: an asset restore writes
// its own store outside the workspace root. So the workspace task and the
// asset tasks share one parallel set. Keep any future asset that must write
// inside the host workspace root out of this set, and run it after the
// merge. Each task gets its own restore temp directory, so two concurrent
// tasks never share scratch state.
if (syncWorkspace) {
outboundTasks.push(() =>
runStepSpan("restore.workspace", async () => {
// Each repository owns its Git history and merge. The parent baseline also
// records child files so restart recovery has their original merge inputs.
for (const repository of repositories) {
const prefix = `${repository.path}/`;
const localDir = path.join(input.workspaceLocalDir, repository.path);
if (await fs.realpath(localDir) !== path.join(await fs.realpath(input.workspaceLocalDir), repository.path)) {
throw new Error("Project repository escaped its workspace");
}
const nestedExclude = mergeExcludes(
repository.snapshot.ignoredPaths,
baselineSnapshot!.exclude.flatMap((entry) =>
entry.startsWith(prefix) ? [entry.slice(prefix.length)]
: entry.startsWith("*/") ? [entry] : []),
);
const nested = await prepareSandboxManagedRuntime({
spec: { ...input.spec, remoteCwd: path.posix.join(workspaceRemoteDir, repository.path) },
client: input.client,
adapterKey: input.adapterKey,
workspaceLocalDir: localDir,
workspaceInboundMode: "adopt_remote",
workspaceGitSnapshot: repository.snapshot,
workspaceExclude: nestedExclude,
workspaceBaseline: {
exclude: mergeExcludes(
SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,View on GitHub (pinned to 3f1d897a7c)