refactor(media): harden localRoots bypass (#16739)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 89dce69f50
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
Peter Steinberger
2026-02-15 03:27:01 +01:00
committed by GitHub
parent b607c41a52
commit 683aa09b55
9 changed files with 73 additions and 25 deletions

View File

@@ -19,6 +19,7 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createTtsTool } from "./tools/tts-tool.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
import { resolveWorkspaceRoot } from "./workspace-dir.js";
export function createOpenClawTools(options?: {
sandboxBrowserBridgeUrl?: string;
@@ -60,7 +61,7 @@ export function createOpenClawTools(options?: {
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
}): AnyAgentTool[] {
const workspaceDir = options?.workspaceDir?.trim() || process.cwd();
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
const imageTool = options?.agentDir?.trim()
? createImageTool({
config: options?.config,

View File

@@ -211,7 +211,7 @@ export async function loadImageFromRef(
const media = options?.sandbox
? await loadWebMedia(targetPath, {
maxBytes: options.maxBytes,
localRoots: "any",
sandboxValidated: true,
readFile: (filePath) =>
options.sandbox!.bridge.readFile({ filePath, cwd: options.sandbox!.root }),
})

View File

@@ -53,6 +53,7 @@ import {
collectExplicitAllowlist,
resolveToolProfilePolicy,
} from "./tool-policy.js";
import { resolveWorkspaceRoot } from "./workspace-dir.js";
function isOpenAIProvider(provider?: string) {
const normalized = provider?.trim().toLowerCase();
@@ -253,7 +254,7 @@ export function createOpenClawCodingTools(options?: {
const sandboxRoot = sandbox?.workspaceDir;
const sandboxFsBridge = sandbox?.fsBridge;
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
const workspaceRoot = options?.workspaceDir ?? process.cwd();
const workspaceRoot = resolveWorkspaceRoot(options?.workspaceDir);
const workspaceOnly = fsConfig.workspaceOnly === true;
const applyPatchConfig = execConfig.applyPatch;
// Secure by default: apply_patch is workspace-contained unless explicitly disabled.

View File

@@ -14,6 +14,7 @@ import { runWithImageModelFallback } from "../model-fallback.js";
import { resolveConfiguredModelRef } from "../model-selection.js";
import { ensureOpenClawModelsJson } from "../models-config.js";
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
import { normalizeWorkspaceDir } from "../workspace-dir.js";
import {
coerceImageAssistantText,
coerceImageModelConfig,
@@ -354,20 +355,11 @@ export function createImageTool(options?: {
const localRoots = (() => {
const roots = getDefaultLocalRoots();
const workspaceDir = options?.workspaceDir?.trim();
const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir);
if (!workspaceDir) {
return roots;
}
const expanded = workspaceDir.startsWith("~") ? resolveUserPath(workspaceDir) : workspaceDir;
const resolved = path.resolve(expanded);
// Defensive: never allow "/" as an implicit media root.
if (resolved === path.parse(resolved).root) {
return roots;
}
if (!roots.includes(resolved)) {
roots.push(resolved);
}
return roots;
return Array.from(new Set([...roots, workspaceDir]));
})();
return {
@@ -460,7 +452,7 @@ export function createImageTool(options?: {
: sandboxConfig
? await loadWebMedia(resolvedPath ?? resolvedImage, {
maxBytes,
localRoots: "any",
sandboxValidated: true,
readFile: (filePath) =>
sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }),
})

View File

@@ -0,0 +1,20 @@
import path from "node:path";
import { resolveUserPath } from "../utils.js";
export function normalizeWorkspaceDir(workspaceDir?: string): string | null {
const trimmed = workspaceDir?.trim();
if (!trimmed) {
return null;
}
const expanded = trimmed.startsWith("~") ? resolveUserPath(trimmed) : trimmed;
const resolved = path.resolve(expanded);
// Refuse filesystem roots as "workspace" (too broad; almost always a bug).
if (resolved === path.parse(resolved).root) {
return null;
}
return resolved;
}
export function resolveWorkspaceRoot(workspaceDir?: string): string {
return normalizeWorkspaceDir(workspaceDir) ?? process.cwd();
}