Skip to content

fix(subagents): gate subagent tool calls against the parent's context - #298

Open
CrazyWillBear wants to merge 1 commit into
mainfrom
issue-297
Open

fix(subagents): gate subagent tool calls against the parent's context#298
CrazyWillBear wants to merge 1 commit into
mainfrom
issue-297

Conversation

@CrazyWillBear

Copy link
Copy Markdown
Collaborator

Closes #297. Unblocks #296.

The hole

The gate is a wrapper around the registry, not a property of it. spawn_agent handed its sub-session the raw ToolRegistry through FilteredTools, so a subagent tool call raised no card, emitted no announce line, wrote no audit row, and skipped gate.never — a tool the owner listed under never was callable from a subagent.

caller path gated?
main session GatedTools → registry yes
subagent (before) FilteredTools → registry no
subagent (after) GatedToolsFilteredTools → registry yes

The fix

spawn_agent builds its sub-session as gated(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.

  • ToolContext gains agent. Cards and announce lines are prefixed with the calling subagent's name (the owner did not initiate the call); audit rows carry an agent field, null for the owner's own calls.
  • spawn_agent is wants_context=True and fails closed without one — 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. mypy enforces it at every call site.

Owner decisions

  • "always" from inside a subagent persists globally, exactly like a main-session tap — consistency across packages, no strange exceptions. A tap inside a subagent does widen the main session's permissions; documented in SECURITY.md.
  • 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

criterion test
card on the parent's thread test_subagent_tool_call_raises_a_card_on_the_parent_thread
denying the card denies the call test_denying_a_subagent_card_denies_the_call
gate.never denies test_never_denies_a_subagent_call
read_only still auto-approves test_read_only_still_auto_approves_for_a_subagent
allowlist miss → error, no card, no always test_a_tool_outside_the_allowlist_errors_without_a_card
audit attributable to the agent test_subagent_calls_are_audited_against_the_calling_agent
"always" persists globally test_always_from_a_subagent_persists_globally
the deadlock that isn't test_a_parent_blocked_on_a_subagent_card_still_resolves
default-closed allowlist test_a_definition_without_a_tools_key_gets_no_tools
fails closed without a context test_spawn_agent_without_a_context_fails_closed

Not covered here: "gate.ask_when cards a subagent call" — that key is #296's and isn't on main yet. It needs no extra code, since ask_when lives in GatePolicy.decide and subagents now route through the same GatedTools; the test belongs on #296 once it rebases.

Note on the line cap

gate.py, app.py, and wiring.py each 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.py is back at 199 with no headroom — #296's gate_policy.py split is what actually fixes that, so this PR deliberately does not duplicate it.

Done-check

uv run pytest 800 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

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
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.

Subagent tool calls bypass the gate entirely (blocks #296)

1 participant