amont-agent 2.15.0

A guard that inspects a shell command before Claude Code runs it
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! The rules, and the contract every rule obeys.
//!
//! A rule is one module plus one line in [`RULES`] — the same shape
//! amont's `registry::CHECKS` uses, and for the same reason: a rule's
//! name, its default stance and its function are declared together, so adding
//! one cannot half-happen.
//!
//! Deliberately NOT registered in `registry::CHECKS`. That table is the set of
//! things that gate a commit, `crates/amont/tests/docs_counts.rs` asserts on
//! its length against counts spelled out in three prose files, and these rules
//! gate nothing in git.
//!
//! ## Two devices carry the precision
//!
//! **[`Rule::examine`] is pure.** No processes, no filesystem, no network. It
//! runs on every Bash call the model makes, so anything it touches is paid for
//! thousands of times a week. Purity is also what makes the backtester honest:
//! a rule that consulted the world during `examine` could not be replayed
//! against a transcript, because the world has moved.
//!
//! **[`Rule::confirm`] runs only after `examine` has already fired.** It is the
//! one place a rule may look at the world, and it exists to turn a heuristic
//! into a fact — "is this repository actually shared across worktrees?", "does
//! this glob actually match nothing?". Fires are rare, so the cost is rare.

use std::ops::Range;

use crate::shell::Parsed;

pub mod amend_pushed;
pub mod bare_stash_pop;
pub mod branch_force_delete;
pub mod dump;
pub mod equals_separator;
pub mod file_reread;
pub mod foreground_poll;
pub mod forge_merge_by_hand;
pub mod forge_status_stale_row;
pub mod gh_pr_merge_auto;
pub mod git_add_broad;
pub mod glob_in_flag_value;
pub mod glob_no_match;
pub mod kubectl_gitops;
pub mod no_verify;
pub mod path_operand_missing;
pub mod persisted_output_dump;
pub mod pipe_to_tail;
pub mod poll_blank_verdict;
pub mod push_preflight;
pub mod release_tag_push;
pub mod sed_in_place;
pub mod stale_base;
pub mod stat_bsd_format;
pub mod stdin_hang;
pub mod tag_after_commit;
pub mod tool_shell;
pub mod whole_file_dump;
pub mod worktree_isolation;
pub mod worktree_remove_force;

// A `fish-glob` rule was written and removed before the first commit, on the
// argument that a zero-match glob "aborts the command loudly and names the
// glob, which is the best feedback a person or a model can get", and that the
// rate was falling on its own (12.9 per thousand in early July, 3.4 by
// mid-August). Both halves were measured from command SHAPE.
//
// Measured from what the shell actually printed (2026-09-09, 32,555 calls),
// neither survives. The Bash tool runs zsh, not fish; the abort names the glob
// on stderr, but the tool's error flag follows the exit status of the LAST
// clause, and in 86–95% of real cases that was 0 — an empty result labelled
// success, which no correcting loop can see. And the rate was flat across five
// weeks, not falling. `glob-no-match`, `glob-in-flag-value` and
// `equals-separator` are that rule, rebuilt on the measurement: the shape in
// `examine`, and the one fact that ties it to a shell — which shell the tool
// runs — read in `confirm`, where an impure question belongs (`tool_shell`).
//
/// What a rule is allowed to DO when it fires.
///
/// Three states, not two, and the middle one is the point. `Observe` and
/// `Advise` are not interchangeable ways of "not blocking yet": `Advise` puts
/// text into the model's context and therefore changes its behaviour, which
/// contaminates the very rate the observation exists to measure. A rule that
/// talks is intervening.
///
/// So: `Observe` is where every rule ships and where the baseline is measured.
/// `Advise` answers "does it correct itself when told?" — and if the answer is
/// yes, `Deny` is never needed.
// All three rungs are now occupied, and the build order that got them here is
// the argument for trusting them: the backtester and the rules shipped BEFORE
// the hook that could act on them, so no rule blocked until its rate had been
// looked at. `pipe-to-tail` ships `Deny` on seven flat weeks of evidence;
// `stale-base` and `push-preflight` ship `Advise` because each speaks only
// after a `confirm` has established a fact. Everything else still ships
// `Observe`, which is where a rule goes to earn its case.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Stance {
    Observe,
    Advise,
    Deny,
}

