Inline Sign in

← ARTICLES RESEARCH 5 min read

Delegating less than you hold

Four manager-to-sub-agent scenarios run against three role-scoped Gmail binaries, including a sub-agent that tries every write it can. What the process boundary actually guarantees, and what it does not.

The finding

A sub-agent handed a read-only binary cannot send mail, and the reason has nothing to do with what it was told. It fails because the executable in its process refuses the action before constructing a request, and because the credential that executable carries was issued for a scope the API would reject the write under anyway. Two independent refusals, neither of them a prompt.

We built three role-scoped Gmail clients, ran all four supported actions against each of them, then ran four delegation scenarios on top — including one in which a sub-agent behaves as if it has been compromised and attempts every write available to it. Twelve boundary cases and four scenarios, all behaving as specified.

That result is less interesting than the shape of the thing being tested, so this piece is mostly about the shape, and about the caveat at the end.

Three binaries, twelve cases

Each client is a small program that knows one role. It declares an allowed action set, checks the requested action against it, and refuses anything outside it with a structured error on stderr and a non-zero exit code.

BinaryOAuth scopePermitted actions
gmail_admin.pyhttps://mail.google.com/list, send, delete, modify
gmail_readonly.py.../auth/gmail.readonlylist
gmail_modify_no_delete.py.../auth/gmail.modifylist, send, modify

Cross those three against the four actions and you get twelve cases. Four should succeed on the admin client, one should succeed and three should be refused on the read-only one, and three should succeed with one refusal on the modify client. All twelve behaved that way.

The refusals are worth looking at, because the error is doing more work than an error usually does:

{
  "role": "readonly",
  "action": "send",
  "status": "error",
  "code": 403,
  "error": "Forbidden",
  "message": "Role 'readonly' does not permit action 'send'. Allowed: ['list']. The credential file for this binary carries only the gmail.readonly scope. Use a binary with a broader role."
}

The message names the role, the action, the full allowed set, the reason, and the remedy. The audience for it is a language model that has just had a tool call fail and has to decide what to do next. A bare 403 Forbidden invites a retry, a rephrase, or a creative workaround. A message that says this is the wrong tool for that, the right one exists, ask for it produces a request for escalation instead of an attempt at it.

The modify_no_delete refusal carries the same shape and a sharper reason:

{
  "role": "modify_no_delete",
  "action": "delete",
  "code": 403,
  "error": "Forbidden",
  "message": "Role 'modify_no_delete' does not permit action 'delete'. Allowed: ['list', 'modify', 'send']. The gmail.modify scope does not include permanent deletion. Use the admin binary to permanently delete messages."
}

This is a real property of the Gmail scope catalogue rather than an invention of ours. gmail.modify grants everything except permanent deletion, which makes it the most practically useful boundary in the set: an agent that can triage, label, archive and reply, but cannot destroy anything, without writing a line of policy.

Four delegation scenarios

The interesting question is not whether one client enforces its own limits. It is what happens at the moment a manager hands work to a sub-agent.

Manager with full access. Baseline. A manager holding the admin client performed list, send, modify and delete, all successful. Nothing is being restricted here; the scenario exists so the later blocks mean something.

Sub-agent with read-only. The manager delegates inbox triage and hands over only the read-only client. Listing succeeded; a send and a delete were both refused. The sub-agent’s process never held the admin credential, so there was nothing for it to escalate to.

Sub-agent with modify-no-delete. The manager delegates label remediation. List, modify and send succeeded; delete was refused. Note how capable this sub-agent is — it can send mail — and yet the one destructive operation is still off the table. Narrowing does not have to mean crippling.

A sub-agent that tries everything. The adversarial case. A read-only sub-agent attempts a send to an external address, a permanent delete, and a misleading relabel. All three were refused. The test asserts the block, so the block is the pass.

Scenario four is the one the whole exercise exists for. In a shared-identity model, a sub-agent that decides to send mail to an address it invented is limited only by whether anything in the surrounding system notices. Here it is limited by the fact that the binary in front of it has no code path to a send, and no credential that would survive one.

The caveat, stated plainly

These runs used mock clients. The role logic, the allowed-action sets, the exit codes and the error payloads are real; the Gmail responses are not. No live API call was made, and no real OAuth token was exercised. The refusal happens in the client’s own check, before the point where a network call would occur.

So what has actually been demonstrated? That the design holds — that if you partition capability by executable and give each executable its own credential, a caller who holds one executable cannot reach the operations of another, and there is no instruction it can receive that changes that. What has not been demonstrated is the second line of defence: that a real gmail.readonly token is rejected by Google’s servers on a write. That part is a documented property of OAuth scopes rather than something we measured, and it is worth being precise about which of the two claims a given run supports.

A production deployment wants both. The local check exists so the failure is fast and legible. The scope exists so the local check is not the only thing standing between a confused sub-agent and someone’s inbox. A client that enforced only locally would be one patch away from enforcing nothing.

What this changes about delegation

The usual way to restrict a sub-agent is to tell it what not to do. That works until the sub-agent reads a document containing instructions, or misreads its own, or reasons its way to an exception. None of these are exotic failures; they are the ordinary behaviour of a system whose control plane is natural language.

Partitioning by executable moves the decision to a point where the sub-agent has no vote. The manager decides which client the sub-agent gets, once, at spawn time. After that the question “may this agent send mail?” has the same answer regardless of what the agent thinks, what it was told, or what it read.

Two practical consequences. First, the manager must be able to grant strictly less than it holds — three roles for one API, not one. Second, the roles have to be designed around the work: modify_no_delete exists because “triage my inbox” and “empty my inbox” are different jobs, and the second one is rarely what anybody delegated.