Files
openclaw/src/agents/sandbox-paths.ts
2026-03-23 00:29:46 -07:00

222 lines
6.4 KiB
TypeScript

import os from "node:os";
import path from "node:path";
import { URL } from "node:url";
import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js";
import { assertNoPathAliasEscape, type PathAliasPolicy } from "../infra/path-alias-guards.js";
import { isPathInside } from "../infra/path-guards.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.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 normalizeAtPrefix(filePath: string): string {
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
}
function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(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;
allowFinalSymlinkForUnlink?: boolean;
allowFinalHardlinkForUnlink?: boolean;
}) {
const resolved = resolveSandboxPath(params);
const policy: PathAliasPolicy = {
allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink,
allowFinalHardlinkForUnlink: params.allowFinalHardlinkForUnlink,
};
await assertNoPathAliasEscape({
absolutePath: resolved.resolved,
rootPath: params.root,
boundaryLabel: "sandbox root",
policy,
});
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 = safeFileURLToPath(candidate);
} catch (err) {
throw new Error(`Invalid file:// URL for sandboxed media: ${(err as Error).message}`, {
cause: err,
});
}
}
}
const containerWorkspaceMapped = mapContainerWorkspacePath({
candidate,
sandboxRoot: params.sandboxRoot,
});
if (containerWorkspaceMapped) {
candidate = containerWorkspaceMapped;
}
assertNoWindowsNetworkPath(candidate, "Sandbox media path");
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 openClawTmpDir = path.resolve(resolvePreferredOpenClawTmpDir());
if (!isPathInside(openClawTmpDir, resolved)) {
return undefined;
}
await assertNoTmpAliasEscape({ filePath: resolved, tmpRoot: openClawTmpDir });
return resolved;
}
async function assertNoTmpAliasEscape(params: {
filePath: string;
tmpRoot: string;
}): Promise<void> {
await assertNoPathAliasEscape({
absolutePath: params.filePath,
rootPath: params.tmpRoot,
boundaryLabel: "tmp root",
});
}
function shortPath(value: string) {
if (value.startsWith(os.homedir())) {
return `~${value.slice(os.homedir().length)}`;
}
return value;
}