Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions replicas-matrix-bridge/src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion replicas-matrix-bridge/src/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,13 @@ export class MatrixListener {
// branch below can fetch + decrypt + transcribe. Same shape
// for plain and decrypted-megolm events.
let audioContent: Record<string, unknown> | 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<string, unknown> = ev.content ?? {};

if (ev.type === "m.room.message") {
const content = ev.content ?? {};
Expand All @@ -436,13 +443,17 @@ 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);
if (!decrypted) continue;
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;
}
Expand All @@ -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;
Expand All @@ -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
Expand Down
54 changes: 54 additions & 0 deletions replicas-matrix-bridge/src/output-archive.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
24 changes: 20 additions & 4 deletions replicas-matrix-bridge/src/output-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Loading