truth-mirror 0.3.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
//! Deterministic pre-push and repository safety gates.

use std::{
    collections::BTreeSet,
    fs,
    path::{Path, PathBuf},
    process::{Command, ExitCode},
};

use anyhow::{Context, Result, bail};

use crate::{
    claim, cli,
    ledger::{LedgerEntry, LedgerStore},
};

pub fn run(
    args: cli::GateArgs,
    state_dir: &Path,
    config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
    if let Some(range) = args.pre_push {
        let commits = commits_for_range(&range)?;
        match pre_push_decision(&LedgerStore::new(state_dir), &commits)? {
            PushGateDecision::Allow => return Ok(ExitCode::SUCCESS),
            PushGateDecision::Block(entries) => {
                bail!(
                    "pre-push blocked unresolved rejection(s): {}",
                    rejection_summary(&entries)
                );
            }
        }
    }

    if args.pre_tool_use {
        return pre_tool_use_gate(args, state_dir, config);
    }

    let commit_msg_path = args
        .commit_msg
        .as_ref()
        .context("gate requires --commit-msg, --pre-push, or --pre-tool-use")?;
    let commit_message = fs::read_to_string(commit_msg_path).with_context(|| {
        format!(
            "failed to read commit message {}",
            commit_msg_path.display()
        )
    })?;

    let claim_file = read_optional_file(args.claim_file.as_ref(), "claim file")?;
    let diff_file = read_optional_file(args.diff_file.as_ref(), "diff file")?;

    let mut policy = config.gates.to_policy();
    if !args.fake_markers.is_empty() {
        // CLI markers are additive on top of the resolved policy.
        for marker in &args.fake_markers {
            if !policy
                .fake_markers
                .iter()
                .any(|existing| existing == marker)
            {
                policy.fake_markers.push(marker.clone());
            }
        }
    }

    claim::evaluate_commit_message(
        &commit_message,
        claim_file.as_deref(),
        diff_file.as_deref(),
        &policy,
    )?;

    Ok(ExitCode::SUCCESS)
}

fn pre_tool_use_gate(
    args: cli::GateArgs,
    state_dir: &Path,
    config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
    let unresolved = LedgerStore::new(state_dir).unresolved_rejections()?;
    let count = u32::try_from(unresolved.len()).unwrap_or(u32::MAX);
    let oldest_age = unresolved
        .iter()
        .map(|entry| entry.created_at_unix)
        .min()
        .map(|oldest| now_unix().saturating_sub(oldest));

    // Tool name comes from `--tool`, else the agent's PreToolUse hook stdin JSON.
    // A hook payload we cannot parse fails CLOSED (treated as mutating): a safety
    // gate must not silently allow a tool just because its name was unreadable.
    let (tool, mutating) = match args.tool {
        Some(name) => {
            let mutating = crate::enforcement::is_mutating_tool(
                &name,
                crate::enforcement::DEFAULT_MUTATING_TOOLS,
            );
            (name, mutating)
        }
        None => match resolve_tool_from_stdin() {
            ResolvedTool::Named(name) => {
                let mutating = crate::enforcement::is_mutating_tool(
                    &name,
                    crate::enforcement::DEFAULT_MUTATING_TOOLS,
                );
                (name, mutating)
            }
            // Payload present but unreadable -> fail closed.
            ResolvedTool::Unknown => ("<unparseable-hook-payload>".to_owned(), true),
            // No payload at all (manual invocation) -> nothing to gate.
            ResolvedTool::None => (String::new(), false),
        },
    };

    match crate::enforcement::pre_tool_use_decision(
        count,
        oldest_age,
        mutating,
        &config.enforcement,
    ) {
        crate::enforcement::ToolGateDecision::Allow => Ok(ExitCode::SUCCESS),
        crate::enforcement::ToolGateDecision::Block { reason } => {
            // Exit code 2 is the agent-hook block convention (Claude Code / Codex
            // PreToolUse); the message goes to stderr for the model to read.
            eprintln!(
                "truth-mirror blocked tool {tool:?}: {reason}. Resolve or waive the ledger to continue."
            );
            Ok(ExitCode::from(2))
        }
    }
}

/// Outcome of resolving the tool name from a PreToolUse hook stdin payload.
enum ResolvedTool {
    /// The tool name was read from the payload.
    Named(String),
    /// A payload was present but no tool name could be parsed — fail closed.
    Unknown,
    /// No payload at all (manual invocation) — nothing to gate.
    None,
}

