mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
CC capability lockdown: shared credential mount + curl|sh RCE closed (+5 hardenings spec'd) (#302)
* fix(security): lock down shared Claude Code credential mount + curl|sh RCE Audit of Claude Code capabilities reachable inside a spawned agent container turned up two live gaps against the shared harness state: - Every agent container bind-mounts the host's ~/.claude (OAuth store) and ~/.claude.json read-write (_build_mount_args) — the shared subscription auth used by the whole fleet. Nothing denied the native Read tool or the bash-guard hook from reading .credentials.json / .claude.json, so any role could exfiltrate the harness's own Claude Code auth. Deny both at the settings.json layer (absolute // form, per the #167 gotcha) and in the bash-guard hook's credential-exfil checks (cat/grep/source/base64/ interpreter one-liners), mirroring the existing .netrc/.git-credentials treatment. - The bash-guard hook only blocked curl/wget to github.com or internal hosts; `curl <any other host>/install.sh | bash` (or `bash <(curl ...)`, `eval "$(curl ...)"`) executed untrusted remote code unchecked. New checks deny piping a fetch into an actual shell (sh/bash/zsh/dash/ksh) while leaving non-executing consumers (tar, jq, -o file) untouched. Also add --disable-slash-commands to every container agent spawn: skills resolve independently of the --tools allowlist, so a contaminated shared ~/.claude could otherwise leak host skills/plugins into an agent session. No RoboCo role's workflow uses a Claude Code skill. 64 -> 78 shell bash-guard cases, 54 -> 71 pytest bash-guard cases, plus a new 5-case settings/CLI test module. ruff/mypy/xenon B clean. * docs: changelog for the CC capability lockdown --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""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 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"
|
||||
Reference in New Issue
Block a user