Skip to content

Commit 0443b85

Browse files
committed
fix: allow symlinked workspace write parents
1 parent 1f28c3e commit 0443b85

3 files changed

Lines changed: 125 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai
8181
- Scripts/Windows: route remaining QA, release, profile, and live-media `pnpm` launches through the managed runner so native Windows avoids brittle `.cmd` execution and shell-argv warnings.
8282
- Release: align generated config/API baselines and the meeting-notes plugin version so release preflight stays green on native Windows.
8383
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
84+
- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork.
8485
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
8586
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
8687
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.

src/agents/pi-tools.read.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { URL } from "node:url";
44
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
55
import { createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-coding-agent";
66
import { isWindowsDrivePath } from "../infra/archive-path.js";
7-
import { root as fsRoot, FsSafeError } from "../infra/fs-safe.js";
7+
import {
8+
canonicalPathFromExistingAncestor,
9+
root as fsRoot,
10+
FsSafeError,
11+
} from "../infra/fs-safe.js";
812
import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js";
913
import { hasEncodedFileUrlSeparator, trySafeFileURLToPath } from "../infra/local-file-access.js";
1014
import { detectMime } from "../media/mime.js";
@@ -884,7 +888,7 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo
884888
await fs.mkdir(resolved, { recursive: true });
885889
},
886890
writeFile: async (absolutePath: string, content: string) => {
887-
const relative = toRelativeWorkspacePath(root, absolutePath);
891+
const relative = await toCanonicalRelativeWorkspacePath(root, absolutePath);
888892
await (await rootPromise).write(relative, content, { mkdir: true });
889893
},
890894
} as const;
@@ -917,7 +921,7 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
917921
return safeRead.buffer;
918922
},
919923
writeFile: async (absolutePath: string, content: string) => {
920-
const relative = toRelativeWorkspacePath(root, absolutePath);
924+
const relative = await toCanonicalRelativeWorkspacePath(root, absolutePath);
921925
await (await rootPromise).write(relative, content, { mkdir: true });
922926
},
923927
access: async (absolutePath: string) => {
@@ -950,6 +954,21 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
950954
} as const;
951955
}
952956

957+
async function toCanonicalRelativeWorkspacePath(
958+
root: string,
959+
absolutePath: string,
960+
): Promise<string> {
961+
const lexicalRelative = toRelativeWorkspacePath(root, absolutePath);
962+
const lexicalPath = path.resolve(root, lexicalRelative);
963+
const parentPath = path.dirname(lexicalPath);
964+
const [rootReal, canonicalParentPath] = await Promise.all([
965+
fs.realpath(root),
966+
canonicalPathFromExistingAncestor(parentPath),
967+
]);
968+
const canonicalPath = path.join(canonicalParentPath, path.basename(lexicalPath));
969+
return toRelativeWorkspacePath(rootReal, canonicalPath);
970+
}
971+
953972
function createFsAccessError(code: string, filePath: string): NodeJS.ErrnoException {
954973
const error = new Error(`Sandbox FS error (${code}): ${filePath}`) as NodeJS.ErrnoException;
955974
error.code = code;

src/agents/pi-tools.workspace-paths.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,108 @@ describe("workspace path resolution", () => {
224224
});
225225
});
226226

227+
it.runIf(process.platform !== "win32")(
228+
"writes through in-workspace symlink parents when workspaceOnly is enabled",
229+
async () => {
230+
await withTempDir("openclaw-ws-symlink-write-", async (workspaceDir) => {
231+
const realDir = path.join(workspaceDir, "oc_system", "memory");
232+
const aliasDir = path.join(workspaceDir, "memory");
233+
await fs.mkdir(realDir, { recursive: true });
234+
await fs.symlink(realDir, aliasDir);
235+
236+
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
237+
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
238+
const { writeTool } = expectReadWriteEditTools(tools);
239+
240+
await writeTool.execute("ws-write-symlink-parent", {
241+
path: "memory/2026-05-20.md",
242+
content: "remember this\n",
243+
});
244+
245+
await expect(fs.readFile(path.join(realDir, "2026-05-20.md"), "utf8")).resolves.toBe(
246+
"remember this\n",
247+
);
248+
});
249+
},
250+
);
251+
252+
it.runIf(process.platform !== "win32")(
253+
"edits through in-workspace symlink parents when workspaceOnly is enabled",
254+
async () => {
255+
await withTempDir("openclaw-ws-symlink-edit-", async (workspaceDir) => {
256+
const realDir = path.join(workspaceDir, "oc_system", "memory");
257+
const aliasDir = path.join(workspaceDir, "memory");
258+
const targetPath = path.join(realDir, "2026-05-20.md");
259+
await fs.mkdir(realDir, { recursive: true });
260+
await fs.symlink(realDir, aliasDir);
261+
await fs.writeFile(targetPath, "old memory\n", "utf8");
262+
263+
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
264+
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
265+
const { editTool } = expectReadWriteEditTools(tools);
266+
267+
await editTool.execute("ws-edit-symlink-parent", {
268+
path: "memory/2026-05-20.md",
269+
edits: [{ oldText: "old", newText: "new" }],
270+
});
271+
272+
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("new memory\n");
273+
});
274+
},
275+
);
276+
277+
it.runIf(process.platform !== "win32")(
278+
"rejects writes through symlink parents that resolve outside the workspace",
279+
async () => {
280+
await withTempDir("openclaw-ws-symlink-escape-", async (rootDir) => {
281+
const workspaceDir = path.join(rootDir, "workspace");
282+
const outsideDir = path.join(rootDir, "outside");
283+
const aliasDir = path.join(workspaceDir, "memory");
284+
await fs.mkdir(workspaceDir, { recursive: true });
285+
await fs.mkdir(outsideDir, { recursive: true });
286+
await fs.symlink(outsideDir, aliasDir);
287+
288+
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
289+
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
290+
const { writeTool } = expectReadWriteEditTools(tools);
291+
292+
await expect(
293+
writeTool.execute("ws-write-symlink-escape", {
294+
path: "memory/secret.md",
295+
content: "pwned\n",
296+
}),
297+
).rejects.toThrow(/Path escapes workspace root|outside-workspace|sandbox/i);
298+
await expect(fs.stat(path.join(outsideDir, "secret.md"))).rejects.toMatchObject({
299+
code: "ENOENT",
300+
});
301+
});
302+
},
303+
);
304+
305+
it.runIf(process.platform !== "win32")(
306+
"rejects writes to final symlinks when workspaceOnly is enabled",
307+
async () => {
308+
await withTempDir("openclaw-ws-symlink-leaf-", async (workspaceDir) => {
309+
const targetPath = path.join(workspaceDir, "target.md");
310+
const linkPath = path.join(workspaceDir, "memory.md");
311+
await fs.writeFile(targetPath, "original\n", "utf8");
312+
await fs.symlink(targetPath, linkPath);
313+
314+
const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } };
315+
const tools = createOpenClawCodingTools({ workspaceDir, config: cfg });
316+
const { writeTool } = expectReadWriteEditTools(tools);
317+
318+
await expect(
319+
writeTool.execute("ws-write-final-symlink", {
320+
path: "memory.md",
321+
content: "pwned\n",
322+
}),
323+
).rejects.toThrow(/symlink|not-file|directory component/i);
324+
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("original\n");
325+
});
326+
},
327+
);
328+
227329
it("allows workspaceOnly reads for resolved skill roots without allowing other filesystem access", async () => {
228330
await withTempDir("openclaw-skill-read-", async (rootDir) => {
229331
const workspaceDir = path.join(rootDir, "workspace");

0 commit comments

Comments
 (0)