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
- Normalize the path before calling: const canonical = path.resolve(workspaceRoot, relative) and verify it startsWith(workspaceRoot + path.sep) before passing it in.
- Strip '.' and '..' segments yourself (or reject the request with 400) when the path comes from an external client.
- If the intent is to open a parent-directory file, resolve to the concrete absolute child path instead of passing '..' segments.
- 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
- Always build workspace paths with path.resolve from the workspace root, never string concatenation.
- Reject (HTTP 400) client-supplied paths containing '.' or '..' segments before they reach the file layer.
- Store and reuse the canonical path returned at authorization time instead of reconstructing it.
- Add a unit test asserting traversal attempts like '/ws/a/../b' are rejected.
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
- sandbox runtime asset key is not a simple path segment: ${ke
- Trusted viewer must not use symlinks
- Unexpected trusted viewer asset
- Unsafe protocol eval campaign ID
- ${label} escapes its declared root
AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10).
Data as JSON: /api/errors/62419e67829ee8b6.
Report an issue: GitHub.