Skip to content

Fix privilege boundary: subagents cannot exceed parent tools - #101

Merged
antiv merged 1 commit into
mainfrom
claude/fix-subagent-privilege-escalation
Sep 7, 2026
Merged

Fix privilege boundary: subagents cannot exceed parent tools#101
antiv merged 1 commit into
mainfrom
claude/fix-subagent-privilege-escalation

Conversation

@antiv

@antiv antiv commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes a critical security vulnerability where subagents could be equipped with tools their parent agent does not hold, allowing prompt injection to grant unauthorized privileges like code_executor running as the server user.

Problem

_build_subagent_tools was granting any tool named in a subtask request, defaulting to enabled when the parent had no configuration entry. This meant:

  1. An orchestrator's prompt (including injected instructions) decided the child's privileges, not the parent's configuration
  2. code_executor was not in the forbidden set, so {"code_executor": true} alone could build execute_shell_command
  3. The widget guard keying off agent name didn't apply to subagents (which get fresh UUIDs), so widget-exposed agents could obtain the executor on children
  4. Subagents have no RBAC of their own — the parent's tool set is the only boundary

Changes

  • subagent_delegation_tool.py:

    • Added _parent_effective_tools() to compute the parent's actual tool set, accounting for widget exposure (code_executor refusal)
    • Modified _build_subagent_tools() to enforce that subagents can only receive tools the parent already holds
    • Added MCP boundary: subagents inherit parent's MCP servers, so a parent with none grants none
    • Changed to inherit parent's tool settings (dicts like memory_blocks config) rather than flattening to True
    • Updated SubtaskSpec.tools docstring to clarify the boundary
  • test_subagent_privilege_boundary.py (new):

    • 158-line test suite covering the privilege boundary with 13 test cases
    • Tests that code_executor and aliases are blocked when parent lacks them
    • Tests that agent management, shop, and MCP are similarly bounded
    • Tests that widget exposure is inherited (parent's widget refusal applies to child)
    • Tests that existing behavior survives (parent's tools are still granted, settings are inherited)
  • DYNAMIC_SUBAGENTS.md:

    • Added "Privilege boundary" section documenting the rule and its consequences
  • CHANGELOG.md:

    • Added security entry describing the fix

Implementation Details

The fix calls ToolFactory._agent_has_widget_key() to check the parent's widget exposure rather than restating the logic, ensuring the two checks cannot drift. A subagent's effective tool set is the intersection of what it requests and what its parent actually holds (after widget checks).

https://claude.ai/code/session_01ToCUNy2SqwvfTq4a6xfSk1

Reported privately against 695c735 and confirmed from source.

_build_subagent_tools equipped a child with any tool family named in a
subtask, falling back to enabling it when the parent had no such entry. So
the orchestrator's prompt decided the child's privileges, not the parent's
configuration — and that prompt can be influenced by anyone talking to the
agent. A child with execute_shell_command, running as the server user, was
one injected instruction away on any agent with delegation enabled.

Three things made it serious rather than untidy. code_executor is not in
the forbidden set, which covers only delegation tools to stop fork bombs,
and {"code_executor": true} alone is enough to build the shell tool. The
widget guard from #72 keys off the agent name, and a subagent is named
subagent_<uuid>, which has no widget key — so the refusal that exists
precisely to keep anonymous visitors away from a shell did not reach the
child. And a subagent is not a row in agents_config, so it carries no RBAC
of its own; the parent's tool set is the only boundary there is.

The same route granted create_agent, which pulls delete_agent and
update_agent with it, and shop, which returns place_order.

Requested tools are now intersected with the parent's effective set. The
widget refusal is part of that set, applied by calling the factory's own
check rather than restating it, so the two cannot drift. A parent that
legitimately holds the executor still passes it on. Children inherit the
parent's settings for a tool rather than a bare true, so a shop subagent
gets the real catalog instead of an empty default. MCP is bound the same
way: a parent with no servers grants none.

Also stops the tool's own schema advertising code_executor to the
orchestrator as an example — it was actively suggesting the escalation.

876 tests, OK. Ten of the eleven new tests fail against the previous
implementation; the four that pass on both are the no-regression cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToCUNy2SqwvfTq4a6xfSk1
@antiv
antiv merged commit c67e13e into main Sep 7, 2026
1 check passed
@antiv
antiv deleted the claude/fix-subagent-privilege-escalation branch September 7, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants