diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1c2b0d1a3..fdd138fc996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan. - Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan. - Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo. +- Sandbox/Media: map container workspace paths (`/workspace/...` and `file:///workspace/...`) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931. - Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake. - Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting. - Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756) diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index 67408536db8..de317320a80 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -62,6 +62,26 @@ describe("resolveSandboxedMediaSource", () => { }); }); + it("maps container /workspace absolute paths into sandbox root", async () => { + await withSandboxRoot(async (sandboxDir) => { + const result = await resolveSandboxedMediaSource({ + media: "/workspace/media/pic.png", + sandboxRoot: sandboxDir, + }); + expect(result).toBe(path.join(sandboxDir, "media", "pic.png")); + }); + }); + + it("maps file:// URLs under /workspace into sandbox root", async () => { + await withSandboxRoot(async (sandboxDir) => { + const result = await resolveSandboxedMediaSource({ + media: "file:///workspace/media/pic.png", + sandboxRoot: sandboxDir, + }); + expect(result).toBe(path.join(sandboxDir, "media", "pic.png")); + }); + }); + // Group 3: Rejections (security) it.each([ { @@ -69,6 +89,11 @@ describe("resolveSandboxedMediaSource", () => { media: "/etc/passwd", expected: /sandbox/i, }, + { + name: "paths under similarly named container roots", + media: "/workspace-two/secret.txt", + expected: /sandbox/i, + }, { name: "path traversal through tmpdir", media: path.join(os.tmpdir(), "..", "etc", "passwd"), diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 31a9653e62f..f848a1a4697 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -7,6 +7,7 @@ import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const HTTP_URL_RE = /^https?:\/\//i; const DATA_URL_RE = /^data:/i; +const SANDBOX_CONTAINER_WORKDIR = "/workspace"; function normalizeUnicodeSpaces(str: string): string { return str.replace(UNICODE_SPACES, " "); @@ -90,6 +91,13 @@ export async function resolveSandboxedMediaSource(params: { throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`); } } + const containerWorkspaceMapped = mapContainerWorkspacePath({ + candidate, + sandboxRoot: params.sandboxRoot, + }); + if (containerWorkspaceMapped) { + candidate = containerWorkspaceMapped; + } const tmpMediaPath = await resolveAllowedTmpMediaPath({ candidate, sandboxRoot: params.sandboxRoot, @@ -105,6 +113,25 @@ export async function resolveSandboxedMediaSource(params: { return sandboxResult.resolved; } +function mapContainerWorkspacePath(params: { + candidate: string; + sandboxRoot: string; +}): string | undefined { + const normalized = params.candidate.replace(/\\/g, "/"); + if (normalized === SANDBOX_CONTAINER_WORKDIR) { + return path.resolve(params.sandboxRoot); + } + const prefix = `${SANDBOX_CONTAINER_WORKDIR}/`; + if (!normalized.startsWith(prefix)) { + return undefined; + } + const rel = normalized.slice(prefix.length); + if (!rel) { + return path.resolve(params.sandboxRoot); + } + return path.resolve(params.sandboxRoot, ...rel.split("/").filter(Boolean)); +} + async function resolveAllowedTmpMediaPath(params: { candidate: string; sandboxRoot: string; diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 23c1fb8568b..e3f9a798ad5 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -585,6 +585,27 @@ describe("runMessageAction sandboxed media validation", () => { }); }); + it("rewrites /workspace media paths to host sandbox root", async () => { + await withSandbox(async (sandboxDir) => { + const result = await runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + media: "/workspace/data/file.txt", + message: "", + }, + sandboxRoot: sandboxDir, + }); + + expect(result.kind).toBe("send"); + if (result.kind !== "send") { + throw new Error("expected send result"); + } + expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "file.txt")); + }); + }); + it("rewrites MEDIA directives under sandbox", async () => { await withSandbox(async (sandboxDir) => { const result = await runDrySend({