From 0bb81f7294f464d04df1be14f404fd1530cc3cba Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Mon, 16 Feb 2026 03:37:19 -0500 Subject: [PATCH] fix(media): allow os.tmpdir() paths in sandbox media source validation resolveSandboxedMediaSource() rejected all paths outside the sandbox workspace root, including /tmp. This blocked sandboxed agents from sending locally-generated temp files (e.g. images from Python scripts) via messaging actions. Add an os.tmpdir() prefix check before the strict sandbox containment assertion, consistent with buildMediaLocalRoots() which already includes os.tmpdir() in its default allowlist. Path traversal through /tmp (e.g. /tmp/../etc/passwd) is prevented by path.resolve() normalization before the prefix check. Relates-to: #16382, #14174 --- src/agents/sandbox-paths.test.ts | 48 +++++++++++++++++++++++++++++++- src/agents/sandbox-paths.ts | 10 +++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index 328366860..0969c8550 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -1,10 +1,54 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { resolveSandboxedMediaSource } from "./sandbox-paths.js"; describe("resolveSandboxedMediaSource", () => { + // Group 1: /tmp paths (the bug fix) + it("allows absolute paths under os.tmpdir()", async () => { + const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "sandbox-media-")); + try { + const result = await resolveSandboxedMediaSource({ + media: path.join(os.tmpdir(), "image.png"), + sandboxRoot: sandboxDir, + }); + expect(result).toBe(path.join(os.tmpdir(), "image.png")); + } finally { + await fs.rm(sandboxDir, { recursive: true, force: true }); + } + }); + + it("allows file:// URLs pointing to os.tmpdir()", async () => { + const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "sandbox-media-")); + try { + const tmpFile = path.join(os.tmpdir(), "photo.png"); + const fileUrl = pathToFileURL(tmpFile).href; + const result = await resolveSandboxedMediaSource({ + media: fileUrl, + sandboxRoot: sandboxDir, + }); + expect(result).toBe(tmpFile); + } finally { + await fs.rm(sandboxDir, { recursive: true, force: true }); + } + }); + + it("allows nested paths under os.tmpdir()", async () => { + const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "sandbox-media-")); + try { + const result = await resolveSandboxedMediaSource({ + media: path.join(os.tmpdir(), "subdir", "deep", "file.png"), + sandboxRoot: sandboxDir, + }); + expect(result).toBe(path.join(os.tmpdir(), "subdir", "deep", "file.png")); + } finally { + await fs.rm(sandboxDir, { recursive: true, force: true }); + } + }); + + // Group 2: Sandbox-relative paths (existing behavior) it("resolves sandbox-relative paths", async () => { const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "sandbox-media-")); try { @@ -18,7 +62,8 @@ describe("resolveSandboxedMediaSource", () => { } }); - it("rejects paths outside sandbox root", async () => { + // Group 3: Rejections (security) + it("rejects paths outside sandbox root and tmpdir", async () => { const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "sandbox-media-")); try { await expect( @@ -71,6 +116,7 @@ describe("resolveSandboxedMediaSource", () => { } }); + // Group 4: Passthrough it("passes HTTP URLs through unchanged", async () => { const result = await resolveSandboxedMediaSource({ media: "https://example.com/image.png", diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index c5547291c..8dbe822d3 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -90,12 +90,18 @@ export async function resolveSandboxedMediaSource(params: { throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`); } } - const resolved = await assertSandboxPath({ + // Allow files under os.tmpdir() — consistent with buildMediaLocalRoots() defaults. + const resolved = path.resolve(params.sandboxRoot, candidate); + const tmpDir = os.tmpdir(); + if (resolved === tmpDir || resolved.startsWith(tmpDir + path.sep)) { + return resolved; + } + const sandboxResult = await assertSandboxPath({ filePath: candidate, cwd: params.sandboxRoot, root: params.sandboxRoot, }); - return resolved.resolved; + return sandboxResult.resolved; } async function assertNoSymlinkEscape(