Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
316 lines
19 KiB
Markdown
316 lines
19 KiB
Markdown
# M4b design: approvals over Mattermost
|
|
|
|
Status: draft for the owner's review, 2026-09-24. It builds on M4a
|
|
(`docs/specs/2026-09-23-m4a-gateway.md`, section 12 there is the agreed outline) and M3a's approval
|
|
path (`docs/specs/2026-09-18-m3a-decision-path.md`, section 6). Owner's decisions of 2026-09-24,
|
|
changing the outline: **no reactions**, answers are commands only; an `[approvals] users` list
|
|
that falls back to `allow.users` when empty; approvals are posted in the asking thread, channel
|
|
threads included (redacting secrets from tool arguments is later work, with the tools); a
|
|
`gatewayd` restart marks its old approval posts dead and `brokerd` posts them again; and
|
|
`gatewayd` strips a leading mention of its own bot from every message before it is handled, so
|
|
`@boxmaker0 !approve 42` is a command and `@boxmaker0 summarise the log` reaches the model as
|
|
`summarise the log`.
|
|
|
|
Facts this rests on, checked on 2026-09-24 against Mattermost's source and API reference at the
|
|
server's version, v11.11.0:
|
|
|
|
- `PUT /api/v4/posts/{post_id}/patch` with `{"message": …}` replaces a post's text; the other
|
|
fields are left alone. It needs the `edit_post` permission, which a user and a bot have for
|
|
their own posts by default (to be confirmed on the owner's server at the device check).
|
|
- `GET /api/v4/users/{user_id}` returns the user, with `username`.
|
|
- M4a's facts (posts, `posted` events, threads, the 16,383-character limit) still hold.
|
|
|
|
## 1. What M4b proves
|
|
|
|
| Claim | Checked by |
|
|
|---|---|
|
|
| An `ask` call is posted in the asking thread, or for a session not from Mattermost in the first approver's direct messages, with its tool, grant, taint, expiry and full arguments | Tests against a fake Mattermost; on the owner's server |
|
|
| `!approve <id>` from an approver, in that thread, after the post, while pending, runs the call exactly as `bxctl approve` would, and the audit record names the approver and the post | Tests; on the owner's server; `bxctl audit verify` |
|
|
| `!deny <id> [reason]` refuses it the same way, with the reason recorded | Tests; on the owner's server |
|
|
| Nobody else can answer: not a user outside the approvers, not a command elsewhere or before the post, not an id that is not pending there | Tests |
|
|
| An approval answered in `bxctl`, expired, or abandoned by its requester is marked "no longer pending" in Mattermost | Tests; on the owner's server |
|
|
| Arguments are never shortened: long ones come in up to 4 parts with a hash; longer ones are answerable only in `bxctl` | Tests |
|
|
| A `gatewayd` restart leaves no post that looks answerable and is not; pending approvals are posted again | Tests; on the owner's server |
|
|
| `gatewayd` still opens no TCP port, and never reaches `admin.sock` | On the owner's machine (`ss`); by construction |
|
|
|
|
Out of scope: reactions (dropped by the owner); redacting secrets from arguments (with the tools,
|
|
later); approving grants or changing them from Mattermost (never: grants are the owner's files).
|
|
|
|
## 2. Components
|
|
|
|
```
|
|
brokerd ──(one connection per pending approval)──> gateway.sock ── gatewayd ── Mattermost
|
|
▲ │
|
|
└── admin.sock ── bxctl (unchanged, plus `deny`) └── state.json (+ approvals)
|
|
```
|
|
|
|
`gatewayd` listens on **`gateway.sock`**, a Unix socket in its own directory
|
|
(`<home>/run/gateway/`, 0700; the socket 0600), made the way `brokerd` makes its sockets. It is
|
|
still the only listening thing `gatewayd` has, and it is not TCP. `brokerd` is the client. Nothing
|
|
else is meant to connect; a process that could would only be able to make the bot post an
|
|
approval whose answer goes back to that process, never to `brokerd` (section 9).
|
|
|
|
`gatewayd` can answer only the approvals `brokerd` hands it, one per connection. It never learns
|
|
of grants, never reaches `admin.sock`, and cannot create, list or widen anything.
|
|
|
|
### New and changed modules
|
|
|
|
| Crate | Module | Change |
|
|
|---|---|---|
|
|
| `proto` | `escape` | Moved from `bxctl`: `escape_json_text`, `escape_model_text`, unchanged. `bxctl` uses it from here. |
|
|
| `proto` | `wire` | The four gateway messages (section 4). |
|
|
| `brokerd` | `config` | `[gateway] socket` (section 3). |
|
|
| `brokerd` | `courier` | New: one thread per pending approval, which carries it to `gatewayd` and its answer back (section 5). |
|
|
| `brokerd` | `ledger`, `admin` | `Answer::Approved` and `Refused` gain `post`; the `Approval` record's `post` is filled from it. |
|
|
| `brokerd` | `approvals` | `Table::contains(id)`. |
|
|
| `gatewayd` | `config` | `[approvals] users` and `socket`. |
|
|
| `gatewayd` | `approvals` | New: the pending approvals it has posted, the commands that answer them, the text of the posts. Pure. |
|
|
| `gatewayd` | `gateway` | New: the listener and one thread per `brokerd` connection. |
|
|
| `gatewayd` | `mm::rest` | `patch_post`, `username`. |
|
|
| `gatewayd` | `sessions` | A leading mention of this bot is stripped first (section 7); `!approve` and `!deny` go to `approvals` instead of the M4a notice. |
|
|
| `gatewayd` | `state` | `approvals`: the posts of each open approval, for the restart. |
|
|
| `gatewayd` | `deliver`, `serve` | Typing paused while a turn waits for approval; the M4a "waiting for approval" line only when `brokerd` did not send it (section 7). |
|
|
| `bxctl` | `cli` | `deny` as another name for `refuse`. |
|
|
|
|
No new dependency. SHA-256 is `proto`'s (`emsha`).
|
|
|
|
## 3. Configuration
|
|
|
|
`brokerd.toml`:
|
|
|
|
```toml
|
|
[gateway] # absent: Mattermost approvals are off, as in M3
|
|
socket = "" # empty: <home>/run/gateway/gateway.sock
|
|
```
|
|
|
|
`gatewayd.toml`:
|
|
|
|
```toml
|
|
[approvals]
|
|
users = [] # Mattermost user ids who may answer; empty: everyone in [allow] users
|
|
socket = "" # empty: <home>/run/gateway/gateway.sock
|
|
```
|
|
|
|
Every id in `approvals.users` must also be in `allow.users` (an approver who is not heard could
|
|
never answer); anything else is a configuration error. The **first approver** is
|
|
`approvals.users[0]`, or `allow.users[0]` when the list is empty.
|
|
|
|
## 4. The gateway messages
|
|
|
|
On one `gateway.sock` connection, every frame is a `proto` `Envelope` (`read_frame`,
|
|
`write_frame`, as on the other sockets):
|
|
|
|
| # | From | Message (`kind`) | `final` | Body |
|
|
|---|---|---|---|---|
|
|
| 1 | `brokerd` | `approval_request` | true | `{ approval: PendingApproval }` |
|
|
| 2 | `gatewayd` | `approval_posted` | false | `{ posts: [String] }`, the post ids, first part first |
|
|
| 3 | `gatewayd` | `approval_answer` | true | `{ approval: u64, approved: bool, by: String, post: String, reason: Option<String> }` |
|
|
| 4 | `brokerd` | `approval_outcome` | true | `{ outcome: ApprovalOutcome }` |
|
|
|
|
```rust
|
|
pub enum ApprovalOutcome { // tag "outcome", snake_case
|
|
Runs, // approved, and the grants as they are now still allow it
|
|
Denied { reason: DenyReason }, // approved, but denied on the re-check; or the record failed
|
|
Refused, // the refusal is recorded
|
|
Gone, // no longer pending when the answer came
|
|
}
|
|
```
|
|
|
|
Frames 1 and 4 carry `id` 1 and 2; frames 2 and 3 answer id 1. Instead of 2, `gatewayd` may send
|
|
`error { code: internal, detail }` (final) when it cannot post; `brokerd` then tries again as in
|
|
section 5. `by` and `post` are Mattermost ids (26 characters of `a-z0-9`), `reason` at most 500
|
|
characters; `brokerd` refuses anything else as a bad frame and does not answer the approval.
|
|
|
|
Either side may close at any time. When `brokerd` closes before frame 3, the approval ended some
|
|
other way. When `gatewayd` closes before frame 3, it went away.
|
|
|
|
## 5. `brokerd`: the courier
|
|
|
|
When an `ask` call enters the pending table and `[gateway]` is set, `brokerd` starts a **courier**
|
|
thread for it (with `std::thread::Builder`; if that fails, it logs one line and the approval stays
|
|
answerable in `bxctl`). The courier:
|
|
|
|
1. Stops as soon as the table no longer holds the approval (`Table::contains`).
|
|
2. Connects to the socket and sends frame 1. If it cannot connect, or the answer is an `error`,
|
|
it logs once for this approval, `brokerd: approval <id>: gatewayd is not reachable at <path>:
|
|
<why>; bxctl can still answer it` and `see docs/runbook.md#gateway-unreachable`, waits 5 s
|
|
(checking the table every second), and tries again from step 1. When a later attempt posts it,
|
|
it logs `brokerd: approval <id> posted in Mattermost`.
|
|
3. Waits for frame 3, with a read timeout of 1 s, and at every timeout checks the table: if the
|
|
approval has gone (answered in `bxctl`, expired, requester gone), it closes the connection and
|
|
stops. If `gatewayd` closes first, or sends anything but frame 2 then frame 3, the courier goes
|
|
back to step 1: `gatewayd` restarted, or broke, and a fresh post is needed.
|
|
4. On frame 3 for its own id: takes the entry out of the table. If it gets it, it answers it with
|
|
`admin::answer`, exactly as `bxctl approve` and `refuse` do: `Answer::Approved { by, post }` or
|
|
`Answer::Refused { by, post, reason }`, the `by` being the Mattermost user id. The `Approval`
|
|
record then carries `by`, `post` and `reason`. It sends frame 4 with `Runs` or `Denied` (from
|
|
the re-decision), or `Refused`. If the entry was already gone, frame 4 is `Gone` and nothing is
|
|
written. Then it closes and stops.
|
|
|
|
"Whoever takes the entry out of the table answers it" (M3a) holds unchanged: the courier is one
|
|
more such taker, beside the admin and expiry threads.
|
|
|
|
## 6. `gatewayd`: posting an approval
|
|
|
|
One thread per accepted `gateway.sock` connection. It reads frame 1 (bounded by `read_frame`),
|
|
checks the ids in it, and decides **where**:
|
|
|
|
- If the session is `mm-<root>` and `state.in_flight` holds a turn of that session, the approval
|
|
goes into that turn's thread (its channel and root). This covers direct messages and channels.
|
|
- Otherwise (`bxctl chat`, a session no longer in flight, later scheduled jobs), it goes to the
|
|
first approver's direct channel (`POST /channels/direct`), as a new top-level post whose own
|
|
thread is where it is answered.
|
|
|
|
### The text
|
|
|
|
The arguments are `PendingApproval.arguments` (the re-serialised JSON `brokerd` shows `bxctl`),
|
|
passed through `proto::escape::escape_json_text`, then fenced with a run of backticks one longer
|
|
than the longest run inside them (at least three), so nothing in them renders as Markdown or
|
|
mentions anyone. The grant name gets the same escaping, in backticks. Times are UTC.
|
|
|
|
One part, for arguments up to 15,000 characters after escaping:
|
|
|
|
````text
|
|
**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC
|
|
```
|
|
{"command":"ls -la /srv/backups","timeout_ms":10000}
|
|
```
|
|
Reply in this thread with `!approve 42` or `!deny 42 <reason>`.
|
|
````
|
|
|
|
Longer arguments are split, at character boundaries, into parts of at most 15,000 characters,
|
|
each posted in order in the same thread. Parts before the last:
|
|
|
|
````text
|
|
**Approval 42**, part 1 of 3 of the arguments
|
|
```
|
|
{"command":"…
|
|
```
|
|
````
|
|
|
|
The last part adds, after its fence: `Approval 42: 3 parts, 41,207 characters, sha256 9f2c…e1a0
|
|
(of the arguments as shown).` and the reply line. Only an answer after the **last** part counts
|
|
(section 7), so nobody approves what they have not been shown in full.
|
|
|
|
More than 4 parts: one post, `**Approval 42** · tool … · the arguments are 91,234 characters
|
|
(sha256 …), too long to show here; answer it with bxctl approvals`, and no command answers it.
|
|
|
|
### The outcome
|
|
|
|
When the approval ends, `gatewayd` edits the **last** part (the only answerable one) to its text
|
|
plus one line:
|
|
|
|
| How it ended | Added line |
|
|
|---|---|
|
|
| `Runs` | `✅ Approved by @<username>: the call runs.` |
|
|
| `Denied` | `Approved by @<username>, but denied on the re-check: <deny reason>.` |
|
|
| `Refused` | `❌ Refused by @<username>: <reason, or "no reason given">.` |
|
|
| `Gone`, or `brokerd` closed first | `No longer pending (answered elsewhere, expired, or its turn ended).` |
|
|
| `gatewayd` restarted | `No longer answerable here: gatewayd restarted. If it is still pending it is posted again.` |
|
|
|
|
`<username>` comes from `GET /users/{id}`, falling back to the id. An edit that fails is logged and
|
|
not retried.
|
|
|
|
## 7. `gatewayd`: answers, typing, and the old notice
|
|
|
|
**The leading mention.** Before commands are looked for, and before a message is queued for
|
|
`loopd`, `gatewayd` strips a leading mention of its own bot: `@<username>` at the very start of the
|
|
message, matched as M4a names are (case-insensitive, not followed by a name character), then any
|
|
spaces after it. If nothing would remain, the message is left as it was. This happens after M4a
|
|
has decided the post is for this Boxmaker (the mention still counts as naming it), and in every
|
|
kind of channel. So in a thread shared by two Boxmakers, `@boxmaker0 !approve 42` is a command for
|
|
`boxmaker0` and, since it names another user, is ignored by the other. A mention anywhere else in
|
|
the message stays.
|
|
|
|
**Answers.** M4a's routing is unchanged up to commands: only allowlisted users are heard, and in
|
|
channels only in this Boxmaker's threads. A command `!approve <id>` or `!deny <id> [reason]`
|
|
(exactly: one space, the id in decimal, then for `!deny` optionally one space and a reason of at
|
|
most 500 characters) now goes to `approvals`, which accepts it only if **all** hold:
|
|
|
|
1. its author is an approver;
|
|
2. approval `<id>` is pending in this `gatewayd`;
|
|
3. the command is in that approval's thread (its root is the thread's root);
|
|
4. it was posted after the approval's last part (`create_at` later).
|
|
|
|
Then the answer goes to the courier's connection as frame 3, and the thread says nothing more
|
|
until the edit of section 6. Otherwise `gatewayd` replies in the thread with the first reason that
|
|
applies: `only an approver can answer approvals`, `approval <id> is not pending here`, `approval
|
|
<id> is answered in its own thread`, `approval <id> was answered before it was shown in full`, or
|
|
`use !approve <id> or !deny <id> <reason>`. A command never reaches `loopd`. `!!` escapes as in
|
|
M4a.
|
|
|
|
**Typing.** While a session has a pending approval in `approvals`, `user_typing` is not sent for
|
|
its thread: the turn is waiting for the owner, not working.
|
|
|
|
**The M4a notice.** On an `approval_pending` event, `deliver` waits up to 10 s for `approvals` to
|
|
hold that id. If it does, it posts nothing (the approval post is there). If not (no `[gateway]` in
|
|
`brokerd`, or it could not connect), it posts M4a's line, reworded: `waiting for approval <id>:
|
|
brokerd has not sent it here; answer it with bxctl approvals`.
|
|
|
|
## 8. State and restarts
|
|
|
|
`state.json` gains `approvals: [{ approval, channel, posts: [ids] }]`, written when an approval is
|
|
posted and removed when it ends. The field is optional, so an M4a state file loads unchanged; like
|
|
every other field, its ids must be valid.
|
|
|
|
At the first connection after a start, every entry left in `approvals` has its last part edited
|
|
with the restart line of section 6, and is removed. The couriers of approvals still pending notice
|
|
the dropped connection and post them again (section 5), so the owner sees one live post per
|
|
pending approval.
|
|
|
|
`brokerd` restarts need nothing new: its pending table is in memory, its couriers die with it, and
|
|
their connections close, so `gatewayd` marks the posts "no longer pending".
|
|
|
|
## 9. Security notes
|
|
|
|
- The approvers are the only people whose commands answer anything; an agent in `allow.users` can
|
|
talk to Boxmaker but, if the owner lists the approvers, not approve for it.
|
|
- Arguments are posted in full, in the thread, which in a channel is visible to its members,
|
|
other agents included. Redacting secrets is deferred to the tools work (owner's decision).
|
|
- Several Boxmakers in one channel have their own `brokerd`s, whose approval ids can coincide. A
|
|
command counts only in the thread where this `gatewayd` posted that id. Where two Boxmakers
|
|
share a thread, the operator addresses the command, `@boxmaker0 !approve 42`, and only that bot
|
|
takes it; a bare `!approve 42` would be taken by every Boxmaker with approval 42 pending in that
|
|
thread. The owner's decision: that is the operator's care, not something the design prevents.
|
|
- A local process of the owner's user can connect to `gateway.sock` and have the bot post a fake
|
|
approval; its answer goes back to that process only. The socket is 0600 in a 0700 directory;
|
|
M7 keeps it out of `loopd`'s and the tools' reach.
|
|
|
|
## 10. Runbook
|
|
|
|
New: `gateway-unreachable` (`brokerd` cannot post an approval: `gatewayd` down, `[gateway]
|
|
socket` wrong, or Mattermost unreachable; `bxctl approvals` still works). Changed:
|
|
`gatewayd-start-failed` gains `gateway.sock` (directory, bind, permissions) and the approvers
|
|
check. Every message for these states ends with its pointer.
|
|
|
|
## 11. Testing
|
|
|
|
Offline, as tasks with given tests:
|
|
|
|
- **`proto::escape`**: the moved tests pass unchanged, and `bxctl`'s output is byte-identical.
|
|
- **Wire**: each gateway message round-trips; unknown fields and bad ids are refused.
|
|
- **`brokerd` courier**, against a fake `gateway.sock`: posted and approved (the call runs, the
|
|
record has `by` and `post`); refused with a reason; approved after the grant was removed
|
|
(`Denied`); answered in `bxctl` meanwhile (the courier closes, frame 4 never sent); expired;
|
|
the fake closes before answering (the courier posts again); unreachable at first, reachable 5 s
|
|
later; a bad frame 3 (another id, bad `by`) answers nothing.
|
|
- **The leading mention**, pure: stripped in any case of letters and before a command; kept when
|
|
nothing would remain, when another name comes first, for a longer name (`@boxmaker0x`), and in
|
|
the middle of a message; a post naming another bot first is still ignored.
|
|
- **`gatewayd` approvals**, pure: every rule of section 7 in both directions; the text of one part,
|
|
of 3 parts with the hash, and of the refusal to show; fences around backtick runs; escaping of
|
|
controls, bidi overrides and zero-width characters; every outcome line.
|
|
- **`gatewayd` end to end**, against the fake Mattermost and a fake `brokerd`: the approval in a
|
|
direct-message thread, in a channel thread, in the first approver's DMs; `!approve` from an
|
|
approver runs it; from a non-approver, in another thread, before the last part, for an unknown
|
|
id, each answered with its reason and nothing sent; `brokerd` closing marks the post; a restart
|
|
marks old posts and the re-sent approval is posted fresh; typing stops while pending; the
|
|
fallback notice when no approval arrives.
|
|
- **`bxctl deny`**: the same output as `refuse`.
|
|
|
|
On the owner's server, by the design model with the owner answering: an approval in a direct
|
|
message thread approved with `!approve` (the call runs; `bxctl audit verify` shows `by` and
|
|
`post`), one in a channel thread denied with a reason, one answered in `bxctl` and marked in
|
|
Mattermost, a long one in parts, one answered by a non-approver (refused with the reason), and a
|
|
`gatewayd` restart while one is pending.
|