You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Second-round audit, fresh angle (security / Matrix spec compliance / agent integration boundaries). Four high/medium findings shipped; four low-severity items deferred with rationale.
Findings shipped
HIGH — Admin/debug auth gate (src/index.ts)
Every /admin/*, /debug/*, /dispatch, and /start-listener route was unauthenticated.
/admin/vault/reset could DoS all E2EE state. /debug/vault/keystore leaked Megolm session counts per room. /dispatch let anyone spawn replicas + burn Anthropic credits.
Now gated behind ADMIN_TOKEN with timing-safe compare. Falls back to warn-and-allow when ADMIN_TOKEN is unset (migration mode) so existing operator curl flows keep working until the secret is set.
MED — m.replace edit dedup (src/listener.ts)
When a user edited a prior message the homeserver echoed a fresh m.room.message whose body was the fallback * <new content> string. Listener treated it as a new prompt and spawned a turn whose body literally started with * .
Fixed in both the unencrypted and decrypted-Megolm paths by skipping events whose m.relates_to.rel_type is m.replace.
MED — Member-count fail-closed (src/listener.ts)
cachedRoomMemberCount returned 2 ("treat as DM") on cold-cache + transient lookup failure, causing the bot to dispatch on every message in a 50-person room until the next successful lookup.
Now returns 100 (sentinel "definitely a group") so the mention gate stays active until membership is genuinely known.
LOW — Content-Type on Replicas POSTs (src/dispatch.ts, src/poller.ts)
replicasHeaders omitted Content-Type: application/json. Servers were auto-detecting JSON but explicit is correct.
Deferred
m.forwarded_room_key sender validation — Matrix E2EE spec recommends verifying the forwarder is a known device in the room. Currently any Olm-paired sender can plant Megolm keys for any room. Needs careful spec work to avoid breaking auto-recovery.
usage:org KV read-modify-write race — concurrent Done frames across rooms can drop entries. Cosmetic data only. Proper fix is routing through a Singleton DO.
Olm sessions cap=8 silent drop — .slice(-8) evicts oldest. Bump to 32 + log evictions.
/admin/recover-room missing-senderKey — add a warning log when senderKey is missing so the operator can investigate.
Validation
npm run typecheck ✅
npm test ✅ 97/97 tests pass
Deployed to https://replicas-matrix-bridge.jadengarza.workers.dev (version 0645cb26-2cfb-4250-bd3b-ae503543c395)
Test plan
Typecheck clean
Existing test suite passes
Deploy succeeds
After merge: wrangler secret put ADMIN_TOKEN to lock down endpoints
Smoke: send a test message, confirm normal turn flow still works
Smoke: edit a Matrix message, confirm bot does NOT respawn a turn
Smoke: hit /debug/listener without auth header → should log warning + allow (until ADMIN_TOKEN is set)
Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.
The reason will be displayed to describe this comment to others. Learn more.
🔴 drainPendingForSession unconditionally deletes queue even when items failed to decrypt
The drainPendingForSession method iterates over queued encrypted events and attempts to decrypt each. When decryption fails (line 543-544), it logs "leaving in queue" and continues — implying the entry should persist for future retry. However, after the loop exits, line 552 unconditionally deletes the entire queue key (await this.state.storage.delete(key)), permanently discarding any events that still couldn't be decrypted. This silently drops user messages that the log claims are being preserved.
Prompt for agents
In replicas-matrix-bridge/src/listener.ts, the drainPendingForSession method at line 552 unconditionally deletes the pending-decrypt queue after iterating through it, even though some entries may have failed to decrypt (logged as 'leaving in queue' at line 544). The fix should track which entries were successfully processed (dispatched or definitively skipped) versus which still failed to decrypt, and only delete the queue key if all entries were handled. Otherwise, write back the remaining undecryptable entries so they survive for the next drain attempt when a newer key might arrive. The simplest approach: maintain a 'stillPending' array, push items that fail to decrypt into it, and at the end either delete the key (if stillPending is empty) or write stillPending back to storage.
Was this helpful? React with 👍 or 👎 to provide feedback.
The reason will be displayed to describe this comment to others. Learn more.
🔴 drainPendingForSession unconditionally deletes queue even when items failed to decrypt
The drainPendingForSession method iterates over queued encrypted events and attempts to decrypt each. When decryption fails (line 543-544), it logs "leaving in queue" and continues — implying the entry should persist for future retry. However, after the loop exits, line 552 unconditionally deletes the entire queue key (await this.state.storage.delete(key)), permanently discarding any events that still couldn't be decrypted. This silently drops user messages that the log claims are being preserved.
Prompt for agents
In replicas-matrix-bridge/src/listener.ts, the drainPendingForSession method at line 552 unconditionally deletes the pending-decrypt queue after iterating through it, even though some entries may have failed to decrypt (logged as 'leaving in queue' at line 544). The fix should track which entries were successfully processed (dispatched or definitively skipped) versus which still failed to decrypt, and only delete the queue key if all entries were handled. Otherwise, write back the remaining undecryptable entries so they survive for the next drain attempt when a newer key might arrive. The simplest approach: maintain a 'stillPending' array, push items that fail to decrypt into it, and at the end either delete the key (if stillPending is empty) or write stillPending back to storage.
Was this helpful? React with 👍 or 👎 to provide feedback.
@devin-ai-integration[bot] This repository is not connected to a Replicas organization.
The reason will be displayed to describe this comment to others. Learn more.
🔴 drainPendingForSession passes encrypted wrapper to shouldHandleMessage instead of decrypted content
At line 548, shouldHandleMessage(roomId, synthetic) is called with synthetic containing the encrypted wrapper (algorithm, session_id, ciphertext, sender_key) — not the decrypted inner content. shouldHandleMessage (replicas-matrix-bridge/src/listener.ts:560-571) checks content["m.mentions"] and content.formatted_body for mention detection in group rooms (>2 members). Since the encrypted wrapper never contains these fields, mention detection always fails and messages in encrypted group rooms are silently dropped during queue drain.
The same bug was already identified and fixed in the recover-room path
At replicas-matrix-bridge/src/listener.ts:124-131, "Audit finding #8" explicitly addresses this: const synthetic = { content: inner.content ?? {} } — passing the DECRYPTED inner content. But drainPendingForSession at line 548 was not updated with the same fix.
Prompt for agents
In replicas-matrix-bridge/src/listener.ts drainPendingForSession(), line 548 passes the encrypted outer `synthetic` object to shouldHandleMessage(). The synthetic object has content = {algorithm, session_id, ciphertext, sender_key} which doesn't contain m.mentions or formatted_body, so mention detection in group rooms (>2 members) always fails.
The fix should mirror the recover-room path (line 131, audit finding #8): after tryDecrypt succeeds and returns decrypted content, construct a synthetic event using the DECRYPTED inner content for the shouldHandleMessage call. The tryDecrypt method returns {msgtype, body} but doesn't return the full inner content object. You'll need to either: (a) change tryDecrypt to also return the full inner content, or (b) reconstruct a minimal synthetic with the known fields, or (c) for 2-person rooms (the common case for DMs) this already works because shouldHandleMessage returns true for memberCount <= 2. The bug only affects encrypted group rooms during the drain path.
Was this helpful? React with 👍 or 👎 to provide feedback.
The reason will be displayed to describe this comment to others. Learn more.
🔴 drainPendingForSession passes encrypted wrapper to shouldHandleMessage instead of decrypted content
At line 548, shouldHandleMessage(roomId, synthetic) is called with synthetic containing the encrypted wrapper (algorithm, session_id, ciphertext, sender_key) — not the decrypted inner content. shouldHandleMessage (replicas-matrix-bridge/src/listener.ts:560-571) checks content["m.mentions"] and content.formatted_body for mention detection in group rooms (>2 members). Since the encrypted wrapper never contains these fields, mention detection always fails and messages in encrypted group rooms are silently dropped during queue drain.
The same bug was already identified and fixed in the recover-room path
At replicas-matrix-bridge/src/listener.ts:124-131, "Audit finding #8" explicitly addresses this: const synthetic = { content: inner.content ?? {} } — passing the DECRYPTED inner content. But drainPendingForSession at line 548 was not updated with the same fix.
Prompt for agents
In replicas-matrix-bridge/src/listener.ts drainPendingForSession(), line 548 passes the encrypted outer `synthetic` object to shouldHandleMessage(). The synthetic object has content = {algorithm, session_id, ciphertext, sender_key} which doesn't contain m.mentions or formatted_body, so mention detection in group rooms (>2 members) always fails.
The fix should mirror the recover-room path (line 131, audit finding #8): after tryDecrypt succeeds and returns decrypted content, construct a synthetic event using the DECRYPTED inner content for the shouldHandleMessage call. The tryDecrypt method returns {msgtype, body} but doesn't return the full inner content object. You'll need to either: (a) change tryDecrypt to also return the full inner content, or (b) reconstruct a minimal synthetic with the known fields, or (c) for 2-person rooms (the common case for DMs) this already works because shouldHandleMessage returns true for memberCount <= 2. The bug only affects encrypted group rooms during the drain path.
Was this helpful? React with 👍 or 👎 to provide feedback.
@devin-ai-integration[bot] This repository is not connected to a Replicas organization.
Second-round audit, fresh angle (security / Matrix spec / agent integration).
Four high/medium findings shipped:
1. **Admin/debug auth gate** — every /admin/*, /debug/*, /dispatch, and
/start-listener route was unauthenticated. /admin/vault/reset alone
could DoS all E2EE state; /debug/vault/keystore leaked Megolm session
counts; /dispatch let anyone spawn replicas + burn Anthropic credits.
Now gated behind ADMIN_TOKEN with timing-safe compare. Falls back to
warn-and-allow when ADMIN_TOKEN is unset (migration mode) so existing
operator curl flows keep working until the secret is set.
2. **m.replace edit dedup** — when a user edited a prior message the
homeserver echoed a fresh m.room.message whose body was the fallback
`* <new content>` string. Listener treated it as a new prompt and
spawned a turn whose body literally started with `* `. Fixed in both
the unencrypted and decrypted-Megolm paths by skipping events whose
m.relates_to.rel_type is m.replace.
3. **Member-count fail-closed** — `cachedRoomMemberCount` returned 2
("treat as DM") on cold-cache + transient lookup failure, causing the
bot to dispatch on every message in a 50-person room until the next
successful lookup. Now returns 100 (sentinel "definitely a group") so
the mention gate stays active until membership is genuinely known.
4. **Content-Type on Replicas POSTs** — replicasHeaders omitted
Content-Type entirely. Servers were auto-detecting JSON but explicit
is correct. Added in both src/dispatch.ts and src/poller.ts.
Deferred (low severity / requires deeper spec work):
- m.forwarded_room_key sender validation (Matrix E2EE spec recommends
verifying the forwarder is a known device in the room — currently any
Olm-paired sender can plant Megolm keys for any room)
- usage:org KV read-modify-write race across rooms (cosmetic data only)
- Olm sessions cap=8 silent drop (bump to 32 + log evictions)
- /admin/recover-room missing-senderKey warning log
Typecheck clean. 97/97 tests pass.
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes out the deferred-with-rationale items from rounds 1–3 (commits
2313473, PR #18, PR #19). Every item now has a real code fix.
**Round 1 (correctness/lifecycle) — 4 items**
#1. pending-decrypt:* prune cron — listener alarm now runs a 30-min
periodic prune that drops queue entries older than 7 days and deletes
empty queues. Bounds long-term DO storage growth from sessions whose
forwarders went offline before responding.
#2. seen:* KV race redesign — dispatch dedupe was using KV's eventual
read-modify-write, letting two concurrent reads both see "absent" and
both proceed. New /dedup-claim endpoint on MatrixListener wraps the
check + write in blockConcurrencyWhile for genuine put-if-absent
semantics. Periodic prune handles cleanup (1h TTL); KV fallback
preserved for the case where the listener DO is unreachable.
#3. /admin/listener/reload-keys endpoint — clears the in-memory
cachedKeys + cachedCurve25519 so the operator can rotate Megolm
session keys (MATRIX_MEGOLM_KEYS_JSON) without a full redeploy.
#4. parsePlan hijack tightening — only accept Plan(d/t) headers
BEFORE any tool calls and before any prior plan has been parsed.
Prevents a confused/adversarial agent from overwriting in-progress
view by emitting `Plan (5/5)` mid-turn to fake completion.
**Round 2 (security/spec/integration) — 4 items**
#5. m.forwarded_room_key validation — vault now tags captured Megolm
keys as `forwarded` vs `live`; listener checks `key-request:*`
prefix to confirm we previously asked for the (room, session). If
not, calls new vault `/keystore-delete` to evict the unsolicited
key. Closes the "any Olm-paired sender can plant Megolm keys for any
room" hole.
#6. usage:org KV race — routed through OlmVault singleton DO via
new /usage-bump (write, atomic) and /usage-read (read) endpoints.
Concurrent Done frames across rooms can no longer drop entries via
read-modify-write race.
#7. Olm session cap bump — was .slice(-8), now .slice(-32). Verified
multi-device senders rotate fast enough that 8 was occasionally
dropping legitimate live sessions. Evictions are now logged so the
operator can spot pathological churn.
#8. /admin/recover-room missing-senderKey warning — surface bridges
that strip the outer sender_key field (m.room_key_request routing
degrades to `*` when missing, may not land on the originating device).
Typecheck clean. 102/102 tests pass.
Rolling tally tonight: 19 findings · 19 shipped · 0 deferred.
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes out the deferred-with-rationale items from rounds 1–3 (commits
2313473, PR #18, PR #19). Every item now has a real code fix.
**Round 1 (correctness/lifecycle) — 4 items**
#1. pending-decrypt:* prune cron — listener alarm now runs a 30-min
periodic prune that drops queue entries older than 7 days and deletes
empty queues. Bounds long-term DO storage growth from sessions whose
forwarders went offline before responding.
#2. seen:* KV race redesign — dispatch dedupe was using KV's eventual
read-modify-write, letting two concurrent reads both see "absent" and
both proceed. New /dedup-claim endpoint on MatrixListener wraps the
check + write in blockConcurrencyWhile for genuine put-if-absent
semantics. Periodic prune handles cleanup (1h TTL); KV fallback
preserved for the case where the listener DO is unreachable.
#3. /admin/listener/reload-keys endpoint — clears the in-memory
cachedKeys + cachedCurve25519 so the operator can rotate Megolm
session keys (MATRIX_MEGOLM_KEYS_JSON) without a full redeploy.
#4. parsePlan hijack tightening — only accept Plan(d/t) headers
BEFORE any tool calls and before any prior plan has been parsed.
Prevents a confused/adversarial agent from overwriting in-progress
view by emitting `Plan (5/5)` mid-turn to fake completion.
**Round 2 (security/spec/integration) — 4 items**
#5. m.forwarded_room_key validation — vault now tags captured Megolm
keys as `forwarded` vs `live`; listener checks `key-request:*`
prefix to confirm we previously asked for the (room, session). If
not, calls new vault `/keystore-delete` to evict the unsolicited
key. Closes the "any Olm-paired sender can plant Megolm keys for any
room" hole.
#6. usage:org KV race — routed through OlmVault singleton DO via
new /usage-bump (write, atomic) and /usage-read (read) endpoints.
Concurrent Done frames across rooms can no longer drop entries via
read-modify-write race.
#7. Olm session cap bump — was .slice(-8), now .slice(-32). Verified
multi-device senders rotate fast enough that 8 was occasionally
dropping legitimate live sessions. Evictions are now logged so the
operator can spot pathological churn.
#8. /admin/recover-room missing-senderKey warning — surface bridges
that strip the outer sender_key field (m.room_key_request routing
degrades to `*` when missing, may not land on the originating device).
Typecheck clean. 102/102 tests pass.
Rolling tally tonight: 19 findings · 19 shipped · 0 deferred.
Co-authored-by: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>
Co-authored-by: itsablabla <itsablabla@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PRs touching replicas-matrix-bridge/ now run npm typecheck + npm test
(previously these were only run locally before each deploy).
On merges to main, also probe the deployed worker to assert:
- /admin/listener/reset returns 401 without ADMIN_TOKEN
- /debug/listener returns 401 without ADMIN_TOKEN
- /health stays 200 (must remain public for uptime checks)
Prevents regression of the security gate added in PR #18 — if a future
change accidentally drops the ADMIN_TOKEN check on any of the protected
paths, this job fails and the deploy is flagged.
Co-authored-by: replicas-connector[bot] <replicas-connector[bot]@users.noreply.github.com>
Co-authored-by: itsablabla <itsablabla@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Second-round audit, fresh angle (security / Matrix spec compliance / agent integration boundaries). Four high/medium findings shipped; four low-severity items deferred with rationale.
Findings shipped
HIGH — Admin/debug auth gate (
src/index.ts)/admin/*,/debug/*,/dispatch, and/start-listenerroute was unauthenticated./admin/vault/resetcould DoS all E2EE state./debug/vault/keystoreleaked Megolm session counts per room./dispatchlet anyone spawn replicas + burn Anthropic credits.ADMIN_TOKENwith timing-safe compare. Falls back to warn-and-allow whenADMIN_TOKENis unset (migration mode) so existing operator curl flows keep working until the secret is set.MED —
m.replaceedit dedup (src/listener.ts)m.room.messagewhose body was the fallback* <new content>string. Listener treated it as a new prompt and spawned a turn whose body literally started with*.m.relates_to.rel_typeism.replace.MED — Member-count fail-closed (
src/listener.ts)cachedRoomMemberCountreturned2("treat as DM") on cold-cache + transient lookup failure, causing the bot to dispatch on every message in a 50-person room until the next successful lookup.100(sentinel "definitely a group") so the mention gate stays active until membership is genuinely known.LOW — Content-Type on Replicas POSTs (
src/dispatch.ts,src/poller.ts)replicasHeadersomittedContent-Type: application/json. Servers were auto-detecting JSON but explicit is correct.Deferred
usage:orgKV read-modify-write race — concurrent Done frames across rooms can drop entries. Cosmetic data only. Proper fix is routing through a Singleton DO..slice(-8)evicts oldest. Bump to 32 + log evictions./admin/recover-roommissing-senderKey — add a warning log whensenderKeyis missing so the operator can investigate.Validation
npm run typecheck✅npm test✅ 97/97 tests passhttps://replicas-matrix-bridge.jadengarza.workers.dev(version0645cb26-2cfb-4250-bd3b-ae503543c395)Test plan
wrangler secret put ADMIN_TOKENto lock down endpoints/debug/listenerwithout auth header → should log warning + allow (until ADMIN_TOKEN is set)Workspace · Slack Thread