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.
|
||||
|
||||
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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user