diff --git a/CHANGELOG.md b/CHANGELOG.md index 3213916df7e..c1b29a7d668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - 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) - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts. - 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. - Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc. - Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc. - Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc. diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index b14179f5907..1f305379b5d 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("resolves delete targets before calling fs.rm", 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 }); + }, }; }