use std::io::{IsTerminal, Read};
use std::process::ExitCode;
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),
}
}
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 !confirmed(rule, finding, bash, &parsed) {
note(rule, "unconfirmed", "skipped", 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 confirmed(rule: &Rule, finding: &Finding, bash: &Bash, parsed: &Parsed) -> bool {
let Some(confirm) = rule.confirm else {
return true;
};
if !bash.cwd.is_dir() {
return false;
}
let ctx = Context {
cwd: &bash.cwd,
parsed,
background: bash.background,
};
matches!(confirm(&ctx, finding), Confirmed::Yes)
}
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"));
}
}