mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 00:27:57 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
75
extensions/clickclack/src/access.ts
Normal file
75
extensions/clickclack/src/access.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user