openmoat 0.10.0

Policy checks for AI coding agents: decides allow, defer, ask or deny for every tool call from Claude Code, Codex, Cursor, OpenClaw, GitHub Copilot CLI, pi and Gemini CLI, configures Claude Code's, Codex's and Cursor's own OS sandboxes from the same policy, and keeps a local audit log.
//! Writing the guard's decision to the audit log, with what goes along with
//! it: the session cache, file provenance and the link to an `openmoat run`.

use std::time::Instant;

use anyhow::Result;
use openmoat_audit::{KnownSecrets, NewEvent, Store};
use openmoat_core::{Decision, Verdict};
use openmoat_hosts::{HookRequest, Host};

use super::drift;
use crate::hangs::{self, Phase};
use crate::home::Home;
use crate::integrity::Drift;
use crate::taint;

/// What [`record`] writes.
pub(super) struct Recorded<'a> {
    pub(super) request: Option<&'a HookRequest>,
    pub(super) decision: &'a Decision,
    /// Settings drift the call was decided through; the first call of a
    /// session records it with its row, and the host's answer stays as decided.
    pub(super) drifted: &'a [Drift],
    pub(super) started: Instant,
    pub(super) known: KnownSecrets,
    pub(super) cache: Option<taint::CacheWrite>,
    pub(super) provenance: taint::provenance::Seen,
}

/// Record the decision, after the session cache on the same handle. The cache
/// write never waits for the lock and its result is ignored; it is a separate
/// statement before the record's own transaction, so it cannot touch the
/// hash chain.
pub(super) fn record(host: Host, recorded: Recorded<'_>) -> Result<()> {
    let Recorded {
        request,
        decision,
        drifted,
        started,
        known,
        cache,
        provenance,
    } = recorded;
    hangs::enter(Phase::Record);
    let home = Home::locate()?;
    home.require_exists()?;
    let store = Store::open_existing(&home.audit_path())?;
    if let Some(cache) = cache {
        hangs::enter(Phase::Cache);
        // Before `with_secrets`: the cache is keyed as the unmasked reader reads it.
        cache.write(&store);
        hangs::enter(Phase::Record);
    }
    let store = store.with_secrets(known);
    let noted = match request {
        Some(r) => drift::noted(&store, &r.session_id, decision, drifted)?,
        None => None,
    };
    let decision = noted.as_ref().unwrap_or(decision);
    let latency_us = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX);
    let event = NewEvent {
        host: host.id(),
        session_id: request.map_or("unknown", |r| r.session_id.as_str()),
        call_id: request.and_then(|r| r.call_id.as_deref()),
        cwd: request.and_then(|r| r.cwd.as_deref()),
        tool: request.map_or("unknown", |r| r.tool.as_str()),
        action: request.and_then(|r| r.action.as_ref()),
        decision,
        latency_us,
    };
    let id = store.record(&event)?;
    // A call that may have run wrote and read what it named (ADR-029); like
    // the row, provenance that cannot be written fails the call.
    if let Some(r) = request
        && taint::ran(host, decision.verdict)
    {
        provenance.write(&store, id, host.id(), &r.session_id)?;
    }
    // Under `moat run`, the run's proxy applies this session's taint too
    // (ADR-027); a link that cannot be written fails the call like the row.
    let run = std::env::var(taint::run::RUN_SESSION_ENV).ok();
    if let (Some(run), Some(r)) = (taint::run::run_session(run.as_deref()), request) {
        taint::run::link(&store, run, host, &r.session_id)?;
    }
    // A deferred call is the agent's to decide; OpenMoat stays quiet about it.
    if !matches!(decision.verdict, Verdict::Allow | Verdict::Defer) {
        eprintln!("moat: trace {id}  (openmoat show {id})");
    }
    Ok(())
}