amont-agent 2.4.0

A guard that inspects a shell command before Claude Code runs it
# amont-agent

**Your agent just ran `git push … | tail -5`, the push was rejected, and the
command reported success.**

A pipeline's exit status is its **last** command's. `tail` succeeds at tailing
an error message — so a rejected push, a push killed by a timeout, and a push
that never left the machine all look identical, and the trimming throws the
error text away too. The failure is silent in both channels, and the model
reads its own impatience as a green tick.

No git hook can catch that. The mistake is in the command string and never
reaches one.

`amont-agent` is a Claude Code `PreToolUse` hook that reads a shell command
before it runs, and can observe it, advise against it, or refuse it.

[![CI](https://github.com/fredericrous/amont-agent/actions/workflows/ci.yaml/badge.svg)](https://github.com/fredericrous/amont-agent/actions/workflows/ci.yaml)
[![License](https://img.shields.io/github/license/fredericrous/amont-agent)](LICENSE)

```console
$ amont-agent check 'git push origin main 2>&1 | tail -5'
pipe-to-tail [deny]
  `git push` pipes into `tail`, so the pipeline reports tail's exit status,
  not git push's. A failed, rejected or timed-out run reads as success, and
  the trimming discards the error text as well.
  → Run `git push` on its own and read its output afterwards. Then verify the
    effect rather than the exit code.
  ▸ git push origin main 2>&1 | tail -5
```

## Install

```sh
curl -fsSL https://raw.githubusercontent.com/fredericrous/amont-agent/main/install/install.sh | sh
```

Then wire it in — a separate, deliberate step, because a program that can
refuse your agent's commands should not add itself to your settings as a side
effect of you downloading it:

```sh
amont-agent install          # prints the settings block, writes nothing
amont-agent install --write  # merges it into ~/.claude/settings.json
amont-agent doctor           # installed, runnable, and actually firing?
```

Also: `brew install fredericrous/tap/amont-agent` · `cargo install amont-agent`
· [prebuilt binaries](https://github.com/fredericrous/amont-agent/releases/latest)
for Linux (gnu/musl, x86_64 and aarch64), macOS (Intel and Apple silicon) and
Windows.

## It measures before it blocks

Five of the six rules ship as `observe` — they record and say nothing at all.
That is not timidity, it is the method:

| stance | effect |
|---|---|
| `observe` | records the firing and says nothing |
| `advise` | puts the reason into the model's context; refuses nothing |
| `deny` | refuses the tool call, with the reason and the remedy |

`observe` and `advise` are not two ways of saying "not blocking yet".
`additionalContext` enters the model's context and changes its behaviour,
which contaminates the rate the observation exists to measure. **A rule that
talks is intervening.**

So a rule is promoted from your own transcripts, not from an argument:

```sh
amont-agent backtest --since 2026-07-06         # firings per 1,000 tool calls, weekly
amont-agent explain pipe-to-tail --format cases >> tests/corpus/pipe-to-tail.cases
$EDITOR tests/corpus/pipe-to-tail.cases          # each `?` becomes match or nomatch
amont-agent corpus check                         # and this runs in the test suite
amont-agent graduate pipe-to-tail --to deny
```

`pipe-to-tail` is the only rule that blocks, and it blocks because seven
consecutive weeks of measurement showed no downward trend while every other
habit halved. A habit the model is already correcting does not need a `deny`.

Demotion is not gated at all — `amont-agent demote <rule>`, no questions. A
guard that is hard to back out of is one people uninstall instead of demoting,
and uninstalling takes every rule with it.

[The full method](docs/measuring.md).

## The rules

| rule | ships as | what it catches |
|---|---|---|
| `pipe-to-tail` | `deny` | a mutating command whose status is swallowed by a pipe |
| `bare-stash-pop` | `observe` | `git stash pop` with no ref, where `refs/stash` is shared across worktrees |
| `gh-pr-merge-auto` | `observe` | `--auto` on a repo with no required checks, which merges immediately |
| `no-verify` | `observe` | turning the whole commit gate off rather than one check |
| `git-add-broad` | `observe` | staging the tree instead of the change |
| `stale-base` | `advise` | a branch or worktree started from a checkout the remote has moved past |
| `push-preflight` | `advise` | a `git push` whose slow pre-push test gate has not been rehearsed with `amont rehearse --wait` |
| `foreground-poll` | `advise` | a polling loop or `gh run watch` in the foreground, where the tool's ten-minute clock will kill it one poll short |
| `sed-in-place` | `advise` | `sed -i` spelled for the other sed (`-i ''` on GNU, bare `-i` on BSD) |
| `kubectl-gitops` | `advise` | an imperative `kubectl` write in a repository Flux or Argo reconciles |
| `tag-after-commit` | `advise` | `git tag` chained onto a `git commit` that a hook may have refused |
| `worktree-remove-force` | `advise` | `git worktree remove --force` on a worktree that still holds uncommitted work |
| `amend-pushed` | `advise` | `git commit --amend` on a commit the remote already has |
| `branch-force-delete` | `observe` | `git branch -D` on a branch whose commits are on no remote and not merged |

`stale-base` advises from the start because it refuses nothing and names a
failure no correcting loop can see: **nothing fails when you build on stale
code.** The work is correct against the code it can see, and the conflict
arrives later, from somewhere else. So a session opening in a checkout the
remote has moved past is told — after a five-second, one-branch fetch that
never pulls. [Why it never pulls](docs/session-notice.md).

`push-preflight` advises for the same reason. git opens its connection to
the remote *before* it runs `pre-push` and holds it idle for as long as the
test gate takes; a remote that closes idle sessions kills the push after the
gate has already passed, and the model reads "the network" where the cause
was the gate's placement. With amont ≥ 1.28, `amont rehearse --wait` runs the
same gate on a snapshot of `HEAD` with no connection open — or follows the
rehearsal `amont.rehearseOnCommit` already started — and stamps the tree, so
the push that follows skips the suite (`amont run pre-push` on 1.27). The
rule speaks only when `confirm` finds all three facts: amont guards this
repository's pushes, a test gate would run for this push, and `HEAD`'s tree
carries no stamp yet.

The seven rules added in 2.2.0 came out of the transcripts the same way —
nineteen thousand Bash calls, sorted by what failed, was killed, or drew a
correction. Each fires on shape and, where the fact lives in the world,
confirms it first: whether the call already runs in the background, which
`sed` is on `PATH`, whether the repository holds a Flux or Argo resource,
whether the worktree is dirty, whether the remote has the commit, whether
any other branch has the commits. The one shape-only rule, `tag-after-commit`,
names a failure every command in the chain reports as success.

## What it will not do

- **It never emits `allow`.** Approving everything it has no objection to would
  switch off the permission system it was installed beside. Silence is how it
  says "no objection".
- **Every failure path is silence.** An unreadable payload, an unknown event, a
  command it cannot parse, a rule that panics — all exit 0 having written
  nothing. A hook that fails toward *refusing* gets deleted from
  `settings.json`, which switches off every rule at once; one that fails toward
  silence loses a single firing.
- **It does not judge what it cannot read.** Heredocs without terminators,
  `eval`, `sh -c`, unbalanced quotes — opaque never fires.
- **It does not phone home.** No telemetry, no update checks. Every firing is
  journalled to `~/.claude/amont-agent/journal.log`, redacted, and it only
  counts — nothing in it may participate in a decision.
- **No repository can change a stance.** Stances are read from `--global` and
  `--system` git config only — never from a committed file, and never from the
  `.git/config` of the repository the agent is standing in, which is a file
  that agent could write.

[The reasoning in full](docs/refusals.md).

## Turning it down

```sh
git config --global amont.agent.pipe-to-tail.stance observe   # one rule
git config --global amont.agent.stance observe                # all of them
AMONT_AGENT_OFF=1                                             # this shell
amont-agent uninstall --write                                 # remove the entries
```

## Documentation

[Installing](docs/install.md) · [Stances](docs/stances.md) ·
[The rules](docs/rules.md) ·
[Measuring and graduating](docs/measuring.md) ·
[The session notice](docs/session-notice.md) ·
[Configuration](docs/configuration.md) ·
[What it will not do](docs/refusals.md)

## Contributing

```sh
make check    # fmt, clippy -D warnings, tests — exactly what CI runs
```

One crate, `serde` and `serde_json` its only dependencies, and both only for
*reading* — Claude Code's payload and your `settings.json`, two shapes defined
by somebody else. What this binary writes is emitted by a hand-rolled escaper,
so the reading and the writing share no representation.

## Related

[amont](https://github.com/fredericrous/amont) — git hooks that catch a bad
commit before it exists, by the same author. Independent of this: no shared
code, and neither needs the other. They meet in one optional place, described
in [the session notice](docs/session-notice.md).

[attest](https://github.com/fredericrous/attest) — the CI end of the same
story: amont signs a note at pre-push naming the gates that really ran, and
attest verifies it so CI can skip them. No connection to this guard beyond
the author and the conviction all three share — trust what was verified,
never what was reported. The masked push at the top of this page is what
that looks like when it fails.

## License

[MIT](LICENSE).