# agentsh-secure-sandbox-python — Technical Specification

**Version:** 0.1.0
**Date:** March 2026
**Status:** Draft
**Package name:** `agentsh-secure-sandbox`
**Python:** 3.11+

---

## 1. Overview

`agentsh-secure-sandbox` is a Python library that installs and configures
[agentsh](https://github.com/canyonroad/agentsh) inside any supported AI
sandbox provider, giving developers syscall-level policy enforcement with
minimal code changes.

The library ships two interfaces:

1. **Standalone** — `secure_sandbox()` returns a `SecuredSandbox` that works
   with any agent framework or no framework at all.
2. **Pydantic AI Toolset** — `SecureSandboxToolset` is a ready-made
   `AbstractToolset` that plugs directly into Pydantic AI agents.

Both interfaces share the same core: adapter protocol, provisioning engine,
policy schema, and runtime wrapper. The Toolset is a thin layer that maps
`SecuredSandbox` operations to Pydantic AI tool definitions.

The library targets **AI sandboxing solutions only**: Vercel Sandbox, E2B,
Daytona, Cloudflare Containers, Blaxel, and Sprites (Fly.io). Docker and SSH are out of scope.

### 1.1 What it does

1. Normalizes sandbox provider APIs behind a common adapter protocol.
2. Provisions agentsh inside the sandbox (install binary, write policy, start
   server, install shell shim) — all before the agentic loop begins.
3. Returns a `SecuredSandbox` whose runtime methods route ALL operations
   through agentsh — never through raw provider APIs.

### 1.2 Security boundary statement

This library governs runtime operations that flow through agentsh. It does
not replace the isolation guarantees of the underlying sandbox provider.
The sandbox provider handles host-level isolation (VM boundary, network
namespace, filesystem mount). agentsh handles per-operation policy
enforcement within that boundary (which files, which commands, which
network destinations, with what verdicts).

### 1.3 Prerequisites

Requires **agentsh v0.14.0+** with support for:

- Multi-layer policy evaluation (`system_dir` config option)
- Security modes (`full`, `landlock`, `landlock-only`, `minimal`) with
  auto-detection via `agentsh detect`
- Enforced redirects
- Path canonicalization (symlink resolution before policy evaluation)
- Transparent command unwrapping (`env`, `sudo`, `nice`, `nohup`, etc.)
- W3C trace context propagation
- Package install security scanning

### 1.4 Sandbox prerequisites

For the `download` install strategy, the sandbox must have:

- `curl` or `wget` — for downloading the agentsh binary
- `tar` — for extracting the downloaded archive
- `sha256sum`, `shasum`, or `openssl` — for checksum verification

The `upload` and `preinstalled` strategies have no tool requirements.

### 1.5 Relationship to TypeScript library

This library is a Python port of
[`@agentsh/secure-sandbox`](https://github.com/canyonroad/agentsh-secure-sandbox).
The two libraries share:

- Intended behavioral parity for adapter protocol semantics
- Same provisioning sequence
- Same policy schema concepts and YAML serialization format
- Same default policy rules where the underlying APIs allow
- Same system policy for self-protection

They differ in:

- Language (Python vs TypeScript)
- Validation (Pydantic models vs Zod schemas)
- Framework integration (Pydantic AI Toolset vs Vercel AI SDK tools)
- Package distribution (PyPI vs npm)

---

## 2. Design Principles

**P1 — Sandbox-agnostic.** The library does not force a specific sandbox
provider. It ships adapters for supported providers and accepts custom
adapters conforming to the `SandboxAdapter` protocol.

**P2 — Optional dependencies.** Installing `agentsh-secure-sandbox` does not
pull in `e2b`, `@daytonaio/sdk`, or any provider SDK. The developer installs
only the provider they use. Provider SDKs are declared as optional extras.

**P3 — Policy is code, frozen before execution.** Policy is defined as Python
dataclasses/Pydantic models, validated at definition time, serialized to
YAML, written as a root-owned read-only file inside the sandbox, and
protected by agentsh's system policy layer. The agent cannot modify policy
at runtime.

**P4 — Minimal surface.** The public API is one function (`secure_sandbox`),
one class (`SecureSandboxToolset`), a set of adapter functions, a set of
policy presets, and one protocol (`SecuredSandbox`).

**P5 — Shared core, provider-specific adapters.** All providers share the
same provisioning engine, runtime wrapper, policy schema, and serialization.
Adapters are thin translation layers (typically 30-60 lines) that map
provider SDK calls to the `SandboxAdapter` protocol.

**P6 — Provisioning and runtime are separate channels.** The adapter (raw
provider API) is used only during provisioning. At runtime, all operations
flow through `agentsh exec` inside the sandbox.

---

## 3. Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│  Application Layer                                              │
│                                                                 │
│  ┌─────────────────────┐     ┌──────────────────────────────┐   │
│  │  secure_sandbox()   │     │  SecureSandboxToolset         │   │
│  │  (standalone)       │     │  (Pydantic AI)               │   │
│  └────────┬────────────┘     └──────────────┬───────────────┘   │
│           │                                 │                   │
│           └──────────┬──────────────────────┘                   │
│                      ▼                                          │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  SecuredSandbox (runtime interface)                     │    │
│  │  .exec()  .write_file()  .read_file()  .stop()         │    │
│  └──────────────────────┬──────────────────────────────────┘    │
│                         │                                       │
│  ┌──────────────────────▼──────────────────────────────────┐    │
│  │  Core Engine                                            │    │
│  │  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐  │    │
│  │  │provision │ │ runtime  │ │ policy   │ │ integrity │  │    │
│  │  │          │ │          │ │ schema + │ │ checksum  │  │    │
│  │  │          │ │          │ │ serialize│ │ + verify  │  │    │
│  │  └──────────┘ └──────────┘ └──────────┘ └───────────┘  │    │
│  └──────────────────────┬──────────────────────────────────┘    │
│                         │                                       │
│  ┌──────────────────────▼──────────────────────────────────┐    │
│  │  SandboxAdapter Protocol                                │    │
│  │  .exec()  .write_file()  .read_file()  .stop()          │    │
│  └──┬──────────┬──────────┬──────────┬──────────┬──────────┬─────┘    │
│     │          │          │          │          │          │          │
│  ┌──▼──┐  ┌───▼──┐  ┌───▼───┐  ┌───▼────┐  ┌─▼─────┐  ┌▼───────┐  │
│  │Vercel│  │ E2B  │  │Daytona│  │Cloudfl.│  │Blaxel │  │Sprites │  │
│  └──────┘  └──────┘  └───────┘  └────────┘  └───────┘  └────────┘  │
└─────────────────────────────────────────────────────────────────┘
```

---

## 4. Package Structure

```
agentsh-secure-sandbox/
├── pyproject.toml
├── src/
│   └── agentsh_secure_sandbox/
│       ├── __init__.py              # Public API re-exports
│       ├── _api.py                  # secure_sandbox() entry point
│       ├── _version.py              # __version__
│       ├── py.typed                 # PEP 561 marker for mypy/pyright
│       │
│       ├── core/
│       │   ├── __init__.py
│       │   ├── types.py             # Protocols + dataclasses
│       │   ├── provision.py         # Bootstrap engine
│       │   ├── runtime.py           # SecuredSandbox implementations
│       │   ├── config.py            # Server config generation
│       │   ├── integrity.py         # Checksums + binary URLs
│       │   ├── shell.py             # Shell escaping
│       │   ├── trace.py            # Trace context auto-resolution
│       │   └── errors.py            # Exception hierarchy
│       │
│       ├── adapters/
│       │   ├── __init__.py          # Adapter re-exports
│       │   ├── vercel.py
│       │   ├── e2b.py
│       │   ├── daytona.py
│       │   ├── cloudflare.py
│       │   └── blaxel.py
│       │
│       ├── policies/
│       │   ├── __init__.py          # Policy re-exports
│       │   ├── schema.py            # Pydantic models
│       │   ├── presets.py           # agent_default, dev_safe, etc.
│       │   ├── serialize.py         # PolicyDefinition -> YAML
│       │   └── merge.py             # merge(), merge_prepend()
│       │
│       ├── toolset/
│       │   ├── __init__.py
│       │   └── pydantic_ai.py       # SecureSandboxToolset
│       │
│       └── testing/
│           ├── __init__.py
│           └── mock.py              # mock_secured_sandbox()
│
└── tests/
    ├── test_api.py
    ├── test_provision.py
    ├── test_runtime.py
    ├── test_policies.py
    ├── test_adapters.py
    ├── test_toolset.py
    └── e2e/
        ├── test_vercel.py
        ├── test_e2b.py
        └── test_daytona.py
```

### 4.1 Dependencies

```toml
[project]
name = "agentsh-secure-sandbox"
requires-python = ">=3.11"
dependencies = [
    "pydantic>=2.0",
    "pyyaml>=6.0",
    "httpx>=0.27",       # For upload strategy (host-side download)
]

[project.optional-dependencies]
pydantic-ai = ["pydantic-ai>=0.2"]
trace = ["opentelemetry-api>=1.20"]
logfire = ["pydantic-ai>=0.2", "logfire>=2.0", "opentelemetry-api>=1.20"]
vercel = []              # No Python SDK yet — adapter uses generic protocol
e2b = ["e2b>=1.0"]
daytona = ["daytona-sdk>=0.10"]
blaxel = ["blaxel-core>=0.1"]
sprites = ["sprites-py>=0.0.1rc30"]
dev = [
    "pytest>=8.0",
    "pytest-asyncio>=0.24",
    "ruff>=0.8",
    "mypy>=1.13",
]
```

---

## 5. Core Types

### 5.1 Result Types

```python
# core/types.py

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable

@dataclass(frozen=True, slots=True)
class ExecResult:
    stdout: str
    stderr: str
    exit_code: int

@dataclass(frozen=True, slots=True)
class WriteFileSuccess:
    success: Literal[True]
    path: str

@dataclass(frozen=True, slots=True)
class WriteFileFailure:
    success: Literal[False]
    path: str
    error: str

WriteFileResult = WriteFileSuccess | WriteFileFailure

@dataclass(frozen=True, slots=True)
class ReadFileSuccess:
    success: Literal[True]
    path: str
    content: str

@dataclass(frozen=True, slots=True)
class ReadFileFailure:
    success: Literal[False]
    path: str
    error: str

ReadFileResult = ReadFileSuccess | ReadFileFailure
```

### 5.2 Enums

```python
SecurityMode = Literal["full", "landlock", "landlock-only", "minimal"]
InstallStrategy = Literal["preinstalled", "download", "upload", "running"]
```

### 5.3 SandboxAdapter Protocol

This is the shared abstraction that all providers implement. It is
intentionally minimal: `exec`, `write_file`, `read_file`, and optional
`stop` and `file_exists`.

```python
@dataclass
class ExecOpts:
    cwd: str | None = None
    sudo: bool = False
    detached: bool = False

@runtime_checkable
class SandboxAdapter(Protocol):
    """
    Minimal interface to a sandbox environment.

    During provisioning: used for installing binary, starting server,
    creating session, health checks.

    At runtime: used as transport for `agentsh exec $SID -- <command>`.
    """

    async def exec(
        self,
        cmd: str,
        args: list[str] | None = None,
        opts: ExecOpts | None = None,
    ) -> ExecResult: ...

    async def write_file(
        self,
        path: str,
        content: str | bytes,
        *,
        sudo: bool = False,
    ) -> None: ...

    async def read_file(self, path: str) -> str: ...

    async def stop(self) -> None: ...

    async def file_exists(self, path: str) -> bool: ...
```

The `stop` and `file_exists` methods have default implementations in
the base. `file_exists` falls back to `exec('test', ['-f', path])`.
`stop` is a no-op by default.

To provide these defaults while keeping the Protocol-based duck typing,
we also ship an abstract base class:

```python
class SandboxAdapterBase:
    """
    Optional base class with default implementations.
    Adapters can inherit from this or just satisfy SandboxAdapter protocol.
    """

    async def exec(
        self,
        cmd: str,
        args: list[str] | None = None,
        opts: ExecOpts | None = None,
    ) -> ExecResult:
        raise NotImplementedError

    async def write_file(
        self, path: str, content: str | bytes, *, sudo: bool = False,
    ) -> None:
        raise NotImplementedError

    async def read_file(self, path: str) -> str:
        raise NotImplementedError

    async def stop(self) -> None:
        pass

    async def file_exists(self, path: str) -> bool:
        result = await self.exec("test", ["-f", path])
        return result.exit_code == 0
```

### 5.4 SecuredSandbox Protocol

```python
@runtime_checkable
class SecuredSandbox(Protocol):
    """
    A sandbox with agentsh policy enforcement active.

    Every operation routes through agentsh. The agent cannot
    bypass policy from userspace.
    """

    @property
    def session_id(self) -> str: ...

    @property
    def security_mode(self) -> SecurityMode: ...

    async def exec(
        self,
        command: str,
        *,
        cwd: str | None = None,
        timeout: float | None = None,
    ) -> ExecResult: ...

    async def write_file(self, path: str, content: str | bytes) -> WriteFileResult: ...

    async def read_file(self, path: str) -> ReadFileResult: ...

    async def stop(self) -> None: ...
```

### 5.5 SecureConfig

```python
from agentsh_secure_sandbox.policies.schema import PolicyDefinition
from pydantic import BaseModel, ConfigDict

class ThreatFeed(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str
    url: str
    format: Literal["hostfile", "domain-list"]
    refresh_interval: str = "6h"

class ThreatFeedsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    feeds: list[ThreatFeed]
    action: Literal["deny", "audit"] = "deny"
    allowlist: list[str] | None = None

class SecureConfig(BaseModel):
    """Configuration for secure_sandbox()."""
    model_config = ConfigDict(extra="forbid")

    policy: PolicyDefinition | None = None
    workspace: str = "/workspace"
    watchtower: str | None = None
    install_strategy: InstallStrategy = "download"
    agentsh_version: str | None = None        # Default: pinned per release
    agentsh_arch: Literal["linux_amd64", "linux_arm64"] | None = None
    agentsh_binary_url: str | None = None
    agentsh_checksum: str | None = None
    skip_integrity_check: bool = False
    minimum_security_mode: SecurityMode | None = None
    real_paths: bool | None = None
    """
    FUSE-based path canonicalization.
    - None (default): Auto-detected during provisioning. Enabled when
      security mode is ``full`` (FUSE available), disabled otherwise.
    - True/False: Override auto-detection.
    """
    enforce_redirects: bool = False
    trace_parent: str | Literal[False] | None = None
    """
    Trace context propagation mode.
    - None (default): Auto-resolve. Tries active OTel span first (works
      automatically with Logfire), then TRACEPARENT env var, then skips.
    - A W3C traceparent string: Use verbatim.
    - False: Disable trace propagation entirely.
    See TRACE-PROPAGATION.md for full design.
    """
    policy_name: str = "policy"
    threat_feeds: ThreatFeedsConfig | Literal[False] | None = None
```

---

## 6. Adapters

Each adapter is a factory function that takes a raw provider SDK object
and returns a `SandboxAdapter`. Adapters are thin: they translate between
the provider's API shape and the protocol's `exec/write_file/read_file`
contract.

### 6.1 Adapter contract

All adapters must handle:

- **`exec` with `detached=True`**: Fire-and-forget for daemon processes
  (agentsh server startup). Must not block. Return immediately with
  `ExecResult(stdout="", stderr="", exit_code=0)`. Implementation: use
  the provider's native background/async mode if available (e.g., E2B's
  `background` param). Fallback: `nohup ... > /dev/null 2>&1 &`.
- **`exec` with `sudo=True`**: Request elevated privileges. Provider-specific
  mapping: E2B uses `user='root'`, Daytona prefixes `sudo`, Cloudflare
  runs as root (flag is silently ignored). If a provider cannot honor sudo,
  the adapter should attempt the command without elevation and let agentsh
  provisioning fail naturally if root access was truly required.
- **Non-zero exit codes**: Never raise exceptions for non-zero exits.
  Return the `ExecResult` with the actual exit code. Some provider SDKs
  throw on non-zero (E2B's `CommandExitError`, Daytona's `DaytonaError`) —
  adapters must catch and normalize.
- **`write_file` with binary content**: Accept both `str` and `bytes`.
  For providers without native binary upload, use base64 encoding via
  shell command.
- **Timeout behavior**: The adapter protocol does not include a timeout
  parameter on `exec`. Timeouts are handled at the `SecuredSandbox` level
  (via asyncio task cancellation) or by the provider SDK's built-in
  timeout. Adapters should use provider-native timeouts where available.
- **stdout/stderr normalization**: Adapters must return `str` for both
  stdout and stderr, never `None`. Use `result.stdout or ""` normalization.
  Known limitation: Daytona's SDK mixes stdout and stderr into a single
  stream. The Daytona adapter captures stderr via a temp file workaround
  but this is imperfect. Document this as a known limitation.

### 6.1.1 Provider maturity

| Provider | Status | Notes |
|----------|--------|-------|
| E2B | Stable | Async SDK (`AsyncSandbox.create()`), native file I/O |
| Daytona | Stable | Known stderr limitation |
| Cloudflare | Stable | Runs as root, no sudo needed |
| Blaxel | Stable | Standard pattern |
| Sprites | Stable | Fly.io Firecracker microVMs. Sync-first SDK wrapped via `run_in_executor`. |
| Vercel | **Experimental** | No Python SDK. Adapter assumes a compatible wrapper. |

Vercel Sandbox currently has a TypeScript SDK only. The Vercel adapter
specifies the required wrapper interface in its docstring. It will be
promoted to stable when either Vercel ships a Python SDK or we ship a
thin HTTP client adapter for the Vercel Sandbox REST API.

### 6.2 Vercel Sandbox

```python
# adapters/vercel.py

from __future__ import annotations
from typing import Any
from ..core.types import SandboxAdapter, ExecResult, ExecOpts

def vercel(sandbox: Any) -> SandboxAdapter:
    """
    Adapter for @vercel/sandbox.

    The Vercel Sandbox SDK is TypeScript-native. This adapter targets
    a Python-compatible wrapper or a generic HTTP client that speaks
    the Vercel Sandbox API. If using from Python, the developer must
    provide an object with:
      - sandbox.run_command(cmd, args, **kwargs) -> result
      - sandbox.write_files(files) -> None
      - sandbox.read_file(path) -> stream/str
      - sandbox.stop() -> None
    """
    return _VercelAdapter(sandbox)

class _VercelAdapter:
    def __init__(self, sandbox: Any) -> None:
        self._sb = sandbox

    async def exec(
        self,
        cmd: str,
        args: list[str] | None = None,
        opts: ExecOpts | None = None,
    ) -> ExecResult:
        opts = opts or ExecOpts()
        params: dict[str, Any] = {"cmd": cmd, "args": args or []}
        if opts.cwd:
            params["cwd"] = opts.cwd
        if opts.sudo:
            params["sudo"] = True
        if opts.detached:
            params["detached"] = True

        result = await self._sb.run_command(**params)

        if opts.detached:
            return ExecResult(stdout="", stderr="", exit_code=0)

        stdout = result.stdout
        if callable(stdout):
            stdout = await stdout()
        stderr = result.stderr
        if callable(stderr):
            stderr = await stderr()

        return ExecResult(
            stdout=stdout or "",
            stderr=stderr or "",
            exit_code=result.exit_code,
        )

    async def write_file(
        self, path: str, content: str | bytes, *, sudo: bool = False
    ) -> None:
        # sudo is not supported by Vercel's file API — silently ignored.
        data = content if isinstance(content, bytes) else content.encode()
        await self._sb.write_files([{"path": path, "content": data}])

    async def read_file(self, path: str) -> str:
        stream = await self._sb.read_file(path)
        if hasattr(stream, "__aiter__"):
            chunks: list[bytes] = []
            async for chunk in stream:
                chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode())
            return b"".join(chunks).decode("utf-8")
        return str(stream)

    async def stop(self) -> None:
        await self._sb.stop()

    async def file_exists(self, path: str) -> bool:
        result = await self.exec("test", ["-f", path])
        return result.exit_code == 0
```

### 6.3 E2B

```python
# adapters/e2b.py

from __future__ import annotations
from typing import Any
from ..core.types import ExecResult, ExecOpts
from ..core.shell import shell_escape, fire_and_forget

def e2b(sandbox: Any) -> _E2BAdapter:
    return _E2BAdapter(sandbox)

class _E2BAdapter:
    def __init__(self, sandbox: Any) -> None:
        self._sb = sandbox

    async def exec(
        self,
        cmd: str,
        args: list[str] | None = None,
        opts: ExecOpts | None = None,
    ) -> ExecResult:
        opts = opts or ExecOpts()
        command = shell_escape(cmd, args)
        user = "root" if opts.sudo else "user"

        try:
            if opts.detached:
                # Use E2B's native background mode instead of nohup shell trick
                fire_and_forget(
                    self._sb.commands.run(
                        command, background=True, cwd=opts.cwd, user=user,
                    )
                )
                return ExecResult(stdout="", stderr="", exit_code=0)

            result = await self._sb.commands.run(
                command, cwd=opts.cwd, user=user,
            )
            return ExecResult(
                stdout=result.stdout or "",
                stderr=result.stderr or "",
                exit_code=result.exit_code,
            )
        except Exception as e:
            # E2B throws CommandExitError for non-zero exits
            return ExecResult(
                stdout=getattr(e, "stdout", "") or "",
                stderr=getattr(e, "stderr", "") or getattr(e, "message", str(e)),
                exit_code=getattr(e, "exit_code", 1),
            )

    async def write_file(
        self, path: str, content: str | bytes, *, sudo: bool = False
    ) -> None:
        # Use user="root" for privileged paths during provisioning
        user = "root" if sudo else "user"
        await self._sb.files.write(path, content, user=user)

    async def read_file(self, path: str) -> str:
        return await self._sb.files.read(path)

    async def stop(self) -> None:
        await self._sb.kill()

    async def file_exists(self, path: str) -> bool:
        result = await self.exec("test", ["-f", path])
        return result.exit_code == 0
```

### 6.4 Daytona

```python
# adapters/daytona.py

from __future__ import annotations
from typing import Any
from ..core.types import ExecResult, ExecOpts
from ..core.shell import shell_escape, fire_and_forget

def daytona(sandbox: Any) -> _DaytonaAdapter:
    return _DaytonaAdapter(sandbox)

class _DaytonaAdapter:
    def __init__(self, sandbox: Any) -> None:
        self._sb = sandbox

    async def exec(
        self,
        cmd: str,
        args: list[str] | None = None,
        opts: ExecOpts | None = None,
    ) -> ExecResult:
        opts = opts or ExecOpts()
        raw = shell_escape(cmd, args)
        command = f"sudo {raw}" if opts.sudo else raw

        try:
            if opts.detached:
                fire_and_forget(
                    self._sb.process.execute_command(
                        f"nohup {command} > /dev/null 2>&1 &", opts.cwd
                    )
                )
                return ExecResult(stdout="", stderr="", exit_code=0)

            # Known limitation: Daytona SDK mixes stdout and stderr into
            # result.result. stderr is not available separately. The returned
            # ExecResult.stdout contains the combined output, stderr is "".
            result = await self._sb.process.execute_command(command, opts.cwd)
            return ExecResult(
                stdout=result.result or "",
                stderr="",
                exit_code=result.exit_code,
            )
        except Exception as e:
            return ExecResult(
                stdout="",
                stderr=str(e),
                exit_code=getattr(e, "exit_code", 1),
            )

    async def write_file(
        self, path: str, content: str | bytes, *, sudo: bool = False
    ) -> None:
        data = content if isinstance(content, bytes) else content.encode()
        await self._sb.fs.upload_file(data, path)

    async def read_file(self, path: str) -> str:
        return await self._sb.fs.download_file(path)

    async def stop(self) -> None:
        # Daytona sandbox lifecycle is managed by the Daytona client,
        # not the sandbox object. This is a no-op. The caller must
        # call client.remove(sandbox) separately if full teardown is needed.
        pass

    async def file_exists(self, path: str) -> bool:
        result = await self.exec("test", ["-f", path])
        return result.exit_code == 0
```

### 6.5 Cloudflare Containers

```python
# adapters/cloudflare.py

from __future__ import annotations
import base64
from typing import Any
from ..core.types import ExecResult, ExecOpts
from ..core.shell import shell_escape, fire_and_forget

def cloudflare(sandbox: Any) -> _CloudflareAdapter:
    return _CloudflareAdapter(sandbox)

class _CloudflareAdapter:
    def __init__(self, sandbox: Any) -> None:
        self._sb = sandbox

    async def exec(
        self,
        cmd: str,
        args: list[str] | None = None,
        opts: ExecOpts | None = None,
    ) -> ExecResult:
        opts = opts or ExecOpts()
        command = shell_escape(cmd, args)
        # Cloudflare runs as root — sudo flag is silently ignored.

        if opts.detached:
            fire_and_forget(
                self._sb.exec(f"nohup {command} > /dev/null 2>&1 &", cwd=opts.cwd)
            )
            return ExecResult(stdout="", stderr="", exit_code=0)

        result = await self._sb.exec(command, cwd=opts.cwd)
        return ExecResult(
            stdout=result.stdout or "",
            stderr=result.stderr or "",
            exit_code=result.exit_code,
        )

    async def write_file(
        self, path: str, content: str | bytes, *, sudo: bool = False
    ) -> None:
        data = content if isinstance(content, bytes) else content.encode()
        b64 = base64.b64encode(data).decode("ascii")
        # Note: base64 shell argument limits this to files ~96KB (128KB ARG_MAX).
        # For larger files, use chunked writes via multiple exec calls.
        cmd = shell_escape(
            "sh", ["-c", 'printf "%s" "$1" | base64 -d > "$2"', "_", b64, path]
        )
        result = await self._sb.exec(cmd)
        if (result.exit_code or 0) != 0:
            raise RuntimeError(
                f"writeFile failed (exit {result.exit_code}): {result.stderr or ''}"
            )

    async def read_file(self, path: str) -> str:
        cmd = shell_escape("cat", [path])
        result = await self._sb.exec(cmd)
        if (result.exit_code or 0) != 0:
            raise RuntimeError(
                f"readFile failed (exit {result.exit_code}): {result.stderr or ''}"
            )
        return result.stdout or ""

    async def stop(self) -> None:
        pass  # Cloudflare manages container lifecycle

    async def file_exists(self, path: str) -> bool:
        result = await self.exec("test", ["-f", path])
        return result.exit_code == 0
```

### 6.6 Blaxel

```python
# adapters/blaxel.py

from __future__ import annotations
from typing import Any
from ..core.types import ExecResult, ExecOpts
from ..core.shell import shell_escape, fire_and_forget

def blaxel(sandbox: Any) -> _BlaxelAdapter:
    return _BlaxelAdapter(sandbox)

class _BlaxelAdapter:
    def __init__(self, sandbox: Any) -> None:
        self._sb = sandbox

    async def exec(
        self,
        cmd: str,
        args: list[str] | None = None,
        opts: ExecOpts | None = None,
    ) -> ExecResult:
        opts = opts or ExecOpts()
        command = shell_escape(cmd, args)
        full_cmd = f"sudo {command}" if opts.sudo else command

        try:
            if opts.detached:
                fire_and_forget(
                    self._sb.exec(f"nohup {full_cmd} > /dev/null 2>&1 &", cwd=opts.cwd)
                )
                return ExecResult(stdout="", stderr="", exit_code=0)

            result = await self._sb.exec(full_cmd, cwd=opts.cwd)
            return ExecResult(
                stdout=result.stdout or "",
                stderr=result.stderr or "",
                exit_code=result.exit_code,
            )
        except Exception as e:
            return ExecResult(
                stdout="",
                stderr=str(e),
                exit_code=getattr(e, "exit_code", 1),
            )

    async def write_file(
        self, path: str, content: str | bytes, *, sudo: bool = False
    ) -> None:
        await self._sb.files.write(path, content)

    async def read_file(self, path: str) -> str:
        return await self._sb.files.read(path)

    async def stop(self) -> None:
        await self._sb.stop()

    async def file_exists(self, path: str) -> bool:
        result = await self.exec("test", ["-f", path])
        return result.exit_code == 0
```

### 6.7 Sprites (Fly.io)

```python
# adapters/sprites.py

from __future__ import annotations
import asyncio
import base64
from typing import Any
from ..core.types import ExecResult, ExecOpts, SecureConfig
from ..core.shell import shell_escape, fire_and_forget


def sprites(sprite: Any) -> _SpritesAdapter:
    return _SpritesAdapter(sprite)


def sprites_defaults() -> SecureConfig:
    """Return Sprites-optimized defaults for SecureConfig."""
    return SecureConfig(
        install_strategy="preinstalled",
        skip_integrity_check=True,
        real_paths=True,
        workspace="/home/sprite",
    )


class _SpritesAdapter:
    """Wraps a ``sprites-py`` SDK ``Sprite`` instance.

    The sprites-py SDK is sync-first with its own internal event loop,
    so all calls are dispatched via ``run_in_executor`` to avoid
    event loop conflicts with the caller's async loop.
    """

    _CHUNK_SIZE = 48 * 1024  # Max base64 payload per WebSocket command

    def __init__(self, sprite: Any) -> None:
        self._sprite = sprite

    def _sh_sync(self, cmd: str, **opts: Any) -> tuple[str, str, int]:
        """Run a shell command synchronously via sprites-py.

        Manually sets capture flags on the Cmd object and uses the SDK's
        internal ``run_sync`` to execute, ensuring both stdout and stderr
        are captured regardless of exit code.
        """
        from sprites.loop import run_sync

        c = self._sprite.command("sh", "-c", cmd, **opts)
        c._capture_stdout = True
        c._capture_stderr = True
        c._started = True
        try:
            exit_code: int = run_sync(c._run_async(), timeout=c.timeout)
        except Exception as e:
            stdout = getattr(e, "stdout", b"") or b""
            stderr = getattr(e, "stderr", b"") or b""
            code_fn = getattr(e, "exit_code", None)
            exit_code = code_fn() if callable(code_fn) else (code_fn or 1)
            return (
                stdout.decode("utf-8", errors="replace") if isinstance(stdout, bytes) else str(stdout),
                stderr.decode("utf-8", errors="replace") if isinstance(stderr, bytes) else str(stderr),
                exit_code,
            )
        finally:
            c._finished = True

        return (
            c._stdout_data.decode("utf-8", errors="replace"),
            c._stderr_data.decode("utf-8", errors="replace"),
            exit_code,
        )

    async def _sh(self, cmd: str, **opts: Any) -> tuple[str, str, int]:
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, lambda: self._sh_sync(cmd, **opts))

    async def exec(
        self,
        cmd: str,
        args: list[str] | None = None,
        opts: ExecOpts | None = None,
    ) -> ExecResult:
        opts = opts or ExecOpts()
        command = shell_escape(cmd, args)
        full_cmd = f"sudo {command}" if opts.sudo else command

        if opts.detached:
            async def _detached() -> None:
                await self._sh(f"nohup {full_cmd} > /dev/null 2>&1 &")
            fire_and_forget(_detached())
            return ExecResult(stdout="", stderr="", exit_code=0)

        kw: dict[str, Any] = {}
        if opts.cwd:
            kw["cwd"] = opts.cwd
        stdout, stderr, exit_code = await self._sh(full_cmd, **kw)
        # sprites-py may route program output to stderr (fd 2)
        if not stdout and stderr and exit_code == 0:
            stdout = stderr
            stderr = ""
        return ExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code)

    async def write_file(self, path: str, content: str | bytes, *, sudo: bool = False) -> None:
        data = content if isinstance(content, bytes) else content.encode()
        safe_path = path.replace("'", "'\\''")
        if len(data) <= self._CHUNK_SIZE:
            b64 = base64.b64encode(data).decode("ascii")
            _, stderr, exit_code = await self._sh(
                f"printf '%s' '{b64}' | base64 -d > '{safe_path}'"
            )
            if exit_code != 0:
                raise RuntimeError(f"writeFile failed (exit {exit_code}): {stderr}")
        else:
            # Chunked write for large files (e.g. binary upload)
            offset = 0
            first = True
            while offset < len(data):
                chunk = data[offset : offset + self._CHUNK_SIZE]
                b64 = base64.b64encode(chunk).decode("ascii")
                op = ">" if first else ">>"
                _, stderr, exit_code = await self._sh(
                    f"printf '%s' '{b64}' | base64 -d {op} '{safe_path}'"
                )
                if exit_code != 0:
                    raise RuntimeError(f"writeFile failed (exit {exit_code}): {stderr}")
                first = False
                offset += self._CHUNK_SIZE

    async def read_file(self, path: str) -> str:
        safe_path = path.replace("'", "'\\''")
        stdout, stderr, exit_code = await self._sh(f"cat '{safe_path}'")
        if exit_code != 0:
            raise RuntimeError(f"readFile failed (exit {exit_code}): {stderr}")
        return stdout

    async def stop(self) -> None:
        self._sprite.delete()

    async def file_exists(self, path: str) -> bool:
        result = await self.exec("test", ["-f", path])
        return result.exit_code == 0
```

**Key design decisions:**

- **Sync-to-async bridge:** The sprites-py SDK uses its own internal event
  loop in a background daemon thread (`sprites.loop.run_sync`). Direct
  `await` of sprites-py coroutines from the caller's event loop would deadlock.
  All calls go through `run_in_executor` instead.
- **Manual capture flags:** `cmd.output()` only sets `_capture_stdout`.
  The adapter sets both `_capture_stdout` and `_capture_stderr` manually
  to capture stderr (needed for `agentsh detect --output json` which writes
  to stderr).
- **stderr→stdout fallback:** Some sprites-py SDK versions route program
  output to fd 2 instead of fd 1. When stdout is empty, stderr has content,
  and exit code is 0, the adapter swaps them.
- **Chunked writes:** WebSocket messages have size limits (~64KB). Large
  file uploads (e.g. agentsh binary via `upload` install strategy) are
  split into 48KB chunks, each base64-encoded and appended.
- **`preinstalled` default:** Sprites VMs are long-lived and have agentsh
  baked into the image. DNS resolution may not work inside fresh sprites,
  ruling out `download`. The `upload` strategy works with chunked writes
  but `preinstalled` is preferred.

### 6.8 Custom Adapters

Any object satisfying `SandboxAdapter` works:

```python
from agentsh_secure_sandbox import secure_sandbox, SandboxAdapter

class MyCustomAdapter:
    async def exec(self, cmd, args=None, opts=None):
        # Your sandbox's exec implementation
        return ExecResult(stdout="", stderr="", exit_code=0)

    async def write_file(self, path, content, *, sudo=False):
        ...

    async def read_file(self, path):
        ...

    async def stop(self):
        ...

    async def file_exists(self, path):
        ...

sandbox = await secure_sandbox(MyCustomAdapter())
```

---

## 7. Policy Schema

Policies are defined with Pydantic models, validated at construction time,
and serialized to agentsh YAML format. The schema is intended to have
behavioral parity with the TypeScript Zod schema where the underlying
APIs allow.

**Scope:** The public `PolicyDefinition` exposes a curated subset of the
full agentsh policy language — the fields users need to define agent
security rules. Internal-only fields used by the system policy (such as
`name`, `message`, `args_match`) are not part of the public schema. The
`system_policy_yaml()` function emits these as a static YAML string that
users never construct or modify.

**Command matching:** Command deny strings like `"git push --force"` work
because agentsh performs prefix matching on the full command line. The
schema models these as plain strings; the matching semantics are
agentsh's responsibility, not the schema's.

### 7.1 Models

```python
# policies/schema.py

from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, field_validator

FileOp = Literal["read", "write", "create", "delete"]

