fix(agents): block subagent spawning at the Claude Code level (disallow Task) (#377)

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 <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-10 02:37:12 +02:00
committed by GitHub
co-authored by Renn F
parent 92410d47cf
commit 3787d15524
5 changed files with 82 additions and 0 deletions
@@ -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
+35
View File
@@ -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."""