fix: enforce message context isolation

This commit is contained in:
Peter Steinberger
2026-01-13 01:03:23 +00:00
parent 0edbdb1948
commit ffc465394e
6 changed files with 164 additions and 5 deletions

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { runMessageAction } from "./message-action-runner.js";
const slackConfig = {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
} as ClawdbotConfig;
describe("runMessageAction context isolation", () => {
it("allows send when target matches current channel", async () => {
const result = await runMessageAction({
cfg: slackConfig,
action: "send",
params: {
provider: "slack",
to: "#C123",
message: "hi",
},
toolContext: { currentChannelId: "C123" },
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("blocks send when target differs from current channel", async () => {
await expect(
runMessageAction({
cfg: slackConfig,
action: "send",
params: {
provider: "slack",
to: "channel:C999",
message: "hi",
},
toolContext: { currentChannelId: "C123" },
dryRun: true,
}),
).rejects.toThrow(/Cross-context messaging denied/);
});
it("blocks thread-reply when channelId differs from current channel", async () => {
await expect(
runMessageAction({
cfg: slackConfig,
action: "thread-reply",
params: {
provider: "slack",
channelId: "C999",
message: "hi",
},
toolContext: { currentChannelId: "C123" },
dryRun: true,
}),
).rejects.toThrow(/Cross-context messaging denied/);
});
});

View File

@@ -1,4 +1,5 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { normalizeTargetForProvider } from "../../agents/pi-embedded-messaging.js";
import {
readNumberParam,
readStringArrayParam,
@@ -125,6 +126,56 @@ function parseButtonsParam(params: Record<string, unknown>): void {
}
}
const CONTEXT_GUARDED_ACTIONS = new Set<ProviderMessageActionName>([
"send",
"poll",
"thread-create",
"thread-reply",
"sticker",
]);
function resolveContextGuardTarget(
action: ProviderMessageActionName,
params: Record<string, unknown>,
): string | undefined {
if (!CONTEXT_GUARDED_ACTIONS.has(action)) return undefined;
if (action === "thread-reply" || action === "thread-create") {
return (
readStringParam(params, "channelId") ?? readStringParam(params, "to")
);
}
return readStringParam(params, "to") ?? readStringParam(params, "channelId");
}
function enforceContextIsolation(params: {
provider: ProviderId;
action: ProviderMessageActionName;
params: Record<string, unknown>;
toolContext?: ProviderThreadingToolContext;
}): void {
const currentTarget = params.toolContext?.currentChannelId?.trim();
if (!currentTarget) return;
if (!CONTEXT_GUARDED_ACTIONS.has(params.action)) return;
const target = resolveContextGuardTarget(params.action, params.params);
if (!target) return;
const normalizedTarget =
normalizeTargetForProvider(params.provider, target) ?? target.toLowerCase();
const normalizedCurrent =
normalizeTargetForProvider(params.provider, currentTarget) ??
currentTarget.toLowerCase();
if (!normalizedTarget || !normalizedCurrent) return;
if (normalizedTarget === normalizedCurrent) return;
throw new Error(
`Cross-context messaging denied: action=${params.action} target="${target}" while bound to "${currentTarget}" (provider=${params.provider}).`,
);
}
async function resolveProvider(
cfg: ClawdbotConfig,
params: Record<string, unknown>,
@@ -150,6 +201,13 @@ export async function runMessageAction(
readStringParam(params, "accountId") ?? input.defaultAccountId;
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
enforceContextIsolation({
provider,
action,
params,
toolContext: input.toolContext,
});
const gateway = input.gateway
? {
url: input.gateway.url,