Bounded Autonomy: How to Let a Coding Agent Push Fixes Without Letting It Ship

12 min read
AI AgentsClaude CodeGuardrailsCode ReviewAI-First SDLC

For about a month I was the slowest part of my own pipeline. A coding agent would read a review comment, write a correct two-line fix, run the tests, and then stop and ask me: "Push?" I'd be in a meeting or asleep or deep in something else. When I finally looked, the answer was yes. It was almost always yes. I had built a confirmation gate that only ever approved, and all it did was turn a five-minute loop into a five-hour one.

The obvious fix, giving the agent full permissions, is the one that keeps people up at night, and they're right to worry about it. What I settled on sits between the two: AI coding agent guardrails that let an agent push fixes to an open pull request on its own, inside limits a person sets, with a deterministic check that can say no before anything leaves the machine. The agent pushes fixes. It never ships.

Why "Ask Before Every Push" and "Full Auto" Both Fail

Most teams I talk to sit at one of two extremes.

Ask before everything. Every push, every commit and every file write waits for a person. It feels safe. In practice the human gate decays fast. After the twentieth "yes" in a row, nobody reads the diff before approving. You end up with all the latency of human review and none of the attention. A gate that only ever says yes isn't a control. It's a delay with a checkbox on it.

Full auto. The agent runs with broad permissions and is trusted to behave. It's fast, and it works fine until the day the agent "fixes" a failing check by editing the CI config, or force-pushes over a teammate's commit, or decides a migration needs a small tweak. OWASP's Top 10 for LLM Applications calls this excessive agency: too much functionality, too many permissions, too much autonomy for what the task needs.

Both extremes fail for the same reason. They treat autonomy as one global setting, when it really depends on the action. Pushing a lint fix to a feature branch and merging to main aren't the same kind of risk, and they shouldn't share a policy.

The Autonomy Ladder

I sort agent actions onto five rungs, ordered by how visible and how reversible they are. The question for each rung isn't "do I trust the model?" It's "who has to approve this, and how bad is it if it's wrong?"

Loading diagram...
RungExampleWho approvesWhere most teams should be
1. SuggestDraft a fix as a commentNobody, it's adviceAlways on
2. Act locallyEdit, run tests, commit locallySandbox rulesAlways on, sandboxed
3. Act visibly, reversiblePush a fix to an open PRPerson arms it; guard checks every pushThe rung this post is about
4. Act visibly, socialReply to a reviewer, approve a PRHumanHuman only
5. Act irreversiblyMerge, deploy, force-pushHuman, plus branch protectionHuman only, always

Rung 3 is the interesting one. A push to a feature branch is visible, since reviewers and CI see it, but it's reversible: a bad commit can be reverted, and nothing reaches users until a human merges. That's the sweet spot for bounded autonomy.

Rung 4 stays human for a reason that has nothing to do with capability. Replying to a reviewer or approving a PR is a social act. When an agent answers a teammate's comment on its own, the teammate reasonably thinks a person read it. I don't want to break that assumption.

Arming, Not Defaulting

The single most useful decision I made was this: autonomy is off by default, and a person turns it on explicitly, per pull request and per session.

That means no global "auto mode" flag in a config file that someone set six months ago and forgot about. A human looks at a specific PR and says, in effect, "for this one, while I'm away, you may push fixes, up to N rounds." The arming is a small record the guard can read:

# illustrative shape only
autonomy:
  armed_by: "a-person"
  pull_request: 123
  scope: review-fixes
  max_cycles: 3
  expires: end-of-session

Three things matter:

  • A named person. Someone owns the decision. If something goes sideways, there's a human who chose to delegate, not a default nobody remembers choosing.
  • A cycle budget. One cycle is: read new findings, fix, verify, push. Capping cycles stops the agent from ping-ponging with an automated reviewer forever. If the fixes aren't converging after a few rounds, more rounds won't help. That's a signal for a human, not for another attempt.
  • An expiry. Autonomy dies with the session. A new session starts disarmed, even on the same PR. That's slightly annoying, and that's deliberate.

