-
Notifications
You must be signed in to change notification settings - Fork 17
bridge: convert Markdown to Slack mrkdwn in outbound messages #178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -34,6 +34,7 @@ import { | |||||||
| validateSendParams, | ||||||||
| validateReactParams, | ||||||||
| createRateLimiter, | ||||||||
| markdownToMrkdwn, | ||||||||
| } from "./security.mjs"; | ||||||||
|
|
||||||||
| // ── Config ────────────────────────────────────────────────────────────────── | ||||||||
|
|
@@ -467,14 +468,15 @@ function startApiServer() { | |||||||
| } | ||||||||
|
|
||||||||
| const { channel, text, thread_ts } = apiRequestBody; | ||||||||
| const safeText = markdownToMrkdwn(text); | ||||||||
| const result = await app.client.chat.postMessage({ | ||||||||
| token: process.env.SLACK_BOT_TOKEN, | ||||||||
| channel, | ||||||||
| text, | ||||||||
| text: safeText, | ||||||||
| ...(thread_ts && { thread_ts }), | ||||||||
| }); | ||||||||
|
|
||||||||
| console.log(`📤 Sent to ${channel}: ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`); | ||||||||
| console.log(`📤 Sent to ${channel}: ${safeText.slice(0, 80)}${safeText.length > 80 ? "..." : ""}`); | ||||||||
|
|
||||||||
| // If this is a threaded reply, check for a pending ✅ ack reaction. | ||||||||
| if (thread_ts) { | ||||||||
|
|
@@ -511,14 +513,15 @@ function startApiServer() { | |||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| const safeText = markdownToMrkdwn(text); | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same security issue as
Suggested change
Prompt To Fix With AIThis is a comment left during a code review.
Path: slack-bridge/bridge.mjs
Line: 516
Comment:
Same security issue as `/send` endpoint - needs `sanitizeOutboundText()` before markdown conversion
```suggestion
const sanitized = sanitizeOutboundText(text);
const safeText = sanitized.blocked ? sanitized.text : markdownToMrkdwn(sanitized.text);
```
How can I resolve this? If you propose a fix, please make it concise.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — same change as above, both endpoints now go through Responded by pi using anthropic/claude-sonnet-4-20250514. |
||||||||
| const result = await app.client.chat.postMessage({ | ||||||||
| token: process.env.SLACK_BOT_TOKEN, | ||||||||
| channel: thread.channel, | ||||||||
| text, | ||||||||
| text: safeText, | ||||||||
| thread_ts: thread.thread_ts, | ||||||||
| }); | ||||||||
|
|
||||||||
| console.log(`📤 Reply to ${thread_id} (${thread.channel}): ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`); | ||||||||
| console.log(`📤 Reply to ${thread_id} (${thread.channel}): ${safeText.slice(0, 80)}${safeText.length > 80 ? "..." : ""}`); | ||||||||
|
|
||||||||
| // Check for a pending ✅ ack reaction on the /reply path too. | ||||||||
| resolveAckReaction(thread.channel, thread.thread_ts); | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -252,6 +252,85 @@ export function formatForSlack(text) { | |
| return text; | ||
| } | ||
|
|
||
| /** | ||
| * Convert Markdown to Slack mrkdwn. | ||
| * | ||
| * The agent (LLM) naturally writes Markdown but Slack uses mrkdwn, which has | ||
| * different syntax. This converts the most common divergences: | ||
| * | ||
| * - **bold** / __bold__ → *bold* | ||
| * - *italic* / _italic_ → _italic_ (already valid — but must not | ||
| * collide with bold conversion) | ||
| * - ~~strikethrough~~ → ~strikethrough~ | ||
| * - [text](url) → <url|text> | ||
| * -  → <url|alt> (image links → plain link) | ||
| * - # Headings (h1–h6) → *Heading* (bolded line) | ||
| * - ```lang\ncode\n``` → ```\ncode\n``` (strip language hint) | ||
| * - `inline code` → `inline code` (already valid) | ||
| * - > blockquote → > blockquote (already valid) | ||
| * - Ordered list (1. item) → kept as-is (Slack renders plain text lists fine) | ||
| * - Horizontal rules (---, ***) → ───────── (unicode line) | ||
| * | ||
| * Designed to be safe/idempotent: already-valid mrkdwn passes through unchanged. | ||
| */ | ||
| export function markdownToMrkdwn(text) { | ||
| if (typeof text !== "string") return String(text); | ||
|
|
||
| // Protect fenced code blocks from transformation by extracting them first. | ||
| // Use a placeholder unlikely to appear in real text (U+FFFC OBJECT REPLACEMENT CHARACTER). | ||
| const PH = "\uFFFC"; | ||
| const codeBlocks = []; | ||
| let processed = text.replace(/```[^\n]*\n([\s\S]*?)```/g, (_match, code) => { | ||
| const index = codeBlocks.length; | ||
| codeBlocks.push(`\`\`\`\n${code}\`\`\``); | ||
| return `${PH}CODEBLOCK_${index}${PH}`; | ||
| }); | ||
|
|
||
| // Protect inline code spans from transformation. | ||
| const inlineCode = []; | ||
| processed = processed.replace(/`[^`\n]+`/g, (match) => { | ||
| const index = inlineCode.length; | ||
| inlineCode.push(match); | ||
| return `${PH}INLINE_${index}${PH}`; | ||
| }); | ||
|
|
||
| // Headings: "# Heading" → "*Heading*" (bold line) | ||
| // Only match at line start; support h1–h6. | ||
| processed = processed.replace(/^#{1,6}\s+(.+)$/gm, (_, heading) => `*${heading.trim()}*`); | ||
|
|
||
| // Images:  → <url|alt> (must come before link conversion) | ||
| processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => { | ||
| return alt ? `<${url}|${alt}>` : `<${url}>`; | ||
| }); | ||
|
|
||
| // Links: [text](url) → <url|text> | ||
| processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText, url) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The regex for converting markdown links incorrectly parses URLs that contain parentheses, leading to broken links in Slack messages. Suggested FixUpdate the link conversion regex to correctly handle parentheses within the URL part. A common approach is to allow for balanced parentheses within the URL capture group or to make the text part non-greedy and capture until the final parenthesis of the markdown link syntax. Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| return `<${url}|${linkText}>`; | ||
| }); | ||
|
|
||
| // Bold: **text** or __text__ → *text* | ||
| // Use negative lookbehind/ahead to avoid converting already-single-star patterns. | ||
| // Process ** first, then __ . | ||
| processed = processed.replace(/\*\*(.+?)\*\*/g, (_, content) => `*${content}*`); | ||
| processed = processed.replace(/__(.+?)__/g, (_, content) => `*${content}*`); | ||
|
|
||
| // Strikethrough: ~~text~~ → ~text~ | ||
| processed = processed.replace(/~~(.+?)~~/g, (_, content) => `~${content}~`); | ||
|
|
||
| // Horizontal rules: lines that are just ---, ***, or ___ (with optional spaces) | ||
| processed = processed.replace(/^[ \t]*([-*_])\1{2,}[ \t]*$/gm, "─────────"); | ||
|
|
||
| // Restore inline code spans. | ||
| const inlineRe = new RegExp(`${PH}INLINE_(\\d+)${PH}`, "g"); | ||
| processed = processed.replace(inlineRe, (_, index) => inlineCode[Number(index)]); | ||
|
|
||
| // Restore code blocks. | ||
| const blockRe = new RegExp(`${PH}CODEBLOCK_(\\d+)${PH}`, "g"); | ||
| processed = processed.replace(blockRe, (_, index) => codeBlocks[Number(index)]); | ||
|
|
||
| return processed; | ||
| } | ||
|
|
||
| // ── Bridge API Validation ─────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bridge.mjsbypassessanitizeOutboundText()security checks. Inbroker-bridge.mjs:701-711, markdown conversion happens after sanitization, but here it's applied directly to unsanitized text. This could allow sensitive data (API keys, tokens) to reach Slack if they're in the agent's output.Prompt To Fix With AI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — the legacy bridge was missing
sanitizeOutboundText()entirely (pre-existing gap). Added it now with the same block-or-sanitize-then-convert pattern from broker-bridge.Responded by pi using anthropic/claude-sonnet-4-20250514.