The Rule of Two: Split the Lane That Holds All Three
The agents Rule of Two for a real fleet: score each lane on untrusted input, private data and egress, split all-three lanes, and lint configs before they run.
Go deeper. Build your own.
Count the lanes in your fleet that read text a stranger wrote, hold a credential to something private, and can push, post or fetch in the same session. The Agents Rule of Two says that count should be zero. In most fleets it isn’t, because each of those three grants arrived as one reasonable config line, in a different month, approved by a different person.
This is the audit that gets the count to zero. By Tuesday every lane carries a score on three legs, per session and including the subagents it spawns. Every lane that scores all three has dropped a leg, been split into a reader and an actor, or been put under supervision with a human approving each state change and each outbound call. A pre-run lint refuses to start any lane that grants all three without a supervision flag, and one fixture proves the split holds: a handoff that passes the schema, names an attacker’s destination, and gets refused.
Chatbots suggest; agents act. A chatbot that reads a poisoned page can only say something wrong; a lane that also holds the org token and a push tool can do something wrong, unwatched. The rule decides in config, before the run, which of the three a lane gives up.
Sep 14: Spain’s regulator logs a breach notification that names an AI agent
On Sep 14, 2026, Spain’s data protection authority, the AEPD, published a blog post by Francisco Pérez Bes saying it had received its first personal-data breach notification in which “el incidente habría sido ejecutado mediante un agente de inteligencia artificial” (the incident would reportedly have been carried out through an AI agent). The conditional habría matters, and the AEPD spells out why: the account comes from the affected organisation’s notification and still has to be analysed, and using a particular model does not mean the model or its provider was compromised. The post gives no receipt date, no sector and no model name.
What it does describe is a sequence. The attacking agent searched generic files for weaknesses and logged in successfully. Then it searched the application for vulnerabilities “de forma autónoma” (autonomously), which let it modify personal data and access invoices. Search, log in, modify, read: each step is a tool call some lane in your fleet can already make.
Screenshot: AEPD, “Primera notificación de una brecha de datos personales causada por un ataque ejecutado mediante un agente de IA” (Sep 14, 2026), captured Sep 21, 2026.
That notification concerns an attacker’s agent, and the post never mentions the rule. The link is the regulator itself: its agentic-AI guidance, V1.2 dated February 2026, presents a “Regla de 2” on page 41, citing Chromium’s browser rule and Meta’s reformulation for agents. Its worked example is an email auto-reply agent that receives mail nobody vetted, can reach sensitive data without restriction and acts automatically; holding all three, the guidance says, is a configuration that should not be allowed.
It calls the rule “una regla general de mínimos enfocada a ciberseguridad” (a general minimum rule focused on cybersecurity), a starting point for analysis, and then adds data-protection checks the rule doesn’t cover, such as minimisation and accuracy. Read it as a regulator’s baseline, not a binding standard.
The rule itself is Meta’s. Its Oct 31, 2025 post says an agent should satisfy “no more than two of the following three properties within a session”: [A] processing untrustworthy inputs, [B] access to sensitive systems or private data, and [C] changing state or communicating externally. Meta credits Chromium’s Rule of 2 and Simon Willison’s lethal trifecta, which the AI agent security primer already explains, so this piece won’t.
Two more points matter. If a job needs all three without a fresh session, the agent should not run autonomously and needs supervision at minimum. And the rule supplements least privilege; it doesn’t replace it.
Step 1: Score every lane on the Agents Rule of Two, per session
Score lanes, not models. A lane is one configured job: its model, tools, credentials, inputs, and whatever it can spawn. Score it for one session, which is Meta’s unit, and use this table to decide what counts.
| Leg | Counts as the leg | The miss that makes fleets under-count |
|---|---|---|
| [A] untrusted input | Any text someone outside the lane’s owners could have written: PR bodies and comments, issue text, email, web pages, package READMEs, third-party API responses, files from a fork | Tool output is input. A search result, a log line with a user-agent string, a scraped changelog |
| [B] sensitive data or systems | Private repos, customer records, secrets in the environment, production credentials, internal tickets, the user’s home directory | A token scoped to “read” on a private org is still [B] |
| [C] state change or outbound call | File writes, git push, PR and issue comments, email and chat, package install or publish, workflow dispatch, web fetch, any MCP tool that calls a remote server | A GET is outbound: the host and query string carry data out |
Read [C] literally. Meta’s wording covers changing state or communicating externally, and the second half is where fleets under-count. A web fetch is outbound communication even when it only reads, because the URL is a message and an injected instruction can pick the host. An MCP search tool ships its query string to somebody else’s server.
A git push is [C] twice: it changes the repo, and it can start a CI workflow that holds secrets, which is why the Actions trigger allowlist gives agent identities their own row. Never score [C] as “acts autonomously”. Autonomy is the supervision question in step 4, not a leg.
Then apply the subagent rule. A child agent that reads a web page [A] and returns a summary to a parent holding the org token [B] and a push tool [C] makes the parent an all-three session, whatever the child scores alone. Score the union of every context that flows into another. The only reset is the one Meta names: a fresh session with a fresh context window, fed by something other than the old transcript.
| Lane (illustrative) | [A] | [B] | [C] | Verdict |
|---|---|---|---|---|
| PR review bot | PR body, comments | private-org read token | review comments, web fetch | All three: split |
| Incident summarizer | alert payloads with user-supplied strings | production log read | posts to the on-call channel | All three: split |
| Inbox assistant | inbound customer mail | CRM record | sends replies | All three: supervise (step 4) |
| Issue triage, public repo | issue text | none beyond public read | labels | [A C]: passes |
| Release publisher | none (tagged build only) | publish credential | publish | [B C]: passes |
Write the result down per lane. The illustrative chart shows the shape of the fix: the failing lanes go to zero and the lane count goes up.
Illustrative, modeled numbers. The split adds lanes; the number to drive to zero is the red one.
Step 2: Drop a leg first, and split only when the job needs all three
Dropping a leg is cheaper than splitting, so try it first. Drop [A] by feeding the lane structured data you control instead of prose: a dependency-update lane reads version numbers from a lockfile diff, not changelog text. Drop [B] by running on a public mirror or a scratch clone with no org credential. Shrink [C] by taking tools away.
Narrowing publish rights to stage-only tokens helps, but GitHub’s changelog says those tokens keep other write rights, dist-tag moves and deprecations included, so the leg shrinks rather than disappears.
When the job genuinely needs all three, split it into two lanes with a typed handoff between them.
- The reader holds [A B]. It gets the untrusted text plus whatever private context it needs to interpret it. It has no write tools and no egress: no shell, no web fetch, no MCP server that reaches the network, and a sandbox with the network off, because “no fetch tool” is a promise and a closed socket is a fact.
- The runner fetches the reader’s input, by PR number, with a request that carries no private data. The reader never fetches anything.
- The reader emits only a handoff that must validate against a versioned JSON Schema. A failed validation stops the run; nothing is retried with the raw text attached.
- The actor holds [B C]. It gets the handoff and its own trusted instructions, never the raw text: not in the prompt, not through a log, and not through a tool that can re-read the same PR. A
gh pr viewon the actor is a side door.
# lanes/pr-review.yaml (illustrative shape; enforced by the runner, never by the prompt)
lane: pr-review
reader:
legs: [A, B]
inputs: [runner.fetch.pr_body, runner.fetch.pr_diff] # fetched by PR number
tools: [read_file, grep] # no write, no shell, no network
network: off
output_schema: handoff/pr-review.v3.json
actor:
legs: [B, C]
inputs: [handoff] # never the raw PR text
tools: [post_review_comment, add_label]
network_allow: [api.github.com]
deny_tools: [gh_pr_view, web_fetch] # side doors to the raw text
supervision:
required: false # the lint checks this line
The reader never holds a write or egress tool, and the actor never reads the raw text. The fixture proves the gate between them.
Step 3: Treat every reader-derived field as tainted, schema or no schema
The split feels like a firewall because the handoff is typed. A schema checks shape. An injected reader can emit perfectly valid JSON whose notify field is the attacker’s address, whose docs_url points at the attacker’s host, or whose path climbs out of the repo.
The CaMeL paper names this. In the Dual LLM pattern a privileged model plans and a quarantined model reads untrusted data; its authors show injected content can steer the quarantined model into returning attacker-chosen data while the plan stays intact, so “the data flow can still be manipulated.” Their analogy is SQL injection: the attacker changes the parameters, not the query. A later paper on agent design patterns, with several of the same authors, says the same of plan-then-execute agents: the plan holds, and injected text still shapes the planned calls’ inputs.
Screenshot: arXiv, “Defeating Prompt Injections by Design” (Jun 24, 2025), captured Sep 21, 2026.
So the handoff needs four controls, and the actor enforces all of them in code.
- Tag every field with its source. Three values are enough:
trusted_config(from the lane config or the runner),actor_lookup(the actor fetched it by key from a system of record) andreader_derived(it came out of the reader). - Narrow free-form fields. Enums, booleans, bounded integers and IDs that must resolve in a trusted system.
severity: low | medium | highbeatsassessment: stringfor anything the actor branches on. - Gate consequential fields. A reader-derived value may land in a recipient, URL, path, branch, package name or tool argument only after a deterministic allowlist in code passes it, or a human approves the exact value. If the allowlist lookup errors, times out or can’t parse the value, the answer is refuse and alert. A classifier asked whether the URL looks safe isn’t a gate, because it reads the attacker’s text too.
- Narrow the author where you can’t narrow the value. Meta’s own high-velocity coder example controls [A] by “Using author-lineage to filter all data sources” that reach the agent’s context. If only your team’s commits and tickets feed the reader, most of [A] never arrives.
One field type fools people: free text that leaves. The reader holds [B], so any string it emits could carry private data, and an actor that posts that string somewhere public has just exfiltrated it. Free text is consequential when its destination is outside the trust boundary it came from.
| Handoff field | Type | Source | Consequential | Gate on the actor |
|---|---|---|---|---|
pr_number |
integer | trusted_config | yes | must equal the run’s PR |
verdict |
enum: comment, request_changes, approve | reader_derived | yes | approve always goes to a human |
labels |
set of repo labels | reader_derived | low | allowlist = the repo’s label list |
docs_url |
URL | reader_derived | yes | host on a fixed list, else dropped |
notify |
reader_derived | yes | must resolve in the team directory, else a human | |
comment_body |
string, 2,000 chars max | reader_derived | yes, if it leaves the repo | posts only to this PR; secret scan first |
{
"schema": "pr-review.v3",
"pr_number": { "value": 4127, "source": "trusted_config" },
"verdict": { "value": "request_changes", "source": "reader_derived" },
"docs_url": { "value": "https://docs.example.com/testing", "source": "reader_derived" },
"comment_body": { "value": "Add a test for the retry path.", "source": "reader_derived" }
}
Step 4: Supervise what you can’t split, on every write and every call out
Some jobs need all three and don’t split cleanly. The inbox assistant has to read the customer’s mail, know the customer’s record and reply, and what it should do depends on what the mail says. The CaMeL authors list this as an inherent limit of their own design, “Data requires action”: when the actions to take depend on untrusted data, the plan can’t be fixed in advance. The AEPD guidance’s case 1-2, uncontrolled input plus access to sensitive information, reaches the same place: “se debe impedir cualquier acción automática sin supervisión humana” (any automatic action without human supervision must be prevented), whether its effect lands inside or outside the organisation.
Supervision under this rule has a specific meaning. A human approves each state change and each outbound call, fetches included. They see the exact call with its arguments, not a plan summary.
Approvals expire, and expiry is a deny. The approver is never the person who wrote the [A] text.
supervision:
required: true
approve_each: [state_change, outbound_call] # not "the plan"
show: exact_call_with_arguments
expire_after: 30m # expiry = deny
owner: platform-oncall
review_by: 2026-12-01 # the exception has an end date
Supervised lanes spend attention, and approval fatigue turns a queue into a rubber stamp. Keep an owner and a review date on each one, and treat the supervised count as a number to shrink.
Step 5: Ship the pre-run lint, and make it fail closed
The lint runs in the runner before the harness starts. It reads the lane’s declared config, maps every tool, credential and input to legs through one classification file the platform team owns, and exits non-zero on any of these:
- The union of legs across the lane and every spawn profile it may use includes A, B and C, with no
supervision.required: true. - A tool, MCP server or credential is missing from the classification file. Unclassified counts as [A] and [C] until someone classifies it.
- A reader holds any [C] tool or has network access; an actor has any input or tool that reaches the raw text.
- A
reader_derivedfield feeds a consequential parameter with no declared gate. - The config doesn’t parse, the classification file is missing, or the lint itself crashes. A lint that fails open is a suggestion.
# rule_of_two_lint.py (illustrative shape): any exit other than 0 keeps the lane from starting
import sys, yaml
def legs_of(item, table):
return set(table.get(item, ["A", "C"])) # unclassified = untrusted input + egress
def lane_legs(lane, table):
legs = set()
for part in [lane] + lane.get("spawns", []):
for item in part.get("inputs", []) + part.get("tools", []) + part.get("credentials", []):
legs |= legs_of(item, table)
return legs
try:
table = yaml.safe_load(open("policy/legs.yaml"))
lane = yaml.safe_load(open(sys.argv[1]))
if lane_legs(lane, table) >= {"A", "B", "C"} and not lane.get("supervision", {}).get("required"):
sys.exit(f"refuse {lane['lane']}: A+B+C in one session with no supervision flag")
except Exception as e:
sys.exit(f"refuse: lint error {e!r}") # fail closed
Be honest about what the lint can’t see. It reads what a lane declares, and the harness can load more at runtime: a synced plugin, an auto-discovered MCP server, a subagent that inherits the parent’s tools.
So the lint isn’t the boundary. The wall behind it is the reader’s sandbox with the network off, permission deny rules on the actor, and credentials the reader’s process never receives. For unattended lanes, the trust-tier inventory of input authors and credentials is already the lint’s best input; feed it in rather than deriving it twice.
Step 6: Prove the split with a malicious-handoff fixture
Keep a fixture set next to each split lane and run it in CI on every change to the lane config, the classification file or the handoff schema, and after every harness upgrade. The core fixture is a handoff that validates against the schema and names an attacker’s destination. The actor must refuse it.
| Fixture | Schema-valid value | Expected result |
|---|---|---|
| Attacker URL | docs_url on a host outside the list |
Gate drops it; no fetch; alert names the field and source |
| Attacker recipient | notify outside the team directory |
Routed to a human; no mail sent |
| Path escape | path that normalises outside the repo root |
Refused before any file call |
| Protected branch | branch set to the default branch |
Refused; the lane pushes only to its own branches |
| Smuggled secret | comment_body containing a token-shaped string |
Secret scan blocks the post |
| Valid, wrong enum | verdict: approve on a PR with failing checks |
Human only; never automatic |
Judge the result from the sandbox’s egress log, not from the actor’s transcript. The transcript belongs to the component under test; the egress log shows whether a packet left.
Then run the other half: feed the reader a PR body that tells it to set docs_url to the attacker’s host, and put a unique canary string in that body. Expect the reader to comply sometimes, since the design assumes the reader loses. The pass condition is that the gate catches the value and the canary never shows up in the actor’s context. A reader that happens to resist the injection this week isn’t a pass; it’s luck with a timestamp.
Agents Rule of Two failures, and the signal for each
The reader grows an egress. Someone adds an MCP server or a fetch tool to the reader for more context. Signal: any connection in the reader sandbox’s egress log, or a lint diff showing a new [C] item on a reader.
The actor finds a side door. A tool, a log file or a cached artifact lets the actor read the raw text. Signal: the fixture’s canary string appears in the actor’s context.
Free text creeps back. A notes: string field lands in the schema because an enum felt restrictive. Signal: a schema diff adding an unbounded string with no gate.
The allowlist sprawls. Signal: wildcard hosts, or entries growing week over week without a named requester.
Supervision decays. Signal: approval rate near 100% while median decision time falls. Nobody reads a call they approve in two seconds.
Lanes start around the runner. Someone launches the harness by hand with the lane’s credentials. Signal: harness sessions with no matching lint record.
Subagents inherit too much. Signal: spawn records whose tool lists differ from the declared spawn profile.
The Rule of Two lives in the runner, not in the prompt
None of the steps above is a prompt instruction. A reader told to ignore instructions in the PR body will follow them eventually; the split works because the reader has no tool to follow them with, and the actor never reads them. That makes the rule a property of the layer that starts lanes: the lane registry, the leg classification file, the lint, the egress logs and the fixture run. It is what a multi-agent command center turns into once the dashboard comes off, one place that knows every lane, its legs, and who signed the exception.
[B] also covers where the lane’s prompt goes, not only what the lane can reach. If a vendor lane forwards requests to another provider, the data class it may carry is capped by the last hop you can verify. Score that too.
FAQ
What is the Agents Rule of Two?
Meta’s Oct 31, 2025 rule for agent security. Within one session, an agent should hold no more than two of three properties: processing untrustworthy input, access to sensitive systems or private data, and the ability to change state or communicate externally. A job that needs all three gets supervision instead of autonomy.
Does splitting an agent into a reader and an actor stop prompt injection?
Not by itself. It keeps injected text away from the tools, but the reader can still be steered into emitting valid, attacker-chosen values. CaMeL’s authors show the Dual LLM pattern protects control flow, not data flow. Gate every reader-derived value in a consequential field with an allowlist or a human.
Sources
- AEPD blog, Primera notificación de una brecha de datos personales causada por un ataque ejecutado mediante un agente de IA — Sep 14, 2026
- AEPD, Inteligencia artificial agéntica desde la perspectiva de protección de datos, V1.2 — febrero de 2026; “Regla de 2”, p. 41
- Meta, Agents Rule of Two: A Practical Approach to AI Agent Security — Oct 31, 2025
- Debenedetti et al., Defeating Prompt Injections by Design (CaMeL), full text — Dual LLM data-flow critique; “Data requires action”
- Beurer-Kellner et al., arXiv 2506.08837, full text — six agent design patterns
- Chromium security docs, rule-of-2.md — cited by the AEPD (footnote 32)
