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();
crate::session_state::sweep();
on_session_start(&session)
}
Event::NotOurs => Decision::Silent,
Event::PreFile(op) => on_file(&op),
Event::PostFile(op) => on_post_file(&op),
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 let Some(line) = crate::shim::notice() {
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);
let dumped = rules::dump::dumps(&parsed);
if fired.is_empty() && dumped.is_empty() {
return Decision::Silent;
}
for cmd in parsed.hidden() {
if let Some(why) = &cmd.opaque {
journal::record(&journal::Entry {
rule: "-",
stance: "-",
outcome: "partial",
session: &bash.session,
repo: &repo_name(&bash.cwd),
mode: &bash.permission_mode,
excerpt: &why.why(),
});
break;
}
}
let mut deny: Vec<String> = Vec::new();
let mut advise: Vec<String> = Vec::new();
if !bash.background {
for d in &dumped {
let ctx = Context {
cwd: &bash.cwd,
parsed: &parsed,
background: bash.background,
timeout_ms: bash.timeout_ms,
};
let path = resolve_path(&ctx.cwd_at(d.at), &d.path);
let window = dump_window(&d.extent);
if let Some(text) = reread_verdict(
&bash.session,
&path,
&window,
&bash.permission_mode,
&bash.cwd,
&d.path,
) {
match crate::stance::resolve(&rules::file_reread::RULE) {
Stance::Deny => deny.push(text),
Stance::Advise => advise.push(text),
Stance::Observe => {}
}
}
}
}
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);
let stance = if parsed.fully_read() {
stance
} else {
stance.min(Stance::Advise)
};
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 ctx = Context {
cwd: &bash.cwd,
parsed: &parsed,
background: bash.background,
timeout_ms: bash.timeout_ms,
};
if parsed.fully_read() {
for d in rules::dump::dumps(&parsed) {
let path = resolve_path(&ctx.cwd_at(d.at), &d.path);
crate::session_state::record(&bash.session, "read", &path, &dump_window(&d.extent));
}
}
let claimed = assertions::examine_all(&parsed);
if claimed.is_empty() {
return Decision::Silent;
}
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 on_file(op: &crate::payload::FileOp) -> Decision {
if op.writes {
crate::session_state::record(&op.session, "write", &op.path, "full");
return Decision::Silent;
}
let mut advise: Vec<String> = Vec::new();
let mut deny: Vec<String> = Vec::new();
let shown = op.path.to_string_lossy().into_owned();
if op.window == "full" && rules::persisted_output_dump::is_persisted(&shown) {
let rule = &rules::persisted_output_dump::RULE;
let stance = crate::stance::resolve(rule);
let text = decision::phrase(
rule.id,
&rules::persisted_output_dump::reason(),
&rules::persisted_output_dump::remedy(),
);
note_file(rule, stance, op, &shown);
match stance {
Stance::Deny => deny.push(text),
Stance::Advise => advise.push(text),
Stance::Observe => {}
}
}
if let Some(text) = reread_verdict(
&op.session,
&op.path,
&op.window,
&op.permission_mode,
&op.cwd,
&shown,
) {
match crate::stance::resolve(&rules::file_reread::RULE) {
Stance::Deny => deny.push(text),
Stance::Advise => advise.push(text),
Stance::Observe => {}
}
}
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_file(op: &crate::payload::FileOp) -> Decision {
if !op.writes {
crate::session_state::record(&op.session, "read", &op.path, &op.window);
}
Decision::Silent
}
fn dump_window(extent: &rules::dump::Extent) -> String {
match extent {
rules::dump::Extent::Whole => "full".to_string(),
rules::dump::Extent::Lines(n) => format!("0:{n}"),
rules::dump::Extent::Bytes(n) => format!("bytes:{n}"),
}
}
fn reread_verdict(
session: &str,
path: &std::path::Path,
window: &str,
mode: &str,
cwd: &std::path::Path,
shown: &str,
) -> Option<String> {
let seen = crate::session_state::last_read(session, path)?;
if seen.window != "full" && seen.window != window {
return None;
}
let rule = &rules::file_reread::RULE;
let stance = crate::stance::resolve(rule);
let (reason, remedy) = rules::file_reread::phrase(shown, &seen);
let outcome = match stance {
Stance::Observe => "watched",
Stance::Advise => "advised",
Stance::Deny => "denied",
};
journal::record(&journal::Entry {
rule: rule.id,
stance: stance.as_str(),
outcome,
session,
repo: &repo_name(cwd),
mode,
excerpt: shown,
});
match stance {
Stance::Observe => None,
_ => Some(decision::phrase(rule.id, &reason, &remedy)),
}
}
fn note_file(rule: &Rule, stance: Stance, op: &crate::payload::FileOp, shown: &str) {
journal::record(&journal::Entry {
rule: rule.id,
stance: stance.as_str(),
outcome: match stance {
Stance::Observe => "watched",
Stance::Advise => "advised",
Stance::Deny => "denied",
},
session: &op.session,
repo: &repo_name(&op.cwd),
mode: &op.permission_mode,
excerpt: shown,
});
}
fn resolve_path(cwd: &std::path::Path, text: &str) -> std::path::PathBuf {
if text.starts_with('/') {
std::path::PathBuf::from(text)
} else if let Some(rest) = text.strip_prefix("~/") {
std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join(rest))
.unwrap_or_else(|| cwd.join(text))
} else {
cwd.join(text)
}
}
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,
timeout_ms: bash.timeout_ms,
};
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"));
}
}