mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 02:15:26 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user