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.

Agents Rule of Two: one lane holding untrusted input, private data and egress is refused by the lint, then split into a reader holding A and B and an actor holding B and C, joined by a typed handoff
Any two legs, never three. The split puts a typed handoff between the text and the tools.

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.

AEPD blog post dated 14 de Septiembre de 2026 by Francisco Pérez Bes, headed Primera notificación de una brecha de datos personales causada por un ataque ejecutado mediante un agente de IA, with the opening sentence using the conditional habría sido ejecutado 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 chart of an agents Rule of Two fleet audit: before the split 14 lanes, 4 of them holding all three legs unsupervised; after the split 18 lanes, none unsupervised with all three 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 view on 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

Diagram of the agents Rule of Two reader and actor split: input fetched by the runner goes to a reader with no write or egress tools, a schema-checked handoff with every field tagged by source, a gate that refuses reader values in consequential fields without an allowlist or a human, and an actor that makes the call; a red fixture injects an attacker URL and is refused 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.

CaMeL paper section asking whether the Dual LLM pattern is enough, with the passage explaining that the control flow is protected but the data flow can still be manipulated, compared to SQL injection 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.

  1. 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) and reader_derived (it came out of the reader).
  2. Narrow free-form fields. Enums, booleans, bounded integers and IDs that must resolve in a trusted system. severity: low | medium | high beats assessment: string for anything the actor branches on.
  3. 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.
  4. 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 email 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_derived field 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