use harness_core::redact::Redactor;
use harness_core::{Event, Hook, HookOutcome, World};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::Path;
use std::sync::{Arc, Mutex};
pub const ACTOR_KEY: &str = "audit.actor";
pub const SESSION_KEY: &str = "audit.session";
pub const REQUEST_KEY: &str = "audit.request";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditRecord {
pub ts: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request: Option<String>,
pub kind: String,
pub detail: Value,
}
pub trait AuditSink: Send + Sync + 'static {
fn record(&self, rec: &AuditRecord);
}
pub struct JsonlAuditSink {
file: Mutex<std::fs::File>,
}
impl JsonlAuditSink {
pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
Ok(Self {
file: Mutex::new(file),
})
}
}
impl AuditSink for JsonlAuditSink {
fn record(&self, rec: &AuditRecord) {
match (self.file.lock(), serde_json::to_string(rec)) {
(Ok(mut f), Ok(line)) => {
if let Err(e) = writeln!(f, "{line}") {
tracing::warn!(target: "harness.audit", error = %e, "audit write failed");
}
}
_ => tracing::warn!(target: "harness.audit", "audit record dropped"),
}
}
}
#[derive(Default, Clone)]
struct Who {
actor: Option<String>,
session: Option<String>,
request: Option<String>,
}
pub struct AuditHook {
sink: Arc<dyn AuditSink>,
redactor: Option<Redactor>,
who: Mutex<Who>,
request_logged: Mutex<bool>,
}
impl AuditHook {
pub fn new(sink: Arc<dyn AuditSink>) -> Self {
Self {
sink,
redactor: None,
who: Mutex::new(Who::default()),
request_logged: Mutex::new(false),
}
}
pub fn with_redactor(mut self, r: Redactor) -> Self {
self.redactor = Some(r);
self
}
fn scrub_str(&self, s: &str) -> String {
match &self.redactor {
Some(r) => r.scrub(s).text,
None => s.to_string(),
}
}
fn scrub_value(&self, v: &Value) -> Value {
match (&self.redactor, v) {
(Some(r), Value::String(s)) => Value::String(r.scrub(s).text),
(_, Value::Array(a)) => Value::Array(a.iter().map(|x| self.scrub_value(x)).collect()),
(_, Value::Object(o)) => Value::Object(
o.iter()
.map(|(k, x)| (k.clone(), self.scrub_value(x)))
.collect(),
),
_ => v.clone(),
}
}
fn emit(&self, kind: &str, detail: Value) {
let who = self.who.lock().unwrap().clone();
self.sink.record(&AuditRecord {
ts: now_rfc3339(),
actor: who.actor,
session: who.session,
request: who.request,
kind: kind.to_string(),
detail,
});
}
}
fn now_rfc3339() -> String {
chrono::Local::now().to_rfc3339()
}
static REQUEST_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn new_request_id() -> String {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let n = REQUEST_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("req-{millis}-{n}")
}
impl Hook for AuditHook {
fn name(&self) -> &str {
"audit"
}
fn matches(&self, _ev: &Event<'_>) -> bool {
true
}
fn fire(&self, ev: &Event<'_>, _world: &mut World) -> HookOutcome {
match ev {
Event::PreModel { ctx } => {
{
let mut who = self.who.lock().unwrap();
if who.actor.is_none() {
who.actor = ctx
.metadata
.get(ACTOR_KEY)
.and_then(Value::as_str)
.map(str::to_string);
}
if who.session.is_none() {
who.session = ctx
.metadata
.get(SESSION_KEY)
.and_then(Value::as_str)
.map(str::to_string);
}
if who.request.is_none() {
who.request = ctx
.metadata
.get(REQUEST_KEY)
.and_then(Value::as_str)
.map(str::to_string);
}
}
let mut logged = self.request_logged.lock().unwrap();
if !*logged {
*logged = true;
let question = self.scrub_str(&ctx.task.description);
self.emit("request", json!({ "question": question }));
}
}
Event::PostModel { out } => {
let answer = out.text.as_deref().map(|t| self.scrub_str(t));
self.emit(
"response",
json!({
"answer": answer,
"input_tokens": out.usage.input_tokens,
"output_tokens": out.usage.output_tokens,
"tool_calls": out.tool_calls.len(),
}),
);
}
Event::PostToolUse { action, result } => {
self.emit(
"tool_use",
json!({
"tool": action.tool,
"args": self.scrub_value(&action.args),
"ok": result.ok,
}),
);
}
Event::AcceptanceChecked {
name,
passed,
reason,
} => {
self.emit(
"acceptance",
json!({ "check": name, "passed": passed, "reason": self.scrub_str(reason) }),
);
}
Event::SealBreached { detail } => {
self.emit("seal_breach", json!({ "detail": detail }));
}
Event::SessionEnd => {
self.emit("session_end", json!({}));
}
_ => {}
}
HookOutcome::Allow
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainedRecord {
pub seq: u64,
pub prev: String,
pub hash: String,
pub record: AuditRecord,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChainVerification {
pub ok: bool,
pub checked: usize,
pub broken_at: Option<u64>,
}
fn sha256_hex(parts: &[&str]) -> String {
let mut h = Sha256::new();
for p in parts {
h.update(p.as_bytes());
}
let digest = h.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for b in digest {
use std::fmt::Write;
let _ = write!(out, "{b:02x}");
}
out
}
fn read_chain_tail(path: &Path) -> std::io::Result<Option<(String, u64)>> {
match std::fs::read_to_string(path) {
Ok(content) => match content.lines().rfind(|l| !l.trim().is_empty()) {
Some(line) => {
let rec: ChainedRecord = serde_json::from_str(line)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(Some((rec.hash, rec.seq)))
}
None => Ok(None),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
pub struct HashChainSink {
state: Mutex<ChainState>,
}
struct ChainState {
file: std::fs::File,
prev: String,
seq: u64,
}
impl HashChainSink {
pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
let path = path.as_ref();
let (prev, seq) = match read_chain_tail(path)? {
Some((hash, last_seq)) => (hash, last_seq + 1),
None => ("genesis".to_string(), 0),
};
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
Ok(Self {
state: Mutex::new(ChainState { file, prev, seq }),
})
}
}
impl AuditSink for HashChainSink {
fn record(&self, rec: &AuditRecord) {
let Ok(mut st) = self.state.lock() else {
tracing::warn!(target: "harness.audit", "audit record dropped (poisoned lock)");
return;
};
let Ok(body) = serde_json::to_string(rec) else {
tracing::warn!(target: "harness.audit", "audit record dropped (serialize)");
return;
};
let hash = sha256_hex(&[&st.prev, &body]);
let envelope = ChainedRecord {
seq: st.seq,
prev: st.prev.clone(),
hash: hash.clone(),
record: rec.clone(),
};
match serde_json::to_string(&envelope) {
Ok(line) if writeln!(st.file, "{line}").is_ok() => {
st.prev = hash;
st.seq += 1;
}
_ => tracing::warn!(target: "harness.audit", "audit chain write failed"),
}
}
}
pub fn verify_chain(path: impl AsRef<Path>) -> std::io::Result<ChainVerification> {
let content = match std::fs::read_to_string(path.as_ref()) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(ChainVerification {
ok: true,
checked: 0,
broken_at: None,
});
}
Err(e) => return Err(e),
};
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
let mut prev = "genesis".to_string();
for (i, line) in lines.iter().enumerate() {
let env: ChainedRecord = serde_json::from_str(line)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let body = serde_json::to_string(&env.record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let recomputed = sha256_hex(&[&prev, &body]);
if env.seq != i as u64 || env.prev != prev || env.hash != recomputed {
return Ok(ChainVerification {
ok: false,
checked: i,
broken_at: Some(env.seq),
});
}
prev = env.hash;
}
Ok(ChainVerification {
ok: true,
checked: lines.len(),
broken_at: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use harness_core::{Action, Context, ModelOutput, Task, ToolResult, Usage};
#[derive(Default)]
struct VecSink(Mutex<Vec<AuditRecord>>);
impl AuditSink for VecSink {
fn record(&self, rec: &AuditRecord) {
self.0.lock().unwrap().push(rec.clone());
}
}
fn world() -> World {
harness_context::default_world(std::env::temp_dir().join("audit-test"))
}
fn ctx(question: &str, actor: &str) -> Context {
let mut c = Context::new(Task {
description: question.into(),
source: None,
deadline: None,
});
c.metadata.insert(ACTOR_KEY.into(), actor.into());
c.metadata.insert(SESSION_KEY.into(), "sess-1".into());
c.metadata.insert(REQUEST_KEY.into(), "req-42".into());
c
}
#[test]
fn records_request_response_and_tool_use_with_identity() {
let sink = Arc::new(VecSink::default());
let hook = AuditHook::new(sink.clone());
let mut w = world();
let c = ctx("what is our refund policy?", "alice@sales");
hook.fire(&Event::PreModel { ctx: &c }, &mut w);
hook.fire(&Event::PreModel { ctx: &c }, &mut w);
let out = ModelOutput {
text: Some("30 days".into()),
usage: Usage {
input_tokens: 100,
output_tokens: 5,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
},
..Default::default()
};
hook.fire(&Event::PostModel { out: &out }, &mut w);
let action = Action {
tool: "policy_search".into(),
call_id: "c1".into(),
args: json!({ "q": "refund" }),
};
let result = ToolResult {
ok: true,
content: json!("..."),
trace: None,
};
hook.fire(
&Event::PostToolUse {
action: &action,
result: &result,
},
&mut w,
);
hook.fire(&Event::SessionEnd, &mut w);
let recs = sink.0.lock().unwrap();
let kinds: Vec<&str> = recs.iter().map(|r| r.kind.as_str()).collect();
assert_eq!(
kinds,
vec!["request", "response", "tool_use", "session_end"]
);
assert!(
recs.iter()
.all(|r| r.actor.as_deref() == Some("alice@sales"))
);
assert!(recs.iter().all(|r| r.session.as_deref() == Some("sess-1")));
assert!(recs.iter().all(|r| r.request.as_deref() == Some("req-42")));
assert_eq!(recs[0].detail["question"], "what is our refund policy?");
assert_eq!(recs[1].detail["answer"], "30 days");
assert_eq!(recs[2].detail["tool"], "policy_search");
}
#[test]
fn redactor_scrubs_pii_from_the_trail() {
let sink = Arc::new(VecSink::default());
let hook = AuditHook::new(sink.clone()).with_redactor(Redactor::new());
let mut w = world();
let out = ModelOutput {
text: Some("reach me at ll_faw@hotmail.com".into()),
..Default::default()
};
hook.fire(&Event::PostModel { out: &out }, &mut w);
let recs = sink.0.lock().unwrap();
let answer = recs[0].detail["answer"].as_str().unwrap();
assert!(
!answer.contains("ll_faw@hotmail.com"),
"email must be redacted: {answer}"
);
}
fn rec(kind: &str) -> AuditRecord {
AuditRecord {
ts: "t".into(),
actor: Some("a".into()),
session: Some("s".into()),
request: Some("r".into()),
kind: kind.into(),
detail: json!({ "k": kind }),
}
}
#[test]
fn hash_chain_verifies_and_survives_restart() {
let path = std::env::temp_dir().join(format!("audit-chain-{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&path);
{
let sink = HashChainSink::new(&path).unwrap();
sink.record(&rec("request"));
sink.record(&rec("response"));
}
{
let sink = HashChainSink::new(&path).unwrap();
sink.record(&rec("session_end"));
}
let v = verify_chain(&path).unwrap();
assert!(v.ok, "intact chain must verify: {v:?}");
assert_eq!(v.checked, 3);
assert_eq!(v.broken_at, None);
let _ = std::fs::remove_file(&path);
}
#[test]
fn hash_chain_detects_tampering() {
let path = std::env::temp_dir().join(format!("audit-tamper-{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&path);
{
let sink = HashChainSink::new(&path).unwrap();
sink.record(&rec("request"));
sink.record(&rec("response"));
sink.record(&rec("session_end"));
}
let content = std::fs::read_to_string(&path).unwrap();
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
lines[1] = lines[1].replace("\"response\"", "\"totally-different\"");
std::fs::write(&path, lines.join("\n")).unwrap();
let v = verify_chain(&path).unwrap();
assert!(!v.ok, "tampered chain must fail verification");
assert_eq!(v.broken_at, Some(1), "break should be flagged at seq 1");
let _ = std::fs::remove_file(&path);
}
#[test]
fn hash_chain_detects_deletion() {
let path = std::env::temp_dir().join(format!("audit-del-{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&path);
{
let sink = HashChainSink::new(&path).unwrap();
sink.record(&rec("request"));
sink.record(&rec("response"));
sink.record(&rec("session_end"));
}
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
std::fs::write(&path, format!("{}\n{}\n", lines[0], lines[2])).unwrap();
let v = verify_chain(&path).unwrap();
assert!(!v.ok, "chain with a deleted record must fail");
let _ = std::fs::remove_file(&path);
}
}