Skip to content

fix(gate): a denial tells the model to stop, and which no it hit - #300

Merged
CrazyWillBear merged 2 commits into
mainfrom
fix/gate-denial-message
Aug 18, 2026
Merged

fix(gate): a denial tells the model to stop, and which no it hit#300
CrazyWillBear merged 2 commits into
mainfrom
fix/gate-denial-message

Conversation

@CrazyWillBear

Copy link
Copy Markdown
Collaborator

Why

`error: tool 'x' denied by the gate` said only that something refused. The reliable next move from there is to route around it — deny `write_file`, reach for a `shell` heredoc, same effect achieved. The returned string is the only signal the model gets about a denial, so it has to carry the instruction, not just the fact.

What

The denial now says stop, explicitly — don't retry, don't re-ask, don't reach the same effect another way, surface it to the owner instead.

And it says which "no" it hit. A card decline and a never-list hit both land on `Decision.NEVER` and printed identical text, but they mean opposite things: one is a decision the owner can revisit, the other can never be lifted. An agent told the wrong one either nags about a permanent denial or abandons a retryable ask. `dispatch()` now remembers which path denied and picks the matching text.

The card text does not claim the owner typed "no." `Approval.DENY` also covers the 600s timeout, an unparseable answer, and a card refused because one was already pending on the thread — so it says "declined, or the card went unanswered."

Structure

Wording moves to `gate_text.py` alongside `announce_text`. `gate.py` was 4 lines from the 200-line hard cap, and the strings the gate emits are a separate concern from the logic that decides. `announce_text` had no callers outside `gate.py`, so the move is zero-churn.

Note this overlaps #296, which splits `gate.py` a different way (`gate_policy.py` for `GatePolicy` + `load_approved`/`save_approved`). The two splits are orthogonal — different concerns leaving the same overfull file — but both touch its import header, so whichever lands second will want a small rebase.

Not addressed

Distinguishing a real decline from a 600s timeout inside `Approval.DENY` — that needs a reason on the enum, and the vaguer wording is accurate today. Worth doing when the ambiguity actually costs something.

Testing

`799 passed` · `ruff check` clean · `mypy` clean.

New test pins that the two denials read differently and that both carry the stop instruction; the two existing denial assertions were updated to the new text.

🤖 Generated with Claude Code

"error: tool 'x' denied by the gate" said only that something refused. The
reliable next move from there was to route around it — denied write_file,
reach for a shell heredoc, same effect. The string is the only signal the
model gets, so it has to carry the instruction.

Card decline and never-list hit both land on Decision.NEVER but mean
different things: one is a decision the owner can revisit, the other can
never be lifted, and an agent told the wrong one either nags about a
permanent denial or abandons a retryable ask. dispatch() now remembers which
path denied and picks the matching text.

The card text does not claim the owner typed "no" — Approval.DENY also
covers the 600s timeout, an unparseable answer, and a card refused because
one was already pending.

Wording moves to gate_text.py alongside announce_text (gate.py was 4 lines
from the 200-line cap, and the emitted strings are their own concern).
Both denial texts contradicted the PR's own argument, in opposite directions.

card_denied said "do not retry, do not re-ask" absolutely, which erases the
distinction the change exists to draw: the body argues a card decline is a
decision the owner can revisit, and the string forbade the retry revisiting
needs. The model tells the owner, the owner says go ahead, and the model holds
a standing instruction not to. Scoped to "on your own", with the owner's
go-ahead named as the exception.

never_denied claimed "no approval can lift it", which is false here —
gate.never is config, and self-edit/SKILL.md teaches the model to edit
gate.never/gate.approved and restart as routine. So the single highest-leverage
route around a never-list denial was the one workaround the string did not
name. It now says what is true (only the owner, in config.yaml) and forbids
that route explicitly. Not an enforcement hole (editing config still cards),
but a false absolute is worse than a scoped truth in a string whose whole job
is to be believed.

The perseveration breaker (agent/loop.py) fires on exactly this path and said
"take a different approach" — the workaround both denials forbid, from a second
string the model reads at the same moment. Reworded to respect a refusal while
keeping the advice for the ordinary repeated-failure case.

Lows: SECURITY.md documents the two texts and that they are behavioural, not
enforcement; cron/tools.py dropped the same "the owner declined" overclaim on
the sibling path. Rationale had been restated in four places — module docstring
is now the single home.

Left: the split vs #296 (gate_policy.py) — orthogonal concerns out of the same
overfull file, mechanical rebase whichever lands second.
@CrazyWillBear
CrazyWillBear merged commit 68d9cca into main Aug 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant