truth-mirror 0.3.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
use std::{fs, path::Path};

use assert_cmd::Command;

fn git(repo: &Path, args: &[&str]) {
    let status = std::process::Command::new("git")
        .args(args)
        .current_dir(repo)
        .status()
        .unwrap();
    assert!(status.success(), "git {args:?} failed");
}

#[cfg(unix)]
fn make_executable(path: &Path) {
    use std::os::unix::fs::PermissionsExt;
    let mut permissions = fs::metadata(path).unwrap().permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions).unwrap();
}

#[cfg(not(unix))]
fn make_executable(_path: &Path) {}

/// Seed a repo's ledger with one unresolved REJECT via a mocked reviewer.
fn repo_with_unresolved_rejection() -> (tempfile::TempDir, std::path::PathBuf) {
    let temp = tempfile::tempdir().unwrap();
    let repo = temp.path().join("repo");
    let bin = temp.path().join("bin");
    fs::create_dir_all(&repo).unwrap();
    fs::create_dir_all(&bin).unwrap();

    git(&repo, &["init"]);
    git(&repo, &["config", "user.email", "t@t.invalid"]);
    git(&repo, &["config", "user.name", "T"]);
    fs::write(repo.join("f.txt"), "hi\n").unwrap();
    git(&repo, &["add", "f.txt"]);
    git(
        &repo,
        &[
            "commit",
            "-m",
            "feat: add f",
            "-m",
            "CLAIM: add f | verified: cargo test | evidence: tests:x",
        ],
    );
    let sha = {
        let out = std::process::Command::new("git")
            .args(["rev-parse", "HEAD"])
            .current_dir(&repo)
            .output()
            .unwrap();
        String::from_utf8(out.stdout).unwrap().trim().to_owned()
    };

    let fake_codex = bin.join("codex");
    fs::write(
        &fake_codex,
        "#!/usr/bin/env python3\nprint('VERDICT: REJECT')\nprint('FINDINGS:')\nprint('- unsupported')\n",
    )
    .unwrap();
    make_executable(&fake_codex);
    let path = format!(
        "{}:{}",
        bin.display(),
        std::env::var("PATH").unwrap_or_default()
    );

    Command::cargo_bin("truth-mirror")
        .unwrap()
        .current_dir(&repo)
        .env("PATH", path)
        .args([
            "--state-dir",
            ".truth-mirror",
            "review",
            &sha,
            "--watched-agent",
            "claude",
            "--watched-model",
            "model-a",
            "--reviewer-harness",
            "codex",
            "--reviewer-model",
            "model-b",
        ])
        .assert()
        .success();

    (temp, repo)
}

#[test]
fn pre_tool_use_blocks_mutating_tool_when_enforced_and_unresolved() {
    let (_temp, repo) = repo_with_unresolved_rejection();
    // Enable enforcement: block after 1 unresolved rejection.
    fs::write(
        repo.join(".truth-mirror/config.toml"),
        "[enforcement]\nblock_tools_after_unresolved = 1\n",
    )
    .unwrap();

    // Mutating tool is blocked.
    Command::cargo_bin("truth-mirror")
        .unwrap()
        .current_dir(&repo)
        .args([
            "--state-dir",
            ".truth-mirror",
            "gate",
            "--pre-tool-use",
            "--tool",
            "Edit",
        ])
        .assert()
        .failure();

    // Read-only tool is allowed even under enforcement.
    Command::cargo_bin("truth-mirror")
        .unwrap()
        .current_dir(&repo)
        .args([
            "--state-dir",
            ".truth-mirror",
            "gate",
            "--pre-tool-use",
            "--tool",
            "Read",
        ])
        .assert()
        .success();
}

#[test]
fn pre_tool_use_allows_when_enforcement_disabled_by_default() {
    let (_temp, repo) = repo_with_unresolved_rejection();
    // No config -> enforcement disabled -> even a mutating tool is allowed.
    Command::cargo_bin("truth-mirror")
        .unwrap()
        .current_dir(&repo)
        .args([
            "--state-dir",
            ".truth-mirror",
            "gate",
            "--pre-tool-use",
            "--tool",
            "Edit",
        ])
        .assert()
        .success();
}

#[test]
fn pre_tool_use_fails_closed_on_unparseable_hook_payload() {
    let (_temp, repo) = repo_with_unresolved_rejection();
    fs::write(
        repo.join(".truth-mirror/config.toml"),
        "[enforcement]\nblock_tools_after_unresolved = 1\n",
    )
    .unwrap();

    // A hook payload with no recognizable tool field must NOT silently allow.
    Command::cargo_bin("truth-mirror")
        .unwrap()
        .current_dir(&repo)
        .args(["--state-dir", ".truth-mirror", "gate", "--pre-tool-use"])
        .write_stdin("{\"unexpected\":\"schema\"}")
        .assert()
        .failure();
}

#[test]
fn pre_tool_use_honors_global_config_flag() {
    let (_temp, repo) = repo_with_unresolved_rejection();
    // Enforcement lives ONLY in a non-default config path.
    fs::write(
        repo.join("enforce.toml"),
        "[enforcement]\nblock_tools_after_unresolved = 1\n",
    )
    .unwrap();

    // Without --config: default config has no enforcement -> allowed.
    Command::cargo_bin("truth-mirror")
        .unwrap()
        .current_dir(&repo)
        .args([
            "--state-dir",
            ".truth-mirror",
            "gate",
            "--pre-tool-use",
            "--tool",
            "Edit",
        ])
        .assert()
        .success();

    // With --config enforce.toml: enforcement applies -> blocked.
    Command::cargo_bin("truth-mirror")
        .unwrap()
        .current_dir(&repo)
        .args([
            "--state-dir",
            ".truth-mirror",
            "--config",
            "enforce.toml",
            "gate",
            "--pre-tool-use",
            "--tool",
            "Edit",
        ])
        .assert()
        .failure();
}