# All policy models use extra="forbid" to catch typos in policy keys.

# ─── File rules ─────────────────────────────────────────

class FileAllowRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    allow: str | list[str]
    ops: list[FileOp] | None = None

class FileDenyRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    deny: str | list[str]
    ops: list[FileOp] | None = None

class FileRedirectRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    redirect: str | list[str]
    to: str
    ops: list[FileOp] | None = None

class FileAuditRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    audit: str | list[str]
    ops: list[FileOp] | None = None

class FileSoftDeleteRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    soft_delete: str | list[str]

FileRule = FileAllowRule | FileDenyRule | FileRedirectRule | FileAuditRule | FileSoftDeleteRule

# ─── Network rules ──────────────────────────────────────

class NetworkAllowRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    allow: str | list[str]
    ports: list[int] | None = None

    @field_validator("ports", mode="before")
    @classmethod
    def _validate_ports(cls, v):
        if v is not None:
            for p in v:
                if not (1 <= p <= 65535):
                    raise ValueError(f"Port {p} out of range 1-65535")
        return v

class NetworkDenyRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    deny: str | list[str]

class NetworkRedirectRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    redirect: str
    to: str

NetworkRule = NetworkAllowRule | NetworkDenyRule | NetworkRedirectRule

# ─── Command rules ──────────────────────────────────────

class CommandRedirectTarget(BaseModel):
    model_config = ConfigDict(extra="forbid")
    cmd: str
    args: list[str]

class CommandAllowRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    allow: str | list[str]

class CommandDenyRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    deny: str | list[str]

class CommandRedirectRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    redirect: str | list[str]
    to: str | CommandRedirectTarget

CommandRule = CommandAllowRule | CommandDenyRule | CommandRedirectRule

# ─── Env rules ──────────────────────────────────────────

class EnvRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    commands: list[str]
    allow: list[str] | None = None
    deny: list[str] | None = None

# ─── DNS / Connect redirects ────────────────────────────

class DnsRedirect(BaseModel):
    model_config = ConfigDict(extra="forbid")
    match: str
    resolve_to: str

class ConnectRedirect(BaseModel):
    model_config = ConfigDict(extra="forbid")
    match: str
    redirect_to: str

# ─── PolicyDefinition ───────────────────────────────────

class PolicyDefinition(BaseModel):
    model_config = ConfigDict(extra="forbid")
    file: list[FileRule] | None = None
    network: list[NetworkRule] | None = None
    commands: list[CommandRule] | None = None
    env: list[EnvRule] | None = None
    dns: list[DnsRedirect] | None = None
    connect: list[ConnectRedirect] | None = None

def validate_policy(policy: dict | PolicyDefinition) -> PolicyDefinition:
    """Validate and return a PolicyDefinition. Raises ValidationError."""
    if isinstance(policy, PolicyDefinition):
        return policy
    return PolicyDefinition.model_validate(policy)
```

### 7.2 Serialization

Converts `PolicyDefinition` to agentsh YAML format. Intended to produce the same output as
the TypeScript `serializePolicy()`.

```python
# policies/serialize.py

from __future__ import annotations
import yaml
from .schema import (
    PolicyDefinition, FileRule, NetworkRule, CommandRule,
    FileAllowRule, FileDenyRule, FileRedirectRule, FileAuditRule,
    FileSoftDeleteRule, NetworkAllowRule, NetworkDenyRule,
    NetworkRedirectRule, CommandAllowRule, CommandDenyRule,
    CommandRedirectRule, CommandRedirectTarget,
    EnvRule, DnsRedirect, ConnectRedirect,
)


def _as_list(v: str | list[str]) -> list[str]:
    return v if isinstance(v, list) else [v]


def _serialize_file_rules(rules: list[FileRule]) -> list[dict]:
    out: list[dict] = []
    for rule in rules:
        entry: dict = {}
        if isinstance(rule, FileAllowRule):
            entry["paths"] = _as_list(rule.allow)
            entry["decision"] = "allow"
        elif isinstance(rule, FileDenyRule):
            entry["paths"] = _as_list(rule.deny)
            entry["decision"] = "deny"
        elif isinstance(rule, FileRedirectRule):
            entry["paths"] = _as_list(rule.redirect)
            entry["decision"] = "redirect"
            entry["redirect_to"] = rule.to
        elif isinstance(rule, FileAuditRule):
            entry["paths"] = _as_list(rule.audit)
            entry["decision"] = "audit"
        elif isinstance(rule, FileSoftDeleteRule):
            entry["paths"] = _as_list(rule.soft_delete)
            entry["decision"] = "soft-delete"
        if hasattr(rule, "ops") and rule.ops:
            entry["operations"] = rule.ops
        out.append(entry)
    return out


def _serialize_network_rules(rules: list[NetworkRule]) -> list[dict]:
    out: list[dict] = []
    for rule in rules:
        entry: dict = {}
        if isinstance(rule, NetworkAllowRule):
            entry["hosts"] = _as_list(rule.allow)
            entry["decision"] = "allow"
            if rule.ports:
                entry["ports"] = rule.ports
        elif isinstance(rule, NetworkDenyRule):
            entry["hosts"] = _as_list(rule.deny)
            entry["decision"] = "deny"
        elif isinstance(rule, NetworkRedirectRule):
            entry["hosts"] = [rule.redirect]
            entry["decision"] = "redirect"
            entry["redirect_to"] = rule.to
        out.append(entry)
    return out


def _serialize_command_rules(rules: list[CommandRule]) -> list[dict]:
    out: list[dict] = []
    for rule in rules:
        entry: dict = {}
        if isinstance(rule, CommandAllowRule):
            entry["commands"] = _as_list(rule.allow)
            entry["decision"] = "allow"
        elif isinstance(rule, CommandDenyRule):
            entry["commands"] = _as_list(rule.deny)
            entry["decision"] = "deny"
        elif isinstance(rule, CommandRedirectRule):
            entry["commands"] = _as_list(rule.redirect)
            entry["decision"] = "redirect"
            if isinstance(rule.to, CommandRedirectTarget):
                entry["redirect_to"] = {"cmd": rule.to.cmd, "args": rule.to.args}
            else:
                entry["redirect_to"] = rule.to
        out.append(entry)
    return out


def _serialize_env_rules(rules: list[EnvRule]) -> list[dict]:
    out: list[dict] = []
    for rule in rules:
        entry: dict = {"commands": rule.commands}
        if rule.allow:
            entry["allow"] = rule.allow
        if rule.deny:
            entry["deny"] = rule.deny
        out.append(entry)
    return out


def _serialize_dns_redirects(rules: list[DnsRedirect]) -> list[dict]:
    return [{"match": r.match, "resolve_to": r.resolve_to} for r in rules]


def _serialize_connect_redirects(rules: list[ConnectRedirect]) -> list[dict]:
    return [{"match": r.match, "redirect_to": r.redirect_to} for r in rules]


def serialize_policy(policy: PolicyDefinition) -> str:
    """Convert a PolicyDefinition to agentsh YAML format."""
    doc: dict = {"version": 1, "name": "secure-sandbox-policy"}

    if policy.file:
        doc["file_rules"] = _serialize_file_rules(policy.file)
    if policy.network:
        doc["network_rules"] = _serialize_network_rules(policy.network)
    if policy.commands:
        doc["command_rules"] = _serialize_command_rules(policy.commands)
    if policy.env:
        doc["env_rules"] = _serialize_env_rules(policy.env)
    if policy.dns:
        doc["dns_redirects"] = _serialize_dns_redirects(policy.dns)
    if policy.connect:
        doc["connect_redirects"] = _serialize_connect_redirects(policy.connect)

    return yaml.dump(doc, default_flow_style=False, width=1000)

def system_policy_yaml() -> str:
    """Fixed system policy protecting agentsh internals."""
    # Mirrors the TypeScript systemPolicyYaml()
    doc = {
        "version": 1,
        "name": "_system-protection",
        "file_rules": [
            {
                "name": "_system-protect-config",
                "paths": ["/etc/agentsh/**"],
                "operations": ["write", "create", "delete"],
                "decision": "deny",
                "message": "Policy files are immutable during agent execution",
            },
            {
                "name": "_system-protect-binary",
                "paths": ["/usr/local/bin/agentsh*", "/usr/bin/agentsh*"],
                "operations": ["write", "create", "delete"],
                "decision": "deny",
                "message": "agentsh binary is immutable during agent execution",
            },
            {
                "name": "_system-protect-shim-files",
                "paths": ["/usr/bin/agentsh-shell-shim", "/bin/bash", "/bin/sh"],
                "operations": ["write", "create", "delete"],
                "decision": "deny",
                "message": "Shell and shim binaries are immutable during agent execution",
            },
        ],
        "command_rules": [
            {
                "name": "_system-protect-process",
                "commands": ["kill", "killall", "pkill"],
                "args_match": ["agentsh"],
                "decision": "deny",
                "message": "Cannot terminate agentsh processes",
            },
        ],
    }
    return yaml.dump(doc, default_flow_style=False, width=1000)
```

### 7.3 Presets

Four built-in presets, same default rules as the TypeScript library:

```python
# policies/presets.py

from .schema import PolicyDefinition
from .merge import merge

def agent_default(
    extensions: PolicyDefinition | None = None,
) -> PolicyDefinition:
    """
    Comprehensive policy for AI coding agents.
    This is the DEFAULT policy used when no policy is specified.
    """
    base = PolicyDefinition(
        file=[
            {"allow": "/workspace/**", "ops": ["read", "write", "create", "delete"]},
            {"deny": ["/workspace/.git/config", "/workspace/.netrc"]},
            {"deny": ["**/.env", "**/.env.*", "**/credentials*", "**/*.pem", "**/*.key"]},
            {"deny": ["~/.ssh/**", "/proc/*/environ"]},
            {"deny": ["~/.aws/**", "~/.gcp/**", "~/.azure/**", "~/.config/gcloud/**"]},
            {"deny": ["~/.bashrc", "~/.zshrc", "~/.profile", "~/.bash_profile"]},
            {"deny": ["~/.gitconfig", "~/.netrc", "~/.curlrc", "~/.wgetrc"]},
            {"deny": "~/.local/bin/**"},
            {"deny": ["**/.cursorrules", "**/CLAUDE.md", "**/copilot-instructions.md"],
             "ops": ["write", "create", "delete"]},
        ],
        network=[
            {
                "allow": [
                    "registry.npmjs.org", "registry.yarnpkg.com",
                    "pypi.org", "files.pythonhosted.org",
                    "crates.io", "static.crates.io", "index.crates.io",
                    "proxy.golang.org", "sum.golang.org",
                    "github.com", "raw.githubusercontent.com",
                ],
                "ports": [443],
            },
            {"deny": "*"},
        ],
        commands=[
            {"allow": [
                "bash", "sh", "echo", "cat", "head", "tail", "grep", "find",
                "ls", "wc", "sort", "uniq", "diff", "pwd", "date", "which",
                "whoami", "id", "uname", "printf", "test", "true", "false",
                "mkdir", "cp", "mv", "rm", "touch", "chmod", "tr", "cut",
                "sed", "awk", "tee", "xargs", "basename", "dirname", "realpath",
                "base64", "md5sum", "sha256sum", "tar", "gzip", "gunzip",
            ]},
            {"allow": [
                "git", "node", "npm", "npx", "yarn", "pnpm", "bun",
                "python", "python3", "pip", "pip3",
                "cargo", "rustc", "go", "make", "cmake",
            ]},
            {"deny": ["env", "printenv", "sudo", "su", "doas"]},
            {"deny": ["shutdown", "reboot", "halt", "poweroff"]},
            {"deny": ["nc", "ncat", "netcat", "socat", "telnet"]},
            {"deny": ["git push --force", "git reset --hard"]},
            {"redirect": ["curl", "wget"],
             "to": {"cmd": "agentsh-fetch", "args": ["--audit"]}},
        ],
    )
    return merge(base, extensions) if extensions else base

