diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 70c264ce123..226a9f60eb7 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -854,6 +854,9 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool if (error instanceof SafeOpenError && error.code === "not-found") { throw createFsAccessError("ENOENT", absolutePath); } + if (error instanceof SafeOpenError && error.code === "outside-workspace") { + throw createFsAccessError("EACCES", absolutePath); + } throw error; } }, diff --git a/src/browser/paths.ts b/src/browser/paths.ts index e171f40c732..422585695e5 100644 --- a/src/browser/paths.ts +++ b/src/browser/paths.ts @@ -235,6 +235,12 @@ async function resolveCheckedPathsWithinRoot(params: { resolvedPaths.push(pathResult.fallbackPath); continue; } + if (err instanceof SafeOpenError && err.code === "outside-workspace") { + return { + ok: false, + error: `File is outside ${params.scopeLabel}`, + }; + } return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel} and be a regular non-symlink file`, diff --git a/src/infra/fs-safe.test.ts b/src/infra/fs-safe.test.ts index cb2399c616b..c8302e5e0e4 100644 --- a/src/infra/fs-safe.test.ts +++ b/src/infra/fs-safe.test.ts @@ -67,7 +67,7 @@ describe("fs-safe", () => { rootDir: root, relativePath: path.join("..", path.basename(outside), "outside.txt"), }), - ).rejects.toMatchObject({ code: "invalid-path" }); + ).rejects.toMatchObject({ code: "outside-workspace" }); }); it.runIf(process.platform !== "win32")("blocks symlink escapes under root", async () => { @@ -131,7 +131,7 @@ describe("fs-safe", () => { relativePath: "../escape.txt", data: "x", }), - ).rejects.toMatchObject({ code: "invalid-path" }); + ).rejects.toMatchObject({ code: "outside-workspace" }); }); it.runIf(process.platform !== "win32")("rejects writing through hardlink aliases", async () => { diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 4ac06f937fd..e986980f8ac 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -10,6 +10,7 @@ import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-gu export type SafeOpenErrorCode = | "invalid-path" | "not-found" + | "outside-workspace" | "symlink" | "not-file" | "path-mismatch" @@ -120,7 +121,7 @@ export async function openFileWithinRoot(params: { const rootWithSep = ensureTrailingSep(rootReal); const resolved = path.resolve(rootWithSep, params.relativePath); if (!isPathInside(rootWithSep, resolved)) { - throw new SafeOpenError("invalid-path", "path escapes root"); + throw new SafeOpenError("outside-workspace", "file is outside workspace root"); } let opened: SafeOpenResult; @@ -145,7 +146,7 @@ export async function openFileWithinRoot(params: { if (!isPathInside(rootWithSep, opened.realPath)) { await opened.handle.close().catch(() => {}); - throw new SafeOpenError("invalid-path", "path escapes root"); + throw new SafeOpenError("outside-workspace", "file is outside workspace root"); } return opened; @@ -189,7 +190,7 @@ export async function writeFileWithinRoot(params: { const rootWithSep = ensureTrailingSep(rootReal); const resolved = path.resolve(rootWithSep, params.relativePath); if (!isPathInside(rootWithSep, resolved)) { - throw new SafeOpenError("invalid-path", "path escapes root"); + throw new SafeOpenError("outside-workspace", "file is outside workspace root"); } try { await assertNoPathAliasEscape({ @@ -208,7 +209,7 @@ export async function writeFileWithinRoot(params: { try { const resolvedRealPath = await fs.realpath(resolved); if (!isPathInside(rootWithSep, resolvedRealPath)) { - throw new SafeOpenError("invalid-path", "path escapes root"); + throw new SafeOpenError("outside-workspace", "file is outside workspace root"); } ioPath = resolvedRealPath; } catch (err) { @@ -254,7 +255,7 @@ export async function writeFileWithinRoot(params: { throw new SafeOpenError("invalid-path", "hardlinked path not allowed"); } if (!isPathInside(rootWithSep, realPath)) { - throw new SafeOpenError("invalid-path", "path escapes root"); + throw new SafeOpenError("outside-workspace", "file is outside workspace root"); } if (typeof params.data === "string") { diff --git a/src/media/server.ts b/src/media/server.ts index 58c6e10b7c0..8f3cc819893 100644 --- a/src/media/server.ts +++ b/src/media/server.ts @@ -75,6 +75,10 @@ export function attachMediaRoutes( }); } catch (err) { if (err instanceof SafeOpenError) { + if (err.code === "outside-workspace") { + res.status(400).send("file is outside workspace root"); + return; + } if (err.code === "invalid-path") { res.status(400).send("invalid path"); return;