diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index 20b5938ff..67408536d 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -18,6 +18,11 @@ async function expectSandboxRejection(media: string, sandboxRoot: string, patter await expect(resolveSandboxedMediaSource({ media, sandboxRoot })).rejects.toThrow(pattern); } +function isPathInside(root: string, target: string): boolean { + const relative = path.relative(path.resolve(root), path.resolve(target)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + describe("resolveSandboxedMediaSource", () => { // Group 1: /tmp paths (the bug fix) it.each([ @@ -94,9 +99,15 @@ describe("resolveSandboxedMediaSource", () => { if (process.platform === "win32") { return; } + const outsideTmpTarget = path.resolve(process.cwd(), "package.json"); + if (isPathInside(os.tmpdir(), outsideTmpTarget)) { + return; + } + await withSandboxRoot(async (sandboxDir) => { + await fs.access(outsideTmpTarget); const symlinkPath = path.join(sandboxDir, "tmp-link-escape"); - await fs.symlink("/etc/passwd", symlinkPath); + await fs.symlink(outsideTmpTarget, symlinkPath); await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i); }); }); diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index f18b81824..31a9653e6 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -90,12 +90,12 @@ export async function resolveSandboxedMediaSource(params: { throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`); } } - const resolved = path.resolve(resolveSandboxInputPath(candidate, params.sandboxRoot)); - const tmpDir = path.resolve(os.tmpdir()); - const candidateIsAbsolute = path.isAbsolute(expandPath(candidate)); - if (candidateIsAbsolute && isPathInside(tmpDir, resolved)) { - await assertNoSymlinkEscape(path.relative(tmpDir, resolved), tmpDir); - return resolved; + const tmpMediaPath = await resolveAllowedTmpMediaPath({ + candidate, + sandboxRoot: params.sandboxRoot, + }); + if (tmpMediaPath) { + return tmpMediaPath; } const sandboxResult = await assertSandboxPath({ filePath: candidate, @@ -105,6 +105,23 @@ export async function resolveSandboxedMediaSource(params: { return sandboxResult.resolved; } +async function resolveAllowedTmpMediaPath(params: { + candidate: string; + sandboxRoot: string; +}): Promise { + const candidateIsAbsolute = path.isAbsolute(expandPath(params.candidate)); + if (!candidateIsAbsolute) { + return undefined; + } + const resolved = path.resolve(resolveSandboxInputPath(params.candidate, params.sandboxRoot)); + const tmpDir = path.resolve(os.tmpdir()); + if (!isPathInside(tmpDir, resolved)) { + return undefined; + } + await assertNoSymlinkEscape(path.relative(tmpDir, resolved), tmpDir); + return resolved; +} + async function assertNoSymlinkEscape( relative: string, root: string,