Skip to main content

harness_loop/hooks/
audit.rs

1//! [`AuditHook`] — a compliance-oriented audit trail for agent runs.
2//!
3//! Where `harness-experience` records a run so the agent can *learn* from it,
4//! this records a run so a *human* (compliance, a manager, an auditor) can later
5//! answer: **who** asked **what**, **what the AI answered**, and **which data
6//! sources it touched**. For the SMB process/policy/BI deployment this framework
7//! targets, that trail is a hard requirement, not a nicety.
8//!
9//! It's an ordinary [`Hook`]: attach it to the loop and it writes one JSON line
10//! per event to an [`AuditSink`]. Identity (`actor`, `session`) is read from
11//! [`Context::metadata`](harness_core::Context) — the serving layer stamps it
12//! per request:
13//!
14//! ```ignore
15//! use harness_loop::hooks::audit::{AuditHook, JsonlAuditSink, ACTOR_KEY, SESSION_KEY};
16//! use harness_core::redact::Redactor;
17//! use std::sync::Arc;
18//!
19//! let sink = Arc::new(JsonlAuditSink::new("/var/lib/myapp/audit.jsonl")?);
20//! // Redact PII out of the trail (optional but recommended for logs at rest).
21//! let hook = Arc::new(AuditHook::new(sink).with_redactor(Redactor::new()));
22//!
23//! // Per request, in the serving layer:
24//! ctx.metadata.insert(ACTOR_KEY.into(), "alice@sales".into());
25//! ctx.metadata.insert(SESSION_KEY.into(), session_id.into());
26//! ```
27//!
28//! Records captured: `request` (actor + question), `response` (answer + token
29//! usage), `tool_use` (tool + args + ok — the data-source touch), `session_end`.
30
31use harness_core::redact::Redactor;
32use harness_core::{Event, Hook, HookOutcome, World};
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35use sha2::{Digest, Sha256};
36use std::io::Write;
37use std::path::Path;
38use std::sync::{Arc, Mutex};
39
40/// `Context.metadata` key for the acting user's identity (e.g. `alice@sales`).
41pub const ACTOR_KEY: &str = "audit.actor";
42/// `Context.metadata` key for the session/conversation id.
43pub const SESSION_KEY: &str = "audit.session";
44/// `Context.metadata` key for the per-request correlation id. The same id keys
45/// the OTel trace and the replay recording, so an audit line points straight at
46/// the full trace and a re-runnable recording of that exact request.
47pub const REQUEST_KEY: &str = "audit.request";
48
49/// One line in the audit trail. Serialized as a single JSON object.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AuditRecord {
52    /// RFC 3339 local timestamp.
53    pub ts: String,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub actor: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub session: Option<String>,
58    /// Per-request correlation id (ties this line to its trace + replay).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub request: Option<String>,
61    /// `request` | `response` | `tool_use` | `session_end`.
62    pub kind: String,
63    /// Event-specific payload.
64    pub detail: Value,
65}
66
67/// Where audit records go. Implement this to ship to a DB, syslog, SIEM, etc.
68pub trait AuditSink: Send + Sync + 'static {
69    fn record(&self, rec: &AuditRecord);
70}
71
72/// Appends one JSON object per line to a file, opened in append mode so the
73/// trail survives restarts. Cheap and grep-able — the default for single-machine
74/// deployments.
75pub struct JsonlAuditSink {
76    file: Mutex<std::fs::File>,
77}
78
79impl JsonlAuditSink {
80    /// Open (creating if needed) `path` for appending audit records.
81    pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
82        let file = std::fs::OpenOptions::new()
83            .create(true)
84            .append(true)
85            .open(path)?;
86        Ok(Self {
87            file: Mutex::new(file),
88        })
89    }
90}
91
92impl AuditSink for JsonlAuditSink {
93    fn record(&self, rec: &AuditRecord) {
94        // Audit logging must never break the run: a poisoned lock or IO error is
95        // swallowed (and surfaced via `tracing`) rather than propagated.
96        match (self.file.lock(), serde_json::to_string(rec)) {
97            (Ok(mut f), Ok(line)) => {
98                if let Err(e) = writeln!(f, "{line}") {
99                    tracing::warn!(target: "harness.audit", error = %e, "audit write failed");
100                }
101            }
102            _ => tracing::warn!(target: "harness.audit", "audit record dropped"),
103        }
104    }
105}
106
107/// Identity captured from the first model call of a run.
108#[derive(Default, Clone)]
109struct Who {
110    actor: Option<String>,
111    session: Option<String>,
112    request: Option<String>,
113}
114
115/// Emits an [`AuditRecord`] per lifecycle event of interest. Build one per run
116/// (the serving layer does this per request), attach with `.with_hook(...)`.
117pub struct AuditHook {
118    sink: Arc<dyn AuditSink>,
119    redactor: Option<Redactor>,
120    who: Mutex<Who>,
121    request_logged: Mutex<bool>,
122}
123
124impl AuditHook {
125    pub fn new(sink: Arc<dyn AuditSink>) -> Self {
126        Self {
127            sink,
128            redactor: None,
129            who: Mutex::new(Who::default()),
130            request_logged: Mutex::new(false),
131        }
132    }
133
134    /// Scrub PII out of recorded text/args before it hits the sink. Recommended
135    /// for trails stored at rest. Without it, records are verbatim.
136    pub fn with_redactor(mut self, r: Redactor) -> Self {
137        self.redactor = Some(r);
138        self
139    }
140
141    fn scrub_str(&self, s: &str) -> String {
142        match &self.redactor {
143            Some(r) => r.scrub(s).text,
144            None => s.to_string(),
145        }
146    }
147
148    /// Recursively scrub string leaves of a JSON value (tool args may embed PII).
149    fn scrub_value(&self, v: &Value) -> Value {
150        match (&self.redactor, v) {
151            (Some(r), Value::String(s)) => Value::String(r.scrub(s).text),
152            (_, Value::Array(a)) => Value::Array(a.iter().map(|x| self.scrub_value(x)).collect()),
153            (_, Value::Object(o)) => Value::Object(
154                o.iter()
155                    .map(|(k, x)| (k.clone(), self.scrub_value(x)))
156                    .collect(),
157            ),
158            _ => v.clone(),
159        }
160    }
161
162    fn emit(&self, kind: &str, detail: Value) {
163        let who = self.who.lock().unwrap().clone();
164        self.sink.record(&AuditRecord {
165            ts: now_rfc3339(),
166            actor: who.actor,
167            session: who.session,
168            request: who.request,
169            kind: kind.to_string(),
170            detail,
171        });
172    }
173}
174
175/// Wall-clock timestamp for the trail. Real time is what an auditor needs.
176fn now_rfc3339() -> String {
177    chrono::Local::now().to_rfc3339()
178}
179
180/// Process-local monotonic tail so ids are unique even within the same millisecond.
181static REQUEST_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
182
183/// Mint a per-request correlation id: `req-<unix_millis>-<counter>`. Stamp it
184/// into [`REQUEST_KEY`] on the request's `Context.metadata` and the same id keys
185/// the audit trail, the OTel trace, and any replay recording — one value ties all
186/// three together. The serving layer does this automatically; call it directly
187/// when you drive an `AgentLoop` yourself.
188pub fn new_request_id() -> String {
189    let millis = std::time::SystemTime::now()
190        .duration_since(std::time::UNIX_EPOCH)
191        .map(|d| d.as_millis())
192        .unwrap_or(0);
193    let n = REQUEST_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
194    format!("req-{millis}-{n}")
195}
196
197impl Hook for AuditHook {
198    fn name(&self) -> &str {
199        "audit"
200    }
201
202    fn matches(&self, _ev: &Event<'_>) -> bool {
203        true
204    }
205
206    fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
207        match ev {
208            Event::PreModel { ctx } => {
209                // Capture identity once, from request metadata.
210                {
211                    let mut who = self.who.lock().unwrap();
212                    if who.actor.is_none() {
213                        who.actor = ctx
214                            .metadata
215                            .get(ACTOR_KEY)
216                            .and_then(Value::as_str)
217                            .map(str::to_string);
218                    }
219                    if who.session.is_none() {
220                        who.session = ctx
221                            .metadata
222                            .get(SESSION_KEY)
223                            .and_then(Value::as_str)
224                            .map(str::to_string);
225                    }
226                    if who.request.is_none() {
227                        who.request = ctx
228                            .metadata
229                            .get(REQUEST_KEY)
230                            .and_then(Value::as_str)
231                            .map(str::to_string);
232                    }
233                }
234                // Log the question once per run (the task description).
235                let mut logged = self.request_logged.lock().unwrap();
236                if !*logged {
237                    *logged = true;
238                    let question = self.scrub_str(&ctx.task.description);
239                    self.emit("request", json!({ "question": question }));
240                }
241            }
242            Event::PostModel { out } => {
243                let answer = out.text.as_deref().map(|t| self.scrub_str(t));
244                self.emit(
245                    "response",
246                    json!({
247                        "answer": answer,
248                        "input_tokens": out.usage.input_tokens,
249                        "output_tokens": out.usage.output_tokens,
250                        "tool_calls": out.tool_calls.len(),
251                    }),
252                );
253            }
254            Event::PostToolUse { action, result } => {
255                self.emit(
256                    "tool_use",
257                    json!({
258                        "tool": action.tool,
259                        "args": self.scrub_value(&action.args),
260                        "ok": result.ok,
261                    }),
262                );
263            }
264            // Whether anything agreed the work was done. A trail that lists
265            // every tool the agent called and never says this answers "what
266            // did it touch" while leaving "was it right" to the reader.
267            Event::AcceptanceChecked {
268                name,
269                passed,
270                reason,
271            } => {
272                self.emit(
273                    "acceptance",
274                    json!({ "check": name, "passed": passed, "reason": self.scrub_str(reason) }),
275                );
276            }
277            // The one record here that is about the agent rather than the work.
278            Event::SealBreached { detail } => {
279                self.emit("seal_breach", json!({ "detail": detail }));
280            }
281            Event::SessionEnd => {
282                self.emit("session_end", json!({}));
283            }
284            _ => {}
285        }
286        HookOutcome::Allow
287    }
288}
289
290// ---------------------------------------------------------------------------
291// Tamper-evident trail: hash-chained sink
292// ---------------------------------------------------------------------------
293
294/// One envelope in a hash-chained trail: the record plus the chain fields that
295/// make deletion / edit / reordering detectable.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct ChainedRecord {
298    /// 0-based position in the chain.
299    pub seq: u64,
300    /// The previous envelope's `hash` (`"genesis"` for the first).
301    pub prev: String,
302    /// `sha256(prev + json(record))`, hex-encoded.
303    pub hash: String,
304    /// The audited record.
305    pub record: AuditRecord,
306}
307
308/// Result of [`verify_chain`].
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct ChainVerification {
311    /// True if every link verifies and the sequence is unbroken.
312    pub ok: bool,
313    /// How many envelopes were checked (up to the first break).
314    pub checked: usize,
315    /// The `seq` at which verification failed, if any.
316    pub broken_at: Option<u64>,
317}
318
319/// hex(`sha256(concat(parts))`).
320fn sha256_hex(parts: &[&str]) -> String {
321    let mut h = Sha256::new();
322    for p in parts {
323        h.update(p.as_bytes());
324    }
325    let digest = h.finalize();
326    let mut out = String::with_capacity(digest.len() * 2);
327    for b in digest {
328        use std::fmt::Write;
329        let _ = write!(out, "{b:02x}");
330    }
331    out
332}
333
334/// Read the last envelope's `(hash, seq)` from an existing trail, if any.
335fn read_chain_tail(path: &Path) -> std::io::Result<Option<(String, u64)>> {
336    match std::fs::read_to_string(path) {
337        Ok(content) => match content.lines().rfind(|l| !l.trim().is_empty()) {
338            Some(line) => {
339                let rec: ChainedRecord = serde_json::from_str(line)
340                    .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
341                Ok(Some((rec.hash, rec.seq)))
342            }
343            None => Ok(None),
344        },
345        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
346        Err(e) => Err(e),
347    }
348}
349
350/// Tamper-evident [`AuditSink`]: each record is wrapped in a [`ChainedRecord`]
351/// whose `hash` folds in the previous record's hash. Deleting, editing, or
352/// reordering any line breaks the chain, which [`verify_chain`] detects. On
353/// restart it resumes from the file's last hash, so the chain spans restarts.
354///
355/// This is integrity (tamper-*evidence*), not secrecy — pair with
356/// [`AuditHook::with_redactor`] to also keep PII out of the trail, and store the
357/// file on append-only / WORM media for stronger guarantees.
358pub struct HashChainSink {
359    state: Mutex<ChainState>,
360}
361
362struct ChainState {
363    file: std::fs::File,
364    prev: String,
365    seq: u64,
366}
367
368impl HashChainSink {
369    /// Open (creating if needed) `path`, resuming the chain from its tail.
370    pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
371        let path = path.as_ref();
372        let (prev, seq) = match read_chain_tail(path)? {
373            Some((hash, last_seq)) => (hash, last_seq + 1),
374            None => ("genesis".to_string(), 0),
375        };
376        if let Some(parent) = path.parent()
377            && !parent.as_os_str().is_empty()
378        {
379            std::fs::create_dir_all(parent)?;
380        }
381        let file = std::fs::OpenOptions::new()
382            .create(true)
383            .append(true)
384            .open(path)?;
385        Ok(Self {
386            state: Mutex::new(ChainState { file, prev, seq }),
387        })
388    }
389}
390
391impl AuditSink for HashChainSink {
392    fn record(&self, rec: &AuditRecord) {
393        let Ok(mut st) = self.state.lock() else {
394            tracing::warn!(target: "harness.audit", "audit record dropped (poisoned lock)");
395            return;
396        };
397        let Ok(body) = serde_json::to_string(rec) else {
398            tracing::warn!(target: "harness.audit", "audit record dropped (serialize)");
399            return;
400        };
401        let hash = sha256_hex(&[&st.prev, &body]);
402        let envelope = ChainedRecord {
403            seq: st.seq,
404            prev: st.prev.clone(),
405            hash: hash.clone(),
406            record: rec.clone(),
407        };
408        match serde_json::to_string(&envelope) {
409            Ok(line) if writeln!(st.file, "{line}").is_ok() => {
410                st.prev = hash;
411                st.seq += 1;
412            }
413            _ => tracing::warn!(target: "harness.audit", "audit chain write failed"),
414        }
415    }
416}
417
418/// Walk a hash-chained trail and confirm every link. Detects any deletion,
419/// edit, or reordering. A missing file verifies as an empty (ok) chain.
420pub fn verify_chain(path: impl AsRef<Path>) -> std::io::Result<ChainVerification> {
421    let content = match std::fs::read_to_string(path.as_ref()) {
422        Ok(c) => c,
423        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
424            return Ok(ChainVerification {
425                ok: true,
426                checked: 0,
427                broken_at: None,
428            });
429        }
430        Err(e) => return Err(e),
431    };
432    let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
433    let mut prev = "genesis".to_string();
434    for (i, line) in lines.iter().enumerate() {
435        let env: ChainedRecord = serde_json::from_str(line)
436            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
437        let body = serde_json::to_string(&env.record)
438            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
439        let recomputed = sha256_hex(&[&prev, &body]);
440        // seq must equal position (catches deletion/reorder), prev must match the
441        // running hash (catches reorder), hash must recompute (catches edits).
442        if env.seq != i as u64 || env.prev != prev || env.hash != recomputed {
443            return Ok(ChainVerification {
444                ok: false,
445                checked: i,
446                broken_at: Some(env.seq),
447            });
448        }
449        prev = env.hash;
450    }
451    Ok(ChainVerification {
452        ok: true,
453        checked: lines.len(),
454        broken_at: None,
455    })
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use harness_core::{Action, Context, ModelOutput, Task, ToolResult, Usage};
462
463    /// Collects records in memory for assertions.
464    #[derive(Default)]
465    struct VecSink(Mutex<Vec<AuditRecord>>);
466    impl AuditSink for VecSink {
467        fn record(&self, rec: &AuditRecord) {
468            self.0.lock().unwrap().push(rec.clone());
469        }
470    }
471
472    fn world() -> World {
473        harness_context::default_world(std::env::temp_dir().join("audit-test"))
474    }
475
476    fn ctx(question: &str, actor: &str) -> Context {
477        let mut c = Context::new(Task {
478            description: question.into(),
479            source: None,
480            deadline: None,
481        });
482        c.metadata.insert(ACTOR_KEY.into(), actor.into());
483        c.metadata.insert(SESSION_KEY.into(), "sess-1".into());
484        c.metadata.insert(REQUEST_KEY.into(), "req-42".into());
485        c
486    }
487
488    #[test]
489    fn records_request_response_and_tool_use_with_identity() {
490        let sink = Arc::new(VecSink::default());
491        let hook = AuditHook::new(sink.clone());
492        let mut w = world();
493
494        let c = ctx("what is our refund policy?", "alice@sales");
495        hook.fire(&Event::PreModel { ctx: &c }, &mut w);
496        // A second PreModel must NOT duplicate the request line.
497        hook.fire(&Event::PreModel { ctx: &c }, &mut w);
498
499        let out = ModelOutput {
500            text: Some("30 days".into()),
501            usage: Usage {
502                input_tokens: 100,
503                output_tokens: 5,
504                cached_input_tokens: 0,
505                cache_write_input_tokens: 0,
506            },
507            ..Default::default()
508        };
509        hook.fire(&Event::PostModel { out: &out }, &mut w);
510
511        let action = Action {
512            tool: "policy_search".into(),
513            call_id: "c1".into(),
514            args: json!({ "q": "refund" }),
515        };
516        let result = ToolResult {
517            ok: true,
518            content: json!("..."),
519            trace: None,
520        };
521        hook.fire(
522            &Event::PostToolUse {
523                action: &action,
524                result: &result,
525            },
526            &mut w,
527        );
528        hook.fire(&Event::SessionEnd, &mut w);
529
530        let recs = sink.0.lock().unwrap();
531        let kinds: Vec<&str> = recs.iter().map(|r| r.kind.as_str()).collect();
532        assert_eq!(
533            kinds,
534            vec!["request", "response", "tool_use", "session_end"]
535        );
536        // Identity flows onto every record.
537        assert!(
538            recs.iter()
539                .all(|r| r.actor.as_deref() == Some("alice@sales"))
540        );
541        assert!(recs.iter().all(|r| r.session.as_deref() == Some("sess-1")));
542        // The correlation id rides on every record (ties to trace + replay).
543        assert!(recs.iter().all(|r| r.request.as_deref() == Some("req-42")));
544        assert_eq!(recs[0].detail["question"], "what is our refund policy?");
545        assert_eq!(recs[1].detail["answer"], "30 days");
546        assert_eq!(recs[2].detail["tool"], "policy_search");
547    }
548
549    #[test]
550    fn redactor_scrubs_pii_from_the_trail() {
551        let sink = Arc::new(VecSink::default());
552        let hook = AuditHook::new(sink.clone()).with_redactor(Redactor::new());
553        let mut w = world();
554
555        let out = ModelOutput {
556            text: Some("reach me at ll_faw@hotmail.com".into()),
557            ..Default::default()
558        };
559        hook.fire(&Event::PostModel { out: &out }, &mut w);
560
561        let recs = sink.0.lock().unwrap();
562        let answer = recs[0].detail["answer"].as_str().unwrap();
563        assert!(
564            !answer.contains("ll_faw@hotmail.com"),
565            "email must be redacted: {answer}"
566        );
567    }
568
569    fn rec(kind: &str) -> AuditRecord {
570        AuditRecord {
571            ts: "t".into(),
572            actor: Some("a".into()),
573            session: Some("s".into()),
574            request: Some("r".into()),
575            kind: kind.into(),
576            detail: json!({ "k": kind }),
577        }
578    }
579
580    #[test]
581    fn hash_chain_verifies_and_survives_restart() {
582        let path = std::env::temp_dir().join(format!("audit-chain-{}.jsonl", std::process::id()));
583        let _ = std::fs::remove_file(&path);
584
585        {
586            let sink = HashChainSink::new(&path).unwrap();
587            sink.record(&rec("request"));
588            sink.record(&rec("response"));
589        } // drop → reopen below resumes the chain
590
591        // Reopen (simulates a restart) and append more.
592        {
593            let sink = HashChainSink::new(&path).unwrap();
594            sink.record(&rec("session_end"));
595        }
596
597        let v = verify_chain(&path).unwrap();
598        assert!(v.ok, "intact chain must verify: {v:?}");
599        assert_eq!(v.checked, 3);
600        assert_eq!(v.broken_at, None);
601        let _ = std::fs::remove_file(&path);
602    }
603
604    #[test]
605    fn hash_chain_detects_tampering() {
606        let path = std::env::temp_dir().join(format!("audit-tamper-{}.jsonl", std::process::id()));
607        let _ = std::fs::remove_file(&path);
608
609        {
610            let sink = HashChainSink::new(&path).unwrap();
611            sink.record(&rec("request"));
612            sink.record(&rec("response"));
613            sink.record(&rec("session_end"));
614        }
615
616        // Edit the middle record's payload in place (classic cover-up).
617        let content = std::fs::read_to_string(&path).unwrap();
618        let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
619        lines[1] = lines[1].replace("\"response\"", "\"totally-different\"");
620        std::fs::write(&path, lines.join("\n")).unwrap();
621
622        let v = verify_chain(&path).unwrap();
623        assert!(!v.ok, "tampered chain must fail verification");
624        assert_eq!(v.broken_at, Some(1), "break should be flagged at seq 1");
625        let _ = std::fs::remove_file(&path);
626    }
627
628    #[test]
629    fn hash_chain_detects_deletion() {
630        let path = std::env::temp_dir().join(format!("audit-del-{}.jsonl", std::process::id()));
631        let _ = std::fs::remove_file(&path);
632
633        {
634            let sink = HashChainSink::new(&path).unwrap();
635            sink.record(&rec("request"));
636            sink.record(&rec("response"));
637            sink.record(&rec("session_end"));
638        }
639
640        // Delete the middle line — seq/prev continuity breaks.
641        let content = std::fs::read_to_string(&path).unwrap();
642        let lines: Vec<&str> = content.lines().collect();
643        std::fs::write(&path, format!("{}\n{}\n", lines[0], lines[2])).unwrap();
644
645        let v = verify_chain(&path).unwrap();
646        assert!(!v.ok, "chain with a deleted record must fail");
647        let _ = std::fs::remove_file(&path);
648    }
649}