truth-mirror 0.1.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
//! Per-agent reinjection surface installation.
//!
//! `install-hooks --claude|--codex|--pi` installs `truth-mirror reinject --agent <agent>`
//! into each selected agent's repo-local configuration surface. Installs are
//! non-clobbering (existing config is merged, not overwritten), idempotent, and
//! reversible: uninstall removes only truth-mirror's own entries.

use std::{
    fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use serde_json::{Map, Value, json};

use crate::cli::Agent;

/// All agents that expose a reinjection surface.
pub const ALL_AGENTS: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Pi];

pub fn agent_slug(agent: Agent) -> &'static str {
    match agent {
        Agent::Claude => "claude",
        Agent::Codex => "codex",
        Agent::Pi => "pi",
    }
}

/// Repo-relative path of an agent's reinjection surface file.
pub fn surface_relative_path(agent: Agent) -> &'static str {
    match agent {
        Agent::Claude => ".claude/settings.json",
        Agent::Codex => ".codex/hooks.json",
        Agent::Pi => ".pi/hooks.json",
    }
}

/// The exact command truth-mirror installs into each surface.
pub fn reinject_command(agent: Agent) -> String {
    format!("truth-mirror reinject --agent {}", agent_slug(agent))
}

/// Claude nests hooks under `hooks.UserPromptSubmit`; Codex and Pi use a flat
/// top-level `UserPromptSubmit` array.
fn is_nested(agent: Agent) -> bool {
    matches!(agent, Agent::Claude)
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SurfacePlan {
    pub agent: Agent,
    pub path: PathBuf,
}

impl SurfacePlan {
    pub fn for_agent(repo_root: &Path, agent: Agent) -> Self {
        Self {
            agent,
            path: repo_root.join(surface_relative_path(agent)),
        }
    }

    pub fn install(&self) -> Result<()> {
        let mut root = read_object(&self.path)?;
        install_command(self.agent, &mut root, &reinject_command(self.agent));
        write_object(&self.path, &root)
    }

    pub fn uninstall(&self) -> Result<()> {
        if !self.path.exists() {
            return Ok(());
        }
        let mut root = read_object(&self.path)?;
        remove_command(self.agent, &mut root, &reinject_command(self.agent));
        if root.is_empty() {
            fs::remove_file(&self.path)
                .with_context(|| format!("removing empty surface {}", self.path.display()))?;
        } else {
            write_object(&self.path, &root)?;
        }
        Ok(())
    }

    pub fn contains_reinject(&self) -> Result<bool> {
        if !self.path.exists() {
            return Ok(false);
        }
        let root = read_object(&self.path)?;
        Ok(surface_contains(
            self.agent,
            &root,
            &reinject_command(self.agent),
        ))
    }
}

fn read_object(path: &Path) -> Result<Map<String, Value>> {
    match fs::read_to_string(path) {
        Ok(contents) if contents.trim().is_empty() => Ok(Map::new()),
        Ok(contents) => {
            let value: Value = serde_json::from_str(&contents)
                .with_context(|| format!("parsing existing surface {}", path.display()))?;
            match value {
                Value::Object(map) => Ok(map),
                _ => anyhow::bail!(
                    "surface {} is not a JSON object; refusing to clobber",
                    path.display()
                ),
            }
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
        Err(error) => Err(error).with_context(|| format!("reading surface {}", path.display()))?,
    }
}

fn write_object(path: &Path, root: &Map<String, Value>) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("creating surface dir {}", parent.display()))?;
    }
    let mut serialized = serde_json::to_string_pretty(&Value::Object(root.clone()))?;
    serialized.push('\n');
    fs::write(path, serialized).with_context(|| format!("writing surface {}", path.display()))?;
    Ok(())
}

fn install_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
    let entries = user_prompt_submit_mut(agent, root);
    if array_contains_command(agent, entries, command) {
        return;
    }
    entries.push(surface_entry(agent, command));
}

fn remove_command(agent: Agent, root: &mut Map<String, Value>, command: &str) {
    if is_nested(agent) {
        let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
            return;
        };
        if let Some(groups) = hooks
            .get_mut("UserPromptSubmit")
            .and_then(Value::as_array_mut)
        {
            for group in groups.iter_mut() {
                if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
                    inner.retain(|entry| !entry_matches_command(entry, command));
                }
            }
            groups.retain(|group| {
                group
                    .get("hooks")
                    .and_then(Value::as_array)
                    .is_none_or(|inner| !inner.is_empty())
            });
            if groups.is_empty() {
                hooks.remove("UserPromptSubmit");
            }
        }
        if hooks.is_empty() {
            root.remove("hooks");
        }
    } else if let Some(entries) = root
        .get_mut("UserPromptSubmit")
        .and_then(Value::as_array_mut)
    {
        entries.retain(|entry| !entry_matches_command(entry, command));
        if entries.is_empty() {
            root.remove("UserPromptSubmit");
        }
    }
}

/// Return a mutable handle to the array we append entries to, creating the
/// nested containers if they do not exist.
fn user_prompt_submit_mut(agent: Agent, root: &mut Map<String, Value>) -> &mut Vec<Value> {
    if is_nested(agent) {
        let hooks = root
            .entry("hooks")
            .or_insert_with(|| Value::Object(Map::new()));
        if !hooks.is_object() {
            *hooks = Value::Object(Map::new());
        }
        let hooks = hooks.as_object_mut().expect("hooks is object");
        let entries = hooks
            .entry("UserPromptSubmit")
            .or_insert_with(|| Value::Array(Vec::new()));
        if !entries.is_array() {
            *entries = Value::Array(Vec::new());
        }
        entries.as_array_mut().expect("UserPromptSubmit is array")
    } else {
        let entries = root
            .entry("UserPromptSubmit")
            .or_insert_with(|| Value::Array(Vec::new()));
        if !entries.is_array() {
            *entries = Value::Array(Vec::new());
        }
        entries.as_array_mut().expect("UserPromptSubmit is array")
    }
}

fn surface_entry(agent: Agent, command: &str) -> Value {
    if is_nested(agent) {
        json!({ "hooks": [ { "type": "command", "command": command } ] })
    } else {
        json!({ "command": command })
    }
}

fn array_contains_command(agent: Agent, entries: &[Value], command: &str) -> bool {
    if is_nested(agent) {
        entries.iter().any(|group| {
            group
                .get("hooks")
                .and_then(Value::as_array)
                .is_some_and(|inner| inner.iter().any(|e| entry_matches_command(e, command)))
        })
    } else {
        entries.iter().any(|e| entry_matches_command(e, command))
    }
}

fn entry_matches_command(entry: &Value, command: &str) -> bool {
    entry
        .get("command")
        .and_then(Value::as_str)
        .is_some_and(|value| value == command)
}

/// Whether the surface JSON already carries the reinject command for an agent.
pub fn surface_contains(agent: Agent, root: &Map<String, Value>, command: &str) -> bool {
    if is_nested(agent) {
        root.get("hooks")
            .and_then(Value::as_object)
            .and_then(|hooks| hooks.get("UserPromptSubmit"))
            .and_then(Value::as_array)
            .is_some_and(|entries| array_contains_command(agent, entries, command))
    } else {
        root.get("UserPromptSubmit")
            .and_then(Value::as_array)
            .is_some_and(|entries| array_contains_command(agent, entries, command))
    }
}

#[cfg(test)]
mod tests {
    use super::{
        Agent, SurfacePlan, install_command, reinject_command, remove_command, surface_contains,
    };
    use proptest::prelude::*;
    use serde_json::{Map, Value, json};

    fn install_into(agent: Agent, mut root: Map<String, Value>) -> Map<String, Value> {
        install_command(agent, &mut root, &reinject_command(agent));
        root
    }

    #[test]
    fn claude_surface_uses_nested_user_prompt_submit() {
        let root = install_into(Agent::Claude, Map::new());
        let value = Value::Object(root.clone());

        let command = value
            .pointer("/hooks/UserPromptSubmit/0/hooks/0/command")
            .and_then(Value::as_str)
            .unwrap();
        assert_eq!(command, "truth-mirror reinject --agent claude");
        assert!(surface_contains(
            Agent::Claude,
            &root,
            &reinject_command(Agent::Claude)
        ));
    }

    #[test]
    fn codex_and_pi_use_flat_user_prompt_submit() {
        for agent in [Agent::Codex, Agent::Pi] {
            let root = install_into(agent, Map::new());
            let value = Value::Object(root.clone());

            let command = value
                .pointer("/UserPromptSubmit/0/command")
                .and_then(Value::as_str)
                .unwrap();
            assert_eq!(command, reinject_command(agent));
        }
    }

    #[test]
    fn install_is_idempotent() {
        let mut root = install_into(Agent::Claude, Map::new());
        install_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));

        let count = Value::Object(root)
            .pointer("/hooks/UserPromptSubmit")
            .and_then(Value::as_array)
            .map(Vec::len)
            .unwrap();
        assert_eq!(count, 1);
    }

    #[test]
    fn install_preserves_foreign_config() {
        let existing: Map<String, Value> = json!({
            "model": "sonnet",
            "hooks": { "PreToolUse": [ { "matcher": "Bash" } ] }
        })
        .as_object()
        .cloned()
        .unwrap();

        let root = install_into(Agent::Claude, existing);
        let value = Value::Object(root);

        assert_eq!(
            value.pointer("/model").and_then(Value::as_str),
            Some("sonnet")
        );
        assert!(value.pointer("/hooks/PreToolUse").is_some());
        assert!(value.pointer("/hooks/UserPromptSubmit/0").is_some());
    }

    #[test]
    fn uninstall_removes_only_truth_mirror_entries() {
        let existing: Map<String, Value> = json!({
            "model": "sonnet",
            "hooks": {
                "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "other-tool" } ] } ]
            }
        })
        .as_object()
        .cloned()
        .unwrap();

        let mut root = install_into(Agent::Claude, existing);
        remove_command(Agent::Claude, &mut root, &reinject_command(Agent::Claude));
        let value = Value::Object(root);

        assert_eq!(
            value.pointer("/model").and_then(Value::as_str),
            Some("sonnet")
        );
        let commands: Vec<&str> = value
            .pointer("/hooks/UserPromptSubmit")
            .and_then(Value::as_array)
            .unwrap()
            .iter()
            .filter_map(|group| group.pointer("/hooks/0/command").and_then(Value::as_str))
            .collect();
        assert_eq!(commands, ["other-tool"]);
    }

    #[test]
    fn install_then_uninstall_on_disk_round_trips() {
        let temp = tempfile::tempdir().unwrap();
        for agent in [Agent::Claude, Agent::Codex, Agent::Pi] {
            let plan = SurfacePlan::for_agent(temp.path(), agent);
            plan.install().unwrap();
            assert!(plan.contains_reinject().unwrap());
            plan.uninstall().unwrap();
            assert!(!plan.contains_reinject().unwrap());
            assert!(!plan.path.exists());
        }
    }

    proptest! {
        #[test]
        fn foreign_keys_survive_install_uninstall(
            key in "[a-z]{1,8}",
            val in "[a-z0-9]{1,8}",
        ) {
            prop_assume!(key != "hooks" && key != "UserPromptSubmit");
            let existing: Map<String, Value> = json!({ key.clone(): val.clone() })
                .as_object()
                .cloned()
                .unwrap();

            let mut root = existing.clone();
            install_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
            prop_assert!(surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));

            remove_command(Agent::Codex, &mut root, &reinject_command(Agent::Codex));
            prop_assert!(!surface_contains(Agent::Codex, &root, &reinject_command(Agent::Codex)));
            prop_assert_eq!(root.get(&key).and_then(Value::as_str), Some(val.as_str()));
        }
    }
}