diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc503a0568..c115388c838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/clickclack/src/access.ts b/extensions/clickclack/src/access.ts new file mode 100644 index 00000000000..454a29b7406 --- /dev/null +++ b/extensions/clickclack/src/access.ts @@ -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 { + 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, + }; +} diff --git a/extensions/clickclack/src/gateway.test.ts b/extensions/clickclack/src/gateway.test.ts index fca19398fdf..594df75fe29 100644 --- a/extensions/clickclack/src/gateway.test.ts +++ b/extensions/clickclack/src/gateway.test.ts @@ -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; + }); }); diff --git a/extensions/clickclack/src/gateway.ts b/extensions/clickclack/src/gateway.ts index 235ace8294f..2517477a1f6 100644 --- a/extensions/clickclack/src/gateway.ts +++ b/extensions/clickclack/src/gateway.ts @@ -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( diff --git a/extensions/clickclack/src/inbound.test.ts b/extensions/clickclack/src/inbound.test.ts index 068d556abb8..356939348c6 100644 --- a/extensions/clickclack/src/inbound.test.ts +++ b/extensions/clickclack/src/inbound.test.ts @@ -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 { + 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 { + 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(); + }); }); diff --git a/extensions/clickclack/src/inbound.ts b/extensions/clickclack/src/inbound.ts index 1f94b742c43..a47e2ba78aa 100644 --- a/extensions/clickclack/src/inbound.ts +++ b/extensions/clickclack/src/inbound.ts @@ -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,