def dev_safe(extensions: PolicyDefinition | None = None) -> PolicyDefinition:
    """
    Permissive defaults for local development.
    Allows broader network access and file operations. Still denies
    credential access and privilege escalation.
    """
    base = PolicyDefinition(
        file=[
            {"allow": "/workspace/**"},
            {"deny": ["**/.env", "**/.env.*", "**/credentials*", "**/*.pem", "**/*.key"]},
            {"deny": ["~/.ssh/**", "/proc/*/environ"]},
            {"deny": ["~/.aws/**", "~/.gcp/**", "~/.azure/**", "~/.config/gcloud/**"]},
        ],
        network=[
            {"allow": "*", "ports": [80, 443]},
            {"deny": ["169.254.169.254", "metadata.google.internal"]},
        ],
        commands=[
            {"allow": ["bash", "sh"]},
            {"deny": ["sudo", "su", "doas"]},
            {"deny": ["shutdown", "reboot", "halt", "poweroff"]},
            {"redirect": ["curl", "wget"],
             "to": {"cmd": "agentsh-fetch", "args": ["--audit"]}},
        ],
    )
    return merge(base, extensions) if extensions else base

def ci_strict(extensions: PolicyDefinition | None = None) -> PolicyDefinition:
    """
    Locked down for CI/CD runners.
    Read-only workspace except build output dirs. Network limited to
    package registries. No interactive commands.
    """
    base = PolicyDefinition(
        file=[
            {"allow": "/workspace/**", "ops": ["read"]},
            {"allow": ["/workspace/build/**", "/workspace/dist/**", "/workspace/target/**"],
             "ops": ["read", "write", "create", "delete"]},
            {"deny": ["**/.env", "**/.env.*", "**/credentials*", "**/*.pem", "**/*.key"]},
            {"deny": ["~/.ssh/**", "~/.aws/**", "~/.gcp/**", "~/.azure/**"]},
            {"deny": ["/proc/*/environ"]},
        ],
        network=[
            {
                "allow": [
                    "registry.npmjs.org", "registry.yarnpkg.com",
                    "pypi.org", "files.pythonhosted.org",
                    "crates.io", "static.crates.io", "index.crates.io",
                    "proxy.golang.org", "sum.golang.org",
                    "github.com", "raw.githubusercontent.com",
                ],
                "ports": [443],
            },
            {"deny": "*"},
        ],
        commands=[
            {"allow": [
                "bash", "sh", "echo", "cat", "head", "tail", "grep", "find",
                "ls", "wc", "sort", "uniq", "diff", "pwd", "date", "which",
                "mkdir", "cp", "mv", "rm", "touch", "chmod",
                "base64", "sha256sum", "tar", "gzip", "gunzip",
            ]},
            {"allow": [
                "git", "node", "npm", "npx", "yarn", "pnpm",
                "python", "python3", "pip", "pip3",
                "cargo", "rustc", "go", "make", "cmake",
            ]},
            {"deny": ["env", "printenv", "sudo", "su", "doas"]},
            {"deny": ["shutdown", "reboot", "halt", "poweroff"]},
            {"deny": ["nc", "ncat", "netcat", "socat", "telnet"]},
            {"deny": ["git push --force", "git reset --hard"]},
            {"redirect": ["curl", "wget"],
             "to": {"cmd": "agentsh-fetch", "args": ["--audit"]}},
        ],
    )
    return merge(base, extensions) if extensions else base

def agent_sandbox(extensions: PolicyDefinition | None = None) -> PolicyDefinition:
    """
    Maximum restriction for untrusted code.
    Read-only workspace, no network, no dangerous commands.
    """
    base = PolicyDefinition(
        file=[
            {"allow": "/workspace/**", "ops": ["read"]},
            {"allow": "/tmp/**"},
            {"deny": ["**/.env", "**/.env.*", "**/credentials*", "**/*.pem", "**/*.key"]},
            {"deny": ["~/.ssh/**", "~/.aws/**", "~/.gcp/**", "~/.azure/**"]},
            {"deny": ["/proc/*/environ"]},
        ],
        network=[
            {"deny": "*"},
        ],
        commands=[
            {"allow": [
                "echo", "cat", "head", "tail", "grep", "find",
                "ls", "wc", "sort", "uniq", "diff", "pwd", "date",
                "test", "true", "false", "printf",
                "basename", "dirname",
            ]},
            {"deny": ["bash", "sh"]},
            {"deny": ["env", "printenv", "sudo", "su", "doas"]},
            {"deny": ["shutdown", "reboot", "halt", "poweroff"]},
            {"deny": ["nc", "ncat", "netcat", "socat", "telnet"]},
            {"deny": ["curl", "wget", "git", "pip", "npm"]},
        ],
    )
    return merge(base, extensions) if extensions else base
```

### 7.4 Merge

```python
# policies/merge.py

from .schema import PolicyDefinition, validate_policy

_CATEGORIES = ("file", "network", "commands", "env", "dns", "connect")

def merge(
    base: PolicyDefinition,
    *overrides: PolicyDefinition,
) -> PolicyDefinition:
    """
    Merge overrides AFTER base rules (first-match-wins: base takes priority).
    """
    return _merge_internal(base, overrides, mode="append")

def merge_prepend(
    base: PolicyDefinition,
    *overrides: PolicyDefinition,
) -> PolicyDefinition:
    """
    Merge overrides BEFORE base rules (overrides take priority).
    """
    return _merge_internal(base, overrides, mode="prepend")

def _merge_internal(base, overrides, mode):
    updates: dict[str, list] = {}
    for override in overrides:
        for key in _CATEGORIES:
            override_rules = getattr(override, key, None)
            if override_rules:
                base_rules = updates.get(key) or getattr(base, key, None) or []
                if mode == "append":
                    updates[key] = base_rules + override_rules
                else:
                    updates[key] = override_rules + base_rules
    # Rebuild via model_validate to trigger full Pydantic validation
    merged_data = base.model_dump(exclude_none=True)
    merged_data.update(updates)
    return PolicyDefinition.model_validate(merged_data)
```

---

## 8. Provisioning

The provisioning engine is shared across all providers. It takes a
`SandboxAdapter` and `SecureConfig`, and returns a `ProvisionResult`
containing the session ID and detected security mode.

### 8.1 Provisioning sequence

```
Phase 1: Binary Installation
  1. Check if binary exists (fast path for snapshots)
  2. Detect architecture (uname -m)
  3. Install binary (download, upload, or verify preinstalled)
  4. Verify SHA256 checksum
  5. Install binaries (agentsh + shell-shim + unixwrap)
  6. Detect security mode (agentsh detect --output json)
  7. Install shell shim (agentsh shim install-shell)

Phase 2: Policy & Config
  8. Create /etc/agentsh/system/ directory
  9. Write system policy (self-protection rules)
  10. Write user policy (serialized PolicyDefinition)
  11. Write server config
  12. Set permissions (chmod 444, root ownership)
  13. Ensure workspace directory exists

Phase 3: Server Startup
  14. Start agentsh server (detached)
  15. Health check (retry loop, up to 10 attempts)
  16. Create session

Phase 4: Handoff
  17. Return ProvisionResult { session_id, security_mode }
```

### 8.2 Implementation

```python
# core/provision.py

from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass
from .types import (
    SandboxAdapter, SecureConfig, SecurityMode, ExecResult, ExecOpts,
)
from .integrity import get_checksum, build_verify_command, binary_url, PINNED_VERSION
from .config import generate_server_config
from .errors import ProvisioningError, IntegrityError
from ..policies.serialize import serialize_policy, system_policy_yaml
from ..policies.presets import agent_default
from ..policies.schema import validate_policy

SECURITY_MODE_RANK: dict[SecurityMode, int] = {
    "full": 4,
    "landlock": 3,
    "landlock-only": 2,
    "minimal": 1,
}

AGENTSH_PATHS = ["/usr/local/bin/agentsh", "/usr/bin/agentsh"]

@dataclass(frozen=True, slots=True)
class ProvisionResult:
    session_id: str
    security_mode: SecurityMode
    passthrough: bool = False

async def provision(
    adapter: SandboxAdapter,
    config: SecureConfig | None = None,
) -> ProvisionResult:
    cfg = config or SecureConfig()
    policy = validate_policy(cfg.policy) if cfg.policy else agent_default()
    ...
    # Full implementation mirrors TypeScript provision() 1:1
    # See Section 8.1 for the complete sequence.
```

The implementation is a direct port of the TypeScript `provision()` function.
The implementation follows the same sequence and error handling as the TS version.

### 8.3 Install strategies

| Strategy | Behavior |
|----------|----------|
| `download` | Download agentsh tarball inside sandbox via curl/wget. Default. |
| `upload` | Download on host via httpx, upload via `adapter.write_file()`. |
| `preinstalled` | Binary already in sandbox (baked image or snapshot). |
| `running` | agentsh already running with shell shim installed. Skip all provisioning. Uses **passthrough** runtime mode (Section 9.1) — commands go directly through the adapter with the shell shim enforcing policy. |

### 8.4 Security mode detection

The library calls `agentsh detect --output json` and parses the result.
If `minimum_security_mode` is set and the detected mode is weaker,
provisioning fails with `ProvisioningError`.

Security modes ranked strongest to weakest:

| Mode | Enforcement | Rank |
|------|------------|------|
| `full` | seccomp + FUSE + Landlock + network proxy | 4 |
| `landlock` | seccomp + Landlock + network proxy (no FUSE) | 3 |
| `landlock-only` | Landlock filesystem restrictions only | 2 |
| `minimal` | Policy evaluation only, no kernel enforcement | 1 |

---

## 9. Runtime

### 9.0 Runtime security invariant

**HARD RULE: At runtime, `SecuredSandbox.exec()`, `write_file()`, and
`read_file()` MUST all route through `agentsh exec` (or the shell shim
in passthrough mode). They MUST NEVER call `adapter.write_file()` or
`adapter.read_file()` directly.**

The raw adapter file/exec APIs are used during provisioning only (to
install the binary, write policy, start the server). Once provisioning
completes and `SecuredSandbox` is returned, the adapter's direct methods
are off-limits. All runtime operations go through `agentsh exec $SID --`
which subjects them to policy evaluation.

This is the core security guarantee. If any runtime path bypasses agentsh,
the policy is unenforceable.

### 9.1 SecuredSandbox implementations

Two runtime modes, matching the TypeScript library:

**Standard mode** — wraps commands in `agentsh exec`:

```python
# Internally calls:
adapter.exec("agentsh", [
    "exec", "--output", "json", session_id,
    "--", "bash", "-c", command
])
```

**Passthrough mode** — used with `install_strategy="running"`. The shell
shim enforces policy on every command, so commands go directly through
the adapter without the `agentsh exec` wrapper.

**Important:** Even in passthrough mode, `write_file()` and `read_file()`
are implemented as shell commands via `adapter.exec()` (e.g.,
`sh -c 'base64 -d > path'` and `cat path`). They do NOT use
`adapter.write_file()` or `adapter.read_file()` directly. The shell shim
intercepts these commands and enforces policy. This preserves the runtime
security invariant from Section 9.0 in both modes.

### 9.1.1 Implementation

```python
# core/runtime.py

from __future__ import annotations
import asyncio
import base64
import json
from .types import (
    SandboxAdapter, SecuredSandbox, ExecResult, ExecOpts,
    ReadFileResult, ReadFileSuccess, ReadFileFailure,
    WriteFileResult, WriteFileSuccess, WriteFileFailure,
    SecurityMode,
)
from .errors import TransportError
from .shell import shell_escape


def create_secured_sandbox(
    adapter: SandboxAdapter,
    session_id: str,
    security_mode: SecurityMode,
    *,
    passthrough: bool = False,
) -> SecuredSandbox:
    """Create the appropriate SecuredSandbox for the runtime mode."""
    if passthrough:
        return _PassthroughSecuredSandbox(adapter, session_id, security_mode)
    return _StandardSecuredSandbox(adapter, session_id, security_mode)


class _StandardSecuredSandbox:
    """Wraps every operation in `agentsh exec --output json $SID --`."""

    def __init__(
        self, adapter: SandboxAdapter, sid: str, mode: SecurityMode,
    ) -> None:
        self._adapter = adapter
        self._session_id = sid
        self._security_mode = mode

    @property
    def session_id(self) -> str:
        return self._session_id

    @property
    def security_mode(self) -> SecurityMode:
        return self._security_mode

    async def exec(
        self,
        command: str,
        *,
        cwd: str | None = None,
        timeout: float | None = None,
    ) -> ExecResult:
        args = ["exec", "--output", "json", self._session_id, "--", "bash", "-c"]
        full_cmd = f"cd {cwd} && {command}" if cwd else command
        args.append(full_cmd)

        if timeout:
            raw = await asyncio.wait_for(
                self._adapter.exec("agentsh", args), timeout=timeout,
            )
        else:
            raw = await self._adapter.exec("agentsh", args)

        # Transport failure detection
        if raw.exit_code == 127 and "agentsh" in raw.stderr:
            raise TransportError(
                session_id=self._session_id,
                command=command,
                stderr=raw.stderr,
            )

        # Parse JSON envelope
        return _parse_exec_envelope(raw)

    async def write_file(
        self, path: str, content: str | bytes,
    ) -> WriteFileResult:
        data = content if isinstance(content, bytes) else content.encode()
        b64 = base64.b64encode(data).decode("ascii")
        result = await self.exec(
            f'printf "%s" {b64!r} | base64 -d > {path!r}',
        )
        if result.exit_code != 0:
            return WriteFileFailure(
                success=False, path=path, error=result.stderr,
            )
        return WriteFileSuccess(success=True, path=path)

    async def read_file(self, path: str) -> ReadFileResult:
        result = await self.exec(f"cat {path!r}")
        if result.exit_code != 0:
            return ReadFileFailure(
                success=False, path=path, error=result.stderr,
            )
        return ReadFileSuccess(success=True, path=path, content=result.stdout)

    async def stop(self) -> None:
        # Stop agentsh session, then stop provider sandbox
        try:
            await self._adapter.exec(
                "agentsh", ["session", "stop", self._session_id],
            )
        except Exception:
            pass
        await self._adapter.stop()


class _PassthroughSecuredSandbox:
    """
    Used with install_strategy="running". Commands go directly through
    the adapter; the shell shim enforces policy on every command.
    """

    def __init__(
        self, adapter: SandboxAdapter, sid: str, mode: SecurityMode,
    ) -> None:
        self._adapter = adapter
        self._session_id = sid
        self._security_mode = mode

    @property
    def session_id(self) -> str:
        return self._session_id

    @property
    def security_mode(self) -> SecurityMode:
        return self._security_mode

    async def exec(
        self,
        command: str,
        *,
        cwd: str | None = None,
        timeout: float | None = None,
    ) -> ExecResult:
        opts = ExecOpts(cwd=cwd)
        if timeout:
            return await asyncio.wait_for(
                self._adapter.exec("bash", ["-c", command], opts), timeout=timeout,
            )
        return await self._adapter.exec("bash", ["-c", command], opts)

    async def write_file(
        self, path: str, content: str | bytes,
    ) -> WriteFileResult:
        # Route through adapter.exec (NOT adapter.write_file) so the
        # shell shim can enforce policy.
        data = content if isinstance(content, bytes) else content.encode()
        b64 = base64.b64encode(data).decode("ascii")
        result = await self.exec(
            f'printf "%s" {b64!r} | base64 -d > {path!r}',
        )
        if result.exit_code != 0:
            return WriteFileFailure(
                success=False, path=path, error=result.stderr,
            )
        return WriteFileSuccess(success=True, path=path)

    async def read_file(self, path: str) -> ReadFileResult:
        result = await self.exec(f"cat {path!r}")
        if result.exit_code != 0:
            return ReadFileFailure(
                success=False, path=path, error=result.stderr,
            )
        return ReadFileSuccess(success=True, path=path, content=result.stdout)

    async def stop(self) -> None:
        await self._adapter.stop()


def _parse_exec_envelope(raw: ExecResult) -> ExecResult:
    """
    Parse the agentsh exec JSON envelope. Falls back to raw result
    if parsing fails (e.g., mock adapters or non-JSON output).
    """
    try:
        envelope = json.loads(raw.stdout)
        result = envelope["result"]
        return ExecResult(
            stdout=result.get("stdout", ""),
            stderr=result.get("stderr", ""),
            exit_code=result.get("exit_code", 0),
        )
    except (json.JSONDecodeError, KeyError, TypeError):
        return raw
