Delegation¶
Since 0.10.29 a host that hands part of a task to another agent can give that agent a
policy no wider than its own, prove it, and hold the child's calls to it. Until then a
child given a wider policy than its parent got it, nothing tied a tool argument to the
lease its caller held, and a parent run with max_turns=2 could let each nested agent take
its framework's default of 10.
Raised in openai/codex#36381, modelcontextprotocol/modelcontextprotocol#3354 and openai/openai-agents-python#5453.
Narrow a policy¶
SecurityPolicy.narrow() returns a copy that can only admit less:
from agent_airlock import SecurityPolicy
from agent_airlock.amplification import AmplificationBudget
planner = SecurityPolicy(
allowed_tools=["read_*", "write_file", "spawn_agent"],
rate_limits={"*": "100/hour"},
)
researcher = planner.narrow(
allowed_tools=["read_*", "search"], # "search" is not the planner's: dropped
denied_tools=["read_secrets"],
rate_limits={"*": "20/hour"},
amplification_budget=AmplificationBudget(max_calls_per_run=30),
)
assert researcher.is_subset_of(planner)
allowed_tools |
intersected with the parent's; an intersection that comes out empty admits no tool |
denied_tools |
added to the parent's |
rate_limits |
added, or put in place of the parent's for a pattern; each must be lower than every parent limit it could stand in for: no bigger burst, no faster refill (10/minute is not lower than 100/hour) |
amplification_budget |
no looser than the parent's |
Every other field is the parent's own object. A request that would widen the policy
raises ValueError naming what widens it.
is_subset_of(parent) checks any policy, not only one narrow() built. It is sound, not
complete: it says False also when it cannot prove the subset from the policies' shape, such
as a glob that may overlap another. agent_airlock.delegation.subset_problems(child,
parent) says what it could not prove. The rules:
| Field | The child is within the parent when |
|---|---|
| allowlist | each child pattern is within a parent pattern, or the parent admits every tool |
| deny list | each parent pattern is within a child pattern |
| time windows, rate limits | every parent pattern is kept, no looser; a pattern the child adds is no looser than any of the parent's |
rate_limit_mode, rate_limit_scope |
"all" if the parent's is; "policy" (one bucket for every agent) if the parent's is |
require_agent_id, allowed_roles |
on if the parent's is; the child's roles are among the parent's |
| re-authorization | on if the parent's is, at a threshold no higher |
| escalations | every parent rule is kept; the child's approver, channel and ledger are the parent's or none; no standing grant admits what the parent's do not |
| call budget | no looser than the parent's: calls per run, amplification ratio and max_wall_seconds, and max_sessions no lower |
| every other field | the parent's own object |
A field added to SecurityPolicy later fails the check until the rules compare it.
Delegate¶
AirlockContext.delegate(child_policy) refuses a child that is not within its parent and
otherwise issues a DelegationLease. Entering the lease starts the child's run:
from agent_airlock import AirlockContext, SecurityPolicy
planner = SecurityPolicy(allowed_tools=["read_*", "write_file", "spawn_agent"])
run = AirlockContext(agent_id="planner")
lease = run.delegate(
planner.narrow(allowed_tools=["read_*"]),
parent_policy=planner, # a delegated run's parent is its own ceiling instead
agent_id="researcher",
max_depth=0, # the researcher may not delegate further
)
with lease as child:
... # the researcher's tool calls
Every call in the child's run must pass the policies of its delegation chain, outermost
first, as well as its tool's own: the parent's (so siblings share its rate limits and call
budget) and the child's. That check is step 2.1, layer 2.1:delegation_ceiling in the
decision record; a refusal there names the policy DelegationCeiling. An escalation on a
ceiling goes to that policy's approver.
The ceiling applies the policy's tool lists, identity, time windows, rate limits, escalations, re-authorization and call budget. Its other fields (shell commands, data flow, capabilities, the guards) must be the parent's, and the tool's own policy applies them as before.
The lease is single-use. Entering it spends it, whatever comes of the entry, and checks
again that the child is within the parent. A second entry is refused, and so is an entry
from another run or another level of delegation. The child's context shares the run's
taint, halt latch and untrusted-output counts, so a halted run cannot carry on through a
child; the parent's authorize_once grants stay the parent's. The lease keeps a frozen copy
of the child's policy, so changing the policy object afterwards changes nothing.
delegate() refuses with DelegationRefused, whose problems say what made the child
wider.
Leases on arguments¶
@lease_scoped binds an argument to the capability lease its caller holds in a
CapabilityCapEngine:
from agent_airlock import Airlock, lease_scoped
from agent_airlock.capability_caps import (
Capability,
CapabilityCapEngine,
CapabilityRulesConfig,
Lease,
)
engine = CapabilityCapEngine(CapabilityRulesConfig())
engine.grant_lease(
"writer",
Lease(lease_id="docs", capability="write_file", categories=frozenset(), target="/repo/docs/*"),
)
@Airlock()
@lease_scoped(arg="path", capability=Capability.WRITE_FILE, engine=engine)
def write_file(path: str, text: str) -> str:
...
Before the tool runs (step 4.5), the call's path must fall within the target glob of a
lease its agent holds whose capability label is the capability's value ("write_file"),
however well it fits the tool's schema. The use is then metered through
engine.check_and_use, whose caps can still refuse it. A refusal is capability_denied
with violation_type lease_scope, and it never shows the lease's target.
The value is matched as written, with fnmatchcase, so * crosses /. A value with a
.. segment or a NUL is refused, a call that names no agent holds no lease, and an
argument the call leaves out is checked at its default. Symlinks are the filesystem
guard's to resolve (step 3).
delegate(..., engine=engine, leases=[...]) grants a child leases for its run, each within
one its parent holds (the same capability, a target the parent's covers, no category the
parent's lacks), under the child's own agent_id, and revokes them when the run ends. With
an engine, entering the lease also meters the parent's delegate_to_agent capability, so a
cap there bounds how many children it starts.
Nested-run budget¶
An agent that runs another agent as a tool starts a new run, and each run counts its own
turns. run_budget() sets the budget of the whole tree at its root, and each
nested_run() below it takes one level of depth and draws its turns from what is left. A
share it names (max_turns) is reserved, and what it did not spend goes back when it ends;
with none, it spends from what is left as it goes, so concurrent siblings draw on the same
turns:
from agent_airlock import Airlock, nested_run, run_budget, spend_turn
@Airlock()
def ask_researcher(question: str) -> str:
with nested_run(max_turns=10) as budget:
# budget.turns_left is at most what the tree has left: pass it as the
# framework's own max_turns, and count each turn the nested agent takes.
spend_turn()
return "answer"
with run_budget(max_depth=1, max_turns=6):
... # the root agent's run
max_depth |
levels of nested run below this one; 1 lets the root's agent run agents as tools, not those agents run more |
max_turns |
the turns the whole tree may take; a nested run's share is at most what is left |
hard_stop |
the root's choice: raise out of the run instead |
Running out raises RunBudgetExhausted before the nested run starts, and @Airlock hands
it to the agent that called the tool as a failed tool result (run_budget_exhausted), so
that agent can finish with what it has: the calls before it have already run. With
hard_stop=True at the root, the exception goes up through every @Airlock instead and
ends the run. A budget inside another can only tighten it, and may not set hard_stop.
A DelegationLease takes a level too, with its own max_depth and max_turns. The budget
rides a ContextVar: tasks and asyncio.to_thread calls started inside a scope inherit it,
a bare threading.Thread does not.
What it does not do¶
- It counts turns only when told: a framework adapter, or the host, calls
spend_turn()for each turn. - It does not prove a child's task needs what it is given, only that it is given no more than its parent has.
- Glob coverage is decided from the patterns' shape, so
is_subset_of()refuses some true subsets it cannot prove:ba*within*a*(a pattern with two*), or anything with a character class. Andnarrow()drops an intersection the patterns cannot express: narrowingread_*to*_fileadmits no tool, though both matchread_file. Nameread_fileto keep it.