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

  1. Remove symlinks inside the workspace so each repository path is a real directory under workspaceLocalDir.
  2. Ensure repository.path is a plain relative path with no '..' segments or absolute components.
  3. Re-clone/resture the workspace so repositories live physically inside it.
  4. 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

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


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)