impl Stance {
    pub fn as_str(self) -> &'static str {
        match self {
            Stance::Observe => "observe",
            Stance::Advise => "advise",
            Stance::Deny => "deny",
        }
    }
    /// The inverse of [`Stance::as_str`], for reading a stance back out of
    /// `git config amont.agent.<rule>.stance`. Paired with `as_str` from the
    /// start so the two spellings cannot drift apart later.
    #[allow(dead_code)]
    pub fn parse(s: &str) -> Option<Stance> {
        match s {
            "observe" => Some(Stance::Observe),
            "advise" => Some(Stance::Advise),
            "deny" => Some(Stance::Deny),
            _ => None,
        }
    }
}

/// Why a rule fired, and what to do about it.
#[derive(Debug, Clone)]
pub struct Finding {
    /// One sentence naming the MECHANISM, not the sin. "A pipeline's exit
    /// status is the last command's" tells the reader something they can use;
    /// "this is dangerous" does not.
    pub reason: String,
    /// What to do instead. A rule that says what is wrong without saying what
    /// to do cannot be obeyed, and an unobeyable rule is noise.
    pub remedy: String,
    /// Byte range of what actually matched, within the original command.
    ///
    /// Not cosmetic. 35% of real commands are multi-clause scripts, and a
    /// sample printed from the head of a 2 KB script shows text that has
    /// nothing to do with the match — which makes human review review the
    /// wrong thing. Every excerpt is centred on this.
    pub span: Range<usize>,
}

/// The outcome of the one world-touching step a rule is allowed.
///
/// Read by the hook path, which is the only caller that has a working
/// directory to confirm against; the backtester deliberately never runs
/// `confirm`, because the world has moved since those commands ran.
#[allow(dead_code)]
pub enum Confirmed {
    Yes,
    /// Not confirmed, with the reason. Failing to confirm is always silence.
    No(&'static str),
}

/// How the default stance was chosen, so a graduation shows its evidence.
/// Nothing reads this at run time.
#[derive(Debug, Clone, Copy)]
pub struct Evidence {
    pub per_1000: f32,
    pub measured: &'static str,
    pub trend: Trend,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trend {
    /// Not improving on its own over the stated number of weeks.
    Flat(u8),
    /// Already falling without a guard; leave it alone.
    Improving,
    /// Too rare to trend, kept for cost-of-a-miss rather than frequency.
    Rare,
    /// Not a mistake at all — the rate is how often the CLAIM is made, not how
    /// often somebody got something wrong.
    ///
    /// Only an assertion can carry this. `push-landed` fires on roughly one
    /// Bash call in twenty-six, and that number is the cost of ASKING (one
    /// `git ls-remote`), not the cost of speaking: it speaks only when the
    /// remote disagrees with the local branch. Reading it as "flat" would say a
    /// habit is not improving, and there is no habit here.
    Routine,
}

pub struct Rule {
    pub id: &'static str,
    pub default_stance: Stance,
    pub evidence: Evidence,
    /// PURE. See the module note.
    pub examine: fn(&Parsed) -> Option<Finding>,
    /// Consumed by the hook path; see [`Confirmed`].
    #[allow(dead_code)]
    pub confirm: Option<fn(&crate::rules::Context, &Finding) -> Confirmed>,
}

/// What a `confirm` is allowed to know.
#[allow(dead_code)]
pub struct Context<'a> {
    pub cwd: &'a std::path::Path,
    pub parsed: &'a Parsed,
    /// The call runs with `run_in_background: true`. A sibling field of the
    /// payload rather than part of the command, so only a `confirm` can see
    /// it — `foreground-poll` is the rule that asks.
    pub background: bool,
    /// `tool_input.timeout` in milliseconds, when the call set one. The
    /// tool's default is two minutes.
    pub timeout_ms: Option<u64>,
}

impl Context<'_> {
    /// The clock the call actually runs against.
    pub fn timeout_ms(&self) -> u64 {
        self.timeout_ms.unwrap_or(120_000)
    }

