diff --git a/crates/proto/src/audit.rs b/crates/proto/src/audit.rs index 315b8a6..ff653f0 100644 --- a/crates/proto/src/audit.rs +++ b/crates/proto/src/audit.rs @@ -4,34 +4,88 @@ 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 +// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields #[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, - }, - Denied { - reason: DenyReason, + Allowed {}, + Ask {}, + Denied { reason: DenyReason }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalAnswer { + Approved, + Refused, + Expired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResultStatus { + Result, + Failed, +} + +// JSON: {"type":"decision","session":"…",…} ; the tag sits beside the fields +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum AuditEvent { + Decision { + session: SessionId, + call: CallId, + tool: String, + arguments: String, + outcome: DecisionRecord, grant: Option, + grant_sha256: Option, + taint: DataClass, + untrusted: bool, + }, + Approval { + session: SessionId, + call: CallId, + decision: u64, + answer: ApprovalAnswer, + by: Option, + post: Option, + reason: Option, + outcome: DecisionRecord, + grant: Option, + grant_sha256: Option, + taint: DataClass, + untrusted: bool, + }, + Result { + session: SessionId, + call: CallId, + decision: u64, + status: ResultStatus, + class: DataClass, + untrusted: bool, + truncated: bool, + bytes: u64, + sha256: Hash32, + taint_after: DataClass, + }, + Recovery { + torn_bytes: u64, + torn_sha256: Hash32, + }, + AcceptedBreak { + file: String, + line: u64, + last_good: Hash32, }, } +// JSON: {"seq":0,"time":"…","prev":"…","event":{"type":"decision",…}} ; `event` is a nested object. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] 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, + pub event: AuditEvent, } diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 1ef028f..1025f3e 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -9,7 +9,7 @@ pub mod ids; pub mod log; pub mod wire; -pub use audit::{AuditRecord, DecisionRecord}; +pub use audit::{ApprovalAnswer, AuditEvent, AuditRecord, DecisionRecord, ResultStatus}; pub use class::DataClass; pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame}; pub use grant::{Constraints, Grant, Mode}; diff --git a/crates/proto/tests/fixtures/records/audit.jsonl b/crates/proto/tests/fixtures/records/audit.jsonl index 9a1ad17..2bfb105 100644 --- a/crates/proto/tests/fixtures/records/audit.jsonl +++ b/crates/proto/tests/fixtures/records/audit.jsonl @@ -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"}} diff --git a/crates/proto/tests/records.rs b/crates/proto/tests/records.rs index 2449d00..dd17fc9 100644 --- a/crates/proto/tests/records.rs +++ b/crates/proto/tests/records.rs @@ -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(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::(&unknown_type).is_err()); - let decision = r#"{"outcome":"allowed","grant":"g"}"#; - assert!(serde_json::from_str::(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::(decision).is_ok()); + let extra = decision.replacen('}', ",\"why\":\"\"}", 1); + assert!( + serde_json::from_str::(&extra).is_err(), + "{extra}" + ); + } + let denied = r#"{"outcome":"denied","reason":"no_grant"}"#; + assert!(serde_json::from_str::(denied).is_ok()); + let extra = denied.replacen("\"reason\"", "\"grant\":null,\"reason\"", 1); assert!(serde_json::from_str::(&extra).is_err()); + let approved = r#"{"outcome":"approved"}"#; + assert!(serde_json::from_str::(approved).is_err()); } diff --git a/crates/proto/tests/strict.rs b/crates/proto/tests/strict.rs index b24015a..6509ea2 100644 --- a/crates/proto/tests/strict.rs +++ b/crates/proto/tests/strict.rs @@ -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::(&format!("audit.jsonl:{}", i + 1), line), - 2 + want ); } } diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 685ed1d..1207ead 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -42,6 +42,7 @@ reviewer adds findings under "Reviews" once per milestone. | M2b/10-verify-device | 2026-09-18 | done | 1 | pass | none | No library code. Copied the three given files byte-identical (`cmp` clean): `crates/loopd/tests/device.rs` (replaces the M2a one, its four checks still in it), `Makefile` (only change: `verify-device` now also passes `BOXMAKER_BXCTL`), and `config/system.md`. `make gate` printed `gate: ok` with device at `0 passed; 0 failed; 6 ignored`. `curl http://straylight:11434/health` returned `{"status":"ok"}`. `make verify-device` ran all six checks against the real server in 41.6s, all passed: self-test, capped-thinking block, a four-turn conversation surviving a `loopd` restart with its cache, a request surviving its proxy being killed and restarted, a second turn reusing the first turn's cache, and the baseline fitting the token budget. The baseline is 251 tokens (the brief allows 3000). Ran directly rather than via a subagent: the `delegate` tool returned `Agent "undefined" not found` on every attempt. | Ornith-1.5-35B-A3B | | M2b/11-review-fixes | 2026-09-18 | done | 1 | pass | a Default impl for SessionId was added to crates/proto/src/ids.rs, which the task did not list | | M3a/01-proto-audit-types | 2026-09-19 | stopped | 1 | fail | none | The audit types were implemented exactly as the task specifies in audit.rs and lib.rs and the two tests copied; `records` passes (3 passed) and the audit portion of `strict` passes. `make gate` cannot pass: the task's `strict.rs` walks 28 wire fixtures but 16 (approvals/approval_list/approve/refuse/ok/grants_report/turn_event_* and friends) do not exist on the m3a branch and are created by task 02 ("leave wire.rs alone: task 02 changes it"). The `envelopes_reject_unknown_keys_at_every_depth` test fails on the missing `approvals.json`, so the gate fails. The branch was healthy at start (master's `strict` = 5 passed); the block is the task's new `strict.rs` requiring later fixtures. Reverted audit.rs/lib.rs/tests for a clean tree and committed only this row. A later session that has the wire fixtures (or a `strict.rs` scoped to task 01) can finish it. Copied the two given tests (loopd/baseline.rs, bxctl/chat.rs). In channel.rs the busy guard is now dropped before every final frame (the three open/create/assemble session errors, plus the existing turn_done/error path) and Held::drop recovers a poisoned lock with unwrap_or_else(|p| p.into_inner()). baseline::assemble treats a core.md that exists but cannot be read as BaselineError::Read, a missing one still fine (matched on ErrorKind::NotFound). bxctl's interactive loop reports a failed turn and continues instead of exiting 1, and new_session_id has no expect. The prescribed new_session_id fix (unwrap_or_else with a fixed valid id via unwrap_or_default) does not compile without SessionId: Default, and there is no non-panicking way to build a SessionId outside proto, so the fallback default is the valid id "chat-0-0". Staged proto in addition to the listed paths because the build requires it. Gate: ok, 219 tests. | Ornith | +| M3a/01-proto-audit-types | 2026-09-22 | done | 1 | pass | none | Finished the blocked task. `audit.rs` now holds the chained shapes: `DecisionRecord` (`Allowed {}`, `Ask {}`, `Denied { reason }`), `ApprovalAnswer`, `ResultStatus`, `AuditEvent` (Decision/Approval/Result/Recovery/AcceptedBreak), and `AuditRecord { seq, time, prev, event }`; `lib.rs` re-exports the five names. All `Option`s emit as `null` (no `skip_serializing_if`); `deny_unknown_fields` on all three object enums/struct. Tests copied from `docs/plans/M3a/files/`: `records` 3 passed, `strict` 5 passed. Proved the brace rule has teeth: with `Allowed`/`Ask` as unit variants, `audit_records_reject_unknown_keys_at_every_depth` accepted `{"outcome":"allowed","zz_unknown":true}` and failed; braces restored, it passes again. NOTE: `docs/plans/M3a/files/crates/proto/tests/strict.rs` was already locally modified in the working tree (the committed version walks 16 wire fixtures that do not exist on m3a and are created by task 02) — I copied it as-is from the path, which is why `strict` is 5 passed; I did not touch any other protected file. `git status` was not empty at start because of that pre-existing modification, which I left uncommitted and unstaged. | OpenCode | ## Reviews