```

### 9.2 JSON envelope parsing

`agentsh exec --output json` returns a JSON envelope:

```json
{
  "result": {
    "exit_code": 0,
    "stdout": "hello\n",
    "stderr": ""
  }
}
```

The runtime layer parses this and returns a flat `ExecResult`. If parsing
fails (e.g., mock adapters), the raw result is returned as-is.

### 9.3 Transport failure detection

If `agentsh` itself is not found (`exit_code == 127` and stderr mentions
`agentsh`), a `TransportError` is raised instead of returning a result.
This distinguishes "the command failed" from "agentsh is broken."

### 9.4 Self-protection guarantee

The system policy (written to `/etc/agentsh/system/policy.yml`) prevents
the agent from:

- Modifying policy files (`/etc/agentsh/**`)
- Overwriting agentsh binaries (`/usr/local/bin/agentsh*`, `/usr/bin/agentsh*`)
- Replacing the shell shim (`/usr/bin/agentsh-shell-shim`, `/bin/bash`, `/bin/sh`)
- Killing agentsh processes (`kill/killall/pkill agentsh`)

This layer is evaluated before user policy and cannot be disabled.

### 9.5 Trace context propagation

The library automatically propagates W3C traceparent from the host process
into the agentsh session. This connects sandbox spans (command evaluations,
policy decisions, network/file events) to the host's trace tree.

Resolution priority (when `trace_parent=None`, the default):

1. **Active OTel context** — If `opentelemetry-api` is installed and an
   active span exists (e.g., Logfire-instrumented Pydantic AI agent),
   extract traceparent via `propagate.inject()`.
2. **`TRACEPARENT` env var** — Standard OTel mechanism for process-to-process
   propagation. Used in CI/CD and supervisor-spawned processes.
3. **Skip** — If neither source is available, propagation is silently skipped.

### 9.6 Trace context implementation

```python
# core/trace.py

from __future__ import annotations
import os


def resolve_trace_parent(explicit: str | bool | None) -> str | None:
    """
    Resolve the traceparent string to propagate into the agentsh session.

    Args:
        explicit: The ``trace_parent`` value from ``SecureConfig``.
            - ``None``: Auto-resolve (OTel span -> env var -> skip).
            - A string: Use verbatim as W3C traceparent.
            - ``False``: Disable propagation entirely.

    Returns:
        A W3C traceparent string, or ``None`` if propagation is disabled
        or no context is available.
    """
    # Explicit disable
    if explicit is False:
        return None

    # Explicit string — use verbatim
    if isinstance(explicit, str):
        return explicit

    # Auto-resolve: try active OTel span first
    tp = _try_otel_context()
    if tp:
        return tp

    # Fallback: TRACEPARENT env var
    return os.environ.get("TRACEPARENT")


def _try_otel_context() -> str | None:
    """Extract traceparent from active OpenTelemetry span, if available."""
    try:
        from opentelemetry import context
        from opentelemetry.propagators import textmap
        from opentelemetry.propagate import get_global_textmap

        carrier: dict[str, str] = {}
        get_global_textmap().inject(carrier, context=context.get_current())
        return carrier.get("traceparent")
    except ImportError:
        return None
    except Exception:
        return None
```

The traceparent is captured once at provisioning time and PUT to the
agentsh session API. All agentsh spans for that session are parented
under this traceparent.

Users can override with an explicit string or disable with `trace_parent=False`.

Full design: see `TRACE-PROPAGATION.md`.

---

## 10. Pydantic AI Toolset Integration

### 10.1 API compatibility note

This section targets Pydantic AI's `AbstractToolset` as of v0.2+. The
`AbstractToolset` API is still evolving. We pin a minimum version in
optional dependencies and will track breaking changes via adapter version
bumps. The exact signatures below are taken from the current source.

### 10.2 SecureSandboxToolset

```python
# toolset/pydantic_ai.py

from __future__ import annotations
from typing import Any
from typing_extensions import Self

from pydantic_ai import RunContext
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets import AbstractToolset, ToolsetTool

from ..core.types import SecuredSandbox, SandboxAdapter, SecureConfig
from .._api import secure_sandbox

# Type alias — toolset is deps-agnostic, works with any agent deps type.
AgentDepsT = Any


class SecureSandboxToolset(AbstractToolset[AgentDepsT]):
    """
    Pydantic AI Toolset that wraps a sandbox provider with agentsh
    security enforcement.

    Lifecycle:
    - One sandbox per toolset instance (1:1 ownership).
    - Not concurrency-safe. Do not share across concurrent agent runs.
    - After stop(), the instance is dead. Create a new one for a new run.
    - Provisioning happens eagerly in __aenter__, not lazily per tool call.

    Usage:
        from agentsh_secure_sandbox import SecureSandboxToolset
        from agentsh_secure_sandbox.adapters import e2b
        from e2b import AsyncSandbox

        raw = await AsyncSandbox.create()
        toolset = SecureSandboxToolset(e2b(raw))

        agent = Agent("claude-sonnet-4-20250514", toolsets=[toolset])

        async with agent:
            result = await agent.run("Install express and create a hello world server")
    """

    _TOOL_DEFS: dict[str, ToolDefinition] = {
        "run_command": ToolDefinition(
            name="run_command",
            description=(
                "Execute a shell command in the secure sandbox. "
                "All commands are mediated by security policy. "
                "Dangerous operations (reading secrets, network exfiltration, "
                "privilege escalation) will be blocked."
            ),
            parameters_json_schema={
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "Shell command to execute",
                    },
                    "cwd": {
                        "type": "string",
                        "description": "Working directory (default: /workspace)",
                    },
                },
                "required": ["command"],
                "additionalProperties": False,
            },
        ),
        "write_file": ToolDefinition(
            name="write_file",
            description=(
                "Write content to a file in the secure sandbox. "
                "Writes to sensitive paths (secrets, config files) "
                "will be blocked by policy."
            ),
            parameters_json_schema={
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "File path to write",
                    },
                    "content": {
                        "type": "string",
                        "description": "File content",
                    },
                },
                "required": ["path", "content"],
                "additionalProperties": False,
            },
        ),
        "read_file": ToolDefinition(
            name="read_file",
            description=(
                "Read a file from the secure sandbox. "
                "Reads from sensitive paths (secrets, credentials) "
                "will be blocked by policy."
            ),
            parameters_json_schema={
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "File path to read",
                    },
                },
                "required": ["path"],
                "additionalProperties": False,
            },
        ),
    }

    def __init__(
        self,
        adapter: SandboxAdapter,
        config: SecureConfig | None = None,
        *,
        toolset_id: str = "agentsh-sandbox",
    ) -> None:
        self._adapter = adapter
        self._config = config or SecureConfig()
        self._sandbox: SecuredSandbox | None = None
        self._toolset_id = toolset_id

    # ─── AbstractToolset required properties ──────────────────

    @property
    def id(self) -> str:
        return self._toolset_id

    # ─── Lifecycle ────────────────────────────────────────────

    async def __aenter__(self) -> Self:
        """Provision agentsh inside the sandbox (eager, not lazy)."""
        self._sandbox = await secure_sandbox(self._adapter, self._config)
        return self

    async def __aexit__(self, *args: Any) -> None:
        """Stop the agentsh session and the underlying provider sandbox."""
        if self._sandbox:
            await self._sandbox.stop()
            self._sandbox = None

    # ─── Tool listing and execution ──────────────────────────

    @property
    def sandbox(self) -> SecuredSandbox:
        """Access the underlying SecuredSandbox for direct use."""
        if self._sandbox is None:
            raise RuntimeError(
                "Toolset not initialized. Use 'async with agent:' to "
                "enter the toolset context before running."
            )
        return self._sandbox

    async def get_tools(
        self, ctx: RunContext[AgentDepsT],
    ) -> dict[str, ToolsetTool[AgentDepsT]]:
        """Return tool definitions. ctx is available but not used."""
        return {
            name: ToolsetTool(
                tool_def=defn,
                toolset=self,
                max_retries=0,
            )
            for name, defn in self._TOOL_DEFS.items()
        }

    async def call_tool(
        self,
        name: str,
        tool_args: dict[str, Any],
        ctx: RunContext[AgentDepsT],
        tool: ToolsetTool[AgentDepsT],
    ) -> Any:
        """Execute a tool call against the secured sandbox."""
        sb = self.sandbox

        if name == "run_command":
            result = await sb.exec(
                tool_args["command"], cwd=tool_args.get("cwd"),
            )
            if result.exit_code != 0:
                parts = [f"Command failed (exit {result.exit_code}):"]
                if result.stdout:
                    parts.append(f"stdout:\n{result.stdout}")
                if result.stderr:
                    parts.append(f"stderr:\n{result.stderr}")
                return "\n".join(parts)
            return result.stdout

        if name == "write_file":
            result = await sb.write_file(tool_args["path"], tool_args["content"])
            if result.success:
                return f"Written to {result.path}"
            return f"Write denied: {result.error}"

        if name == "read_file":
            result = await sb.read_file(tool_args["path"])
            if result.success:
                return result.content
            return f"Read denied: {result.error}"

        raise ValueError(f"Unknown tool: {name}")
```

### 10.3 Lifecycle and concurrency rules

- **One sandbox per toolset instance** — 1:1 ownership. The toolset creates
  the sandbox in `__aenter__` and destroys it in `__aexit__`.
- **Not concurrency-safe** — Do not share one toolset instance across
  concurrent agent runs. Each concurrent run needs its own toolset.
- **Not reusable after stop** — Once `__aexit__` runs, the instance is dead.
  Create a new `SecureSandboxToolset` for a new run.
- **Provisioning is eager** — agentsh is installed and started in
  `__aenter__`, not lazily on first tool call. This means the security
  boundary is established before the LLM makes any decisions.
- **`stop()` always stops the agentsh session.** It also attempts to stop
  the underlying provider sandbox via `adapter.stop()`, but this is
  best-effort. Some providers (Daytona) manage sandbox lifecycle externally —
  `adapter.stop()` is a no-op and the caller is responsible for teardown.
  E2B, Vercel, and Blaxel support direct sandbox termination.

If you need to separate agentsh session lifecycle from provider sandbox
lifecycle (e.g., reuse a Daytona sandbox across multiple agent runs),
use the standalone `secure_sandbox()` API directly instead of the Toolset.

### 10.4 Usage with Pydantic AI

```python
from pydantic_ai import Agent
from e2b import AsyncSandbox
from agentsh_secure_sandbox import SecureSandboxToolset, SecureConfig
from agentsh_secure_sandbox.adapters import e2b
from agentsh_secure_sandbox.policies import agent_default
from agentsh_secure_sandbox.policies.schema import PolicyDefinition

raw = await AsyncSandbox.create(api_key="...")
toolset = SecureSandboxToolset(
    e2b(raw),
    config=SecureConfig(
        policy=agent_default(extensions=PolicyDefinition(
            network=[{"allow": ["api.stripe.com"], "ports": [443]}],
        )),
    ),
)

agent = Agent("claude-sonnet-4-20250514", toolsets=[toolset])

async with agent:
    result = await agent.run(
        "Install express and create a hello world server in /workspace/app.js"
    )
    print(result.output)
```

### 10.5 Usage with other frameworks

The standalone `secure_sandbox()` works with any framework:

```python
from agentsh_secure_sandbox import secure_sandbox
from agentsh_secure_sandbox.adapters import daytona as daytona_adapter
from daytona_sdk import Daytona

client = Daytona()
raw = await client.create()
sandbox = await secure_sandbox(daytona_adapter(raw))

# Use with LangChain, CrewAI, raw API calls, or anything else
result = await sandbox.exec("npm install express")
print(result.stdout)

await sandbox.stop()
```

---

## 11. Error Hierarchy

```python
# core/errors.py

class AgentSHError(Exception):
    """Base exception for all agentsh-secure-sandbox errors."""

class PolicyValidationError(AgentSHError):
    """Policy failed Pydantic validation."""
    def __init__(self, errors: list):
        self.errors = errors
        summaries = "; ".join(str(e) for e in errors)
        super().__init__(f"Policy validation failed: {summaries}")

class ProvisioningError(AgentSHError):
    """A provisioning step failed."""
    def __init__(self, *, phase: str, command: str, stderr: str):
        self.phase = phase
        self.command = command
        self.stderr = stderr
        super().__init__(f"Provisioning failed at phase: {phase}")

class IntegrityError(AgentSHError):
    """Binary checksum mismatch."""
    def __init__(self, *, expected: str, actual: str, message: str | None = None):
        self.expected = expected
        self.actual = actual
        super().__init__(message or f"Checksum mismatch: expected {expected}, got {actual}")

class TransportError(AgentSHError):
    """agentsh exec transport failure (binary not found, not a policy denial)."""
    def __init__(self, *, session_id: str, command: str, stderr: str):
        self.session_id = session_id
        self.command = command
        self.stderr = stderr
        super().__init__(f"agentsh exec failed (session {session_id})")
```

---

## 12. Public API

### 12.1 `__init__.py` exports

```python
# __init__.py

__all__ = [
    # Main API
    "secure_sandbox",
    # Pydantic AI integration
    "SecureSandboxToolset",
    # Core types
    "SecuredSandbox",
    "SandboxAdapter",
    "SandboxAdapterBase",
    "ExecResult",
    "ReadFileResult",
    "WriteFileResult",
    "SecureConfig",
    "SecurityMode",
    "InstallStrategy",
    "ThreatFeedsConfig",
    "ThreatFeed",
    "ExecOpts",
    # Policy types
    "PolicyDefinition",
    # Errors
    "AgentSHError",
    "PolicyValidationError",
    "ProvisioningError",
    "IntegrityError",
    "TransportError",
    # Namespaced
    "policies",
    "adapters",
]

# Main API
from ._api import secure_sandbox

# Pydantic AI integration
from .toolset.pydantic_ai import SecureSandboxToolset

# Core types
from .core.types import (
    SecuredSandbox,
    SandboxAdapter,
    SandboxAdapterBase,
    ExecResult,
    ReadFileResult,
    WriteFileResult,
    SecureConfig,
    SecurityMode,
    InstallStrategy,
    ThreatFeedsConfig,
    ThreatFeed,
    ExecOpts,
)

# Policy types
from .policies.schema import PolicyDefinition

# Errors
from .core.errors import (
    AgentSHError,
    PolicyValidationError,
    ProvisioningError,
    IntegrityError,
    TransportError,
)

# Namespaced
from . import policies
from . import adapters
```

### 12.1.1 `policies/__init__.py`

```python
# policies/__init__.py

from .presets import agent_default, dev_safe, ci_strict, agent_sandbox
from .merge import merge, merge_prepend
from .schema import PolicyDefinition, validate_policy
from .serialize import serialize_policy

__all__ = [
    "agent_default",
    "dev_safe",
    "ci_strict",
    "agent_sandbox",
    "merge",
    "merge_prepend",
    "PolicyDefinition",
    "validate_policy",
    "serialize_policy",
]
```

### 12.1.2 `adapters/__init__.py`

```python
# adapters/__init__.py

from .vercel import vercel, VercelSandbox
from .e2b import e2b
from .daytona import daytona
from .cloudflare import cloudflare
from .blaxel import blaxel
from .sprites import sprites, sprites_defaults

__all__ = ["vercel", "VercelSandbox", "e2b", "daytona", "cloudflare", "blaxel", "sprites", "sprites_defaults"]
```

### 12.2 `secure_sandbox()`

```python
# _api.py

from .core.types import SandboxAdapter, SecuredSandbox, SecureConfig
from .core.provision import provision
from .core.runtime import create_secured_sandbox

async def secure_sandbox(
    adapter: SandboxAdapter,
    config: SecureConfig | None = None,
) -> SecuredSandbox:
    """
    Secure any sandbox by installing agentsh inside it.

    This is the main entry point. It provisions agentsh (install binary,
    write policy, start server, install shell shim) and returns a
    SecuredSandbox where every operation is mediated by policy.

    Args:
        adapter: A SandboxAdapter wrapping the raw provider SDK.
        config: Optional configuration (policy, install strategy, etc.).

    Returns:
        A SecuredSandbox with policy enforcement active.
    """
    cfg = config or SecureConfig()
    result = await provision(adapter, cfg)
    return create_secured_sandbox(
        adapter,
        result.session_id,
        result.security_mode,
        passthrough=result.passthrough,
    )
```

---

## 13. Testing Utilities

### 13.1 Mock sandbox

```python
# testing/mock.py

from __future__ import annotations
from ..core.types import (
    SecuredSandbox, ExecResult, ReadFileResult, WriteFileResult,
    ReadFileSuccess, ReadFileFailure, WriteFileSuccess, SecurityMode,
)

def mock_secured_sandbox(
    *,
    commands: dict[str, ExecResult] | None = None,
    files: dict[str, str] | None = None,
    security_mode: SecurityMode = "full",
    session_id: str = "mock-session",
) -> SecuredSandbox:
    """
    Create a mock SecuredSandbox for unit testing.

    Args:
        commands: Map of command string -> ExecResult.
        files: Map of path -> content (mutable).
        security_mode: Reported security mode.
        session_id: Reported session ID.
    """
    file_store = dict(files or {})
    cmd_store = commands or {}

    class _Mock:
        @property
        def session_id(self) -> str:
            return session_id

        @property
        def security_mode(self) -> SecurityMode:
            return security_mode

        async def exec(self, command, *, cwd=None, timeout=None):
            if command in cmd_store:
                return cmd_store[command]
            return ExecResult(
                stdout="", stderr=f'mock: no response for "{command}"', exit_code=1
            )

        async def write_file(self, path, content):
            file_store[path] = content.decode("utf-8") if isinstance(content, bytes) else content
            return WriteFileSuccess(success=True, path=path)

        async def read_file(self, path):
            if path in file_store:
                return ReadFileSuccess(success=True, path=path, content=file_store[path])
            return ReadFileFailure(success=False, path=path, error=f'mock: file not found "{path}"')

        async def stop(self):
            pass

    return _Mock()
```

### 13.2 Mock adapter

```python
def mock_adapter(
    *,
    exec_results: list[ExecResult] | None = None,
) -> SandboxAdapter:
    """
    Create a mock SandboxAdapter for testing provisioning.
    Returns exec results in sequence.
    """
    results = list(exec_results or [])
    call_idx = 0

    class _MockAdapter:
        async def exec(self, cmd, args=None, opts=None):
            nonlocal call_idx
            if call_idx < len(results):
                r = results[call_idx]
                call_idx += 1
                return r
            return ExecResult(stdout="", stderr="", exit_code=0)

        async def write_file(self, path, content, *, sudo=False):
            pass

        async def read_file(self, path):
            return ""

        async def stop(self):
            pass

        async def file_exists(self, path):
            return False

    return _MockAdapter()
```

---

## 14. Supported Platforms

| Provider | seccomp | Landlock | FUSE | Network Proxy | DLP | Default Mode |
|----------|---------|----------|------|---------------|-----|-------------|
| Vercel | yes | yes | no (Firecracker) | yes | yes | `landlock` |
| E2B | yes | yes | yes | yes | yes | `full` |
| Daytona | yes | yes | yes | yes | yes | `full` |
| Cloudflare | yes | yes | no (Firecracker) | yes | yes | `landlock` |
| Blaxel | yes | yes | yes | yes | yes | `full` |
| Sprites | yes | no | yes | yes | yes | `full` |

---

## 15. Usage Examples

### 15.1 Standalone with E2B

```python
from e2b import AsyncSandbox
from agentsh_secure_sandbox import secure_sandbox
from agentsh_secure_sandbox.adapters import e2b

raw = await AsyncSandbox.create(api_key="...")
sandbox = await secure_sandbox(e2b(raw))
result = await sandbox.exec('python3 -c "print(42)"')
assert result.stdout.strip() == "42"

result = await sandbox.exec("cat ~/.ssh/id_rsa")
assert result.exit_code != 0  # Blocked by policy

await sandbox.stop()
```

### 15.2 Pydantic AI with Daytona

```python
from pydantic_ai import Agent
from daytona_sdk import Daytona
from agentsh_secure_sandbox import SecureSandboxToolset, SecureConfig
from agentsh_secure_sandbox.adapters import daytona
from agentsh_secure_sandbox.policies import agent_default

client = Daytona()
raw = await client.create()

toolset = SecureSandboxToolset(
    daytona(raw),
    config=SecureConfig(
        policy=agent_default(),
    ),
)

agent = Agent("claude-sonnet-4-20250514", toolsets=[toolset])

async with agent:
    result = await agent.run("Install flask and create an app in /workspace/app.py")
    print(result.output)
```

### 15.3 Custom policy with Vercel

```python
from agentsh_secure_sandbox import secure_sandbox, SecureConfig
from agentsh_secure_sandbox.adapters import vercel
from agentsh_secure_sandbox.policies import agent_default, merge
from agentsh_secure_sandbox.policies.schema import PolicyDefinition

# Extend default with custom network allowlist
policy = agent_default(extensions=PolicyDefinition(
    network=[{"allow": ["api.stripe.com", "api.openai.com"], "ports": [443]}],
    file=[{"allow": "/data/**", "ops": ["read"]}],
))

sandbox = await secure_sandbox(
    vercel(raw),
    config=SecureConfig(
        policy=policy,
        minimum_security_mode="landlock",
    ),
)
```

### 15.4 Override base rules

```python
from agentsh_secure_sandbox.policies import agent_default, merge_prepend
from agentsh_secure_sandbox.policies.schema import PolicyDefinition

# Prepend rules to override base (allow .env reads)
policy = merge_prepend(
    agent_default(),
    PolicyDefinition(
        file=[{"allow": "**/.env", "ops": ["read"]}],
    ),
)
```

---

## 16. Shell Escaping and Utilities

```python
# core/shell.py