Arming costs a few seconds and replaces dozens of "push?" prompts with one deliberate decision.

Two Allowed Actions, Everything Else Human

Once armed, the agent gets a deliberately short list. I've converged on exactly two actions:

  1. Push a fast-forward fix commit to the PR's own branch.
  2. Resolve the review thread that commit fixed, and only that thread.

Everything else stays with a person. Here's the decision table I keep in my agent instructions:

ActionAgent (when armed)Human onlyNever automatedWhy, in one line
Push fast-forward fix to PR branch✓Reversible, reviewed before merge
Resolve the thread the fix addressed✓Keeps the review state honest
Reply to a reviewer comment✓Social act; reader assumes a person
Approve or request changes✓The agent can't review its own work
Re-request review✓Someone should decide it's ready
Merge✓Irreversible from users' point of view
Force-push or rewrite history✓Destroys other people's work
Push to a protected or default branch✓That's what branch protection is for
Edit CI, lockfiles or repo config✓Changes the rules the fix is judged by
Touch migrations or public contracts✓Hard to undo once anything consumes them

The "resolve the thread" permission is narrower than it looks. The agent may resolve a thread only when its own commit addressed that specific finding. It can't resolve threads it disagrees with, and it can't resolve a human reviewer's open question. If the agent thinks a finding is wrong, it says so in its hand-back report, and a person decides.

This lines up with what I described in Separate Talking From Acting: the model can propose anything, but the set of actions that actually execute is small, explicit and enforced outside the model.

The Deterministic Guard

Instructions in a prompt are a request. A guard is a refusal. Before every push, a plain script (not the model) checks a list of conditions. If any of them fails, the push doesn't happen. The model can't argue with it, because the model isn't consulted.

In Claude Code, the natural place for this is a hook that runs before the relevant tool call and can block it. Other agents have equivalents, and if yours doesn't, a git pre-push hook plus server-side branch protection gets you most of the way.

Here are the refusal rules in plain English:

  • Not armed, or armed for a different PR. No arming record for this exact PR and session means no push.
  • Cycle budget spent. The counter hit the cap.
  • Protected or default branch. Never, regardless of arming.
  • Fork or foreign remote. The branch must live in the repo the person armed, not a contributor's fork.
  • The head moved. If the remote branch has commits the agent hasn't seen (a teammate pushed), refuse. Only fast-forward pushes from a known head are allowed.
  • Out-of-scope files. The diff touches files outside what the PR already changes, or outside the scope the arming allows.
  • Sensitive paths. CI workflows, lockfiles, dependency manifests, repo settings, ownership files.
  • Migrations and contracts. Schema migrations, API schemas, public types other code depends on.
  • Size cap. A review fix should be small. A diff above the cap is a rewrite, and rewrites get a human.
  • Regressed findings. If a finding that was resolved earlier reappears, the agent is going in circles.
  • Failing local gates. Tests, type checks or lint fail on the fix commit.

A few of these deserve a comment.

The moved head check is the one I'd add first. Without it, the agent can push a fix built on a stale branch, and the result is anything from a confusing merge to someone's work getting lost.

The sensitive paths rule exists because agents are very good at making checks pass. If the fastest way to turn CI green is to loosen the CI config, an agent under pressure will find it. The guard takes that option away. CODEOWNERS is a good source for the list: anything with a named owner probably shouldn't change without that owner looking.

The regressed findings rule catches ping-pong: fix A breaks B, fix B breaks A, and every cycle looks like progress.

Here's the full flow of one review-fix cycle:

Loading diagram...

Fail Back to a Human, Not to Silence

The worst way for an autonomous loop to fail is quietly: the guard refuses, the agent stops, and nobody finds out until the next morning.

So every stop produces a short report a person can act on in under a minute:

  • What happened. "Stopped after cycle 2 of 3."
  • Why. "The guard refused: the fix touched a lockfile." Or: "Finding about error handling came back after being resolved in cycle 1."
  • What's done. The commits pushed and the threads resolved, with links.
  • What's left. The findings still open, and for each one, whether the agent thinks it's valid, wrong, or needs a decision.
  • What it would do next, if a person wants to continue.

