use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tracing::{Level, instrument, warn};
use crate::policy::Effect;
use crate::policy::ir::DecisionTrace;
use crate::session_dir::SessionDir;
#[derive(Debug, Serialize)]
struct AuditEntry<'a> {
timestamp: String,
session_id: &'a str,
tool_name: &'a str,
tool_input_summary: String,
decision: &'a str,
reason: Option<&'a str>,
matched_rules: usize,
skipped_rules: usize,
resolution: &'a str,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct AuditConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub path: Option<String>,
}
impl AuditConfig {
pub fn log_path(&self) -> PathBuf {
if let Some(ref path) = self.path {
PathBuf::from(path)
} else {
dirs::home_dir()
.map(|h| h.join(".clash").join("audit.jsonl"))
.unwrap_or_else(|| PathBuf::from("audit.jsonl"))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionStats {
pub allowed: u64,
pub denied: u64,
pub asked: u64,
pub last_tool: Option<String>,
pub last_input_summary: Option<String>,
pub last_effect: Option<Effect>,
pub last_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_deny_hint: Option<String>,
}
pub fn session_dir(session_id: &str) -> PathBuf {
SessionDir::new(session_id).root().to_path_buf()
}
fn stats_path(session_id: &str) -> PathBuf {
SessionDir::new(session_id).stats()
}
#[derive(Debug, thiserror::Error)]
pub enum StatsReadError {
#[error("stats file not found")]
NotFound,
#[error("failed to read stats: {0}")]
Io(#[from] std::io::Error),
#[error("malformed stats JSON: {0}")]
Malformed(#[from] serde_json::Error),
}
pub fn read_session_stats(session_id: &str) -> Result<SessionStats, StatsReadError> {
let path = stats_path(session_id);
let contents = std::fs::read_to_string(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
StatsReadError::NotFound
} else {
StatsReadError::Io(e)
}
})?;
Ok(serde_json::from_str(&contents)?)
}
pub fn update_session_stats(
session_id: &str,
tool_name: &str,
tool_input: &serde_json::Value,
effect: Effect,
cwd: &str,
) {
let mut stats = match read_session_stats(session_id) {
Ok(s) => s,
Err(StatsReadError::NotFound) => SessionStats::default(),
Err(e) => {
warn!(error = %e, "Failed to read session stats, resetting");
SessionStats::default()
}
};
match effect {
Effect::Allow => stats.allowed += 1,
Effect::Deny => stats.denied += 1,
Effect::Ask => stats.asked += 1,
}
stats.last_tool = Some(tool_name.to_string());
stats.last_effect = Some(effect);
stats.last_at = Some(chrono_timestamp());
stats.last_input_summary = Some(tool_input_summary(tool_name, tool_input, cwd));
stats.last_deny_hint = if effect == Effect::Deny {
match deny_hint(tool_name, tool_input, cwd) {
Ok(hint) => Some(hint),
Err(e) => {
warn!(error = %e, "Failed to generate deny hint");
None
}
}
} else {
None
};
write_session_stats(session_id, &stats);
}
fn write_session_stats(session_id: &str, stats: &SessionStats) {
let path = stats_path(session_id);
let tmp_path = SessionDir::new(session_id).root().join(".stats.json.tmp");
let json = match serde_json::to_string(stats) {
Ok(j) => j,
Err(e) => {
warn!(error = %e, "Failed to serialize session stats");
return;
}
};
if let Err(e) = std::fs::write(&tmp_path, &json) {
warn!(error = %e, path = %tmp_path.display(), "Failed to write session stats temp file");
return;
}
if let Err(e) = std::fs::rename(&tmp_path, &path) {
warn!(error = %e, "Failed to rename session stats temp file");
}
}
pub fn init_session(
session_id: &str,
cwd: &str,
source: Option<&str>,
model: Option<&str>,
) -> std::io::Result<PathBuf> {
let dir = session_dir(session_id);
std::fs::create_dir_all(&dir)?;
let metadata = serde_json::json!({
"session_id": session_id,
"cwd": cwd,
"source": source,
"model": model,
"started_at": chrono_timestamp(),
});
let meta_path = dir.join("metadata.json");
let mut f = std::fs::File::create(&meta_path)?;
serde_json::to_writer_pretty(&mut f, &metadata).map_err(std::io::Error::other)?;
Ok(dir)
}
#[instrument(level = Level::TRACE, skip(trace, tool_input))]
pub fn log_decision(
config: &AuditConfig,
session_id: &str,
tool_name: &str,
tool_input: &serde_json::Value,
effect: Effect,
reason: Option<&str>,
trace: &DecisionTrace,
) {
let input_str = tool_input.to_string();
let tool_input_summary = if input_str.len() > 200 {
let truncate_at = input_str
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= 200)
.last()
.unwrap_or(0);
format!("{}...", &input_str[..truncate_at])
} else {
input_str
};
let entry = AuditEntry {
timestamp: chrono_timestamp(),
session_id,
tool_name,
tool_input_summary,
decision: effect_str(effect),
reason,
matched_rules: trace.matched_rules.len(),
skipped_rules: trace.skipped_rules.len(),
resolution: &trace.final_resolution,
};
if config.enabled {
let path = config.log_path();
if let Err(e) = append_entry(&path, &entry) {
warn!(error = %e, path = %path.display(), "Failed to write audit log entry");
}
}
let session_log = SessionDir::new(session_id).audit_log();
if let Err(e) = append_entry(&session_log, &entry) {
warn!(error = %e, path = %session_log.display(), "Failed to write session audit entry");
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxViolation {
pub operation: String,
pub path: String,
}
#[derive(Debug, Serialize)]
struct SandboxViolationEntry<'a> {
timestamp: String,
session_id: &'a str,
tool_name: &'a str,
tool_use_id: &'a str,
decision: &'a str, tool_input_summary: &'a str,
violations: &'a [SandboxViolation],
suggested_rules: Vec<String>,
}
pub fn log_sandbox_violations(
session_id: &str,
tool_name: &str,
tool_use_id: &str,
tool_input_summary: &str,
violations: &[SandboxViolation],
) {
if violations.is_empty() {
return;
}
let suggested_rules: Vec<String> = deduplicated_suggestions(violations);
let entry = SandboxViolationEntry {
timestamp: chrono_timestamp(),
session_id,
tool_name,
tool_use_id,
decision: "sandbox_violation",
tool_input_summary,
violations,
suggested_rules,
};
let session_log = SessionDir::new(session_id).audit_log();
if let Some(parent) = session_log.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string(&entry) {
let mut file = match OpenOptions::new()
.create(true)
.append(true)
.open(&session_log)
{
Ok(f) => f,
Err(e) => {
warn!(error = %e, "Failed to open session audit log for sandbox violations");
return;
}
};
let _ = writeln!(file, "{}", json);
}
}
pub fn read_sandbox_violations(session_id: &str, tool_use_id: &str) -> Vec<SandboxViolation> {
let session_log = SessionDir::new(session_id).audit_log();
let content = match std::fs::read_to_string(&session_log) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
for line in content.lines().rev() {
if !line.contains("sandbox_violation") {
continue;
}
if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line)
&& entry.get("decision").and_then(|v| v.as_str()) == Some("sandbox_violation")
&& entry.get("tool_use_id").and_then(|v| v.as_str()) == Some(tool_use_id)
&& let Some(violations) = entry.get("violations")
{
return serde_json::from_value(violations.clone()).unwrap_or_default();
}
}
Vec::new()
}
fn deduplicated_suggestions(violations: &[SandboxViolation]) -> Vec<String> {
let mut seen_dirs = std::collections::BTreeSet::new();
let mut suggestions = Vec::new();
for v in violations {
let dir = parent_dir_suggestion(&v.path);
if seen_dirs.insert(dir.clone()) {
suggestions.push(format!(
"path(\"{}\").allow(read=True, write=True, create=True)",
dir
));
}
}
suggestions
}
fn parent_dir_suggestion(path: &str) -> String {
let p = std::path::Path::new(path);
if let Some(home) = dirs::home_dir()
&& let Ok(rel) = p.strip_prefix(&home)
{
let mut components = rel.components();
if let Some(first) = components.next() {
let first_str = first.as_os_str().to_string_lossy();
if first_str.starts_with('.') && components.next().is_some() {
return home.join(first_str.as_ref()).to_string_lossy().into_owned();
}
}
}
p.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
fn deny_hint(tool_name: &str, tool_input: &serde_json::Value, cwd: &str) -> Result<String, String> {
let rule = crate::session_policy::suggest_rule_description(tool_name, tool_input, cwd)
.ok_or_else(|| format!("cannot generate hint for {tool_name}"))?;
Ok(format!("clash allow '{}'", rule))
}
fn tool_input_summary(tool_name: &str, input: &serde_json::Value, _cwd: &str) -> String {
let noun = crate::permissions::extract_noun(tool_name, input);
truncate_str(&noun, 60)
}
fn truncate_str(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let truncate_at = s
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= max)
.last()
.unwrap_or(0);
format!("{}...", &s[..truncate_at])
}
fn effect_str(effect: Effect) -> &'static str {
match effect {
Effect::Allow => "allow",
Effect::Deny => "deny",
Effect::Ask => "ask",
}
}
fn chrono_timestamp() -> String {
let now = std::time::SystemTime::now();
let duration = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let millis = duration.subsec_millis();
format!("{}.{:03}", secs, millis)
}
fn append_entry(path: &std::path::Path, entry: &AuditEntry) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let json = serde_json::to_string(entry).map_err(std::io::Error::other)?;
writeln!(file, "{}", json)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::ir::{DecisionTrace, RuleMatch};
fn mock_trace(matched: usize) -> DecisionTrace {
DecisionTrace {
matched_rules: (0..matched)
.map(|i| RuleMatch {
rule_index: i,
description: format!("rule {}", i),
effect: Effect::Allow,
has_active_constraints: false,
node_id: None,
})
.collect(),
skipped_rules: vec![],
final_resolution: "test".into(),
}
}
#[test]
fn test_session_dir_uses_session_id() {
let dir = session_dir("abc-123");
assert!(dir.ends_with("clash-abc-123"));
}
#[test]
fn test_init_session_creates_dir_and_metadata() {
let id = format!("test-{}", std::process::id());
let dir = session_dir(&id);
let _ = std::fs::remove_dir_all(&dir);
let result = init_session(&id, "/tmp", Some("startup"), Some("claude-sonnet"));
assert!(result.is_ok());
let meta_path = dir.join("metadata.json");
assert!(meta_path.exists(), "metadata.json should exist");
let contents = std::fs::read_to_string(&meta_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert_eq!(json["session_id"], id);
assert_eq!(json["cwd"], "/tmp");
assert_eq!(json["source"], "startup");
assert_eq!(json["model"], "claude-sonnet");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_log_decision_writes_to_session_dir() {
let id = format!("test-log-{}", std::process::id());
let dir = session_dir(&id);
let _ = std::fs::remove_dir_all(&dir);
init_session(&id, "/tmp", None, None).unwrap();
let config = AuditConfig {
enabled: false,
path: None,
};
log_decision(
&config,
&id,
"Bash",
&serde_json::json!({"command": "ls"}),
Effect::Allow,
Some("policy: allowed"),
&mock_trace(1),
);
let session_log = dir.join("audit.jsonl");
assert!(session_log.exists(), "session audit.jsonl should exist");
let contents = std::fs::read_to_string(&session_log).unwrap();
let entry: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
assert_eq!(entry["tool_name"], "Bash");
assert_eq!(entry["decision"], "allow");
assert_eq!(entry["matched_rules"], 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_log_decision_creates_session_dir_if_needed() {
let id = format!("test-autocreate-{}", std::process::id());
let dir = session_dir(&id);
let _ = std::fs::remove_dir_all(&dir);
let config = AuditConfig {
enabled: false,
path: None,
};
log_decision(
&config,
&id,
"Read",
&serde_json::json!({"file_path": "/tmp/x"}),
Effect::Ask,
None,
&mock_trace(0),
);
let session_log = dir.join("audit.jsonl");
assert!(
session_log.exists(),
"session audit.jsonl should be created even without prior init"
);
let _ = std::fs::remove_dir_all(&dir);
}
}