fix: cover slack open policy gating (#1563) (thanks @itsjaydesu)
This commit is contained in:
@@ -20,6 +20,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||||
|
|||||||
@@ -90,6 +90,18 @@ function safeJsonStringify(value: unknown): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatError(error: unknown): string | undefined {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
return safeJsonStringify(error) ?? "unknown error";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function digest(value: unknown): string | undefined {
|
function digest(value: unknown): string | undefined {
|
||||||
const serialized = safeJsonStringify(value);
|
const serialized = safeJsonStringify(value);
|
||||||
if (!serialized) return undefined;
|
if (!serialized) return undefined;
|
||||||
@@ -163,7 +175,7 @@ export function createAnthropicPayloadLogger(params: {
|
|||||||
options?.onPayload?.(payload);
|
options?.onPayload?.(payload);
|
||||||
};
|
};
|
||||||
return streamFn(model, context, {
|
return streamFn(model, context, {
|
||||||
...(options ?? {}),
|
...options,
|
||||||
onPayload: nextOnPayload,
|
onPayload: nextOnPayload,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -172,13 +184,14 @@ export function createAnthropicPayloadLogger(params: {
|
|||||||
|
|
||||||
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
|
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
|
||||||
const usage = findLastAssistantUsage(messages);
|
const usage = findLastAssistantUsage(messages);
|
||||||
|
const errorMessage = formatError(error);
|
||||||
if (!usage) {
|
if (!usage) {
|
||||||
if (error) {
|
if (errorMessage) {
|
||||||
record({
|
record({
|
||||||
...base,
|
...base,
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
stage: "usage",
|
stage: "usage",
|
||||||
error: String(error),
|
error: errorMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -188,7 +201,7 @@ export function createAnthropicPayloadLogger(params: {
|
|||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
stage: "usage",
|
stage: "usage",
|
||||||
usage,
|
usage,
|
||||||
error: error ? String(error) : undefined,
|
error: errorMessage,
|
||||||
});
|
});
|
||||||
log.info("anthropic usage", {
|
log.info("anthropic usage", {
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
|
|||||||
@@ -195,7 +195,13 @@ function sanitizeArgs(args: string | undefined): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove control characters (except newlines and tabs which may be intentional)
|
// Remove control characters (except newlines and tabs which may be intentional)
|
||||||
return args.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
let sanitized = "";
|
||||||
|
for (const char of args) {
|
||||||
|
const code = char.charCodeAt(0);
|
||||||
|
const isControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f;
|
||||||
|
if (!isControl) sanitized += char;
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
186
src/slack/monitor/slash.policy.test.ts
Normal file
186
src/slack/monitor/slash.policy.test.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { registerSlackMonitorSlashCommands } from "./slash.js";
|
||||||
|
|
||||||
|
const dispatchMock = vi.fn();
|
||||||
|
const readAllowFromStoreMock = vi.fn();
|
||||||
|
const upsertPairingRequestMock = vi.fn();
|
||||||
|
const resolveAgentRouteMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||||
|
dispatchReplyWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||||
|
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||||
|
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../routing/resolve-route.js", () => ({
|
||||||
|
resolveAgentRoute: (...args: unknown[]) => resolveAgentRouteMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../agents/identity.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../agents/identity.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveEffectiveMessagesConfig: () => ({ responsePrefix: "" }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function createHarness(overrides?: {
|
||||||
|
groupPolicy?: "open" | "allowlist";
|
||||||
|
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
||||||
|
channelId?: string;
|
||||||
|
channelName?: string;
|
||||||
|
}) {
|
||||||
|
const commands = new Map<unknown, (args: unknown) => Promise<void>>();
|
||||||
|
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
const app = {
|
||||||
|
client: { chat: { postEphemeral } },
|
||||||
|
command: (name: unknown, handler: (args: unknown) => Promise<void>) => {
|
||||||
|
commands.set(name, handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const channelId = overrides?.channelId ?? "C_UNLISTED";
|
||||||
|
const channelName = overrides?.channelName ?? "unlisted";
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
cfg: { commands: { native: false } },
|
||||||
|
runtime: {},
|
||||||
|
botToken: "bot-token",
|
||||||
|
botUserId: "bot",
|
||||||
|
teamId: "T1",
|
||||||
|
allowFrom: ["*"],
|
||||||
|
dmEnabled: true,
|
||||||
|
dmPolicy: "open",
|
||||||
|
groupDmEnabled: false,
|
||||||
|
groupDmChannels: [],
|
||||||
|
defaultRequireMention: true,
|
||||||
|
groupPolicy: overrides?.groupPolicy ?? "open",
|
||||||
|
useAccessGroups: true,
|
||||||
|
channelsConfig: overrides?.channelsConfig,
|
||||||
|
slashCommand: { enabled: true, name: "clawd", ephemeral: true, sessionPrefix: "slack:slash" },
|
||||||
|
textLimit: 4000,
|
||||||
|
app,
|
||||||
|
isChannelAllowed: () => true,
|
||||||
|
resolveChannelName: async () => ({ name: channelName, type: "channel" }),
|
||||||
|
resolveUserName: async () => ({ name: "Ada" }),
|
||||||
|
} as unknown;
|
||||||
|
|
||||||
|
const account = { accountId: "acct", config: { commands: { native: false } } } as unknown;
|
||||||
|
|
||||||
|
return { commands, ctx, account, postEphemeral, channelId, channelName };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dispatchMock.mockReset().mockResolvedValue({ counts: { final: 1, tool: 0, block: 0 } });
|
||||||
|
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||||
|
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
|
resolveAgentRouteMock.mockReset().mockReturnValue({
|
||||||
|
agentId: "main",
|
||||||
|
sessionKey: "session:1",
|
||||||
|
accountId: "acct",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("slack slash commands channel policy", () => {
|
||||||
|
it("allows unlisted channels when groupPolicy is open", async () => {
|
||||||
|
const { commands, ctx, account, channelId, channelName } = createHarness({
|
||||||
|
groupPolicy: "open",
|
||||||
|
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||||
|
channelId: "C_UNLISTED",
|
||||||
|
channelName: "unlisted",
|
||||||
|
});
|
||||||
|
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||||
|
|
||||||
|
const handler = [...commands.values()][0];
|
||||||
|
if (!handler) throw new Error("Missing slash handler");
|
||||||
|
|
||||||
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
|
await handler({
|
||||||
|
command: {
|
||||||
|
user_id: "U1",
|
||||||
|
user_name: "Ada",
|
||||||
|
channel_id: channelId,
|
||||||
|
channel_name: channelName,
|
||||||
|
text: "hello",
|
||||||
|
trigger_id: "t1",
|
||||||
|
},
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
respond,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(respond).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ text: "This channel is not allowed." }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks explicitly denied channels when groupPolicy is open", async () => {
|
||||||
|
const { commands, ctx, account, channelId, channelName } = createHarness({
|
||||||
|
groupPolicy: "open",
|
||||||
|
channelsConfig: { C_DENIED: { allow: false } },
|
||||||
|
channelId: "C_DENIED",
|
||||||
|
channelName: "denied",
|
||||||
|
});
|
||||||
|
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||||
|
|
||||||
|
const handler = [...commands.values()][0];
|
||||||
|
if (!handler) throw new Error("Missing slash handler");
|
||||||
|
|
||||||
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
|
await handler({
|
||||||
|
command: {
|
||||||
|
user_id: "U1",
|
||||||
|
user_name: "Ada",
|
||||||
|
channel_id: channelId,
|
||||||
|
channel_name: channelName,
|
||||||
|
text: "hello",
|
||||||
|
trigger_id: "t1",
|
||||||
|
},
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
respond,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dispatchMock).not.toHaveBeenCalled();
|
||||||
|
expect(respond).toHaveBeenCalledWith({
|
||||||
|
text: "This channel is not allowed.",
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
|
||||||
|
const { commands, ctx, account, channelId, channelName } = createHarness({
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||||
|
channelId: "C_UNLISTED",
|
||||||
|
channelName: "unlisted",
|
||||||
|
});
|
||||||
|
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||||
|
|
||||||
|
const handler = [...commands.values()][0];
|
||||||
|
if (!handler) throw new Error("Missing slash handler");
|
||||||
|
|
||||||
|
const respond = vi.fn().mockResolvedValue(undefined);
|
||||||
|
await handler({
|
||||||
|
command: {
|
||||||
|
user_id: "U1",
|
||||||
|
user_name: "Ada",
|
||||||
|
channel_id: channelId,
|
||||||
|
channel_name: channelName,
|
||||||
|
text: "hello",
|
||||||
|
trigger_id: "t1",
|
||||||
|
},
|
||||||
|
ack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
respond,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dispatchMock).not.toHaveBeenCalled();
|
||||||
|
expect(respond).toHaveBeenCalledWith({
|
||||||
|
text: "This channel is not allowed.",
|
||||||
|
response_type: "ephemeral",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user