That last item is where the agent gets to disagree. If it thinks a reviewer's finding is wrong, the hand-back is where it says so, to the person who armed it, not in a reply to the reviewer.

Besides the guard's refusals, the loop also stops when the budget runs out or when the agent can't classify a finding with confidence. "I'm not sure" is a valid reason to stop.

Sandboxing Underneath the Guard

The guard protects the moment something becomes visible. Sandboxing protects everything before that. The two work together, and neither replaces the other.

Agent shells should run with:

  • Filesystem deny rules. No reading secrets files, credential stores or SSH keys. No writing outside the working tree. Claude Code's permission rules let you deny specific paths and commands, and its sandboxing restricts filesystem and network access at the OS level.
  • A network egress allow-list. The package registry, the git host and the APIs the tests need. Nothing else. An agent that can reach arbitrary hosts can be talked into sending your code somewhere, and prompt injection through a review comment or a fetched page is a real path for that. I cover how to test for it in Red-Teaming Your LLM Agent.
  • A short list of trusted commands that run without prompting (test runner, type checker, linter), with everything else either prompting or denied.

Each layer assumes the one above it might be wrong: sandbox, then guard, then branch protection, then a human who merges.

Write the Decision Down

The day you let an agent push on its own is a policy change for the repo, and it should be recorded like one. I write it as an architecture decision record: what the agent may do, what it may never do, the guard's rules, the cycle budget, who can arm it, and the conditions under which we'd revoke it.

The next teammate, or the next agent session, can then find out why the agent is allowed to push instead of discovering it by surprise. I go deeper on this habit in ADRs and Session Handoffs.

The ADR also connects to the rest of your approval model. Bounded autonomy covers the fix loop inside a PR. It doesn't change who signs off on the PR itself, which is a separate question I cover in Risk-Tiered Approval in an AI-First SDLC.

Checklist: Rolling Bounded Autonomy Out to Your Own Repo

If I were starting from zero, this is the order I'd go in:

  1. Turn on branch protection first. Require PRs and reviews on the default branch, block force-pushes. This is your backstop if everything else fails.
  2. Sandbox the agent's shell. Filesystem deny rules for secrets, an egress allow-list, a short list of trusted commands.
  3. Write the decision table. Copy the one above and adjust it. Put it in your agent instruction file and in an ADR.
  4. Build the guard as a script, not a prompt. Start with three rules: armed for this PR, not a protected branch, head hasn't moved. Add sensitive paths and the size cap next.
  5. Add arming with a budget. Per PR, per session, a named person, a cycle cap. Start the cap low.
  6. Make every stop produce a report. Test it by triggering each refusal on purpose and reading what comes out.
  7. Run it on low-risk PRs for a while. Docs, tests, small refactors. Read every autonomous commit after the fact.
  8. Review the refusals, not just the successes. If the guard never refuses, it's either too loose or nobody is arming it. If it refuses constantly on one rule, that rule may be too broad.
  9. Only then consider the next rung, and record that decision too.

If you want a public, simplified example of an agent loop with explicit stop conditions to experiment on, my spec-dev-ralph repo is a small autonomous-loop project you can read and adapt.

Trade-offs Worth Being Honest About

This isn't free. People will forget to arm. The guard will have false positives, especially the size cap and scope check, and each one costs an interruption. Keeping the sensitive-paths list current is ongoing work. And a review-fix loop is only as good as the findings feeding it. If your automated reviewer is noisy, the agent will diligently fix noise. How to layer reviewers so they don't step on each other is its own topic, which I cover in Layered AI Code Review.

I still think it's the right trade: the interruptions you keep come with a reason.

Conclusion

The question has shifted. Agents can already watch a PR, read the review, fix the findings and push. Capability isn't the bottleneck anymore. Governance is. Bounded autonomy is my answer to that: autonomy a person arms explicitly, a budget that runs out, two allowed actions, a deterministic guard that refuses before anything becomes visible, and a clean hand-back when it stops.

Let the agent push fixes. Keep merging, replying and approving with people. Write down where the line is, and enforce it in code.