Multi-agent handoffs are an unguarded trust boundary
Every other place in your infrastructure where authority changes hands has a check on it. Service-to-service calls authenticate. Users re-authorise for sensitive operations. The agent-to-agent handoff — which grants something the ability to act on your behalf — is usually a function call.
Multi-agent systems are mostly sold on capability — a triage agent that routes to specialists, a planner that farms work to workers, a supervisor that coordinates. The security story usually stops at the perimeter: the user authenticates, the system gets a session, the agents inside it cooperate.
Inside that perimeter, a handoff is typically handoff(agent) or agent.as_tool() — an internal function call with no checkpoint. But something is being granted the ability to act. If the child ends up with capabilities the parent never had, privilege was created out of nothing, and in a deep tree that compounds one hop at a time.
Two tiers, two different questions
The distinction matters because the answers come from different places and can disagree.
| Question | Gated by | Fires |
|---|---|---|
| Can this agent create or delegate to a child? | agent_lifecycle.rego | On the lifecycle event, before the child exists |
| What is the resulting child allowed to do? | delegation.rego | On each of the child's tool calls, referencing its depth |
The shipped defaults are deliberately conservative: spawn allowed to delegation depth 2, delegate to depth 1, depth-1 children capped at $50 refunds, and depth-2 or deeper blocked from refunds entirely. Any tool call beyond depth 2 is a SECURITY_CRITICAL deny — no HITL escalation, no scope override.
The second tier exists because "you may delegate" and "your delegate may do anything you can do" are different statements. A support agent can legitimately spawn a helper without that helper inheriting refund authority.
The subset rule
This is the load-bearing constraint, and it's four lines of Rego:
allow if {
input.event_type == "agent.spawn"
input.context.delegation_depth <= 2
every cap in input.requested_capabilities {
cap in input.context.session_scopes
}
}
deny if {
input.event_type == "agent.spawn"
input.context.delegation_depth > 2
}Every requested capability must already be in the parent's session scopes. A child can never hold a scope its parent lacked, no matter what the model decided or what an injected instruction asked for. Combined with the depth cap, the reachable authority in an agent tree is bounded by the root session and shrinks monotonically as you go down.
It's worth noticing that this is a structural check, not a textual one. "Delegate the collections follow-up to a sub-agent" is an entirely ordinary request. The violation isn't in the words — it's in the relationship between two sets of scopes, which no prompt scanner is positioned to evaluate.
Three ways to wire it
Framework-agnostic. The lowest-level helper, for custom multi-agent code or a framework without an adapter:
from kitelogik.adapters._base import governed_handoff
from kitelogik.governed import GovernanceError
try:
await governed_handoff(
gate=gate,
context=parent_context,
target="refund_agent",
action="agent.delegate",
requested_capabilities=["approve_refund"],
)
except GovernanceError as exc:
print(f"Delegation denied: {exc.decision.reason}")
return
await refund_agent.run(task)OpenAI Agents SDK. The adapter wraps agents.handoff() so every transfer hits the gate, and the SDK rejects the handoff if policy denies:
from kitelogik.adapters.openai_agents import OpenAIAgentsAdapter
adapter = OpenAIAgentsAdapter(gate=gate, context=parent_context)
to_refund = adapter.register_handoff(
target_agent=refund_agent,
action="agent.delegate",
)
triage_agent.handoffs = [to_refund]Manual spawn. When your runtime creates the sub-agent itself, fire the event first and derive the child context from the parent:
decision = await gate.evaluate(spawn_event)
if not decision.allow:
raise GovernanceError(f"Spawn denied: {decision.reason}", decision=decision)
child_context = parent_context.model_copy(update={
"session_id": "sess_002_child",
"session_scopes": ["read_customer", "send_notification"],
"delegation_depth": parent_context.delegation_depth + 1,
"parent_token_id": parent_context.token_id,
"parent_session_id": parent_context.session_id,
})Deriving the child from the parent with model_copy is what keeps the lineage intact. delegation_depth and parent_token_id are what the policy reasons about, so a child context assembled from scratch is a child the depth cap can't see.
The audit trail is the quiet win
Every spawn and delegate event is recorded with parent_session_id, delegation_depth, requested_capabilities, and the rule that produced the decision. So the question "which agent created this child, with what scope, under which policy version" is answerable from a grep.
In a single-agent system that's mild convenience. In a tree of agents spawning agents, it's the difference between reconstructing an incident and guessing at it — and you get it without having anticipated the question, which is the only kind of logging that's ever there when you need it.
What this doesn't do
No transport security. This is semantic validation of what's being delegated. Authentication and encryption between agent processes are mTLS, a service mesh, or your existing transport layer. OWASP's ASI07 bundles all three concerns, and enforcement addresses one — which is why we score that entry partial rather than enforced. If your agents talk over an unauthenticated channel, a check on the payload is not the control you're missing.
The subset rule is structural, not semantic. It guarantees a child holds no scope its parent lacked. It says nothing about whether delegating that scope was wise. A parent with legitimate refund authority handing it to a child that's been fed a poisoned instruction passes the subset check cleanly — the scope was genuinely held. Narrow root scopes are what limits that, and they're a design decision no rule makes for you.
No drift detection. An agent operating entirely within its granted scopes, for the wrong reasons, is invisible here. Bounded blast radius is not the same as knowing something is wrong.
Frequently asked questions
Why is a handoff a trust boundary?
Because authority changes hands. When a triage agent hands a task to a refund agent, something is being granted the ability to act — and in most frameworks that grant is expressed as a function call with no checkpoint. Every other place in your infrastructure where authority transfers has a check on it. The handoff usually does not, which makes it the cheapest place to escalate privilege.
What stops a child agent getting more privilege than its parent?
A subset rule evaluated before the child exists. agent_lifecycle.rego requires every requested capability to be present in the parent's session scopes, so a child can never hold a scope its parent lacked — regardless of what the model asked for. Combined with a depth cap, that stops privilege being manufactured one hop at a time down a deep agent tree.
What are the shipped defaults?
Spawn is allowed to delegation depth 2, delegate to depth 1. Depth-1 children are capped at $50 refunds, and depth-2 or deeper are blocked from refunds entirely. Any tool call beyond depth 2 is a SECURITY_CRITICAL deny. Both modules' denials are security-critical in main.rego, meaning no HITL escalation and no scope override.
Does this secure the communication channel between agents?
No. This is semantic validation of what is being delegated, not transport security. Authentication and encryption between agent processes are mTLS, a service mesh, or your existing transport layer. OWASP's ASI07 covers all three concerns together; policy enforcement addresses one of them, which is why we score that entry partial rather than enforced.
Can I audit which agent created which child?
Yes — that is the useful side effect. Every spawn and delegate event is recorded with parent_session_id, delegation_depth, requested_capabilities, and the rule that produced the decision. So "which agent created this child, with what scope, under which policy version" is answerable from a JSONL grep or a SQL query, after the fact, without having anticipated the question.