Book a 30-min call
cd ../blogs
$ cat posts/read-everything-write-through-a-gate.mdx

Read everything, write through a gate: permissions for operational agents

May 21, 2026 · ImmovableTech Team

  • Agentic AI
  • Security
  • Production AI

The asymmetry is the whole argument

An agent with broad read access to production has a bounded worst case. It can be wrong, expensively wrong, confidently and fluently wrong — and the damage stops at a human reading a bad answer. The same agent holding write credentials has an unbounded worst case, because one bad inference now reaches the thing customers are using. That is not a difference of degree. It is the difference between a failure you recover from by arguing and a failure you recover from by restoring.

So the split we ship on operational agents is: read broadly and autonomously, write only through a gate. Logs, metrics, traces, dashboards, Kubernetes object state, Terraform, application code, runbooks and past incident timelines are all available without asking. Anything that changes state goes through a pull request that a human merges.

The general shape of this is not a novel position. OWASP’s LLM06 entry, Excessive Agency, names three root causes — excessive functionality, excessive permissions and excessive autonomy — and its sixth mitigation is to require user approval for high-impact actions. Google’s agent security white paper says that for actions deemed critical or irreversible the system should require explicit human confirmation. Both are correct, and both are underspecified in exactly the place that decides whether the design works: they tell you a gate should exist. They do not tell you what makes a gate hold.

An approval prompt is not a gate

The failure mode of human-in-the-loop is not that reviewers refuse to review. It is that they stop.

Our first version put an approve/deny button in Slack on every proposed write. It lasted about a fortnight. By the end of it approvals were coming back in a couple of seconds, which is not enough time to open the linked context, let alone read it. Nobody decided to rubber-stamp. The volume decided for them.

This result predates language models by fifteen years. Parasuraman and Manzey’s review of the automation complacency literature found that complacency shows up specifically under multiple-task load, when the manual task competes with the monitoring task for attention; that it appears in expert participants as readily as in novices; and that it cannot be overcome by simple practice, training or instructions. An on-call engineer holding a live incident is the canonical multiple-task load. Handing them a stream of confirmations is not oversight, it is a second job they will shed under pressure — and shedding it looks exactly like clicking approve.

That gives the gate two required properties rather than one. It has to be rare, so that each decision still gets attention. And it has to be information-dense, so that the attention it gets is enough. A gate that fires forty times a day fails the first test. A gate that presents “the agent would like to modify the payments service, approve?” fails the second, because there is nothing in it a reviewer could be wrong about.

A pull request is a good gate because you do not have to build one

A pull request satisfies both properties by accident of what it already is, which is the main reason to prefer it over anything purpose-built.

It is diffable, so the decision is about a specific before and after rather than about an intention. It is reviewable, with line comments and CODEOWNERS routing already wired to the people who know the service. It is revertible by a single commit, which changes the risk calculus of a wrong merge from “incident” to “annoyance”. It is auditable without a parallel audit system, because the history is the audit log. And it lands in a workflow every engineer already performs several times a day, so review is not an interruption of the work — it is the shape of the work. You inherit an approval process instead of inventing one, and inventing approval processes is where this normally goes wrong.

GitOps sharpens the argument from convenient to structural. The OpenGitOps principles define a managed system as one whose desired state is declarative, versioned and immutable, pulled automatically by agents in the environment and continuously reconciled against actual state. If a reconciler is continuously applying the repository, then a write that bypasses the repository is not merely risky, it is temporary. The agent that patches a live object is fighting the reconciler and will lose at the next sync. The agent that opens a pull request is speaking the only language in which a change to that system is durable. On a GitOps-managed estate the gate is not a control you bolt on; it is the sole write path that already existed, and giving the agent a second one is the anomaly.

The honest limit is that this only covers state with a declarative representation. A wedged queue consumer, a poisoned cache key, a third party rate-limiting you — none of these has a line in a repository to change. Pull-request gating covers configuration, infrastructure and code. It does not cover operations, and pretending otherwise is how you end up with an agent that is useless during the incidents that matter most.

What the gate costs, and where we carve exceptions

The cost is real remediation speed, and it should be stated plainly rather than argued away. An agent that has already read the logs, correlated the memory ceiling with the deploy that changed it and identified the OOMKill loop could raise the limit and end the incident in under a minute. Under this design it opens a pull request and waits for a human who may be asleep. Nobody should pretend that is free, and the teams who write about agentic incident response as though the merge button costs nothing have not been paged at 03:00.

So we do carve out a narrow pre-authorised set, and the bounding is the entire design. An action qualifies only if all four of these hold. It must be idempotent, so a replay collapses into the original. It must be reversible without data loss, with the reversal itself pre-authorised. It must be bounded to a named resource rather than a selector, because a selector is a promise about a set that can change under you. And it must be enumerable in advance, as a static list rather than a capability class.

That last one does most of the work. Restarting a named deployment qualifies. Scaling one within a declared range qualifies. “Run a kubectl command” does not qualify, and the reason is not that it is dangerous — it is that a set defined as “whatever the model types” cannot be reasoned about at all, so no amount of care in the surrounding design tells you anything about its worst case.

# Pre-authorised actions. If all four properties do not hold, it goes through a PR.
- action: restart_deployment
  scope: { namespace: payments, name: quote-worker } # named resource, not a selector
  reverse: none_required # a restart leaves no state to undo
  budget: { count: 2, per: 1h } # exhausted -> falls back to the PR path

- action: scale_deployment
  scope: { namespace: payments, name: quote-worker }
  range: { min: 3, max: 12 } # a declared range, not an arbitrary integer
  reverse: scale_deployment # the undo is itself pre-authorised
  budget: { count: 4, per: 1h }

The budget line is there because of a specific thing we got wrong. We classified a pod restart as idempotent, which is true of the action and false of the situation: under a crashloop, “restart and then observe” looks to the agent like a restart that has not worked yet, so it restarted the same deployment over and over. Nothing broke that was not already broken, but it cost around twenty minutes of an incident and left a timeline that took longer to read than the outage did. Idempotent and safe to repeat are different properties, and the second one is about the loop the action sits in rather than the action. Every pre-authorised action now carries a count budget, and exhausting it is not an error — it demotes the agent back to the pull-request path, which is where it should have been once the obvious fix stopped working.

Pre-authorised actions still produce a written record. Where the change corresponds to declared state, such as a replica count, the agent opens a follow-up pull request that makes it durable, because otherwise the reconciler quietly undoes it and the next engineer inherits a system that disagrees with its own repository.

Read-only is a confidentiality surface, not an integrity one

Granting read access is not the safe half of this design. It is the half whose risks are a different category, and calling it “read-only” hides that.

Logs, traces and ticket systems are where credentials and personal data actually accumulate. GitGuardian’s 2026 secrets report found that 28% of the leaks it detected originate entirely outside code repositories — in Slack, Jira, Confluence and similar tools — and that those out-of-code leaks were more likely to be rated critical than the in-code ones. That is a report about sprawl, and the implication for us is direct: the systems where secrets end up are precisely the systems we just granted an agent unrestricted read access to.

Our own version of this was duller and worse. We scoped the agent’s credentials read-only, considered the confidentiality question closed, and later found the log search tool returning a debug request dump complete with its Authorization header — into the model’s context, and from there into our trace store and the model provider’s logs. A read-only agent had copied a live credential into three systems that had never been assessed for holding one. We now redact at the tool boundary rather than relying on redaction at the log boundary, because we control the tools and we do not control every service that writes a log line.

The second problem with read access is that much of it is attacker-controlled. A user-agent string, a support ticket body, a commit message, an exception carrying user input — anything reaching a log line may have been written by someone outside your trust boundary. This is the class of risk we hit with tool outputs when we put MCP servers into production, except worse, because nobody curates observability data and its whole purpose is to record faithfully whatever arrived.

Beurer-Kellner and colleagues state the design principle this implies better than we can: once an agent has ingested untrusted input, it must be constrained so that it is impossible for that input to trigger any consequential action. Read that as the specification for the gate rather than as advice. The gate is not there because models are unreliable, though they are. It is there because the read path is an untrusted input channel, and the write path is where untrusted input turns into consequences.

Numbers here should be handled carefully. AgentDojo, which evaluates tool-using agents across 97 simulated tasks and 629 security test cases with injections planted in tool outputs, reported that its attacks succeeded against the best-performing agents in under 25% of cases, falling to about 8% with a secondary injection detector deployed. That measures a fixed attack set against a fixed defence set in simulated environments on mid-2024 models; it says nothing about whether your log pipeline is safe. Treat it as a floor — and note that an 8% residual against static attacks is not a rate you would accept on anything that writes.

The gate has to sit between the write and its audience

The uncomfortable case is the one where the pull request is the exfiltration channel. Invariant Labs demonstrated this against the official GitHub MCP server in May 2025: an attacker files an issue on a public repository, the victim asks their agent to triage open issues, and the agent pulls private repository contents into context and leaks them in a pull request it opens autonomously against the public repository. Every leg of that is a read except the last one, and the last one is the gate.

The lesson is not that pull-request gating fails. It is that a gate only gates if the reviewer sits between the write and its audience. A pull request opened against a repository the attacker can already read is not review, it is publication. So agent-authored pull requests target a private repository, and any content that entered the agent’s context from an untrusted read stays untrusted for the rest of its path — including when it appears in a diff, a branch name or a PR description. The wider agent stack offers plenty of places to get this wrong; the one that matters is wherever the artefact first becomes visible to someone who is not the reviewer.

What we’d do differently

We would build the pre-authorised action set at the start instead of defending purity for two quarters. We spent that time insisting every write go through review, including during incidents where a 40-second cordon would have ended the whole thing, and the cost of that stance was paid by the on-call rota rather than by the people holding the principle. The principle survived contact with production. The absolutism did not.

We would design the diff for a reviewer rather than for a merge queue. Our early agent pull requests were correct and unreviewable: the right two-line change, with no evidence attached, which left the reviewer either re-running the whole investigation or trusting the agent. Both are bad, and the second is what actually happens. Agent pull requests now carry the query that found the problem, the window it covers and what the agent expects to change after merge. Review went from reading a diff sceptically to checking whether the evidence supports it, which is a decision a tired human can make correctly — and that, rather than the merge button, is what makes the gate high-information.

The lesson underneath all of this is that the read plane is where the confidentiality work lives and the write plane is where the integrity work lives. A design that takes only one of them seriously is half a design.

References


We design permission models for operational agents as part of our AI & Machine Learning Engineering practice. Talk to us if you are giving an agent access to production and want the write path bounded before it ships.