diff --git a/CHANGELOG.md b/CHANGELOG.md index e44e3d32b..5a52abd7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index a5fb9a1cc..4fe53c331 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -571,7 +571,7 @@ function mapContainerPathToWorkspaceRoot(params: { return params.filePath; } - let candidate = params.filePath; + let candidate = params.filePath.startsWith("@") ? params.filePath.slice(1) : params.filePath; if (/^file:\/\//i.test(candidate)) { try { candidate = fileURLToPath(candidate); diff --git a/src/agents/pi-tools.read.workspace-root-guard.test.ts b/src/agents/pi-tools.read.workspace-root-guard.test.ts index 0e6f76109..3757e7a1f 100644 --- a/src/agents/pi-tools.read.workspace-root-guard.test.ts +++ b/src/agents/pi-tools.read.workspace-root-guard.test.ts @@ -61,6 +61,36 @@ describe("wrapToolWorkspaceRootGuardWithOptions", () => { }); }); + it("maps @-prefixed container workspace paths to host workspace root", async () => { + const { tool } = createToolHarness(); + const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, { + containerWorkdir: "/workspace", + }); + + await wrapped.execute("tc-at-container", { path: "@/workspace/docs/readme.md" }); + + expect(mocks.assertSandboxPath).toHaveBeenCalledWith({ + filePath: path.resolve(root, "docs", "readme.md"), + cwd: root, + root, + }); + }); + + it("normalizes @-prefixed absolute paths before guard checks", async () => { + const { tool } = createToolHarness(); + const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, { + containerWorkdir: "/workspace", + }); + + await wrapped.execute("tc-at-absolute", { path: "@/etc/passwd" }); + + expect(mocks.assertSandboxPath).toHaveBeenCalledWith({ + filePath: "/etc/passwd", + cwd: root, + root, + }); + }); + it("does not remap absolute paths outside the configured container workdir", async () => { const { tool } = createToolHarness(); const wrapped = wrapToolWorkspaceRootGuardWithOptions(tool, root, { diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index 969bc448c..6fe98ff03 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; import { expectReadWriteEditTools, getTextContent } from "./test-helpers/pi-tools-fs-helpers.js"; @@ -137,6 +138,19 @@ describe("workspace path resolution", () => { }); }); }); + + it("rejects @-prefixed absolute paths outside workspace when workspaceOnly is enabled", async () => { + await withTempDir("openclaw-ws-", async (workspaceDir) => { + const cfg: OpenClawConfig = { tools: { fs: { workspaceOnly: true } } }; + const tools = createOpenClawCodingTools({ workspaceDir, config: cfg }); + const { readTool } = expectReadWriteEditTools(tools); + + const outsideAbsolute = path.resolve(path.parse(workspaceDir).root, "outside-openclaw.txt"); + await expect( + readTool.execute("ws-read-at-prefix", { path: `@${outsideAbsolute}` }), + ).rejects.toThrow(/Path escapes sandbox root/i); + }); + }); }); describe("sandboxed workspace paths", () => { diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 31203715f..5b684bbe4 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -13,8 +13,12 @@ function normalizeUnicodeSpaces(str: string): string { return str.replace(UNICODE_SPACES, " "); } +function normalizeAtPrefix(filePath: string): string { + return filePath.startsWith("@") ? filePath.slice(1) : filePath; +} + function expandPath(filePath: string): string { - const normalized = normalizeUnicodeSpaces(filePath); + const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); if (normalized === "~") { return os.homedir(); } diff --git a/src/agents/sandbox/fs-paths.ts b/src/agents/sandbox/fs-paths.ts index 5de2f9e12..3073c6e84 100644 --- a/src/agents/sandbox/fs-paths.ts +++ b/src/agents/sandbox/fs-paths.ts @@ -227,7 +227,13 @@ function isPathInsidePosix(root: string, target: string): boolean { function isPathInsideHost(root: string, target: string): boolean { const canonicalRoot = resolveSandboxHostPathViaExistingAncestor(path.resolve(root)); - const canonicalTarget = resolveSandboxHostPathViaExistingAncestor(path.resolve(target)); + const resolvedTarget = path.resolve(target); + // Preserve the final path segment so pre-existing symlink leaves are validated + // by the dedicated symlink guard later in the bridge flow. + const canonicalTargetParent = resolveSandboxHostPathViaExistingAncestor( + path.dirname(resolvedTarget), + ); + const canonicalTarget = path.resolve(canonicalTargetParent, path.basename(resolvedTarget)); const rel = path.relative(canonicalRoot, canonicalTarget); if (!rel) { return true;