feat(channel): relay permission prompts through Matrix #115
No reviewers
Labels
No labels
blocked
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
waiting-on-julian
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/matrix-mcp!115
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "permission-relay"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Refs #109. Does not close it — see below.
/channeldeclaresclaude/channel/permissionalongsideclaude/channel, soa session on it can have its tool-approval dialogs answered from Matrix. Claude
Code forwards each prompt as
notifications/claude/channel/permission_request;we render it into a Matrix message carrying the five-letter request id verbatim
and send
notifications/claude/channel/permissionback with the verdict. Bothdialogs stay live and the first answer wins.
The three things the docs' worked example does not answer
The docs' example is a stdio server serving one user at a terminal. We are a
remote streamable-HTTP server serving many identities and many sessions.
Which room the prompt goes to. A permission request arrives on an MCP peer
that carries no room at all.
permission_roomin the account's ownapp.matrix_mcp.channelaccount data is the destination when set — an explicitstatement beats an inferred one, and it cannot be moved by traffic. Otherwise
it is the room an allowlisted sender last wrote in, recorded only after the
allowlist gate passes: recording it before would let a stranger who DMs the bot
redirect every later approval prompt into a room he controls. Neither known is
a
warn!and a drop that says the prompt could not be relayed. A guessed roomis an approval handed to whoever is standing in it.
Where the verdict goes. To the session that issued the request, via a
(mxid, request_id) -> session_keymap with a 15-minute TTL and a 256-entrycap. Broadcasting would also work, since a client drops a verdict for an id it
did not issue, but targeting is what produces the log line that makes issue
acceptance item 3 observable at our own layer. The MXID is part of the key
rather than a stored field: five letters from a 25-letter alphabet is ~9.8M
ids, and a bare-id key would let a collision across identities silently evict
the other identity's pending request.
No feedback loop. Confirmed rather than assumed. The prompt is sent by the
client built from the authenticated identity, so
ev.sender == mxidinpush_message, and that check sits above both the verdict branch and theallowlist gate (
src/channel.rs,push_message).The ordering, which is the security requirement
Anyone who can get a verdict through the inbound path can approve tool use in
the session, so the allowlist gate has to run before the verdict branch. That
ordering now lives in a signature rather than in statement order:
classify_inbound(false, "yes abcde")isIgnored, whatever the text says, anda unit test asks that question directly instead of it being a property of where
a
returnhappens to sit. The regex is the docs' pattern verbatim,^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$with(?i), and the captured id islowercased. A verdict is answered exactly once and never also forwarded as chat.
Second round: four findings from review
alan reviewed the diff and Codex confirmed the first trace independently. All
four were confirmed against the code before being fixed, in
a6e9d74.1. A verdict was replayed into a fresh context as chat.
handled_as_verdictconsumes the message and returns without pushing, so nothing calls
mark_readand the receipt watermark never moves past it.
replay_roomfiltered only onsender and
carried_of, and a verdict ism.textwith a body — so the nextattach got
<matrix:message>no qmzkd</matrix:message>with no referent, whichis exactly what the live path exists to refuse, defeated by the other path. The
two paths disagreeing about one event is the #107 bug in another costume.
Replay classifies too now. Not fixed by advancing the receipt: that would skip
the unacknowledged messages behind it.
There was a trap inside this fix.
ReadEvent::untrusted_bodyholds thesandbox-wrapped body, and the verdict pattern is anchored, so a check written
against that field matches nothing and silently does nothing while looking
correct.
is_replayed_verdictreadscontent.bodythroughraw_body, andthe_replay_verdict_check_reads_the_unwrapped_bodyexists to tell the twoapart — pointing
raw_bodyatuntrusted_bodyturns it red.2.
deliver_verdictretired the request before knowing the send worked. Amissing peer or a failed send left the id gone, so a second
no qmzkdfromMatrix read as unissued while the terminal dialog was still open. It now looks
up, sends, and retires only on
Ok. Retirement is conditional on a per-entryserial: no lock is held across the await, so the map may hold a different
prompt at that key by the time delivery succeeds, and removing by key alone
would retire the newer one.
Instantcannot serve — two entries created in thesame tick compare equal.
3.
client.get_room()returns Left, Invited, Knocked and Banned rooms, soSomewas not "joined" — the trap #113 fixed indownload_attachment. Nothingleaked, because
room.sendthen failed withWrongRoomState, but a pending idwas retained for a prompt nobody saw and the operator was told the wrong thing
twice.
prompt_room_is_usableis an allowlist of one rather than!= Left, soa state added by a future matrix-sdk defaults to refused.
4.
live_peers(mxid) == 0returned above the verdict branch.live_peerscounts without evicting, because
is_transport_closedfalse-positives andevicting on a read once killed the channel for a whole session — so
live == 0does not mean
deliver_verdictwould fail to find the peer. A verdict wasdropped at the top of
push_messagefor a dialog still open at the terminal.The early return now stands unless
has_pending(mxid): one in-memory map read,on the path that was about to return anyway, so the no-listener common case
still costs no config fetch.
Controls for the four
a_verdict_is_not_replayed_into_a_fresh_context_as_chatthe_replay_verdict_check_reads_the_unwrapped_bodya_verdict_that_reached_nobody_stays_answerablean_in_flight_delivery_cannot_retire_a_reissued_prompta_delivered_verdict_is_answerable_exactly_oncea_prompt_only_goes_into_a_joined_room!= Lefthas_pendingalways falsea_verdict_is_looked_for_even_when_nothing_looks_livehas_pendingignores the identityhas_pendingignores the TTLan_expired_prompt_does_not_keep_the_no_listener_path_awakeFix 2 falsified an existing test rather than adding to it:
a_verdict_is_answerable_exactly_onceasserted the entry was consumed afterdeliver_verdict, which is now true only on a path that delivered. Its realproperty moved into
retire_if_current, which is a function rather than testscaffolding, and split into the two tests above.
Negative controls
Per AGENTS.md, every claim below was checked by breaking the code it covers and
watching the test go red. Twenty mutations, all red:
classify_inboundignores the sender gatea_verdict_from_a_sender_who_is_not_allowlisted_is_ignoredlnear_misses_fall_through_as_chat_rather_than_as_verdicts(?i)droppedautocorrect_capitalisation_is_tolerated_and_the_id_is_lowercasedthe_prompt_carries_the_request_id_verbatiman_oversized_preview_never_eats_the_reply_instructionclipslices without walking to a char boundarya_multibyte_preview_is_clipped_on_a_character_boundarya_verdict_for_an_id_we_never_issued_is_droppedrequest_idalonea_verdict_does_not_cross_an_identity_boundarya_verdict_is_answerable_exactly_onceoutstanding_prompts_are_boundedthe_newest_prompt_survives_the_capremember_roomkeeps the first room foreverthe_prompt_destination_starts_unknown_and_follows_allowlisted_trafficpermission_roomfield name wrong on the wirethe_config_round_trips_and_defaults_to_no_permission_roombehaviorwire values swappedthe_verdict_wire_values_are_the_two_claude_code_acceptsthe_relay_method_names_are_the_ones_in_the_contractm.noticecarried live but not on replaythe_live_classifier_agrees_with_the_replay_onechannel_mount_declares_the_permission_capabilitythe_main_mount_never_declares_the_permission_capabilityThree of these were green on the first pass and the tests were fixed, not the
mutations:
a_multibyte_preview_is_clipped_on_a_character_boundaryused"ü".repeat(MAX), which puts a character boundary at every even offset — andboth caps are even, so a
clipthat never walked to a boundary passed. Thefixture now carries a one-byte ASCII prefix to shift every boundary off the
cap. This is now an AGENTS.md pitfall: the same trap applies to
cap_bodyand
cap_wrapped.a_verdict_does_not_cross_an_identity_boundaryneeded the mutation appliedat both the insert and the lookup; with only one changed the lookup missed
for the wrong reason.
the_newest_prompt_survives_the_capis guarded by a conjunction(prune-before-insert and evict-oldest); flipping either half alone leaves
it true. The control applies both.
What is not covered by a test, and why
Every decision in this PR is a pure function with a mutation-proven test.
No call site in
push_message,replay_roomoron_custom_notificationis reachable from a unit test — each needs a live
Room,ClientorPeer.I measured that rather than assuming it: deleting the call to
is_replayed_verdict,retire_if_current,has_pendingorprompt_room_is_usableleaves the suite green.So the split is: what to decide is tested here, and that the decision is
consulted is issue acceptance items 1-4 on the deployed server. The one
exception is
deliver_verdict's look-up-don't-consume ordering, which isexercised end to end by
a_verdict_that_reached_nobody_stays_answerable—hoisting the removal back above the send turns it red.
Not closing the issue
Refs #109, deliberately notCloses. The four acceptance observations in#109 are live ones: a real gated call answered
no <id>from Matrix and seento be rejected, the same with
yes <id>, a well-formed unissued id seen to bedropped, and a non-allowlisted sender's
yes <id>seen to do nothing. Theyneed the deployed server and a human at a phone, so they happen after Clark's
release, not in this PR. A keyword that auto-closed on merge would leave the
reopen as the step that gets forgotten.
Scope
CHANNEL_TOOLSandCHANNEL_INSTRUCTIONSare untouched (#111 is changingboth). No release tag.
carried_of_liveis the one refactor beyond relay:clippy::too_many_linesfired on
push_messageonce the verdict branch went in, and splitting thefive-armed msgtype match out mirrors
carried_ofon the replay path. A testasserts the two classifiers agree.
Gates
cargo fmt --all --check,cargo clippy --all-targets --all-features --locked -- -D warnings,cargo test --all-features --locked(225 passed),cargo audit,cargo deny check bans licenses sources— all green on stable1.98.0 with
RUSTFLAGS=-DwarningsandOPUS_STATIC=1.Rebased onto
7525066(#114 merged), soci.ymlpins1.98.0and my localruns match the runner exactly. The tree cannot lint below 1.98.0 — that is #112,
and AGENTS.md now carries the measured before/after table.
regexis now a direct dependency, named for the verbatim pattern. It wasalready in the tree transitively, so the lockfile delta is one line and no new
crate compiles.
🤖 Generated with Claude Code
https://claude.ai/code/session_01SHy74Zo4sLTBcEY4o5REXu
Director's re-read, and three items applied in
02ff731Gates reproduced independently on 1.98.0 before review: fmt clean, clippy 0 errors, 235 tests at that point. The four findings are closed and the
untrusted_bodytrap found inside finding 1 is the best catch here — verified atsrc/mcp.rs:1085-1091, where the doc says the field is sandbox-wrapped, so an anchored pattern against it matches nothing and the fix would have looked right and done nothing.The declared coverage gap was verified rather than accepted: stubbing the
is_replayed_verdictcall inreplay_roomleft all 235 tests green. Honest, not pessimistic.1. Replay's judgement is now reachable.
replay_deliverableis lifted out ofreplay_room's closure — allowlisted sender, not an already-answered verdict, a carried msgtype, within budget. Inside a closure none of it could be tested;replay_roomkeeps the homeserver round trip and nothing else.the_replay_filter_does_not_drop_everythingis the control for the control: a filter returningNonefor everything satisfies "a verdict is not replayed" while destroying the channel.on_custom_notificationandpush_messagestay unreachable, and that is accepted — they need a liveClient,RoomandPeer, and inventing seams to reach them buys a test of the seam. Replay was different: the seam was already a closure and theReadEventfixtures already existed.2.
prompt_room_is_usablemoved to the site its doc describes. It documents "whether a permission prompt may be sent into a room in this state" and its only call site wasread_thread, which has nothing to do with permission prompts, while the permission room used a raw!= Joined. Behaviourally identical, but the allowlist-of-one argument did not apply where it was written down, andread_threadwas not this PR's to touch. Reverted there, applied at the permission site. One predicate across all fiveRoomState::Joinedsites is worth doing separately and should be named for the property, not a caller.3. A retirement declined by the serial is logged.
retire_if_currentreturning false is the case the serial exists for — a delivery in flight while the id was reissued. Declining is correct; being unable to observe it is not.Cross-engine review (Codex)
u64wrap: comparison and removal happen under one write lock, a replacement before it carries a different serial, one after it survives.push_messagerestructure pushes nothing new. A verdict now reaches the peer whenlive_peersfalse-negatives to 0 — which is finding 4's fix, intended. Every non-verdict withlive == 0hits the second return, andremember_roomstays below it.Gates
fmt, clippy
-D warnings(0 errors), 240 tests on rustc 1.98.0, rebased ontoe15a350.Still not closing #109
Unchanged: the four acceptance observations need the deployed server and a human answering from a phone.
Refs, notCloses.23dc9555eea6e9d74c63a6e9d74c6302ff731d7bjlxq0 referenced this pull request2026-08-26 09:20:56 +00:00