Specify M4b (approvals over Mattermost); propose P16 on approval replies
The owner's decisions of 2026-09-24: commands only, no reactions; an approvers list that falls back to allow.users; approvals in the asking thread; on a gatewayd restart, old posts are marked dead and brokerd's courier posts pending approvals again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
# 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.
|
||||
|
||||
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` | `!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
|
||||
|
||||
**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; two Boxmakers with the
|
||||
same id pending in the same thread would both take it. This needs both named in one thread and
|
||||
both asking at once; it is accepted, and noted in the runbook.
|
||||
- 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.
|
||||
- **`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.
|
||||
Reference in New Issue
Block a user