    /// The directory the clause at byte offset `at` actually runs in.
    ///
    /// The payload's `cwd` is the SESSION's directory. A third of real
    /// commands begin `cd /somewhere && …`, and every git question a
    /// `confirm` asks — is this checkout behind, is `refs/stash` shared — is
    /// about the directory the git command runs in, not the one the shell
    /// started in. Asking the wrong repository produced a confident wrong
    /// answer: a branch created in an up-to-date clone was advised as stale
    /// because the session sat in a checkout that was.
    ///
    /// The last `cd` clause before `at` wins, resolved against the session
    /// cwd (`~` against `$HOME`). A `cd` whose target came from a
    /// substitution is unknowable and ends the search: better the session
    /// cwd than a guess. A bare `cd` is `$HOME`; `cd -` is unknowable.
    pub fn cwd_at(&self, at: usize) -> std::path::PathBuf {
        let mut dir = self.cwd.to_path_buf();
        // A `cd` inside a substitution moves that subshell and nothing
        // else: it counts for the clauses of the SAME substitution and for
        // no other. The clauses of a substitution sit after every clause of
        // the line, so this is `continue`, not `break`: a nested clause's own
        // earlier `cd` is further down the list than clauses that follow it
        // in the source.
        let asking = self
            .parsed
            .clauses()
            .iter()
            .find(|c| c.at == at)
            .and_then(|c| c.nested);
        for cmd in self.parsed.clauses() {
            if cmd.at >= at {
                continue;
            }
            if cmd.program() != Some("cd") {
                continue;
            }
            if cmd.nested.is_some() && cmd.nested != asking {
                continue;
            }
            // The raw word after `cd`, not `operands()`: that helper drops a
            // leading `-` as a flag and a blanked substitution as nothing,
            // and both are exactly the cases that must read as unknowable.
            let target = cmd.words.iter().skip_while(|w| w.text != "cd").nth(1);
            let Some(target) = target else {
                if let Some(home) = std::env::var_os("HOME") {
                    dir = std::path::PathBuf::from(home);
                }
                continue;
            };
            if target.expanded || target.text.trim().is_empty() || target.text == "-" {
                return self.cwd.to_path_buf();
            }
            let t = target.text.as_str();
            dir = if let Some(rest) = t.strip_prefix("~/") {
                match std::env::var_os("HOME") {
                    Some(home) => std::path::PathBuf::from(home).join(rest),
                    None => return self.cwd.to_path_buf(),
                }
            } else if t == "~" {
                match std::env::var_os("HOME") {
                    Some(home) => std::path::PathBuf::from(home),
                    None => return self.cwd.to_path_buf(),
                }
            } else {
                dir.join(t)
            };
        }
        dir
    }
}

pub const RULES: &[Rule] = &[
    pipe_to_tail::RULE,
    bare_stash_pop::RULE,
    gh_pr_merge_auto::RULE,
    forge_merge_by_hand::RULE,
    forge_status_stale_row::RULE,
    no_verify::RULE,
    git_add_broad::RULE,
    stale_base::RULE,
    push_preflight::RULE,
    foreground_poll::RULE,
    sed_in_place::RULE,
    kubectl_gitops::RULE,
    tag_after_commit::RULE,
    release_tag_push::RULE,
    worktree_remove_force::RULE,
    amend_pushed::RULE,
    branch_force_delete::RULE,
    poll_blank_verdict::RULE,
    worktree_isolation::RULE,
    stdin_hang::RULE,
    glob_in_flag_value::RULE,
    glob_no_match::RULE,
    equals_separator::RULE,
    path_operand_missing::RULE,
    stat_bsd_format::RULE,
    whole_file_dump::RULE,
    persisted_output_dump::RULE,
    file_reread::RULE,
];

pub fn by_id(id: &str) -> Option<&'static Rule> {
    RULES.iter().find(|r| r.id == id)
}

