amont-agent 2.26.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
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
//! 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 implementation_review;
pub mod kubectl_gitops;
pub mod lint_suppression_added;
pub mod no_verify;
pub mod path_operand_missing;
pub mod persisted_output_dump;
pub mod pipe_to_tail;
pub mod plan_review_panel;
pub mod poll_blank_verdict;
pub mod push_preflight;
pub mod push_preview;
pub mod read_unbounded_large;
pub mod release_tag_push;
pub mod request_fanout;
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 unsplit_expansion;
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,
    /// Confirmed, and this instance is serious enough to be held at least at
    /// this stance — never above the rule's `max_stance`, and never raised
    /// from `observe`, which is how a person turns a rule off.
    YesAt(Stance),
    /// Confirmed, with what to say and what to journal known only after
    /// looking: `reason` replaces the finding's, and `excerpt` replaces the
    /// command span in the journal line, so a rule whose fact is "which
    /// tree, which review" can name it without a second journal line.
    /// `floor` is [`Confirmed::YesAt`]'s.
    YesSaying {
        floor: Option<Stance>,
        reason: String,
        excerpt: String,
    },
    /// 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,
    /// The loudest this rule may ever be, whatever git config says.
    ///
    /// `amont.agent.stance deny` promotes every rule at once, and the
    /// per-rule key promotes one. For a rule whose match is exact that is the
    /// person's call to make. For a rule built on an estimate — "this loop MAY
    /// make hundreds of requests" — a refusal would be the guard claiming a
    /// certainty its analysis does not have, so the ceiling is part of the
    /// rule, enforced in [`crate::stance::resolve`] and by `graduate`, and not
    /// a default somebody can configure past.
    pub max_stance: Stance,
    pub evidence: Evidence,
    /// PURE. See the module note, and [`Examine`] for what each kind reads.
    pub examine: Examine,
    /// Consumed by the hook path; see [`Confirmed`].
    #[allow(dead_code)]
    pub confirm: Option<fn(&crate::rules::Context, &Finding) -> Confirmed>,
}

/// What a rule's `examine` reads, which is also what decides whether it may
/// run on a command the legacy lexer could not read.
///
/// A variant rather than a flag: the two kinds differ in their input type, and
/// the dispatch in [`evaluate`] is the only place that has to know.
#[derive(Clone, Copy)]
pub enum Examine {
    /// Reads the legacy lexer's clauses ([`crate::shell::lex`]). Skipped
    /// entirely when that lexer returned [`Parsed::Opaque`] — the behaviour
    /// every caller had before [`evaluate`] existed, kept exactly.
    Legacy(fn(&Parsed) -> Option<Finding>),
    /// Reads the whole [`Input`], and runs whatever the legacy lexer made of
    /// the command: a rule of this kind carries its own account of what it
    /// could not understand.
    Analysis(fn(&Input) -> Option<Finding>),
}

/// Which shell the command will run in.
///
/// An input to judgement, never read from the world inside `examine`: the hook
/// knows it (see [`tool_shell::dialect`]), `check` is told it, and the
/// backtester cannot know it because transcripts do not record the shell.
/// `Unknown` is a real answer, and a rule that depends on the difference must
/// allow for both shells rather than assume one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dialect {
    Bash,
    Zsh,
    Unknown,
}

impl Dialect {
    pub fn as_str(self) -> &'static str {
        match self {
            Dialect::Bash => "bash",
            Dialect::Zsh => "zsh",
            Dialect::Unknown => "unknown",
        }
    }

    pub fn parse(s: &str) -> Option<Dialect> {
        match s {
            "bash" => Some(Dialect::Bash),
            "zsh" => Some(Dialect::Zsh),
            "unknown" => Some(Dialect::Unknown),
            _ => None,
        }
    }
}

/// Everything a rule may judge a command by: the source as written, the shell
/// it runs in, the legacy lexer's reading of it, and — computed at most once,
/// and only when a rule asks — the shell analysis ([`crate::analysis`]).
pub struct Input<'a> {
    pub src: &'a str,
    pub dialect: Dialect,
    pub legacy: &'a Parsed,
    analysis: std::cell::OnceCell<crate::analysis::Analysis>,
}

impl<'a> Input<'a> {
    pub fn new(src: &'a str, dialect: Dialect, legacy: &'a Parsed) -> Input<'a> {
        Input {
            src,
            dialect,
            legacy,
            analysis: std::cell::OnceCell::new(),
        }
    }

    /// The analysis of [`Input::src`] under [`Input::dialect`], run on first
    /// use and shared by every rule that reads it.
    pub fn analysis(&self) -> &crate::analysis::Analysis {
        self.analysis
            .get_or_init(|| crate::analysis::analyze(self.src, self.dialect))
    }
}

impl Rule {
    /// This rule's verdict on one command, applying [`Examine`]'s dispatch.
    pub fn judge(&self, input: &Input) -> Option<Finding> {
        match self.examine {
            Examine::Legacy(f) => match input.legacy {
                Parsed::Opaque(_) => None,
                parsed => f(parsed),
            },
            Examine::Analysis(f) => f(input),
        }
    }
}

/// 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>,
    /// The payload's `tool_use_id`, shared by a call's `PreToolUse` and
    /// `PostToolUse`. Empty where there is no call (tests, `check`).
    pub tool_use_id: &'a str,
    /// The session's transcript, when the payload named one. Read by a
    /// `confirm` that asks what this session's agents completed
    /// (`implementation-review`); `None` where there is no session.
    pub transcript: Option<&'a std::path::Path>,
}

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,
    push_preview::RULE,
    plan_review_panel::RULE,
    implementation_review::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,
    unsplit_expansion::RULE,
    path_operand_missing::RULE,
    stat_bsd_format::RULE,
    whole_file_dump::RULE,
    persisted_output_dump::RULE,
    file_reread::RULE,
    read_unbounded_large::RULE,
    lint_suppression_added::RULE,
    request_fanout::RULE,
];

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

/// Every rule's verdict on one command — the single dispatch the hook,
/// `check`, the backtester and the corpus all share, so that the same command
/// and the same dialect can never be judged two ways.
///
/// 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 evaluate(input: &Input) -> Vec<(&'static Rule, Finding)> {
    let mut out = Vec::new();
    for rule in RULES {
        let found = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| rule.judge(input)));
        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,
            tool_use_id: "",
            transcript: 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,
            tool_use_id: "",
            transcript: 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,
            tool_use_id: "",
            transcript: 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,
            tool_use_id: "",
            transcript: 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,
                tool_use_id: "",
                transcript: 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,
            tool_use_id: "",
            transcript: 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"));
    }
}