AI-Driven Development

The most useful thing my agent does is refuse

Refusals are only useful if they name the remedy. Four that fired during one session, what each redirected me to, and the one that fired on nothing at all.

MobileByteSensei 9 min read
A blocked path with the permitted one marked beside it
aiagentsguardrails

A guard that blocks an action and stops there has done half a job, and the missing half is the half that decides whether anyone keeps using it.

The refusals that work in practice all share one property: they name what to do instead.

The case this post is about

One refusal, fired on me mid-task, quoted whole:

BLOCKED — the file would be ~205 lines (limit 200)

  1. Move NON-EXECUTABLE content to the reference doc
  2. Split cohesive blocks into a sub-module, moved VERBATIM
  3. The runtime keeps ONLY: step headings, '> Load', table rows, commands, halts

NEVER drop a step heading, a '> Load', a table row, a command, or a halt —
relocate, don't delete.

I was five lines over a limit on a file I was editing. That message is the case this post follows, because every property that makes a refusal work is visible in it — and because the version of it that would have taught me to route around the guard is one sentence shorter.

The symptom

A bare refusal creates a puzzle. The agent wanted to do something, cannot, and now has to guess at an alternative — which it will, immediately, and the guess is the problem.

The guesses are locally reasonable and globally wrong. Blocked from one command, reach for a different command that does the same thing. Blocked from writing one path, write to a neighbouring path. Blocked at the tool layer, do it in a shell one-liner the tool layer cannot see.

None of that is defiance. It is an agent doing what it is for — finding a way — applied to an obstacle it has no explanation for. And each successful route-around teaches that guards are navigable, which is exactly the lesson you cannot afford it to learn.

Why the obvious fix failed

The obvious fix is a stricter guard: block the alternatives too.

That is an arms race you lose on arithmetic. A previous version of a secrets rule listed three commands nobody should use on a credential file. The list of ways to read a file is not a list anyone finishes, and the next command tried — cut, to print field names — was not on it and printed a secret.

The second attempt is a stern message. This is forbidden. Do not attempt to work around it. It adds no information, so the agent is still holding an obstacle with no path, and the sternness does not supply one.

The actual mechanism

Make the refusal carry the remedy, specifically enough to act on.

Here is one that fired on me during this session, trimmed:

BLOCKED — the file would be ~205 lines (limit 200)

  1. Move NON-EXECUTABLE content to the reference doc
  2. Split cohesive blocks into a sub-module, moved VERBATIM
  3. The runtime keeps ONLY: step headings, '> Load', table rows, commands, halts

NEVER drop a step heading, a '> Load', a table row, a command, or a halt —
relocate, don't delete.

What a refusal contains when it works

Four things in there, and each is load-bearing:

The measurement. "~205 lines (limit 200)" — not "too long". I know exactly how far over I am, so I know whether this is a trim or a restructure.

Numbered remedies, in preference order. Three specific moves, not a principle to derive one from.

The forbidden shortcut, named. "Relocate, don't delete" closes the cheapest route-around before it is attempted. Without it, the fastest way past a line limit is deletion, and a limit that encourages deleting halts is worse than no limit.

Nothing about intent. It does not ask why I wanted to write 205 lines. It tells me what to do with them.

The fix

The rule I would extract: a refusal is an instruction, not a verdict.

Every guard in this project that I have not resented ends with the sanctioned path spelled out. The shell-tool guard on the specification layer refuses sed and then lists what to use instead — Read, then Edit; Read, then Write; the Grep tool for search. The secrets guard refuses a read and names the intake command. The git guard refuses an improvised branch operation and names the session command that does it properly.

The shape, as the only refusal helper anything in the project is allowed to call — which is how "name the path" stops being optional:

refuse() {                       # refuse <rule> <what> <then...>
  local rule="$1" what="$2"; shift 2
  {
    echo "refusing: $what"
    echo "  rule: $rule"
    for line in "$@"; do echo "  -> $line"; done
    echo "  if this is genuinely required, a HUMAN grants it in a real shell."
  } >&2
  exit 2
}
# every guard calls it the same way, so every refusal has the same shape
refuse "RULE-SPEC-001" "shell mutation of the specification layer" \
  "mutate a file:  Read it, then Edit" \
  "rewrite a file: Read it, then Write" \
  "search:         the Grep tool, or read-only grep"

What the caller actually sees:

$ stream-edit -i 's/foo/bar/' spec/profile.yaml
refusing: shell mutation of the specification layer
  rule: RULE-SPEC-001
  -> mutate a file:  Read it, then Edit
  -> rewrite a file: Read it, then Write
  -> search:         the Grep tool, or read-only grep
  if this is genuinely required, a HUMAN grants it in a real shell.

