Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/backend/services/agent_service/capabilities.py
Original file line number Diff line number Diff line change
@@ -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
]
43 changes: 9 additions & 34 deletions src/backend/services/agent_service/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
101 changes: 101 additions & 0 deletions tests/unit/test_capability_set.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading