diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb1e9f6209..d8c53f815bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. - Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung. - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. diff --git a/src/agents/apply-patch.ts b/src/agents/apply-patch.ts index ef756b37a25..fecf4cf03bc 100644 --- a/src/agents/apply-patch.ts +++ b/src/agents/apply-patch.ts @@ -260,6 +260,14 @@ async function resolvePatchPath( filePath, cwd: options.cwd, }); + if (options.workspaceOnly !== false) { + await assertSandboxPath({ + filePath: resolved.hostPath, + cwd: options.cwd, + root: options.cwd, + allowFinalSymlink: purpose === "unlink", + }); + } return { resolved: resolved.hostPath, display: resolved.relativePath || resolved.hostPath, diff --git a/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts b/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts index f40489f20ef..77fd1f724f5 100644 --- a/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts +++ b/src/agents/pi-tools.sandbox-mounted-paths.workspace-only.test.ts @@ -73,6 +73,10 @@ function createSandbox(params: { }); } +type ToolWithExecute = { + execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise; +}; + async function withUnsafeMountedSandboxHarness( run: (ctx: { sandboxRoot: string; agentRoot: string; sandbox: SandboxContext }) => Promise, ) { @@ -131,4 +135,74 @@ describe("tools.fs.workspaceOnly", () => { expect(await fs.readFile(path.join(agentRoot, "secret.txt"), "utf8")).toBe("shh"); }); }); + + it("enforces apply_patch workspace-only in sandbox mounts by default", async () => { + await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => { + const cfg: OpenClawConfig = { + tools: { + allow: ["read", "exec"], + exec: { applyPatch: { enabled: true } }, + }, + }; + const tools = createOpenClawCodingTools({ + sandbox, + workspaceDir: sandboxRoot, + config: cfg, + modelProvider: "openai", + modelId: "gpt-5.2", + }); + const applyPatchTool = tools.find((t) => t.name === "apply_patch") as + | ToolWithExecute + | undefined; + if (!applyPatchTool) { + throw new Error("apply_patch tool missing"); + } + + const patch = `*** Begin Patch +*** Add File: /agent/pwned.txt ++owned-by-apply-patch +*** End Patch`; + + await expect(applyPatchTool.execute("t1", { input: patch })).rejects.toThrow( + /Path escapes sandbox root/i, + ); + await expect(fs.stat(path.join(agentRoot, "pwned.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }); + + it("allows apply_patch outside workspace root when explicitly disabled", async () => { + await withUnsafeMountedSandboxHarness(async ({ sandboxRoot, agentRoot, sandbox }) => { + const cfg: OpenClawConfig = { + tools: { + allow: ["read", "exec"], + exec: { applyPatch: { enabled: true, workspaceOnly: false } }, + }, + }; + const tools = createOpenClawCodingTools({ + sandbox, + workspaceDir: sandboxRoot, + config: cfg, + modelProvider: "openai", + modelId: "gpt-5.2", + }); + const applyPatchTool = tools.find((t) => t.name === "apply_patch") as + | ToolWithExecute + | undefined; + if (!applyPatchTool) { + throw new Error("apply_patch tool missing"); + } + + const patch = `*** Begin Patch +*** Add File: /agent/pwned.txt ++owned-by-apply-patch +*** End Patch`; + + await applyPatchTool.execute("t2", { input: patch }); + expect(await fs.readFile(path.join(agentRoot, "pwned.txt"), "utf8")).toBe( + "owned-by-apply-patch\n", + ); + }); + }); });