fix(media): honor sender tool policy for outbound file reads (#125950)

* fix(media): enforce sender policy for outbound reads

* fix(media): block denied attachment fallbacks

* fix(gateway): preserve sender aliases for media policy

* fix(media): scope wildcard and attachment actions

* test(gateway): prove denied media stays unbuffered

* fix(gateway): scope send attachment hydration
This commit is contained in:
Josh Avant
2026-08-18 17:49:18 -05:00
committed by GitHub
parent 1d9cda833f
commit d7fe595ebb
18 changed files with 451 additions and 101 deletions
@@ -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",
@@ -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,
+1
View File
@@ -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,
+7 -5
View File
@@ -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<string | undefined> {
const expanded = expandPath(candidate);
if (!isManagedMediaPathUnderRoot(expanded)) {
if (!resolveManagedMediaRoot(expanded)) {
return undefined;
}
const resolved = path.resolve(expanded);
+6 -1
View File
@@ -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,
+3 -4
View File
@@ -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,
+9
View File
@@ -188,6 +188,9 @@ type RunMessageActionInput = {
params?: Record<string, unknown>;
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",
+1
View File
@@ -83,6 +83,7 @@ export function resolveWebSearchToolPolicy(
const senderPolicyParams = {
config: params.config,
agentId,
sessionKey: params.sessionKey,
messageProvider: params.messageProvider,
};
const requesterPolicies = resolveRequesterToolPolicies({
@@ -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,
+3
View File
@@ -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,
@@ -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",
@@ -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) {
@@ -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",
+30 -3
View File
@@ -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),
};
}
+109 -2
View File
@@ -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<string, unknown>;
};
};
@@ -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" });
+28 -37
View File
@@ -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<typeof selectMessageActionRequesterIdentity>)
| { ok: false; error: ReturnType<typeof errorShape> } {
// 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,
+155
View File
@@ -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({
+66 -49
View File
@@ -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 } : {}),