diff --git a/src/agents/embedded-agent-runner/run/recovery-message-action-capability.test.ts b/src/agents/embedded-agent-runner/run/recovery-message-action-capability.test.ts index 85b2f2318c32..035714198912 100644 --- a/src/agents/embedded-agent-runner/run/recovery-message-action-capability.test.ts +++ b/src/agents/embedded-agent-runner/run/recovery-message-action-capability.test.ts @@ -20,6 +20,9 @@ function createParams() { sessionId: "session-1", sessionKey: "agent:main:telegram:direct:chat-1", senderId: "user-1", + senderName: "User One", + senderUsername: "user-one", + senderE164: "+15551234567", timeoutMs: 60_000, }; } @@ -42,6 +45,9 @@ describe("createRecoveryMessageActionTurnCapability", () => { ).toMatchObject({ requesterAccountId: "work", requesterSenderId: "user-1", + requesterSenderName: "User One", + requesterSenderUsername: "user-one", + requesterSenderE164: "+15551234567", sourceReplySessionKey: "agent:main:telegram:direct:chat-1", toolContext: { currentChannelId: "chat-1", diff --git a/src/agents/embedded-agent-runner/run/recovery-message-action-capability.ts b/src/agents/embedded-agent-runner/run/recovery-message-action-capability.ts index 316810397a23..9c78ec0911a3 100644 --- a/src/agents/embedded-agent-runner/run/recovery-message-action-capability.ts +++ b/src/agents/embedded-agent-runner/run/recovery-message-action-capability.ts @@ -28,6 +28,9 @@ type RecoveryMessageActionCapabilityParams = Pick< | "sessionId" | "sessionKey" | "senderId" + | "senderName" + | "senderUsername" + | "senderE164" | "timeoutMs" >; @@ -56,6 +59,9 @@ export function createRecoveryMessageActionTurnCapability( sessionId: params.sessionId, requesterAccountId: params.agentAccountId, requesterSenderId: params.senderId ?? undefined, + requesterSenderName: params.senderName ?? undefined, + requesterSenderUsername: params.senderUsername ?? undefined, + requesterSenderE164: params.senderE164 ?? undefined, toolContext: { currentChannelId: params.currentChannelId, currentChatType: params.chatType, diff --git a/src/agents/requester-tool-policy.ts b/src/agents/requester-tool-policy.ts index d8c7b4c78381..66977ca01e17 100644 --- a/src/agents/requester-tool-policy.ts +++ b/src/agents/requester-tool-policy.ts @@ -246,6 +246,7 @@ export function resolveRequesterToolPolicies( ? resolveSenderToolPolicy({ config: params.config, agentId: params.agentId, + sessionKey: params.sessionKey, messageProvider: params.messageProvider, senderId: params.senderId, senderName: params.senderName, diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 386ac8facfc3..dba4038d4607 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -189,10 +189,10 @@ export function assertMediaNotDataUrl(media: string): void { } } -function isManagedMediaPathUnderRoot(candidate: string): boolean { +export function resolveManagedMediaRoot(candidate: string): string | undefined { const expanded = expandPath(candidate); if (!hostPathLooksAbsolute(expanded)) { - return false; + return undefined; } const mediaRoot = path.join(resolveConfigDir(), "media"); const resolvedMediaRoot = path.resolve(mediaRoot); @@ -201,18 +201,20 @@ function isManagedMediaPathUnderRoot(candidate: string): boolean { resolvedExpanded === resolvedMediaRoot || !isPathInside(resolvedMediaRoot, resolvedExpanded) ) { - return false; + return undefined; } const relative = path.relative(resolvedMediaRoot, resolvedExpanded); const firstSegment = relative.split(path.sep)[0] ?? ""; - return MANAGED_MEDIA_SUBDIRS.has(firstSegment) || firstSegment.startsWith("tool-"); + return MANAGED_MEDIA_SUBDIRS.has(firstSegment) || firstSegment.startsWith("tool-") + ? path.join(resolvedMediaRoot, firstSegment) + : undefined; } export async function resolveAllowedManagedMediaPath( candidate: string, ): Promise { const expanded = expandPath(candidate); - if (!isManagedMediaPathUnderRoot(expanded)) { + if (!resolveManagedMediaRoot(expanded)) { return undefined; } const resolved = path.resolve(expanded); diff --git a/src/agents/sender-tool-policy.ts b/src/agents/sender-tool-policy.ts index ec531bc31c68..355172085e19 100644 --- a/src/agents/sender-tool-policy.ts +++ b/src/agents/sender-tool-policy.ts @@ -5,6 +5,7 @@ */ import { resolveToolsBySender } from "../config/group-policy.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { parseSessionDeliveryRoute } from "../routing/session-key.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; import type { SandboxToolPolicy } from "./sandbox/types.js"; @@ -12,6 +13,7 @@ import type { SandboxToolPolicy } from "./sandbox/types.js"; type SenderToolPolicyParams = { config?: OpenClawConfig; agentId?: string; + sessionKey?: string | null; messageProvider?: string | null; senderId?: string | null; senderName?: string | null; @@ -27,8 +29,11 @@ export function resolveSenderToolPolicy( if (!cfg) { return undefined; } + // The requester session is authoritative when a message action targets a different channel. + const messageProvider = + parseSessionDeliveryRoute(params.sessionKey)?.channel ?? params.messageProvider; const sender = { - messageProvider: params.messageProvider, + messageProvider, senderId: params.senderId, senderName: params.senderName, senderUsername: params.senderUsername, diff --git a/src/agents/tools/message-tool-execution.ts b/src/agents/tools/message-tool-execution.ts index 08ac64ba6ed8..cb5ebf5b61c9 100644 --- a/src/agents/tools/message-tool-execution.ts +++ b/src/agents/tools/message-tool-execution.ts @@ -19,7 +19,7 @@ import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret- import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js"; import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { resolveMessageActionTurnCapability } from "../../gateway/message-action-turn-capability.js"; +import * as messageActionTurnCapability from "../../gateway/message-action-turn-capability.js"; import { createAbortError } from "../../infra/abort-signal.js"; import { sha256Base64UrlPrefix } from "../../infra/crypto-digest.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; @@ -370,7 +370,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const deliveryRunId = options?.runId ?? executionIdentityToken?.runId; const trustedTurnContext = resolvedAgentId && options?.agentSessionKey - ? resolveMessageActionTurnCapability({ + ? messageActionTurnCapability.resolveMessageActionTurnCapability({ token: options.messageActionTurnCapability, agentId: resolvedAgentId, runId: options.runId, @@ -652,8 +652,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { params: actionParams, actionOrigin: "message-tool", defaultAccountId: accountId ?? undefined, - requesterAccountId: trustedTurnContext?.requesterAccountId, - requesterSenderId: trustedTurnContext?.requesterSenderId, + ...messageActionTurnCapability.selectMessageActionRequesterIdentity(trustedTurnContext), messageActionAuthorization: { requesterAccountId: trustedTurnContext?.requesterAccountId, requesterSenderId: trustedTurnContext?.requesterSenderId, diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 9d4d998eeea4..bb6705882cf2 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -188,6 +188,9 @@ type RunMessageActionInput = { params?: Record; requesterAccountId?: string; requesterSenderId?: string; + requesterSenderName?: string; + requesterSenderUsername?: string; + requesterSenderE164?: string; runId?: string; messageActionAuthorization?: { requesterAccountId?: string; @@ -5131,6 +5134,9 @@ describe("message tool sandbox passthrough", () => { sessionId: "session-1", requesterAccountId: "trusted-account", requesterSenderId: "trusted-sender", + requesterSenderName: "Trusted Sender", + requesterSenderUsername: "trusted-user", + requesterSenderE164: "+15551234567", toolContext: { currentChannelProvider: "discord", currentChannelId: "trusted-current", @@ -5159,6 +5165,9 @@ describe("message tool sandbox passthrough", () => { expect(call?.requesterAccountId).toBe("trusted-account"); expect(call?.requesterSenderId).toBe("trusted-sender"); + expect(call?.requesterSenderName).toBe("Trusted Sender"); + expect(call?.requesterSenderUsername).toBe("trusted-user"); + expect(call?.requesterSenderE164).toBe("+15551234567"); expect(call?.toolContext).toMatchObject({ currentChannelProvider: "discord", currentChannelId: "forged-current", diff --git a/src/agents/web-search-tool-policy.ts b/src/agents/web-search-tool-policy.ts index 5276eebe2139..a59405d710d9 100644 --- a/src/agents/web-search-tool-policy.ts +++ b/src/agents/web-search-tool-policy.ts @@ -83,6 +83,7 @@ export function resolveWebSearchToolPolicy( const senderPolicyParams = { config: params.config, agentId, + sessionKey: params.sessionKey, messageProvider: params.messageProvider, }; const requesterPolicies = resolveRequesterToolPolicies({ diff --git a/src/auto-reply/reply/agent-runner-embedded-candidate.ts b/src/auto-reply/reply/agent-runner-embedded-candidate.ts index 1d538b61019d..9c1c99ada1d2 100644 --- a/src/auto-reply/reply/agent-runner-embedded-candidate.ts +++ b/src/auto-reply/reply/agent-runner-embedded-candidate.ts @@ -164,6 +164,9 @@ export async function runEmbeddedFallbackCandidate(params: { sessionId: embeddedContext.sessionId, requesterAccountId: embeddedContext.agentAccountId, requesterSenderId: senderContext.senderId, + requesterSenderName: senderContext.senderName, + requesterSenderUsername: senderContext.senderUsername, + requesterSenderE164: senderContext.senderE164, toolContext: { currentChannelId: embeddedContext.currentChannelId, currentChatType: embeddedContext.chatType, diff --git a/src/auto-reply/reply/commands-btw.ts b/src/auto-reply/reply/commands-btw.ts index 1d4d1e0db8b7..570c00828465 100644 --- a/src/auto-reply/reply/commands-btw.ts +++ b/src/auto-reply/reply/commands-btw.ts @@ -80,6 +80,9 @@ export const handleBtwCommand: CommandHandler = defineAuthorizedTextCommand( sessionId: targetSessionEntry.sessionId, requesterAccountId: params.ctx.AccountId, requesterSenderId: params.ctx.SenderId ?? params.command.senderId, + requesterSenderName: params.ctx.SenderName, + requesterSenderUsername: params.ctx.SenderUsername, + requesterSenderE164: params.ctx.SenderE164, toolContext: { currentChannelId, currentChatType: chatType, diff --git a/src/gateway/agent-runtime-identity-token.test.ts b/src/gateway/agent-runtime-identity-token.test.ts index 001e506c76d7..e67c8f88085a 100644 --- a/src/gateway/agent-runtime-identity-token.test.ts +++ b/src/gateway/agent-runtime-identity-token.test.ts @@ -463,6 +463,9 @@ describe("agent runtime identity token", () => { sessionId: "session-id-1", requesterAccountId: "ops", requesterSenderId: "sender-1", + requesterSenderName: "Sender One", + requesterSenderUsername: "sender-one", + requesterSenderE164: "+15551234567", toolContext: { currentChannelProvider: "matrix", currentChannelId: "!room:example.org", @@ -485,6 +488,9 @@ describe("agent runtime identity token", () => { sessionId: "session-id-1", requesterAccountId: "ops", requesterSenderId: "sender-1", + requesterSenderName: "Sender One", + requesterSenderUsername: "sender-one", + requesterSenderE164: "+15551234567", toolContext: { currentChannelProvider: "matrix", currentChannelId: "!room:example.org", diff --git a/src/gateway/agent-runtime-identity-token.ts b/src/gateway/agent-runtime-identity-token.ts index 9f28e8a427ce..403155d2dcf1 100644 --- a/src/gateway/agent-runtime-identity-token.ts +++ b/src/gateway/agent-runtime-identity-token.ts @@ -198,6 +198,9 @@ const messageActionContextSchema = z.object({ sourceReplySessionKey: ignoredOptionalStringSchema, requesterAccountId: ignoredOptionalStringSchema, requesterSenderId: ignoredOptionalStringSchema, + requesterSenderName: ignoredOptionalStringSchema, + requesterSenderUsername: ignoredOptionalStringSchema, + requesterSenderE164: ignoredOptionalStringSchema, toolContext: messageActionToolContextSchema.optional(), }); const cronSelfManagementContextSchema = z.object({ @@ -291,6 +294,9 @@ function decodeMessageActionContext( sourceReplySessionKey: value.sourceReplySessionKey, requesterAccountId: value.requesterAccountId, requesterSenderId: value.requesterSenderId, + requesterSenderName: value.requesterSenderName, + requesterSenderUsername: value.requesterSenderUsername, + requesterSenderE164: value.requesterSenderE164, toolContext: value.toolContext, }; if (value.sourceReplyFinal === true) { diff --git a/src/gateway/message-action-turn-capability.test.ts b/src/gateway/message-action-turn-capability.test.ts index cd16a13b3691..4a78bce238ee 100644 --- a/src/gateway/message-action-turn-capability.test.ts +++ b/src/gateway/message-action-turn-capability.test.ts @@ -24,6 +24,9 @@ describe("message action turn capability", () => { sessionId: "session-1", requesterAccountId: "ops", requesterSenderId: "@sender:example.org", + requesterSenderName: "Sender Name", + requesterSenderUsername: "sender-user", + requesterSenderE164: "+15551234567", toolContext: { currentChannelProvider: "matrix", currentChannelId: "!room-1:example.org", @@ -49,6 +52,9 @@ describe("message action turn capability", () => { sessionId: "session-1", requesterAccountId: "ops", requesterSenderId: "@sender:example.org", + requesterSenderName: "Sender Name", + requesterSenderUsername: "sender-user", + requesterSenderE164: "+15551234567", toolContext: { currentChannelProvider: "matrix", currentChannelId: "!room-1:example.org", diff --git a/src/gateway/message-action-turn-capability.ts b/src/gateway/message-action-turn-capability.ts index 1161245536e9..fa6811e2b03c 100644 --- a/src/gateway/message-action-turn-capability.ts +++ b/src/gateway/message-action-turn-capability.ts @@ -14,13 +14,19 @@ const MAX_ACTIVE_CAPABILITIES = 4096; const RUN_LIFETIME_EXPIRES_AT_MS = Number.MAX_SAFE_INTEGER; const CAPABILITY_COMPLETION_GRACE_MS = 60_000; -type AgentRuntimeMessageActionContextBase = { +type MessageActionRequesterIdentity = { + requesterAccountId?: string; + requesterSenderId?: string; + requesterSenderName?: string; + requesterSenderUsername?: string; + requesterSenderE164?: string; +}; + +type AgentRuntimeMessageActionContextBase = MessageActionRequesterIdentity & { expiresAtMs: number; sessionId?: string; /** Durable session entry that owns restart-recovery receipt state. */ sourceReplySessionKey?: string; - requesterAccountId?: string; - requesterSenderId?: string; toolContext?: InternalChannelThreadingToolContext; }; @@ -36,6 +42,18 @@ export type AgentRuntimeMessageActionContext = AgentRuntimeMessageActionContextB } ); +export function selectMessageActionRequesterIdentity( + context: MessageActionRequesterIdentity | undefined, +): MessageActionRequesterIdentity { + return { + requesterAccountId: context?.requesterAccountId, + requesterSenderId: context?.requesterSenderId, + requesterSenderName: context?.requesterSenderName, + requesterSenderUsername: context?.requesterSenderUsername, + requesterSenderE164: context?.requesterSenderE164, + }; +} + type MessageActionTurnCapability = AgentRuntimeMessageActionContext & { agentId: string; runId: string; @@ -112,6 +130,9 @@ export function mintMessageActionTurnCapability(params: { sessionId?: string; requesterAccountId?: string; requesterSenderId?: string; + requesterSenderName?: string; + requesterSenderUsername?: string; + requesterSenderE164?: string; toolContext?: InternalChannelThreadingToolContext; expiresWithRun?: boolean; ttlMs?: number; @@ -140,6 +161,9 @@ export function mintMessageActionTurnCapability(params: { sourceReplySessionKey: normalizeOptionalString(params.sourceReplySessionKey), requesterAccountId: normalizeOptionalString(params.requesterAccountId), requesterSenderId: normalizeOptionalString(params.requesterSenderId), + requesterSenderName: normalizeOptionalString(params.requesterSenderName), + requesterSenderUsername: normalizeOptionalString(params.requesterSenderUsername), + requesterSenderE164: normalizeOptionalString(params.requesterSenderE164), toolContext: copyToolContext(params.toolContext), }); return token; @@ -180,6 +204,9 @@ export function resolveMessageActionTurnCapability(params: { sourceReplySessionKey: capability.sourceReplySessionKey, requesterAccountId: capability.requesterAccountId, requesterSenderId: capability.requesterSenderId, + requesterSenderName: capability.requesterSenderName, + requesterSenderUsername: capability.requesterSenderUsername, + requesterSenderE164: capability.requesterSenderE164, toolContext: copyToolContext(capability.toolContext), }; } diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index cb32928f6923..74bafe841ea6 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -14,6 +14,8 @@ import { createDeferred } from "../../../test/helpers/promise.js"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.public.js"; import type { SessionTranscriptAppendResult } from "../../config/sessions/transcript.js"; +import { buildOutboundMediaLoadOptions } from "../../media/load-options.js"; +import { loadWebMediaRaw } from "../../media/web-media.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { AGENT_HARNESS_SESSION_KEY_RESERVED_MESSAGE } from "../../sessions/agent-harness-session-key.js"; import { @@ -319,6 +321,9 @@ async function runMessageActionRequest( sourceReplyToolCallId?: string; requesterAccountId?: string; requesterSenderId?: string; + requesterSenderName?: string; + requesterSenderUsername?: string; + requesterSenderE164?: string; toolContext?: Record; }; }; @@ -4065,7 +4070,7 @@ describe("gateway send mirroring", () => { expect(mocks.completeRestartRecoveryTerminalDelivery).not.toHaveBeenCalled(); }); - it("passes agent-scoped media roots to gateway message actions", async () => { + it("passes reader-free agent-scoped media access to gateway attachment actions", async () => { registerMessageActionPlugin({ action: "sendAttachment", registrySuffix: "message-action-media-roots", @@ -4085,7 +4090,12 @@ describe("gateway send mirroring", () => { expect(firstRespondCall(respond)[0]).toBe(true); const actionCall = lastDispatchChannelMessageActionCall(); expect(actionCall?.mediaLocalRoots).toContain(TEST_AGENT_WORKSPACE); - expect(actionCall).not.toHaveProperty("mediaAccess"); + expect(actionCall?.mediaAccess).toMatchObject({ + localRoots: expect.arrayContaining([TEST_AGENT_WORKSPACE]), + workspaceDir: TEST_AGENT_WORKSPACE, + }); + expect(actionCall?.mediaAccess.localRoots).toBe(actionCall?.mediaLocalRoots); + expect(actionCall?.mediaAccess).not.toHaveProperty("readFile"); expect(actionCall).not.toHaveProperty("mediaReadFile"); expect(actionCall?.gatewayClientScopes).toEqual(["operator.write"]); }); @@ -4169,6 +4179,103 @@ describe("gateway send mirroring", () => { expect(actionCall).not.toHaveProperty("mediaReadFile"); }); + it.each([ + { + action: "send" as const, + params: { to: "123", message: "chart" }, + }, + { + action: "sendAttachment" as const, + params: { chatId: "123" }, + }, + ])( + "applies signed sender aliases to gateway $action media policy", + async ({ action, params }) => { + registerMessageActionPlugin({ + action, + registrySuffix: `message-action-signed-sender-alias-policy-${action}`, + }); + const sessionKey = "agent:work:telegram:direct:123"; + + await withTempOpenClawStateDir(async (stateDir) => { + const workspaceFile = path.join( + TEST_AGENT_WORKSPACE, + `gateway-alias-denied-${process.pid}.bin`, + ); + const managedFile = path.join(stateDir, "media", "outbound", "managed.bin"); + await fs.mkdir(TEST_AGENT_WORKSPACE, { recursive: true }); + await fs.mkdir(path.dirname(managedFile), { recursive: true }); + await fs.writeFile(workspaceFile, "private"); + await fs.writeFile(managedFile, "managed"); + + try { + const { respond } = await runMessageActionRequest( + { + channel: "telegram", + action, + params: { ...params, mediaUrl: workspaceFile }, + requesterSenderId: "forged-allowed-sender", + sessionKey, + agentId: "work", + idempotencyKey: `idem-message-action-signed-sender-alias-policy-${action}`, + }, + { + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "work", + sessionKey, + messageActionContext: { + expiresAtMs: Date.now() + 60_000, + requesterSenderId: "allowed-id", + requesterSenderName: "Blocked Sender", + requesterSenderUsername: "blocked-user", + requesterSenderE164: "+15551234567", + }, + }, + }, + }, + { + ...makeContext(), + getRuntimeConfig: () => ({ + agents: { list: [{ id: "main" }, { id: "work" }] }, + tools: { + allow: ["read"], + toolsBySender: { "username:blocked-user": { deny: ["read"] } }, + }, + }), + } as GatewayRequestContext, + ); + + expect(firstRespondCall(respond)[0]).toBe(true); + const actionCall = lastDispatchChannelMessageActionCall(); + expect(actionCall).toMatchObject({ + requesterSenderId: "allowed-id", + requesterSenderName: "Blocked Sender", + requesterSenderUsername: "blocked-user", + requesterSenderE164: "+15551234567", + }); + if (action === "send") { + expect(actionCall?.params).toMatchObject({ mediaUrl: workspaceFile }); + expect(actionCall?.params).not.toHaveProperty("buffer"); + } + const mediaAccess = actionCall?.mediaAccess; + expect(mediaAccess.localRoots).not.toContain(TEST_AGENT_WORKSPACE); + await expect( + loadWebMediaRaw(workspaceFile, buildOutboundMediaLoadOptions({ mediaAccess })), + ).rejects.toThrow(/not under an allowed directory/i); + const managed = await loadWebMediaRaw( + managedFile, + buildOutboundMediaLoadOptions({ mediaAccess }), + ); + expect(managed.buffer.toString()).toBe("managed"); + } finally { + await fs.rm(workspaceFile, { force: true }); + } + }); + }, + ); + it("materializes buffer-only message.action sends on the gateway before plugin dispatch", async () => { registerMessageActionPlugin({ registrySuffix: "message-action-buffer-materialize" }); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 0e644ade7668..454df96e5407 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -74,6 +74,7 @@ import { } from "../../sessions/session-key-utils.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import { resolveGatewayConversationReadOrigin } from "../conversation-read-origin.js"; +import { selectMessageActionRequesterIdentity } from "../message-action-turn-capability.js"; import { ADMIN_SCOPE } from "../operator-scopes.js"; import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js"; import { DEDUPE_MAX, DEDUPE_TTL_MS } from "../server-constants.js"; @@ -207,17 +208,15 @@ function resolveTrustedMessageActionToolContext(params: { sessionId?: string; }; }): - | { + | ({ ok: true; toolContext: InternalChannelThreadingToolContext | undefined; - requesterAccountId: string | undefined; - requesterSenderId: string | undefined; sessionId: string | undefined; sourceReplySessionKey: string | undefined; sourceReplyFinal: boolean | undefined; sourceReplyToolCallId: string | undefined; runtimeAgentId: string | undefined; - } + } & ReturnType) | { ok: false; error: ReturnType } { // Current-turn metadata can relax channel read policy. It must come from the // signed ingress-issued turn context, never from message.action request fields. @@ -227,8 +226,7 @@ function resolveTrustedMessageActionToolContext(params: { return { ok: true, toolContext: undefined, - requesterAccountId: undefined, - requesterSenderId: undefined, + ...selectMessageActionRequesterIdentity(undefined), sessionId: undefined, sourceReplySessionKey: undefined, sourceReplyFinal: undefined, @@ -276,8 +274,7 @@ function resolveTrustedMessageActionToolContext(params: { return { ok: true, toolContext: messageActionContext.toolContext, - requesterAccountId: messageActionContext.requesterAccountId, - requesterSenderId: messageActionContext.requesterSenderId, + ...selectMessageActionRequesterIdentity(messageActionContext), sessionId: messageActionContext.sessionId, sourceReplySessionKey, sourceReplyFinal: messageActionContext.sourceReplyFinal, @@ -1002,29 +999,25 @@ export const sendHandlers: GatewayRequestHandlers = { if (accountId) { request.params.accountId = accountId; } - const resolvedMediaAccess = - request.action === "send" - ? resolveAgentScopedOutboundMediaAccess({ - cfg, - agentId, - sessionKey, - messageProvider: sessionKey ? undefined : channel, - accountId: sessionKey - ? (trustedContext.requesterAccountId ?? accountId) - : accountId, - requesterSenderId: trustedContext.requesterSenderId, - }) - : undefined; - // Gateway identities omit trusted sender aliases; expose roots/workspace - // only so a host reader cannot bypass alias-based group read policy. - const mediaAccess = resolvedMediaAccess - ? { - localRoots: resolvedMediaAccess.localRoots, - ...(resolvedMediaAccess.workspaceDir - ? { workspaceDir: resolvedMediaAccess.workspaceDir } - : {}), - } - : undefined; + const resolvedMediaAccess = resolveAgentScopedOutboundMediaAccess({ + cfg, + agentId, + sessionKey, + messageProvider: sessionKey ? undefined : channel, + accountId: sessionKey ? (trustedContext.requesterAccountId ?? accountId) : accountId, + requesterSenderId: trustedContext.requesterSenderId, + requesterSenderName: trustedContext.requesterSenderName, + requesterSenderUsername: trustedContext.requesterSenderUsername, + requesterSenderE164: trustedContext.requesterSenderE164, + }); + // Gateway actions receive policy-scoped roots/workspace only; the + // originating agent turn never delegates its host reader over RPC. + const mediaAccess = { + localRoots: resolvedMediaAccess.localRoots, + ...(resolvedMediaAccess.workspaceDir + ? { workspaceDir: resolvedMediaAccess.workspaceDir } + : {}), + }; if (request.action === "send") { await hydrateAttachmentParamsForAction({ cfg, @@ -1033,7 +1026,7 @@ export const sendHandlers: GatewayRequestHandlers = { args: request.params, action: "send", mediaPolicy: resolveAttachmentMediaPolicy({ - mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId), + mediaAccess: resolvedMediaAccess, }), }); } @@ -1080,8 +1073,7 @@ export const sendHandlers: GatewayRequestHandlers = { cfg, params: request.params, accountId, - requesterAccountId: trustedContext.requesterAccountId, - requesterSenderId: trustedContext.requesterSenderId, + ...selectMessageActionRequesterIdentity(trustedContext), senderIsOwner: gatewayClientScopes.includes(ADMIN_SCOPE) ? request.senderIsOwner === true : false, @@ -1090,9 +1082,8 @@ export const sendHandlers: GatewayRequestHandlers = { sessionId: normalizeOptionalString(request.sessionId) ?? undefined, inboundEventKind: request.inboundTurnKind, agentId, - ...(mediaAccess - ? { mediaAccess, mediaLocalRoots: mediaAccess.localRoots } - : { mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId) }), + mediaAccess, + mediaLocalRoots: mediaAccess.localRoots, toolContext: trustedContext.toolContext, dryRun: false, gatewayClientScopes, diff --git a/src/media/read-capability.test.ts b/src/media/read-capability.test.ts index 1e5d98418ab0..e846539ad7f6 100644 --- a/src/media/read-capability.test.ts +++ b/src/media/read-capability.test.ts @@ -4,10 +4,13 @@ import os from "node:os"; import path from "node:path"; import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../config/types.js"; import { readOutboundMediaFile } from "./bounded-read-file.js"; +import { buildOutboundMediaLoadOptions } from "./load-options.js"; import { getDefaultMediaLocalRoots } from "./local-roots.js"; import { resolveAgentScopedOutboundMediaAccess } from "./read-capability.js"; +import { loadWebMediaRaw } from "./web-media.js"; const channelPluginMocks = vi.hoisted(() => ({ getLoadedChannelPlugin: vi.fn< @@ -21,6 +24,8 @@ const channelPluginMocks = vi.hoisted(() => ({ >(() => undefined), })); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + vi.mock("../channels/plugins/index.js", () => ({ getChannelPlugin: () => undefined, getLoadedChannelPlugin: channelPluginMocks.getLoadedChannelPlugin, @@ -113,6 +118,156 @@ describe("resolveAgentScopedOutboundMediaAccess", () => { expect(result.localRoots).not.toContain("/Users/peter/Pictures"); }); + it.each([ + { + name: "global sender id", + cfg: { + tools: { + allow: ["read"], + toolsBySender: { "id:attacker": { deny: ["read"] } }, + }, + } as OpenClawConfig, + identity: { messageProvider: "requestchat", requesterSenderId: "attacker" }, + }, + { + name: "agent sender username", + cfg: { + tools: { allow: ["read"] }, + agents: { + list: [ + { + id: "restricted", + workspace: "/tmp/restricted-workspace", + tools: { + toolsBySender: { "username:blocked-user": { deny: ["read"] } }, + }, + }, + ], + }, + } as OpenClawConfig, + identity: { + agentId: "restricted", + messageProvider: "requestchat", + requesterSenderUsername: "blocked-user", + }, + }, + { + name: "session-derived channel sender id", + cfg: { + tools: { + allow: ["read"], + toolsBySender: { "channel:requestchat:attacker": { deny: ["read"] } }, + }, + } as OpenClawConfig, + identity: { + sessionKey: "agent:main:requestchat:group:ops", + requesterSenderId: "attacker", + }, + }, + { + name: "sender wildcard", + cfg: { + tools: { + allow: ["read"], + toolsBySender: { "*": { deny: ["read"] } }, + }, + } as OpenClawConfig, + identity: { messageProvider: "requestchat", requesterSenderId: "attacker" }, + }, + { + name: "sender wildcard without identity", + cfg: { + tools: { + allow: ["read"], + toolsBySender: { "*": { deny: ["read"] } }, + }, + } as OpenClawConfig, + identity: { messageProvider: "requestchat" }, + }, + ])("does not enable host reads for $name policy", ({ cfg, identity }) => { + const result = resolveAgentScopedOutboundMediaAccess({ + cfg, + ...identity, + mediaSources: ["/Users/peter/Pictures/photo.png"], + }); + + expect(result.readFile).toBeUndefined(); + expect(result.localRoots).not.toContain("/Users/peter/Pictures"); + }); + + it("keeps host reads enabled when agent sender policy allows the requester", () => { + const cfg: OpenClawConfig = { + tools: { + allow: ["read"], + toolsBySender: { "*": { deny: ["read"] } }, + }, + agents: { + list: [ + { + id: "trusted", + workspace: "/tmp/trusted-workspace", + tools: { toolsBySender: { "id:trusted-user": {} } }, + }, + ], + }, + }; + + const result = resolveAgentScopedOutboundMediaAccess({ + cfg, + agentId: "trusted", + messageProvider: "requestchat", + requesterSenderId: "trusted-user", + mediaSources: ["/Users/peter/Pictures/photo.png"], + }); + + expect(result.readFile).toBeTypeOf("function"); + expect(result.localRoots).toContain("/Users/peter/Pictures"); + }); + + it("blocks denied workspace attachments while preserving managed artifacts", async () => { + const baseDir = tempDirs.make("openclaw-media-sender-policy-"); + const stateDir = path.join(baseDir, "state"); + const workspaceDir = path.join(baseDir, "workspace"); + const workspaceFile = path.join(workspaceDir, "private.bin"); + const managedFile = path.join(stateDir, "media", "tool-image-generation", "result.bin"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + await fs.mkdir(path.dirname(managedFile), { recursive: true }); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(workspaceFile, "private"); + await fs.writeFile(managedFile, "managed"); + const cfg: OpenClawConfig = { + tools: { + allow: ["read"], + toolsBySender: { "id:attacker": { deny: ["read"] } }, + }, + agents: { list: [{ id: "restricted", workspace: workspaceDir }] }, + }; + + const deniedAccess = resolveAgentScopedOutboundMediaAccess({ + cfg, + agentId: "restricted", + messageProvider: "requestchat", + requesterSenderId: "attacker", + mediaSources: [workspaceFile], + }); + await expect( + loadWebMediaRaw(workspaceFile, buildOutboundMediaLoadOptions({ mediaAccess: deniedAccess })), + ).rejects.toThrow(/not under an allowed directory/i); + + const managedAccess = resolveAgentScopedOutboundMediaAccess({ + cfg, + agentId: "restricted", + messageProvider: "requestchat", + requesterSenderId: "attacker", + mediaSources: [managedFile], + }); + const loaded = await loadWebMediaRaw( + managedFile, + buildOutboundMediaLoadOptions({ mediaAccess: managedAccess }), + ); + expect(loaded.buffer.toString()).toBe("managed"); + }); + it("honors plugin-owned group tool policy with channel metadata", () => { const resolveToolPolicy = vi.fn(() => ({ deny: ["read"] })); channelPluginMocks.getLoadedChannelPlugin.mockReturnValue({ diff --git a/src/media/read-capability.ts b/src/media/read-capability.ts index 4da71b6e6d0b..a8f955605ab3 100644 --- a/src/media/read-capability.ts +++ b/src/media/read-capability.ts @@ -3,17 +3,17 @@ import path from "node:path"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { resolveGroupToolPolicy } from "../agents/agent-tools.policy.js"; import { resolvePathFromInput } from "../agents/path-policy.js"; +import { resolveManagedMediaRoot } from "../agents/sandbox-paths.js"; +import { resolveSenderToolPolicy } from "../agents/sender-tool-policy.js"; import { resolveEffectiveToolFsRootExpansionAllowed } from "../agents/tool-fs-policy.js"; import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; import { resolveWorkspaceRoot } from "../agents/workspace-dir.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveConfigDir } from "../utils.js"; import { createBoundedOutboundMediaReadFile } from "./bounded-read-file.js"; import type { OutboundMediaAccess, OutboundMediaReadFile } from "./load-options.js"; import { readLocalMediaFile } from "./local-media-access.js"; -import { - getAgentScopedMediaLocalRoots, - getAgentScopedMediaLocalRootsForSources, -} from "./local-roots.js"; +import { getAgentScopedMediaLocalRootsForSources } from "./local-roots.js"; type OutboundHostMediaPolicyContext = { sessionKey?: string; @@ -28,20 +28,12 @@ type OutboundHostMediaPolicyContext = { requesterSenderE164?: string | null; }; -function isAgentScopedHostMediaReadAllowed( +function isAgentScopedMediaReadAllowedByToolPolicy( params: { cfg: OpenClawConfig; agentId?: string; } & OutboundHostMediaPolicyContext, ): boolean { - if ( - !resolveEffectiveToolFsRootExpansionAllowed({ - cfg: params.cfg, - agentId: params.agentId, - }) - ) { - return false; - } const groupPolicy = resolveGroupToolPolicy({ config: params.cfg, sessionKey: params.sessionKey, @@ -55,8 +47,17 @@ function isAgentScopedHostMediaReadAllowed( senderUsername: params.requesterSenderUsername, senderE164: params.requesterSenderE164, }); - // Sender/group policy only applies when a concrete group override exists. - if (groupPolicy && !isToolAllowedByPolicies("read", [groupPolicy])) { + const senderPolicy = resolveSenderToolPolicy({ + config: params.cfg, + agentId: params.agentId, + sessionKey: params.sessionKey, + messageProvider: params.messageProvider, + senderId: params.requesterSenderId, + senderName: params.requesterSenderName, + senderUsername: params.requesterSenderUsername, + senderE164: params.requesterSenderE164, + }); + if (!isToolAllowedByPolicies("read", [groupPolicy, senderPolicy])) { return false; } return true; @@ -71,7 +72,13 @@ function createAgentScopedHostMediaReadFile( workspaceDir?: string; } & OutboundHostMediaPolicyContext, ): OutboundMediaReadFile | undefined { - if (!isAgentScopedHostMediaReadAllowed(params)) { + if ( + !resolveEffectiveToolFsRootExpansionAllowed({ + cfg: params.cfg, + agentId: params.agentId, + }) || + !isAgentScopedMediaReadAllowedByToolPolicy(params) + ) { return undefined; } const inferredWorkspaceDir = @@ -86,6 +93,17 @@ function createAgentScopedHostMediaReadFile( }); } +function getManagedMediaLocalRoots(mediaSources?: readonly string[]): readonly string[] { + const roots = new Set([path.join(resolveConfigDir(), "media", "outbound")]); + for (const source of mediaSources ?? []) { + const managedRoot = resolveManagedMediaRoot(source); + if (managedRoot) { + roots.add(managedRoot); + } + } + return Array.from(roots); +} + function appendWorkspaceDirToLocalRoots( roots: readonly string[] | undefined, workspaceDir?: string, @@ -118,39 +136,38 @@ export function resolveAgentScopedOutboundMediaAccess( params.workspaceDir ?? params.mediaAccess?.workspaceDir ?? (params.agentId ? resolveAgentWorkspaceDir(params.cfg, params.agentId) : undefined); - const hostMediaReadAllowed = isAgentScopedHostMediaReadAllowed(params); - // Even when host reads are denied, keep base roots so generated media remains addressable. - const baseLocalRoots = - params.mediaAccess?.localRoots ?? - (hostMediaReadAllowed - ? getAgentScopedMediaLocalRootsForSources({ - cfg: params.cfg, - agentId: params.agentId, - mediaSources: params.mediaSources, - }) - : getAgentScopedMediaLocalRoots(params.cfg, params.agentId)); - const localRoots = appendWorkspaceDirToLocalRoots(baseLocalRoots, resolvedWorkspaceDir); - const readFile = - params.mediaAccess?.readFile ?? - params.mediaReadFile ?? - (hostMediaReadAllowed - ? createAgentScopedHostMediaReadFile({ - cfg: params.cfg, - agentId: params.agentId, - localRoots: localRoots ?? [], - workspaceDir: resolvedWorkspaceDir, - sessionKey: params.sessionKey, - messageProvider: params.messageProvider, - groupId: params.groupId, - groupChannel: params.groupChannel, - groupSpace: params.groupSpace, - accountId: params.accountId, - requesterSenderId: params.requesterSenderId, - requesterSenderName: params.requesterSenderName, - requesterSenderUsername: params.requesterSenderUsername, - requesterSenderE164: params.requesterSenderE164, - }) - : undefined); + const mediaReadAllowed = isAgentScopedMediaReadAllowedByToolPolicy(params); + const baseLocalRoots = mediaReadAllowed + ? (params.mediaAccess?.localRoots ?? + getAgentScopedMediaLocalRootsForSources({ + cfg: params.cfg, + agentId: params.agentId, + mediaSources: params.mediaSources, + })) + : getManagedMediaLocalRoots(params.mediaSources); + const localRoots = mediaReadAllowed + ? appendWorkspaceDirToLocalRoots(baseLocalRoots, resolvedWorkspaceDir) + : baseLocalRoots; + const readFile = mediaReadAllowed + ? (params.mediaAccess?.readFile ?? + params.mediaReadFile ?? + createAgentScopedHostMediaReadFile({ + cfg: params.cfg, + agentId: params.agentId, + localRoots: localRoots ?? [], + workspaceDir: resolvedWorkspaceDir, + sessionKey: params.sessionKey, + messageProvider: params.messageProvider, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + accountId: params.accountId, + requesterSenderId: params.requesterSenderId, + requesterSenderName: params.requesterSenderName, + requesterSenderUsername: params.requesterSenderUsername, + requesterSenderE164: params.requesterSenderE164, + })) + : undefined; return { ...(localRoots?.length ? { localRoots } : {}), ...(readFile ? { readFile } : {}),