use std::process::ExitCode;
use std::sync::LazyLock;
use regex::Regex;
static GIT_COMMIT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\bgit\b[^\n]*?\bcommit(?:\s|$|[^\w-])").expect("static regex compiles")
});
pub(super) fn matches_git_commit(cmd: &str) -> bool {
GIT_COMMIT_RE.is_match(cmd)
}
const MAX_STDIN_BYTES: u64 = 1024 * 1024;
pub fn run() -> ExitCode {
match try_run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::debug!("claude-hook run: {e:#}");
eprintln!("repotoire claude-hook: {e:#}");
ExitCode::SUCCESS
}
}
}
fn try_run() -> anyhow::Result<()> {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.lock()
.take(MAX_STDIN_BYTES)
.read_to_string(&mut buf)
.map_err(|e| anyhow::anyhow!("read stdin: {e}"))?;
let payload: serde_json::Value =
serde_json::from_str(&buf).map_err(|e| anyhow::anyhow!("parse stdin JSON: {e}"))?;
let tool_name = payload
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("");
if tool_name != "Bash" {
return Ok(());
}
let command = payload
.get("tool_input")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !matches_git_commit(command) {
return Ok(());
}
let cwd_str = payload
.get("cwd")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("payload.cwd missing"))?;
let cwd = std::path::Path::new(cwd_str);
if !cwd.is_dir() {
return Ok(());
}
let toplevel = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| anyhow::anyhow!("spawn git rev-parse: {e}"))?;
if !toplevel.status.success() {
return Ok(());
}
let repo_root = String::from_utf8_lossy(&toplevel.stdout).trim().to_string();
if repo_root.is_empty() {
return Ok(());
}
let repo_root = std::path::PathBuf::from(repo_root);
let head_check = std::process::Command::new("git")
.arg("-C")
.arg(&repo_root)
.args(["rev-parse", "--verify", "HEAD"])
.output()
.map_err(|e| anyhow::anyhow!("spawn git rev-parse HEAD: {e}"))?;
if !head_check.status.success() {
return Ok(());
}
let baseline = crate::cache::paths::cache_dir(&repo_root).join("baseline_findings.json");
if !baseline.exists() {
return Ok(());
}
let opts = crate::cli::diff::SmartDiffOptions {
allow_inline_analysis: false,
emit_telemetry: false,
};
let telemetry = crate::telemetry::Telemetry::Disabled;
let result = crate::cli::diff::compute_smart_diff(
&repo_root,
Some("HEAD"),
true, false, false, opts,
&telemetry,
)?;
let result = match result {
Some(r) => r,
None => return Ok(()),
};
let block_tier = match resolved_block_tier() {
Some(t) => t,
None => return Ok(()), };
if !gate_tripped(&result, block_tier) {
return Ok(());
}
let reason = format_deny_reason(&result, block_tier);
let response = build_deny_response(&reason);
println!("{}", serde_json::to_string(&response)?);
Ok(())
}
use crate::cli::diff::SmartDiffResult;
use crate::models::{Evidence, Severity, SourceSpan, Tier};
const MAX_BULLETS: usize = 5;
const IGNORE_INSTRUCTION: &str = "Genuine false positive: add `// repotoire:ignore[<detector>] \u{2014} <reason>` where <reason> is one of framework-pattern | test-fixture | protocol-required | redaction-list | vendored | generated | accepted-risk, and tell me you did it. A bare `// repotoire:ignore` will hide it from the report but will NOT clear this gate.";
fn resolved_block_tier() -> Option<Tier> {
if std::env::var("REPOTOIRE_GATE_TIER").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off")) {
return None;
}
for key in ["REPOTOIRE_HOOK_BLOCK_TIER", "REPOTOIRE_GATE_TIER"] {
let raw = match std::env::var(key) {
Ok(v) => v,
Err(_) => continue,
};
let raw = raw.trim();
if raw.is_empty() {
continue;
}
if raw.eq_ignore_ascii_case("off") {
return None;
}
match raw.parse::<Tier>() {
Ok(t) => return Some(t),
Err(e) => {
tracing::debug!("claude-hook: ignoring {key}={raw:?}: {e:#}");
continue;
}
}
}
Some(Tier::Blocking)
}
fn gate_tripped(result: &SmartDiffResult, block_tier: Tier) -> bool {
result
.new_findings
.iter()
.any(|af| af.finding.tier >= block_tier)
|| result.suppressed_unaccounted_blocking_count > 0
}
fn severity_rank(sev: Severity) -> u8 {
match sev {
Severity::Critical => 0,
Severity::High => 1,
Severity::Medium => 2,
Severity::Low => 3,
Severity::Info => 4,
}
}
fn first_file(finding: &crate::models::Finding) -> String {
finding
.affected_files
.first()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unknown>".into())
}
fn span_loc(span: &SourceSpan) -> String {
format!("{}:{}", span.file.display(), span.line_start)
}
fn evidence_summary(finding: &crate::models::Finding) -> String {
match &finding.evidence {
Some(Evidence::TaintPath {
source,
sink,
sink_kind,
..
}) => format!(
"source {} \u{2192} {sink_kind} sink {}",
span_loc(source),
span_loc(sink)
),
Some(Evidence::Secret { span, format, .. }) => {
format!("{format} at {}", span_loc(span))
}
Some(Evidence::ConfigFact { span, rule }) => format!("{rule} at {}", span_loc(span)),
None => {
let line = finding
.line_start
.map(|l| format!(":{l}"))
.unwrap_or_default();
format!("{}{line}", first_file(finding))
}
}
}
pub(super) fn format_deny_reason(result: &SmartDiffResult, block_tier: Tier) -> String {
let mut out = String::new();
let mut blockers: Vec<&crate::cli::diff::AttributedFinding> = result
.new_findings
.iter()
.filter(|af| af.finding.tier >= block_tier)
.collect();
let tier_word = if block_tier == Tier::Blocking {
"blocking".to_string()
} else {
format!("{block_tier}+")
};
out.push_str(&format!(
"Repotoire: {} new {tier_word} finding(s) on this branch:\n",
blockers.len()
));
if let (Some(before), Some(after)) = (result.score_before, result.score_after) {
let delta = after - before;
out.push_str(&format!(
"Score: {before:.1} \u{2192} {after:.1} (\u{0394} {delta:+.1})\n"
));
}
if result.suppressed_unaccounted_blocking_count > 0 {
out.push_str(&format!(
"{} blocking finding(s) suppressed without an accounted reason \u{2014} still blocking.\n",
result.suppressed_unaccounted_blocking_count
));
}
out.push('\n');
blockers.sort_by(|a, b| {
b.finding
.tier
.cmp(&a.finding.tier)
.then_with(|| severity_rank(a.finding.severity).cmp(&severity_rank(b.finding.severity)))
.then_with(|| first_file(&a.finding).cmp(&first_file(&b.finding)))
});
for af in blockers.iter().take(MAX_BULLETS) {
out.push_str(&format!(
"- [{}] {} \u{2014} {}\n",
af.finding.detector,
af.finding.title,
evidence_summary(&af.finding),
));
}
if blockers.len() > MAX_BULLETS {
out.push_str(&format!("- ...and {} more\n", blockers.len() - MAX_BULLETS));
}
out.push('\n');
out.push_str(IGNORE_INSTRUCTION);
out.push_str("\n\nFix these before committing.\n");
out
}
pub(super) fn build_deny_response(reason: &str) -> serde_json::Value {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_basic_git_commit() {
assert!(matches_git_commit("git commit"));
}
#[test]
fn matches_git_commit_with_short_flags() {
assert!(matches_git_commit("git commit -am 'fix bug'"));
}
#[test]
fn matches_git_commit_with_amend() {
assert!(matches_git_commit("git commit --amend"));
}
#[test]
fn matches_git_commit_with_config_override() {
assert!(matches_git_commit(
"git -c user.email=x@y.z commit -am 'fix'"
));
}
#[test]
fn matches_git_commit_with_extra_whitespace() {
assert!(matches_git_commit("\t git commit\n"));
}
#[test]
fn rejects_git_commit_tree() {
assert!(!matches_git_commit("git commit-tree foo"));
}
#[test]
fn rejects_git_commit_graph() {
assert!(!matches_git_commit("git commit-graph write"));
}
#[test]
fn rejects_gitlab_commit() {
assert!(!matches_git_commit("gitlab commit something"));
}
#[test]
fn rejects_git_status() {
assert!(!matches_git_commit("git status"));
}
#[test]
fn rejects_empty_string() {
assert!(!matches_git_commit(""));
}
#[test]
fn rejects_bare_git() {
assert!(!matches_git_commit("git"));
}
#[test]
fn matches_chained_git_commit() {
assert!(matches_git_commit("git checkout main && git commit"));
}
#[test]
fn matches_quoted_git_commit_in_echo() {
assert!(matches_git_commit("echo 'git commit'"));
}
use crate::cli::diff::AttributedFinding;
use crate::cli::diff_hunks::Attribution;
use crate::models::Finding;
use std::path::PathBuf;
use std::sync::Mutex;
static GATE_ENV_LOCK: Mutex<()> = Mutex::new(());
fn with_gate_env<R>(
hook_block_tier: Option<&str>,
gate_tier: Option<&str>,
f: impl FnOnce() -> R,
) -> R {
let _guard = GATE_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let keys = ["REPOTOIRE_HOOK_BLOCK_TIER", "REPOTOIRE_GATE_TIER"];
let prev: Vec<Option<String>> = keys.iter().map(|k| std::env::var(k).ok()).collect();
for (k, v) in keys.iter().zip([hook_block_tier, gate_tier]) {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
let out = f();
for (k, v) in keys.iter().zip(prev) {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
out
}
fn span(file: &str, line: u32) -> SourceSpan {
SourceSpan {
file: PathBuf::from(file),
line_start: line,
line_end: line,
snippet: None,
}
}
fn fake_finding(
detector: &str,
title: &str,
file: &str,
line: u32,
tier: Tier,
evidence: Option<Evidence>,
) -> AttributedFinding {
AttributedFinding {
finding: Finding {
detector: detector.into(),
title: title.into(),
severity: Severity::High,
affected_files: vec![PathBuf::from(file)],
line_start: Some(line),
tier,
evidence,
..Default::default()
},
attribution: Attribution::InChangedHunk,
}
}
fn taint_finding(detector: &str, file: &str, line: u32) -> AttributedFinding {
fake_finding(
detector,
"tainted input reaches a dangerous sink",
file,
line,
Tier::Blocking,
Some(Evidence::TaintPath {
source: span(file, line),
sink: span(file, line + 5),
sink_kind: "exec".into(),
flow: Vec::new(),
sanitizers_seen: Vec::new(),
}),
)
}
fn advisory_finding(detector: &str, file: &str, line: u32) -> AttributedFinding {
fake_finding(detector, "magic number", file, line, Tier::Advisory, None)
}
fn fake_result(
findings: Vec<AttributedFinding>,
before: Option<f64>,
after: Option<f64>,
) -> SmartDiffResult {
let n = findings.len();
SmartDiffResult {
base_ref: "cached".into(),
head_ref: "HEAD".into(),
files_changed: 1,
new_findings: findings,
all_new_count: n,
fixed_findings: vec![],
score_before: before,
score_after: after,
suppression_events: Vec::new(),
suppressed_unaccounted_blocking_count: 0,
}
}
#[test]
fn gate_blocks_on_new_blocking_finding() {
let r = fake_result(
vec![taint_finding("command-injection", "a.js", 3)],
None,
None,
);
assert!(gate_tripped(&r, Tier::Blocking));
}
#[test]
fn gate_allows_when_only_advisory_or_deep() {
let mut r = fake_result(
vec![advisory_finding("magic-numbers", "a.js", 7)],
None,
None,
);
assert!(!gate_tripped(&r, Tier::Blocking));
r.new_findings[0].finding.tier = Tier::Deep;
assert!(!gate_tripped(&r, Tier::Blocking));
}
#[test]
fn gate_blocks_advisory_when_widened() {
let r = fake_result(
vec![advisory_finding("magic-numbers", "a.js", 7)],
None,
None,
);
assert!(!gate_tripped(&r, Tier::Blocking));
assert!(gate_tripped(&r, Tier::Advisory));
}
#[test]
fn gate_blocks_on_unaccounted_suppressed_blocking() {
let mut r = fake_result(vec![], None, None);
r.suppressed_unaccounted_blocking_count = 1;
assert!(gate_tripped(&r, Tier::Blocking));
}
#[test]
fn block_tier_defaults_to_blocking() {
with_gate_env(None, None, || {
assert_eq!(resolved_block_tier(), Some(Tier::Blocking));
});
}
#[test]
fn hook_block_tier_advisory_widens() {
with_gate_env(Some("advisory"), None, || {
assert_eq!(resolved_block_tier(), Some(Tier::Advisory));
});
}
#[test]
fn gate_tier_advisory_widens() {
with_gate_env(None, Some("advisory"), || {
assert_eq!(resolved_block_tier(), Some(Tier::Advisory));
});
}
#[test]
fn hook_block_tier_wins_over_gate_tier() {
with_gate_env(Some("blocking"), Some("advisory"), || {
assert_eq!(resolved_block_tier(), Some(Tier::Blocking));
});
}
#[test]
fn gate_tier_off_disables_the_gate() {
with_gate_env(None, Some("off"), || {
assert_eq!(resolved_block_tier(), None);
});
with_gate_env(None, Some("OFF"), || {
assert_eq!(resolved_block_tier(), None);
});
with_gate_env(Some("blocking"), Some("off"), || {
assert_eq!(resolved_block_tier(), None);
});
}
#[test]
fn empty_or_garbage_env_is_ignored() {
with_gate_env(Some(" "), Some("not-a-tier"), || {
assert_eq!(resolved_block_tier(), Some(Tier::Blocking));
});
}
#[test]
fn deny_reason_leads_with_taint_evidence_and_ignore_instruction() {
let r = fake_result(
vec![taint_finding("command-injection", "src/a.js", 12)],
None,
None,
);
let s = format_deny_reason(&r, Tier::Blocking);
assert!(s.contains("1 new blocking finding(s)"), "header: {s}");
assert!(s.contains("[command-injection]"), "detector in bullet: {s}");
assert!(
s.contains("source src/a.js:12 \u{2192} exec sink src/a.js:17"),
"evidence summary: {s}"
);
assert!(
s.contains("repotoire:ignore[<detector>]") && s.contains("accepted-risk"),
"structured :ignore instruction: {s}"
);
assert!(
s.contains("A bare `// repotoire:ignore`"),
"bare-ignore caveat: {s}"
);
}
#[test]
fn deny_reason_summarizes_secret_and_config_fact_evidence() {
let secret = fake_finding(
"secrets",
"AWS access key id committed",
"config.py",
4,
Tier::Blocking,
Some(Evidence::Secret {
span: span("config.py", 4),
format: "aws_access_key_id".into(),
entropy_bits: 132.0,
checksum_valid: None,
}),
);
let cfg = fake_finding(
"insecure-tls",
"TLS verification disabled",
"client.js",
9,
Tier::Blocking,
Some(Evidence::ConfigFact {
span: span("client.js", 9),
rule: "tls_verify_disabled".into(),
}),
);
let s = format_deny_reason(&fake_result(vec![secret, cfg], None, None), Tier::Blocking);
assert!(
s.contains("aws_access_key_id at config.py:4"),
"secret: {s}"
);
assert!(
s.contains("tls_verify_disabled at client.js:9"),
"config fact: {s}"
);
}
#[test]
fn deny_reason_truncates_to_top_5() {
let findings: Vec<_> = (0..50)
.map(|i| taint_finding("command-injection", "a.js", i + 1))
.collect();
let r = fake_result(findings, Some(95.0), Some(90.0));
let s = format_deny_reason(&r, Tier::Blocking);
let bullet_count = s.matches("- [command-injection]").count();
assert_eq!(bullet_count, 5, "should be exactly 5 bullets, got: {s}");
assert!(
s.contains("...and 45 more"),
"should mention truncated count: {s}"
);
}
#[test]
fn deny_reason_includes_score_line_when_both_set() {
let r = fake_result(
vec![taint_finding("command-injection", "a.js", 1)],
Some(95.0),
Some(90.0),
);
let s = format_deny_reason(&r, Tier::Blocking);
assert!(s.contains("Score: 95.0"), "missing score line: {s}");
assert!(s.contains("90.0"), "missing after-score: {s}");
assert!(s.contains("-5.0"), "missing delta: {s}");
}
#[test]
fn deny_reason_omits_score_line_when_either_missing() {
let r = fake_result(
vec![taint_finding("command-injection", "a.js", 1)],
None,
Some(90.0),
);
let s = format_deny_reason(&r, Tier::Blocking);
assert!(!s.contains("Score:"), "should omit score line: {s}");
}
#[test]
fn deny_reason_mentions_unaccounted_suppressions() {
let mut r = fake_result(
vec![taint_finding("command-injection", "a.js", 1)],
None,
None,
);
r.suppressed_unaccounted_blocking_count = 2;
let s = format_deny_reason(&r, Tier::Blocking);
assert!(
s.contains("2 blocking finding(s) suppressed without an accounted reason"),
"unaccounted note: {s}"
);
}
#[test]
fn deny_reason_sorts_blocking_before_advisory_when_widened() {
let r = fake_result(
vec![
advisory_finding("magic-numbers", "a.js", 10),
taint_finding("command-injection", "a.js", 20),
],
None,
None,
);
let s = format_deny_reason(&r, Tier::Advisory);
let block_pos = s
.find("command-injection")
.expect("blocking bullet present");
let adv_pos = s.find("magic-numbers").expect("advisory bullet present");
assert!(
block_pos < adv_pos,
"Blocking should sort before Advisory: {s}"
);
}
#[test]
fn deny_response_has_correct_schema() {
let v = build_deny_response("hello");
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert_eq!(v["hookSpecificOutput"]["permissionDecisionReason"], "hello");
}
}