fix: execute sandboxed file ops inside containers (#4026)

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

Prepared head SHA: 795ec6aa2f
Co-authored-by: davidbors-snyk <240482518+davidbors-snyk@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
davidbors-snyk
2026-02-13 17:29:10 +02:00
committed by GitHub
parent 1def8c5448
commit 29d7839582
20 changed files with 862 additions and 152 deletions

View File

@@ -798,7 +798,10 @@ export async function runEmbeddedAttempt(
historyMessages: activeSession.messages,
maxBytes: MAX_IMAGE_BYTES,
// Enforce sandbox path restrictions when sandbox is enabled
sandboxRoot: sandbox?.enabled ? sandbox.workspaceDir : undefined,
sandbox:
sandbox?.enabled && sandbox?.fsBridge
? { root: sandbox.workspaceDir, bridge: sandbox.fsBridge }
: undefined,
});
// Inject history images into their original message positions.

View File

@@ -1,11 +1,7 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
import { resolveUserPath } from "../../../utils.js";
import { loadWebMedia } from "../../../web/media.js";
import { assertSandboxPath } from "../../sandbox-paths.js";
import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js";
import { sanitizeImageBlocks } from "../../tool-images.js";
import { log } from "../logger.js";
@@ -177,8 +173,7 @@ export async function loadImageFromRef(
workspaceDir: string,
options?: {
maxBytes?: number;
/** If set, enforce that file paths are within this sandbox root */
sandboxRoot?: string;
sandbox?: { root: string; bridge: SandboxFsBridge };
},
): Promise<ImageContent | null> {
try {
@@ -190,46 +185,34 @@ export async function loadImageFromRef(
return null;
}
// For file paths, resolve relative to the appropriate root:
// - When sandbox is enabled, resolve relative to sandboxRoot for security
// - Otherwise, resolve relative to workspaceDir
// Note: ref.resolved may already be absolute (e.g., after ~ expansion in detectImageReferences),
// in which case we skip relative resolution.
if (ref.type === "path" && !path.isAbsolute(targetPath)) {
const resolveRoot = options?.sandboxRoot ?? workspaceDir;
targetPath = path.resolve(resolveRoot, targetPath);
}
// Enforce sandbox restrictions if sandboxRoot is set
if (ref.type === "path" && options?.sandboxRoot) {
try {
const validated = await assertSandboxPath({
filePath: targetPath,
cwd: options.sandboxRoot,
root: options.sandboxRoot,
});
targetPath = validated.resolved;
} catch (err) {
// Log the actual error for debugging (sandbox violation or other path error)
log.debug(
`Native image: sandbox validation failed for ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
}
// Check file exists for local paths
// Resolve paths relative to sandbox or workspace as needed
if (ref.type === "path") {
try {
await fs.stat(targetPath);
} catch {
log.debug(`Native image: file not found: ${targetPath}`);
return null;
if (options?.sandbox) {
try {
const resolved = options.sandbox.bridge.resolvePath({
filePath: targetPath,
cwd: options.sandbox.root,
});
targetPath = resolved.hostPath;
} catch (err) {
log.debug(
`Native image: sandbox validation failed for ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
} else if (!path.isAbsolute(targetPath)) {
targetPath = path.resolve(workspaceDir, targetPath);
}
}
// loadWebMedia handles local file paths (including file:// URLs)
const media = await loadWebMedia(targetPath, options?.maxBytes);
const media = options?.sandbox
? await loadWebMedia(targetPath, {
maxBytes: options.maxBytes,
readFile: (filePath) =>
options.sandbox!.bridge.readFile({ filePath, cwd: options.sandbox!.root }),
})
: await loadWebMedia(targetPath, options?.maxBytes);
if (media.kind !== "image") {
log.debug(`Native image: not an image file: ${targetPath} (got ${media.kind})`);
@@ -344,8 +327,7 @@ export async function detectAndLoadPromptImages(params: {
existingImages?: ImageContent[];
historyMessages?: unknown[];
maxBytes?: number;
/** If set, enforce that file paths are within this sandbox root */
sandboxRoot?: string;
sandbox?: { root: string; bridge: SandboxFsBridge };
}): Promise<{
/** Images for the current prompt (existingImages + detected in current prompt) */
images: ImageContent[];
@@ -406,7 +388,7 @@ export async function detectAndLoadPromptImages(params: {
for (const ref of allRefs) {
const image = await loadImageFromRef(ref, params.workspaceDir, {
maxBytes: params.maxBytes,
sandboxRoot: params.sandboxRoot,
sandbox: params.sandbox,
});
if (image) {
if (ref.messageIndex !== undefined) {