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
This commit is contained in:
Alberto Leal
2026-02-16 03:37:19 -05:00
committed by Peter Steinberger
parent 4cf5c3e109
commit 0bb81f7294
2 changed files with 55 additions and 3 deletions

View File

@@ -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",

View File

@@ -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(