diff --git a/src/infra/fs-safe.test.ts b/src/infra/fs-safe.test.ts index eaf1edbdd6b..bed764642eb 100644 --- a/src/infra/fs-safe.test.ts +++ b/src/infra/fs-safe.test.ts @@ -73,6 +73,22 @@ describe("fs-safe", () => { ).rejects.toMatchObject({ code: "outside-workspace" }); }); + it("rejects directory path within root without leaking EISDIR (issue #31186)", async () => { + const root = await tempDirs.make("openclaw-fs-safe-root-"); + await fs.mkdir(path.join(root, "memory"), { recursive: true }); + + await expect( + openFileWithinRoot({ rootDir: root, relativePath: "memory" }), + ).rejects.toMatchObject({ code: expect.stringMatching(/invalid-path|not-file/) }); + + const err = await openFileWithinRoot({ + rootDir: root, + relativePath: "memory", + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(SafeOpenError); + expect((err as SafeOpenError).message).not.toMatch(/EISDIR/i); + }); + it("reads a file within root", async () => { const root = await tempDirs.make("openclaw-fs-safe-root-"); await fs.writeFile(path.join(root, "inside.txt"), "inside"); diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index b5e9a375b30..6d0ee7c7660 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -7,7 +7,12 @@ import path from "node:path"; import { sameFileIdentity } from "./file-identity.js"; import { expandHomePrefix } from "./home-dir.js"; import { assertNoPathAliasEscape } from "./path-alias-guards.js"; -import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js"; +import { + hasNodeErrorCode, + isNotFoundPathError, + isPathInside, + isSymlinkOpenError, +} from "./path-guards.js"; export type SafeOpenErrorCode = | "invalid-path" @@ -68,6 +73,20 @@ async function openVerifiedLocalFile( rejectHardlinks?: boolean; }, ): Promise { + // Reject directories before opening so we never surface EISDIR to callers (e.g. tool + // results that get sent to messaging channels). See openclaw/openclaw#31186. + try { + const preStat = await fs.lstat(filePath); + if (preStat.isDirectory()) { + throw new SafeOpenError("not-file", "not a file"); + } + } catch (err) { + if (err instanceof SafeOpenError) { + throw err; + } + // ENOENT and other lstat errors: fall through and let fs.open handle. + } + let handle: FileHandle; try { handle = await fs.open(filePath, OPEN_READ_FLAGS); @@ -78,6 +97,10 @@ async function openVerifiedLocalFile( if (isSymlinkOpenError(err)) { throw new SafeOpenError("symlink", "symlink open blocked", { cause: err }); } + // Defensive: if open still throws EISDIR (e.g. race), sanitize so it never leaks. + if (hasNodeErrorCode(err, "EISDIR")) { + throw new SafeOpenError("not-file", "not a file"); + } throw err; }