From 6343c101fdddc1837608ab5ee490d64359719817 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Sat, 30 May 2026 18:20:37 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(matrix-bridge):=203=20audit-found=20bug?= =?UTF-8?q?s=20=E2=80=94=20R2=20secret=20leak,=20E2EE/voice=20mention=20ga?= =?UTF-8?q?te,=20reverse-map=20backfill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 audit of the new code from tonight's 12 PRs (#21-#30). Three real bugs found and fixed in this PR. **#1 (HIGH) โ€” R2 archive basename can leak inline env-var values** `archiveBasename("Bash", { command: "OPENAI_API_KEY=sk-xxx node srv.js" })` used the first whitespace-delimited token (`OPENAI_API_KEY=sk-xxx`) as the R2 object's basename, which lands in the *public* r2.dev URL the bridge emits as `๐Ÿ“Ž View full output`. Any inline env-var assignment in front of a Bash command would publish the value to anyone who reads the chat. Fix: walk tokens skipping `KEY=value` env vars + `nohup`/`time`/`sudo`/ `exec`/`env` prefixes; defensively strip any trailing `=โ€ฆ` even if the regex missed something; cap the final basename at 40 chars. New test exercises the exact leak vector. **#2 (MED) โ€” mention gate skipped over E2EE and voice content** `shouldHandleMessage(roomId, ev)` was reading body/m.mentions from the OUTER ev.content. For E2EE rooms that's only the megolm ciphertext (no body/mentions). For voice messages it's `"Voice message.ogg"` (not the transcript). Result: in E2EE groups the bot stayed silent on explicit mentions; in any group with voice messages the bot couldn't trigger on the "Jada" wake-word even when the transcript started with it. Fix: introduce `mentionContent` alongside `body`/`msgtype`. For plain events it's `ev.content`; for E2EE it's `tryDecrypt`'s inner content; for voice it's a synthetic `{ msgtype: "m.text", body: transcript }`. Then call `shouldHandleMessage(roomId, { content: mentionContent })`. **#3 (MED) โ€” cross-room guard backward-compat hole** PR #29's guard only checks the `replica:{id}` reverse mapping; the 47 existing room mappings that pre-date the guard have no reverse, so the check is a no-op for them. Until each room respawns its replica (could be days), the contamination guard does nothing. Fix: opportunistic backfill on every successful follow-up โ€” if the reverse mapping is absent, write it (this room owns this replica). Race-safe: a colliding peer's NEXT follow-up sees mismatch and respawns. Same TTL as the forward mapping. **Validation** - 173/173 tests pass (+8 new โ€” archive basename security suite) - Typecheck clean - Deployed `43e13059` Co-Authored-By: itsablabla Co-Authored-By: Claude Opus 4.7 (1M context) --- replicas-matrix-bridge/src/dispatch.ts | 19 +++++++ replicas-matrix-bridge/src/listener.ts | 19 ++++++- .../src/output-archive.test.ts | 54 +++++++++++++++++++ replicas-matrix-bridge/src/output-archive.ts | 24 +++++++-- 4 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 replicas-matrix-bridge/src/output-archive.test.ts diff --git a/replicas-matrix-bridge/src/dispatch.ts b/replicas-matrix-bridge/src/dispatch.ts index 97576597..0b060502 100644 --- a/replicas-matrix-bridge/src/dispatch.ts +++ b/replicas-matrix-bridge/src/dispatch.ts @@ -124,6 +124,25 @@ export async function handleMatrixMessage( await watcherP; if (followUp.ok) { replicaId = existing; + // Opportunistic backfill of the reverse mapping for pre-PR-29 + // replicas that don't have one yet. The cross-room guard only + // fires when the reverse exists; writing it on the first + // successful follow-up after upgrade closes the protection + // gap for the 47 mappings that existed when the guard + // shipped. Race-safe: a colliding peer will see mismatch on + // its NEXT follow-up and respawn. + try { + const reverseSet = await env.MAP.get(`replica:${existing}`); + if (!reverseSet) { + const ttlEnv = parseInt(env.REPLICA_TTL_SECONDS, 10); + const opts: KVNamespacePutOptions = ttlEnv > 0 + ? { expirationTtl: Math.max(60, ttlEnv) } + : {}; + await env.MAP.put(`replica:${existing}`, roomId, opts); + } + } catch (e) { + console.log(`[dispatch] reverse-map backfill failed: ${e instanceof Error ? e.message : e}`); + } } else { // Any non-ok response (including the explicit `gone` 404/410 and // every other failure mode โ€” 429, 5xx, network blip, etc.) falls diff --git a/replicas-matrix-bridge/src/listener.ts b/replicas-matrix-bridge/src/listener.ts index d9e9fa9c..033565e2 100644 --- a/replicas-matrix-bridge/src/listener.ts +++ b/replicas-matrix-bridge/src/listener.ts @@ -420,6 +420,13 @@ export class MatrixListener { // branch below can fetch + decrypt + transcribe. Same shape // for plain and decrypted-megolm events. let audioContent: Record | undefined; + // Content that drives the mention gate / wake-word check. + // For plain m.room.message: ev.content (has body + m.mentions). + // For E2EE: the DECRYPTED inner content (outer wrapper has + // only the megolm ciphertext). For voice: synthetic with the + // transcript as body. Centralizing here so the dispatch gate + // downstream doesn't have to branch on encryption / voice. + let mentionContent: Record = ev.content ?? {}; if (ev.type === "m.room.message") { const content = ev.content ?? {}; @@ -436,6 +443,7 @@ export class MatrixListener { msgtype = content.msgtype as string | undefined; body = content.body as string | undefined; if (msgtype === "m.audio") audioContent = content; + mentionContent = content; } else if (ev.type === "m.room.encrypted") { // E2EE event โ€” try to decrypt using imported Megolm keys. const decrypted = await this.tryDecrypt(roomId, ev, megolmKeys); @@ -443,6 +451,9 @@ export class MatrixListener { msgtype = decrypted.msgtype; body = decrypted.body; if (decrypted.msgtype === "m.audio") audioContent = decrypted.content; + // Mention / wake-word gate must see the DECRYPTED content โ€” + // the outer ev.content carries only megolm ciphertext. + mentionContent = decrypted.content; } else { continue; } @@ -462,6 +473,12 @@ export class MatrixListener { body = transcribed; msgtype = "m.text"; cameFromVoice = true; + // Mention/wake-word gate runs against the TRANSCRIPT so a + // voice "Jada do X" in a group hits the wake-word path + // just like a typed "Jada do X". Without this, voice + // messages in groups were always being dropped because + // the encrypted audio content has no body/m.mentions. + mentionContent = { msgtype: "m.text", body }; } if (msgtype !== "m.text" || !body) continue; @@ -480,7 +497,7 @@ export class MatrixListener { // Gate auto-dispatch on room size + mention. In a 2-person // room (you + me), every message is for me. In a larger room // the bot should stay quiet unless it's been mentioned. - const shouldDispatch = await this.shouldHandleMessage(roomId, ev); + const shouldDispatch = await this.shouldHandleMessage(roomId, { content: mentionContent }); if (!shouldDispatch) continue; // Mirror mode: if the user's prompt was a voice message, the diff --git a/replicas-matrix-bridge/src/output-archive.test.ts b/replicas-matrix-bridge/src/output-archive.test.ts new file mode 100644 index 00000000..575b5ee3 --- /dev/null +++ b/replicas-matrix-bridge/src/output-archive.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { archiveBasename } from "./output-archive"; + +describe("archiveBasename", () => { + it("uses Read/Edit file_path basename", () => { + expect(archiveBasename("Read", { file_path: "/foo/bar/baz.ts" })).toBe("baz.ts.out.txt"); + }); + + it("sanitizes path special chars in file_path", () => { + const name = archiveBasename("Read", { file_path: "/etc/foo bar?.txt" }); + expect(name).not.toContain(" "); + expect(name).not.toContain("?"); + }); + + it("uses Bash command's first NON-env-var token", () => { + // SECURITY: this used to leak secrets when the agent inlined an + // env-var assignment in front of a command, e.g. + // `OPENAI_API_KEY=sk-xxx node srv.js` โ†’ basename + // `OPENAI_API_KEY_sk-xxx.out.txt` ending up in a public R2 URL. + const name = archiveBasename("Bash", { + command: "OPENAI_API_KEY=sk-xxx node srv.js", + }); + expect(name).toBe("node.out.txt"); + expect(name).not.toContain("OPENAI"); + expect(name).not.toContain("sk-xxx"); + }); + + it("skips multiple env vars + nohup prefix", () => { + const name = archiveBasename("Bash", { + command: "DEBUG=1 OPENAI_API_KEY=sk-xxx nohup bun build", + }); + expect(name).toBe("bun.out.txt"); + }); + + it("strips trailing =โ€ฆ defensively even if regex missed something", () => { + const name = archiveBasename("Bash", { command: "FOO=bar" }); + // FOO=bar matches env-var rule and is skipped โ†’ falls back to "bash" + expect(name).toBe("bash.out.txt"); + }); + + it("falls back to bash when command is empty", () => { + expect(archiveBasename("Bash", { command: "" })).toBe("bash.out.txt"); + }); + + it("falls back to tool name when no input", () => { + expect(archiveBasename("Glob", undefined)).toBe("Glob-output.txt"); + }); + + it("caps basename length to avoid URL bloat", () => { + const longCmd = "a".repeat(200); + const name = archiveBasename("Bash", { command: longCmd }); + expect(name.length).toBeLessThan(60); + }); +}); diff --git a/replicas-matrix-bridge/src/output-archive.ts b/replicas-matrix-bridge/src/output-archive.ts index c1e4d628..79f55a1d 100644 --- a/replicas-matrix-bridge/src/output-archive.ts +++ b/replicas-matrix-bridge/src/output-archive.ts @@ -90,12 +90,28 @@ export function archiveBasename( if (filePath) { const parts = filePath.split("/"); const base = parts[parts.length - 1] || "output.txt"; - return `${base}.out.txt`; + return `${base.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80) || "output.txt"}.out.txt`; } if (toolName === "Bash") { const cmd = typeof input?.command === "string" ? (input.command as string).trim() : ""; - const firstWord = cmd.split(/\s+/)[0] ?? "bash"; - return `${firstWord.replace(/[^a-zA-Z0-9._-]/g, "_") || "bash"}.out.txt`; + // SECURITY: walk tokens skipping env-var assignments (`KEY=value`) + // and known no-op prefixes so the basename reflects the actual + // command, not the secret values an inline env assignment might + // carry. Otherwise `OPENAI_API_KEY=sk-xxx node srv.js` would + // produce a public R2 URL containing the key. + const tokens = cmd.split(/\s+/); + let firstReal = "bash"; + for (const tok of tokens) { + if (/^[A-Z_][A-Z0-9_]*=/.test(tok)) continue; // env var assignment + if (/^(?:nohup|time|sudo|exec|env)$/.test(tok)) continue; + firstReal = tok; + break; + } + // Strip any trailing `=โ€ฆ` just in case the regex above missed + // something (defensive belt-and-suspenders). + if (firstReal.includes("=")) firstReal = firstReal.split("=")[0]!; + const base = firstReal.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40) || "bash"; + return `${base}.out.txt`; } - return `${toolName.replace(/[^a-zA-Z0-9._-]/g, "_") || "tool"}-output.txt`; + return `${toolName.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40) || "tool"}-output.txt`; } From b9ce203f6daf64e85a40ce99964fdcd591f690b5 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Sat, 30 May 2026 18:26:50 +0000 Subject: [PATCH 2/2] fix(bridges): R2 unguessable key + 30-day lifecycle + TG tables + Whisper budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps the remaining audit notes from the night's work โ€” 3 fixes, both bridges. **#1 R2 archive: unguessable key + 30-day lifecycle** The sha256-derived archive key let an attacker who could *guess* the content (e.g. the bytes of a public /etc/os-release dump) reconstruct the public r2.dev URL without ever seeing it. In E2EE rooms where the URL is the only thing crossing the encryption boundary, that's a real leak. Switched to a 128-bit random prefix (`crypto.getRandomValues(16)` โ†’ hex). Loses cross-room dedup, gains genuine unguessability. Also set a 30-day lifecycle rule on the `replicas-output-archive` bucket via the CF R2 API so objects auto-prune. Cost stays bounded as the bridge runs indefinitely. **#2 TG markdown tables โ†’ pre-formatted text** Telegram's HTML parse_mode doesn't support ``, but it does support `
`. Added a pre-pass to `markdown.ts` that detects GFM
tables (header row + `|---|---|` separator), computes column widths,
emits the same data inside a fenced code block with `โ”‚` / `โ”€โ”ผ` box-
drawing separators. The existing fenced-code path picks it up
unchanged. 2 new tests.

**#3 Whisper cost guardrail**

A determined sender could fire 25-minute voice messages back-to-back
to burn OpenAI credits at $0.006/min ร— 25 = $0.15/message. With
mirror-mode they'd ALSO trigger TTS replies. Added a per-room rolling
60-min budget tracked in DO storage: when the room has already used
> 30 minutes of Whisper time this hour, new voice msgs are skipped
(same UX as the no-API-key path).

Budget ledger keyed by `voice-budget:{roomId}` with entries pruned to
the rolling window on every check. Bounded storage.

**Validation**
- Matrix: 173/173 โœ… ยท typecheck clean ยท deployed `159cbb42`
- Telegram: 99/99 โœ… (+2 table tests) ยท typecheck clean ยท deployed `a9d1165f`
- R2 lifecycle verified live via CF API GET

Co-Authored-By: itsablabla 
Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 replicas-matrix-bridge/src/listener.ts        | 28 +++++++-
 replicas-matrix-bridge/src/output-archive.ts  | 33 ++++-----
 replicas-telegram-bridge/src/markdown.test.ts | 23 +++++++
 replicas-telegram-bridge/src/markdown.ts      | 67 +++++++++++++++++++
 4 files changed, 134 insertions(+), 17 deletions(-)

diff --git a/replicas-matrix-bridge/src/listener.ts b/replicas-matrix-bridge/src/listener.ts
index 033565e2..5ea543aa 100644
--- a/replicas-matrix-bridge/src/listener.ts
+++ b/replicas-matrix-bridge/src/listener.ts
@@ -868,10 +868,32 @@ export class MatrixListener {
 			console.log(`[listener] voice msg in room=${roomId} but OPENAI_API_KEY not set โ€” skipping`);
 			return undefined;
 		}
-		const info = content.info as { duration?: number; mimetype?: string } | undefined;
+		const info = content.info as { duration?: number; mimetype?: string; size?: number } | undefined;
 		const durationMs = info?.duration ?? 0;
 		const declaredMime = info?.mimetype ?? "audio/ogg";
 
+		// Cost guardrail: rate-limit Whisper transcribes to avoid an
+		// adversarial / runaway sender burning OpenAI credits. We track
+		// a rolling window of transcription seconds per room across the
+		// last 60 minutes; if total > VOICE_MINUTES_PER_HOUR_CAP, skip
+		// new voice messages and log. Caller falls through to "voice
+		// skipped" UX (same as no-API-key path).
+		const VOICE_MINUTES_PER_HOUR_CAP = 30;
+		const WINDOW_MS = 60 * 60 * 1000;
+		const ledgerKey = `voice-budget:${roomId}`;
+		const now = Date.now();
+		const ledger =
+			(await this.state.storage.get<{ ts: number; sec: number }[]>(ledgerKey)) ?? [];
+		const fresh = ledger.filter((e) => now - e.ts < WINDOW_MS);
+		const totalSec = fresh.reduce((acc, e) => acc + e.sec, 0);
+		const proposedSec = Math.max(0, Math.round(durationMs / 1000));
+		if (totalSec + proposedSec > VOICE_MINUTES_PER_HOUR_CAP * 60) {
+			console.log(
+				`[listener] voice budget exhausted room=${roomId} usedSec=${totalSec} thisSec=${proposedSec} cap=${VOICE_MINUTES_PER_HOUR_CAP * 60} โ€” skipping`,
+			);
+			return undefined;
+		}
+
 		const audio = await fetchVoiceAudio(matrixEnv(this.env), {
 			url: content.url as string | undefined,
 			file: content.file as EncryptedFile | undefined,
@@ -906,6 +928,10 @@ export class MatrixListener {
 		console.log(
 			`[listener] voice transcribed room=${roomId} duration=${durLabel} text=${JSON.stringify(transcript.slice(0, 80))}`,
 		);
+		// Persist the consumed seconds in the budget ledger so subsequent
+		// voice messages in this room debit against the same window.
+		fresh.push({ ts: now, sec: proposedSec });
+		await this.state.storage.put(ledgerKey, fresh);
 		return `๐ŸŽค (voice ${durLabel}) ${transcript}`;
 	}
 
diff --git a/replicas-matrix-bridge/src/output-archive.ts b/replicas-matrix-bridge/src/output-archive.ts
index 79f55a1d..1a1eeae2 100644
--- a/replicas-matrix-bridge/src/output-archive.ts
+++ b/replicas-matrix-bridge/src/output-archive.ts
@@ -3,11 +3,14 @@
 // the Done frame can surface a `๐Ÿ“Ž View full output` link instead of
 // dropping the rest of the content on the floor.
 //
-// Keying: `/`. Sha-based dedupes identical
-// outputs across rooms / sessions; basename is the tool's most likely
-// filename hint so a casual click in the browser sees a meaningful
-// name in the title bar. Public-read R2 bucket (configured in
-// wrangler.toml) so the URL works without auth headers.
+// Keying: `<128-bit random>/`. Random-prefixed so the URL is
+// genuinely unguessable โ€” an earlier sha256-derived scheme let an
+// attacker who could *guess* the content (e.g. the bytes of a public
+// /etc/os-release dump) reconstruct the URL without ever seeing it,
+// which leaks data in E2EE rooms where the URL is the only thing
+// crossing the encryption boundary. Loses cross-room dedup; gains
+// real unguessability. Lifecycle on the bucket prunes objects after
+// 30 days so cost stays bounded.
 
 export interface ArchiveEnv {
 	OUTPUT_ARCHIVE: R2Bucket;
@@ -25,13 +28,12 @@ const R2_PUBLIC_BASE = "https://pub-54d26cd2ad324055a4a573666935ce53.r2.dev";
 export const ARCHIVE_THRESHOLD_BYTES = 2_000;
 
 /**
- * SHA-256 of the input, hex-encoded. Used as the R2 key prefix for
- * dedupe across rooms.
+ * 32-hex-char unguessable random prefix (128 bits of entropy from
+ * crypto.getRandomValues). Used as the R2 key prefix so the public
+ * URL can't be constructed by guessing the content.
  */
-async function sha256Hex(s: string): Promise {
-	const buf = new TextEncoder().encode(s);
-	const hash = await crypto.subtle.digest("SHA-256", buf as BufferSource);
-	const bytes = new Uint8Array(hash);
+function randomPrefix(): string {
+	const bytes = crypto.getRandomValues(new Uint8Array(16));
 	let hex = "";
 	for (let i = 0; i < bytes.length; i++) {
 		hex += bytes[i]!.toString(16).padStart(2, "0");
@@ -42,8 +44,7 @@ async function sha256Hex(s: string): Promise {
 /**
  * Best-effort archive of a tool result. Returns the public r2.dev URL
  * on success, undefined on failure (caller falls through to inline
- * truncation). Idempotent โ€” re-archiving identical content is a
- * no-op put.
+ * truncation). Each call writes to a fresh random prefix โ€” no dedup.
  */
 export async function archiveLargeOutput(
 	env: ArchiveEnv,
@@ -53,14 +54,14 @@ export async function archiveLargeOutput(
 	if (!env.OUTPUT_ARCHIVE) return undefined;
 	if (content.length < ARCHIVE_THRESHOLD_BYTES) return undefined;
 	try {
-		const hex = await sha256Hex(content);
+		const prefix = randomPrefix();
 		// Strip path separators from the basename so an attacker can't
-		// climb the key namespace via `../`. The hash prefix is the
+		// climb the key namespace via `../`. The random prefix is the
 		// trust anchor; basename is purely cosmetic.
 		const cleanBase = suggestedBasename
 			.replace(/[^a-zA-Z0-9._-]/g, "_")
 			.slice(0, 80) || "output.txt";
-		const key = `${hex}/${cleanBase}`;
+		const key = `${prefix}/${cleanBase}`;
 		// httpMetadata content-type so a browser renders the blob inline
 		// instead of forcing a download โ€” most tool outputs are plain text.
 		await env.OUTPUT_ARCHIVE.put(key, content, {
diff --git a/replicas-telegram-bridge/src/markdown.test.ts b/replicas-telegram-bridge/src/markdown.test.ts
index 1d152581..1ed183d1 100644
--- a/replicas-telegram-bridge/src/markdown.test.ts
+++ b/replicas-telegram-bridge/src/markdown.test.ts
@@ -100,4 +100,27 @@ describe("markdownToTelegramHtml", () => {
 		const out = markdownToTelegramHtml("a\u0000PH0\u0000b");
 		expect(out).not.toContain("\u0000");
 	});
+
+	it("renders GFM tables as 
 with column-aligned ASCII", () => {
+		const md = [
+			"| Tool | Status |",
+			"|------|--------|",
+			"| read | done   |",
+			"| edit | pending |",
+		].join("\n");
+		const out = markdownToTelegramHtml(md);
+		expect(out).toContain("
");
+		// Each cell space-padded to widest in its column; separator uses
+		// box-drawing chars.
+		expect(out).toContain("Tool โ”‚ Status");
+		expect(out).toContain("โ”€โ”€โ”€โ”€โ”€โ”ผ");
+		expect(out).toContain("read โ”‚ done");
+		expect(out).toContain("edit โ”‚ pending");
+	});
+
+	it("ignores pipe-rows without a separator row", () => {
+		const out = markdownToTelegramHtml("| just | text |\nnot a separator");
+		expect(out).not.toContain("
");
+		expect(out).not.toContain("โ”‚");
+	});
 });
diff --git a/replicas-telegram-bridge/src/markdown.ts b/replicas-telegram-bridge/src/markdown.ts
index 59bbc469..a8cee5f9 100644
--- a/replicas-telegram-bridge/src/markdown.ts
+++ b/replicas-telegram-bridge/src/markdown.ts
@@ -39,6 +39,16 @@ export function markdownToTelegramHtml(md: string): string {
 	// spliced-back content.
 	md = md.replace(/\u0000/g, "");
 
+	// GFM markdown tables โ†’ ASCII-aligned 
 blocks. Telegram's HTML
+	// parse_mode doesn't support 
, so we render the same data as a + // monospaced pre-formatted block. Detect `| header | header |` rows + // followed by `|---|---|` separator + body rows; emit a `
` with
+	// each cell padded to the column's widest value. Runs BEFORE the
+	// fenced-code-block pass so the pre placeholder is registered
+	// alongside the other code blocks and survives the rest of the
+	// markdown passes intact.
+	md = renderGfmTables(md);
+
 	const placeholders: string[] = [];
 	const placeholder = (html: string): string => {
 		const key = `\u0000PH${placeholders.length}\u0000`;
@@ -110,3 +120,60 @@ export function markdownToTelegramHtml(md: string): string {
 function escapeHtml(s: string): string {
 	return s.replace(/&/g, "&").replace(//g, ">");
 }
+
+// Pre-pass that walks the markdown line by line, finds GFM tables, and
+// rewrites each into a triple-backtick block containing the ASCII-padded
+// table. The triple-backtick block is then consumed by the existing
+// fenced-code-block extractor, so the table survives unmangled through
+// the rest of the markdown passes.
+function renderGfmTables(md: string): string {
+	const lines = md.split("\n");
+	const out: string[] = [];
+	let i = 0;
+	while (i < lines.length) {
+		const line = lines[i]!;
+		const isRowLike = /^\s*\|.+\|?\s*$/.test(line);
+		const isSeparator =
+			i + 1 < lines.length &&
+			/^\s*\|?\s*:?-{2,}/.test(lines[i + 1]!) &&
+			/^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]!);
+		if (!isRowLike || !isSeparator) {
+			out.push(line);
+			i++;
+			continue;
+		}
+		const header = parseTableRow(line);
+		i += 2;
+		const body: string[][] = [];
+		while (i < lines.length && /^\s*\|.+\|?\s*$/.test(lines[i]!)) {
+			body.push(parseTableRow(lines[i]!));
+			i++;
+		}
+		// Compute column widths.
+		const widths: number[] = header.map((c) => c.length);
+		for (const row of body) {
+			for (let c = 0; c < row.length; c++) {
+				if ((row[c] ?? "").length > (widths[c] ?? 0)) widths[c] = row[c]!.length;
+			}
+		}
+		const pad = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length));
+		const renderRow = (cells: string[]): string =>
+			cells.map((c, idx) => pad(c, widths[idx] ?? c.length)).join(" โ”‚ ");
+		const sep = widths.map((w) => "โ”€".repeat(w)).join("โ”€โ”ผโ”€");
+		const renderedHeader = renderRow(header);
+		const renderedBody = body.map(renderRow);
+		out.push("```");
+		out.push(renderedHeader);
+		out.push(sep);
+		for (const r of renderedBody) out.push(r);
+		out.push("```");
+	}
+	return out.join("\n");
+}
+
+function parseTableRow(line: string): string[] {
+	let s = line.trim();
+	if (s.startsWith("|")) s = s.slice(1);
+	if (s.endsWith("|")) s = s.slice(0, -1);
+	return s.split("|").map((c) => c.trim());
+}