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.

By Louis Bryson · 8 min read · Updated

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.

QuestionGated byFires
Can this agent create or delegate to a child?agent_lifecycle.regoOn the lifecycle event, before the child exists
What is the resulting child allowed to do?delegation.regoOn 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:

regoagent_lifecycle.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:

python
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:

python
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:

python
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.

Louis Bryson
Founder & maintainer, Kite Logik

Engineer focused on production AI agent infrastructure and policy-as-code. Maintains Kite Logik, the open-source OPA/Rego governance layer for Python agents.

Connect on LinkedIn