/// Run every rule's `examine` over one parsed command.
///
/// A panicking rule is dropped and the others still report, mirroring
/// `dispatch::run_concurrently`. That isolation only exists because the
/// workspace release profile refuses `panic = "abort"` — see the root
/// `Cargo.toml`.
pub fn examine_all(parsed: &Parsed) -> Vec<(&'static Rule, Finding)> {
    let mut out = Vec::new();
    for rule in RULES {
        let found =
            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (rule.examine)(parsed)));
        match found {
            Ok(Some(f)) => out.push((rule, f)),
            Ok(None) => {}
            Err(_) => eprintln!("amont-agent: rule `{}` panicked; ignoring it", rule.id),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::shell::lex;

    fn at_of(parsed: &Parsed, needle: &str) -> usize {
        parsed
            .clauses()
            .iter()
            .find(|c| c.words.iter().any(|w| w.text == needle))
            .map(|c| c.at)
            .expect("clause")
    }

    #[test]
    fn a_cd_inside_a_substitution_moves_only_that_substitution() {
        let parsed = lex(
            "cd /tmp/here && echo $(cd /tmp/sub && git status) && git worktree add ../x -b feat/y",
        );
        let ctx = Context {
            cwd: std::path::Path::new("/session"),
            parsed: &parsed,
            background: false,
            timeout_ms: None,
        };
        assert_eq!(
            ctx.cwd_at(at_of(&parsed, "worktree")),
            std::path::PathBuf::from("/tmp/here"),
            "the subshell's cd did not leak out"
        );
        assert_eq!(
            ctx.cwd_at(at_of(&parsed, "status")),
            std::path::PathBuf::from("/tmp/sub"),
            "the line's cd reached in, then the subshell's own applied"
        );
    }

    #[test]
    fn a_leading_cd_moves_the_question() {
        let parsed = lex("cd /tmp/elsewhere && git worktree add ../x -b feat/y");
        let ctx = Context {
            cwd: std::path::Path::new("/session"),
            parsed: &parsed,
            background: false,
            timeout_ms: None,
        };
        assert_eq!(
            ctx.cwd_at(at_of(&parsed, "worktree")),
            std::path::PathBuf::from("/tmp/elsewhere")
        );
    }

    #[test]
    fn a_relative_cd_resolves_against_the_session_and_the_last_wins() {
        let parsed = lex("cd sub; cd deeper && git stash pop");
        let ctx = Context {
            cwd: std::path::Path::new("/session"),
            parsed: &parsed,
            background: false,
            timeout_ms: None,
        };
        assert_eq!(
            ctx.cwd_at(at_of(&parsed, "stash")),
            std::path::PathBuf::from("/session/sub/deeper")
        );
    }

    #[test]
    fn a_cd_after_the_clause_does_not_count() {
        let parsed = lex("git stash pop && cd /tmp/after");
        let ctx = Context {
            cwd: std::path::Path::new("/session"),
            parsed: &parsed,
            background: false,
            timeout_ms: None,
        };
        assert_eq!(
            ctx.cwd_at(at_of(&parsed, "stash")),
            std::path::PathBuf::from("/session")
        );
    }

    /// A target nobody can know without running the shell is not guessed.
    #[test]
    fn an_unknowable_cd_falls_back_to_the_session() {
        for command in ["cd $(mktemp -d) && git stash pop", "cd - && git stash pop"] {
            let parsed = lex(command);
            let ctx = Context {
                cwd: std::path::Path::new("/session"),
                parsed: &parsed,
                background: false,
                timeout_ms: None,
            };
            assert_eq!(
                ctx.cwd_at(at_of(&parsed, "stash")),
                std::path::PathBuf::from("/session"),
                "{command}"
            );
        }
    }

    #[test]
    fn tilde_is_home() {
        let parsed = lex("cd ~/work/repo && git stash pop");
        let ctx = Context {
            cwd: std::path::Path::new("/session"),
            parsed: &parsed,
            background: false,
            timeout_ms: None,
        };
        let home = std::path::PathBuf::from(std::env::var_os("HOME").expect("HOME"));
        assert_eq!(ctx.cwd_at(at_of(&parsed, "stash")), home.join("work/repo"));
    }
}