Skip to main content

truth_mirror/
gate.rs

1//! Deterministic pre-push and repository safety gates.
2
3use std::{
4    collections::BTreeSet,
5    fs,
6    path::{Path, PathBuf},
7    process::{Command, ExitCode},
8};
9
10use anyhow::{Context, Result, bail};
11
12use crate::{
13    claim, cli,
14    ledger::{LedgerEntry, LedgerStore},
15};
16
17pub fn run(
18    args: cli::GateArgs,
19    state_dir: &Path,
20    config: &crate::config::TruthMirrorConfig,
21) -> Result<ExitCode> {
22    if let Some(range) = args.pre_push {
23        let commits = commits_for_range(&range)?;
24        match pre_push_decision(&LedgerStore::new(state_dir), &commits)? {
25            PushGateDecision::Allow => return Ok(ExitCode::SUCCESS),
26            PushGateDecision::Block(entries) => {
27                bail!(
28                    "pre-push blocked unresolved rejection(s): {}",
29                    rejection_summary(&entries)
30                );
31            }
32        }
33    }
34
35    if args.pre_tool_use {
36        return pre_tool_use_gate(args, state_dir, config);
37    }
38
39    let commit_msg_path = args
40        .commit_msg
41        .as_ref()
42        .context("gate requires --commit-msg, --pre-push, or --pre-tool-use")?;
43    let commit_message = fs::read_to_string(commit_msg_path).with_context(|| {
44        format!(
45            "failed to read commit message {}",
46            commit_msg_path.display()
47        )
48    })?;
49
50    let claim_file = read_optional_file(args.claim_file.as_ref(), "claim file")?;
51    let diff_file = read_optional_file(args.diff_file.as_ref(), "diff file")?;
52
53    let mut policy = config.gates.to_policy();
54    if !args.fake_markers.is_empty() {
55        // CLI markers are additive on top of the resolved policy.
56        for marker in &args.fake_markers {
57            if !policy
58                .fake_markers
59                .iter()
60                .any(|existing| existing == marker)
61            {
62                policy.fake_markers.push(marker.clone());
63            }
64        }
65    }
66
67    claim::evaluate_commit_message(
68        &commit_message,
69        claim_file.as_deref(),
70        diff_file.as_deref(),
71        &policy,
72    )?;
73
74    Ok(ExitCode::SUCCESS)
75}
76
77fn pre_tool_use_gate(
78    args: cli::GateArgs,
79    state_dir: &Path,
80    config: &crate::config::TruthMirrorConfig,
81) -> Result<ExitCode> {
82    let unresolved = LedgerStore::new(state_dir).unresolved_rejections()?;
83    let count = u32::try_from(unresolved.len()).unwrap_or(u32::MAX);
84    let oldest_age = unresolved
85        .iter()
86        .map(|entry| entry.created_at_unix)
87        .min()
88        .map(|oldest| now_unix().saturating_sub(oldest));
89
90    // Tool name comes from `--tool`, else the agent's PreToolUse hook stdin JSON.
91    // A hook payload we cannot parse fails CLOSED (treated as mutating): a safety
92    // gate must not silently allow a tool just because its name was unreadable.
93    let (tool, mutating) = match args.tool {
94        Some(name) => {
95            let mutating = crate::enforcement::is_mutating_tool(
96                &name,
97                crate::enforcement::DEFAULT_MUTATING_TOOLS,
98            );
99            (name, mutating)
100        }
101        None => match resolve_tool_from_stdin() {
102            ResolvedTool::Named(name) => {
103                let mutating = crate::enforcement::is_mutating_tool(
104                    &name,
105                    crate::enforcement::DEFAULT_MUTATING_TOOLS,
106                );
107                (name, mutating)
108            }
109            // Payload present but unreadable -> fail closed.
110            ResolvedTool::Unknown => ("<unparseable-hook-payload>".to_owned(), true),
111            // No payload at all (manual invocation) -> nothing to gate.
112            ResolvedTool::None => (String::new(), false),
113        },
114    };
115
116    match crate::enforcement::pre_tool_use_decision(
117        count,
118        oldest_age,
119        mutating,
120        &config.enforcement,
121    ) {
122        crate::enforcement::ToolGateDecision::Allow => Ok(ExitCode::SUCCESS),
123        crate::enforcement::ToolGateDecision::Block { reason } => {
124            // Exit code 2 is the agent-hook block convention (Claude Code / Codex
125            // PreToolUse); the message goes to stderr for the model to read.
126            eprintln!(
127                "truth-mirror blocked tool {tool:?}: {reason}. Resolve or waive the ledger to continue."
128            );
129            Ok(ExitCode::from(2))
130        }
131    }
132}
133
134/// Outcome of resolving the tool name from a PreToolUse hook stdin payload.
135enum ResolvedTool {
136    /// The tool name was read from the payload.
137    Named(String),
138    /// A payload was present but no tool name could be parsed — fail closed.
139    Unknown,
140    /// No payload at all (manual invocation) — nothing to gate.
141    None,
142}
143
144fn resolve_tool_from_stdin() -> ResolvedTool {
145    use std::io::{IsTerminal, Read};
146    if std::io::stdin().is_terminal() {
147        return ResolvedTool::None;
148    }
149    let mut buffer = String::new();
150    // A payload that cannot even be read (e.g. non-UTF8) fails CLOSED, not open.
151    if std::io::stdin().read_to_string(&mut buffer).is_err() {
152        return ResolvedTool::Unknown;
153    }
154    if buffer.trim().is_empty() {
155        return ResolvedTool::None;
156    }
157
158    match serde_json::from_str::<serde_json::Value>(&buffer)
159        .ok()
160        .and_then(|value| {
161            value
162                .get("tool_name")
163                .or_else(|| value.get("toolName"))
164                .or_else(|| value.get("tool"))
165                .and_then(|name| name.as_str())
166                .map(str::to_owned)
167        }) {
168        Some(name) if !name.trim().is_empty() => ResolvedTool::Named(name),
169        // Payload present but no recognizable tool field: fail closed.
170        _ => ResolvedTool::Unknown,
171    }
172}
173
174fn now_unix() -> u64 {
175    std::time::SystemTime::now()
176        .duration_since(std::time::UNIX_EPOCH)
177        .map_or(0, |duration| duration.as_secs())
178}
179
180#[derive(Clone, Debug, Eq, PartialEq)]
181pub enum PushGateDecision {
182    Allow,
183    Block(Vec<LedgerEntry>),
184}
185
186pub fn pre_push_decision(store: &LedgerStore, commits: &[String]) -> Result<PushGateDecision> {
187    let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
188    let blocked = store
189        .unresolved_rejections()?
190        .into_iter()
191        .filter(|entry| commit_set.contains(entry.commit_sha.as_str()))
192        .collect::<Vec<_>>();
193
194    if blocked.is_empty() {
195        Ok(PushGateDecision::Allow)
196    } else {
197        Ok(PushGateDecision::Block(blocked))
198    }
199}
200
201fn commits_for_range(range: &str) -> Result<Vec<String>> {
202    if range == "all" {
203        return Ok(Vec::new());
204    }
205
206    if !range.contains("..") {
207        return Ok(vec![range.to_owned()]);
208    }
209
210    let output = Command::new("git")
211        .args(["rev-list", range])
212        .output()
213        .context("failed to run git rev-list for pre-push range")?;
214    if !output.status.success() {
215        bail!(
216            "git rev-list failed for pre-push range {range}: {}",
217            String::from_utf8_lossy(&output.stderr)
218        );
219    }
220
221    Ok(String::from_utf8_lossy(&output.stdout)
222        .lines()
223        .map(str::trim)
224        .filter(|line| !line.is_empty())
225        .map(str::to_owned)
226        .collect())
227}
228
229fn rejection_summary(entries: &[LedgerEntry]) -> String {
230    entries
231        .iter()
232        .map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
233        .collect::<Vec<_>>()
234        .join("; ")
235}
236
237fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
238    path.map(|path| {
239        fs::read_to_string(path)
240            .with_context(|| format!("failed to read {label} {}", path.display()))
241    })
242    .transpose()
243}
244
245#[cfg(test)]
246mod tests {
247    use proptest::prelude::*;
248
249    use crate::ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict};
250
251    use super::{PushGateDecision, pre_push_decision};
252
253    fn reject_entry(sha: &str) -> LedgerEntry {
254        LedgerEntry::new_at(
255            sha,
256            Verdict::Reject,
257            "CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
258            vec!["tests:cargo-test".to_owned()],
259            ReviewerConfig::new("claude", "opus", false),
260            vec!["unsupported".to_owned()],
261            100,
262        )
263    }
264
265    #[test]
266    fn pre_push_blocks_unresolved_rejection_in_range() {
267        let temp = tempfile::tempdir().unwrap();
268        let store = LedgerStore::new(temp.path());
269        store.append_entry(&reject_entry("abc123")).unwrap();
270
271        let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();
272
273        assert!(matches!(decision, PushGateDecision::Block(_)));
274    }
275
276    #[test]
277    fn pre_push_allows_after_resolve_or_waive() {
278        let temp = tempfile::tempdir().unwrap();
279        let store = LedgerStore::new(temp.path());
280        store.append_entry(&reject_entry("abc123")).unwrap();
281        store.resolve("abc123").unwrap();
282
283        let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();
284
285        assert_eq!(decision, PushGateDecision::Allow);
286    }
287
288    proptest! {
289        #[test]
290        fn pushed_range_decision_blocks_only_matching_unresolved_sha(
291            rejected_sha in "[a-f0-9]{7,40}",
292            other_sha in "[a-f0-9]{7,40}",
293        ) {
294            prop_assume!(rejected_sha != other_sha);
295            let temp = tempfile::tempdir().unwrap();
296            let store = LedgerStore::new(temp.path());
297            store.append_entry(&reject_entry(&rejected_sha)).unwrap();
298
299            let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha)).unwrap();
300            prop_assert_eq!(unrelated, PushGateDecision::Allow);
301
302            let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha)).unwrap();
303            prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
304        }
305    }
306}