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();
crate::preview::sweep();
on_session_start(&session)
}
Event::NotOurs => Decision::Silent,
Event::Prompt(prompt) => {
crate::preview::on_prompt(&prompt);
Decision::Silent
}
Event::PreAsk(ask) => {
crate::implementation_review::on_pre_ask(&ask);
let stance = crate::stance::resolve(&rules::push_preview::RULE);
match crate::preview::on_pre_ask(&ask, stance) {
Some(why) => {
Decision::Deny(decision::phrase(rules::push_preview::RULE.id, &why, ""))
}
None => Decision::Silent,
}
}
Event::PrePlanExit(plan) => {
let stance = crate::stance::resolve(&rules::plan_review_panel::RULE);
crate::plan_review::on_plan_exit(&plan, stance)
}
Event::PostAsk(ask) => {
let mut said: Vec<String> = Vec::new();
if let Some(text) = crate::preview::on_post_ask(&ask) {
said.push(decision::phrase(rules::push_preview::RULE.id, &text, ""));
}
if let Some(text) = crate::implementation_review::on_post_ask(&ask) {
if crate::stance::resolve(&rules::implementation_review::RULE) != Stance::Observe {
said.push(decision::phrase(
rules::implementation_review::RULE.id,
&text,
"",
));
}
}
if said.is_empty() {
Decision::Silent
} else {
Decision::Assert(said.join("\n\n"))
}
}
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 let Some(line) = crate::plans::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 let Some(span) = crate::implementation_review::store_clause(&parsed) {
let rule = &rules::implementation_review::RULE;
journal::record(&journal::Entry {
rule: rule.id,
stance: "deny",
outcome: "denied",
session: &bash.session,
repo: &repo_name(&bash.cwd),
mode: &bash.permission_mode,
excerpt: &crate::backtest::excerpt(&bash.command, span.start, span.end),
});
return Decision::Deny(decision::phrase(
rule.id,
crate::implementation_review::GUARD_REASON,
crate::implementation_review::GUARD_REMEDY,
));
}
let input = rules::Input::new(&bash.command, rules::tool_shell::dialect(), &parsed);
let fired = rules::evaluate(&input);
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,
tool_use_id: &bash.tool_use_id,
transcript: bash.transcript.as_deref(),
};
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,
)
.text
{
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 mut stance = crate::stance::resolve(rule);
let Confirmation { floor, said } = match confirmed(rule, finding, bash, &parsed) {
Ok(c) => c,
Err(why) => {
note(rule, "unconfirmed", why, bash, finding);
continue;
}
};
if let Some(floor) = floor {
if stance >= Stance::Advise {
stance = stance.max(floor).min(rule.max_stance);
}
}
let (reason, excerpt): (&str, Option<&str>) = match &said {
Some((reason, excerpt)) => (reason.as_str(), Some(excerpt.as_str())),
None => (finding.reason.as_str(), None),
};
let text = decision::phrase(rule.id, reason, &finding.remedy);
let stance = if parsed.fully_read() {
stance
} else {
stance.min(Stance::Advise)
};
match stance {
Stance::Observe => note_with(rule, "observe", "watched", bash, finding, excerpt),
Stance::Advise => {
note_with(rule, "advise", "advised", bash, finding, excerpt);
advise.push(text);
}
Stance::Deny => {
note_with(rule, "deny", "denied", bash, finding, excerpt);
deny.push(text);
}
}
}
if deny.is_empty() {
crate::preview::record_before(bash, &parsed);
}
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;
}
let unbound = crate::preview::bind(bash, &parsed)
.map(|t| decision::phrase(rules::push_preview::RULE.id, &t, ""));
if bash.background {
return unbound.map_or(Decision::Silent, Decision::Assert);
}
let ctx = Context {
cwd: &bash.cwd,
parsed: &parsed,
background: bash.background,
timeout_ms: bash.timeout_ms,
tool_use_id: &bash.tool_use_id,
transcript: bash.transcript.as_deref(),
};
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 unbound.map_or(Decision::Silent, Decision::Assert);
}
let mut spoken: Vec<String> = unbound.into_iter().collect();
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::Noted(outcome) => note_claim(assertion, stance.as_str(), outcome, 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: &attributed_repo(assertion.id, bash),
mode: &bash.permission_mode,
excerpt: &excerpt,
});
}
fn on_file(op: &crate::payload::FileOp) -> Decision {
if op.writes {
if crate::implementation_review::guards_path(&op.path) {
let rule = &rules::implementation_review::RULE;
journal::record(&journal::Entry {
rule: rule.id,
stance: "deny",
outcome: "denied",
session: &op.session,
repo: &repo_name(&op.cwd),
mode: &op.permission_mode,
excerpt: &format!("write {}", op.path.display()),
});
return Decision::Deny(decision::phrase(
rule.id,
crate::implementation_review::GUARD_REASON,
crate::implementation_review::GUARD_REMEDY,
));
}
crate::session_state::record(&op.session, "write", &op.path, "full");
return lint_suppression(op);
}
let mut advise: Vec<String> = Vec::new();
let mut deny: Vec<String> = Vec::new();
let shown = op.path.to_string_lossy().into_owned();
let persisted = op.window == "full" && rules::persisted_output_dump::is_persisted(&shown);
if persisted {
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 => {}
}
}
let reread = reread_verdict(
&op.session,
&op.path,
&op.window,
&op.permission_mode,
&op.cwd,
&shown,
);
if let Some(text) = reread.text {
match crate::stance::resolve(&rules::file_reread::RULE) {
Stance::Deny => deny.push(text),
Stance::Advise => advise.push(text),
Stance::Observe => {}
}
}
if !persisted && !reread.seen {
if let Some(bytes) = rules::read_unbounded_large::applies(&op.path, &op.window) {
let rule = &rules::read_unbounded_large::RULE;
let stance = crate::stance::resolve(rule);
let (reason, remedy) = rules::read_unbounded_large::phrase(&shown, bytes);
let text = decision::phrase(rule.id, &reason, &remedy);
note_file(rule, stance, op, &shown);
match stance {
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 lint_suppression(op: &crate::payload::FileOp) -> Decision {
use rules::lint_suppression_added as lsa;
let Some(change) = &op.change else {
return Decision::Silent;
};
if !lsa::worth_rebuilding(&op.path, change) {
return Decision::Silent;
}
let rebuilt = lsa::reconstruct(&op.path, change);
let hits = lsa::examine_change(&op.path, &rebuilt.before, &rebuilt.after);
if hits.is_empty() {
return Decision::Silent;
}
let rule = &lsa::RULE;
let stance = crate::stance::resolve(rule);
let shown = op.path.to_string_lossy().into_owned();
note_file(
rule,
stance,
op,
&lsa::excerpt(&shown, &hits, &rebuilt.mode),
);
let text = lsa::phrase(&shown, &hits, &rebuilt.mode);
match stance {
Stance::Deny => Decision::Deny(text),
Stance::Advise => Decision::Advise(text),
Stance::Observe => 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}"),
}
}
struct Reread {
seen: bool,
text: Option<String>,
}
fn reread_verdict(
session: &str,
path: &std::path::Path,
window: &str,
mode: &str,
cwd: &std::path::Path,
shown: &str,
) -> Reread {
let unseen = Reread {
seen: false,
text: None,
};
let Some(seen) = crate::session_state::last_read(session, path) else {
return unseen;
};
if seen.window != "full" && seen.window != window {
return unseen;
}
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,
});
Reread {
seen: true,
text: 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)
}
}
struct Confirmation {
floor: Option<Stance>,
said: Option<(String, String)>,
}
fn confirmed(
rule: &Rule,
finding: &Finding,
bash: &Bash,
parsed: &Parsed,
) -> Result<Confirmation, &'static str> {
let Some(confirm) = rule.confirm else {
return Ok(Confirmation {
floor: None,
said: None,
});
};
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,
tool_use_id: &bash.tool_use_id,
transcript: bash.transcript.as_deref(),
};
match confirm(&ctx, finding) {
Confirmed::Yes => Ok(Confirmation {
floor: None,
said: None,
}),
Confirmed::YesAt(floor) => Ok(Confirmation {
floor: Some(floor),
said: None,
}),
Confirmed::YesSaying {
floor,
reason,
excerpt,
} => Ok(Confirmation {
floor,
said: Some((reason, excerpt)),
}),
Confirmed::No(why) => Err(why),
}
}
fn note(rule: &Rule, stance: &str, outcome: &str, bash: &Bash, finding: &Finding) {
note_with(rule, stance, outcome, bash, finding, None);
}
fn note_with(
rule: &Rule,
stance: &str,
outcome: &str,
bash: &Bash,
finding: &Finding,
excerpt: Option<&str>,
) {
let span = crate::backtest::excerpt(&bash.command, finding.span.start, finding.span.end);
let excerpt = excerpt.unwrap_or(&span);
let mode = match rule.examine {
rules::Examine::Analysis(_) => format!(
"{}+{}",
bash.permission_mode,
rules::tool_shell::dialect().as_str()
),
rules::Examine::Legacy(_) => bash.permission_mode.clone(),
};
journal::record(&journal::Entry {
rule: rule.id,
stance,
outcome,
session: &bash.session,
repo: &attributed_repo(rule.id, bash),
mode: &mode,
excerpt,
});
}
fn attributed_repo(id: &str, bash: &Bash) -> String {
let pushes = [
rules::push_preview::RULE.id,
rules::implementation_review::RULE.id,
crate::assertions::push_published::ASSERTION.id,
];
if !pushes.contains(&id) {
return repo_name(&bash.cwd);
}
let parsed = shell::lex(&bash.command);
let resolved = crate::push_target::find(&parsed).and_then(|cmd| {
let ctx = Context {
cwd: &bash.cwd,
parsed: &parsed,
background: bash.background,
timeout_ms: bash.timeout_ms,
tool_use_id: &bash.tool_use_id,
transcript: bash.transcript.as_deref(),
};
crate::push_target::repository(&ctx.cwd_at(cmd.at), cmd)
});
match resolved.as_deref().and_then(std::path::Path::file_name) {
Some(n) => n.to_string_lossy().into_owned(),
None => repo_name(&bash.cwd),
}
}
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"));
}
}