1use 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
40pub const ACTOR_KEY: &str = "audit.actor";
42pub const SESSION_KEY: &str = "audit.session";
44pub const REQUEST_KEY: &str = "audit.request";
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AuditRecord {
52 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 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub request: Option<String>,
61 pub kind: String,
63 pub detail: Value,
65}
66
67pub trait AuditSink: Send + Sync + 'static {
69 fn record(&self, rec: &AuditRecord);
70}
71
72pub struct JsonlAuditSink {
76 file: Mutex<std::fs::File>,
77}
78
79impl JsonlAuditSink {
80 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 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#[derive(Default, Clone)]
109struct Who {
110 actor: Option<String>,
111 session: Option<String>,
112 request: Option<String>,
113}
114
115pub 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 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 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
175fn now_rfc3339() -> String {
177 chrono::Local::now().to_rfc3339()
178}
179
180static REQUEST_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
182
183pub 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 {
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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct ChainedRecord {
298 pub seq: u64,
300 pub prev: String,
302 pub hash: String,
304 pub record: AuditRecord,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct ChainVerification {
311 pub ok: bool,
313 pub checked: usize,
315 pub broken_at: Option<u64>,
317}
318
319fn 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
334fn 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
350pub 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 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
418pub 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 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 #[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 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 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 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 } {
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 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 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}