use std::{
collections::BTreeSet,
fs,
path::{Path, PathBuf},
process::{Command, ExitCode},
};
use anyhow::{Context, Result, bail};
use crate::{
claim, cli,
ledger::{LedgerEntry, LedgerStore},
};
pub fn run(
args: cli::GateArgs,
state_dir: &Path,
config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
if let Some(range) = args.pre_push {
let commits = commits_for_range(&range)?;
match pre_push_decision(&LedgerStore::new(state_dir), &commits)? {
PushGateDecision::Allow => return Ok(ExitCode::SUCCESS),
PushGateDecision::Block(entries) => {
bail!(
"pre-push blocked unresolved rejection(s): {}",
rejection_summary(&entries)
);
}
}
}
if args.pre_tool_use {
return pre_tool_use_gate(args, state_dir, config);
}
let commit_msg_path = args
.commit_msg
.as_ref()
.context("gate requires --commit-msg, --pre-push, or --pre-tool-use")?;
let commit_message = fs::read_to_string(commit_msg_path).with_context(|| {
format!(
"failed to read commit message {}",
commit_msg_path.display()
)
})?;
let claim_file = read_optional_file(args.claim_file.as_ref(), "claim file")?;
let diff_file = read_optional_file(args.diff_file.as_ref(), "diff file")?;
let mut policy = config.gates.to_policy();
if !args.fake_markers.is_empty() {
for marker in &args.fake_markers {
if !policy
.fake_markers
.iter()
.any(|existing| existing == marker)
{
policy.fake_markers.push(marker.clone());
}
}
}
claim::evaluate_commit_message(
&commit_message,
claim_file.as_deref(),
diff_file.as_deref(),
&policy,
)?;
Ok(ExitCode::SUCCESS)
}
fn pre_tool_use_gate(
args: cli::GateArgs,
state_dir: &Path,
config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
let unresolved = LedgerStore::new(state_dir).unresolved_rejections()?;
let count = u32::try_from(unresolved.len()).unwrap_or(u32::MAX);
let oldest_age = unresolved
.iter()
.map(|entry| entry.created_at_unix)
.min()
.map(|oldest| now_unix().saturating_sub(oldest));
let (tool, mutating) = match args.tool {
Some(name) => {
let mutating = crate::enforcement::is_mutating_tool(
&name,
crate::enforcement::DEFAULT_MUTATING_TOOLS,
);
(name, mutating)
}
None => match resolve_tool_from_stdin() {
ResolvedTool::Named(name) => {
let mutating = crate::enforcement::is_mutating_tool(
&name,
crate::enforcement::DEFAULT_MUTATING_TOOLS,
);
(name, mutating)
}
ResolvedTool::Unknown => ("<unparseable-hook-payload>".to_owned(), true),
ResolvedTool::None => (String::new(), false),
},
};
match crate::enforcement::pre_tool_use_decision(
count,
oldest_age,
mutating,
&config.enforcement,
) {
crate::enforcement::ToolGateDecision::Allow => Ok(ExitCode::SUCCESS),
crate::enforcement::ToolGateDecision::Block { reason } => {
eprintln!(
"truth-mirror blocked tool {tool:?}: {reason}. Resolve or waive the ledger to continue."
);
Ok(ExitCode::from(2))
}
}
}
enum ResolvedTool {
Named(String),
Unknown,
None,
}
fn resolve_tool_from_stdin() -> ResolvedTool {
use std::io::{IsTerminal, Read};
if std::io::stdin().is_terminal() {
return ResolvedTool::None;
}
let mut buffer = String::new();
if std::io::stdin().read_to_string(&mut buffer).is_err() {
return ResolvedTool::Unknown;
}
if buffer.trim().is_empty() {
return ResolvedTool::None;
}
match serde_json::from_str::<serde_json::Value>(&buffer)
.ok()
.and_then(|value| {
value
.get("tool_name")
.or_else(|| value.get("toolName"))
.or_else(|| value.get("tool"))
.and_then(|name| name.as_str())
.map(str::to_owned)
}) {
Some(name) if !name.trim().is_empty() => ResolvedTool::Named(name),
_ => ResolvedTool::Unknown,
}
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PushGateDecision {
Allow,
Block(Vec<LedgerEntry>),
}
pub fn pre_push_decision(store: &LedgerStore, commits: &[String]) -> Result<PushGateDecision> {
let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
let blocked = store
.unresolved_rejections()?
.into_iter()
.filter(|entry| commit_set.contains(entry.commit_sha.as_str()))
.collect::<Vec<_>>();
if blocked.is_empty() {
Ok(PushGateDecision::Allow)
} else {
Ok(PushGateDecision::Block(blocked))
}
}
fn commits_for_range(range: &str) -> Result<Vec<String>> {
if range == "all" {
return Ok(Vec::new());
}
if !range.contains("..") {
return Ok(vec![range.to_owned()]);
}
let output = Command::new("git")
.args(["rev-list", range])
.output()
.context("failed to run git rev-list for pre-push range")?;
if !output.status.success() {
bail!(
"git rev-list failed for pre-push range {range}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
fn rejection_summary(entries: &[LedgerEntry]) -> String {
entries
.iter()
.map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
.collect::<Vec<_>>()
.join("; ")
}
fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
path.map(|path| {
fs::read_to_string(path)
.with_context(|| format!("failed to read {label} {}", path.display()))
})
.transpose()
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use crate::ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict};
use super::{PushGateDecision, pre_push_decision};
fn reject_entry(sha: &str) -> LedgerEntry {
LedgerEntry::new_at(
sha,
Verdict::Reject,
"CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
vec!["tests:cargo-test".to_owned()],
ReviewerConfig::new("claude", "opus", false),
vec!["unsupported".to_owned()],
100,
)
}
#[test]
fn pre_push_blocks_unresolved_rejection_in_range() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry("abc123")).unwrap();
let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();
assert!(matches!(decision, PushGateDecision::Block(_)));
}
#[test]
fn pre_push_allows_after_resolve_or_waive() {
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry("abc123")).unwrap();
store.resolve("abc123").unwrap();
let decision = pre_push_decision(&store, &["abc123".to_owned()]).unwrap();
assert_eq!(decision, PushGateDecision::Allow);
}
proptest! {
#[test]
fn pushed_range_decision_blocks_only_matching_unresolved_sha(
rejected_sha in "[a-f0-9]{7,40}",
other_sha in "[a-f0-9]{7,40}",
) {
prop_assume!(rejected_sha != other_sha);
let temp = tempfile::tempdir().unwrap();
let store = LedgerStore::new(temp.path());
store.append_entry(&reject_entry(&rejected_sha)).unwrap();
let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha)).unwrap();
prop_assert_eq!(unrelated, PushGateDecision::Allow);
let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha)).unwrap();
prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
}
}
}