{"record":{"id":"78c7e2b0d6bdc1d6","repo":"paperclipai/paperclip","slug":"project-repository-escaped-its-workspace","errorCode":null,"errorMessage":"Project repository escaped its workspace","messagePattern":"Project repository escaped its workspace","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"packages/adapter-utils/src/sandbox-managed-runtime.ts","lineNumber":1684,"sourceCode":"      // The task exports the sandbox git history (git-backed workspace), reads\n      // the sandbox workspace back, and merges it into the host workspace root.\n      // The merge is the only outbound write inside the host workspace root.\n      // Every other task writes a disjoint host target: an asset restore writes\n      // its own store outside the workspace root. So the workspace task and the\n      // asset tasks share one parallel set. Keep any future asset that must write\n      // inside the host workspace root out of this set, and run it after the\n      // merge. Each task gets its own restore temp directory, so two concurrent\n      // tasks never share scratch state.\n      if (syncWorkspace) {\n        outboundTasks.push(() =>\n          runStepSpan(\"restore.workspace\", async () => {\n            // Each repository owns its Git history and merge. The parent baseline also\n            // records child files so restart recovery has their original merge inputs.\n            for (const repository of repositories) {\n              const prefix = `${repository.path}/`;\n              const localDir = path.join(input.workspaceLocalDir, repository.path);\n              if (await fs.realpath(localDir) !== path.join(await fs.realpath(input.workspaceLocalDir), repository.path)) {\n                throw new Error(\"Project repository escaped its workspace\");\n              }\n              const nestedExclude = mergeExcludes(\n                repository.snapshot.ignoredPaths,\n                baselineSnapshot!.exclude.flatMap((entry) =>\n                  entry.startsWith(prefix) ? [entry.slice(prefix.length)]\n                    : entry.startsWith(\"*/\") ? [entry] : []),\n              );\n              const nested = await prepareSandboxManagedRuntime({\n                spec: { ...input.spec, remoteCwd: path.posix.join(workspaceRemoteDir, repository.path) },\n                client: input.client,\n                adapterKey: input.adapterKey,\n                workspaceLocalDir: localDir,\n                workspaceInboundMode: \"adopt_remote\",\n                workspaceGitSnapshot: repository.snapshot,\n                workspaceExclude: nestedExclude,\n                workspaceBaseline: {\n                  exclude: mergeExcludes(\n                    SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,","sourceCodeStart":1666,"sourceCodeEnd":1702,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/packages/adapter-utils/src/sandbox-managed-runtime.ts#L1666-L1702","documentation":"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.","triggerScenarios":"During child repository restore, fs.realpath(localDir) !== path.join(realpath(workspaceLocalDir), repository.path) — typically because repository.path or localDir contains a symlink pointing elsewhere.","commonSituations":"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).","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)."],"exampleFix":null,"handlingStrategy":"validation","validationCode":"const localDir = path.join(workspaceLocalDir, repo.path);\nif (await fs.realpath(localDir) !== path.join(await fs.realpath(workspaceLocalDir), repo.path)) {\n  throw new Error(`repository ${repo.path} escapes workspace`);\n}","typeGuard":"const isInsideWorkspace = async (root: string, rel: string) => {\n  const realRoot = await fs.realpath(root);\n  const real = await fs.realpath(path.join(root, rel));\n  return real === path.join(realRoot, rel) || real.startsWith(realRoot + path.sep);\n};","tryCatchPattern":"try { await restoreRepositories(repos); } catch (e) {\n  if (e.message === \"Project repository escaped its workspace\") { await reCloneWorkspace(); }\n  throw e;\n}","preventionTips":["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"],"tags":["security","path-traversal","workspace","symlink"],"backgroundTag":"path-traversal-blocked","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}