Skip to main content

split_brain_harness/
session_log.rs

1/// Append-only session escalation log for `sbh serve`.
2///
3/// Every time the multi-turn slow-boil escalation algorithm fires, one JSON
4/// line is written here.  The log is append-only and never modified in place,
5/// making it suitable as a witness-layer feed.
6///
7/// Raw input is never stored.  Each entry carries an FNV-1a-64 fingerprint of
8/// the user input and a masked client IP (last two IPv4 octets zeroed) so
9/// entries can be correlated without preserving PII.
10use std::io::Write;
11use std::net::IpAddr;
12
13use serde::{Deserialize, Serialize};
14
15use crate::audit::{fingerprint, iso_now};
16
17// ---------------------------------------------------------------------------
18// Entry
19// ---------------------------------------------------------------------------
20
21#[derive(Debug, Serialize, Deserialize, Clone)]
22pub struct SessionLogEntry {
23    /// ISO 8601 UTC timestamp of the escalation event.
24    pub timestamp: String,
25    /// Event type — always `"escalation_detected"` in this release.
26    pub event: String,
27    /// Session identifier echoed from `x-sbh-session`.
28    pub session_id: String,
29    /// Number of turns in this session at the time of the event.
30    pub turn_count: usize,
31    /// Full risk trajectory for the session window, e.g. `["low","low","high"]`.
32    pub risk_trajectory: Vec<String>,
33    /// Risk label of the turn that triggered the alert.
34    pub current_risk: String,
35    /// Mean risk score of all turns except the triggering turn (0=low,1=medium,2=high).
36    pub historical_mean: f64,
37    /// Client IP with last two IPv4 octets (or last four IPv6 groups) masked.
38    pub client_ip_masked: String,
39    /// FNV-1a-64 hex fingerprint of the raw user input — no plaintext stored.
40    pub input_fingerprint: String,
41}
42
43impl SessionLogEntry {
44    pub fn new(
45        session_id: String,
46        turn_count: usize,
47        risk_trajectory: Vec<String>,
48        historical_mean: f64,
49        client_ip: &IpAddr,
50        user_input: &str,
51    ) -> Self {
52        let current_risk = risk_trajectory
53            .last()
54            .cloned()
55            .unwrap_or_else(|| "unknown".into());
56        Self {
57            timestamp: iso_now(),
58            event: "escalation_detected".into(),
59            session_id,
60            turn_count,
61            risk_trajectory,
62            current_risk,
63            historical_mean,
64            client_ip_masked: mask_ip(client_ip),
65            input_fingerprint: fingerprint(user_input.as_bytes()),
66        }
67    }
68}
69
70// ---------------------------------------------------------------------------
71// IP masking — last two IPv4 octets zeroed; last 64 bits of IPv6 zeroed
72// ---------------------------------------------------------------------------
73
74pub fn mask_ip(ip: &IpAddr) -> String {
75    match ip {
76        IpAddr::V4(v4) => {
77            let o = v4.octets();
78            format!("{}.{}.x.x", o[0], o[1])
79        }
80        IpAddr::V6(v6) => {
81            let s = v6.segments();
82            format!("{:x}:{:x}:{:x}:{:x}:x:x:x:x", s[0], s[1], s[2], s[3])
83        }
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Append
89// ---------------------------------------------------------------------------
90
91pub fn append(path: &str, entry: &SessionLogEntry) -> std::io::Result<()> {
92    let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
93    line.push('\n');
94    let mut file = std::fs::OpenOptions::new()
95        .create(true)
96        .append(true)
97        .open(path)?;
98    file.write_all(line.as_bytes())
99}
100
101// ---------------------------------------------------------------------------
102// Read
103// ---------------------------------------------------------------------------
104
105pub fn read_all(path: &str) -> std::io::Result<Vec<SessionLogEntry>> {
106    let raw = std::fs::read_to_string(path)?;
107    let entries = raw
108        .lines()
109        .filter(|l| !l.trim().is_empty())
110        .filter_map(|l| serde_json::from_str::<SessionLogEntry>(l).ok())
111        .collect();
112    Ok(entries)
113}
114
115// ---------------------------------------------------------------------------
116// Tests
117// ---------------------------------------------------------------------------
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
123
124    #[test]
125    fn mask_ipv4_zeros_last_two_octets() {
126        let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50));
127        assert_eq!(mask_ip(&ip), "192.168.x.x");
128    }
129
130    #[test]
131    fn mask_ipv4_loopback() {
132        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
133        assert_eq!(mask_ip(&ip), "127.0.x.x");
134    }
135
136    #[test]
137    fn mask_ipv6_zeros_last_four_groups() {
138        let ip = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 1, 2, 3, 4));
139        let masked = mask_ip(&ip);
140        assert!(masked.starts_with("2001:db8:0:0:"), "got: {masked}");
141        assert!(masked.ends_with(":x:x:x:x"), "got: {masked}");
142    }
143
144    #[test]
145    fn append_and_read_roundtrip() {
146        let dir = tempfile::tempdir().unwrap();
147        let path = dir.path().join("session.jsonl");
148        let path_str = path.to_str().unwrap();
149
150        let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 1, 2));
151        let entry = SessionLogEntry::new(
152            "sbh-s-42".into(),
153            3,
154            vec!["low".into(), "low".into(), "high".into()],
155            0.0,
156            &ip,
157            "test input that triggered escalation",
158        );
159
160        append(path_str, &entry).unwrap();
161        append(path_str, &entry).unwrap();
162
163        let entries = read_all(path_str).unwrap();
164        assert_eq!(entries.len(), 2);
165        assert_eq!(entries[0].event, "escalation_detected");
166        assert_eq!(entries[0].session_id, "sbh-s-42");
167        assert_eq!(entries[0].turn_count, 3);
168        assert_eq!(entries[0].current_risk, "high");
169        assert_eq!(entries[0].risk_trajectory, vec!["low", "low", "high"]);
170        assert_eq!(entries[0].client_ip_masked, "10.0.x.x");
171        assert!(!entries[0].input_fingerprint.is_empty());
172    }
173
174    #[test]
175    fn fingerprint_is_stable_across_entries() {
176        let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
177        let input = "same input every time";
178        let e1 = SessionLogEntry::new(
179            "s1".into(), 3, vec!["low".into(), "low".into(), "high".into()],
180            0.0, &ip, input,
181        );
182        let e2 = SessionLogEntry::new(
183            "s2".into(), 4, vec!["low".into(), "low".into(), "low".into(), "high".into()],
184            0.0, &ip, input,
185        );
186        assert_eq!(e1.input_fingerprint, e2.input_fingerprint);
187    }
188
189    #[test]
190    fn new_sets_current_risk_from_trajectory() {
191        let ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
192        let entry = SessionLogEntry::new(
193            "s".into(), 3,
194            vec!["low".into(), "medium".into(), "high".into()],
195            0.5, &ip, "x",
196        );
197        assert_eq!(entry.current_risk, "high");
198    }
199
200    #[test]
201    fn new_empty_trajectory_current_risk_unknown() {
202        let ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
203        let entry = SessionLogEntry::new("s".into(), 0, vec![], 0.0, &ip, "x");
204        assert_eq!(entry.current_risk, "unknown");
205    }
206}