DEV Community

Cover image for Does a LangGraph subagent inherit its parent's tool permissions? I tested it.
Rafael Asor
Rafael Asor

Posted on Originally published at attenu.io AI-assisted

Does a LangGraph subagent inherit its parent's tool permissions? I tested it.

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.')]
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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.')]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)