Relay permission prompts through the Matrix channel #109

Closed
opened 2026-08-25 06:45:17 +00:00 by jlxq0 · 3 comments
Owner

The channel server declares claude/channel and nothing else, so a permission
prompt in any session using it can only be answered at the terminal. Julian is
driving the fleet from Matrix on a phone, so every gated tool call stalls until
he reaches a keyboard.

Claude Code's channel contract has a relay capability for exactly this. Docs:
https://code.claude.com/docs/en/channels-reference#relay-permission-prompts

Three pieces:

  1. Declare claude/channel/permission: {} alongside claude/channel under
    experimental capabilities. Today src/channel.rs:166 has only
    CHANNEL_CAPABILITY = "claude/channel".

  2. Handle notifications/claude/channel/permission_request. Params are four
    strings: request_id (five lowercase letters from a-z without l),
    tool_name, description, input_preview. Format them into the outgoing
    Matrix message and include the id verbatim — the terminal dialog never shows
    it, so the outbound message is the only place it can be learned.

  3. In the inbound handler, before forwarding text as chat, match
    ^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$ case-insensitively, lowercase the
    captured id, and emit notifications/claude/channel/permission with
    {request_id, behavior: "allow"|"deny"}. Return without also forwarding it
    as a message.

Both dialogs stay live; Claude Code applies whichever verdict arrives first and
drops the other. A verdict for an unknown id is dropped silently.

Scope, so it is not oversold: relay covers tool-use approvals (Bash, Write,
Edit). Project-trust and MCP-consent dialogs never relay. AskUserQuestion
selections and plan-mode approval are not part of this protocol either — those
are Remote Control's job, tracked separately.

Security

The docs are explicit that this should only be declared by a channel that
authenticates the sender, because anyone who can reply through the channel can
approve tool use in the session. Our inbound path is already sender-gated;
confirm that gate runs before the verdict branch, not after, and add a test that
a non-allowlisted sender's yes <id> is dropped rather than applied.

Acceptance

Not "the capability is declared". A negative control, per AGENTS.md:

  • With the capability declared, trigger a real gated call in a session on this
    channel, answer no <id> from Matrix, and watch the tool call be rejected.
  • Then repeat with yes <id> and watch it run.
  • Then send a verdict with a well-formed but unissued id and confirm it is
    dropped while the terminal dialog stays open.
  • Then send yes <id> from a sender not on the allowlist and confirm nothing
    happens.

Client requirement is Claude Code >= 2.1.234; the fleet is on 2.1.245.

The channel server declares `claude/channel` and nothing else, so a permission prompt in any session using it can only be answered at the terminal. Julian is driving the fleet from Matrix on a phone, so every gated tool call stalls until he reaches a keyboard. Claude Code's channel contract has a relay capability for exactly this. Docs: https://code.claude.com/docs/en/channels-reference#relay-permission-prompts Three pieces: 1. Declare `claude/channel/permission: {}` alongside `claude/channel` under `experimental` capabilities. Today `src/channel.rs:166` has only `CHANNEL_CAPABILITY = "claude/channel"`. 2. Handle `notifications/claude/channel/permission_request`. Params are four strings: `request_id` (five lowercase letters from a-z without `l`), `tool_name`, `description`, `input_preview`. Format them into the outgoing Matrix message and include the id verbatim — the terminal dialog never shows it, so the outbound message is the only place it can be learned. 3. In the inbound handler, **before** forwarding text as chat, match `^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$` case-insensitively, lowercase the captured id, and emit `notifications/claude/channel/permission` with `{request_id, behavior: "allow"|"deny"}`. Return without also forwarding it as a message. Both dialogs stay live; Claude Code applies whichever verdict arrives first and drops the other. A verdict for an unknown id is dropped silently. Scope, so it is not oversold: relay covers tool-use approvals (Bash, Write, Edit). Project-trust and MCP-consent dialogs never relay. `AskUserQuestion` selections and plan-mode approval are not part of this protocol either — those are Remote Control's job, tracked separately. ## Security The docs are explicit that this should only be declared by a channel that authenticates the sender, because anyone who can reply through the channel can approve tool use in the session. Our inbound path is already sender-gated; confirm that gate runs before the verdict branch, not after, and add a test that a non-allowlisted sender's `yes <id>` is dropped rather than applied. ## Acceptance Not "the capability is declared". A negative control, per AGENTS.md: - With the capability declared, trigger a real gated call in a session on this channel, answer `no <id>` from Matrix, and watch the tool call be rejected. - Then repeat with `yes <id>` and watch it run. - Then send a verdict with a well-formed but unissued id and confirm it is dropped while the terminal dialog stays open. - Then send `yes <id>` from a sender not on the allowlist and confirm nothing happens. Client requirement is Claude Code >= 2.1.234; the fleet is on 2.1.245.
Author
Owner

Release state for v0.10.5, recorded here because the condition was addressed to a session and sessions end.

The tag is v0.10.5 on 21d74338ad39806945373a589b732a02d6efddf3, which is the #115 merge, not origin/mainorigin/main has since moved to docs-only #121, and docker has needs: cargo, so tagging an unpassed commit spends a version for nothing.

Verified and unchanged as of this comment:

  • CI / cargo on 21d74338: success, 1m36s, 11:05:03Z. CI / docker: success, 3m16s, 11:08:22Z.
  • Five gates green locally on 1.98.0: fmt, clippy, 240 passed; 0 failed, audit clean over 498 dependencies with 8 pre-existing allowed warnings, deny check bans licenses sources ok.
  • v0.10.4..21d74338 is four commits, all #115: 6c0abf2, 58fc55b, 02ff731, 21d74338.
  • No v0.10.5 on the remote; tags top out at v0.10.4e15a350. 21d74338 is an ancestor of origin/main.
  • The deployed pod is on v0.10.4 at sha256:c1e102f4….

The condition. Clark, holding the release gate, put the authorisation question to Julian in writing. He engaged and chose the resolution: one line direct to the worker, not a relay. The worker it was addressed to died with its window before the line arrived, so the hold has outlived its addressee. Nothing about the release is in question and has not been for hours; what is outstanding is a word from Julian, and only from Julian — a relay of his answer by any director or peer is a peer message however faithful, and is not it.

Why this is written down rather than said. A hold whose release is addressed to a session is unreleasable by construction once that session ends, and the only moves left are overriding it or asking the principal twice. A release condition belongs somewhere still there when the reader is not. Whoever picks this up next inherits the state from this comment instead of rediscovering it.

The annotation to tag with is drafted, reviewed and approved; it follows the v0.10.2–v0.10.4 convention of the lead commit's subject then prose on what broke and why.

**Release state for `v0.10.5`, recorded here because the condition was addressed to a session and sessions end.** The tag is `v0.10.5` on `21d74338ad39806945373a589b732a02d6efddf3`, which is the #115 merge, not `origin/main` — `origin/main` has since moved to docs-only #121, and `docker` has `needs: cargo`, so tagging an unpassed commit spends a version for nothing. Verified and unchanged as of this comment: - `CI / cargo` on `21d74338`: success, 1m36s, 11:05:03Z. `CI / docker`: success, 3m16s, 11:08:22Z. - Five gates green locally on 1.98.0: fmt, clippy, `240 passed; 0 failed`, audit clean over 498 dependencies with 8 pre-existing allowed warnings, `deny check bans licenses sources` ok. - `v0.10.4..21d74338` is four commits, all #115: `6c0abf2`, `58fc55b`, `02ff731`, `21d74338`. - No `v0.10.5` on the remote; tags top out at `v0.10.4` → `e15a350`. `21d74338` is an ancestor of `origin/main`. - The deployed pod is on `v0.10.4` at `sha256:c1e102f4…`. **The condition.** Clark, holding the release gate, put the authorisation question to Julian in writing. He engaged and chose the resolution: one line direct to the worker, not a relay. The worker it was addressed to died with its window before the line arrived, so the hold has outlived its addressee. Nothing about the release is in question and has not been for hours; what is outstanding is a word from Julian, and only from Julian — a relay of his answer by any director or peer is a peer message however faithful, and is not it. **Why this is written down rather than said.** A hold whose release is addressed to a session is unreleasable by construction once that session ends, and the only moves left are overriding it or asking the principal twice. A release condition belongs somewhere still there when the reader is not. Whoever picks this up next inherits the state from this comment instead of rediscovering it. The annotation to tag with is drafted, reviewed and approved; it follows the v0.10.2–v0.10.4 convention of the lead commit's subject then prose on what broke and why.
Author
Owner

