fix(agents): tighten workspace file opens (#66636)

* fix(agents): tighten workspace file opens

* fix(agents): clarify symlink rejection tests

* fix(agents): surface unsafe identity reads

* fix(agents): use non-blocking opens for identity reads and write-mode probes

* fix(fssafe): restore symlink read identity check

* fix(worklog): append comment resolution status

* fix(fssafe): close afterOpen handle leaks

* fix(worklog): append comment resolution follow-up

* fix(worklog): drop internal user file

* fix(agents): rethrow unexpected errors in agents.files.get

* changelog: note agents.files fs-safe routing + fd-first realpath (#66636)

* fix(agents): rethrow unexpected errors in agents.files.set too

Match the narrow-SafeOpenError catch pattern that agents.files.get
(commit 633b8f92) and writeWorkspaceFileOrRespond already use, so a
real OS error (ENOSPC, EACCES, EBUSY, ...) surfaces through normal
gateway error handling instead of being masked as
'unsafe workspace file'.

* test(agents): match fsStat/fsLstat mock signatures

The mock functions are declared as
  vi.fn(async (..._args: unknown[]) => Stats | null)
so mockImplementation callbacks must accept ...unknown[], not a
narrowed (filePath: string) argument. The narrower signature
works at runtime but trips tsgo's strict type check; switch to
args[0] unpacking so the callbacks match the hoisted mock shape.

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Agustin Rivera
2026-04-14 13:06:15 -07:00
committed by GitHub
parent 9386e3a9d4
commit 472bcbbccc
5 changed files with 437 additions and 443 deletions

View File

@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
- Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa. - Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.
- Slack/native commands: fix option menus for slash commands such as `/verbose` when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared `openclaw_cmdarg*` listener. Thanks @Wangmerlyn. - Slack/native commands: fix option menus for slash commands such as `/verbose` when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared `openclaw_cmdarg*` listener. Thanks @Wangmerlyn.
- Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit. - Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.
- Agents/workspace files: route `agents.files.get`, `agents.files.set`, and workspace listing through the shared `fs-safe` helpers (`openFileWithinRoot`/`readFileWithinRoot`/`writeFileWithinRoot`), reject symlink aliases for allowlisted agent files, and have `fs-safe` resolve opened-file real paths from the file descriptor before falling back to path-based `realpath` so a symlink swap between `open` and `realpath` can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit.
## 2026.4.14 ## 2026.4.14

View File

@@ -1,5 +1,5 @@
import path from "node:path";
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
import { SafeOpenError } from "../../infra/fs-safe.js";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Mocks */ /* Mocks */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -499,9 +499,8 @@ describe("agents.create", () => {
}); });
it("does not persist config when IDENTITY.md write fails with SafeOpenError", async () => { it("does not persist config when IDENTITY.md write fails with SafeOpenError", async () => {
const { SafeOpenError: SOE } = await import("../../infra/fs-safe.js");
mocks.writeFileWithinRoot.mockRejectedValueOnce( mocks.writeFileWithinRoot.mockRejectedValueOnce(
new SOE("path-mismatch", "path escapes workspace root"), new SafeOpenError("path-mismatch", "path escapes workspace root"),
); );
const { respond, promise } = makeCall("agents.create", { const { respond, promise } = makeCall("agents.create", {
@@ -520,24 +519,7 @@ describe("agents.create", () => {
it("does not persist config when IDENTITY.md read fails", async () => { it("does not persist config when IDENTITY.md read fails", async () => {
agentsTesting.setDepsForTests({ agentsTesting.setDepsForTests({
resolveAgentWorkspaceFilePath: async ({ workspaceDir, name }) => { readFileWithinRoot: async () => {
const ioPath = `${workspaceDir}/${name}`;
if (workspaceDir === "/resolved/tmp/ws") {
return {
kind: "ready",
requestPath: ioPath,
ioPath,
workspaceReal: workspaceDir,
};
}
return {
kind: "missing",
requestPath: ioPath,
ioPath,
workspaceReal: workspaceDir,
};
},
readLocalFileSafely: async () => {
throw createErrnoError("EACCES"); throw createErrnoError("EACCES");
}, },
}); });
@@ -556,6 +538,50 @@ describe("agents.create", () => {
expect(mocks.writeFileWithinRoot).not.toHaveBeenCalled(); expect(mocks.writeFileWithinRoot).not.toHaveBeenCalled();
}); });
it("treats unsafe IDENTITY.md reads as invalid create requests", async () => {
agentsTesting.setDepsForTests({
readFileWithinRoot: async () => {
throw new SafeOpenError("invalid-path", "path is not a regular file under root");
},
});
const { respond, promise } = makeCall("agents.create", {
name: "Unsafe Identity Read",
workspace: "/tmp/ws",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining('unsafe workspace file "IDENTITY.md"'),
}),
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.writeFileWithinRoot).not.toHaveBeenCalled();
});
it("uses non-blocking reads for IDENTITY.md during agents.create", async () => {
const readFileWithinRoot = vi.fn(async () => {
throw new SafeOpenError("not-found", "file not found");
});
agentsTesting.setDepsForTests({ readFileWithinRoot });
const { promise } = makeCall("agents.create", {
name: "NB Agent",
workspace: "/tmp/ws",
});
await promise;
expect(readFileWithinRoot).toHaveBeenCalledWith(
expect.objectContaining({
relativePath: "IDENTITY.md",
nonBlockingRead: true,
}),
);
});
it("passes model to applyAgentConfig when provided", async () => { it("passes model to applyAgentConfig when provided", async () => {
const { respond, promise } = makeCall("agents.create", { const { respond, promise } = makeCall("agents.create", {
name: "Model Agent", name: "Model Agent",
@@ -728,27 +754,8 @@ describe("agents.update", () => {
identityPathCreated: true, identityPathCreated: true,
}); });
agentsTesting.setDepsForTests({ agentsTesting.setDepsForTests({
resolveAgentWorkspaceFilePath: async ({ workspaceDir, name }) => { readFileWithinRoot: async ({ rootDir, relativePath }) => {
const ioPath = `${workspaceDir}/${name}`; const filePath = `${rootDir}/${relativePath}`;
if (
workspaceDir === "/workspace/test-agent" ||
workspaceDir === "/resolved/new/workspace"
) {
return {
kind: "ready",
requestPath: ioPath,
ioPath,
workspaceReal: workspaceDir,
};
}
return {
kind: "missing",
requestPath: ioPath,
ioPath,
workspaceReal: workspaceDir,
};
},
readLocalFileSafely: async ({ filePath }) => {
if (filePath === "/workspace/test-agent/IDENTITY.md") { if (filePath === "/workspace/test-agent/IDENTITY.md") {
return { return {
buffer: Buffer.from( buffer: Buffer.from(
@@ -825,27 +832,8 @@ describe("agents.update", () => {
identityPathCreated: false, identityPathCreated: false,
}); });
agentsTesting.setDepsForTests({ agentsTesting.setDepsForTests({
resolveAgentWorkspaceFilePath: async ({ workspaceDir, name }) => { readFileWithinRoot: async ({ rootDir, relativePath }) => {
const ioPath = `${workspaceDir}/${name}`; const filePath = `${rootDir}/${relativePath}`;
if (
workspaceDir === "/workspace/test-agent" ||
workspaceDir === "/resolved/new/workspace"
) {
return {
kind: "ready",
requestPath: ioPath,
ioPath,
workspaceReal: workspaceDir,
};
}
return {
kind: "missing",
requestPath: ioPath,
ioPath,
workspaceReal: workspaceDir,
};
},
readLocalFileSafely: async ({ filePath }) => {
if (filePath === "/workspace/test-agent/IDENTITY.md") { if (filePath === "/workspace/test-agent/IDENTITY.md") {
return { return {
buffer: Buffer.from( buffer: Buffer.from(
@@ -915,9 +903,8 @@ describe("agents.update", () => {
}); });
it("does not persist config when IDENTITY.md write fails on update", async () => { it("does not persist config when IDENTITY.md write fails on update", async () => {
const { SafeOpenError: SOE } = await import("../../infra/fs-safe.js");
mocks.writeFileWithinRoot.mockRejectedValueOnce( mocks.writeFileWithinRoot.mockRejectedValueOnce(
new SOE("path-mismatch", "path escapes workspace root"), new SafeOpenError("path-mismatch", "path escapes workspace root"),
); );
const { respond, promise } = makeCall("agents.update", { const { respond, promise } = makeCall("agents.update", {
@@ -934,6 +921,50 @@ describe("agents.update", () => {
); );
expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled();
}); });
it("treats unsafe IDENTITY.md reads as invalid update requests", async () => {
agentsTesting.setDepsForTests({
readFileWithinRoot: async () => {
throw new SafeOpenError("invalid-path", "path is not a regular file under root");
},
});
const { respond, promise } = makeCall("agents.update", {
agentId: "test-agent",
avatar: "https://example.com/unsafe.png",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining('unsafe workspace file "IDENTITY.md"'),
}),
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.writeFileWithinRoot).not.toHaveBeenCalled();
});
it("uses non-blocking reads for IDENTITY.md during agents.update", async () => {
const readFileWithinRoot = vi.fn(async () => {
throw new SafeOpenError("not-found", "file not found");
});
agentsTesting.setDepsForTests({ readFileWithinRoot });
const { promise } = makeCall("agents.update", {
agentId: "test-agent",
name: "Updated NB",
});
await promise;
expect(readFileWithinRoot).toHaveBeenCalledWith(
expect.objectContaining({
relativePath: "IDENTITY.md",
nonBlockingRead: true,
}),
);
});
}); });
describe("agents.delete", () => { describe("agents.delete", () => {
@@ -1044,6 +1075,40 @@ describe("agents.files.list", () => {
const names = await listAgentFileNames(); const names = await listAgentFileNames();
expect(names).toContain("BOOTSTRAP.md"); expect(names).toContain("BOOTSTRAP.md");
}); });
it("reports unreadable workspace files as present in list responses", async () => {
const openFileWithinRoot = vi.fn(async () => {
throw createErrnoError("EACCES");
});
agentsTesting.setDepsForTests({ openFileWithinRoot });
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
if (args[0] === "/workspace/main/AGENTS.md") {
return makeFileStat({ size: 17, mtimeMs: 4567 });
}
throw createEnoentError();
});
mocks.fsStat.mockImplementation(async (...args: unknown[]) => {
if (args[0] === "/workspace/main/AGENTS.md") {
return makeFileStat({ size: 17, mtimeMs: 4567 });
}
throw createEnoentError();
});
const { respond, promise } = makeCall("agents.files.list", { agentId: "main" });
await promise;
const [, result] = respond.mock.calls[0] ?? [];
const files = (result as { files: Array<{ name: string; missing: boolean; size?: number }> })
.files;
expect(files).toContainEqual(
expect.objectContaining({
name: "AGENTS.md",
missing: false,
size: 17,
}),
);
expect(openFileWithinRoot).not.toHaveBeenCalled();
});
}); });
describe("agents.files.get/set symlink safety", () => { describe("agents.files.get/set symlink safety", () => {
@@ -1058,14 +1123,32 @@ describe("agents.files.get/set symlink safety", () => {
}); });
function mockWorkspaceEscapeSymlink() { function mockWorkspaceEscapeSymlink() {
const workspace = "/workspace/test-agent"; const safeOpenError = new SafeOpenError("invalid-path", "path escapes workspace root");
agentsTesting.setDepsForTests({ agentsTesting.setDepsForTests({
resolveAgentWorkspaceFilePath: async ({ name }) => ({ openFileWithinRoot: async () => {
kind: "invalid", throw safeOpenError;
requestPath: path.join(workspace, name), },
reason: "path escapes workspace root", readFileWithinRoot: async () => {
}), throw safeOpenError;
},
}); });
mocks.writeFileWithinRoot.mockRejectedValue(safeOpenError);
}
function mockInWorkspaceSymlinkAlias() {
const safeOpenError = new SafeOpenError(
"invalid-path",
"path is not a regular file under root",
);
agentsTesting.setDepsForTests({
openFileWithinRoot: async () => {
throw safeOpenError;
},
readFileWithinRoot: async () => {
throw safeOpenError;
},
});
mocks.writeFileWithinRoot.mockRejectedValue(safeOpenError);
} }
it.each([ it.each([
@@ -1082,83 +1165,25 @@ describe("agents.files.get/set symlink safety", () => {
}, },
); );
it("allows in-workspace symlink reads and writes through symlink aliases", async () => { it.each(["agents.files.get", "agents.files.set"] as const)(
const workspace = "/workspace/test-agent"; "rejects %s when allowlisted file is an in-workspace symlink alias",
const target = path.resolve(workspace, "policies", "AGENTS.md"); async (method) => {
const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 }); mockInWorkspaceSymlinkAlias();
await expectUnsafeWorkspaceFile(method);
agentsTesting.setDepsForTests({ },
readLocalFileSafely: async () => ({ );
buffer: Buffer.from("inside\n"),
realPath: target,
stat: targetStat,
}),
resolveAgentWorkspaceFilePath: async ({ name }) => ({
kind: "ready",
requestPath: path.join(workspace, name),
ioPath: target,
workspaceReal: workspace,
}),
});
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === target) {
return targetStat;
}
throw createEnoentError();
});
mocks.fsStat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === target) {
return targetStat;
}
throw createEnoentError();
});
const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" });
await getCall.promise;
expect(getCall.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
file: expect.objectContaining({ missing: false, content: "inside\n" }),
}),
undefined,
);
const setCall = makeCall("agents.files.set", {
agentId: "main",
name: "AGENTS.md",
content: "updated\n",
});
await setCall.promise;
expect(setCall.respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
file: expect.objectContaining({
missing: false,
content: "updated\n",
}),
}),
undefined,
);
});
function mockHardlinkedWorkspaceAlias() { function mockHardlinkedWorkspaceAlias() {
const workspace = "/workspace/test-agent"; const safeOpenError = new SafeOpenError("invalid-path", "hardlinked path not allowed");
const candidate = path.resolve(workspace, "AGENTS.md"); agentsTesting.setDepsForTests({
mocks.fsRealpath.mockImplementation(async (p: string) => { openFileWithinRoot: async () => {
if (p === workspace) { throw safeOpenError;
return workspace; },
} readFileWithinRoot: async () => {
return p; throw safeOpenError;
}); },
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeFileStat({ nlink: 2 });
}
throw createEnoentError();
}); });
mocks.writeFileWithinRoot.mockRejectedValue(safeOpenError);
} }
it.each([ it.each([
@@ -1174,4 +1199,38 @@ describe("agents.files.get/set symlink safety", () => {
} }
}, },
); );
it("uses non-blocking safe reads for agents.files.get", async () => {
const readFileWithinRoot = vi.fn(async () => ({
buffer: Buffer.from("hello"),
realPath: "/workspace/test-agent/AGENTS.md",
stat: makeFileStat({ size: 5 }),
}));
agentsTesting.setDepsForTests({ readFileWithinRoot });
const { respond, promise } = makeCall("agents.files.get", {
agentId: "main",
name: "AGENTS.md",
});
await promise;
expect(readFileWithinRoot).toHaveBeenCalledWith(
expect.objectContaining({
rootDir: "/workspace/test-agent",
relativePath: "AGENTS.md",
rejectHardlinks: true,
nonBlockingRead: true,
}),
);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
file: expect.objectContaining({
name: "AGENTS.md",
content: "hello",
}),
}),
undefined,
);
});
}); });

View File

@@ -31,9 +31,12 @@ import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/path
import type { IdentityConfig } from "../../config/types.base.js"; import type { IdentityConfig } from "../../config/types.base.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { sameFileIdentity } from "../../infra/file-identity.js"; import { sameFileIdentity } from "../../infra/file-identity.js";
import { SafeOpenError, readLocalFileSafely, writeFileWithinRoot } from "../../infra/fs-safe.js"; import {
import { assertNoPathAliasEscape } from "../../infra/path-alias-guards.js"; openFileWithinRoot,
import { isNotFoundPathError } from "../../infra/path-guards.js"; readFileWithinRoot,
SafeOpenError,
writeFileWithinRoot,
} from "../../infra/fs-safe.js";
import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js"; import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js"; import { resolveUserPath } from "../../utils.js";
@@ -67,8 +70,8 @@ const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter(
const agentsHandlerDeps = { const agentsHandlerDeps = {
isWorkspaceSetupCompleted, isWorkspaceSetupCompleted,
readLocalFileSafely, openFileWithinRoot,
resolveAgentWorkspaceFilePath, readFileWithinRoot,
writeFileWithinRoot, writeFileWithinRoot,
}; };
@@ -76,8 +79,8 @@ export const __testing = {
setDepsForTests( setDepsForTests(
overrides: Partial<{ overrides: Partial<{
isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted; isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted;
readLocalFileSafely: typeof readLocalFileSafely; openFileWithinRoot: typeof openFileWithinRoot;
resolveAgentWorkspaceFilePath: typeof resolveAgentWorkspaceFilePath; readFileWithinRoot: typeof readFileWithinRoot;
writeFileWithinRoot: typeof writeFileWithinRoot; writeFileWithinRoot: typeof writeFileWithinRoot;
}>, }>,
) { ) {
@@ -85,8 +88,8 @@ export const __testing = {
}, },
resetDepsForTests() { resetDepsForTests() {
agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted; agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted;
agentsHandlerDeps.readLocalFileSafely = readLocalFileSafely; agentsHandlerDeps.openFileWithinRoot = openFileWithinRoot;
agentsHandlerDeps.resolveAgentWorkspaceFilePath = resolveAgentWorkspaceFilePath; agentsHandlerDeps.readFileWithinRoot = readFileWithinRoot;
agentsHandlerDeps.writeFileWithinRoot = writeFileWithinRoot; agentsHandlerDeps.writeFileWithinRoot = writeFileWithinRoot;
}, },
}; };
@@ -131,166 +134,40 @@ type FileMeta = {
updatedAtMs: number; updatedAtMs: number;
}; };
type ResolvedAgentWorkspaceFilePath = function isPathInsideDirectory(rootDir: string, candidatePath: string): boolean {
| { const relative = path.relative(rootDir, candidatePath);
kind: "ready"; return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
requestPath: string;
ioPath: string;
workspaceReal: string;
}
| {
kind: "missing";
requestPath: string;
ioPath: string;
workspaceReal: string;
}
| {
kind: "invalid";
requestPath: string;
reason: string;
};
type ResolvedWorkspaceFilePath = Exclude<ResolvedAgentWorkspaceFilePath, { kind: "invalid" }>;
function resolveNotFoundWorkspaceFilePathResult(params: {
error: unknown;
allowMissing: boolean;
requestPath: string;
ioPath: string;
workspaceReal: string;
}): Extract<ResolvedAgentWorkspaceFilePath, { kind: "missing" | "invalid" }> | undefined {
if (!isNotFoundPathError(params.error)) {
return undefined;
}
if (params.allowMissing) {
return {
kind: "missing",
requestPath: params.requestPath,
ioPath: params.ioPath,
workspaceReal: params.workspaceReal,
};
}
return { kind: "invalid", requestPath: params.requestPath, reason: "file not found" };
} }
function resolveWorkspaceFilePathResultOrThrow(params: { async function statWorkspaceFileSafely(
error: unknown; workspaceDir: string,
allowMissing: boolean; name: string,
requestPath: string; ): Promise<FileMeta | null> {
ioPath: string;
workspaceReal: string;
}): Extract<ResolvedAgentWorkspaceFilePath, { kind: "missing" | "invalid" }> {
const notFoundResult = resolveNotFoundWorkspaceFilePathResult(params);
if (notFoundResult) {
return notFoundResult;
}
throw params.error;
}
async function resolveWorkspaceRealPath(workspaceDir: string): Promise<string> {
try { try {
return await fs.realpath(workspaceDir); const workspaceReal = await fs.realpath(workspaceDir);
} catch { const candidatePath = path.resolve(workspaceReal, name);
return path.resolve(workspaceDir); if (!isPathInsideDirectory(workspaceReal, candidatePath)) {
}
}
async function resolveAgentWorkspaceFilePath(params: {
workspaceDir: string;
name: string;
allowMissing: boolean;
}): Promise<ResolvedAgentWorkspaceFilePath> {
const requestPath = path.join(params.workspaceDir, params.name);
const workspaceReal = await resolveWorkspaceRealPath(params.workspaceDir);
const candidatePath = path.resolve(workspaceReal, params.name);
try {
await assertNoPathAliasEscape({
absolutePath: candidatePath,
rootPath: workspaceReal,
boundaryLabel: "workspace root",
});
} catch (error) {
return {
kind: "invalid",
requestPath,
reason: error instanceof Error ? error.message : "path escapes workspace root",
};
}
const notFoundContext = {
allowMissing: params.allowMissing,
requestPath,
workspaceReal,
} as const;
let candidateLstat: Awaited<ReturnType<typeof fs.lstat>>;
try {
candidateLstat = await fs.lstat(candidatePath);
} catch (err) {
return resolveWorkspaceFilePathResultOrThrow({
error: err,
...notFoundContext,
ioPath: candidatePath,
});
}
if (candidateLstat.isSymbolicLink()) {
let targetReal: string;
try {
targetReal = await fs.realpath(candidatePath);
} catch (err) {
return resolveWorkspaceFilePathResultOrThrow({
error: err,
...notFoundContext,
ioPath: candidatePath,
});
}
let targetStat: Awaited<ReturnType<typeof fs.stat>>;
try {
targetStat = await fs.stat(targetReal);
} catch (err) {
return resolveWorkspaceFilePathResultOrThrow({
error: err,
...notFoundContext,
ioPath: targetReal,
});
}
if (!targetStat.isFile()) {
return { kind: "invalid", requestPath, reason: "path is not a regular file" };
}
if (targetStat.nlink > 1) {
return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" };
}
return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal };
}
if (!candidateLstat.isFile()) {
return { kind: "invalid", requestPath, reason: "path is not a regular file" };
}
if (candidateLstat.nlink > 1) {
return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" };
}
const targetReal = await fs.realpath(candidatePath).catch(() => candidatePath);
return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal };
}
async function statFileSafely(filePath: string): Promise<FileMeta | null> {
try {
const [stat, lstat] = await Promise.all([fs.stat(filePath), fs.lstat(filePath)]);
if (lstat.isSymbolicLink() || !stat.isFile()) {
return null; return null;
} }
if (stat.nlink > 1) {
const pathStat = await fs.lstat(candidatePath);
if (!pathStat.isFile() || pathStat.nlink > 1) {
return null; return null;
} }
if (!sameFileIdentity(stat, lstat)) {
const realPath = await fs.realpath(candidatePath);
if (!isPathInsideDirectory(workspaceReal, realPath)) {
return null; return null;
} }
const realStat = await fs.stat(realPath);
if (!realStat.isFile() || realStat.nlink > 1 || !sameFileIdentity(pathStat, realStat)) {
return null;
}
return { return {
size: stat.size, size: realStat.size,
updatedAtMs: Math.floor(stat.mtimeMs), updatedAtMs: Math.floor(realStat.mtimeMs),
}; };
} catch { } catch {
return null; return null;
@@ -310,18 +187,8 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING ? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING
: BOOTSTRAP_FILE_NAMES; : BOOTSTRAP_FILE_NAMES;
for (const name of bootstrapFileNames) { for (const name of bootstrapFileNames) {
const resolved = await resolveAgentWorkspaceFilePath({ const filePath = path.join(workspaceDir, name);
workspaceDir, const meta = await statWorkspaceFileSafely(workspaceDir, name);
name,
allowMissing: true,
});
const filePath = resolved.requestPath;
const meta =
resolved.kind === "ready"
? await statFileSafely(resolved.ioPath)
: resolved.kind === "missing"
? null
: null;
if (meta) { if (meta) {
files.push({ files.push({
name, name,
@@ -335,33 +202,21 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
} }
} }
const primaryResolved = await resolveAgentWorkspaceFilePath({ const primaryMeta = await statWorkspaceFileSafely(workspaceDir, DEFAULT_MEMORY_FILENAME);
workspaceDir,
name: DEFAULT_MEMORY_FILENAME,
allowMissing: true,
});
const primaryMeta =
primaryResolved.kind === "ready" ? await statFileSafely(primaryResolved.ioPath) : null;
if (primaryMeta) { if (primaryMeta) {
files.push({ files.push({
name: DEFAULT_MEMORY_FILENAME, name: DEFAULT_MEMORY_FILENAME,
path: primaryResolved.requestPath, path: path.join(workspaceDir, DEFAULT_MEMORY_FILENAME),
missing: false, missing: false,
size: primaryMeta.size, size: primaryMeta.size,
updatedAtMs: primaryMeta.updatedAtMs, updatedAtMs: primaryMeta.updatedAtMs,
}); });
} else { } else {
const altMemoryResolved = await resolveAgentWorkspaceFilePath({ const altMeta = await statWorkspaceFileSafely(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME);
workspaceDir,
name: DEFAULT_MEMORY_ALT_FILENAME,
allowMissing: true,
});
const altMeta =
altMemoryResolved.kind === "ready" ? await statFileSafely(altMemoryResolved.ioPath) : null;
if (altMeta) { if (altMeta) {
files.push({ files.push({
name: DEFAULT_MEMORY_ALT_FILENAME, name: DEFAULT_MEMORY_ALT_FILENAME,
path: altMemoryResolved.requestPath, path: path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME),
missing: false, missing: false,
size: altMeta.size, size: altMeta.size,
updatedAtMs: altMeta.updatedAtMs, updatedAtMs: altMeta.updatedAtMs,
@@ -369,7 +224,7 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
} else { } else {
files.push({ files.push({
name: DEFAULT_MEMORY_FILENAME, name: DEFAULT_MEMORY_FILENAME,
path: primaryResolved.requestPath, path: path.join(workspaceDir, DEFAULT_MEMORY_FILENAME),
missing: true, missing: true,
}); });
} }
@@ -434,31 +289,6 @@ async function moveToTrashBestEffort(pathname: string): Promise<void> {
} }
} }
function respondWorkspaceFileInvalid(respond: RespondFn, name: string, reason: string): void {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}" (${reason})`),
);
}
async function resolveWorkspaceFilePathOrRespond(params: {
respond: RespondFn;
workspaceDir: string;
name: string;
}): Promise<ResolvedWorkspaceFilePath | undefined> {
const resolvedPath = await agentsHandlerDeps.resolveAgentWorkspaceFilePath({
workspaceDir: params.workspaceDir,
name: params.name,
allowMissing: true,
});
if (resolvedPath.kind === "invalid") {
respondWorkspaceFileInvalid(params.respond, params.name, resolvedPath.reason);
return undefined;
}
return resolvedPath;
}
function respondWorkspaceFileUnsafe(respond: RespondFn, name: string): void { function respondWorkspaceFileUnsafe(respond: RespondFn, name: string): void {
respond( respond(
false, false,
@@ -492,27 +322,10 @@ async function writeWorkspaceFileOrRespond(params: {
content: string; content: string;
}): Promise<boolean> { }): Promise<boolean> {
await fs.mkdir(params.workspaceDir, { recursive: true }); await fs.mkdir(params.workspaceDir, { recursive: true });
const resolvedPath = await resolveWorkspaceFilePathOrRespond({
respond: params.respond,
workspaceDir: params.workspaceDir,
name: params.name,
});
if (!resolvedPath) {
return false;
}
const relativeWritePath = path.relative(resolvedPath.workspaceReal, resolvedPath.ioPath);
if (
!relativeWritePath ||
relativeWritePath.startsWith("..") ||
path.isAbsolute(relativeWritePath)
) {
respondWorkspaceFileUnsafe(params.respond, params.name);
return false;
}
try { try {
await agentsHandlerDeps.writeFileWithinRoot({ await agentsHandlerDeps.writeFileWithinRoot({
rootDir: resolvedPath.workspaceReal, rootDir: params.workspaceDir,
relativePath: relativeWritePath, relativePath: params.name,
data: params.content, data: params.content,
encoding: "utf8", encoding: "utf8",
}); });
@@ -548,16 +361,13 @@ async function readWorkspaceFileContent(
workspaceDir: string, workspaceDir: string,
name: string, name: string,
): Promise<string | undefined> { ): Promise<string | undefined> {
const resolvedPath = await agentsHandlerDeps.resolveAgentWorkspaceFilePath({
workspaceDir,
name,
allowMissing: true,
});
if (resolvedPath.kind !== "ready") {
return undefined;
}
try { try {
const safeRead = await agentsHandlerDeps.readLocalFileSafely({ filePath: resolvedPath.ioPath }); const safeRead = await agentsHandlerDeps.readFileWithinRoot({
rootDir: workspaceDir,
relativePath: name,
rejectHardlinks: true,
nonBlockingRead: true,
});
return safeRead.buffer.toString("utf-8"); return safeRead.buffer.toString("utf-8");
} catch (err) { } catch (err) {
if (err instanceof SafeOpenError && err.code === "not-found") { if (err instanceof SafeOpenError && err.code === "not-found") {
@@ -595,6 +405,24 @@ async function buildIdentityMarkdownForWrite(params: {
return mergeIdentityMarkdownContent(baseContent, params.identity); return mergeIdentityMarkdownContent(baseContent, params.identity);
} }
async function buildIdentityMarkdownOrRespondUnsafe(params: {
respond: RespondFn;
workspaceDir: string;
identity: IdentityConfig;
fallbackWorkspaceDir?: string;
preferFallbackWorkspaceContent?: boolean;
}): Promise<string | null> {
try {
return await buildIdentityMarkdownForWrite(params);
} catch (err) {
if (err instanceof SafeOpenError) {
respondWorkspaceFileUnsafe(params.respond, DEFAULT_IDENTITY_FILENAME);
return null;
}
throw err;
}
}
export const agentsHandlers: GatewayRequestHandlers = { export const agentsHandlers: GatewayRequestHandlers = {
"agents.list": ({ params, respond }) => { "agents.list": ({ params, respond }) => {
if (!validateAgentsListParams(params)) { if (!validateAgentsListParams(params)) {
@@ -682,10 +510,14 @@ export const agentsHandlers: GatewayRequestHandlers = {
const persistedIdentity = normalizeIdentityForFile(resolveAgentIdentity(nextConfig, agentId)); const persistedIdentity = normalizeIdentityForFile(resolveAgentIdentity(nextConfig, agentId));
if (persistedIdentity) { if (persistedIdentity) {
const identityContent = await buildIdentityMarkdownForWrite({ const identityContent = await buildIdentityMarkdownOrRespondUnsafe({
respond,
workspaceDir, workspaceDir,
identity: persistedIdentity, identity: persistedIdentity,
}); });
if (identityContent === null) {
return;
}
if ( if (
!(await writeWorkspaceFileOrRespond({ !(await writeWorkspaceFileOrRespond({
respond, respond,
@@ -762,13 +594,17 @@ export const agentsHandlers: GatewayRequestHandlers = {
workspaceDir && identityWorkspaceDir !== previousWorkspaceDir workspaceDir && identityWorkspaceDir !== previousWorkspaceDir
? previousWorkspaceDir ? previousWorkspaceDir
: undefined; : undefined;
const identityContent = await buildIdentityMarkdownForWrite({ const identityContent = await buildIdentityMarkdownOrRespondUnsafe({
respond,
workspaceDir: identityWorkspaceDir, workspaceDir: identityWorkspaceDir,
identity: persistedIdentity, identity: persistedIdentity,
fallbackWorkspaceDir, fallbackWorkspaceDir,
preferFallbackWorkspaceContent: preferFallbackWorkspaceContent:
Boolean(fallbackWorkspaceDir) && ensuredWorkspace?.identityPathCreated === true, Boolean(fallbackWorkspaceDir) && ensuredWorkspace?.identityPathCreated === true,
}); });
if (identityContent === null) {
return;
}
if ( if (
!(await writeWorkspaceFileOrRespond({ !(await writeWorkspaceFileOrRespond({
respond, respond,
@@ -865,28 +701,24 @@ export const agentsHandlers: GatewayRequestHandlers = {
} }
const { agentId, workspaceDir, name } = resolved; const { agentId, workspaceDir, name } = resolved;
const filePath = path.join(workspaceDir, name); const filePath = path.join(workspaceDir, name);
const resolvedPath = await resolveWorkspaceFilePathOrRespond({ let safeRead: Awaited<ReturnType<typeof readFileWithinRoot>>;
respond,
workspaceDir,
name,
});
if (!resolvedPath) {
return;
}
if (resolvedPath.kind === "missing") {
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
return;
}
let safeRead: Awaited<ReturnType<typeof readLocalFileSafely>>;
try { try {
safeRead = await agentsHandlerDeps.readLocalFileSafely({ filePath: resolvedPath.ioPath }); safeRead = await agentsHandlerDeps.readFileWithinRoot({
rootDir: workspaceDir,
relativePath: name,
rejectHardlinks: true,
nonBlockingRead: true,
});
} catch (err) { } catch (err) {
if (err instanceof SafeOpenError && err.code === "not-found") { if (err instanceof SafeOpenError && err.code === "not-found") {
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath }); respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
return; return;
} }
respondWorkspaceFileUnsafe(respond, name); if (err instanceof SafeOpenError) {
return; respondWorkspaceFileUnsafe(respond, name);
return;
}
throw err;
} }
respond( respond(
true, true,
@@ -917,36 +749,22 @@ export const agentsHandlers: GatewayRequestHandlers = {
const { agentId, workspaceDir, name } = resolved; const { agentId, workspaceDir, name } = resolved;
await fs.mkdir(workspaceDir, { recursive: true }); await fs.mkdir(workspaceDir, { recursive: true });
const filePath = path.join(workspaceDir, name); const filePath = path.join(workspaceDir, name);
const resolvedPath = await resolveWorkspaceFilePathOrRespond({
respond,
workspaceDir,
name,
});
if (!resolvedPath) {
return;
}
const content = params.content; const content = params.content;
const relativeWritePath = path.relative(resolvedPath.workspaceReal, resolvedPath.ioPath);
if (
!relativeWritePath ||
relativeWritePath.startsWith("..") ||
path.isAbsolute(relativeWritePath)
) {
respondWorkspaceFileUnsafe(respond, name);
return;
}
try { try {
await agentsHandlerDeps.writeFileWithinRoot({ await agentsHandlerDeps.writeFileWithinRoot({
rootDir: resolvedPath.workspaceReal, rootDir: workspaceDir,
relativePath: relativeWritePath, relativePath: name,
data: content, data: content,
encoding: "utf8", encoding: "utf8",
}); });
} catch { } catch (err) {
if (!(err instanceof SafeOpenError)) {
throw err;
}
respondWorkspaceFileUnsafe(respond, name); respondWorkspaceFileUnsafe(respond, name);
return; return;
} }
const meta = await statFileSafely(resolvedPath.ioPath); const meta = await statWorkspaceFileSafely(workspaceDir, name);
respond( respond(
true, true,
{ {

View File

@@ -1,3 +1,4 @@
import type { FileHandle } from "node:fs/promises";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
@@ -8,10 +9,12 @@ import {
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import * as pinnedPathHelperModule from "./fs-pinned-path-helper.js"; import * as pinnedPathHelperModule from "./fs-pinned-path-helper.js";
import { import {
__setFsSafeTestHooksForTest,
appendFileWithinRoot, appendFileWithinRoot,
copyFileWithinRoot, copyFileWithinRoot,
createRootScopedReadFile, createRootScopedReadFile,
mkdirPathWithinRoot, mkdirPathWithinRoot,
resolveOpenedFileRealPathForHandle,
SafeOpenError, SafeOpenError,
openFileWithinRoot, openFileWithinRoot,
readFileWithinRoot, readFileWithinRoot,
@@ -25,6 +28,8 @@ import {
const tempDirs = createTrackedTempDirs(); const tempDirs = createTrackedTempDirs();
afterEach(async () => { afterEach(async () => {
__setFsSafeTestHooksForTest(undefined);
vi.unstubAllEnvs();
await tempDirs.cleanup(); await tempDirs.cleanup();
}); });
@@ -149,6 +154,32 @@ describe("fs-safe", () => {
}); });
}); });
it.runIf(process.platform !== "win32")(
"resolves opened file real paths from the fd before the current path target",
async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const outside = await tempDirs.make("openclaw-fs-safe-outside-");
const originalPath = path.join(root, "inside.txt");
const movedPath = path.join(root, "inside-moved.txt");
const outsidePath = path.join(outside, "outside.txt");
await fs.writeFile(originalPath, "inside");
await fs.writeFile(outsidePath, "outside");
const handle = await fs.open(originalPath, "r");
try {
await fs.rename(originalPath, movedPath);
await fs.symlink(outsidePath, originalPath);
const resolved = await resolveOpenedFileRealPathForHandle(handle, originalPath);
expect(resolved).toBe(movedPath);
await expect(handle.readFile({ encoding: "utf8" })).resolves.toBe("inside");
} finally {
await handle.close().catch(() => {});
}
},
);
it("blocks traversal outside root", async () => { it("blocks traversal outside root", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-"); const root = await tempDirs.make("openclaw-fs-safe-root-");
const outside = await tempDirs.make("openclaw-fs-safe-outside-"); const outside = await tempDirs.make("openclaw-fs-safe-outside-");
@@ -221,6 +252,70 @@ describe("fs-safe", () => {
).rejects.toMatchObject({ code: "invalid-path" }); ).rejects.toMatchObject({ code: "invalid-path" });
}); });
it.runIf(process.platform !== "win32")(
"rejects symlink-target reads when the path target changes after open",
async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const insideA = path.join(root, "inside-a.txt");
const insideB = path.join(root, "inside-b.txt");
const link = path.join(root, "link.txt");
await fs.writeFile(insideA, "inside-a");
await fs.writeFile(insideB, "inside-b");
await fs.symlink(insideA, link);
__setFsSafeTestHooksForTest({
afterOpen: async () => {
await fs.rm(link);
await fs.symlink(insideB, link);
},
});
await expect(
readFileWithinRoot({
rootDir: root,
relativePath: "link.txt",
allowSymlinkTargetWithinRoot: true,
}),
).rejects.toMatchObject({ code: "invalid-path" });
},
);
it("closes the opened handle when afterOpen hook throws", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
const filePath = path.join(root, "inside.txt");
await fs.writeFile(filePath, "inside");
let openedHandle: FileHandle | undefined;
__setFsSafeTestHooksForTest({
afterOpen: (_target, handle) => {
openedHandle = handle;
throw new Error("after-open boom");
},
});
await expect(
openFileWithinRoot({
rootDir: root,
relativePath: "inside.txt",
}),
).rejects.toThrow("after-open boom");
expect(openedHandle).toBeDefined();
await expect(openedHandle?.readFile({ encoding: "utf8" })).rejects.toMatchObject({
code: "EBADF",
});
});
it("rejects setting fs-safe test hooks outside test mode", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("VITEST", undefined);
expect(() =>
__setFsSafeTestHooksForTest({
afterPreOpenLstat: () => {},
}),
).toThrow("__setFsSafeTestHooksForTest is only available in tests");
});
it.runIf(process.platform !== "win32")("blocks hardlink aliases under root", async () => { it.runIf(process.platform !== "win32")("blocks hardlink aliases under root", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-"); const root = await tempDirs.make("openclaw-fs-safe-root-");
const hardlinkPath = path.join(root, "link.txt"); const hardlinkPath = path.join(root, "link.txt");

View File

@@ -53,11 +53,19 @@ export type SafeLocalReadResult = {
export type FsSafeTestHooks = { export type FsSafeTestHooks = {
afterPreOpenLstat?: (filePath: string) => Promise<void> | void; afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void; beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
}; };
let fsSafeTestHooks: FsSafeTestHooks | undefined; let fsSafeTestHooks: FsSafeTestHooks | undefined;
function allowFsSafeTestHooks(): boolean {
return process.env.NODE_ENV === "test" || process.env.VITEST === "true";
}
export function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void { export function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void {
if (hooks && !allowFsSafeTestHooks()) {
throw new Error("__setFsSafeTestHooksForTest is only available in tests");
}
fsSafeTestHooks = hooks; fsSafeTestHooks = hooks;
} }
@@ -129,6 +137,12 @@ async function openVerifiedLocalFile(
: OPEN_READ_FLAGS; : OPEN_READ_FLAGS;
await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags); await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags);
handle = await fs.open(filePath, openFlags); handle = await fs.open(filePath, openFlags);
try {
await fsSafeTestHooks?.afterOpen?.(filePath, handle);
} catch (err) {
await handle.close().catch(() => {});
throw err;
}
} catch (err) { } catch (err) {
if (isNotFoundPathError(err)) { if (isNotFoundPathError(err)) {
throw new SafeOpenError("not-found", "file not found"); throw new SafeOpenError("not-found", "file not found");
@@ -144,24 +158,30 @@ async function openVerifiedLocalFile(
} }
try { try {
const [stat, pathStat] = await Promise.all([ const stat = await handle.stat();
handle.stat(),
options?.allowSymlinkTargetWithinRoot ? fs.stat(filePath) : fs.lstat(filePath),
]);
if (!options?.allowSymlinkTargetWithinRoot && pathStat.isSymbolicLink()) {
throw new SafeOpenError("symlink", "symlink not allowed");
}
if (!stat.isFile()) { if (!stat.isFile()) {
throw new SafeOpenError("not-file", "not a file"); throw new SafeOpenError("not-file", "not a file");
} }
if (options?.rejectHardlinks && stat.nlink > 1) { if (options?.rejectHardlinks && stat.nlink > 1) {
throw new SafeOpenError("invalid-path", "hardlinked path not allowed"); throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
} }
if (!sameFileIdentity(stat, pathStat)) {
throw new SafeOpenError("path-mismatch", "path changed during read"); if (options?.allowSymlinkTargetWithinRoot) {
const pathStat = await fs.stat(filePath);
if (!sameFileIdentity(stat, pathStat)) {
throw new SafeOpenError("path-mismatch", "path changed during read");
}
} else {
const pathStat = await fs.lstat(filePath);
if (pathStat.isSymbolicLink()) {
throw new SafeOpenError("symlink", "symlink not allowed");
}
if (!sameFileIdentity(stat, pathStat)) {
throw new SafeOpenError("path-mismatch", "path changed during read");
}
} }
const realPath = await fs.realpath(filePath); const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath);
const realStat = await fs.stat(realPath); const realStat = await fs.stat(realPath);
if (options?.rejectHardlinks && realStat.nlink > 1) { if (options?.rejectHardlinks && realStat.nlink > 1) {
throw new SafeOpenError("invalid-path", "hardlinked path not allowed"); throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
@@ -397,14 +417,6 @@ export async function resolveOpenedFileRealPathForHandle(
handle: FileHandle, handle: FileHandle,
ioPath: string, ioPath: string,
): Promise<string> { ): Promise<string> {
try {
return await fs.realpath(ioPath);
} catch (err) {
if (!isNotFoundPathError(err)) {
throw err;
}
}
const fdCandidates = const fdCandidates =
process.platform === "linux" process.platform === "linux"
? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`] ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
@@ -418,6 +430,14 @@ export async function resolveOpenedFileRealPathForHandle(
// try next fd path // try next fd path
} }
} }
try {
return await fs.realpath(ioPath);
} catch (err) {
if (!isNotFoundPathError(err)) {
throw err;
}
}
throw new SafeOpenError("path-mismatch", "unable to resolve opened file path"); throw new SafeOpenError("path-mismatch", "unable to resolve opened file path");
} }
@@ -792,6 +812,7 @@ async function resolvePinnedWriteTargetWithinRoot(params: {
rootDir: params.rootDir, rootDir: params.rootDir,
relativePath: params.relativePath, relativePath: params.relativePath,
rejectHardlinks: true, rejectHardlinks: true,
nonBlockingRead: true,
}); });
try { try {
mode = opened.stat.mode & 0o777; mode = opened.stat.mode & 0o777;