Add audit and session log record types to proto
Implemented-By: Laguna S 2.1 (OpenCode)
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
//! Shared data types and the frame codec for Boxmaker. No policy and no I/O beyond frames.
|
//! 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 class;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
pub mod grant;
|
pub mod grant;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
|
pub mod log;
|
||||||
pub mod wire;
|
pub mod wire;
|
||||||
|
|
||||||
|
pub use audit::{AuditRecord, DecisionRecord};
|
||||||
pub use class::DataClass;
|
pub use class::DataClass;
|
||||||
pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame};
|
pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame};
|
||||||
pub use grant::{Constraints, Grant, Mode};
|
pub use grant::{Constraints, Grant, Mode};
|
||||||
pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError};
|
pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError};
|
||||||
|
pub use log::{LogRecord, ToolCall};
|
||||||
pub use wire::{
|
pub use wire::{
|
||||||
DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, ToolRequest, ToolResponse,
|
DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, ToolRequest, ToolResponse,
|
||||||
WireError,
|
WireError,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
@@ -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."}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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/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/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/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
|
## Reviews
|
||||||
|
|||||||
Reference in New Issue
Block a user