use std::io::{self, Write};
use anyhow::Result;
use openmoat_audit::{Event, SessionSummary, Summary};
use openmoat_core::{Action, Decision, Verdict};
use serde::Serialize;
use crate::time::{clock, timestamp};
const RULES_WIDTH: usize = 14;
const TABLE_ACTION_WIDTH: usize = 70;
const REPLAY_ACTION_WIDTH: usize = 52;
#[derive(Serialize)]
struct DecisionReport<'a> {
verdict: Verdict,
rules: &'a [String],
reasons: &'a [String],
context: &'a [String],
}
#[derive(Default)]
pub struct Deferred {
error: Option<io::Error>,
}
impl Deferred {
pub fn finish(mut self) -> Result<()> {
self.flush()?;
match self.error {
Some(error) => Err(error.into()),
None => Ok(()),
}
}
}
impl Write for Deferred {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.error.is_none()
&& let Err(error) = io::stdout().write_all(buf)
{
self.error = Some(error);
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
if self.error.is_none()
&& let Err(error) = io::stdout().flush()
{
self.error = Some(error);
}
Ok(())
}
}
pub fn json<T: Serialize + ?Sized>(value: &T) -> Result<()> {
let mut out = io::stdout().lock();
serde_json::to_writer_pretty(&mut out, value)?;
writeln!(out)?;
Ok(())
}
pub fn json_lines<T: Serialize>(values: &[T]) -> Result<()> {
let mut out = io::BufWriter::new(io::stdout().lock());
for value in values {
serde_json::to_writer(&mut out, value)?;
writeln!(out)?;
}
out.flush()?;
Ok(())
}
pub fn decision_json(decision: &Decision) -> Result<()> {
json(&DecisionReport {
verdict: decision.verdict,
rules: &decision.rules,
reasons: &decision.reasons,
context: &decision.context,
})
}
pub fn decision_text(decision: &Decision) -> Result<()> {
let mut out = io::stdout().lock();
writeln!(out, "{}", verdict_mark(decision.verdict))?;
writeln!(out, " rules : {}", decision.rules.join(" · "))?;
for reason in &decision.reasons {
writeln!(out, " reason: {reason}")?;
}
for line in &decision.context {
writeln!(out, " also : {line}")?;
}
Ok(())
}
pub fn event_table(events: &[Event]) -> Result<()> {
let mut out = io::stdout().lock();
if events.is_empty() {
writeln!(out, "no events")?;
return Ok(());
}
writeln!(
out,
"id time (UTC) host verdict rules action"
)?;
for event in events {
writeln!(
out,
"{:<6} {:<10} {:<12} {:<7} {:<14} {}",
event.id,
clock(event.ts_ms),
event.host,
event.verdict.as_str(),
truncate(&event.rules.join(","), RULES_WIDTH),
truncate(&describe(event), TABLE_ACTION_WIDTH),
)?;
}
Ok(())
}
pub fn replay(sessions: &[SessionSummary]) -> Result<()> {
let mut out = io::stdout().lock();
if sessions.is_empty() {
writeln!(out, "no sessions in this window")?;
return Ok(());
}
for (i, session) in sessions.iter().enumerate() {
if i > 0 {
writeln!(out)?;
}
writeln!(
out,
"{} UTC {} {} {} {} decisions ({} denied, {} asked)",
clock(session.first_ms),
session.session_id,
session.host,
session.cwd.as_deref().unwrap_or("-"),
session.events.len(),
session.count(Verdict::Deny),
session.count(Verdict::Ask),
)?;
let last = session.events.len().saturating_sub(1);
for (n, event) in session.events.iter().enumerate() {
let branch = if n == last { "└─" } else { "├─" };
let kind = event.action.as_ref().map_or("tool", kind_of);
writeln!(
out,
" {branch} {:<8} {:<52} {} {}",
kind,
truncate(&describe(event), REPLAY_ACTION_WIDTH),
verdict_glyph(event.verdict),
event.rules.join(", "),
)?;
}
}
Ok(())
}
pub fn report(summary: &Summary, window: &str) -> Result<()> {
let mut out = io::stdout().lock();
writeln!(
out,
"since {window} ({}): {} decisions in {} sessions — {} allowed, {} asked, {} denied",
timestamp(summary.since_ms),
summary.total,
summary.sessions,
summary.allowed,
summary.asked,
summary.denied,
)?;
let centi = summary.asks_per_active_hour_centi();
writeln!(
out,
"asks per active hour: {}.{:02} ({} active hours)",
centi / 100,
centi % 100,
summary.active_hours
)?;
if !summary.by_host.is_empty() {
let hosts: Vec<String> = summary
.by_host
.iter()
.map(|(h, n)| format!("{h} {n}"))
.collect();
writeln!(out, "hosts: {}", hosts.join(" · "))?;
}
if !summary.top_rules.is_empty() {
writeln!(out, "top rules (ask + deny):")?;
for (rule, n) in &summary.top_rules {
writeln!(out, " {n:>5} {rule}")?;
}
}
Ok(())
}
fn verdict_glyph(verdict: Verdict) -> &'static str {
match verdict {
Verdict::Allow => "✔",
Verdict::Ask => "❓",
_ => "⛔",
}
}
fn kind_of(action: &Action) -> &'static str {
match action {
Action::Shell { .. } | Action::ForeignShell { .. } => "shell",
Action::FsRead { .. } | Action::ReadFiles { .. } => "fs.read",
Action::FsWrite { .. } | Action::Patch { .. } => "fs.write",
Action::Net { .. } => "net",
Action::Fetch { .. } => "fetch",
Action::McpTool { .. } => "mcp",
_ => "other",
}
}
pub fn event_detail(event: &Event) -> Result<()> {
let mut out = io::stdout().lock();
writeln!(out, "{} event {}", verdict_mark(event.verdict), event.id)?;
writeln!(out, " when : {}", timestamp(event.ts_ms))?;
writeln!(
out,
" host : {} session {}",
event.host, event.session_id
)?;
if let Some(call) = &event.call_id {
writeln!(out, " call : {call}")?;
}
if let Some(cwd) = &event.cwd {
writeln!(out, " cwd : {cwd}")?;
}
writeln!(out, " tool : {}", event.tool)?;
writeln!(out, " action : {}", describe(event))?;
writeln!(out, " rules : {}", event.rules.join(" · "))?;
for reason in &event.reasons {
writeln!(out, " reason : {reason}")?;
}
writeln!(out, " latency : {} µs", event.latency_us)?;
Ok(())
}
fn verdict_mark(verdict: Verdict) -> &'static str {
match verdict {
Verdict::Allow => "✔ allow",
Verdict::Ask => "❓ ask",
Verdict::Deny => "⛔ deny",
_ => "⛔ unknown",
}
}
pub fn describe(event: &Event) -> String {
if event.action_unreadable {
return format!("{} (action unreadable)", event.tool);
}
let tool = &event.tool;
match event.action.as_ref() {
Some(Action::Shell { command }) => command.clone(),
Some(Action::ForeignShell { shell, command }) => format!("{shell}: {command}"),
Some(Action::FsRead { path }) => format!("read {path}"),
Some(Action::FsWrite { path }) => format!("write {path}"),
Some(Action::Net { url }) => format!("net {url}"),
Some(Action::Fetch { url }) => format!("fetch {url}"),
Some(Action::Patch { writes }) => format!("patch {}", writes.join(", ")),
Some(Action::ReadFiles { paths }) => format!("read {}", paths.join(", ")),
Some(Action::McpTool { name, .. }) => name.clone(),
Some(_) => tool.clone(),
None => format!("{tool} (ungoverned)"),
}
}
pub fn truncate(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_owned();
}
let cut: String = text.chars().take(max.saturating_sub(1)).collect();
format!("{cut}…")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncation_keeps_width() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("a very long command line", 10), "a very lo…");
}
}