Slack: escape mrkdwn in interaction confirmations

This commit is contained in:
Colin
2026-02-16 13:57:04 -05:00
committed by Peter Steinberger
parent a7c1b8aea7
commit 7aaf1547df
4 changed files with 124 additions and 2 deletions

View File

@@ -220,6 +220,55 @@ describe("registerSlackInteractionEvents", () => {
);
});
it("escapes mrkdwn characters in confirmation labels", async () => {
enqueueSystemEventMock.mockReset();
const { ctx, app, getHandler } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
body: {
user: { id: "U556" },
channel: { id: "C1" },
message: {
ts: "111.223",
blocks: [{ type: "actions", block_id: "select_block", elements: [] }],
},
},
action: {
type: "static_select",
action_id: "openclaw:pick",
block_id: "select_block",
selected_option: {
text: { type: "plain_text", text: "Canary_*`~<&>" },
value: "canary",
},
},
});
expect(ack).toHaveBeenCalled();
expect(app.client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C1",
ts: "111.223",
blocks: [
{
type: "context",
elements: [
{
type: "mrkdwn",
text: ":white_check_mark: *Canary\\_\\*\\`\\~&lt;&amp;&gt;* selected by <@U556>",
},
],
},
],
}),
);
});
it("falls back to container channel and message timestamps", async () => {
enqueueSystemEventMock.mockReset();
const { ctx, app, getHandler, resolveSessionKey } = createContext();

View File

@@ -109,6 +109,15 @@ function uniqueNonEmptyStrings(values: string[]): string[] {
return unique;
}
function escapeSlackMrkdwn(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replace(/([*_`~])/g, "\\$1");
}
function collectRichTextFragments(value: unknown, out: string[]): void {
if (!value || typeof value !== "object") {
return;
@@ -289,7 +298,7 @@ function formatInteractionConfirmationText(params: {
userId?: string;
}): string {
const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : "";
return `:white_check_mark: *${params.selectedLabel}* selected${actor}`;
return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`;
}
function summarizeViewState(values: unknown): ModalInputSummary[] {

View File

@@ -6,6 +6,7 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
const reportCommand = { key: "report", nativeName: "report" };
const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" };
const reportLongCommand = { key: "reportlong", nativeName: "reportlong" };
const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" };
return {
buildCommandTextFromArgs: (
@@ -38,6 +39,9 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
if (normalized === "reportlong") {
return reportLongCommand;
}
if (normalized === "unsafeconfirm") {
return unsafeConfirmCommand;
}
return undefined;
},
listNativeCommandSpecsForConfig: () => [
@@ -65,6 +69,12 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
acceptsArgs: true,
args: [],
},
{
name: "unsafeconfirm",
description: "UnsafeConfirm",
acceptsArgs: true,
args: [],
},
],
parseCommandArgs: () => ({ values: {} }),
resolveCommandArgMenu: (params: {
@@ -120,6 +130,15 @@ vi.mock("../../auto-reply/commands-registry.js", () => {
],
};
}
if (params.command?.key === "unsafeconfirm") {
return {
arg: { name: "mode_*`~<&>", description: "mode" },
choices: [
{ value: "on", label: "on" },
{ value: "off", label: "off" },
],
};
}
if (params.command?.key !== "usage") {
return null;
}
@@ -230,6 +249,7 @@ describe("Slack native command argument menus", () => {
let reportHandler: (args: unknown) => Promise<void>;
let reportCompactHandler: (args: unknown) => Promise<void>;
let reportLongHandler: (args: unknown) => Promise<void>;
let unsafeConfirmHandler: (args: unknown) => Promise<void>;
let argMenuHandler: (args: unknown) => Promise<void>;
beforeAll(async () => {
@@ -256,6 +276,11 @@ describe("Slack native command argument menus", () => {
throw new Error("Missing /reportlong handler");
}
reportLongHandler = reportLong;
const unsafeConfirm = harness.commands.get("/unsafeconfirm");
if (!unsafeConfirm) {
throw new Error("Missing /unsafeconfirm handler");
}
unsafeConfirmHandler = unsafeConfirm;
const argMenu = harness.actions.get("openclaw_cmdarg");
if (!argMenu) {
@@ -376,6 +401,34 @@ describe("Slack native command argument menus", () => {
expect(element?.confirm).toBeTruthy();
});
it("escapes mrkdwn characters in confirm dialog text", async () => {
const respond = vi.fn().mockResolvedValue(undefined);
const ack = vi.fn().mockResolvedValue(undefined);
await unsafeConfirmHandler({
command: {
user_id: "U1",
user_name: "Ada",
channel_id: "C1",
channel_name: "directmessage",
text: "",
trigger_id: "t1",
},
ack,
respond,
});
expect(respond).toHaveBeenCalledTimes(1);
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
const actions = findFirstActionsBlock(payload);
const element = actions?.elements?.[0] as
| { confirm?: { text?: { text?: string } } }
| undefined;
expect(element?.confirm?.text?.text).toContain(
"Run */unsafeconfirm* with *mode\\_\\*\\`\\~&lt;&amp;&gt;* set to this value?",
);
});
it("dispatches the command when a menu button is clicked", async () => {
const respond = vi.fn().mockResolvedValue(undefined);
await argMenuHandler({

View File

@@ -47,12 +47,23 @@ function truncatePlainText(value: string, max: number): string {
return `${trimmed.slice(0, max - 1)}`;
}
function escapeSlackMrkdwn(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replace(/([*_`~])/g, "\\$1");
}
function buildSlackArgMenuConfirm(params: { command: string; arg: string }) {
const command = escapeSlackMrkdwn(params.command);
const arg = escapeSlackMrkdwn(params.arg);
return {
title: { type: "plain_text", text: "Confirm selection" },
text: {
type: "mrkdwn",
text: `Run */${params.command}* with *${params.arg}* set to this value?`,
text: `Run */${command}* with *${arg}* set to this value?`,
},
confirm: { type: "plain_text", text: "Run command" },
deny: { type: "plain_text", text: "Cancel" },