Forcing every guard through one helper is what makes this structural rather than a convention: you cannot write a refusal without supplying at least one -> line, because the helper's signature requires it. A guard author who wants to refuse something has to say what to do instead, or the code does not compile in the only sense shell has.

In every one of those cases, following the named path produced a better result than the thing I originally reached for — which is the real argument. The guard was not a tax on a correct approach; it was a redirect from a worse one.

That is the claim I would defend hardest, because it is the one that decides whether guards are worth having at all. If a refusal is a tax — if following the named path is reliably worse than what you wanted to do — then the guard is an obstacle and people will route around it, correctly. The reason these have held is that the named path has been better nearly every time, and in the cases where it was merely equivalent it was at least no worse.

There is a design consequence in that. When you write a guard, the sanctioned alternative is not a formality to put in the message — it is the thing being proposed, and the refusal is only as good as it is. A guard whose alternative is "do it a slower way for safety reasons" is asking for discipline. A guard whose alternative is genuinely the better move is asking for nothing.

The helper enforces the shape and cannot enforce the quality. Nothing stops somebody passing a weak -> line, and the only check on that is whether the people hitting the refusal come back annoyed. That feedback is slow and imprecise and it is the only one available, which is worth being honest about rather than pretending the mechanism is self-correcting.

The last thing worth saying is about audience. A refusal is read by whoever hit it, which in this project is usually an agent and occasionally a person, and the two want slightly different things. An agent needs the alternative stated as an executable move. A person needs enough context to judge whether the guard is right at all. The messages that work for both do the first and leave a rule identifier for the second — so the next step is unambiguous and the reasoning is one lookup away rather than inlined into every refusal.

I did not expect that to be the hard part. Writing a guard is easy; writing the sentence that tells somebody what to do instead turns out to require actually knowing the better path, and in two cases I found I did not — the guard was correct about the thing being wrong and I had no good answer for what to do about it. Those two are still unwritten, which is the right outcome: a refusal with no remedy is the bare block this whole post argues against, and shipping one because the guard half was ready would have been the worse trade.

How we proved it

Four refusals fired during this session's ordinary work, and all four improved the outcome:

line-limit guard     → content relocated to a sub-module, nothing lost
shell-tool guard     → head/tail splice instead of an in-place regex edit
write-path guard     → edit made through the tool the other guards can see
daemon guard         → warm comparison instead of a 15-30 min cold rebuild

Four refusals and what each redirected to

The second one is worth expanding because the redirect was non-obvious. I reached for an in-place regex edit on a file; the guard refused it; the named alternative was a head/tail splice with a heredoc. That is more typing and it is also inspectable — I could see the exact boundaries of the replacement rather than trusting a pattern to match what I thought it matched.

The fourth is the clearest value-per-word: a guard that refuses to kill a build daemon, with the cost of doing so stated. Without the number in the message I would have killed it, waited out a cold rebuild, and never known that was avoidable.

The refusal that was wrong

One of those four guards fired on nothing.

The shell-tool guard matches on the command string, so it refused an edit to a temporary file because the replacement text I was inserting happened to mention a protected path. Nothing in that command could have touched anything real.

That is worth stating plainly, because it is the cost side of the ledger. A false refusal is not free: it spends the trust that makes the true refusals work. Enough of them and "the guard is probably wrong" becomes the default reading, at which point the mechanism is decorative.

The two ways a guard can be wrong, and what each costs

I narrowed it — behind nine cases written first, each reproducing a shape the guard must never miss — and it is the first narrowing that file has had against seven previous widenings. The asymmetry is real: a false block costs one extra command and announces itself immediately, while a false pass corrupts a source of truth silently. That ratio justifies over-blocking right up until the point where people stop believing the blocks.

What this changes about writing guards

The practical consequence is that I now spend more effort on the message than on the matcher.

The matcher is usually a few lines and its failure modes are visible in testing. The message is what determines whether the guard produces a better action or a creative one, and it is the part most likely to be written as an afterthought — a banner, a rule citation, a link to documentation nobody opens mid-task.

The test I apply: could someone who has never seen this rule comply with it from the message alone? If the answer is no, the guard will be routed around, and the routing will look like initiative.

What this generalizes to

When you block an agent, spend the words. Say what was blocked, say the measurement that triggered it, list the sanctioned alternatives in preference order, and close the cheapest wrong route explicitly by name.

Then watch your false-positive rate as carefully as your coverage. A guard that is right and unhelpful gets worked around; a guard that is helpful and occasionally wrong gets believed a little less each time. Both failure modes end with the same result, which is a rule that exists on paper and not in the work.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS