truth-mirror 0.2.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
//! Enforcement escalation: block mutating tool calls when the ledger carries
//! unresolved rejections beyond a configured count or age, so a stuck agent stops
//! producing unreviewed work instead of slopping harder.

use crate::config::EnforcementConfig;

/// Tool-name substrings treated as mutating (case-insensitive). Read-only tools
/// stay allowed so the agent can still investigate and fix.
pub const DEFAULT_MUTATING_TOOLS: &[&str] = &[
    "edit", "write", "patch", "create", "delete", "remove", "move", "bash", "shell", "run", "apply",
];

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ToolGateDecision {
    Allow,
    Block { reason: String },
}

impl ToolGateDecision {
    pub fn is_block(&self) -> bool {
        matches!(self, ToolGateDecision::Block { .. })
    }
}

/// Whether a tool name should be treated as mutating.
pub fn is_mutating_tool<S: AsRef<str>>(tool: &str, mutating: &[S]) -> bool {
    let lower = tool.to_ascii_lowercase();
    mutating
        .iter()
        .any(|marker| lower.contains(&marker.as_ref().to_ascii_lowercase()))
}

/// Decide whether to block a tool call. Read-only tools and a clean/disabled
/// configuration always allow.
pub fn pre_tool_use_decision(
    unresolved_count: u32,
    oldest_age_secs: Option<u64>,
    mutating: bool,
    config: &EnforcementConfig,
) -> ToolGateDecision {
    if !config.is_enabled() || !mutating {
        return ToolGateDecision::Allow;
    }

    if config.block_tools_after_unresolved > 0
        && unresolved_count >= config.block_tools_after_unresolved
    {
        return ToolGateDecision::Block {
            reason: format!(
                "{unresolved_count} unresolved truth-mirror rejection(s) at or above threshold {}",
                config.block_tools_after_unresolved
            ),
        };
    }

    if config.block_tools_after_secs > 0
        && let Some(age) = oldest_age_secs
        && age >= config.block_tools_after_secs
    {
        return ToolGateDecision::Block {
            reason: format!(
                "oldest unresolved truth-mirror rejection is {age}s old, at or above {}s",
                config.block_tools_after_secs
            ),
        };
    }

    ToolGateDecision::Allow
}

#[cfg(test)]
mod tests {
    use super::{DEFAULT_MUTATING_TOOLS, is_mutating_tool, pre_tool_use_decision};
    use crate::config::EnforcementConfig;

    fn cfg(unresolved: u32, secs: u64) -> EnforcementConfig {
        EnforcementConfig {
            block_tools_after_unresolved: unresolved,
            block_tools_after_secs: secs,
        }
    }

    #[test]
    fn disabled_config_always_allows() {
        let decision = pre_tool_use_decision(100, Some(10_000), true, &cfg(0, 0));
        assert!(!decision.is_block());
    }

    #[test]
    fn read_only_tools_allowed_even_when_over_threshold() {
        let decision = pre_tool_use_decision(5, None, false, &cfg(2, 0));
        assert!(!decision.is_block());
    }

    #[test]
    fn blocks_mutating_tool_when_unresolved_threshold_met() {
        let decision = pre_tool_use_decision(2, None, true, &cfg(2, 0));
        assert!(decision.is_block());
    }

    #[test]
    fn allows_when_below_threshold() {
        let decision = pre_tool_use_decision(1, None, true, &cfg(2, 0));
        assert!(!decision.is_block());
    }

    #[test]
    fn blocks_on_age_threshold() {
        let decision = pre_tool_use_decision(0, Some(120), true, &cfg(0, 60));
        assert!(decision.is_block());
    }

    #[test]
    fn mutating_classification_matches_common_tools() {
        for tool in [
            "Edit",
            "Write",
            "Bash",
            "apply_patch",
            "shell",
            "run_command",
        ] {
            assert!(is_mutating_tool(tool, DEFAULT_MUTATING_TOOLS), "{tool}");
        }
        for tool in ["Read", "Grep", "Glob", "list_files"] {
            assert!(!is_mutating_tool(tool, DEFAULT_MUTATING_TOOLS), "{tool}");
        }
    }

    #[test]
    fn decision_is_monotonic_in_unresolved_count() {
        // Once blocking begins at the threshold, more unresolved never un-blocks.
        let config = cfg(3, 0);
        let mut seen_block = false;
        for count in 0..10 {
            let block = pre_tool_use_decision(count, None, true, &config).is_block();
            if block {
                seen_block = true;
            }
            if seen_block {
                assert!(block, "count {count} should stay blocked");
            }
        }
    }
}