paperclipai/paperclip · error

Invalid canonical workspace path

Error message

Invalid canonical workspace path

What it means

openRunnerApiWorkspaceFile opens a workspace file via confined, race-free opens (O_NOFOLLOW / procfs fd walk). On Linux it splits the canonical absolute path into segments and rejects any path containing '.' or '..' segments (or an empty path like '/'), because such segments would let the walk escape the workspace root or resolve ambiguously. It is a hard path-traversal guard: the function only accepts fully normalized absolute paths.

Source

Thrown at server/src/services/native-runtime/runner-api-files.ts:16

import { constants } from "node:fs";
import { open, type FileHandle } from "node:fs/promises";
import { isAbsolute } from "node:path";

/** Open a previously authorized canonical path without following raced symlinks. */
export async function openRunnerApiWorkspaceFile(path: string): Promise<FileHandle> {
  if (!isAbsolute(path)) throw new Error("Workspace file must have a canonical absolute path");
  if (process.platform === "darwin") {
    // Darwin sys/fcntl.h: O_NOFOLLOW_ANY rejects symlinks at every component.
    // Node does not expose this flag in fs.constants. Unsupported kernels fail
    // closed instead of falling back to a pathname check followed by open.
    return open(path, constants.O_RDONLY | constants.O_NONBLOCK | 0x20000000);
  }
  if (process.platform !== "linux") throw new Error("Workspace uploads require a platform with confined file opens; use an authorized artifact reference");
  const parts = path.split("/").filter(Boolean);
  if (!parts.length || parts.some(part => part === "." || part === "..")) throw new Error("Invalid canonical workspace path");
  let directory = await open("/", constants.O_RDONLY | constants.O_DIRECTORY);
  try {
    for (const part of parts.slice(0, -1)) {
      // Linux magic descriptor links provide openat-style directory confinement.
      const next = await open(`/proc/self/fd/${directory.fd}/${part}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
      await directory.close();
      directory = next;
    }
    return await open(`/proc/self/fd/${directory.fd}/${parts.at(-1)}`, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
  } finally { await directory.close(); }
}

View on GitHub (pinned to 01ad858492)

Solutions

  1. Normalize the path before calling: const canonical = path.resolve(workspaceRoot, relative) and verify it startsWith(workspaceRoot + path.sep) before passing it in.
  2. Strip '.' and '..' segments yourself (or reject the request with 400) when the path comes from an external client.
  3. If the intent is to open a parent-directory file, resolve to the concrete absolute child path instead of passing '..' segments.
  4. Only pass paths that were previously authorized/stored as canonical absolute paths, per the function's contract.

Example fix

// before
const handle = await openRunnerApiWorkspaceFile(`/workspace/uploads/${name}`);

// after
const canonical = path.resolve('/workspace/uploads', name);
if (!canonical.startsWith('/workspace/uploads/')) throw new Error('outside workspace');
const handle = await openRunnerApiWorkspaceFile(canonical);
Defensive patterns

Strategy: validation

Validate before calling

function isCanonicalWorkspacePath(p: string, root: string): boolean {
  if (!path.isAbsolute(p)) return false;
  const rel = path.relative(root, p);
  return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !p.split('/').includes('..');
}
if (!isCanonicalWorkspacePath(requestedPath, workspaceRoot)) throw new Error('path rejected');

Type guard

const isCanonical = (p: string): boolean =>
  path.isAbsolute(p) && p.split('/').filter(Boolean).every(s => s !== '.' && s !== '..');

Prevention

When it happens

Trigger: Calling openRunnerApiWorkspaceFile with a non-normalized absolute path such as '/workspace/uploads/../secret.txt', '/./file', a path ending in '/..', or the bare root '/'. Any caller-supplied path that has not been run through path.normalize/resolve before reaching the function.

Common situations: Joining user-supplied upload filenames with the workspace root without normalization; URL-decoded paths retaining '../'; legacy code building paths with '..' to mean 'parent directory'; a client sending a relative-looking path that was naively prefixed with '/' instead of resolved.

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@01ad858492 (2026-09-10). Data as JSON: /api/errors/62419e67829ee8b6. Report an issue: GitHub.