paperclipai/paperclip · error

CreateOS transfer path escapes the workspace.

Error message

CreateOS transfer path escapes the workspace.

What it means

After rejecting non-absolute/insecure paths, assertRemotePath normalizes the path and requires the result to be exactly /paperclip-workspace or inside /paperclip-workspace/. This catches paths that are syntactically absolute but resolve outside the sanctioned workspace root — the containment boundary for all CreateOS file transfers.

Solutions

  1. Rewrite the destination so its normalized form is under /paperclip-workspace/ (or equals ROOT).
  2. Use path.posix.join(ROOT, relative) instead of string concatenation to build remote paths.
  3. Validate paths with assertRemotePath yourself in dev/test before shipping calls that transfer files.
  4. Beware prefix bugs: /paperclip-workspace-evil is rejected; rely on the normalized startsWith(ROOT + '/') rule.

Example fix

// before
const remote = "/tmp/results/out.json";
// after
const remote = "/paperclip-workspace/results/out.json";
Defensive patterns

Strategy: validation

Validate before calling

function underWorkspace(p) {
  const n = path.posix.normalize(p);
  return n === "/paperclip-workspace" || n.startsWith("/paperclip-workspace/");
}
// assert before every transfer call
if (!underWorkspace(remote)) throw new Error("destination must be inside /paperclip-workspace");

Type guard

function isInsideWorkspace(p) {
  const n = path.posix.normalize(p);
  return n === "/paperclip-workspace" || n.startsWith("/paperclip-workspace/");
}

Try / catch

try {
  await syncFiles(lease, transfers);
} catch (e) {
  if (e.message.includes("escapes the workspace")) {
    log.error("remote path outside /paperclip-workspace", { transfers });
  }
  throw e;
}

Prevention

When it happens

Trigger: Passing a path like /etc/passwd, /tmp/x, or /paperclip-workspace-sibling/file whose normalized form does not start with /paperclip-workspace/; also paths relying on normalization tricks that escape ROOT.

Common situations: Hardcoding destination paths meant for a different sandbox provider; writing to host-style temp directories; confusing the sandbox root with the host root; prefix string-check bugs when constructing paths (e.g. /paperclip-workspace-backup).

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/07684ed42ca65c94. Report an issue: GitHub.

Appendix: source

Thrown at packages/plugins/sandbox-providers/createos/src/file-sync.ts:19

import path from "node:path";
import os from "node:os";
import { randomUUID } from "node:crypto";
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import * as tar from "tar";
import type { PluginEnvironmentSyncInParams, PluginEnvironmentSyncResult } from "@paperclipai/plugin-sdk";
import { CreateosClient, identifier } from "./client.js";
import { execute, shellQuote } from "./execute.js";

const ROOT = "/paperclip-workspace";

export function assertRemotePath(value: string): void {
  if (!path.posix.isAbsolute(value) || value.includes("\0") || value.split("/").includes("..")) {
    throw new Error("CreateOS transfer requires a confined absolute sandbox path.");
  }
  const normalized = path.posix.normalize(value);
  if (normalized !== ROOT && !normalized.startsWith(`${ROOT}/`)) throw new Error("CreateOS transfer path escapes the workspace.");
}

function remoteGuard(candidate: string): string {
  assertRemotePath(candidate);
  // Re-check symlinks inside the sandbox immediately before use. A missing
  // canonicalizer fails the command rather than weakening containment.
  return `root=$(realpath -- ${shellQuote(ROOT)}) && test "$root" = ${shellQuote(ROOT)} && ` +
    `resolved=$(realpath -m -- ${shellQuote(candidate)}) && ` +
    `case "$resolved" in "$root"|"$root"/*) ;; *) exit 1 ;; esac`;
}

export async function validateArchive(file: string): Promise<number> {
  let invalid = false;
  let bytes = 0;
  let files = 0;
  const inside = (entryPath: string) => !path.posix.isAbsolute(entryPath) &&
    !entryPath.split("/").includes("..") && !entryPath.includes("\\") && !entryPath.includes("\0");
  await tar.t({ file, strict: true, onReadEntry(entry) {

View on GitHub (pinned to 3f1d897a7c)