fn resolve_tool_from_stdin() -> ResolvedTool {
    use std::io::{IsTerminal, Read};
    if std::io::stdin().is_terminal() {
        return ResolvedTool::None;
    }
    let mut buffer = String::new();
    // A payload that cannot even be read (e.g. non-UTF8) fails CLOSED, not open.
    if std::io::stdin().read_to_string(&mut buffer).is_err() {
        return ResolvedTool::Unknown;
    }
    if buffer.trim().is_empty() {
        return ResolvedTool::None;
    }

    match serde_json::from_str::<serde_json::Value>(&buffer)
        .ok()
        .and_then(|value| {
            value
                .get("tool_name")
                .or_else(|| value.get("toolName"))
                .or_else(|| value.get("tool"))
                .and_then(|name| name.as_str())
                .map(str::to_owned)
        }) {
        Some(name) if !name.trim().is_empty() => ResolvedTool::Named(name),
        // Payload present but no recognizable tool field: fail closed.
        _ => ResolvedTool::Unknown,
    }
}

fn now_unix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |duration| duration.as_secs())
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushGateDecision {
    Allow,
    Block(Vec<LedgerEntry>),
}

pub fn pre_push_decision(store: &LedgerStore, commits: &[String]) -> Result<PushGateDecision> {
    let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
    let blocked = store
        .unresolved_rejections()?
        .into_iter()
        .filter(|entry| commit_set.contains(entry.commit_sha.as_str()))
        .collect::<Vec<_>>();

    if blocked.is_empty() {
        Ok(PushGateDecision::Allow)
    } else {
        Ok(PushGateDecision::Block(blocked))
    }
}

fn commits_for_range(range: &str) -> Result<Vec<String>> {
    if range == "all" {
        return Ok(Vec::new());
    }

    if !range.contains("..") {
        return Ok(vec![range.to_owned()]);
    }

    let output = Command::new("git")
        .args(["rev-list", range])
        .output()
        .context("failed to run git rev-list for pre-push range")?;
    if !output.status.success() {
        bail!(
            "git rev-list failed for pre-push range {range}: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    Ok(String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(str::to_owned)
        .collect())
}

fn rejection_summary(entries: &[LedgerEntry]) -> String {
    entries
        .iter()
        .map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
        .collect::<Vec<_>>()
        .join("; ")
}

fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
    path.map(|path| {
        fs::read_to_string(path)
            .with_context(|| format!("failed to read {label} {}", path.display()))
    })
    .transpose()
}

#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    use crate::ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict};

    use super::{PushGateDecision, pre_push_decision};

    fn reject_entry(sha: &str) -> LedgerEntry {
        LedgerEntry::new_at(
            sha,
            Verdict::Reject,
            "CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
            vec!["tests:cargo-test".to_owned()],
            ReviewerConfig::new("claude", "opus", false),
            vec!["unsupported".to_owned()],
            100,
        )
    }

    #[test]
    fn pre_push_blocks_unresolved_rejection_in_range() {
        let temp = tempfile::tempdir().unwrap();
        let store = LedgerStore::new(temp.path());
        store.append_entry(&reject_entry("abc123")).unwrap();

        let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();

        assert!(matches!(decision, PushGateDecision::Block(_)));
    }

    #[test]
    fn pre_push_allows_after_resolve_or_waive() {
        let temp = tempfile::tempdir().unwrap();
        let store = LedgerStore::new(temp.path());
        store.append_entry(&reject_entry("abc123")).unwrap();
        store.resolve("abc123").unwrap();

        let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();

        assert_eq!(decision, PushGateDecision::Allow);
    }

    proptest! {
        #[test]
        fn pushed_range_decision_blocks_only_matching_unresolved_sha(
            rejected_sha in "[a-f0-9]{7,40}",
            other_sha in "[a-f0-9]{7,40}",
        ) {
            prop_assume!(rejected_sha != other_sha);
            let temp = tempfile::tempdir().unwrap();
            let store = LedgerStore::new(temp.path());
            store.append_entry(&reject_entry(&rejected_sha)).unwrap();

            let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha)).unwrap();
            prop_assert_eq!(unrelated, PushGateDecision::Allow);

            let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha)).unwrap();
            prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
        }
    }
}