clickclack: enforce inbound sender allowlist [AI] (#83741)

* fix: enforce clickclack sender allowlist

* addressing codex review

* test(clickclack): drop removed senderIsOwner from inbound test fixture
This commit is contained in:
Michael Appel
2026-05-26 21:41:12 -04:00
committed by GitHub
parent 223655dfc4
commit 10546e57dd
6 changed files with 296 additions and 3 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Gateway: make the CPU scenario checker fail when completed Gateway runs report hot CPU observations instead of only writing them to artifacts.
- CLI: bound startup-memory probes so a hung startup command fails with timeout guidance instead of hanging the memory gate indefinitely.
- File transfer: wrap fetched file text and metadata as external content so untrusted contents cannot inject prompt instructions or spoof external-content markers.
- ClickClack: apply configured `allowFrom` sender allowlists before inbound agent dispatch so blocked senders cannot trigger model requests or command-authorized turns. Thanks @mmaps.
## 2026.5.26

View File

@@ -0,0 +1,75 @@
import {
resolveStableChannelMessageIngress,
type StableChannelIngressIdentityParams,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { getClickClackRuntime } from "./runtime.js";
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
const CHANNEL_ID = "clickclack" as const;
function normalizeClickClackUserId(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const withoutProvider = trimmed.replace(/^(clickclack|cc):/i, "").trim();
const directTarget = withoutProvider.match(/^dm:(.+)$/i);
return directTarget?.[1]?.trim() || withoutProvider || null;
}
const clickClackIngressIdentity = {
key: "user-id",
normalizeEntry: normalizeClickClackUserId,
normalizeSubject: normalizeClickClackUserId,
isWildcardEntry: (entry) => normalizeClickClackUserId(entry) === "*",
entryIdPrefix: "clickclack-user",
} satisfies StableChannelIngressIdentityParams;
export type ClickClackInboundAccess = {
shouldDispatch: boolean;
commandAuthorized: boolean;
};
export async function resolveClickClackInboundAccess(params: {
account: ResolvedClickClackAccount;
config: CoreConfig;
message: ClickClackMessage;
}): Promise<ClickClackInboundAccess> {
const runtime = getClickClackRuntime();
const isDirect = Boolean(params.message.direct_conversation_id);
const cfg = params.config as OpenClawConfig;
const shouldCheckCommand = runtime.channel.commands.shouldComputeCommandAuthorized(
params.message.body,
cfg,
);
const resolved = await resolveStableChannelMessageIngress({
channelId: CHANNEL_ID,
accountId: params.account.accountId,
identity: clickClackIngressIdentity,
cfg,
subject: { stableId: params.message.author_id },
conversation: {
kind: isDirect ? "direct" : "group",
id: isDirect
? (params.message.direct_conversation_id ?? params.message.author_id)
: (params.message.channel_id ?? params.message.thread_root_id),
},
allowFrom: params.account.allowFrom,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
command: shouldCheckCommand
? {
cfg,
modeWhenAccessGroupsOff: "configured",
}
: false,
});
return {
shouldDispatch: resolved.ingress.admission === "dispatch",
commandAuthorized: resolved.commandAccess.requested
? resolved.commandAccess.authorized
: resolved.senderAccess.allowed,
};
}

View File

@@ -19,9 +19,14 @@ const mocks = vi.hoisted(() => ({
thread: vi.fn(),
},
handleClickClackInbound: vi.fn(),
resolveClickClackInboundAccess: vi.fn(),
resolveWorkspaceId: vi.fn(),
}));
vi.mock("./access.js", () => ({
resolveClickClackInboundAccess: mocks.resolveClickClackInboundAccess,
}));
vi.mock("./http-client.js", () => ({
createClickClackClient: vi.fn(() => mocks.client),
}));
@@ -76,6 +81,10 @@ describe("ClickClack gateway", () => {
created_at: "2026-01-01T00:00:00.000Z",
});
mocks.client.events.mockResolvedValue([]);
mocks.resolveClickClackInboundAccess.mockResolvedValue({
shouldDispatch: true,
commandAuthorized: true,
});
mocks.resolveWorkspaceId.mockResolvedValue("workspace-1");
mocks.client.channelMessages.mockResolvedValue([
{
@@ -135,8 +144,47 @@ describe("ClickClack gateway", () => {
);
await vi.waitFor(() => expect(mocks.handleClickClackInbound).toHaveBeenCalledTimes(1));
expect(mocks.handleClickClackInbound.mock.calls[0]?.[0].access).toEqual({
shouldDispatch: true,
commandAuthorized: true,
});
abort.abort();
await run;
expect(runError).toBeUndefined();
});
it("drops messages denied by ClickClack sender access before inbound handling", async () => {
const socket = new FakeSocket();
mocks.client.websocket.mockReturnValue(socket);
mocks.resolveClickClackInboundAccess.mockResolvedValue({
shouldDispatch: false,
commandAuthorized: false,
});
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
const run = startClickClackGatewayAccount(ctx);
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
socket.emit(
"message",
Buffer.from(
JSON.stringify({
id: "evt-1",
cursor: "cursor-1",
type: "message.created",
workspace_id: "workspace-1",
channel_id: "chan-1",
seq: 2,
created_at: "2026-01-01T00:00:00.000Z",
payload: { message_id: "msg-1", author_id: "human-1" },
}),
),
);
await vi.waitFor(() => expect(mocks.resolveClickClackInboundAccess).toHaveBeenCalledTimes(1));
expect(mocks.handleClickClackInbound).not.toHaveBeenCalled();
abort.abort();
await run;
});
});

View File

@@ -1,5 +1,6 @@
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
import type { RawData } from "ws";
import { resolveClickClackInboundAccess } from "./access.js";
import { resolveClickClackAccount } from "./accounts.js";
import { createClickClackClient } from "./http-client.js";
import { handleClickClackInbound } from "./inbound.js";
@@ -93,7 +94,20 @@ async function processEvent(params: {
if (message.author?.kind === "bot") {
return;
}
await handleClickClackInbound({ account: params.account, config: params.config, message });
const access = await resolveClickClackInboundAccess({
account: params.account,
config: params.config,
message,
});
if (!access.shouldDispatch) {
return;
}
await handleClickClackInbound({
account: params.account,
config: params.config,
message,
access,
});
}
export async function startClickClackGatewayAccount(

View File

@@ -3,7 +3,7 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { describe, expect, it, vi } from "vitest";
import { handleClickClackInbound } from "./inbound.js";
import { setClickClackRuntime } from "./runtime.js";
import type { CoreConfig, ResolvedClickClackAccount } from "./types.js";
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
const sendClickClackTextMock = vi.hoisted(() => vi.fn());
@@ -72,6 +72,58 @@ function createRuntime(): PluginRuntime {
} as unknown as PluginRuntime);
}
function createAgentAccount(
overrides: Partial<ResolvedClickClackAccount> = {},
): ResolvedClickClackAccount {
const base = {
accountId: "default",
enabled: true,
configured: true,
baseUrl: "http://127.0.0.1:8080",
token: "ccb_default",
workspace: "wsp_1",
replyMode: "agent",
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["*"],
reconnectMs: 1_500,
config: {
allowFrom: ["*"],
},
} satisfies ResolvedClickClackAccount;
return {
...base,
...overrides,
config: {
...base.config,
...overrides.config,
},
};
}
function createMessage(overrides: Partial<ClickClackMessage> = {}): ClickClackMessage {
return {
id: "msg_1",
workspace_id: "wsp_1",
channel_id: "chn_1",
author_id: "usr_owner",
thread_root_id: "msg_1",
body: "/fast on",
body_format: "markdown",
created_at: "2026-05-09T12:00:00.000Z",
author: {
id: "usr_owner",
kind: "human",
display_name: "Peter",
handle: "steipete",
avatar_url: "",
created_at: "2026-05-09T12:00:00.000Z",
},
...overrides,
};
}
describe("handleClickClackInbound", () => {
it("runs model-mode bot accounts without tools and posts the bot reply", async () => {
sendClickClackTextMock.mockReset();
@@ -139,4 +191,95 @@ describe("handleClickClackInbound", () => {
expect(sendRequest?.text).toBe("service bot online");
expect(sendRequest?.replyToId).toBe("msg_1");
});
it("marks agent turns command-authorized for allowlisted senders", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["usr_owner"],
config: { allowFrom: ["usr_owner"] },
}),
config: cfg,
message: createMessage(),
});
const runPrepared = vi.mocked(runtime.channel.turn.runPrepared);
expect(runPrepared).toHaveBeenCalledTimes(1);
expect(runPrepared.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
});
it("accepts ClickClack DM target syntax in allowFrom", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["dm:usr_owner"],
config: { allowFrom: ["dm:usr_owner"] },
}),
config: cfg,
message: createMessage({
channel_id: undefined,
direct_conversation_id: "dcn_1",
}),
});
const runPrepared = vi.mocked(runtime.channel.turn.runPrepared);
expect(runPrepared).toHaveBeenCalledTimes(1);
expect(runPrepared.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
expect(runPrepared.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
});
it("does not dispatch agent turns from senders outside allowFrom", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["usr_owner"],
config: { allowFrom: ["usr_owner"] },
}),
config: cfg,
message: createMessage({
author_id: "usr_attacker",
author: {
id: "usr_attacker",
kind: "human",
display_name: "Attacker",
handle: "attacker",
avatar_url: "",
created_at: "2026-05-09T12:00:00.000Z",
},
}),
});
expect(runtime.channel.turn.runPrepared).not.toHaveBeenCalled();
expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveClickClackInboundAccess, type ClickClackInboundAccess } from "./access.js";
import { sendClickClackText } from "./outbound.js";
import { getClickClackRuntime } from "./runtime.js";
import { buildClickClackTarget } from "./target.js";
@@ -81,9 +82,20 @@ export async function handleClickClackInbound(params: {
account: ResolvedClickClackAccount;
config: CoreConfig;
message: ClickClackMessage;
access?: ClickClackInboundAccess;
}) {
const runtime = getClickClackRuntime();
const message = params.message;
const access =
params.access ??
(await resolveClickClackInboundAccess({
account: params.account,
config: params.config,
message,
}));
if (!access.shouldDispatch) {
return;
}
const isDirect = Boolean(message.direct_conversation_id);
const target = buildClickClackTarget(
isDirect
@@ -150,7 +162,7 @@ export async function handleClickClackInbound(params: {
Timestamp: message.created_at,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: target,
CommandAuthorized: true,
CommandAuthorized: access.commandAuthorized,
});
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg: params.config as OpenClawConfig,