Replace the audit record with chained audit events

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-19 00:22:25 -07:00
parent cb5ecad4da
commit c6395b16f4
6 changed files with 270 additions and 71 deletions
+183 -47
View File
@@ -1,8 +1,8 @@
//! 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,
ApprovalAnswer, AuditEvent, AuditRecord, CallId, DataClass, DecisionRecord, DenyReason, Epoch,
Hash32, LogRecord, ResultStatus, SessionId, Timestamp, ToolCall,
};
use serde::{Serialize, de::DeserializeOwned};
use std::fmt::Debug;
@@ -35,64 +35,189 @@ fn check<T: Serialize + DeserializeOwned + PartialEq + Debug>(name: &str, want:
}
}
/// 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 {
fn sid(s: &str) -> SessionId {
SessionId::new(s).unwrap()
}
fn record(seq: u64, time: &str, prev: Hash32, event: AuditEvent) -> 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,
},
event,
}
}
/// One line per event variant, every `DecisionRecord` variant, and every option both set and unset.
#[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(),
record(
0,
"2026-09-17T08:05:00.000Z",
Hash32::ZERO,
AuditEvent::Decision {
session: sid("mm-thread-42"),
call: CallId(1),
tool: "read_file".to_string(),
arguments: r#"{"path":"/etc/hosts"}"#.to_string(),
outcome: DecisionRecord::Allowed {},
grant: Some("read-etc".to_string()),
grant_sha256: Some(seq_hash),
taint: DataClass::Private,
untrusted: false,
},
..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(),
),
record(
1,
"2026-09-17T08:05:00.500Z",
seq_hash,
AuditEvent::Result {
session: sid("mm-thread-42"),
call: CallId(1),
decision: 0,
status: ResultStatus::Result,
class: DataClass::Secret,
untrusted: true,
truncated: true,
bytes: 65536,
sha256: rev_hash,
taint_after: DataClass::Secret,
},
),
record(
2,
"2026-09-17T08:05:01.250Z",
rev_hash,
AuditEvent::Decision {
session: sid("mm-thread-42"),
call: CallId(2),
tool: "shell".to_string(),
arguments: r#"{"command":"df -h"}"#.to_string(),
outcome: DecisionRecord::Ask {},
grant: Some("shell-ask".to_string()),
grant_sha256: Some(rev_hash),
taint: DataClass::Secret,
untrusted: true,
},
),
record(
3,
"2026-09-17T08:06:00.000Z",
seq_hash,
AuditEvent::Approval {
session: sid("mm-thread-42"),
call: CallId(2),
decision: 2,
answer: ApprovalAnswer::Approved,
by: Some("u8f3k2".to_string()),
post: Some("p9x7".to_string()),
reason: None,
outcome: DecisionRecord::Allowed {},
grant: Some("shell-auto".to_string()),
grant_sha256: Some(seq_hash),
taint: DataClass::Secret,
untrusted: true,
},
..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()),
),
record(
4,
"2026-09-17T08:06:00.100Z",
rev_hash,
AuditEvent::Result {
session: sid("mm-thread-42"),
call: CallId(2),
decision: 2,
status: ResultStatus::Failed,
class: DataClass::Private,
untrusted: false,
truncated: false,
bytes: 27,
sha256: seq_hash,
taint_after: DataClass::Secret,
},
..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")
},
),
record(
5,
"2026-09-17T08:07:00.000Z",
seq_hash,
AuditEvent::Decision {
session: sid("cron-morning"),
call: CallId(1),
tool: "rm_rf".to_string(),
arguments: "{}".to_string(),
outcome: DecisionRecord::Denied {
reason: DenyReason::NoGrant,
},
grant: None,
grant_sha256: None,
taint: DataClass::Private,
untrusted: false,
},
),
record(
6,
"2026-09-17T08:08:00.000Z",
rev_hash,
AuditEvent::Approval {
session: sid("cron-morning"),
call: CallId(3),
decision: 4,
answer: ApprovalAnswer::Refused,
by: Some("bxctl".to_string()),
post: None,
reason: Some("not \"now\"".to_string()),
outcome: DecisionRecord::Denied {
reason: DenyReason::ApprovalRefused,
},
grant: None,
grant_sha256: None,
taint: DataClass::Private,
untrusted: false,
},
),
record(
7,
"2026-09-17T08:23:00.000Z",
seq_hash,
AuditEvent::Approval {
session: sid("cron-morning"),
call: CallId(4),
decision: 5,
answer: ApprovalAnswer::Expired,
by: None,
post: None,
reason: None,
outcome: DecisionRecord::Denied {
reason: DenyReason::ApprovalExpired,
},
grant: None,
grant_sha256: None,
taint: DataClass::Public,
untrusted: false,
},
),
record(
8,
"2026-09-18T00:00:00.000Z",
rev_hash,
AuditEvent::Recovery {
torn_bytes: 117,
torn_sha256: seq_hash,
},
),
record(
9,
"2026-09-18T00:00:01.000Z",
seq_hash,
AuditEvent::AcceptedBreak {
file: "2026-09-17.jsonl".to_string(),
line: 7,
last_good: rev_hash,
},
),
];
check("audit.jsonl", &want);
}
@@ -159,8 +284,19 @@ fn unknown_fields_and_types_are_rejected() {
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);
// A variant with no fields must reject an unknown key too. A serde unit variant would not.
for decision in [r#"{"outcome":"allowed"}"#, r#"{"outcome":"ask"}"#] {
assert!(serde_json::from_str::<DecisionRecord>(decision).is_ok());
let extra = decision.replacen('}', ",\"why\":\"\"}", 1);
assert!(
serde_json::from_str::<DecisionRecord>(&extra).is_err(),
"{extra}"
);
}
let denied = r#"{"outcome":"denied","reason":"no_grant"}"#;
assert!(serde_json::from_str::<DecisionRecord>(denied).is_ok());
let extra = denied.replacen("\"reason\"", "\"grant\":null,\"reason\"", 1);
assert!(serde_json::from_str::<DecisionRecord>(&extra).is_err());
let approved = r#"{"outcome":"approved"}"#;
assert!(serde_json::from_str::<DecisionRecord>(approved).is_err());
}