From 3787d155241986f124e98cf97a1544e1fef2f72f Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:37:12 +0200 Subject: [PATCH] fix(agents): block subagent spawning at the Claude Code level (disallow Task) (#377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet-wide subagent ban was implemented as an allowlist omission, but Task is a default-permitted Claude Code built-in — an allowlist auto-approves, it does not restrict. Under permission_mode="dontAsk" (intake/secretary SDK) and defaultMode="bypassPermissions" (fleet), Task ran regardless and can_use_tool was never invoked for it, so every Claude-path agent could still spawn subagents despite allows_subagent=False. Only the grok path blocked it. Explicitly disallow the subagent tool at every Claude-path spawn point: disallowed_tools=["Task"] on the intake and secretary SDK drivers, and "Task" in the fleet settings.json base_deny (an explicit deny applies even under bypassPermissions). This mirrors the grok path's --disallowed-tools Agent. Pins the ban in test_cc_lockdown.py (fleet settings deny Task) and a new test_sdk_driver_subagent_ban.py (intake + secretary options disallow Task). Co-authored-by: Renn F --- roboco/agent_sdk/intake_driver.py | 7 ++++ roboco/agent_sdk/secretary_driver.py | 5 +++ roboco/runtime/orchestrator.py | 7 ++++ .../agent_sdk/test_sdk_driver_subagent_ban.py | 28 +++++++++++++++ tests/unit/runtime/test_cc_lockdown.py | 35 +++++++++++++++++++ 5 files changed, 82 insertions(+) create mode 100644 tests/unit/agent_sdk/test_sdk_driver_subagent_ban.py diff --git a/roboco/agent_sdk/intake_driver.py b/roboco/agent_sdk/intake_driver.py index 2612fdf6..2f69a581 100644 --- a/roboco/agent_sdk/intake_driver.py +++ b/roboco/agent_sdk/intake_driver.py @@ -576,6 +576,13 @@ def build_intake_options( "mcp__intake__propose_batch", "mcp__intake__search_past_tasks", ], + # `Task` is a default-permitted Claude Code built-in — omitting it from + # allowed_tools does NOT remove it (an allowlist auto-approves; it does + # not restrict), and permission_mode="dontAsk" never gates a + # pre-permitted built-in, so can_use_tool below never fires for it. The + # ONLY claude-code-level block that removes the subagent tool is an + # explicit disallow → the fleet-wide subagent ban reaches intake here. + disallowed_tools=["Task"], model=model, include_partial_messages=True, # live token streaming permission_mode="dontAsk", diff --git a/roboco/agent_sdk/secretary_driver.py b/roboco/agent_sdk/secretary_driver.py index 693ee6e3..4b8a7cf1 100644 --- a/roboco/agent_sdk/secretary_driver.py +++ b/roboco/agent_sdk/secretary_driver.py @@ -249,6 +249,11 @@ def build_secretary_options( "mcp__secretary__search_tasks", "mcp__secretary__submit_directive", ], + # Fleet-wide subagent ban: `Task` is a default-permitted built-in that an + # allowlist omission + permission_mode="dontAsk" do NOT remove, so an + # explicit disallow is the only claude-code-level block (see the intake + # driver for the full rationale). + disallowed_tools=["Task"], model=model, include_partial_messages=True, permission_mode="dontAsk", diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 0f24fc61..2d71cffb 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -1719,6 +1719,13 @@ class AgentOrchestrator: base_deny = [ # Block ALL native git commands - must use roboco_git_* tools "Bash(git:*)", + # Fleet-wide subagent ban (CEO, 2026-07-09): no agent spawns Claude + # Code subagents. `Task` is a default-permitted built-in, so under + # defaultMode=bypassPermissions the manifest/allowlist omission does + # NOT remove it — only an explicit deny does. Mirrors the grok path's + # `--disallowed-tools Agent`. `allows_subagent` is False for every + # role, so this is unconditional. + "Task", # NOTE: Write/Edit are intentionally NOT globally denied here. # Claude Code evaluates rules deny -> ask -> allow and the first # match wins, so a deny ALWAYS beats a more-specific allow (the diff --git a/tests/unit/agent_sdk/test_sdk_driver_subagent_ban.py b/tests/unit/agent_sdk/test_sdk_driver_subagent_ban.py new file mode 100644 index 00000000..86c738f5 --- /dev/null +++ b/tests/unit/agent_sdk/test_sdk_driver_subagent_ban.py @@ -0,0 +1,28 @@ +"""The SDK-driver agents (intake, secretary) hard-disallow the `Task` tool. + +`Task` is a default-permitted Claude Code built-in: omitting it from +`allowed_tools` only removes an auto-approve entry, it does not restrict, and +`permission_mode="dontAsk"` never routes a pre-permitted built-in through the +`can_use_tool` gate. So the ONLY claude-code-level block is an explicit +`disallowed_tools=["Task"]` (→ CLI `--disallowedTools Task`). These pin that +the intake interviewer and the Secretary cannot fan out subagents. +""" + +from __future__ import annotations + +from roboco.agent_sdk.intake_driver import build_intake_options +from roboco.agent_sdk.secretary_driver import build_secretary_options + + +def test_intake_options_disallow_task() -> None: + opts = build_intake_options( + system_prompt="x", cwd="/tmp", session_id="s1", model="sonnet" + ) + assert "Task" in opts.disallowed_tools + assert "Task" not in opts.allowed_tools + + +def test_secretary_options_disallow_task() -> None: + opts = build_secretary_options(system_prompt="x", cwd="/tmp", model="sonnet") + assert "Task" in opts.disallowed_tools + assert "Task" not in opts.allowed_tools diff --git a/tests/unit/runtime/test_cc_lockdown.py b/tests/unit/runtime/test_cc_lockdown.py index 604e85f2..732f91b5 100644 --- a/tests/unit/runtime/test_cc_lockdown.py +++ b/tests/unit/runtime/test_cc_lockdown.py @@ -106,6 +106,41 @@ class TestSharedClaudeCredentialsDenied: assert inner.startswith("//"), f"must use // absolute form: {entry}" +class TestSubagentBanned: + """Every role's settings.json denies the `Task` subagent tool. + + `Task` is a default-permitted Claude Code built-in; under + defaultMode=bypassPermissions the manifest/allowlist omission does NOT + remove it — only an explicit `permissions.deny` entry does. Without this + the fleet-wide subagent ban (CEO, 2026-07-09) is unenforced on the Claude + path (the grok path already blocks it via `--disallowed-tools Agent`). + """ + + def test_developer_settings_deny_task(self) -> None: + orch = _orch() + path = orch._generate_agent_settings( + agent_id="be-dev-1", + role="developer", + workspace_path=_WS, + cell_workspace_path=_CELL, + ) + deny = json.loads(Path(path).read_text())["permissions"]["deny"] + assert "Task" in deny, deny + + def test_read_only_role_also_denies_task(self) -> None: + """A read-only role (pr_reviewer, the top prompt-injection target) + gets the same base_deny → no subagent escape hatch.""" + orch = _orch() + path = orch._generate_agent_settings( + agent_id="be-pr-reviewer", + role="pr_reviewer", + workspace_path=_WS, + cell_workspace_path=_CELL, + ) + deny = json.loads(Path(path).read_text())["permissions"]["deny"] + assert "Task" in deny, deny + + class TestSlashCommandsDisabled: """--disable-slash-commands accompanies every container agent spawn."""