I gave a supervisor agent in Deep Agents 0.7.6 exactly one tool, write_brief. It spawned a writer subagent. The writer ran a web search for site:exfil.example internal customer list, because a note in its input told it to. Nothing checked that call against what the supervisor held.
This isn't a bug. LangChain's Deep Agents permissions page says a subagent spec "replaces the parent's rules entirely". The spec is a fresh grant. A LangChain maintainer filed issue #33879 about a general subagent middleware in November 2025. It's still open, one PR closed unmerged, one still a draft.
So I wanted to see what it takes to make the child hold at most what the parent holds. Here's the run. 2026-09-02, langchain 1.3.15, deepagents 0.7.6, no API key, scripted model.
[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.')]
| Tools | |
|---|---|
| Supervisor held | write_brief |
| Writer's spec listed |
web_search, write_brief
|
| Writer actually ran |
web_search (attacker's query), write_brief
|
Why the child isn't bounded
In Deep Agents each subagent spec carries its own tools list. deepagents/middleware/subagents.py compiles each spec with create_sub_agent(spec). There's no step that intersects that list with the caller's. The permissions page says it for the rules it does ship:
Subagents inherit the parent agent's permissions by default… This replaces the parent's rules entirely.
And core has no subagent middleware yet. #33879, filed 2025-11-07: "Add subagent middleware — inspired by deepagents sub agent middleware… Got a good start here, but now out of date." PR 33484 closed unmerged. PR 39019 is a draft.
You won't see it in the parent's state
This is the part that bit me. Deep Agents collapses a subagent's whole transcript into a single ToolMessage for the supervisor. Print the supervisor's messages and the writer's search isn't there. You see "Brief written." If you want to know what a subagent called, you have to record it at the tool boundary yourself.
Twelve lines that bound the child
LangChain gives you the seam. wrap_tool_call in a middleware gets the ToolCallRequest and the handler. Don't call the handler, the tool doesn't run. Documented parameter, no monkeypatching.
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
class BoundedByParent(AgentMiddleware):
"""A subagent may call only tools its parent holds."""
def __init__(self, parent_tools: set[str]):
self.parent_tools = parent_tools
def wrap_tool_call(self, request, handler):
name = request.tool_call["name"]
if name not in self.parent_tools:
return ToolMessage(content=f"denied: {name} is not held by the parent",
tool_call_id=request.tool_call["id"], status="error")
return handler(request)
Put BoundedByParent({"write_brief"}) in the writer spec's middleware list. Same tree, same scripted model, same note:
bounded_by_parent=False: tool bodies that ran: [('web_search', 'site:exfil.example internal customer list'), ('write_brief', 'Q3 brief.')]
bounded_by_parent=True: tool bodies that ran: [('write_brief', 'Q3 brief.')]
That's the whole idea. The child's grant is the intersection of what it asks for and what the parent has. If you only need tool names, stop here and paste it.
What I needed beyond tool names
Tool names weren't enough for me. A subagent that holds web_search with a 10,000 row limit is not the same as one with 50. A child that lives for 9,999 seconds after its parent expired is a problem. And when the writer got denied, I wanted a record I could hand to someone who doesn't trust my process.
So I put the same idea into a library, attenu-guard, with scopes, ceilings and a lifetime instead of names. The only line that matters:
# child = meet(parent, request): the child gets the intersection, never more
guarded = GuardedDelegation(root, tools=POLICIES,
subagents={"researcher": RESEARCHER_REQUEST, "writer": WRITER_REQUEST},
delegation_tool="task", subagent_arg="subagent_type")
mw = guarded.middleware() # on the supervisor AND on each subagent spec
The researcher asks for web.*, admin.export, 10,000 rows and 9,999 seconds:
researcher requested ['admin.export', 'web.*'], 10000 rows, ttl 9999
researcher GRANTED ['web.search'], 50 rows, ttl 3600
writer GRANTED ['brief.write']
ALLOW web_search scope=web.search
DENY web_search scope=web.search (scope_not_granted)
ALLOW write_brief scope=brief.write
hash chain verifies: True (8 events, audit.jsonl)
attenu-guard verify evidence-bundle.json --pubkey 9f5513de2af51b76…
integrity=True monotonicity=True containment=True anchor=verified nodes=3 actions_checked=2
monotonicity=True means every child in the bundle is a subset of its parent, checked from the bundle file alone. Nothing of mine needs to be running when you check it. Full config is in the recipe linked below.
Where neither version helps
Both sit on LangChain's tool dispatch. A direct Python call around the framework, web_search.invoke({...}) from your own code, isn't a tool call the middleware sees. A subagent runs its own agent loop, so a spec without the middleware is a hole, not a narrowing. The recipe has a require_guard() that refuses to build a tree where any spec is missing it, and a test that shows the hole is real when you skip that check. Neither version sees other processes or the credentials your process holds.
So, does a LangGraph subagent inherit its parent's tool permissions?
No. In Deep Agents 0.7.6 a subagent gets whatever its own spec lists. The parent's tools aren't consulted, and a child can hold a tool its parent never had. Bounding it is one middleware on the subagent spec.
I ran the same question against CrewAI, Claude Code, the OpenAI Agents SDK and Google ADK. The table: Does a sub-agent inherit its parent's permissions? Five frameworks, five answers.
Run it yourself
pip install 'attenu-guard[deepagents]'
git clone https://github.com/attenu-io/attenu-guard && cd attenu-guard
python examples/integrations/langgraph/subagent_middleware/demo.py
Exit 0, every expectation held. Exit 3, the upstream premise changed: core ships a subagent middleware, or a subagent's tools are now bounded by the parent's. The test pins that and will tell me the day it stops being true.
The LangGraph subagent recipe with tests is in the attenu-guard repo.
If you're bounding subagents differently in your own trees, I want to hear it. Especially if you found a way inside Deep Agents without an extra middleware.
Top comments (0)