diff --git a/src/backend/services/agent_service/capabilities.py b/src/backend/services/agent_service/capabilities.py new file mode 100644 index 000000000..fe2c2e68b --- /dev/null +++ b/src/backend/services/agent_service/capabilities.py @@ -0,0 +1,66 @@ +""" +Linux capability sets for agent containers (Issue #602 — Phase 3c). + +This module is intentionally stdlib-only and import-light so it can be +exercised by `tests/unit/test_capability_set.py` without dragging the +docker / fastapi / database transitive imports of `lifecycle.py` into +the test runner. `lifecycle.py` re-exports these names for callers. + +Trinity always launches agent containers with `cap_drop=ALL` and then +re-adds one of these sets. The pattern (defense in depth): + + cap_drop = ['ALL'] + cap_add = FULL_CAPABILITIES if full_capabilities else RESTRICTED_CAPABILITIES +""" + +from __future__ import annotations + + +# Restricted mode capabilities - minimum for agent operation (default) +RESTRICTED_CAPABILITIES: list[str] = [ + 'NET_BIND_SERVICE', # Bind to ports < 1024 + 'SETGID', 'SETUID', # Change user/group (for su/sudo) + 'CHOWN', # Change file ownership + 'SYS_CHROOT', # Use chroot + 'AUDIT_WRITE', # Write to audit log +] + +# Full capabilities mode - adds package installation support. +# Used when agents need apt-get, pip install, etc. +# +# Issue #602 / Phase 3c (cap tightening): four caps removed from this +# set after AISEC-C2 review. The remaining set is the minimum that keeps +# `sudo apt install` working inside an agent container. +# +# Dropped (no defensible agent use case — documented here so a future +# PR doesn't silently re-add them): +# SYS_PTRACE Lets a process read another process's memory. A malicious +# MCP server could read Claude Code's heap and exfil the +# OAuth token even if the token isn't in env. This is the +# direct AISEC-C2 escalation path; removing it closes it +# without waiting for Layer 3b (bubblewrap sandbox). +# MKNOD Creates device nodes under /dev. Agents have no use case +# for /dev/* manipulation; primarily a container-escape +# primitive (e.g. creating a writable raw disk device). +# NET_RAW Raw / ICMP sockets. Trinity's "ping another agent" UX is +# HTTP-level, not ICMP. Removing this prevents raw-packet +# crafting (TCP RST injection, ARP spoofing on the docker +# bridge, etc.). +# FSETID Lets a process keep setuid/setgid bits on chmod after a +# non-owner write — used to plant a setuid binary the next +# privileged path can run. No agent workflow needs it. +FULL_CAPABILITIES: list[str] = RESTRICTED_CAPABILITIES + [ + 'DAC_OVERRIDE', # Bypass file permission checks (needed for sudo apt) + 'FOWNER', # Bypass permission checks on file owner + 'KILL', # Send signals to processes +] + +# These capabilities are NEVER granted - they pose significant security risks. +# Listed for documentation; we achieve this by always using cap_drop=['ALL']. +PROHIBITED_CAPABILITIES: list[str] = [ + 'SYS_ADMIN', # Mount filesystems, configure namespace - too powerful + 'NET_ADMIN', # Network administration - could escape container + 'SYS_RAWIO', # Raw I/O access - direct hardware access + 'SYS_MODULE', # Load kernel modules - kernel compromise + 'SYS_BOOT', # Reboot system +] diff --git a/src/backend/services/agent_service/lifecycle.py b/src/backend/services/agent_service/lifecycle.py index b8b1b6d20..df66900b1 100644 --- a/src/backend/services/agent_service/lifecycle.py +++ b/src/backend/services/agent_service/lifecycle.py @@ -85,41 +85,16 @@ async def wait_for_agent_ready( # ============================================================================= -# Container Security Capability Sets +# Container Security Capability Sets — see capabilities.py for definitions # ============================================================================= -# These define the Linux capabilities granted to agent containers. -# Security principle: Always drop ALL caps, then add back only what's needed. - -# Restricted mode capabilities - minimum for agent operation (default) -RESTRICTED_CAPABILITIES = [ - 'NET_BIND_SERVICE', # Bind to ports < 1024 - 'SETGID', 'SETUID', # Change user/group (for su/sudo) - 'CHOWN', # Change file ownership - 'SYS_CHROOT', # Use chroot - 'AUDIT_WRITE', # Write to audit log -] - -# Full capabilities mode - adds package installation support -# Used when agents need apt-get, pip install, etc. -FULL_CAPABILITIES = RESTRICTED_CAPABILITIES + [ - 'DAC_OVERRIDE', # Bypass file permission checks (needed for apt) - 'FOWNER', # Bypass permission checks on file owner - 'FSETID', # Don't clear setuid/setgid bits - 'KILL', # Send signals to processes - 'MKNOD', # Create special files - 'NET_RAW', # Use raw sockets (ping, etc.) - 'SYS_PTRACE', # Trace processes (debugging) -] - -# These capabilities are NEVER granted - they pose significant security risks -# Listed for documentation; we achieve this by always using cap_drop=['ALL'] -PROHIBITED_CAPABILITIES = [ - 'SYS_ADMIN', # Mount filesystems, configure namespace - too powerful - 'NET_ADMIN', # Network administration - could escape container - 'SYS_RAWIO', # Raw I/O access - direct hardware access - 'SYS_MODULE', # Load kernel modules - kernel compromise - 'SYS_BOOT', # Reboot system -] +# Re-exported from .capabilities so that test code (and other callers +# that only need the constants) can import them without dragging the +# docker / fastapi / database transitive imports of this module. +from .capabilities import ( # noqa: F401 + RESTRICTED_CAPABILITIES, + FULL_CAPABILITIES, + PROHIBITED_CAPABILITIES, +) async def inject_assigned_credentials(agent_name: str, max_retries: int = 3, retry_delay: float = 2.0) -> dict: diff --git a/tests/unit/test_capability_set.py b/tests/unit/test_capability_set.py new file mode 100644 index 000000000..8694d066c --- /dev/null +++ b/tests/unit/test_capability_set.py @@ -0,0 +1,101 @@ +""" +Pin the FULL_CAPABILITIES set so future PRs can't silently re-add the +caps that Issue #602 / Phase 3c removed. + +Each removed cap is a known security primitive: +- SYS_PTRACE — read other process memory (AISEC-C2 token exfil path) +- MKNOD — create /dev nodes (container-escape primitive) +- NET_RAW — raw / ICMP sockets (packet crafting, ARP spoof) +- FSETID — preserve setuid bits on chmod (priv-escalation primitive) + +If a future caller has a genuine need for one of these, the right path +is: document the use case here, then remove the entry from the +forbidden list with a justification — not silently revert +lifecycle.py. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +# `services.agent_service.capabilities` is pure data, but going through +# the `services.agent_service` package init triggers eager imports +# (lifecycle → docker_utils → tenacity / docker) that would force this +# test to depend on the full backend runtime. Load by file path so the +# test stays stdlib-only. +_CAPS_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "src" / "backend" / "services" / "agent_service" / "capabilities.py" +) + + +def _load_caps(): + # capabilities.py is pure list literals with no decorators that + # introspect via sys.modules — no need to register the loaded module + # there (which would trip tests/lint_sys_modules.py, #762). + spec = importlib.util.spec_from_file_location("caps_under_test", _CAPS_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +REMOVED_BY_ISSUE_602 = { + "SYS_PTRACE", + "MKNOD", + "NET_RAW", + "FSETID", +} + + +def test_restricted_set_minimal(): + """Restricted (default) set must stay tight — no debugger/raw-socket + caps even at the baseline.""" + caps = _load_caps() + + leaked = REMOVED_BY_ISSUE_602 & set(caps.RESTRICTED_CAPABILITIES) + assert not leaked, ( + f"RESTRICTED_CAPABILITIES regained Issue #602 forbidden caps: {leaked}. " + "These are security primitives — see capabilities.py comments before re-adding." + ) + + +def test_full_set_excludes_issue_602_removals(): + """FULL_CAPABILITIES (apt-install mode) must not regain the caps + Issue #602 / Phase 3c removed.""" + caps = _load_caps() + + leaked = REMOVED_BY_ISSUE_602 & set(caps.FULL_CAPABILITIES) + assert not leaked, ( + f"FULL_CAPABILITIES regained Issue #602 forbidden caps: {leaked}. " + "Each entry in REMOVED_BY_ISSUE_602 is a documented security " + "primitive — see capabilities.py FULL_CAPABILITIES comment block " + "before re-adding." + ) + + +def test_full_set_remains_a_superset_of_restricted(): + """FULL must always include everything in RESTRICTED — tightening + the FULL set should not accidentally drop a baseline cap.""" + caps = _load_caps() + + missing = set(caps.RESTRICTED_CAPABILITIES) - set(caps.FULL_CAPABILITIES) + assert not missing, ( + f"FULL_CAPABILITIES dropped baseline caps: {missing}. " + "FULL must remain a superset of RESTRICTED." + ) + + +def test_prohibited_caps_never_appear_in_either_set(): + """SYS_ADMIN-class caps must never leak into RESTRICTED or FULL. + PROHIBITED_CAPABILITIES is the documented blocklist.""" + caps = _load_caps() + + leaks_full = set(caps.PROHIBITED_CAPABILITIES) & set(caps.FULL_CAPABILITIES) + leaks_restricted = set(caps.PROHIBITED_CAPABILITIES) & set(caps.RESTRICTED_CAPABILITIES) + assert not leaks_full, f"PROHIBITED caps in FULL set: {leaks_full}" + assert not leaks_restricted, f"PROHIBITED caps in RESTRICTED set: {leaks_restricted}"