Replace the audit record with chained audit events
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
+10
-4
@@ -1,4 +1,10 @@
|
||||
{"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}}
|
||||
{"seq":0,"time":"2026-09-17T08:05:00.000Z","prev":"0000000000000000000000000000000000000000000000000000000000000000","event":{"type":"decision","session":"mm-thread-42","call":1,"tool":"read_file","arguments":"{\"path\":\"/etc/hosts\"}","outcome":{"outcome":"allowed"},"grant":"read-etc","grant_sha256":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","taint":"private","untrusted":false}}
|
||||
{"seq":1,"time":"2026-09-17T08:05:00.500Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"result","session":"mm-thread-42","call":1,"decision":0,"status":"result","class":"secret","untrusted":true,"truncated":true,"bytes":65536,"sha256":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","taint_after":"secret"}}
|
||||
{"seq":2,"time":"2026-09-17T08:05:01.250Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","event":{"type":"decision","session":"mm-thread-42","call":2,"tool":"shell","arguments":"{\"command\":\"df -h\"}","outcome":{"outcome":"ask"},"grant":"shell-ask","grant_sha256":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","taint":"secret","untrusted":true}}
|
||||
{"seq":3,"time":"2026-09-17T08:06:00.000Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"approval","session":"mm-thread-42","call":2,"decision":2,"answer":"approved","by":"u8f3k2","post":"p9x7","reason":null,"outcome":{"outcome":"allowed"},"grant":"shell-auto","grant_sha256":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","taint":"secret","untrusted":true}}
|
||||
{"seq":4,"time":"2026-09-17T08:06:00.100Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","event":{"type":"result","session":"mm-thread-42","call":2,"decision":2,"status":"failed","class":"private","untrusted":false,"truncated":false,"bytes":27,"sha256":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","taint_after":"secret"}}
|
||||
{"seq":5,"time":"2026-09-17T08:07:00.000Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"decision","session":"cron-morning","call":1,"tool":"rm_rf","arguments":"{}","outcome":{"outcome":"denied","reason":"no_grant"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
|
||||
{"seq":6,"time":"2026-09-17T08:08:00.000Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","event":{"type":"approval","session":"cron-morning","call":3,"decision":4,"answer":"refused","by":"bxctl","post":null,"reason":"not \"now\"","outcome":{"outcome":"denied","reason":"approval_refused"},"grant":null,"grant_sha256":null,"taint":"private","untrusted":false}}
|
||||
{"seq":7,"time":"2026-09-17T08:23:00.000Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"approval","session":"cron-morning","call":4,"decision":5,"answer":"expired","by":null,"post":null,"reason":null,"outcome":{"outcome":"denied","reason":"approval_expired"},"grant":null,"grant_sha256":null,"taint":"public","untrusted":false}}
|
||||
{"seq":8,"time":"2026-09-18T00:00:00.000Z","prev":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100","event":{"type":"recovery","torn_bytes":117,"torn_sha256":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"}}
|
||||
{"seq":9,"time":"2026-09-18T00:00:01.000Z","prev":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","event":{"type":"accepted_break","file":"2026-09-17.jsonl","line":7,"last_good":"ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"}}
|
||||
|
||||
+183
-47
@@ -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());
|
||||
}
|
||||
|
||||
@@ -89,10 +89,12 @@ fn envelopes_reject_unknown_keys_at_every_depth() {
|
||||
#[test]
|
||||
fn audit_records_reject_unknown_keys_at_every_depth() {
|
||||
for (i, line) in fixture("records/audit.jsonl").lines().enumerate() {
|
||||
// The record and its decision: two objects.
|
||||
// The record and its event: two objects. Decision and approval events also hold an
|
||||
// outcome object.
|
||||
let want = if line.contains("\"outcome\"") { 3 } else { 2 };
|
||||
assert_eq!(
|
||||
check::<AuditRecord>(&format!("audit.jsonl:{}", i + 1), line),
|
||||
2
|
||||
want
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user