From dec7c38e35f7e2febae7eca678eef5363f163bf7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 14 Mar 2026 20:19:22 -0700 Subject: [PATCH] Tools: revalidate workspace-only patch targets --- CHANGELOG.md | 1 + src/agents/apply-patch.test.ts | 21 ++++++++++++++++++++- src/agents/apply-patch.ts | 24 ++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e204965d7..75f610c1da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. +- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. ### Fixes diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index b14179f5907..1b203845527 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { applyPatch } from "./apply-patch.js"; async function withTempDir(fn: (dir: string) => Promise) { @@ -147,6 +147,25 @@ describe("applyPatch", () => { }); }); + it("revalidates delete targets before removing files", async () => { + await withTempDir(async (dir) => { + const target = path.join(dir, "delete-me.txt"); + await fs.writeFile(target, "x\n", "utf8"); + const rmSpy = vi.spyOn(fs, "rm"); + + try { + const patch = `*** Begin Patch +*** Delete File: delete-me.txt +*** End Patch`; + + await applyPatch(patch, { cwd: dir }); + expect(rmSpy).toHaveBeenCalledWith(target); + } finally { + rmSpy.mockRestore(); + } + }); + }); + it("rejects symlink escape attempts by default", async () => { // File symlinks require SeCreateSymbolicLinkPrivilege on Windows. if (process.platform === "win32") { diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index 9c948cb3971..d7a5dc1e0ff 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -270,8 +270,28 @@ function resolvePatchFileOps(options: ApplyPatchOptions): PatchFileOps { encoding: "utf8", }); }, - remove: (filePath) => fs.rm(filePath), - mkdirp: (dir) => fs.mkdir(dir, { recursive: true }).then(() => {}), + remove: async (filePath) => { + if (workspaceOnly) { + await assertSandboxPath({ + filePath, + cwd: options.cwd, + root: options.cwd, + allowFinalSymlinkForUnlink: true, + allowFinalHardlinkForUnlink: true, + }); + } + await fs.rm(filePath); + }, + mkdirp: async (dir) => { + if (workspaceOnly) { + await assertSandboxPath({ + filePath: dir, + cwd: options.cwd, + root: options.cwd, + }); + } + await fs.mkdir(dir, { recursive: true }); + }, }; }