mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -576,6 +576,13 @@ def build_intake_options(
|
|||||||
"mcp__intake__propose_batch",
|
"mcp__intake__propose_batch",
|
||||||
"mcp__intake__search_past_tasks",
|
"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,
|
model=model,
|
||||||
include_partial_messages=True, # live token streaming
|
include_partial_messages=True, # live token streaming
|
||||||
permission_mode="dontAsk",
|
permission_mode="dontAsk",
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ def build_secretary_options(
|
|||||||
"mcp__secretary__search_tasks",
|
"mcp__secretary__search_tasks",
|
||||||
"mcp__secretary__submit_directive",
|
"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,
|
model=model,
|
||||||
include_partial_messages=True,
|
include_partial_messages=True,
|
||||||
permission_mode="dontAsk",
|
permission_mode="dontAsk",
|
||||||
|
|||||||
@@ -1719,6 +1719,13 @@ class AgentOrchestrator:
|
|||||||
base_deny = [
|
base_deny = [
|
||||||
# Block ALL native git commands - must use roboco_git_* tools
|
# Block ALL native git commands - must use roboco_git_* tools
|
||||||
"Bash(git:*)",
|
"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.
|
# NOTE: Write/Edit are intentionally NOT globally denied here.
|
||||||
# Claude Code evaluates rules deny -> ask -> allow and the first
|
# Claude Code evaluates rules deny -> ask -> allow and the first
|
||||||
# match wins, so a deny ALWAYS beats a more-specific allow (the
|
# match wins, so a deny ALWAYS beats a more-specific allow (the
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -106,6 +106,41 @@ class TestSharedClaudeCredentialsDenied:
|
|||||||
assert inner.startswith("//"), f"must use // absolute form: {entry}"
|
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:
|
class TestSlashCommandsDisabled:
|
||||||
"""--disable-slash-commands accompanies every container agent spawn."""
|
"""--disable-slash-commands accompanies every container agent spawn."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user