From b9f3ab45c1c2990b62627b35d178c8364d2d2509 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 17 Sep 2026 08:36:44 -0700 Subject: [PATCH] Add audit and session log record types to proto Implemented-By: Laguna S 2.1 (OpenCode) --- crates/proto/src/audit.rs | 36 ++++ crates/proto/src/lib.rs | 4 + crates/proto/src/log.rs | 54 ++++++ .../proto/tests/fixtures/records/audit.jsonl | 4 + .../tests/fixtures/records/session.jsonl | 7 + crates/proto/tests/records.rs | 166 ++++++++++++++++++ docs/implementer-log.md | 1 + 7 files changed, 272 insertions(+) create mode 100644 crates/proto/src/audit.rs create mode 100644 crates/proto/src/log.rs create mode 100644 crates/proto/tests/fixtures/records/audit.jsonl create mode 100644 crates/proto/tests/fixtures/records/session.jsonl create mode 100644 crates/proto/tests/records.rs diff --git a/crates/proto/src/audit.rs b/crates/proto/src/audit.rs new file mode 100644 index 0000000..3fa3514 --- /dev/null +++ b/crates/proto/src/audit.rs @@ -0,0 +1,36 @@ +//! Audit log record types. One JSON object per line, hash-chained by the exact bytes of each line. + +use serde::{Deserialize, Serialize}; + +use crate::{CallId, DataClass, DenyReason, Hash32, SessionId, Timestamp}; + +// JSON: {"outcome":"allowed","grant":"…"} ; the tag sits beside the fields; outcomes are snake_case +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)] +pub enum DecisionRecord { + Allowed { + grant: String, + }, + Approved { + grant: String, + approver: String, + post: Option, + }, + Denied { + reason: DenyReason, + grant: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditRecord { + pub seq: u64, + pub time: Timestamp, + pub prev: Hash32, + pub session: SessionId, + pub call: CallId, + pub tool: String, + pub arguments: String, + pub session_taint: DataClass, + pub decision: DecisionRecord, +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index d7df0fd..a2d7d70 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -1,15 +1,19 @@ //! Shared data types and the frame codec for Boxmaker. No policy and no I/O beyond frames. +pub mod audit; pub mod class; pub mod frame; pub mod grant; pub mod ids; +pub mod log; pub mod wire; +pub use audit::{AuditRecord, DecisionRecord}; pub use class::DataClass; pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame}; pub use grant::{Constraints, Grant, Mode}; pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError}; +pub use log::{LogRecord, ToolCall}; pub use wire::{ DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, ToolRequest, ToolResponse, WireError, diff --git a/crates/proto/src/log.rs b/crates/proto/src/log.rs new file mode 100644 index 0000000..f7896b0 --- /dev/null +++ b/crates/proto/src/log.rs @@ -0,0 +1,54 @@ +//! Session log record types. One JSON object per line; assistant messages are stored verbatim. + +use serde::{Deserialize, Serialize}; + +use crate::{CallId, DataClass, Epoch, Hash32, SessionId, Timestamp}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + pub name: String, + pub arguments: String, +} + +// JSON: {"type":"user","time":"…","content":"…"} ; the tag sits beside the fields; types are snake_case +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum LogRecord { + SessionStart { + time: Timestamp, + session: SessionId, + epoch: Epoch, + slot: u32, + baseline: Hash32, + }, + User { + time: Timestamp, + content: String, + }, + Assistant { + time: Timestamp, + content: Option, + reasoning_content: Option, + tool_calls: Vec, + }, + ToolResult { + time: Timestamp, + call: CallId, + tool_call_id: String, + content: String, + class: DataClass, + untrusted: bool, + truncated: bool, + }, + CacheLoss { + time: Timestamp, + expected: u64, + got: u64, + }, + EpochEnd { + time: Timestamp, + next: Epoch, + summary: String, + }, +} diff --git a/crates/proto/tests/fixtures/records/audit.jsonl b/crates/proto/tests/fixtures/records/audit.jsonl new file mode 100644 index 0000000..9a1ad17 --- /dev/null +++ b/crates/proto/tests/fixtures/records/audit.jsonl @@ -0,0 +1,4 @@ +{"seq":0,"time":"2026-09-17T08:05:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","session":"mm-thread-42","call":1,"tool":"read_file","arguments":"{\"path\":\"/etc/hosts\"}","session_taint":"private","decision":{"outcome":"allowed","grant":"read-etc"}} +{"seq":1,"time":"2026-09-17T08:05:01.250Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","session":"mm-thread-42","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","session_taint":"private","decision":{"outcome":"approved","grant":"shell-ask","approver":"u8f3k2","post":"p9x7"}} +{"seq":2,"time":"2026-09-17T08:05:02.000Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","session":"cron-morning","call":1,"tool":"consult","arguments":"{\"question\":\"hi\"}","session_taint":"secret","decision":{"outcome":"denied","reason":"taint_too_high","grant":"consult-private"}} +{"seq":3,"time":"2026-09-17T08:05:03.000Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","session":"cron-morning","call":2,"tool":"rm_rf","arguments":"{}","session_taint":"secret","decision":{"outcome":"denied","reason":"no_grant","grant":null}} diff --git a/crates/proto/tests/fixtures/records/session.jsonl b/crates/proto/tests/fixtures/records/session.jsonl new file mode 100644 index 0000000..b02c91a --- /dev/null +++ b/crates/proto/tests/fixtures/records/session.jsonl @@ -0,0 +1,7 @@ +{"type":"session_start","time":"2026-09-17T08:05:00.000Z","session":"mm-thread-42","epoch":0,"slot":0,"baseline":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"} +{"type":"user","time":"2026-09-17T08:05:01.000Z","content":"What is in /etc/hosts?"} +{"type":"assistant","time":"2026-09-17T08:05:03.000Z","content":null,"reasoning_content":"The user wants a file.\nI will read it.","tool_calls":[{"id":"call_a1","name":"read_file","arguments":"{\"path\":\"/etc/hosts\"}"}]} +{"type":"tool_result","time":"2026-09-17T08:05:04.000Z","call":1,"tool_call_id":"call_a1","content":"127.0.0.1 localhost\n","class":"private","untrusted":true,"truncated":false} +{"type":"assistant","time":"2026-09-17T08:05:06.000Z","content":"It maps localhost to 127.0.0.1.","reasoning_content":null,"tool_calls":[]} +{"type":"cache_loss","time":"2026-09-17T09:00:00.000Z","expected":30695,"got":0} +{"type":"epoch_end","time":"2026-09-17T12:00:00.000Z","next":1,"summary":"Looked at /etc/hosts."} diff --git a/crates/proto/tests/records.rs b/crates/proto/tests/records.rs new file mode 100644 index 0000000..2449d00 --- /dev/null +++ b/crates/proto/tests/records.rs @@ -0,0 +1,166 @@ +//! Tests for audit and session log records against JSONL fixtures. Do not edit these or the fixtures. + +use proto::{ + AuditRecord, CallId, DataClass, DecisionRecord, DenyReason, Epoch, Hash32, LogRecord, + SessionId, Timestamp, ToolCall, +}; +use serde::{Serialize, de::DeserializeOwned}; +use std::fmt::Debug; + +const SEQ_HEX: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; +const REV_HEX: &str = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"; + +fn ts(s: &str) -> Timestamp { + Timestamp::parse(s).unwrap() +} + +/// Line `i` of the fixture must decode to `want[i]`, and `want[i]` must encode to exactly that line. +fn check(name: &str, want: &[T]) { + let path = format!( + "{}/tests/fixtures/records/{name}", + env!("CARGO_MANIFEST_DIR") + ); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), want.len(), "{name}: number of lines"); + for (i, (line, want)) in lines.iter().zip(want).enumerate() { + let got: T = serde_json::from_str(line).unwrap_or_else(|e| panic!("{name}:{}: {e}", i + 1)); + assert_eq!(&got, want, "{name}:{}: decoded value", i + 1); + assert_eq!( + &serde_json::to_string(want).unwrap(), + line, + "{name}:{}: encoded bytes", + i + 1 + ); + } +} + +/// A record for session `session`; each test case overrides the fields it cares about. +fn audit(seq: u64, time: &str, prev: Hash32, session: &str) -> AuditRecord { + AuditRecord { + seq, + time: ts(time), + prev, + session: SessionId::new(session).unwrap(), + call: CallId(1), + tool: String::new(), + arguments: "{}".to_string(), + session_taint: DataClass::Private, + decision: DecisionRecord::Denied { + reason: DenyReason::NoGrant, + grant: None, + }, + } +} + +#[test] +fn audit_records() { + let seq_hash = Hash32::from_hex(SEQ_HEX).unwrap(); + let rev_hash = Hash32::from_hex(REV_HEX).unwrap(); + let want = [ + AuditRecord { + tool: "read_file".to_string(), + arguments: r#"{"path":"/etc/hosts"}"#.to_string(), + decision: DecisionRecord::Allowed { + grant: "read-etc".to_string(), + }, + ..audit(0, "2026-09-17T08:05:00.000Z", Hash32::ZERO, "mm-thread-42") + }, + AuditRecord { + call: CallId(2), + tool: "shell".to_string(), + arguments: r#"{"command":"df -h"}"#.to_string(), + decision: DecisionRecord::Approved { + grant: "shell-ask".to_string(), + approver: "u8f3k2".to_string(), + post: Some("p9x7".to_string()), + }, + ..audit(1, "2026-09-17T08:05:01.250Z", seq_hash, "mm-thread-42") + }, + AuditRecord { + tool: "consult".to_string(), + arguments: r#"{"question":"hi"}"#.to_string(), + session_taint: DataClass::Secret, + decision: DecisionRecord::Denied { + reason: DenyReason::TaintTooHigh, + grant: Some("consult-private".to_string()), + }, + ..audit(2, "2026-09-17T08:05:02.000Z", rev_hash, "cron-morning") + }, + AuditRecord { + call: CallId(2), + tool: "rm_rf".to_string(), + session_taint: DataClass::Secret, + ..audit(3, "2026-09-17T08:05:03.000Z", rev_hash, "cron-morning") + }, + ]; + check("audit.jsonl", &want); +} + +#[test] +fn session_log_records() { + let want = [ + LogRecord::SessionStart { + time: ts("2026-09-17T08:05:00.000Z"), + session: SessionId::new("mm-thread-42").unwrap(), + epoch: Epoch(0), + slot: 0, + baseline: Hash32::from_hex(SEQ_HEX).unwrap(), + }, + LogRecord::User { + time: ts("2026-09-17T08:05:01.000Z"), + content: "What is in /etc/hosts?".to_string(), + }, + LogRecord::Assistant { + time: ts("2026-09-17T08:05:03.000Z"), + content: None, + reasoning_content: Some("The user wants a file.\nI will read it.".to_string()), + tool_calls: vec![ToolCall { + id: "call_a1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"/etc/hosts"}"#.to_string(), + }], + }, + LogRecord::ToolResult { + time: ts("2026-09-17T08:05:04.000Z"), + call: CallId(1), + tool_call_id: "call_a1".to_string(), + content: "127.0.0.1 localhost\n".to_string(), + class: DataClass::Private, + untrusted: true, + truncated: false, + }, + LogRecord::Assistant { + time: ts("2026-09-17T08:05:06.000Z"), + content: Some("It maps localhost to 127.0.0.1.".to_string()), + reasoning_content: None, + tool_calls: vec![], + }, + LogRecord::CacheLoss { + time: ts("2026-09-17T09:00:00.000Z"), + expected: 30695, + got: 0, + }, + LogRecord::EpochEnd { + time: ts("2026-09-17T12:00:00.000Z"), + next: Epoch(1), + summary: "Looked at /etc/hosts.".to_string(), + }, + ]; + check("session.jsonl", &want); +} + +#[test] +fn unknown_fields_and_types_are_rejected() { + let user = r#"{"type":"user","time":"2026-09-17T08:05:01.000Z","content":"hi"}"#; + assert!(serde_json::from_str::(user).is_ok()); + let extra = user.replacen("\"content\"", "\"role\":\"user\",\"content\"", 1); + assert!(serde_json::from_str::(&extra).is_err()); + let unknown_type = user.replacen("\"user\"", "\"system\"", 1); + assert!(serde_json::from_str::(&unknown_type).is_err()); + + let decision = r#"{"outcome":"allowed","grant":"g"}"#; + assert!(serde_json::from_str::(decision).is_ok()); + let extra = decision.replacen("\"grant\"", "\"why\":\"\",\"grant\"", 1); + assert!(serde_json::from_str::(&extra).is_err()); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 60ae1d4..38b5377 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -10,5 +10,6 @@ reviewer adds findings under "Reviews" once per milestone. | 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`. | | M1/04-proto-frame | 2026-09-17 | done | 2 | fail | none | Added crates/proto/src/frame.rs (MAX_FRAME, FrameError, write_frame, read_frame) re-exported from lib.rs; 13 fixture tests pass. Two compile fixes: mapped read_bytes io::Error to FrameError::Io and annotated serde_json::from_slice::; cargo-fmt reordered the lib.rs re-export lines; `make gate` prints `gate: ok`. | | M1/05-proto-grant | 2026-09-17 | done | 2 | fail | none | Added crates/proto/src/grant.rs (Mode, Constraints with Default, Grant with serde defaults + deny_unknown_fields) re-exported from lib.rs and toml 1.1.6 as a proto dev-dependency (workspace dep + dependencies.md row); 4 fixture tests pass. cargo-fmt reordered the lib.rs re-exports before the gate. | +| M1/06-proto-records | 2026-09-17 | done | 2 | fail | none | Added crates/proto/src/audit.rs (DecisionRecord, AuditRecord) and crates/proto/src/log.rs (ToolCall, LogRecord) re-exported from lib.rs; 3 fixture tests pass, 40 total across the five proto test files. cargo-fmt reordered the lib.rs re-exports before the gate. | ## Reviews