import asyncio
import shlex
from typing import Coroutine, Any

def shell_escape(cmd: str, args: list[str] | None = None) -> str:
    """
    Build a shell-safe command string.
    Uses shlex.quote for each argument.

    Note: ``cmd`` is NOT quoted — it must be a simple command name
    (e.g., "bash", "test", "agentsh") without spaces or special characters.
    """
    if not args:
        return cmd
    escaped = " ".join(shlex.quote(a) for a in args)
    return f"{cmd} {escaped}"


def fire_and_forget(coro: Coroutine[Any, Any, Any]) -> asyncio.Task[None]:
    """
    Schedule an async coroutine to run without awaiting it.
    Swallows exceptions to avoid noisy unhandled-task warnings.
    Used by adapters for detached/background process execution.

    Returns the task so adapters can optionally track and await
    pending background tasks during stop().
    """
    async def _wrapper() -> None:
        try:
            await coro
        except Exception:
            pass  # Detached processes — errors are expected and ignored

    return asyncio.create_task(_wrapper())
```

---

## 17. Server Config Generation

Mirrors the TypeScript `generateServerConfig()`. Produces YAML for
the agentsh server.

### 17.1 Implementation

```python
# core/config.py

from __future__ import annotations
import yaml
from ..core.types import SecureConfig

# Default threat feeds when threat_feeds is None (not explicitly False)
_DEFAULT_FEEDS = [
    {
        "name": "urlhaus",
        "url": "https://urlhaus.abuse.ch/downloads/hostfile/",
        "format": "hostfile",
        "refresh_interval": "6h",
    },
    {
        "name": "phishing",
        "url": "https://raw.githubusercontent.com/elliotwutingfeng/Inversion-DNSBL-Blocklists/main/Google_hostfile/phishing-domains-ACTIVE.txt",
        "format": "domain-list",
        "refresh_interval": "12h",
    },
]

_DEFAULT_ALLOWLIST = [
    "github.com",
    "*.github.com",
    "registry.npmjs.org",
    "registry.yarnpkg.com",
    "pypi.org",
    "files.pythonhosted.org",
    "crates.io",
    "static.crates.io",
    "index.crates.io",
    "proxy.golang.org",
    "sum.golang.org",
]


def generate_server_config(config: SecureConfig) -> str:
    """Generate agentsh server config YAML from SecureConfig."""
    doc: dict = {
        "server": {"http": {"addr": "127.0.0.1:18080"}},
        "auth": {"type": "none"},
        "policies": {
            "system_dir": "/etc/agentsh/system",
            "dir": "/etc/agentsh",
            "default": config.policy_name,
        },
        "workspace": config.workspace,
        "sandbox": {
            "enabled": True,
            "allow_degraded": True,
        },
    }

    if config.real_paths is not None:
        doc["sandbox"]["real_paths"] = config.real_paths

    if config.enforce_redirects:
        doc["sandbox"]["enforce_redirects"] = True

    # Threat feeds: None = defaults, False = disabled, ThreatFeedsConfig = custom
    if config.threat_feeds is not False:
        tf = config.threat_feeds
        if tf is None:
            doc["threat_feeds"] = {
                "enabled": True,
                "action": "deny",
                "feeds": _DEFAULT_FEEDS,
                "allowlist": _DEFAULT_ALLOWLIST,
            }
        else:
            doc["threat_feeds"] = {
                "enabled": True,
                "action": tf.action,
                "feeds": [
                    {
                        "name": f.name,
                        "url": f.url,
                        "format": f.format,
                        "refresh_interval": f.refresh_interval,
                    }
                    for f in tf.feeds
                ],
            }
            if tf.allowlist:
                doc["threat_feeds"]["allowlist"] = tf.allowlist

    if config.watchtower:
        doc["watchtower"] = {"url": config.watchtower}

    return yaml.dump(doc, default_flow_style=False, width=1000)
```

### 17.2 Example output

```yaml
server:
  http:
    addr: 127.0.0.1:18080
auth:
  type: none
policies:
  system_dir: /etc/agentsh/system
  dir: /etc/agentsh
  default: policy
workspace: /workspace
sandbox:
  enabled: true
  allow_degraded: true
threat_feeds:
  enabled: true
  action: deny
  feeds:
    - name: urlhaus
      url: https://urlhaus.abuse.ch/downloads/hostfile/
      format: hostfile
      refresh_interval: 6h
    - name: phishing
      url: https://raw.githubusercontent.com/.../phishing-domains-ACTIVE.txt
      format: domain-list
      refresh_interval: 12h
  allowlist:
    - github.com
    - "*.github.com"
    - registry.npmjs.org
    - pypi.org
    ...
```

---

## 18. Integrity

### 18.1 Pinned version

Each library release pins a specific agentsh version. The pinned version
is stored in `core/integrity.py` (also re-exported from `_version.py`):

```python
# core/integrity.py (excerpt)
PINNED_VERSION = "0.14.0"
```

```python
# _version.py
from .core.integrity import PINNED_VERSION  # noqa: F401
__version__ = "0.1.0"
```

### 18.2 Checksum registry

SHA256 checksums for each version and architecture are embedded in the
library. No network call is required for verification.

```python
CHECKSUMS: dict[str, dict[str, str]] = {
    "0.14.0": {
        "linux_amd64": "abc123...",
        "linux_arm64": "def456...",
    },
}
```

### 18.3 Binary URL

```python
def binary_url(
    version: str,
    arch: str,
    override: str | None = None,
) -> str:
    if override:
        return override
    return (
        f"https://github.com/canyonroad/agentsh/releases/download/"
        f"v{version}/agentsh_{version}_{arch}.tar.gz"
    )
```

---

## 19. Open Questions

### 19.1 Vercel Sandbox Python SDK

Vercel Sandbox currently has a TypeScript SDK only. The adapter assumes
a Python-compatible wrapper. Options:

- Wait for Vercel to ship a Python SDK
- Ship a thin HTTP client adapter that calls the Vercel Sandbox REST API
- Document that Vercel adapter requires a user-provided wrapper object

**Recommendation**: Ship the adapter with the documented protocol shape.
Add an HTTP client adapter when the REST API is documented.

### 19.2 Pydantic AI version targeting

Pydantic AI's `AbstractToolset` API is evolving. The Toolset implementation
should target the latest stable release and declare a minimum version in
optional dependencies.

### 19.3 Sync API

The library is async-only. For sync usage, users can wrap with
`asyncio.run()` or use Pydantic AI's `run_sync()`.

---

## 20. Implementation Roadmap

### Phase 1: Core (Week 1-2)

- [ ] `core/types.py` — All protocols and dataclasses
- [ ] `core/errors.py` — Exception hierarchy
- [ ] `core/shell.py` — Shell escaping
- [ ] `core/integrity.py` — Checksums and binary URLs
- [ ] `core/config.py` — Server config generation
- [ ] `policies/schema.py` — Pydantic models
- [ ] `policies/serialize.py` — YAML serialization
- [ ] `policies/presets.py` — All four presets
- [ ] `policies/merge.py` — merge() and merge_prepend()
- [ ] Unit tests for all above

### Phase 2: Provisioning + Runtime (Week 2-3)

- [ ] `core/provision.py` — Full provisioning engine
- [ ] `core/runtime.py` — SecuredSandbox implementations
- [ ] `_api.py` — `secure_sandbox()` entry point
- [ ] Unit tests with mock adapter

### Phase 3: Adapters (Week 3)

- [ ] `adapters/e2b.py`
- [ ] `adapters/daytona.py`
- [ ] `adapters/vercel.py`
- [ ] `adapters/cloudflare.py`
- [ ] `adapters/blaxel.py`
- [ ] Adapter unit tests

### Phase 4: Pydantic AI Toolset (Week 3-4)

- [ ] `toolset/pydantic_ai.py` — SecureSandboxToolset
- [ ] Toolset unit tests

### Phase 5: Testing + E2E (Week 4)

- [ ] `testing/mock.py` — Mock utilities
- [ ] E2E tests against real sandbox providers
- [ ] Documentation and README

### Phase 6: Package + Publish (Week 4)

- [ ] `pyproject.toml` finalized
- [ ] PyPI publish
- [ ] GitHub repo setup
