# Sandbox Design: MCP Server Process Isolation

**Status:** Draft v2
**Created:** 2026-03-04
**Updated:** 2026-03-04
**Context:** Synthesized from multi-round expert consultation (SE, UX, LLM Agent, Architecture)

## 1. Problem Statement

MCP stdio servers are arbitrary commands exec'd on the host:

```bash
anima run --mcp "npx @anthropic/mcp-filesystem --root /tmp" "organize files"
```

The LLM agent invokes tools exposed by these servers. The agent may hallucinate
dangerous tool calls. The user chose to run the server, but does not control
what the agent asks it to do. Process-level isolation is needed.

## 2. Design Principles

**anima is an orchestrator, not a security policy engine.**

anima decides *when* to sandbox (flag) and *what capabilities to grant* (coarse flags).
The sandbox runtime decides *how* to sandbox (OS primitives, enforcement).

**[NEW] The threat model is the agent, not individual tools.**

The agent is the decision-maker — it chooses which tool to call. If the agent
wants network access and Server A is blocked but Server B is allowed, the agent
will simply use Server B. Per-server permission granularity does not improve
security against agent misbehavior. Therefore `--sandbox-allow` applies globally
to all sandboxed MCP servers. This is the correct design, not a limitation.

Per-server isolation would only help against a malicious MCP server acting
autonomously (beyond what the agent asked). That is a different threat model
and out of scope for this design.

## 3. Backend

[Anthropic sandbox-runtime (srt)](https://github.com/anthropic-experimental/sandbox-runtime):

- macOS: `sandbox-exec` with dynamically generated Seatbelt profiles
- Linux: `bubblewrap` + seccomp BPF + network namespace
- Network isolation via HTTP/SOCKS5 proxy (domain-level filtering)
- Filesystem isolation via OS kernel (deny read/write)
- Violation monitoring on macOS (sandbox violation log store)

srt is an **internal implementation detail**. Users never see srt config,
srt settings files, or srt concepts. anima translates its own CLI flags
into srt API calls.

### Dependency

```toml
# pyproject.toml
[project.optional-dependencies]
sandbox = ["sandbox-runtime>=0.2.0"]
```

[sandbox-runtime on PyPI](https://pypi.org/project/sandbox-runtime/) —
Anthropic-authored, MIT license, Python ≥3.11. Provides both CLI (`srt`)
and library API. Optional dependency — without it, `--sandbox` prints:

```
Error: Sandbox requires sandbox-runtime. Install with:
  pip install 'anima[sandbox]'
```

Hard stop. If user asked for isolation and we can't provide it, refuse.

## 4. CLI Interface

Deno-style capability flags. Progressive disclosure: one flag for basic use,
additional flags only when the sandbox blocks something you need.

### Flags

```
--sandbox                          Enable sandbox for all MCP stdio servers
--no-sandbox                       Force disable (overrides config)
--sandbox-allow fs:<path>          Grant filesystem access to path tree
--sandbox-allow net                Grant all network access
--sandbox-allow net:<domain>       Grant network access to specific domain
```

`--sandbox-allow` applies to ALL sandboxed MCP servers (see Section 2: threat
model is agent, not tools). `--sandbox-allow` implies `--sandbox`.

### Examples

```bash
# Basic: sandbox everything, deny all
anima run --sandbox --mcp "npx mcp-filesystem /tmp" "list files"

# Grant filesystem access
anima run --sandbox --sandbox-allow fs:/tmp \
  --mcp "npx mcp-filesystem /tmp" "organize files"

# Grant network for search server
anima run --sandbox --sandbox-allow net \
  --mcp "npx mcp-web-search" "find latest news"

# Multiple servers, same sandbox policy (by design)
anima run --sandbox \
  --sandbox-allow fs:/tmp \
  --sandbox-allow net:api.brave.com \
  --mcp "npx mcp-filesystem /tmp" \
  --mcp "npx mcp-brave-search" \
  "research and save notes"
```

### Default sandbox policy (when --sandbox with no --sandbox-allow)

| Capability | Default |
|------------|---------|
| Filesystem read | Deny sensitive paths (~/.ssh, ~/.aws, ~/.gnupg, ~/.config) |
| Filesystem write | Deny all except /tmp |
| Network outbound | Deny all |
| Process spawning | Deny |

### **[NEW]** State truth table

| `--sandbox` | `--no-sandbox` | config `sandbox` | Result |
|:-----------:|:--------------:|:-----------------:|:------:|
| — | — | — | off |
| — | — | true | on |
| — | yes | true | **off** (--no-sandbox wins) |
| yes | — | — | on |
| yes | — | false | on (CLI wins) |
| yes | yes | — | **error** (contradictory) |

`--sandbox-allow` without `--sandbox` or config: implies `--sandbox`.

## 5. Config Integration

### Tool presets

```toml
# ~/.anima/config.toml

[tools.filesystem]
command = "npx"
args = ["-y", "@anthropic/mcp-filesystem", "--root", "/tmp"]
sandbox = true                       # boolean (was SandboxType enum)
sandbox_allow = ["fs:/tmp"]          # same syntax as CLI flag

[tools.search]
command = "uvx"
args = ["mcp-web-search"]
sandbox = true
sandbox_allow = ["net"]

[tools.github]
command = "npx"
args = ["-y", "@anthropic/mcp-github"]
# sandbox omitted → default false
```

`sandbox_allow` uses the same syntax as `--sandbox-allow`. One format to learn.

### **[NEW]** sandbox_allow merge behavior (CLI + config)

CLI `--sandbox-allow` and config `sandbox_allow` are **unioned** (additive).
Rationale: CLI adds permissions for this invocation; config sets baseline.

```bash
# Config has sandbox_allow = ["fs:/tmp"]
# CLI adds net access for this run
anima run --sandbox --sandbox-allow net --tools filesystem "task"
# Effective: fs:/tmp + net
```

No subtraction. To remove a config permission, edit the config.

### No global [sandbox] config section

anima does not expose a sandbox rules section in config.toml. Rationale:

- Users cannot write correct security policies (they'll miss paths)
- Granular allow_write/deny_read/allowed_domains is srt's vocabulary, not anima's
- Config creates false security (set once, forget, assume protected)

### Precedence

```
--no-sandbox          → off (overrides everything)
--sandbox             → on (CLI override)
config tools.X.sandbox → on for that tool preset
default               → off
```

No multi-level priority chain. No PersonaSpec participation. No floor semantics.

## 6. UX Specification

### 6.1 Banner (sandbox active)

**Normal mode** — 1 dim line:

```
 sandbox  MCP servers sandboxed (network blocked, fs restricted)
```

**--quiet mode** — no banner (errors still show).

**--verbose mode** — full 3-line banner:

```
 sandbox  MCP servers running in sandbox
          Network: blocked  |  Filesystem: restricted  |  Secrets: hidden
          Disable with --no-sandbox
```

### 6.2 Blocked action (network — srt CAN detect)

srt's proxy intercepts network calls and reports the domain.

```
 sandbox  Server "search" denied network access to api.brave.com
          Add --sandbox-allow net:api.brave.com to permit.
```

### 6.3 Blocked action (filesystem — srt CANNOT detect precisely)

OS kernel returns EPERM. MCP server sees "permission denied".
Agent sees tool error. anima cannot determine it was sandbox-caused.

**No sandbox-specific error message is possible for filesystem blocks.**

The dim one-liner from 6.1 serves as the user's hint. When tools fail
unexpectedly and the user sees "sandbox" in their terminal, they can
try `--no-sandbox` to diagnose.

### 6.4 Detection capability summary

| Block type | Detectable by srt | User experience |
|------------|-------------------|-----------------|
| Network (domain denied) | Yes (proxy intercept) | Precise error + fix command |
| Network (all blocked) | Yes (proxy intercept) | Precise error + fix command |
| Filesystem (read denied) | No (kernel EPERM) | MCP server error, user infers from banner |
| Filesystem (write denied) | No (kernel EPERM) | MCP server error, user infers from banner |

## 7. Scope of sandbox

Sandbox wraps **MCP stdio server processes only**.

| MCP source | Sandboxed? | Reason |
|------------|-----------|--------|
| `--mcp "command args"` (stdio) | Yes | Arbitrary host command |
| `--mcp https://...` (SSE) | No | Already remote, no local exec |
| `--tools preset` (resolves to stdio) | Yes | Same as --mcp stdio |
| Agent process itself | No | Out of scope (opifex agent-process sandbox is separate) |

**[NEW]** PersonaSpec.sandbox field: type changes from `SandboxType` to
`str | None` (free-form, reserved for opifex agent-process sandbox concept).
Does NOT participate in MCP sandbox decisions.

## 8. Implementation: what changes

### Delete (~400 lines)

| Artifact | Location |
|----------|----------|
| `SandboxType` enum (docker/bubblewrap/none) | `core/schemas/enums.py` |
| `SandboxConfig` TypedDict | `core/sandbox.py` |
| `WrappedCommand` TypedDict | `core/sandbox.py` |
| `_build_docker_args()` | `core/sandbox.py` |
| `_build_bwrap_args()` | `core/sandbox.py` |
| `wrap_for_sandbox()` | `core/sandbox.py` |
| `make_sandbox_config()` | `core/sandbox.py` |
| `_resolve_sandbox()` 4-level chain | `shell/cli_commands/run_spawn.py` |
| Duplicated sandbox resolution | `shell/cli_commands/run_spawn.py` (L416-438, L673-685) |
| **[NEW]** Sandbox resolution in agent_runner | `shell/agent_runner.py` (L198-223) |
| `apply_sandbox_to_mcp()` | `shell/mcp_servers.py` |
| **[NEW]** `ResolvedTool.sandbox: str \| None` | `core/tool_resolver.py` |
| `--sandbox` Choice param (docker/bubblewrap/none) | `shell/cli.py` |
| **[NEW]** `TOOL_PRESET_TEMPLATES[*].sandbox` values | `shell/cli_commands/tools_cmd.py` |

### New/Modified

| Artifact | Location | Description |
|----------|----------|-------------|
| `SandboxPolicy` dataclass | `core/sandbox.py` | Holds parsed capabilities |
| `parse_sandbox_allow()` | `core/sandbox.py` | Parse "fs:/tmp", "net", "net:domain" |
| `merge_sandbox_allows()` | `core/sandbox.py` | Union CLI + config allows |
| `build_srt_config()` | `core/sandbox.py` | Translate policy → srt config dict |
| `wrap_command()` | `core/sandbox.py` | Call srt to wrap command |
| `--sandbox` flag | `shell/cli.py` | Boolean flag (is_flag=True) |
| `--no-sandbox` flag | `shell/cli.py` | Boolean flag |
| `--sandbox-allow` option | `shell/cli.py` | Repeatable string option |
| `ToolPresetConfig.sandbox` | `core/schemas/models.py` | `bool` (was `SandboxType`) |
| `ToolPresetConfig.sandbox_allow` | `core/schemas/models.py` | `list[str]` |
| `PersonaSpec.sandbox` | `core/schemas/models.py` | `str \| None` (was `SandboxType`) |
| Sandbox banner output | `shell/cli_commands/run_spawn.py` | Dim one-liner / verbose banner |
| Network block handler | `shell/cli_commands/run_spawn.py` | Capture srt violation, show fix |

### core/sandbox.py (new, ~60 lines)

```python
"""Sandbox command wrapping via srt.

anima translates capability flags into srt configuration.
srt handles OS-level enforcement (macOS seatbelt, Linux bubblewrap).
"""

from dataclasses import dataclass, field


@dataclass
class SandboxPolicy:
    """Parsed sandbox capabilities from CLI/config."""
    enabled: bool = False
    allow_fs: list[str] = field(default_factory=list)    # paths
    allow_net: list[str] = field(default_factory=list)    # domains, or ["*"]


def parse_sandbox_allow(allows: list[str]) -> SandboxPolicy:
    """Parse --sandbox-allow strings into SandboxPolicy.

    Valid formats: "fs:<path>", "net", "net:<domain>"
    Invalid strings raise ValueError.
    Empty list → enabled policy with no extra permissions.
    """
    ...


def merge_sandbox_allows(
    cli_allows: list[str], config_allows: list[str]
) -> list[str]:
    """Union CLI and config sandbox_allow lists (additive)."""
    ...


def build_srt_config(policy: SandboxPolicy) -> dict:
    """Translate SandboxPolicy → srt configuration dict."""
    ...


def wrap_command(command: list[str], policy: SandboxPolicy) -> list[str]:
    """Wrap command for sandboxed execution via srt.

    Requires sandbox-runtime package. Raises ImportError if missing.
    """
    ...
```

### **[NEW]** parse_sandbox_allow edge cases

| Input | Result |
|-------|--------|
| `"fs:/tmp"` | `allow_fs=["/tmp"]` |
| `"fs:"` | **ValueError** (empty path) |
| `"net"` | `allow_net=["*"]` |
| `"net:api.brave.com"` | `allow_net=["api.brave.com"]` |
| `"net:"` | **ValueError** (empty domain) |
| `""` | **ValueError** (empty string) |
| `"disk:/tmp"` | **ValueError** (unknown capability) |

## 9. Risks

| Risk | Severity | Mitigation |
|------|----------|------------|
| srt is Beta (0.2.0), API may change | Medium | Thin shim isolates anima from srt internals |
| macOS sandbox-exec deprecated | Low | Apple still uses it internally; macOS 26 containers may replace |
| Filesystem blocks undetectable | Medium | Dim banner hints user; document in CLI help |
| srt not available in CI/Docker | Low | `--no-sandbox` escape; document CI usage |

## 10. Open Questions

1. ~~**`--sandbox-allow` applies to all servers or preceding `--mcp`?**~~
   **Resolved:** Global. Threat model is agent, not tools (see Section 2).

2. ~~**PyPI community port vs npm srt CLI?**~~
   **Resolved:** PyPI `sandbox-runtime` is Anthropic-authored. Use as Python library.

3. **`--tools` naming conflict with LLM "tool" concept?**
   Identified but not resolved in this design. Separate decision.

## 11. Decision Log

| Round | Decision | Rationale |
|-------|----------|-----------|
| 1 | Remove PersonaSpec.sandbox from MCP decisions | Different concern (agent process vs MCP tool) |
| 2 | Default off | Can't guarantee good filesystem error UX |
| 3 | srt as backend | Cross-platform, lightweight, built for MCP |
| 4 | Don't expose srt config to users | anima is orchestrator, not policy engine |
| 5 | Deno-style capability flags | Proven UX pattern, progressive disclosure |
| 6 | 1-dim-line banner, expand on error | Balance awareness vs noise |
| 7 | Filesystem blocks: accept detection limit | Honest about what's possible |
| 8 | **[NEW]** Global --sandbox-allow | Agent is threat model, not tools; per-server doesn't help |
| 9 | **[NEW]** CLI + config allows are unioned | Additive is simpler, no subtraction semantics |
