1use 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 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 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 ResolvedTool::Unknown => ("<unparseable-hook-payload>".to_owned(), true),
111 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 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
134enum ResolvedTool {
136 Named(String),
138 Unknown,
140 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 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 _ => 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}