From 56e12f40a63d252bcc0209cff0d8523c166e996a Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 17 Sep 2026 07:53:40 -0700 Subject: [PATCH] Add IPC envelope and tool messages to proto Implemented-By: Laguna S 2.1 (OpenCode) --- crates/proto/src/lib.rs | 5 + crates/proto/src/wire.rs | 84 +++++++++ crates/proto/tests/fixtures/wire/error.json | 1 + .../tests/fixtures/wire/tool_request.json | 1 + .../fixtures/wire/tool_response_denied.json | 1 + .../fixtures/wire/tool_response_failed.json | 1 + .../fixtures/wire/tool_response_pending.json | 1 + .../fixtures/wire/tool_response_result.json | 1 + crates/proto/tests/wire.rs | 177 ++++++++++++++++++ docs/implementer-log.md | 1 + 10 files changed, 273 insertions(+) create mode 100644 crates/proto/src/wire.rs create mode 100644 crates/proto/tests/fixtures/wire/error.json create mode 100644 crates/proto/tests/fixtures/wire/tool_request.json create mode 100644 crates/proto/tests/fixtures/wire/tool_response_denied.json create mode 100644 crates/proto/tests/fixtures/wire/tool_response_failed.json create mode 100644 crates/proto/tests/fixtures/wire/tool_response_pending.json create mode 100644 crates/proto/tests/fixtures/wire/tool_response_result.json create mode 100644 crates/proto/tests/wire.rs diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index ea30e7f..6bb0122 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -2,6 +2,11 @@ pub mod class; pub mod ids; +pub mod wire; pub use class::DataClass; pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError}; +pub use wire::{ + DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, ToolRequest, ToolResponse, + WireError, +}; diff --git a/crates/proto/src/wire.rs b/crates/proto/src/wire.rs new file mode 100644 index 0000000..20f0765 --- /dev/null +++ b/crates/proto/src/wire.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; + +use crate::{CallId, DataClass, SessionId, Timestamp}; + +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Envelope { + pub v: u32, + pub id: u64, + pub r#final: bool, + pub msg: Message, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + content = "body", + deny_unknown_fields, + rename_all = "snake_case" +)] +pub enum Message { + ToolRequest(ToolRequest), + ToolResponse(ToolResponse), + Error(WireError), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WireError { + pub code: ErrorCode, + pub detail: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + BadFrame, + BadVersion, + BadMessage, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolRequest { + pub session: SessionId, + pub call: CallId, + pub tool: String, + pub arguments: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", deny_unknown_fields, rename_all = "snake_case")] +pub enum ToolResponse { + PendingApproval { + approval: String, + expires: Timestamp, + }, + Result { + content: String, + class: DataClass, + untrusted: bool, + truncated: bool, + }, + Failed { + message: String, + }, + Denied { + reason: DenyReason, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DenyReason { + NoGrant, + GrantExpired, + TaintTooHigh, + DeniedByGrant, + ApprovalRefused, + ApprovalExpired, +} diff --git a/crates/proto/tests/fixtures/wire/error.json b/crates/proto/tests/fixtures/wire/error.json new file mode 100644 index 0000000..0d08341 --- /dev/null +++ b/crates/proto/tests/fixtures/wire/error.json @@ -0,0 +1 @@ +{"v":1,"id":0,"final":true,"msg":{"kind":"error","body":{"code":"bad_version","detail":"expected 1"}}} diff --git a/crates/proto/tests/fixtures/wire/tool_request.json b/crates/proto/tests/fixtures/wire/tool_request.json new file mode 100644 index 0000000..d0f3bea --- /dev/null +++ b/crates/proto/tests/fixtures/wire/tool_request.json @@ -0,0 +1 @@ +{"v":1,"id":7,"final":true,"msg":{"kind":"tool_request","body":{"session":"mm-thread-42","call":3,"tool":"read_file","arguments":"{\"path\":\"/etc/hosts\"}"}}} diff --git a/crates/proto/tests/fixtures/wire/tool_response_denied.json b/crates/proto/tests/fixtures/wire/tool_response_denied.json new file mode 100644 index 0000000..ae6f72b --- /dev/null +++ b/crates/proto/tests/fixtures/wire/tool_response_denied.json @@ -0,0 +1 @@ +{"v":1,"id":9,"final":true,"msg":{"kind":"tool_response","body":{"status":"denied","reason":"taint_too_high"}}} diff --git a/crates/proto/tests/fixtures/wire/tool_response_failed.json b/crates/proto/tests/fixtures/wire/tool_response_failed.json new file mode 100644 index 0000000..9ccade2 --- /dev/null +++ b/crates/proto/tests/fixtures/wire/tool_response_failed.json @@ -0,0 +1 @@ +{"v":1,"id":8,"final":true,"msg":{"kind":"tool_response","body":{"status":"failed","message":"exit status 2"}}} diff --git a/crates/proto/tests/fixtures/wire/tool_response_pending.json b/crates/proto/tests/fixtures/wire/tool_response_pending.json new file mode 100644 index 0000000..a1148c9 --- /dev/null +++ b/crates/proto/tests/fixtures/wire/tool_response_pending.json @@ -0,0 +1 @@ +{"v":1,"id":7,"final":false,"msg":{"kind":"tool_response","body":{"status":"pending_approval","approval":"ap-0001","expires":"2026-09-17T08:35:00.000Z"}}} diff --git a/crates/proto/tests/fixtures/wire/tool_response_result.json b/crates/proto/tests/fixtures/wire/tool_response_result.json new file mode 100644 index 0000000..c133caa --- /dev/null +++ b/crates/proto/tests/fixtures/wire/tool_response_result.json @@ -0,0 +1 @@ +{"v":1,"id":7,"final":true,"msg":{"kind":"tool_response","body":{"status":"result","content":"127.0.0.1 localhost\n","class":"private","untrusted":true,"truncated":false}}} diff --git a/crates/proto/tests/wire.rs b/crates/proto/tests/wire.rs new file mode 100644 index 0000000..c8df146 --- /dev/null +++ b/crates/proto/tests/wire.rs @@ -0,0 +1,177 @@ +//! Tests for IPC messages against byte-exact fixtures. Do not edit these or the fixtures. + +use proto::{ + CallId, DataClass, DenyReason, Envelope, ErrorCode, Message, SessionId, Timestamp, ToolRequest, + ToolResponse, WireError, +}; + +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, want: Envelope) { + 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 response(id: u64, r#final: bool, body: ToolResponse) -> Envelope { + Envelope { + v: 1, + id, + r#final, + msg: Message::ToolResponse(body), + } +} + +#[test] +fn tool_request() { + let body = ToolRequest { + session: SessionId::new("mm-thread-42").unwrap(), + call: CallId(3), + tool: "read_file".to_string(), + arguments: r#"{"path":"/etc/hosts"}"#.to_string(), + }; + check( + "tool_request.json", + Envelope { + v: 1, + id: 7, + r#final: true, + msg: Message::ToolRequest(body), + }, + ); +} + +#[test] +fn tool_response_pending() { + let body = ToolResponse::PendingApproval { + approval: "ap-0001".to_string(), + expires: Timestamp::parse("2026-09-17T08:35:00.000Z").unwrap(), + }; + check("tool_response_pending.json", response(7, false, body)); +} + +#[test] +fn tool_response_result() { + let body = ToolResponse::Result { + content: "127.0.0.1 localhost\n".to_string(), + class: DataClass::Private, + untrusted: true, + truncated: false, + }; + check("tool_response_result.json", response(7, true, body)); +} + +#[test] +fn tool_response_failed() { + let body = ToolResponse::Failed { + message: "exit status 2".to_string(), + }; + check("tool_response_failed.json", response(8, true, body)); +} + +#[test] +fn tool_response_denied() { + let body = ToolResponse::Denied { + reason: DenyReason::TaintTooHigh, + }; + check("tool_response_denied.json", response(9, true, body)); +} + +#[test] +fn error_message() { + let body = WireError { + code: ErrorCode::BadVersion, + detail: "expected 1".to_string(), + }; + check( + "error.json", + Envelope { + v: 1, + id: 0, + r#final: true, + msg: Message::Error(body), + }, + ); +} + +#[test] +fn deny_reasons_and_error_codes_are_snake_case() { + let reasons = [ + (DenyReason::NoGrant, "no_grant"), + (DenyReason::GrantExpired, "grant_expired"), + (DenyReason::TaintTooHigh, "taint_too_high"), + (DenyReason::DeniedByGrant, "denied_by_grant"), + (DenyReason::ApprovalRefused, "approval_refused"), + (DenyReason::ApprovalExpired, "approval_expired"), + ]; + for (value, text) in reasons { + assert_eq!( + serde_json::to_string(&value).unwrap(), + format!("\"{text}\"") + ); + } + let codes = [ + (ErrorCode::BadFrame, "bad_frame"), + (ErrorCode::BadVersion, "bad_version"), + (ErrorCode::BadMessage, "bad_message"), + (ErrorCode::Internal, "internal"), + ]; + for (value, text) in codes { + assert_eq!( + serde_json::to_string(&value).unwrap(), + format!("\"{text}\"") + ); + } +} + +#[test] +fn unknown_and_missing_fields_are_rejected() { + let good = fixture("tool_request.json"); + assert!(serde_json::from_str::(&good).is_ok()); + let bad = [ + // extra field in the envelope + good.replacen("{\"v\":1,", "{\"v\":1,\"extra\":0,", 1), + // extra field beside kind and body + good.replacen( + "\"kind\":\"tool_request\",", + "\"kind\":\"tool_request\",\"x\":1,", + 1, + ), + // extra field in the body + good.replacen("\"call\":3,", "\"call\":3,\"priority\":9,", 1), + // missing field in the body + good.replacen("\"call\":3,", "", 1), + // missing `final` + good.replacen("\"final\":true,", "", 1), + // unknown kind + good.replacen("tool_request", "tool_demand", 1), + // invalid session id inside a message + good.replacen("mm-thread-42", "../../etc", 1), + ]; + for text in bad { + assert!( + serde_json::from_str::(&text).is_err(), + "accepted {text}" + ); + } +} + +#[test] +fn unknown_field_in_a_response_variant_is_rejected() { + let good = fixture("tool_response_denied.json"); + let bad = good.replacen("\"reason\":", "\"note\":\"x\",\"reason\":", 1); + assert!(serde_json::from_str::(&good).is_ok()); + assert!(serde_json::from_str::(&bad).is_err()); + let bad_status = good.replacen("denied", "refused", 1); + assert!(serde_json::from_str::(&bad_status).is_err()); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 36115ec..9e307bf 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -7,5 +7,6 @@ reviewer adds findings under "Reviews" once per milestone. |---|---|---|---|---|---|---| | M1/01-workspace-and-gate | 2026-09-17 | done | 1 | pass | none | Crate skeletons, Cargo files and the given Makefile/deny.toml/test-gate-scripts.sh were already present untracked from a prior attempt; I verified them against the plan and created only the missing gate scripts, dependencies.md, egress.md and this log row. | | M1/02-proto-values | 2026-09-17 | done | 1 | pass | none | Implemented ValueError, SessionId, Epoch, CallId, Hash32 and Timestamp in crates/proto/src/ids.rs and DataClass in class.rs, using serde try_from/into for string-backed JSON validation, a hand-written hex encoder and humantime for RFC 3339 parsing with canonical re-serialization. | +| M1/03-proto-wire | 2026-09-17 | done | 2 | pass | none | Added Envelope, Message, WireError, ErrorCode, ToolRequest, ToolResponse and DenyReason in crates/proto/src/wire.rs, re-exported from lib.rs; all 9 fixture tests pass and `make gate` prints `gate: ok`. | ## Reviews