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..5ea543aa 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 @@ -851,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, @@ -889,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.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..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, { @@ -90,12 +91,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`; } 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());
+}