mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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>
233 lines
9.2 KiB
Python
233 lines
9.2 KiB
Python
"""Claude Code capability lockdown.
|
|
|
|
Two independent tightenings against the shared Claude Code harness state:
|
|
|
|
1. The host's ~/.claude (OAuth credential store) and ~/.claude.json are
|
|
bind-mounted read-write into EVERY agent container (the shared
|
|
subscription auth every spawned agent uses — see
|
|
AgentOrchestrator._build_mount_args). No role's job requires the LLM to
|
|
read its own harness's credentials, so the generated settings.json must
|
|
deny the native Read tool from the two files that carry them.
|
|
2. `--disable-slash-commands` must accompany every container spawn: skills
|
|
resolve independently of the `--tools` built-in allowlist (Anthropic's
|
|
own `--bare` docs note skills still resolve via `/skill-name` even with
|
|
everything else disabled), so if the shared `~/.claude` mount ever
|
|
carries personal skills/plugins they must not become callable inside an
|
|
agent's session.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from roboco.models.runtime import OrchestratorAgentConfig, SpawnGitContext
|
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
|
|
|
_WS = "/data/workspaces/roboco-api/backend/be-dev-1"
|
|
_CELL = "/data/workspaces/roboco-api/backend"
|
|
|
|
|
|
def _orch() -> AgentOrchestrator:
|
|
with patch.object(AgentOrchestrator, "__init__", return_value=None):
|
|
return AgentOrchestrator.__new__(AgentOrchestrator)
|
|
|
|
|
|
def _make_dev_config() -> OrchestratorAgentConfig:
|
|
return OrchestratorAgentConfig(
|
|
agent_id="be-dev-1",
|
|
blueprint_path=Path("/app/agents/blueprints/be-dev-1.md"),
|
|
model="sonnet",
|
|
mcp_config_path=Path("/app/mcp-config.json"),
|
|
git_context=SpawnGitContext(
|
|
project_slug="roboco-api",
|
|
branch_name="feature/backend/TASK0001",
|
|
),
|
|
)
|
|
|
|
|
|
def _build_image_args() -> list[str]:
|
|
cmd: list[str] = []
|
|
with patch(
|
|
"roboco.runtime.orchestrator._resolve_agent_cli_model",
|
|
return_value="claude-sonnet-5",
|
|
):
|
|
AgentOrchestrator._append_image_and_claude_args(cmd, _make_dev_config(), None)
|
|
return cmd
|
|
|
|
|
|
class TestSharedClaudeCredentialsDenied:
|
|
"""Every role's generated settings.json blocks the Read tool from the
|
|
shared ~/.claude OAuth credential store."""
|
|
|
|
def test_developer_settings_deny_claude_credentials(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 "Read(//home/agent/.claude/.credentials.json)" in deny, deny
|
|
assert "Read(//home/agent/.claude.json)" in deny, deny
|
|
|
|
def test_qa_settings_also_deny_claude_credentials(self) -> None:
|
|
"""Not just the writer roles — a read-only role gets the same base_deny."""
|
|
orch = _orch()
|
|
path = orch._generate_agent_settings(
|
|
agent_id="be-qa",
|
|
role="qa",
|
|
workspace_path=_WS,
|
|
cell_workspace_path=_CELL,
|
|
)
|
|
deny = json.loads(Path(path).read_text())["permissions"]["deny"]
|
|
assert "Read(//home/agent/.claude/.credentials.json)" in deny, deny
|
|
assert "Read(//home/agent/.claude.json)" in deny, deny
|
|
|
|
def test_deny_uses_absolute_double_slash_form(self) -> None:
|
|
"""Per the #167 gotcha: a single leading / resolves against the
|
|
settings.json project root, not the container filesystem root — an
|
|
absolute container path deny needs the // form or it silently never
|
|
matches."""
|
|
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"]
|
|
claude_denies = [d for d in deny if d.startswith("Read(") and ".claude" in d]
|
|
assert claude_denies, deny
|
|
for entry in claude_denies:
|
|
inner = entry[entry.index("(") + 1 :]
|
|
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."""
|
|
|
|
def test_cmd_contains_disable_slash_commands_flag(self) -> None:
|
|
cmd = _build_image_args()
|
|
assert "--disable-slash-commands" in cmd, (
|
|
f"--disable-slash-commands missing from spawn cmd — skills resolve "
|
|
f"independently of --tools, so a contaminated shared ~/.claude mount "
|
|
f"could still expose host skills/plugins. Full cmd: {cmd}"
|
|
)
|
|
|
|
def test_tools_flag_still_present(self) -> None:
|
|
"""The new flag must not crowd out or replace the existing --tools cap."""
|
|
cmd = _build_image_args()
|
|
assert "--tools" in cmd
|
|
idx = cmd.index("--tools")
|
|
assert cmd[idx + 1] == "Read,Write,Edit,Bash,Grep,Glob,TodoWrite"
|
|
|
|
|
|
class TestFableModeHooksInjection:
|
|
"""Fable-mode hooks are additive to settings.json, gated by the flag."""
|
|
|
|
def test_fable_hooks_absent_when_flag_disabled(self) -> None:
|
|
with patch("roboco.config.settings.fable_mode_enabled", False):
|
|
orch = _orch()
|
|
path = orch._generate_agent_settings(
|
|
agent_id="be-dev-1",
|
|
role="developer",
|
|
workspace_path=_WS,
|
|
cell_workspace_path=_CELL,
|
|
)
|
|
hooks = json.loads(Path(path).read_text())["hooks"]
|
|
assert "SubagentStop" not in hooks
|
|
stop_cmds = [h["command"] for g in hooks["Stop"] for h in g["hooks"]]
|
|
assert not any("fable" in c for c in stop_cmds)
|
|
|
|
def test_fable_hooks_present_when_flag_enabled(self) -> None:
|
|
with patch("roboco.config.settings.fable_mode_enabled", True):
|
|
orch = _orch()
|
|
path = orch._generate_agent_settings(
|
|
agent_id="be-dev-1",
|
|
role="developer",
|
|
workspace_path=_WS,
|
|
cell_workspace_path=_CELL,
|
|
)
|
|
hooks = json.loads(Path(path).read_text())["hooks"]
|
|
stop_cmds = [h["command"] for g in hooks["Stop"] for h in g["hooks"]]
|
|
assert stop_cmds[-1] == "/app/scripts/fable-stop-gate-hook.sh" # appended last
|
|
assert stop_cmds[0] == "/app/scripts/stop-hook.sh" # RoboCo's check still first
|
|
subagent_cmds = [
|
|
h["command"] for g in hooks["SubagentStop"] for h in g["hooks"]
|
|
]
|
|
assert subagent_cmds == ["/app/scripts/fable-stop-gate-hook.sh subagent"]
|
|
pretool_bash = [
|
|
h["command"]
|
|
for g in hooks["PreToolUse"]
|
|
if g.get("matcher") == "Bash"
|
|
for h in g["hooks"]
|
|
]
|
|
assert "/app/scripts/bash-guard-hook.sh" in pretool_bash # existing guard kept
|
|
assert "/app/scripts/fable-bash-discipline-hook.sh" in pretool_bash
|
|
posttool_bash = [
|
|
h["command"]
|
|
for g in hooks["PostToolUse"]
|
|
if g.get("matcher") == "Bash"
|
|
for h in g["hooks"]
|
|
]
|
|
assert posttool_bash == ["/app/scripts/fable-honesty-nudge-hook.sh"] # new
|
|
|
|
def test_fable_hooks_off_leaves_hooks_dict_unchanged(self) -> None:
|
|
"""Regression guard: flag-off output equals a captured pre-Phase-2 baseline."""
|
|
with patch("roboco.config.settings.fable_mode_enabled", False):
|
|
orch = _orch()
|
|
path = orch._generate_agent_settings(
|
|
agent_id="be-dev-1",
|
|
role="developer",
|
|
workspace_path=_WS,
|
|
cell_workspace_path=_CELL,
|
|
)
|
|
hooks = json.loads(Path(path).read_text())["hooks"]
|
|
assert set(hooks.keys()) == {
|
|
"SessionStart",
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"Stop",
|
|
"UserPromptSubmit",
|
|
"PreCompact",
|
|
"SessionEnd",
|
|
}
|