use std::io::{IsTerminal, Read};
use std::process::ExitCode;
use crate::assertions::{self, Assertion, Claim, Verdict};
use crate::decision::{self, Decision};
use crate::journal;
use crate::payload::{self, Bash, Event, Session};
use crate::rules::{self, Confirmed, Context, Finding, Rule, Stance};
use crate::shell::{self, Parsed};
pub fn run() -> ExitCode {
if std::io::stdin().is_terminal() {
eprintln!(
"amont-agent: `hook` reads a Claude Code payload on stdin.\n\
Try `amont-agent check '<command>'` to test a command by hand."
);
return ExitCode::from(2);
}
let mut raw = String::new();
if std::io::stdin().read_to_string(&mut raw).is_err() {
return Decision::Silent.emit();
}
let decided = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| decide(&raw)));
match decided {
Ok(d) => d.emit(),
Err(_) => {
eprintln!("amont-agent: internal error; allowing the command through");
Decision::Silent.emit()
}
}
}
fn decide(raw: &str) -> Decision {
match payload::parse(raw) {
Event::SessionStart(session) => {
heartbeat();
on_session_start(&session)
}
Event::NotOurs => Decision::Silent,
Event::PreBash(bash) => on_bash(&bash),
Event::PostBash(bash) => on_post_bash(&bash),
}
}
fn on_session_start(session: &Session) -> Decision {
if !session.cwd.is_dir() {
return Decision::Silent;
}
let mut lines: Vec<String> = Vec::new();
if let Some(line) = stale_checkout_notice(session) {
lines.push(line);
}
if let Some(line) = crate::guidance::notice(&session.cwd) {
lines.push(line);
}
if lines.is_empty() {
Decision::Silent
} else {
Decision::Context(lines.join("\n\n"))
}
}
fn stale_checkout_notice(session: &Session) -> Option<String> {
let rule = &rules::stale_base::RULE;
let stance = crate::stance::resolve(rule);
let drift = crate::stale::measure(&session.cwd, "HEAD")?;
if drift.behind == 0 {
return None;
}
let outcome = match stance {
Stance::Observe => "watched",
Stance::Advise | Stance::Deny => "advised",
};
journal::record(&journal::Entry {
rule: rule.id,
stance: stance.as_str(),
outcome,
session: &session.session,
repo: &drift.repo,
mode: "-",
excerpt: &format!("session start: {} behind {}", drift.behind, drift.base),
});
match stance {
Stance::Observe => None,
Stance::Advise | Stance::Deny => Some(format!(
"amont-agent/{}: {}",
rule.id,
crate::stale::notice(&drift)
)),
}
}
fn on_bash(bash: &Bash) -> Decision {
let parsed = shell::lex(&bash.command);
if matches!(parsed, Parsed::Opaque(_)) {
return Decision::Silent;
}
let fired = rules::examine_all(&parsed);
if fired.is_empty() {
return Decision::Silent;
}
let mut deny: Vec<String> = Vec::new();
let mut advise: Vec<String> = Vec::new();
for (rule, finding) in &fired {
let stance = crate::stance::resolve(rule);
if let Err(why) = confirmed(rule, finding, bash, &parsed) {
note(rule, "unconfirmed", why, bash, finding);
continue;
}
let text = decision::phrase(rule.id, &finding.reason, &finding.remedy);
match stance {
Stance::Observe => note(rule, "observe", "watched", bash, finding),
Stance::Advise => {
note(rule, "advise", "advised", bash, finding);
advise.push(text);
}
Stance::Deny => {
note(rule, "deny", "denied", bash, finding);
deny.push(text);
}
}
}
if !deny.is_empty() {
Decision::Deny(deny.join("\n\n"))
} else if !advise.is_empty() {
Decision::Advise(advise.join("\n\n"))
} else {
Decision::Silent
}
}
fn on_post_bash(bash: &Bash) -> Decision {
let parsed = shell::lex(&bash.command);
if matches!(parsed, Parsed::Opaque(_)) {
return Decision::Silent;
}
if bash.background {
return Decision::Silent;
}
let claimed = assertions::examine_all(&parsed);
if claimed.is_empty() {
return Decision::Silent;
}
let ctx = Context {
cwd: &bash.cwd,
parsed: &parsed,
background: bash.background,
};
let mut spoken: Vec<String> = Vec::new();
for (assertion, claim) in &claimed {
let stance = crate::stance::resolve_assertion(assertion);
match (assertion.verify)(&ctx, claim) {
Verdict::Unknown(why) => {
note_claim(assertion, "unverified", why, bash, claim);
}
Verdict::Held => note_claim(assertion, stance.as_str(), "held", bash, claim),
Verdict::Broken { reason, remedy } => {
note_claim(assertion, stance.as_str(), "broken", bash, claim);
if stance != Stance::Observe {
spoken.push(decision::phrase(assertion.id, &reason, &remedy));
}
}
}
}
if spoken.is_empty() {
Decision::Silent
} else {
Decision::Assert(spoken.join("\n\n"))
}
}
fn note_claim(assertion: &Assertion, stance: &str, outcome: &str, bash: &Bash, claim: &Claim) {
let excerpt = crate::backtest::excerpt(&bash.command, claim.span.start, claim.span.end);
journal::record(&journal::Entry {
rule: assertion.id,
stance,
outcome,
session: &bash.session,
repo: &repo_name(&bash.cwd),
mode: &bash.permission_mode,
excerpt: &excerpt,
});
}
fn confirmed(
rule: &Rule,
finding: &Finding,
bash: &Bash,
parsed: &Parsed,
) -> Result<(), &'static str> {
let Some(confirm) = rule.confirm else {
return Ok(());
};
if !bash.cwd.is_dir() {
return Err("the working directory does not exist");
}
let ctx = Context {
cwd: &bash.cwd,
parsed,
background: bash.background,
};
match confirm(&ctx, finding) {
Confirmed::Yes => Ok(()),
Confirmed::No(why) => Err(why),
}
}
fn note(rule: &Rule, stance: &str, outcome: &str, bash: &Bash, finding: &Finding) {
let excerpt = crate::backtest::excerpt(&bash.command, finding.span.start, finding.span.end);
journal::record(&journal::Entry {
rule: rule.id,
stance,
outcome,
session: &bash.session,
repo: &repo_name(&bash.cwd),
mode: &bash.permission_mode,
excerpt: &excerpt,
});
}
fn repo_name(cwd: &std::path::Path) -> String {
let mut dir = cwd;
loop {
if dir.join(".git").exists() {
break;
}
match dir.parent() {
Some(p) => dir = p,
None => break,
}
}
dir.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "-".to_string())
}
fn heartbeat() {
let Some(dir) = journal::dir() else { return };
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
journal::private(&dir, 0o700);
let tmp = dir.join("heartbeat.new");
if std::fs::write(&tmp, format!("{now} {}\n", env!("CARGO_PKG_VERSION"))).is_ok() {
journal::private(&tmp, 0o600);
let _ = std::fs::rename(&tmp, dir.join("heartbeat"));
}
}