amont-agent 2.6.0

A guard that inspects a shell command before Claude Code runs it
//! The guard itself: payload in, decision out.
//!
//! ## The contract
//!
//! **Every failure path exits 0 having written nothing.** Unreadable payload,
//! unknown event, a tool that is not Bash, a command this crate's lexer cannot
//! parse, a working directory that no longer exists, a rule that panics, a
//! journal that cannot be written — all of them are silence.
//!
//! That is not defensiveness, it is the only posture that keeps the guard
//! installed. A hook that fails toward refusing gets in the way of work the
//! author knew was correct, and the fix a person reaches for at that moment is
//! to delete the whole thing from `settings.json`, which switches off every
//! rule at once. A hook that fails toward silence loses one firing.
//!
//! ## The order is a cost decision
//!
//! `examine` runs first and touches nothing. Only if something fires do we pay
//! for `git config` (one process per key) or `confirm` (one process, or a
//! directory read). This runs before every shell command the model issues, so
//! the no-fire path is the path that has to be free.

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 {
    // A person typing `amont-agent hook` with no payload would otherwise block
    // on stdin forever with no indication why.
    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();
    }

    // A panic anywhere below is a bug in this crate, and a bug in this crate
    // must not become a refused command or a broken session.
    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) => {
            // The fact that we ran is what `doctor` uses to tell "no rule
            // fired" apart from "the guard is dead". Written first, so a slow
            // fetch below can never cost the heartbeat.
            heartbeat();
            on_session_start(&session)
        }
        Event::NotOurs => Decision::Silent,
        Event::PreBash(bash) => on_bash(&bash),
        Event::PostBash(bash) => on_post_bash(&bash),
    }
}

/// Where the checkout stands against the remote, stated once per session.
///
/// Governed by the `stale-base` rule's stance, so one key silences both the
/// notice and the branch-creation rule: `observe` measures and journals but
/// says nothing; anything above it speaks. There is nothing to refuse at a
/// session opening, so `deny` speaks exactly like `advise` here.
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"))
    }
}

/// The stale-checkout half of the session notice. `None` is silence —
/// up to date, not a repository, or the rule is only observing.
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() {
        // The whole no-fire path: one lex, no processes, no files.
        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) {
            // The reason is the record. `status` tallies these, and "why did
            // confirm say no" is the number that decides whether an observing
            // rule may ever advise — a rule declined for `already in a linked
            // worktree` a thousand times is a rule being obeyed, not one that
            // is wrong.
            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);
            }
        }
    }

    // A refusal outranks advice: there is no point advising about a command
    // that is not going to run. The advisory findings are still journalled.
    if !deny.is_empty() {
        Decision::Deny(deny.join("\n\n"))
    } else if !advise.is_empty() {
        Decision::Advise(advise.join("\n\n"))
    } else {
        Decision::Silent
    }
}

/// A call that has already run and reported success.
///
/// The shape mirrors `on_bash` deliberately — lex, pure prefilter, and only
/// then anything that touches the world — because this fires after EVERY
/// successful Bash call, of every session on the machine. A user-scope hook is
/// not per-project: two other sessions' commands arrive here too, which is also
/// why every answer is taken from the payload's `cwd` and never from this
/// process's own.
///
/// There is nothing left to refuse, so `deny` speaks like `advise`, the same
/// way it does at a session opening.
fn on_post_bash(bash: &Bash) -> Decision {
    let parsed = shell::lex(&bash.command);
    if matches!(parsed, Parsed::Opaque(_)) {
        return Decision::Silent;
    }
    if bash.background {
        // Detached: the tool call returned a task id, not a result. Whatever it
        // claimed has not finished happening.
        return Decision::Silent;
    }

    let claimed = assertions::examine_all(&parsed);
    if claimed.is_empty() {
        // The whole no-claim path: one lex, no processes, no files.
        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,
    });
}

/// A rule with no `confirm` is confirmed. A `confirm` that cannot answer is
/// NOT — failing to establish the fact is silence, like everything else here.
/// The `Err` names why, in the words the rule chose, for the journal.
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,
    });
}

/// The basename of the repository, not its path. Enough to group firings by
/// project without writing `/Users/<name>/…` into a log file.
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())
}

/// One line, rewritten by rename so it is always current and always whole.
/// `doctor` compares it against the newest transcript timestamp: transcripts
/// prove sessions happened, this proves the guard ran in one.
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() {
        // Narrowed BEFORE the rename, so no window exists in which the
        // finished file is readable by anyone but its owner. It carries only
        // a timestamp and a version, but it sits in the same directory as the
        // journal and there is no reason for the two to disagree about who
        // may read them.
        journal::private(&tmp, 0o600);
        let _ = std::fs::rename(&tmp, dir.join("heartbeat"));
    }
}