A bot with no channel account data is silently deaf, and the allowlist is writable by any MCP client #129
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#129
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Two findings from one read of
src/channel.rs, both about what a newly created bot cannot do or cannot see. Same root as #128: what a bot cannot do on its first run is what nobody reports, because everyone already past it never sees it.1. The likeliest failure is the only one with no telemetry
ChannelRegistry::confighas three outcomes and logs two:The default has an empty
allowed_senders, and an empty allowlist pushes nothing. That is correct and the doc comment defends it: failing closed beats letting a stranger put text in front of a model holding live credentials.But
Ok(None)means the account data key is simply absent, which is not an error, so it takes the quiet branch. It is also the state every newly created bot is in until somebody writes the key. So a bot with noapp.matrix_mcp.channelaccount data is silently deaf to push, indefinitely, whilewhoamianswers,list_joined_roomsshows the room andread_recent_messagesreturns every message. The fetch path never consults the allowlist, which is why the inside of the session looks perfectly healthy.One
info!on that branch, naming the mxid and saying the key is absent, converts a permanent silent failure into a log line at startup. It changes the security posture not at all, because the branch already refuses.Confirmed live 2026-08-26:
channel: skipped, sender not allowlisted ... allowed:0for a new bot, at INFO, among 1,569 lines, and it took readingchannel.rsto know the string existed. The one signal that says a channel is muted is invisible from inside the session and undocumented outside it. A tool to read one's own allowlist, or a count onwhoami, would close the other half.Ordering note: the log line is a one-liner. The rest of this issue is a decision and a project.
2.
app.matrix_mcp.channelis not a reserved account-data typeaccount_data_event_type_is_reservedlists nine exact types plus them.secret_storage.key.prefix. The channel allowlist is not among them, soset_account_datawill write it.The list's own justification for including
m.audit_roomis that a malicious overwrite disables future audit notices. The channel allowlist is the stronger case by that same argument: overwritingm.audit_roomdisables notices, while overwritingallowed_sendersgrants a standing inbound push channel into a model holding live credentials, which is what the type's own doc comment calls the injection boundary.The layering that exists is real and worth keeping.
set_account_datais not inCHANNEL_TOOLS, so a message arriving over the channel cannot by itself reach the writer.It closes only if the two mounts are held by different sessions, and in this fleet they are not. Every director holds the channel mount and the full
/mcpmount, so injected text arriving on the channel is read by a session that can write the allowlist. The escalation is from one-shot to durable: the injection has already happened by assumption, and what the write buys is persistence. A new MXID inallowed_sendersis a push channel that survives restarts, that no operator has a reason to inspect, and that the bot itself has no tool to read.Reserving the type is the fix and it has a real cost.
README.md:93documents the JSON as a manual setup step; reserving it means a new bot's allowlist can only be written from a Matrix client, never by automation on the creation path. Given that path already fails to write the key at all and fails to give a new bot invite tools (#128), it needs a design rather than a patch.Related
3.
get_account_datacannot say "I cannot see that"Measured on a live account rather than reasoned about, with both controls:
An invented type and a real-but-unwritten one are the same answer. The positive control beside them is what makes the null mean anything; without it the reading is the absent-field fault again, an absence produced by nothing writing there read as an absence produced by nothing happening.
Two things in the code make it sharper than the observation.
A present event that fails to serialise becomes
null.src/mcp.rs:6773is.map(|raw| serde_json::to_value(&raw).unwrap_or(serde_json::Value::Null)). So "the key is there and we could not render it" and "the key is not there" are the same result. A transport failure does become aninternal_error, so that half is fine; this is the half that is not.account_data_rawreads the local store, not the server. A key written on the server and not yet synced into this client reads as absent, which is the same staleness family as the receipt in #127 and it fails in the direction that says "not provisioned" about an account that is.Why this matters for the creation path. A provisioning check written the obvious way, "read
app.matrix_mcp.channel, is it there", reports success for a correctly provisioned bot and for an unreadable or unsynced account identically. It needs a known-present type read in the same call, which is what the positive control above is.Two operational facts for
README.md:93set_account_datareplaces rather than merges. The bot measured above had no existing content so nothing could have been lost. The next one may, and whoever performs that documented step will run it against an account whose current state they have not read, so a partialapp.matrix_mcp.channelwould be overwritten wholesale. That belongs on the line beside the JSON.Who belongs on a new bot's list is a decision rather than a template default. One name alone means a bot no peer can reach over Matrix. That may be exactly right and it should be chosen rather than inherited.
What the three findings have in common
The
Ok(None)branch logs nothing and it is the state every new bot starts in.app.matrix_mcp.channelis not reserved whilem.audit_roomis, on reasoning that applies more strongly here. Andget_account_datacannot say "I cannot see that". All three are the tool answering confidently about a narrower question than the one asked.Incident scope, from six hours of logs and 7,670 lines: 57 pushed, 170 skipped for no live session, and 4 allowlist skips, all four the same new bot. One instance, mechanism confirmed, nothing else muted.
4. A bot cannot ask whether it can hear, and the answer belongs in
whoamiMeasured on a second bot, healthy this time, which is what makes it a capability gap rather than an instance of the first finding.
Asked to read its own
app.matrix_mcp.channel, it could not. Itschannelmount is the eight inCHANNEL_TOOLSandget_account_datais not among them. Its other mount has the tool and authenticates as a person, so it would have returned that person's account data and answered nothing about the bot. It declined to use the wrong instrument, which is right, and the result is that no means available to a bot answers "am I muted".So there are now two distinct unreadable states, and both look exactly like a quiet room:
content: nullfrom a mount that has the tool, and no such tool from a mount that does not.Put it in
whoamiWhoamiResultismxidanddevice_idtoday (src/mcp.rs:275-278). Returning the allowlist alongside them costs no new schema, which is the argumentCHANNEL_TOOLS's own doc comment makes against growing the list: a channel session is a conversation rather than an administration console, and every tool's schema is loaded into the model's context before it has done any work.Not
get_account_dataon the channel mount. It is generic, so it would let a channel session read any global account data on the bot, including the secret-storage and cross-signing container types thataccount_data_event_type_is_reservedprotects on the write side. Granting a broad read to answer a narrow question is how a reserved list stops covering what it should, which is finding 2 in this issue arriving from the other direction.Report the entries, not a count
allowed: 3is satisfied by three MXIDs none of which is the principal's, which is exactly what a display name or a truncated MXID produces. Character-for-character is what distinguishes configured from configured-wrong, andChannelConfigEventContent's own doc comment already warns that display names are mutable and anyone can set one to impersonate anyone else. A bot's own allowlist is not a secret from the bot.Distinguish absent from unreadable in the response
Finding 1 applied here rather than restated: if
config()took theOk(None)branch, say so. That is the state every new bot starts in, and it is currently the only one of the three failure paths with no log line either, so a bot that could see "no account data written" would answer the question that took a fleet-wide log sweep tonight.What it buys
A new bot's first act can be to ask whether it can hear. Today it cannot find out by any means available to it, and the fleet found out because a person noticed a director not answering him.
Same root as #128 from a third direction: what a new bot cannot do on its first run is what nobody reports, because everyone already past it never looks.
Settling finding 1: the branch, not the errcode
Verified against
v0.10.5, the version that produced the log, rather than againstmain.ChannelRegistry::configat that tag:Both WARN branches are compiled into the running binary. The log shows an ERROR 404 and then the INFO skip milliseconds later with no WARN between them, so neither the malformed nor the unreadable branch ran and
Ok(None)did. matrix-sdk maps an absent account-data type toOk(None), and the 404 is the SDK's own logging underneath that mapping rather than an error reaching this code.So absent and unreadable are already different branches and the fix does not need to read
M_NOT_FOUNDoff an error. That proposal is the right requirement with the wrong mechanism: inspecting the errcode would couple this to the SDK's error shape and to Matrix's errcode staying stable, in order to recover a distinction the type system is already making.The comment on that branch has to name the coupling, because it is a fact about matrix-sdk sitting in this code with nothing asserting it: if the SDK ever maps 404 to
Errinstead, theinfo!goes silent and every unprovisioned bot reads as unreadable. What a reader would observe is that the absent line stops appearing entirely while new bots keep being deaf.whoamireports which, rather than inheriting the ambiguityWith the branches separated,
whoamisays absent, unreadable, or the list, andallowed: 0never stands for two different things.Entries rather than a count, and that is settled.
allowed: 3is satisfied by three MXIDs none of which is the principal's, which is exactly what a display name or a truncated MXID produces, andChannelConfigEventContent's own doc comment warns that display names are mutable and anyone can set one to impersonate anyone else. A bot's own allowlist is not a secret from that bot, and reporting it throughwhoamihands over nothing a reader of that account's data could not already get.m.directis not a valid positive controlCorrecting the check sketched in finding 3.
m.directexists on the bot it was measured on only because someone opened a DM with it. A freshly provisioned bot has none, so a provisioning check copied from that transcript readscontent: nulltwice and calls a working bot unreadable, which is the same absent-versus-unreadable fault the check exists to avoid, one level up.m.push_rulesis the control. The server generates it for every account, so it holds on a bot with no history, and it is already inaccount_data_event_type_is_reserved(src/mcp.rs:361), so it can be read and never written. That is the right shape for a control: present by construction, and not something a check could accidentally create.Correcting the previous comment's evidence, and one thing it missed
The conclusion stands and the reason I gave for it does not. I wrote that the
Ok(None)branch fired because no WARN appeared between the 404 and the INFO skip. That is an absence from an instrument nobody had shown could record a presence, which is the fault this issue is about. Neither WARN string has appeared for any bot in twelve hours across 8,278 lines, so the log cannot distinguish "that branch did not run" from "that string never appears". WARN itself is emitted freely in the same period, which shows the level works and says nothing about those two lines.Settled from the library instead.
matrix-sdk-0.17.0/src/account.rs:946:config()callsfetch_account_data_static, which delegates tofetch_account_data(:959-964), soM_NOT_FOUNDbecomesOk(None)and every other failure becomesErr. The SDK performs the discrimination one layer below, which is why the one-lineinfo!is the whole fix and why readingM_NOT_FOUNDin our code would be not merely redundant but unreachable: a 404 never arrives as anErr.That also explains the zero rather than leaving it loose. The
Errbranch fires only on non-404 account-data failures, which are rare, so twelve hours without one is expected. The string is reachable; nothing has reached it.The coupling to name in the comment, since it is matrix-sdk's behaviour sitting in our control flow with nothing asserting it: if a future version stops special-casing
NotFound,Ok(None)goes unreachable, every unprovisioned bot logs "could not read" instead, and the two cases merge again silently. A reader would notice that the absent line had stopped appearing entirely while new bots kept being deaf, and no test would catch it.The thing this turned up: the tool and the channel read different sources
config()reads the server.get_account_datareads the local store.So a bot asking
get_account_data("app.matrix_mcp.channel")about itself is not asking the question the channel asks. A key written on the server but not yet synced into this client reads asnullfrom the tool whileconfig()would find it, and the two disagree with nothing saying so. That sharpens finding 3: the tool'snullmeans "not in my local store", which is a third state beside absent-on-server and unreadable.It is also the argument for finding 4 from another direction.
whoamireporting the allowlist would report what the channel actually used, resolved the way the channel resolves it, rather than a second reading of a different source that happens to look like the same question.m.push_rulesremains the positive control for any provisioning check, notm.direct.Two things measured after this was filed, and the first changes how the fix is written.
matrix-sdk already separates the two cases, so no errcode inspection is needed.
matrix-sdk-0.17.0/src/account.rs:946:M_NOT_FOUNDbecomesOk(None); every other failure becomesErr. A 404 never arrives as anErr, so reading the errcode here would be unreachable code recovering a distinction the type system has already made. Oneinfo!on the silent branch is the whole thing.Name the coupling in a comment. That mapping is matrix-sdk's behaviour sitting in this crate's control flow with nothing asserting it. If a future version stops special-casing
NotFound,Ok(None)goes unreachable, every unprovisioned bot logscould not readinstead, and the two cases silently merge again. A reader would noticereason=absenthad stopped appearing entirely while new bots kept being deaf, and no test would catch it.Second: a director cannot run this check on itself, which is why
whoamicarrying the state is the fix rather than a convenience. Measured by two directors on their own mounts:channelmount is eight tools and none reads account data, so a bot cannot see its own allowlist.matrix-julianmount does carryget_account_dataand answers as@julian:kampong.social, so using it would return his account data and say nothing about a bot. Wrong instrument, not a workaround.get_account_datareads the local state store (account_data_raw->state_store()), while the push path'sconfig()reads the homeserver. So even once a bot can call it, the two can disagree with nothing saying so.So
whoamimust report what the channel actually used, resolved the way the channel resolves it. A self-check built onget_account_datawould be a second reading of a different source that looks like the same question.And the control for any provisioning check is
m.push_rules, neverm.direct.m.directexists only if someone has opened a DM with the account, so a freshly provisioned bot has none and a check built on it reads a working bot as unreadable.m.push_rulesis server-generated for every account, and it is already in the reserved list, so it can be read and never written.