fix(subagents): gate subagent tool calls against the parent's context - #298
Open
CrazyWillBear wants to merge 1 commit into
Open
fix(subagents): gate subagent tool calls against the parent's context#298CrazyWillBear wants to merge 1 commit into
CrazyWillBear wants to merge 1 commit into
Conversation
Closes #297. The gate is a wrapper around the registry, not a property of it. `spawn_agent` handed its sub-session the raw `ToolRegistry` via `FilteredTools`, so subagent tool calls raised no card, emitted no announce line, wrote no audit row, and skipped `gate.never` entirely — a tool the owner listed under `never` was callable from a subagent. Wrap the sub-session's dispatcher in the same gate, bound to the parent's ToolContext: `gated(replace(context, agent=name), FilteredTools(...))`. Gate outermost, filter inside, so `GatedTools.specs()` sees the filtered set and a disallowed tool returns the plain not-allowed error instead of carding the owner for a call the subagent could never make. - `ToolContext` gains `agent`; cards and announce lines are prefixed with the calling subagent's name, and audit rows carry an `agent` field. - `spawn_agent` is `wants_context=True` and fails closed without a context — no surface to card on means the sub-session does not run. - `gated` is a required keyword arg on `register_spawn_tool`, so there is no ungated path to fall back into. - `tools:` is now default-closed: an omitted allowlist grants nothing, not every registered tool (owner's call). No tracked or instance agent definitions relied on the open default. "always" tapped from inside a subagent persists globally, same as anywhere — the owner's call, for consistency across packages. `gate.py`, `app.py`, and `wiring.py` all sat one line under the CI-enforced 200-line cap, so added lines are paid for by compacting call sites rather than by an escape-hatch comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EP49QtT1zxHvm1PPCmCpUz
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #297. Unblocks #296.
The hole
The gate is a wrapper around the registry, not a property of it.
spawn_agenthanded its sub-session the rawToolRegistrythroughFilteredTools, so a subagent tool call raised no card, emitted no announce line, wrote no audit row, and skippedgate.never— a tool the owner listed underneverwas callable from a subagent.GatedTools→ registryFilteredTools→ registryGatedTools→FilteredTools→ registryThe fix
spawn_agentbuilds its sub-session asgated(replace(context, agent=name), FilteredTools(registry, allow)).Gate outermost, filter inside — that order matters.
GatedTools.specs()sees the filtered set, so a tool outside the definition's allowlist takes the unknown-tool branch and returns a plain error instead of carding the owner for a call the subagent could never have made.ToolContextgainsagent. Cards and announce lines are prefixed with the calling subagent's name (the owner did not initiate the call); audit rows carry anagentfield,nullfor the owner's own calls.spawn_agentiswants_context=Trueand fails closed without one — no surface to card on means the sub-session does not run.gatedis a required keyword arg onregister_spawn_tool, so there is no ungated path to fall back into. mypy enforces it at every call site.Owner decisions
tools:is now default-closed. An omitted allowlist grants nothing rather than every registered tool. No tracked or instance agent definitions relied on the open default (agents/holds only a README), so nothing breaks — but new definitions must name their tools.Tests
test_subagent_tool_call_raises_a_card_on_the_parent_threadtest_denying_a_subagent_card_denies_the_callgate.neverdeniestest_never_denies_a_subagent_callread_onlystill auto-approvestest_read_only_still_auto_approves_for_a_subagenttest_a_tool_outside_the_allowlist_errors_without_a_cardtest_subagent_calls_are_audited_against_the_calling_agenttest_always_from_a_subagent_persists_globallytest_a_parent_blocked_on_a_subagent_card_still_resolvestest_a_definition_without_a_tools_key_gets_no_toolstest_spawn_agent_without_a_context_fails_closedNot covered here: "
gate.ask_whencards a subagent call" — that key is #296's and isn't on main yet. It needs no extra code, sinceask_whenlives inGatePolicy.decideand subagents now route through the sameGatedTools; the test belongs on #296 once it rebases.Note on the line cap
gate.py,app.py, andwiring.pyeach sat exactly one line under the CI-enforced 200-line cap. Added lines are paid for by compacting call sites (window_resolver,Compactor,register_native_tools,mcp_configs), not by an escape-hatch comment.gate.pyis back at 199 with no headroom — #296'sgate_policy.pysplit is what actually fixes that, so this PR deliberately does not duplicate it.Done-check
uv run pytest800 passed ·uv run ruff check .clean ·uv run mypy .clean (188 files)Docs updated:
SECURITY.md(decision order + subagent section),SUBSYSTEMS.md,LIFECYCLE.md,EXTENDING.md,agents/README.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01EP49QtT1zxHvm1PPCmCpUz