Add audit and session log record types to proto

Implemented-By: Laguna S 2.1 (OpenCode)
This commit is contained in:
2026-09-17 08:36:44 -07:00
parent 78e13195ea
commit b9f3ab45c1
7 changed files with 272 additions and 0 deletions
+36
View File
@@ -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<String>,
},
Denied {
reason: DenyReason,
grant: Option<String>,
},
}
#[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,
}
+4
View File
@@ -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,
+54
View File
@@ -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<String>,
reasoning_content: Option<String>,
tool_calls: Vec<ToolCall>,
},
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,
},
}
+4
View File
@@ -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}}
+7
View File
@@ -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."}
+166
View File
@@ -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<T: Serialize + DeserializeOwned + PartialEq + Debug>(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::<LogRecord>(user).is_ok());
let extra = user.replacen("\"content\"", "\"role\":\"user\",\"content\"", 1);
assert!(serde_json::from_str::<LogRecord>(&extra).is_err());
let unknown_type = user.replacen("\"user\"", "\"system\"", 1);
assert!(serde_json::from_str::<LogRecord>(&unknown_type).is_err());
let decision = r#"{"outcome":"allowed","grant":"g"}"#;
assert!(serde_json::from_str::<DecisionRecord>(decision).is_ok());
let extra = decision.replacen("\"grant\"", "\"why\":\"\",\"grant\"", 1);
assert!(serde_json::from_str::<DecisionRecord>(&extra).is_err());
}
+1
View File
@@ -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::<Envelope>; 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