UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

463 lines (462 loc) 17.5 kB
import { c as normalizeOptionalLowercaseString } from "./string-coerce-CIXf7egm.js"; import { a as openRootFile, l as readFileDescriptorBoundedSync } from "./boundary-file-read-uaJcf6X6.js"; import { t as FsSafeError } from "./fs-safe-B6pvPGnf.js"; import { n as normalizeContainerPathCore, r as relativePathEscapesContainerRoot, t as isPathInsideContainerRoot } from "./path-utils-Drbu0ZHc.js"; import { D as buildPinnedRemovePlan, E as buildPinnedMkdirpPlan, O as buildPinnedRenamePlan, P as runDockerSandboxShellCommand, T as buildPinnedCreatePlan, b as parseSandboxStatSize, k as buildPinnedWritePlan, w as buildPinnedCopyPlan, y as parseSandboxStatMtimeMs } from "./ssh-backend-Bi2dY2r6.js"; import { i as resolveSandboxFsPathWithMounts, t as buildSandboxFsMounts } from "./fs-paths-a-ImteWc.js"; import fs from "node:fs"; import path from "node:path"; //#region src/agents/sandbox/fs-bridge-path-safety.ts /** * Host/container path safety guard for the sandbox filesystem bridge. * * Proves requested container paths stay inside allowed mounts before host paths are opened or mutated. */ function sandboxBoundaryError(action, containerPath, error) { if (error instanceof Error && !(error instanceof FsSafeError && error.code === "not-file")) return error; return new Error(`Sandbox boundary checks failed; cannot ${action}: ${containerPath}`, { cause: error }); } /** Validates sandbox fs bridge paths against mount, symlink, and writability boundaries. */ var SandboxFsPathGuard = class { constructor(params) { this.mountsByContainer = params.mountsByContainer; this.runCommand = params.runCommand; } async assertPathChecks(checks) { for (const check of checks) await this.assertPathSafety(check.target, check.options); } async assertPathSafety(target, options) { const allowedType = options.allowedType === "file-or-directory" ? this.pathIsExistingDirectory(target.hostPath) ? "directory" : "file" : options.allowedType; const guarded = await this.openBoundaryWithinRequiredMount(target, options.action, { aliasPolicy: options.aliasPolicy, allowedType }); await this.assertGuardedPathSafety(target, { ...options, allowedType }, guarded); } async openReadableFile(target) { const opened = await this.openBoundaryWithinRequiredMount(target, "read files"); if (!opened.ok) throw sandboxBoundaryError("read files", target.containerPath, opened.error); return opened; } resolveRequiredMount(containerPath, action) { const lexicalMount = this.resolveMountByContainerPath(containerPath); if (!lexicalMount) throw new Error(`Sandbox path escapes allowed mounts; cannot ${action}: ${containerPath}`); return lexicalMount; } finalizePinnedEntry(params) { const relativeParentPath = path.posix.relative(params.mount.containerRoot, params.parentPath); if (relativePathEscapesContainerRoot(relativeParentPath)) throw new Error(`Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.targetPath}`); return { mountRootPath: params.mount.containerRoot, relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, basename: params.basename }; } async assertGuardedPathSafety(target, options, guarded) { if (!guarded.ok) { if (guarded.reason !== "path") { if (!(guarded.reason === "io" && options.allowedType === "directory" && this.pathIsExistingDirectory(target.hostPath))) throw sandboxBoundaryError(options.action, target.containerPath, guarded.error); } } else fs.closeSync(guarded.fd); const canonicalContainerPath = await this.resolveCanonicalContainerPath({ containerPath: target.containerPath, allowFinalSymlinkForUnlink: options.aliasPolicy?.allowFinalSymlinkForUnlink === true }); const canonicalMount = this.resolveRequiredMount(canonicalContainerPath, options.action); if (options.requireWritable && (!canonicalMount.writable || options.requireWritable === "subtree" && this.mountsByContainer.some((mount) => !mount.writable && isPathInsideContainerRoot(canonicalContainerPath, mount.containerRoot)))) throw new Error(`Sandbox path is read-only; cannot ${options.action}: ${target.containerPath}`); } async openBoundaryWithinRequiredMount(target, action, options) { const lexicalMount = this.resolveRequiredMount(target.containerPath, action); return await openRootFile({ absolutePath: target.hostPath, rootPath: lexicalMount.hostRoot, boundaryLabel: "sandbox mount root", rejectSymlinks: false, aliasPolicy: options?.aliasPolicy, allowedType: options?.allowedType }); } resolvePinnedEntry(target, action) { const basename = path.posix.basename(target.containerPath); if (!basename || basename === "." || basename === "/") throw new Error(`Invalid sandbox entry target: ${target.containerPath}`); const parentPath = normalizeContainerPathCore(path.posix.dirname(target.containerPath)); const mount = this.resolveRequiredMount(parentPath, action); return this.finalizePinnedEntry({ mount, parentPath, basename, targetPath: target.containerPath, action }); } async resolveAnchoredSandboxEntry(target, action) { const basename = path.posix.basename(target.containerPath); if (!basename || basename === "." || basename === "/") throw new Error(`Invalid sandbox entry target: ${target.containerPath}`); const parentPath = normalizeContainerPathCore(path.posix.dirname(target.containerPath)); const canonicalParentPath = await this.resolveCanonicalContainerPath({ containerPath: parentPath, allowFinalSymlinkForUnlink: false }); this.resolveRequiredMount(canonicalParentPath, action); return { canonicalParentPath, basename }; } async resolveAnchoredPinnedEntry(target, action) { const anchoredTarget = await this.resolveAnchoredSandboxEntry(target, action); const mount = this.resolveRequiredMount(anchoredTarget.canonicalParentPath, action); return this.finalizePinnedEntry({ mount, parentPath: anchoredTarget.canonicalParentPath, basename: anchoredTarget.basename, targetPath: target.containerPath, action }); } resolvePinnedDirectoryEntry(target, action) { const mount = this.resolveRequiredMount(target.containerPath, action); const relativePath = path.posix.relative(mount.containerRoot, target.containerPath); if (relativePathEscapesContainerRoot(relativePath)) throw new Error(`Sandbox path escapes allowed mounts; cannot ${action}: ${target.containerPath}`); return { mountRootPath: mount.containerRoot, relativePath: relativePath === "." ? "" : relativePath }; } pathIsExistingDirectory(hostPath) { try { return fs.statSync(hostPath).isDirectory(); } catch { return false; } } resolveMountByContainerPath(containerPath) { const normalized = normalizeContainerPathCore(containerPath); for (const mount of this.mountsByContainer) if (isPathInsideContainerRoot(normalizeContainerPathCore(mount.containerRoot), normalized)) return mount; return null; } async resolveCanonicalContainerPath(params) { const script = [ "set -eu", "target=\"$1\"", "allow_final=\"$2\"", "suffix=\"\"", "probe=\"$target\"", "if [ \"$allow_final\" = \"1\" ] && [ -L \"$target\" ]; then probe=$(dirname -- \"$target\"); fi", "cursor=\"$probe\"", "while [ ! -e \"$cursor\" ] && [ ! -L \"$cursor\" ]; do", " parent=$(dirname -- \"$cursor\")", " if [ \"$parent\" = \"$cursor\" ]; then break; fi", " base=$(basename -- \"$cursor\")", " suffix=\"/$base$suffix\"", " cursor=\"$parent\"", "done", "canonical=$(readlink -f -- \"$cursor\")", "printf \"%s%s\\n\" \"$canonical\" \"$suffix\"" ].join("\n"); const canonical = (await this.runCommand(script, { args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"] })).stdout.toString("utf8").trim(); if (!canonical.startsWith("/")) throw new Error(`Failed to resolve canonical sandbox path: ${params.containerPath}`); return normalizeContainerPathCore(canonical); } }; //#endregion //#region src/agents/sandbox/fs-bridge-shell-command-plans.ts /** Builds a stat command that anchors the path at its canonical parent before reading metadata. */ function buildStatPlan(target, anchoredTarget) { return { checks: [{ target, options: { action: "stat files" } }], script: "set -eu\ncd -- \"$1\"\nLC_ALL=C stat -c \"%F|%s|%y\" -- \"$2\"", args: [anchoredTarget.canonicalParentPath, anchoredTarget.basename], allowFailure: true }; } //#endregion //#region src/agents/sandbox/fs-bridge.ts /** * Sandbox filesystem bridge implementation. * * Resolves container paths to mounted host paths and executes guarded reads, writes, stats, renames, and deletes. */ /** Create the filesystem bridge for local Docker-style mounted sandboxes. */ function createSandboxFsBridge(params) { return new SandboxFsBridgeImpl(params.sandbox); } var SandboxFsBridgeImpl = class { constructor(sandbox) { this.sandbox = sandbox; this.mounts = buildSandboxFsMounts(sandbox); const mountsByContainer = [...this.mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); this.pathGuard = new SandboxFsPathGuard({ mountsByContainer, runCommand: (script, options) => this.runCommand(script, options) }); } resolvePath(params) { const target = this.resolveResolvedPath(params); return { hostPath: target.hostPath, relativePath: target.relativePath, containerPath: target.containerPath }; } async readFile(params) { const target = this.resolveResolvedPath(params); return this.readPinnedFile(target, params.maxBytes); } async copyFile(params) { const source = this.resolveResolvedPath({ filePath: params.sourcePath, cwd: params.cwd }); const destination = this.resolveResolvedPath({ filePath: params.destinationPath, cwd: params.cwd }); this.ensureWriteAccess(destination, "copy files"); const sourceCheck = { target: source, options: { action: "copy files", allowedType: "file" } }; const destinationCheck = { target: destination, options: { action: "copy files", requireWritable: true } }; await this.runCheckedCommand({ ...buildPinnedCopyPlan({ sourceCheck, destinationCheck, source: await this.pathGuard.resolveAnchoredPinnedEntry(source, "copy files"), destination: await this.pathGuard.resolveAnchoredPinnedEntry(destination, "copy files"), mkdir: params.mkdir !== false }), signal: params.signal }); } async writeFile(params) { const target = this.resolveResolvedPath(params); this.ensureWriteAccess(target, "write files"); const writeCheck = { target, options: { action: "write files", requireWritable: true } }; await this.pathGuard.assertPathSafety(target, writeCheck.options); const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8"); const pinnedWriteTarget = await this.pathGuard.resolveAnchoredPinnedEntry(target, "write files"); await this.runCheckedCommand({ ...buildPinnedWritePlan({ check: writeCheck, pinned: pinnedWriteTarget, mkdir: params.mkdir !== false }), stdin: buffer, signal: params.signal }); } async createFileExclusive(params) { const target = this.resolveResolvedPath(params); this.ensureWriteAccess(target, "create files"); const createCheck = { target, options: { action: "create files", requireWritable: true } }; await this.pathGuard.assertPathSafety(target, createCheck.options); const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.from(params.data, params.encoding ?? "utf8"); const pinnedCreateTarget = await this.pathGuard.resolveAnchoredPinnedEntry(target, "create files"); const result = await this.runCheckedCommand({ ...buildPinnedCreatePlan({ check: createCheck, pinned: pinnedCreateTarget, mkdir: params.mkdir !== false }), allowFailure: true, stdin: buffer, signal: params.signal }); if (result.code === 17) return "exists"; if (result.code !== 0) throw new Error(`sandbox create failed for ${target.containerPath}: ${result.stderr.toString("utf8").trim()}`); return "created"; } async mkdirp(params) { const target = this.resolveResolvedPath(params); this.ensureWriteAccess(target, "create directories"); const mkdirCheck = { target, options: { action: "create directories", requireWritable: true, allowedType: "directory" } }; await this.runCheckedCommand({ ...buildPinnedMkdirpPlan({ check: mkdirCheck, pinned: this.pathGuard.resolvePinnedDirectoryEntry(target, "create directories") }), signal: params.signal }); } async remove(params) { const target = this.resolveResolvedPath(params); this.ensureWriteAccess(target, "remove files"); const removeCheck = { target, options: { action: "remove files", requireWritable: params.recursive ? "subtree" : true, allowedType: "file-or-directory" } }; await this.runCheckedCommand({ ...buildPinnedRemovePlan({ check: removeCheck, pinned: this.pathGuard.resolvePinnedEntry(target, "remove files"), recursive: params.recursive, force: params.force }), signal: params.signal }); } async rename(params) { const from = this.resolveResolvedPath({ filePath: params.from, cwd: params.cwd }); const to = this.resolveResolvedPath({ filePath: params.to, cwd: params.cwd }); this.ensureWriteAccess(from, "rename files"); this.ensureWriteAccess(to, "rename files"); const fromCheck = { target: from, options: { action: "rename files", requireWritable: "subtree", allowedType: "file-or-directory" } }; const toCheck = { target: to, options: { action: "rename files", requireWritable: "subtree", allowedType: "file-or-directory" } }; await this.runCheckedCommand({ ...buildPinnedRenamePlan({ fromCheck, toCheck, from: this.pathGuard.resolvePinnedEntry(from, "rename files"), to: this.pathGuard.resolvePinnedEntry(to, "rename files") }), signal: params.signal }); } async stat(params) { const target = this.resolveResolvedPath(params); const anchoredTarget = await this.pathGuard.resolveAnchoredSandboxEntry(target, "stat files"); const result = await this.runPlannedCommand(buildStatPlan(target, anchoredTarget), params.signal); if (result.code !== 0) { const stderr = result.stderr.toString("utf8"); if (stderr.includes("No such file or directory")) return null; const message = stderr.trim() || `stat failed with code ${result.code}`; throw new Error(`stat failed for ${target.containerPath}: ${message}`); } const [typeRaw, sizeRaw, mtimeRaw] = result.stdout.toString("utf8").trim().split("|"); return { type: coerceStatType(typeRaw), size: parseSandboxStatSize(sizeRaw), mtimeMs: parseSandboxStatMtimeMs(mtimeRaw) }; } async runCommand(script, options = {}) { const backend = this.sandbox.backend; if (backend) return await backend.runShellCommand({ script, args: options.args, stdin: options.stdin, allowFailure: options.allowFailure, signal: options.signal }); return await runDockerSandboxShellCommand({ containerName: this.sandbox.containerName, script, args: options.args, stdin: options.stdin, allowFailure: options.allowFailure, signal: options.signal }); } async readPinnedFile(target, maxBytes) { const opened = await this.pathGuard.openReadableFile(target); try { if (maxBytes === void 0) return fs.readFileSync(opened.fd); if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("maxBytes must be a non-negative safe integer"); const initialStat = fs.fstatSync(opened.fd); if (!initialStat.isFile()) throw new Error(`Sandbox read requires a regular file: ${target.containerPath}`); if (initialStat.size > maxBytes) throw new RangeError(`File exceeds ${maxBytes} bytes`); const data = readFileDescriptorBoundedSync(opened.fd, maxBytes); const finalStat = fs.fstatSync(opened.fd); if (!finalStat.isFile() || finalStat.size > maxBytes) throw new RangeError(`File exceeds ${maxBytes} bytes`); return data; } finally { fs.closeSync(opened.fd); } } async runCheckedCommand(plan) { await this.pathGuard.assertPathChecks(plan.checks); if (plan.recheckBeforeCommand) await this.pathGuard.assertPathChecks(plan.checks); return await this.runCommand(plan.script, { args: plan.args, stdin: plan.stdin, allowFailure: plan.allowFailure, signal: plan.signal }); } async runPlannedCommand(plan, signal) { return await this.runCheckedCommand({ ...plan, signal }); } ensureWriteAccess(target, action) { if (this.sandbox.workspaceAccess === "ro" || !target.writable) throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); } resolveResolvedPath(params) { return resolveSandboxFsPathWithMounts({ filePath: params.filePath, cwd: params.cwd ?? this.sandbox.workspaceDir, defaultWorkspaceRoot: this.sandbox.workspaceDir, defaultContainerRoot: this.sandbox.containerWorkdir, mounts: this.mounts }); } }; function coerceStatType(typeRaw) { if (!typeRaw) return "other"; const normalized = normalizeOptionalLowercaseString(typeRaw) ?? ""; if (normalized.includes("directory")) return "directory"; if (normalized.includes("file")) return "file"; return "other"; } //#endregion export { createSandboxFsBridge as t };