openclaw/src/agents/sandbox-paths.ts

242 lines
6.8 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, URL } from "node:url";
import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const HTTP_URL_RE = /^https?:\/\//i;
const DATA_URL_RE = /^data:/i;
const SANDBOX_CONTAINER_WORKDIR = "/workspace";
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
}
function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(filePath);
if (normalized === "~") {
return os.homedir();
}
if (normalized.startsWith("~/")) {
return os.homedir() + normalized.slice(1);
}
return normalized;
}
function resolveToCwd(filePath: string, cwd: string): string {
const expanded = expandPath(filePath);
if (path.isAbsolute(expanded)) {
return expanded;
}
return path.resolve(cwd, expanded);
}
export function resolveSandboxInputPath(filePath: string, cwd: string): string {
return resolveToCwd(filePath, cwd);
}
export function resolveSandboxPath(params: { filePath: string; cwd: string; root: string }): {
resolved: string;
relative: string;
} {
const resolved = resolveSandboxInputPath(params.filePath, params.cwd);
const rootResolved = path.resolve(params.root);
const relative = path.relative(rootResolved, resolved);
if (!relative || relative === "") {
return { resolved, relative: "" };
}
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Path escapes sandbox root (${shortPath(rootResolved)}): ${params.filePath}`);
}
return { resolved, relative };
}
export async function assertSandboxPath(params: {
filePath: string;
cwd: string;
root: string;
allowFinalSymlink?: boolean;
}) {
const resolved = resolveSandboxPath(params);
await assertNoSymlinkEscape(resolved.relative, path.resolve(params.root), {
allowFinalSymlink: params.allowFinalSymlink,
});
return resolved;
}
export function assertMediaNotDataUrl(media: string): void {
const raw = media.trim();
if (DATA_URL_RE.test(raw)) {
throw new Error("data: URLs are not supported for media. Use buffer instead.");
}
}
export async function resolveSandboxedMediaSource(params: {
media: string;
sandboxRoot: string;
}): Promise<string> {
const raw = params.media.trim();
if (!raw) {
return raw;
}
if (HTTP_URL_RE.test(raw)) {
return raw;
}
let candidate = raw;
if (/^file:\/\//i.test(candidate)) {
const workspaceMappedFromUrl = mapContainerWorkspaceFileUrl({
fileUrl: candidate,
sandboxRoot: params.sandboxRoot,
});
if (workspaceMappedFromUrl) {
candidate = workspaceMappedFromUrl;
} else {
try {
candidate = fileURLToPath(candidate);
} catch {
throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`);
}
}
}
const containerWorkspaceMapped = mapContainerWorkspacePath({
candidate,
sandboxRoot: params.sandboxRoot,
});
if (containerWorkspaceMapped) {
candidate = containerWorkspaceMapped;
}
const tmpMediaPath = await resolveAllowedTmpMediaPath({
candidate,
sandboxRoot: params.sandboxRoot,
});
if (tmpMediaPath) {
return tmpMediaPath;
}
const sandboxResult = await assertSandboxPath({
filePath: candidate,
cwd: params.sandboxRoot,
root: params.sandboxRoot,
});
return sandboxResult.resolved;
}
function mapContainerWorkspaceFileUrl(params: {
fileUrl: string;
sandboxRoot: string;
}): string | undefined {
let parsed: URL;
try {
parsed = new URL(params.fileUrl);
} catch {
return undefined;
}
if (parsed.protocol !== "file:") {
return undefined;
}
// Sandbox paths are Linux-style (/workspace/*). Parse the URL path directly so
// Windows hosts can still accept file:///workspace/... media references.
const normalizedPathname = decodeURIComponent(parsed.pathname).replace(/\\/g, "/");
if (
normalizedPathname !== SANDBOX_CONTAINER_WORKDIR &&
!normalizedPathname.startsWith(`${SANDBOX_CONTAINER_WORKDIR}/`)
) {
return undefined;
}
return mapContainerWorkspacePath({
candidate: normalizedPathname,
sandboxRoot: params.sandboxRoot,
});
}
function mapContainerWorkspacePath(params: {
candidate: string;
sandboxRoot: string;
}): string | undefined {
const normalized = params.candidate.replace(/\\/g, "/");
if (normalized === SANDBOX_CONTAINER_WORKDIR) {
return path.resolve(params.sandboxRoot);
}
const prefix = `${SANDBOX_CONTAINER_WORKDIR}/`;
if (!normalized.startsWith(prefix)) {
return undefined;
}
const rel = normalized.slice(prefix.length);
if (!rel) {
return path.resolve(params.sandboxRoot);
}
return path.resolve(params.sandboxRoot, ...rel.split("/").filter(Boolean));
}
async function resolveAllowedTmpMediaPath(params: {
candidate: string;
sandboxRoot: string;
}): Promise<string | undefined> {
const candidateIsAbsolute = path.isAbsolute(expandPath(params.candidate));
if (!candidateIsAbsolute) {
return undefined;
}
const resolved = path.resolve(resolveSandboxInputPath(params.candidate, params.sandboxRoot));
const tmpDir = path.resolve(os.tmpdir());
if (!isPathInside(tmpDir, resolved)) {
return undefined;
}
await assertNoSymlinkEscape(path.relative(tmpDir, resolved), tmpDir);
return resolved;
}
async function assertNoSymlinkEscape(
relative: string,
root: string,
options?: { allowFinalSymlink?: boolean },
) {
if (!relative) {
return;
}
const rootReal = await tryRealpath(root);
const parts = relative.split(path.sep).filter(Boolean);
let current = root;
for (let idx = 0; idx < parts.length; idx += 1) {
const part = parts[idx];
const isLast = idx === parts.length - 1;
current = path.join(current, part);
try {
const stat = await fs.lstat(current);
if (stat.isSymbolicLink()) {
// Unlinking a symlink itself is safe even if it points outside the root. What we
// must prevent is traversing through a symlink to reach targets outside root.
if (options?.allowFinalSymlink && isLast) {
return;
}
const target = await tryRealpath(current);
if (!isPathInside(rootReal, target)) {
throw new Error(
`Symlink escapes sandbox root (${shortPath(rootReal)}): ${shortPath(current)}`,
);
}
current = target;
}
} catch (err) {
if (isNotFoundPathError(err)) {
return;
}
throw err;
}
}
}
async function tryRealpath(value: string): Promise<string> {
try {
return await fs.realpath(value);
} catch {
return path.resolve(value);
}
}
function shortPath(value: string) {
if (value.startsWith(os.homedir())) {
return `~${value.slice(os.homedir().length)}`;
}
return value;
}