paperclipai/paperclip · error

Writable sandbox path "${normalizedExtraPath}" is outside sy

Error message

Writable sandbox path "${normalizedExtraPath}" is outside synchronized workspace "${workspaceDir}" and has no outbound restore mapping.

What it means

Thrown in the filesystemScope="workspace" branch when an extraPath with access "rw" sits outside workspaceDir AND is not covered by any outboundRestorePaths entry. The sandbox only syncs workspaceDir back to the host; a writable bind-mount outside it would silently drop changes on container teardown. The library refuses to start rather than discard writes the caller might depend on.

Source

Thrown at packages/adapter-utils/src/local-process-sandbox.ts:373

  const cwd = normalizeAbsolutePath(input.cwd, "Sandbox cwd");
  if (filesystemScope === "workspace") {
    const relativeCwd = path.relative(workspaceDir, cwd);
    if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) {
      throw new Error(`Sandbox cwd "${cwd}" must be inside workspaceDir "${workspaceDir}".`);
    }
    const outboundRestorePaths = (input.options.outboundRestorePaths ?? []).map((candidate, index) =>
      normalizeAbsolutePath(candidate, `Sandbox outboundRestorePaths[${index}]`));
    for (const [index, extraPath] of (input.options.extraPaths ?? []).entries()) {
      if (extraPath.access !== "rw") continue;
      const normalizedExtraPath = normalizeAbsolutePath(extraPath.path, `Sandbox extraPaths[${index}].path`);
      const relativeToWorkspace = path.relative(workspaceDir, normalizedExtraPath);
      const synchronized = !relativeToWorkspace.startsWith("..") && !path.isAbsolute(relativeToWorkspace);
      const restored = outboundRestorePaths.some((restorePath) => {
        const relative = path.relative(restorePath, normalizedExtraPath);
        return !relative.startsWith("..") && !path.isAbsolute(relative);
      });
      if (!synchronized && !restored) {
        throw new Error(
          `Writable sandbox path "${normalizedExtraPath}" is outside synchronized workspace "${workspaceDir}" and has no outbound restore mapping.`,
        );
      }
    }
  }

  const bwrapCommand = input.options.command?.trim() || "bwrap";
  const args = ["--die-with-parent", "--new-session", "--unshare-pid", "--unshare-ipc", "--unshare-uts"];
  const env: Record<string, string | undefined> = {};
  let cleanup: (() => Promise<void>) | undefined;
  let executable = input.executable;
  let executableArgs = input.args;

  if (filesystemScope === "workspace") {
    args.push("--tmpfs", "/", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp");
    args.push(
      "--symlink", "usr/bin", "/bin",
      "--symlink", "usr/sbin", "/sbin",

View on GitHub (pinned to 67001ec6eb)

Solutions

  1. If the writable path is supposed to sync back, add its parent to outboundRestorePaths so the restore step recovers it.
  2. If the writable path is disposable (cache, scratch), move it inside workspaceDir so it is synchronized automatically.
  3. If you do not need writes, change access to "ro" — read-only paths can sit outside the workspace without a restore mapping.
  4. Verify outboundRestorePaths entries with path.relative(restorePath, extraPath) in a debugger — the restore check uses non-"..", non-absolute relatives, so a restore path of /tmp covers /tmp/cache but not /var/cache.

Example fix

// before
buildLocalProcessSandboxSpawnTarget({
  ...input,
  options: {
    ...input.options,
    filesystemScope: "workspace",
    workspaceDir: "/srv/work",
    extraPaths: [{ path: "/tmp/build-cache", access: "rw" }],
    outboundRestorePaths: [],
  },
});

// after: cache lives inside the synced workspace
buildLocalProcessSandboxSpawnTarget({
  ...input,
  options: {
    ...input.options,
    filesystemScope: "workspace",
    workspaceDir: "/srv/work",
    extraPaths: [{ path: "/srv/work/.cache", access: "rw" }],
    outboundRestorePaths: [],
  },
});
Defensive patterns

Strategy: validation

Validate before calling

function assertWritablePathsRestored(opts: {
  workspaceDir: string;
  extraPaths: { path: string; access: string }[];
  outboundRestorePaths: string[];
}): void {
  for (const extra of opts.extraPaths) {
    if (extra.access !== "rw") continue;
    const rel = path.relative(opts.workspaceDir, extra.path);
    const inWorkspace = !rel.startsWith("..") && !path.isAbsolute(rel);
    const restored = opts.outboundRestorePaths.some((p) => {
      const r = path.relative(p, extra.path);
      return !r.startsWith("..") && !path.isAbsolute(r);
    });
    if (!inWorkspace && !restored) {
      throw new Error(`Writable path ${extra.path} outside workspace and not in outboundRestorePaths`);
    }
  }
}

Try / catch

try {
  return await buildLocalProcessSandboxSpawnTarget(input);
} catch (error) {
  if (error instanceof Error && error.message.includes("no outbound restore mapping")) {
    input.options.outboundRestorePaths = [...(input.options.outboundRestorePaths ?? []), path.dirname(offendingPath)];
    return buildLocalProcessSandboxSpawnTarget(input);
  }
  throw error;
}

Prevention

When it happens

Trigger: extraPaths: [{ path: "/tmp/cache", access: "rw" }] with workspaceDir: "/srv/work" and no outboundRestorePaths covering /tmp/cache. The check at local-process-sandbox.ts:363-377 iterates only rw entries; ro entries can sit outside the workspace because they cannot diverge from the host.

Common situations: Adapters that bind-mount cache directories, build output dirs, or home-directory dotfiles as rw inside the sandbox without telling Paperclip how to recover their state. Also hit when outboundRestorePaths is populated but the rw path is not nested under any of them (typo, wrong path, missing trailing slash semantics).

Related errors


AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12). Data as JSON: /api/errors/2e3984508ee0bf44. Report an issue: GitHub.