paperclipai/paperclip · error

CreateOS archive destination cannot be a symlink.

Error message

CreateOS archive destination cannot be a symlink.

What it means

When extracting an outbound archive into a local directory, the plugin checks that the destination directory itself is not a symlink before untarring. This blocks archive-extraction attacks where an attacker-controlled sandbox replaces the target path with a symlink pointing elsewhere on the host. It is a security guard for host-side path integrity.

Solutions

  1. Remove the symlink at the target path and let the plugin create a real directory, then retry the transfer.
  2. Audit the host directory for unexpected symlinks — this can indicate a compromised sandbox or archive (tar.x runs with strict:true and preservePaths:false, but the destination check is separate).
  3. Point the mapping's local target at a fresh, plugin-owned directory rather than a shared or user-controlled path.

Example fix

// before (host state)
/work/out -> /etc   // symlink
// after: clear it so a real directory is created
rm /work/out && mkdir /work/out   // or delete the symlink and re-run syncFiles
Defensive patterns

Strategy: validation

Validate before calling

import fs from "node:fs/promises";
const st = await fs.lstat(localTarget).catch(() => null);
if (st?.isSymbolicLink()) throw new Error(`target is a symlink: ${localTarget}`);

Type guard

const isRealDir = async (p: string) => {
  try { return (await fs.lstat(p)).isDirectory() && !(await fs.lstat(p)).isSymbolicLink(); }
  catch { return false; }
};

Try / catch

try {
  await syncFiles({ direction: "out", operations });
} catch (err) {
  if (err.message.includes("symlink")) {
    // inspect for tampering; remove symlink with a real dir and retry once
  } else throw err;
}

Prevention

When it happens

Trigger: download() creates/copies a path at the mapping's local target, and a prior state (or a malicious archive/remote content) leaves `local` as a symlink when lstat runs, i.e. `(await fs.lstat(local)).isSymbolicLink()` is true after mkdir.

Common situations: A previous sync or an attacker planted a symlink at the extraction directory; the target path deliberately points at a symlinked directory the user expected to follow; repeated runs on shared temp directories with stale state.

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/631b30d00cff029e. Report an issue: GitHub.

Appendix: source

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

          filesTransferred += mapping.kind === "file" ? 1 : await countArchiveFiles(source);
          bytesTransferred += (await fs.stat(source)).size;
        } else {
          const excludeArgs = (mapping.exclude ?? []).map((pattern) => `--exclude=${shellQuote(pattern)}`).join(" ");
          await run(mapping.kind === "file"
            ? `${remoteGuard(remote)} && test -f "$resolved" && cp -- "$resolved" ${shellQuote(scratch)}`
            : `${remoteGuard(remote)} && test -d "$resolved" && tar ${mapping.followSymlinks ? "-h " : ""}${excludeArgs} -cf ${shellQuote(scratch)} -C "$resolved" .`);
          await download(scratch, transferFile, mapping.mode ?? 0o600);
          bytesTransferred += (await fs.stat(transferFile)).size;
          if (mapping.kind === "file") {
            // Apply exact requested mode before promotion, including under a
            // restrictive umask. Never expose secret bytes at the final path first.
            await fs.chmod(transferFile, mapping.mode ?? 0o600);
            await fs.rename(transferFile, local);
            filesTransferred++;
          } else {
            filesTransferred += await validateArchive(transferFile);
            await fs.mkdir(local, { recursive: true, mode: mapping.mode ?? 0o700 });
            if ((await fs.lstat(local)).isSymbolicLink()) throw new Error("CreateOS archive destination cannot be a symlink.");
            // tar rejects traversal through existing symlink parents. Validate
            // all archive entries first so an unsafe archive never partly lands.
            await tar.x({ file: transferFile, cwd: local, strict: true, preservePaths: false });
            if (mapping.mode != null) await fs.chmod(local, mapping.mode);
          }
        }
      } finally {
        await fs.rm(temp, { recursive: true, force: true });
        await client.json(`/sandboxes/${id}/exec`, "POST", { cmd: "/bin/rm", args: ["-f", "--", scratch] }).catch(() => undefined);
      }
    }
    for (const command of operation.postUploadCommands ?? []) {
      await run(remoteGuard(command.cwd ?? ROOT));
      await run(command.command, command.cwd ?? ROOT, command.timeoutMs);
    }
    operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
  }
  return { operations };

View on GitHub (pinned to 3f1d897a7c)