From dba83938a4ee4ee6b1055845a6a20aff1a274768 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Wed, 13 May 2026 13:19:02 +0300 Subject: [PATCH 1/3] fix(security): drop SYS_PTRACE + MKNOD + NET_RAW + FSETID from FULL_CAPABILITIES (#602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3c from #602: tighten the agent-container capability set. Four caps had no defensible agent use case and each was a documented escalation primitive: SYS_PTRACE Reads another process's memory. A malicious MCP package installed via npx/uvx can attach to PID 1 (Claude Code) and read the OAuth token from heap even when the token isn't in env — this is the AISEC-C2 escalation path closed without waiting for the larger Layer-3b bubblewrap sandbox work. MKNOD Creates device nodes under /dev. No agent workflow needs it; primarily a container-escape primitive. NET_RAW Raw / ICMP sockets. Trinity's "ping another agent" UX is HTTP-level, not ICMP. Removing prevents raw-packet crafting on the docker bridge. FSETID Lets a process preserve setuid/setgid bits on chmod after a non-owner write — used to plant a setuid binary the next privileged path can run. Kept: DAC_OVERRIDE (sudo apt-install path), FOWNER, KILL. Existing containers keep their old caps until restart; new and recreated containers get the trimmed set immediately. No rolling-restart needed. Verified live on the running stack: - /proc/1/status CapBnd on a fresh agent shows 9 caps (was 13) - Python AF_INET/SOCK_RAW → `[Errno 1] Operation not permitted` (NET_RAW) - ptrace(PTRACE_ATTACH, 1, ...) → `[Errno 1] Operation not permitted` (SYS_PTRACE) - `sudo apt-get update` still succeeds (DAC_OVERRIDE kept) `tests/unit/test_capability_set.py` pins the FULL set against silent re-addition: any future PR that puts SYS_PTRACE / MKNOD / NET_RAW / FSETID back fails the parity test with a pointer to this commit. Related to #602 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/agent_service/lifecycle.py | 27 +++++- tests/unit/test_capability_set.py | 92 +++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_capability_set.py diff --git a/src/backend/services/agent_service/lifecycle.py b/src/backend/services/agent_service/lifecycle.py index b8b1b6d20..ddeec86c0 100644 --- a/src/backend/services/agent_service/lifecycle.py +++ b/src/backend/services/agent_service/lifecycle.py @@ -101,14 +101,31 @@ async def wait_for_agent_ready( # 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 — kept here for documentation): +# 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 = RESTRICTED_CAPABILITIES + [ - 'DAC_OVERRIDE', # Bypass file permission checks (needed for apt) + 'DAC_OVERRIDE', # Bypass file permission checks (needed for sudo 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 diff --git a/tests/unit/test_capability_set.py b/tests/unit/test_capability_set.py new file mode 100644 index 000000000..06d2a3f58 --- /dev/null +++ b/tests/unit/test_capability_set.py @@ -0,0 +1,92 @@ +""" +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 sys +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" +_BACKEND_STR = str(_BACKEND) +while _BACKEND_STR in sys.path: + sys.path.remove(_BACKEND_STR) +sys.path.insert(0, _BACKEND_STR) + + +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.""" + from services.agent_service.lifecycle import RESTRICTED_CAPABILITIES + + leaked = REMOVED_BY_ISSUE_602 & set(RESTRICTED_CAPABILITIES) + assert not leaked, ( + f"RESTRICTED_CAPABILITIES regained Issue #602 forbidden caps: {leaked}. " + "These are security primitives — see lifecycle.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.""" + from services.agent_service.lifecycle import FULL_CAPABILITIES + + leaked = REMOVED_BY_ISSUE_602 & set(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 lifecycle.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.""" + from services.agent_service.lifecycle import ( + FULL_CAPABILITIES, + RESTRICTED_CAPABILITIES, + ) + + missing = set(RESTRICTED_CAPABILITIES) - set(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.""" + from services.agent_service.lifecycle import ( + FULL_CAPABILITIES, + PROHIBITED_CAPABILITIES, + RESTRICTED_CAPABILITIES, + ) + + leaks_full = set(PROHIBITED_CAPABILITIES) & set(FULL_CAPABILITIES) + leaks_restricted = set(PROHIBITED_CAPABILITIES) & set(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}" From 9f0ff147a67c4f77dffaf1ed61eab1b0a9abf38a Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Wed, 13 May 2026 13:39:30 +0300 Subject: [PATCH 2/3] fix(tests): extract cap constants so unit test stays stdlib-only (#602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failure on PR #830: `from services.agent_service.lifecycle import FULL_CAPABILITIES` pulls in `services.docker_utils` via the package init's eager imports (helpers → lifecycle → docker_utils). The conftest stubbing pattern leaves docker_utils as a partial SimpleNamespace missing `container_stop`, so the test crashes with `ImportError: cannot import name 'container_stop' from 'services.docker_utils'`. The cap constants are pure data; nothing about reading them needs docker or fastapi. Move them to a sibling `capabilities.py` that imports nothing beyond stdlib, and have `lifecycle.py` re-export so runtime callers are unchanged. The test now loads `capabilities.py` directly via importlib so it bypasses the agent_service package __init__ entirely — same pattern the #816 backfill script uses to stay venv-free. Verified locally: 4/4 pass without backend deps. Backend re-export path still resolves the constants (checked via running container). Related to #602 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/agent_service/capabilities.py | 66 +++++++++++++++++++ .../services/agent_service/lifecycle.py | 60 +++-------------- tests/unit/test_capability_set.py | 53 ++++++++------- 3 files changed, 105 insertions(+), 74 deletions(-) create mode 100644 src/backend/services/agent_service/capabilities.py 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 ddeec86c0..df66900b1 100644 --- a/src/backend/services/agent_service/lifecycle.py +++ b/src/backend/services/agent_service/lifecycle.py @@ -85,58 +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. -# -# 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 — kept here for documentation): -# 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 = 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 = [ - '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 index 06d2a3f58..d59d595b0 100644 --- a/tests/unit/test_capability_set.py +++ b/tests/unit/test_capability_set.py @@ -16,16 +16,30 @@ from __future__ import annotations +import importlib.util import sys from pathlib import Path import pytest -_BACKEND = Path(__file__).resolve().parent.parent.parent / "src" / "backend" -_BACKEND_STR = str(_BACKEND) -while _BACKEND_STR in sys.path: - sys.path.remove(_BACKEND_STR) -sys.path.insert(0, _BACKEND_STR) + +# `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(): + spec = importlib.util.spec_from_file_location("caps_under_test", _CAPS_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module REMOVED_BY_ISSUE_602 = { @@ -39,25 +53,25 @@ def test_restricted_set_minimal(): """Restricted (default) set must stay tight — no debugger/raw-socket caps even at the baseline.""" - from services.agent_service.lifecycle import RESTRICTED_CAPABILITIES + caps = _load_caps() - leaked = REMOVED_BY_ISSUE_602 & set(RESTRICTED_CAPABILITIES) + 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 lifecycle.py comments before re-adding." + "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.""" - from services.agent_service.lifecycle import FULL_CAPABILITIES + caps = _load_caps() - leaked = REMOVED_BY_ISSUE_602 & set(FULL_CAPABILITIES) + 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 lifecycle.py FULL_CAPABILITIES comment block " + "primitive — see capabilities.py FULL_CAPABILITIES comment block " "before re-adding." ) @@ -65,12 +79,9 @@ def test_full_set_excludes_issue_602_removals(): 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.""" - from services.agent_service.lifecycle import ( - FULL_CAPABILITIES, - RESTRICTED_CAPABILITIES, - ) + caps = _load_caps() - missing = set(RESTRICTED_CAPABILITIES) - set(FULL_CAPABILITIES) + 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." @@ -80,13 +91,9 @@ def test_full_set_remains_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.""" - from services.agent_service.lifecycle import ( - FULL_CAPABILITIES, - PROHIBITED_CAPABILITIES, - RESTRICTED_CAPABILITIES, - ) + caps = _load_caps() - leaks_full = set(PROHIBITED_CAPABILITIES) & set(FULL_CAPABILITIES) - leaks_restricted = set(PROHIBITED_CAPABILITIES) & set(RESTRICTED_CAPABILITIES) + 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}" From 5b4267da865ecacc5330d4bd3af73dd83b6e3feb Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Wed, 13 May 2026 13:45:15 +0300 Subject: [PATCH 3/3] fix(tests): drop bare sys.modules write in test_capability_set (#602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/lint_sys_modules.py` (issue #762) bans `sys.modules[X] = …` at module/function scope. The capabilities.py loader was copied from the #816 backfill script which DOES need the sys.modules entry — because that module uses @dataclass(frozen=True), and dataclasses introspect `cls.__module__` via sys.modules. capabilities.py is pure list literals with no decorators that care, so the registration is unnecessary. Drop it; lint baseline stays at zero new violations. Related to #602 Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/unit/test_capability_set.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_capability_set.py b/tests/unit/test_capability_set.py index d59d595b0..8694d066c 100644 --- a/tests/unit/test_capability_set.py +++ b/tests/unit/test_capability_set.py @@ -35,9 +35,11 @@ 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) - sys.modules[spec.name] = module spec.loader.exec_module(module) return module