proto: gateway, the four messages between brokerd and gatewayd

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-24 04:07:46 -07:00
parent db3f9ed01b
commit ebb78e816d
14 changed files with 301 additions and 1 deletions
+90
View File
@@ -0,0 +1,90 @@
//! The messages on `gateway.sock`, between `brokerd` and `gatewayd`: one connection carries one
//! pending approval to Mattermost and its answer back (M4b spec, section 4).
use serde::{Deserialize, Serialize};
use crate::wire::DenyReason;
use crate::wire::PendingApproval;
/// The most characters a refusal's reason may have.
pub const MAX_REASON: usize = 500;
/// `brokerd` → `gatewayd`, first: the approval to post.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalRequest {
pub approval: PendingApproval,
}
/// `gatewayd` → `brokerd`: it is posted, in these posts, first part first.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalPosted {
pub posts: Vec<String>,
}
/// `gatewayd` → `brokerd`: an approver answered, with this post.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalReply {
pub approval: u64,
pub approved: bool,
pub by: String,
pub post: String,
pub reason: Option<String>,
}
/// `brokerd` → `gatewayd`, last: what came of the answer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApprovalOutcome {
pub outcome: GatewayOutcome,
}
// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
pub enum GatewayOutcome {
/// Approved, and the grants as they are now still allow it.
Runs {},
/// Approved, but denied on the re-check, or the answer could not be recorded.
Denied { reason: DenyReason },
/// The refusal is recorded.
Refused {},
/// It was no longer pending when the answer came.
Gone {},
}
/// A Mattermost id: 26 characters of `a-z0-9`.
pub fn is_mattermost_id(id: &str) -> bool {
// `id.len() == 26` and every byte `b.is_ascii_lowercase() || b.is_ascii_digit()`.
id.len() == 26
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
}
impl ApprovalReply {
/// `by` and `post` are Mattermost ids and the reason is at most `MAX_REASON` characters; the
/// first problem otherwise.
pub fn check(&self) -> Result<(), String> {
// In this order, each an Err with exactly this text:
// 1. `!is_mattermost_id(&self.by)`: "the answer's `by` is not a Mattermost id".
// 2. `!is_mattermost_id(&self.post)`: "the answer's `post` is not a Mattermost id".
// 3. a reason with more than `MAX_REASON` characters (`r.chars().count()`, not bytes):
// `format!("the reason is over {MAX_REASON} characters")`.
// 4. Ok(()).
if !is_mattermost_id(&self.by) {
return Err("the answer's `by` is not a Mattermost id".to_string());
}
if !is_mattermost_id(&self.post) {
return Err("the answer's `post` is not a Mattermost id".to_string());
}
if let Some(reason) = &self.reason
&& reason.chars().count() > MAX_REASON
{
return Err(format!("the reason is over {MAX_REASON} characters"));
}
Ok(())
}
}
+5
View File
@@ -5,6 +5,7 @@ pub mod chain;
pub mod class;
pub mod escape;
pub mod frame;
pub mod gateway;
pub mod grant;
pub mod hash;
pub mod hosts;
@@ -20,6 +21,10 @@ pub use audit::{
pub use chain::{ChainFailure, ChainReport, ChainVerifier, Location, TornTail};
pub use class::DataClass;
pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame};
pub use gateway::{
ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest, GatewayOutcome, MAX_REASON,
is_mattermost_id,
};
pub use grant::{Constraints, Grant, Mode};
pub use hash::{HashError, Sha256, sha256};
pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError};
+5
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use crate::gateway::{ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest};
use crate::{CallId, DataClass, DecisionRecord, SessionId, Timestamp, Usage};
pub const PROTOCOL_VERSION: u32 = 1;
@@ -35,6 +36,10 @@ pub enum Message {
Ok(Empty),
CheckGrants(Empty),
GrantsReport(GrantsReport),
ApprovalRequest(ApprovalRequest),
ApprovalPosted(ApprovalPosted),
ApprovalReply(ApprovalReply),
ApprovalOutcome(ApprovalOutcome),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -0,0 +1 @@
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"denied","reason":"no_grant"}}}}
@@ -0,0 +1 @@
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"gone"}}}}
@@ -0,0 +1 @@
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"refused"}}}}
@@ -0,0 +1 @@
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"runs"}}}}
+1
View File
@@ -0,0 +1 @@
{"v":1,"id":1,"final":false,"msg":{"kind":"approval_posted","body":{"posts":["p0000000000000000000000001","p0000000000000000000000002"]}}}
@@ -0,0 +1 @@
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_reply","body":{"approval":41,"approved":true,"by":"k0000000000000000000000000","post":"c0000000000000000000000001","reason":null}}}
@@ -0,0 +1 @@
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_reply","body":{"approval":41,"approved":false,"by":"k0000000000000000000000000","post":"c0000000000000000000000001","reason":"not on a Friday"}}}
+1
View File
@@ -0,0 +1 @@
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_request","body":{"approval":{"approval":41,"session":"mm-r0000000000000000000000001","call":3,"tool":"shell","arguments":"{\"command\":\"ls -la /srv\"}","grant":"shell-srv","taint":"private","created":"2026-09-24T08:05:00.000Z","expires":"2026-09-24T08:20:00.000Z"}}}}
+187
View File
@@ -0,0 +1,187 @@
//! The four messages of `gateway.sock` against byte-exact fixtures, every object in them refusing
//! an unknown key, and the checks on an answer (M4b spec, section 4). Do not edit these or the
//! fixtures.
use proto::{
ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest, CallId, DataClass, DenyReason,
Envelope, GatewayOutcome, MAX_REASON, Message, PendingApproval, SessionId, Timestamp,
is_mattermost_id,
};
use serde_json::Value;
const BY: &str = "k0000000000000000000000000";
const POST: &str = "c0000000000000000000000001";
fn fixture(name: &str) -> String {
let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
text.trim_end_matches('\n').to_string()
}
/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes.
fn check(name: &str, id: u64, last: bool, msg: Message) {
let want = Envelope {
v: 1,
id,
r#final: last,
msg,
};
let text = fixture(name);
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(got, want, "{name}: decoded value");
assert_eq!(
serde_json::to_string(&want).unwrap(),
text,
"{name}: encoded bytes"
);
}
fn pending() -> PendingApproval {
PendingApproval {
approval: 41,
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
call: CallId(3),
tool: "shell".to_string(),
arguments: r#"{"command":"ls -la /srv"}"#.to_string(),
grant: "shell-srv".to_string(),
taint: DataClass::Private,
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
}
}
fn reply(approved: bool, reason: Option<&str>) -> ApprovalReply {
ApprovalReply {
approval: 41,
approved,
by: BY.to_string(),
post: POST.to_string(),
reason: reason.map(str::to_string),
}
}
#[test]
fn the_four_messages() {
check(
"gateway_request.json",
1,
true,
Message::ApprovalRequest(ApprovalRequest {
approval: pending(),
}),
);
let posts = vec![
"p0000000000000000000000001".to_string(),
"p0000000000000000000000002".to_string(),
];
check(
"gateway_posted.json",
1,
false,
Message::ApprovalPosted(ApprovalPosted { posts }),
);
check(
"gateway_reply_approved.json",
1,
true,
Message::ApprovalReply(reply(true, None)),
);
check(
"gateway_reply_refused.json",
1,
true,
Message::ApprovalReply(reply(false, Some("not on a Friday"))),
);
for (name, outcome) in [
("gateway_outcome_runs.json", GatewayOutcome::Runs {}),
(
"gateway_outcome_denied.json",
GatewayOutcome::Denied {
reason: DenyReason::NoGrant,
},
),
("gateway_outcome_refused.json", GatewayOutcome::Refused {}),
("gateway_outcome_gone.json", GatewayOutcome::Gone {}),
] {
check(
name,
2,
true,
Message::ApprovalOutcome(ApprovalOutcome { outcome }),
);
}
}
/// Every copy of `value` with one extra key in one object.
fn with_one_unknown_key(value: &Value) -> Vec<Value> {
let mut out = Vec::new();
if let Value::Object(map) = value {
let mut extended = map.clone();
extended.insert("zz_unknown".to_string(), Value::Bool(true));
out.push(Value::Object(extended));
for (key, child) in map {
for changed in with_one_unknown_key(child) {
let mut copy = map.clone();
copy.insert(key.clone(), changed);
out.push(Value::Object(copy));
}
}
}
out
}
#[test]
fn every_object_refuses_an_unknown_key() {
for (name, objects) in [
("gateway_request.json", 4),
("gateway_posted.json", 3),
("gateway_reply_approved.json", 3),
("gateway_reply_refused.json", 3),
("gateway_outcome_runs.json", 4),
("gateway_outcome_denied.json", 4),
("gateway_outcome_refused.json", 4),
("gateway_outcome_gone.json", 4),
] {
let value: Value = serde_json::from_str(&fixture(name)).unwrap();
let variations = with_one_unknown_key(&value);
assert_eq!(variations.len(), objects, "{name}: objects walked");
for changed in variations {
assert!(
serde_json::from_value::<Envelope>(changed.clone()).is_err(),
"{name}: accepted {changed}"
);
}
}
}
#[test]
fn an_answer_is_checked() {
assert_eq!(reply(true, None).check(), Ok(()));
assert_eq!(reply(false, Some(&"x".repeat(MAX_REASON))).check(), Ok(()));
assert_eq!(
reply(false, Some(&"é".repeat(MAX_REASON))).check(),
Ok(()),
"characters, not bytes"
);
assert!(
reply(false, Some(&"x".repeat(MAX_REASON + 1)))
.check()
.is_err()
);
for bad in [
"",
"K0000000000000000000000000",
"k000000000000000000000000",
"k00000000000000000000000000",
"../../../../../../../../etc",
] {
let mut r = reply(true, None);
r.by = bad.to_string();
assert!(r.check().is_err(), "by {bad:?}");
let mut r = reply(true, None);
r.post = bad.to_string();
assert!(r.check().is_err(), "post {bad:?}");
assert!(!is_mattermost_id(bad), "{bad:?}");
}
assert!(is_mattermost_id(BY) && is_mattermost_id(POST));
}