use crate::config::EnforcementConfig;
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 { .. })
}
}
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()))
}
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() {
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");
}
}
}
}