fix(messages): authorize external targets before resolution

This commit is contained in:
joshavant
2026-07-24 17:22:13 -05:00
committed by Josh Avant
parent 153c4f088e
commit d8eb6aa8d3
3 changed files with 261 additions and 29 deletions
+93 -21
View File
@@ -43,6 +43,14 @@ type ChannelMessageActionDispatchContext = Omit<ChannelMessageActionContext, "ac
action: unknown;
};
type PreparedMessageActionReadContext = {
actionContext: ChannelMessageActionContext;
plugin: ChannelPlugin;
origin: ServerOwnedConversationReadOrigin;
actionPolicy: ChannelMessageActionReadPolicy;
enforcement: MessageActionReadEnforcement;
};
type ChannelMessageActionReadPolicy =
| { readonly kind: "none" }
| {
@@ -535,6 +543,54 @@ function canonicalizeExternalExactCurrentTarget(ctx: ChannelMessageActionContext
}
}
function prepareMessageActionReadContext(
ctx: ChannelMessageActionDispatchContext,
): PreparedMessageActionReadContext | undefined {
const actionPolicy = resolveChannelMessageActionReadPolicy(ctx.action);
if (!actionPolicy) {
return undefined;
}
const registration = resolveChannelPluginRegistration(ctx.channel);
if (!registration) {
return undefined;
}
const action = ctx.action as ChannelMessageActionName;
const origin = resolveServerOwnedConversationReadOrigin(ctx.conversationReadOrigin);
const actionContext: ChannelMessageActionContext = {
...ctx,
action,
conversationReadOrigin: origin,
};
return {
actionContext,
plugin: registration.plugin,
origin,
actionPolicy,
enforcement: resolveMessageActionReadEnforcement({
action,
channel: actionContext.channel,
pluginOrigin: registration.origin,
}),
};
}
function isExternalDelegatedMessageActionRead(
prepared: PreparedMessageActionReadContext | undefined,
): prepared is PreparedMessageActionReadContext & {
actionPolicy: Extract<ChannelMessageActionReadPolicy, { kind: "conversation-read" }>;
enforcement: Extract<MessageActionReadEnforcement, { kind: "host-exact-current" }> & {
pluginTrust: "external";
};
} {
return Boolean(
prepared &&
prepared.origin !== "direct-operator" &&
prepared.actionPolicy.kind === "conversation-read" &&
prepared.enforcement.kind === "host-exact-current" &&
prepared.enforcement.pluginTrust === "external",
);
}
/** The sole host chokepoint before any read-capable plugin callback runs. */
function enforceMessageActionConversationReadGate(params: {
ctx: ChannelMessageActionContext;
@@ -573,6 +629,40 @@ function enforceMessageActionConversationReadGate(params: {
}
}
/** Authorizes and canonicalizes external exact-current targets before target resolution. */
export function prepareExternalMessageActionTargetForResolution(
ctx: ChannelMessageActionDispatchContext,
): Record<string, unknown> {
const prepared = prepareMessageActionReadContext(ctx);
if (!isExternalDelegatedMessageActionRead(prepared)) {
return ctx.params;
}
// External target resolution can execute plugin directory/provider lookups.
// Establish exact-current authority before that boundary, then recheck at dispatch.
const authorizedActionContext = attachExternalCurrentTargetSibling({
ctx: prepared.actionContext,
plugin: prepared.plugin,
origin: prepared.origin,
actionPolicy: prepared.actionPolicy,
enforcement: prepared.enforcement,
});
enforceMessageActionConversationReadGate({
ctx: authorizedActionContext,
plugin: prepared.plugin,
origin: prepared.origin,
actionPolicy: prepared.actionPolicy,
enforcement: prepared.enforcement,
});
return authorizedActionContext.params;
}
/** Defers delegated external target interpretation to the attested Gateway boundary. */
export function shouldDeferExternalMessageActionTargetResolution(
ctx: ChannelMessageActionDispatchContext,
): boolean {
return isExternalDelegatedMessageActionRead(prepareMessageActionReadContext(ctx));
}
function requiresTrustedRequesterSender(
ctx: ChannelMessageActionContext,
plugin: ChannelPlugin,
@@ -591,33 +681,15 @@ function requiresTrustedRequesterSender(
export async function dispatchChannelMessageAction(
ctx: ChannelMessageActionDispatchContext,
): Promise<AgentToolResult<unknown> | null> {
const actionPolicy = resolveChannelMessageActionReadPolicy(ctx.action);
if (!actionPolicy) {
const prepared = prepareMessageActionReadContext(ctx);
if (!prepared) {
return null;
}
// The policy lookup is the runtime proof that this is a core-owned action.
const action = ctx.action as ChannelMessageActionName;
const registration = resolveChannelPluginRegistration(ctx.channel);
if (!registration) {
return null;
}
const { plugin } = registration;
const { actionContext, plugin, origin, actionPolicy, enforcement } = prepared;
const actions = plugin.actions;
if (!actions?.handleAction) {
return null;
}
const origin = resolveServerOwnedConversationReadOrigin(ctx.conversationReadOrigin);
const actionContext: ChannelMessageActionContext = {
...ctx,
action,
// Plugins receive only the closed server-normalized classification.
conversationReadOrigin: origin,
};
const enforcement = resolveMessageActionReadEnforcement({
action: actionContext.action,
channel: actionContext.channel,
pluginOrigin: registration.origin,
});
const authorizedActionContext = attachExternalCurrentTargetSibling({
ctx: actionContext,
plugin,
@@ -776,6 +776,120 @@ describe("runMessageAction plugin dispatch", () => {
);
});
it("canonicalizes an external exact-current alias before legacy target resolution", async () => {
const looksLikeId = vi.fn((raw: string) => !/^room:/i.test(raw));
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "actionhub",
source: "test",
origin: "config",
plugin: {
...actionHubPlugin,
messaging: {
targetPrefixes: ["actionhub"],
normalizeTarget: (raw: string) =>
raw.replace(/^room:/i, "actionhub:").replace(/^actionhub:/i, "actionhub:"),
targetResolver: {
looksLikeId,
},
},
},
},
]),
);
await runMessageAction({
cfg: {
channels: {
actionhub: {
enabled: true,
},
},
} as OpenClawConfig,
action: "pin",
params: {
channel: "actionhub",
target: "room:current",
messageId: "om_123",
},
defaultAccountId: "default",
requesterAccountId: "default",
conversationReadOrigin: "delegated",
toolContext: {
currentChannelId: "actionhub:current",
currentChannelProvider: "actionhub",
currentChatType: "group",
},
dryRun: false,
});
expect(looksLikeId).toHaveBeenCalledWith("actionhub:current", "actionhub:current");
const call = readFirstPluginCall(handleAction);
expectRecordFields(
readRecordField(call, "params", "normalized plugin params"),
{
target: "actionhub:current",
to: "actionhub:current",
},
"normalized plugin params",
);
});
it.each([false, true])(
"rejects an external exact-current alias with the wrong account before target resolution (dryRun=%s)",
async (dryRun) => {
const looksLikeId = vi.fn(() => true);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "actionhub",
source: "test",
origin: "config",
plugin: {
...actionHubPlugin,
messaging: {
...actionHubPlugin.messaging,
targetResolver: {
looksLikeId,
},
},
},
},
]),
);
await expect(
runMessageAction({
cfg: {
channels: {
actionhub: {
enabled: true,
},
},
} as OpenClawConfig,
action: "pin",
params: {
channel: "actionhub",
target: "room:current",
messageId: "om_123",
},
defaultAccountId: "other",
requesterAccountId: "default",
conversationReadOrigin: "delegated",
toolContext: {
currentChannelId: "actionhub:current",
currentChannelProvider: "actionhub",
currentChatType: "group",
},
dryRun,
}),
).rejects.toThrow("requires the exact current conversation and account");
expect(looksLikeId).not.toHaveBeenCalled();
expect(handleAction).not.toHaveBeenCalled();
},
);
it("preserves no-context owner Discord admin actions through the shared runner", async () => {
const handleDiscordAction = vi.fn(async (ctx: ChannelMessageActionContext) => {
const currentProvider = ctx.toolContext?.currentChannelProvider?.trim().toLowerCase();
@@ -892,12 +1006,18 @@ describe("runMessageAction plugin dispatch", () => {
local: true,
}),
);
const looksLikeId = vi.fn(() => true);
const gatewayPlugin = createGatewayActionPlugin({
pluginId: "gatewaychat",
label: "Gateway Chat",
blurb: "Gateway Chat reaction test plugin.",
actions: ["react"],
capabilities: { chatTypes: ["direct"], reactions: true },
messaging: {
targetResolver: {
looksLikeId,
},
},
handleAction: handleActionEntry,
});
setTestPlugin(gatewayPlugin, "gatewaychat");
@@ -965,6 +1085,7 @@ describe("runMessageAction plugin dispatch", () => {
expect(gatewayParams).not.toHaveProperty("requesterAccountId");
expect(gatewayParams).not.toHaveProperty("requesterSenderId");
expect(gatewayParams).not.toHaveProperty("toolContext");
expect(looksLikeId).not.toHaveBeenCalled();
expect(handleActionEntry).not.toHaveBeenCalled();
expectRecordFields(
result,
+47 -8
View File
@@ -26,7 +26,11 @@ import {
normalizeConversationReadInvocationOrigin,
type ConversationReadInvocationOrigin,
} from "../../channels/plugins/conversation-read-origin.js";
import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js";
import {
dispatchChannelMessageAction,
prepareExternalMessageActionTargetForResolution,
shouldDeferExternalMessageActionTargetResolution,
} from "../../channels/plugins/message-action-dispatch.js";
import type {
ChannelId,
ChannelMessageActionName,
@@ -1945,6 +1949,39 @@ export async function runMessageAction(
params.accountId = accountId;
}
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
const delegatesActionToGateway =
Boolean(input.gateway) &&
channelPlugin?.actions?.resolveExecutionMode?.({ action }) === "gateway";
const defersExternalTargetResolution =
delegatesActionToGateway &&
shouldDeferExternalMessageActionTargetResolution({
channel,
action,
cfg,
params,
accountId: accountId ?? undefined,
conversationReadOrigin: normalizeConversationReadInvocationOrigin(
input.conversationReadOrigin,
),
});
if (!delegatesActionToGateway) {
const authorization = input.messageActionAuthorization;
params = prepareExternalMessageActionTargetForResolution({
channel,
action,
cfg,
params,
accountId: accountId ?? undefined,
requesterAccountId:
authorization !== undefined
? authorization.requesterAccountId
: (input.requesterAccountId ?? undefined),
conversationReadOrigin: normalizeConversationReadInvocationOrigin(
input.conversationReadOrigin,
),
toolContext: authorization !== undefined ? authorization.toolContext : input.toolContext,
});
}
const normalizationPolicy = resolveAttachmentMediaPolicy({
sandboxRoot: input.sandboxRoot,
mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, resolvedAgentId),
@@ -2014,13 +2051,15 @@ export async function runMessageAction(
await hydrateActionAttachmentParams();
}
const resolvedTarget = await resolveActionTarget({
cfg,
channel,
action,
args: params,
accountId,
});
const resolvedTarget = defersExternalTargetResolution
? undefined
: await resolveActionTarget({
cfg,
channel,
action,
args: params,
accountId,
});
enforceCrossContextPolicy({
channel,