mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-29 16:54:30 +00:00
fix: distinguish outside-workspace errors from not-found in fs-safe
When editing a file outside the workspace root, SafeOpenError previously
used the "invalid-path" code with the message "path escapes root". This
was indistinguishable from other invalid-path errors (hardlinks, symlinks,
non-files) and consumers often fell back to a generic "not found" message,
which was misleading.
Add a new "outside-workspace" error code with the message "file is outside
workspace root" so consumers can surface a clear, accurate error message.
- fs-safe.ts: add "outside-workspace" to SafeOpenErrorCode, use it for
all path-escapes-root checks in openFileWithinRoot/writeFileWithinRoot
- pi-tools.read.ts: map "outside-workspace" to EACCES instead of rethrowing
- browser/paths.ts: return specific "File is outside {scopeLabel}" message
- media/server.ts: return 400 with descriptive message for outside-workspace
- fs-safe.test.ts: update traversal test expectations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user