Tagged. v0.10.5 is on the remote at 21d74338ad39806945373a589b732a02d6efddf3, tag object 1348f8e. Clark cut it, after Julian sent the go to Clark in Matrix rather than into the worker's window; re-cutting the ask a fourth time would have spent his attention on a boundary already resolved twice, so Clark took the act itself. CI / cargo for the tag ref is task 17341, created 09:15:59Z.

One defect in the annotation, recorded because the tag is the permanent record. The pushed message is missing one line in the third paragraph, between "so Some was not joined, the" and "dialogs still open, because live_peers counts without evicting":

#113 trap. And the live_peers(mxid) == 0 early return dropped verdicts for

git tag -a defaults to --cleanup=strip, which removes commentary lines, and a line beginning with # is one. Measured with a control on a throwaway local tag at the same sha: the same message keeps that line under --cleanup=verbatim and loses it under the default. #107 survives in the same annotation because it sits mid-line; #113 did not, because the paragraph wrapped so that it started one. Nothing in git's output says a line was dropped.

The consequence is that the shipped release note breaks mid-clause and two of the four review fixes are unnamed in it: the #113 trap, and the half of the live_peers early return that says what it did.

Whether to re-cut is the gate holder's call and costs a cancel and requeue on a capacity-1 queue. The commit and therefore the image are identical either way.

The pitfall for AGENTS.md: a tag annotation that cites an issue by number loses that line silently whenever the wrap puts the # first, and an annotation is exactly where issue numbers belong. Either pass --cleanup=verbatim or reflow so no line starts with #; the reflow is the version that survives someone later dropping the flag.

**Tagged.** `v0.10.5` is on the remote at `21d74338ad39806945373a589b732a02d6efddf3`, tag object `1348f8e`. Clark cut it, after Julian sent the go to Clark in Matrix rather than into the worker's window; re-cutting the ask a fourth time would have spent his attention on a boundary already resolved twice, so Clark took the act itself. `CI / cargo` for the tag ref is task `17341`, created 09:15:59Z. **One defect in the annotation, recorded because the tag is the permanent record.** The pushed message is missing one line in the third paragraph, between "so Some was not joined, the" and "dialogs still open, because live_peers counts without evicting": #113 trap. And the live_peers(mxid) == 0 early return dropped verdicts for `git tag -a` defaults to `--cleanup=strip`, which removes commentary lines, and a line beginning with `#` is one. Measured with a control on a throwaway local tag at the same sha: the same message keeps that line under `--cleanup=verbatim` and loses it under the default. `#107` survives in the same annotation because it sits mid-line; `#113` did not, because the paragraph wrapped so that it started one. Nothing in git's output says a line was dropped. The consequence is that the shipped release note breaks mid-clause and two of the four review fixes are unnamed in it: the `#113` trap, and the half of the `live_peers` early return that says what it did. Whether to re-cut is the gate holder's call and costs a cancel and requeue on a capacity-1 queue. The commit and therefore the image are identical either way. **The pitfall for `AGENTS.md`:** a tag annotation that cites an issue by number loses that line silently whenever the wrap puts the `#` first, and an annotation is exactly where issue numbers belong. Either pass `--cleanup=verbatim` or reflow so no line starts with `#`; the reflow is the version that survives someone later dropping the flag.
Author
Owner

Shipped in v0.10.5. 6c0abf2 implemented the relay, 58fc55b closed four holes found in review, 02ff731 made replay's judgement reachable, and the merge is 21d7433. /channel declares claude/channel/permission, a prompt is rendered into a Matrix message carrying the five-letter request id verbatim, and a reply of yes <id> or no <id> answers it.

Closing on a stale-issue sweep. The tag containing the work is v0.10.5 and it has been running since; the issue stayed open through three further releases.

Shipped in `v0.10.5`. `6c0abf2` implemented the relay, `58fc55b` closed four holes found in review, `02ff731` made replay's judgement reachable, and the merge is `21d7433`. `/channel` declares `claude/channel/permission`, a prompt is rendered into a Matrix message carrying the five-letter request id verbatim, and a reply of `yes <id>` or `no <id>` answers it. Closing on a stale-issue sweep. The tag containing the work is `v0.10.5` and it has been running since; the issue stayed open through three further releases.
jlxq0 closed this issue 2026-08-27 15:11:59 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/matrix-mcp#109
No description provided.