# Ptrace Mode, Modal Adapter & Config Parity Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Bring the Python `agentsh-secure-sandbox` to feature parity with the TypeScript `@agentsh/secure-sandbox` v0.16.2, adding ptrace security mode, Modal adapter, comprehensive server config, and package checks.

**Architecture:** Mirror the TypeScript structure using Pythonic conventions (snake_case, Pydantic models). All new config types become Pydantic `BaseModel`s in `core/types.py`. Modal adapter follows the same pattern as sprites adapter. Server config generation expands to handle all `ServerConfigOpts` fields.

**Tech Stack:** Python 3.11+, Pydantic v2, PyYAML, pytest, pytest-asyncio

---

## File Map

| File | Action | Responsibility |
|------|--------|---------------|
| `src/agentsh_secure_sandbox/core/types.py` | Modify | Add ptrace SecurityMode, ServerConfig model, PackageChecksConfig, skip_shim, package_checks, server_config to SecureConfig |
| `src/agentsh_secure_sandbox/core/config.py` | Modify | Expand `generate_server_config()` to handle all ServerConfig fields |
| `src/agentsh_secure_sandbox/core/provision.py` | Modify | Add skip_shim support, version check, install 3 binaries, sessions dir, pass server_config/package_checks |
| `src/agentsh_secure_sandbox/core/integrity.py` | Modify | Update PINNED_VERSION to 0.16.2, add real checksums |
| `src/agentsh_secure_sandbox/core/errors.py` | Modify | Add RuntimeError class |
| `src/agentsh_secure_sandbox/policies/schema.py` | Modify | Add PackageRule, PackageMatch, LicenseSpdxMatch models; add package_rules to PolicyDefinition |
| `src/agentsh_secure_sandbox/policies/serialize.py` | Modify | Add `_serialize_package_rules()` |
| `src/agentsh_secure_sandbox/adapters/modal.py` | Create | `modal()` adapter + `modal_defaults()` |
| `src/agentsh_secure_sandbox/adapters/__init__.py` | Modify | Export modal, modal_defaults |
| `src/agentsh_secure_sandbox/__init__.py` | Modify | Export RuntimeError |
| `pyproject.toml` | Modify | Add modal optional dependency group |
| `tests/test_types.py` | Modify | Add ptrace SecurityMode, ServerConfig tests |
| `tests/test_config.py` | Modify | Add server config generation tests for all new sections |
| `tests/test_provision.py` | Modify | Add skip_shim, ptrace mode tests |
| `tests/test_policies.py` | Modify | Add package rules tests |
| `tests/test_adapters.py` | Modify | Add modal adapter tests |

---

### Task 1: Add ptrace SecurityMode + ServerConfig types

**Files:**
- Modify: `src/agentsh_secure_sandbox/core/types.py`
- Modify: `src/agentsh_secure_sandbox/core/provision.py` (rank update only)
- Modify: `tests/test_types.py`
- Modify: `tests/test_provision.py`

- [ ] **Step 1: Write failing tests for ptrace SecurityMode**

Add to `tests/test_types.py`:
```python
from agentsh_secure_sandbox.core.types import SecureConfig

def test_security_mode_accepts_ptrace():
    cfg = SecureConfig(security_mode="ptrace")
    assert cfg.security_mode == "ptrace"
```

Add to `tests/test_provision.py`:
```python
def test_security_mode_ranking_includes_ptrace():
    assert SECURITY_MODE_RANK["full"] > SECURITY_MODE_RANK["ptrace"]
    assert SECURITY_MODE_RANK["ptrace"] > SECURITY_MODE_RANK["landlock"]
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_types.py::test_security_mode_accepts_ptrace tests/test_provision.py::test_security_mode_ranking_includes_ptrace -v`
Expected: FAIL

- [ ] **Step 3: Add ptrace to SecurityMode and update ranks**

In `core/types.py`, change:
```python
SecurityMode = Literal["full", "ptrace", "landlock", "landlock-only", "minimal"]
```

In `core/provision.py`, change:
```python
SECURITY_MODE_RANK: dict[SecurityMode, int] = {
    "full": 5,
    "ptrace": 4,
    "landlock": 3,
    "landlock-only": 2,
    "minimal": 1,
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_types.py::test_security_mode_accepts_ptrace tests/test_provision.py::test_security_mode_ranking_includes_ptrace tests/test_provision.py::test_security_mode_ranking -v`
Expected: PASS (including the existing ranking test)

- [ ] **Step 5: Write failing tests for ServerConfig and new SecureConfig fields**

Add to `tests/test_types.py`:
```python
from agentsh_secure_sandbox.core.types import (
    SecureConfig, ServerConfig, PtraceConfig, PtraceTraceConfig,
    PtracePerformanceConfig, FuseConfig, PackageChecksConfig,
)

def test_server_config_ptrace():
    sc = ServerConfig(ptrace=PtraceConfig(
        enabled=True,
        attach_mode="children",
        trace=PtraceTraceConfig(execve=True, file=False, network=True, signal=True),
        performance=PtracePerformanceConfig(seccomp_prefilter=False, max_tracees=500),
        on_attach_failure="fail_open",
    ))
    assert sc.ptrace.enabled is True
    assert sc.ptrace.trace.network is True

def test_server_config_fuse():
    sc = ServerConfig(fuse=FuseConfig(deferred=True, deferred_marker_file="/tmp/.marker"))
    assert sc.fuse.deferred is True

def test_secure_config_skip_shim():
    cfg = SecureConfig(skip_shim=True)
    assert cfg.skip_shim is True

def test_secure_config_server_config():
    cfg = SecureConfig(server_config=ServerConfig(allow_degraded=True))
    assert cfg.server_config.allow_degraded is True

def test_secure_config_package_checks():
    cfg = SecureConfig(package_checks=PackageChecksConfig(scope="all_installs"))
    assert cfg.package_checks.scope == "all_installs"

def test_secure_config_package_checks_disabled():
    cfg = SecureConfig(package_checks=False)
    assert cfg.package_checks is False
```

- [ ] **Step 6: Run tests to verify they fail**

Run: `pytest tests/test_types.py -v -k "server_config or skip_shim or package_checks"`
Expected: FAIL (ImportError)

- [ ] **Step 7: Implement ServerConfig and related models**

Add to `core/types.py` (before SecureConfig class):

```python
class GrpcConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    addr: str

class ServerTimeoutsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    read_timeout: str | None = None
    write_timeout: str | None = None
    max_request_size: str | None = None

class LoggingConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    level: str | None = None
    format: str | None = None
    output: str | None = None

class SessionsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    base_dir: str | None = None
    max_sessions: int | None = None
    default_timeout: str | None = None
    idle_timeout: str | None = None
    cleanup_interval: str | None = None

class AuditConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    enabled: bool | None = None
    sqlite_path: str | None = None

class SandboxLimitsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    max_memory_mb: int | None = None
    max_cpu_percent: int | None = None
    max_processes: int | None = None

class FuseConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    deferred: bool | None = None
    deferred_marker_file: str | None = None
    deferred_enable_command: list[str] | None = None

class NetworkInterceptConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    intercept_mode: str | None = None
    proxy_listen_addr: str | None = None

class SeccompDetailsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    execve: bool | None = None
    file_monitor: FileMonitorConfig | None = None

class FileMonitorConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    enabled: bool | None = None
    enforce_without_fuse: bool | None = None

class CgroupsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    enabled: bool | None = None

class UnixSocketsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    enabled: bool | None = None

class PtraceTraceConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    execve: bool | None = None
    file: bool | None = None
    network: bool | None = None
    signal: bool | None = None

class PtracePerformanceConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    seccomp_prefilter: bool | None = None
    max_tracees: int | None = None
    max_hold_ms: int | None = None

class PtraceConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    enabled: bool | None = None
    attach_mode: Literal["children", "pid"] | None = None
    mask_tracer_pid: str | None = None
    trace: PtraceTraceConfig | None = None
    performance: PtracePerformanceConfig | None = None
    on_attach_failure: Literal["fail_open", "fail_closed"] | None = None

class EnvInjectConfig(BaseModel):
    """Arbitrary key-value env vars to inject."""
    model_config = ConfigDict(extra="allow")

class ProxyConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    mode: str | None = None
    port: int | None = None
    providers: dict[str, str] | None = None

class DlpCustomPattern(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str
    display: str
    regex: str

class DlpConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    mode: str | None = None
    patterns: dict[str, bool] | None = None
    custom_patterns: list[DlpCustomPattern] | None = None

class PoliciesOverrideConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    dir: str | None = None
    default_policy: str | None = None

class ApprovalsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    enabled: bool | None = None
    mode: str | None = None
    timeout: str | None = None

class MetricsConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    enabled: bool | None = None
    path: str | None = None

class HealthConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    path: str | None = None
    readiness_path: str | None = None

class DevelopmentConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    disable_auth: bool | None = None
    verbose_errors: bool | None = None

class ProviderConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    enabled: bool | None = None
    priority: int | None = None
    timeout: str | None = None
    on_failure: Literal["warn", "deny", "allow", "approve"] | None = None
    api_key_env: str | None = None
    type: Literal["exec"] | None = None
    command: str | None = None
    options: dict[str, Any] | None = None

class PackageChecksConfig(BaseModel):
    model_config = ConfigDict(extra="forbid")
    scope: Literal["new_packages_only", "all_installs"] | None = None
    providers: dict[str, bool | ProviderConfig] | None = None

class ServerConfig(BaseModel):
    """Extended server configuration — mirrors TS ServerConfigOpts."""
    model_config = ConfigDict(extra="forbid")
    grpc: GrpcConfig | None = None
    server_timeouts: ServerTimeoutsConfig | None = None
    logging: LoggingConfig | None = None
    sessions: SessionsConfig | None = None
    audit: AuditConfig | None = None
    sandbox_limits: SandboxLimitsConfig | None = None
    allow_degraded: bool | None = None
    fuse: FuseConfig | None = None
    network_intercept: NetworkInterceptConfig | None = None
    seccomp_details: SeccompDetailsConfig | None = None
    cgroups: CgroupsConfig | None = None
    unix_sockets: UnixSocketsConfig | None = None
    ptrace: PtraceConfig | None = None
    env_inject: dict[str, str] | None = None
    proxy: ProxyConfig | None = None
    dlp: DlpConfig | None = None
    policies_override: PoliciesOverrideConfig | None = None
    approvals: ApprovalsConfig | None = None
    metrics: MetricsConfig | None = None
    health: HealthConfig | None = None
    development: DevelopmentConfig | None = None
```

Add to `SecureConfig`:
```python
    skip_shim: bool = False
    package_checks: PackageChecksConfig | Literal[False] | None = None
    server_config: ServerConfig | None = None
```

Remove `enforce_redirects` from SecureConfig (not in TS).

Also need to add `from typing import Any` import.

- [ ] **Step 8: Run tests to verify they pass**

Run: `pytest tests/test_types.py -v`
Expected: PASS

- [ ] **Step 9: Run full test suite to check nothing broke**

Run: `pytest tests/ -v --timeout=30`
Expected: All PASS (the enforce_redirects test in test_config.py will fail — fix it)

- [ ] **Step 10: Commit**

```bash
git add -A
git commit -m "feat: add ptrace SecurityMode, ServerConfig types, and new SecureConfig fields"
```

---

### Task 2: Add PackageRule to policy schema + serialization

**Files:**
- Modify: `src/agentsh_secure_sandbox/policies/schema.py`
- Modify: `src/agentsh_secure_sandbox/policies/serialize.py`
- Modify: `tests/test_policies.py`

- [ ] **Step 1: Write failing tests**

Add to `tests/test_policies.py`:
```python
from agentsh_secure_sandbox.policies.schema import PackageRule, PackageMatch, LicenseSpdxMatch

def test_package_rule():
    r = PackageRule(
        match=PackageMatch(finding_type="malware"),
        action="block",
        reason="Known malware",
    )
    assert r.action == "block"

def test_package_match_with_license():
    m = PackageMatch(license_spdx=LicenseSpdxMatch(deny=["GPL-3.0"]))
    assert m.license_spdx.deny == ["GPL-3.0"]

def test_policy_with_package_rules():
    p = PolicyDefinition(
        package_rules=[{
            "match": {"finding_type": "vulnerability", "severity": "critical"},
            "action": "block",
        }]
    )
    assert len(p.package_rules) == 1

def test_serialize_package_rules():
    p = PolicyDefinition(
        package_rules=[{
            "match": {
                "finding_type": "malware",
                "name_patterns": ["evil-*"],
                "ecosystem": "npm",
            },
            "action": "block",
            "reason": "Known malware package",
        }]
    )
    doc = yaml.safe_load(serialize_policy(p))
    rule = doc["package_rules"][0]
    assert rule["match"]["finding_type"] == "malware"
    assert rule["match"]["name_patterns"] == ["evil-*"]
    assert rule["action"] == "block"
    assert rule["reason"] == "Known malware package"
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_policies.py -v -k "package"`
Expected: FAIL

- [ ] **Step 3: Add PackageRule models to schema.py**

Add to `policies/schema.py` before PolicyDefinition:
```python
class LicenseSpdxMatch(BaseModel):
    model_config = ConfigDict(extra="forbid")
    allow: list[str] | None = None
    deny: list[str] | None = None

class PackageMatch(BaseModel):
    model_config = ConfigDict(extra="forbid")
    packages: list[str] | None = None
    name_patterns: list[str] | None = None
    finding_type: str | None = None
    severity: str | list[str] | None = None
    reasons: list[str] | None = None
    license_spdx: LicenseSpdxMatch | None = None
    ecosystem: str | None = None
    options: dict[str, Any] | None = None

class PackageRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    match: PackageMatch
    action: Literal["allow", "warn", "approve", "block"]
    reason: str | None = None
```

Add `package_rules` to PolicyDefinition:
```python
    package_rules: list[PackageRule] | None = None
```

Add `from typing import Any` import.

- [ ] **Step 4: Add serialization for package rules in serialize.py**

Add to `policies/serialize.py`:
```python
from .schema import PackageMatch, PackageRule

def _serialize_package_rules(rules: list[PackageRule]) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for rule in rules:
        match: dict[str, Any] = {}
        if rule.match.packages is not None:
            match["packages"] = rule.match.packages
        if rule.match.name_patterns is not None:
            match["name_patterns"] = rule.match.name_patterns
        if rule.match.finding_type is not None:
            match["finding_type"] = rule.match.finding_type
        if rule.match.severity is not None:
            match["severity"] = rule.match.severity
        if rule.match.reasons is not None:
            match["reasons"] = rule.match.reasons
        if rule.match.license_spdx is not None:
            match["license_spdx"] = rule.match.license_spdx.model_dump(exclude_none=True)
        if rule.match.ecosystem is not None:
            match["ecosystem"] = rule.match.ecosystem
        if rule.match.options is not None:
            match["options"] = rule.match.options
        entry: dict[str, Any] = {"match": match, "action": rule.action}
        if rule.reason is not None:
            entry["reason"] = rule.reason
        out.append(entry)
    return out
```

Add to `serialize_policy()`:
```python
    if policy.package_rules is not None:
        doc["package_rules"] = _serialize_package_rules(policy.package_rules)
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `pytest tests/test_policies.py -v`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: add PackageRule to policy schema and serialization"
```

---

### Task 3: Expand server config generation

**Files:**
- Modify: `src/agentsh_secure_sandbox/core/config.py`
- Modify: `tests/test_config.py`

- [ ] **Step 1: Write failing tests for expanded config generation**

Add to `tests/test_config.py`:
```python
from agentsh_secure_sandbox.core.types import (
    SecureConfig, ServerConfig, PtraceConfig, PtraceTraceConfig,
    PtracePerformanceConfig, FuseConfig, GrpcConfig, LoggingConfig,
    SessionsConfig, AuditConfig, SandboxLimitsConfig, CgroupsConfig,
    UnixSocketsConfig, NetworkInterceptConfig, DlpConfig,
    MetricsConfig, HealthConfig, ApprovalsConfig, PackageChecksConfig,
    ProviderConfig, DevelopmentConfig,
)

def test_config_with_grpc():
    cfg = SecureConfig(server_config=ServerConfig(grpc=GrpcConfig(addr="127.0.0.1:9090")))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["server"]["grpc"]["enabled"] is True
    assert result["server"]["grpc"]["addr"] == "127.0.0.1:9090"

def test_config_with_ptrace():
    cfg = SecureConfig(server_config=ServerConfig(ptrace=PtraceConfig(
        enabled=True,
        attach_mode="children",
        trace=PtraceTraceConfig(execve=True, file=False, network=True, signal=True),
        performance=PtracePerformanceConfig(seccomp_prefilter=False, max_tracees=500, max_hold_ms=5000),
        on_attach_failure="fail_open",
    )))
    result = yaml.safe_load(generate_server_config(cfg))
    ptrace = result["sandbox"]["ptrace"]
    assert ptrace["enabled"] is True
    assert ptrace["attach_mode"] == "children"
    assert ptrace["trace"]["execve"] is True
    assert ptrace["trace"]["file"] is False
    assert ptrace["performance"]["seccomp_prefilter"] is False
    assert ptrace["on_attach_failure"] == "fail_open"

def test_config_with_fuse_deferred():
    cfg = SecureConfig(server_config=ServerConfig(fuse=FuseConfig(
        deferred=True,
        deferred_marker_file="/tmp/.marker",
        deferred_enable_command=["/bin/chmod", "666", "/dev/fuse"],
    )))
    result = yaml.safe_load(generate_server_config(cfg))
    fuse = result["sandbox"]["fuse"]
    assert fuse["deferred"] is True
    assert fuse["deferred_marker_file"] == "/tmp/.marker"

def test_config_with_cgroups_disabled():
    cfg = SecureConfig(server_config=ServerConfig(cgroups=CgroupsConfig(enabled=False)))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["sandbox"]["cgroups"]["enabled"] is False

def test_config_with_unix_sockets():
    cfg = SecureConfig(server_config=ServerConfig(unix_sockets=UnixSocketsConfig(enabled=False)))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["sandbox"]["unix_sockets"]["enabled"] is False

def test_config_with_dlp():
    cfg = SecureConfig(server_config=ServerConfig(dlp=DlpConfig(
        mode="redact",
        patterns={"api_keys": True, "credit_card": True},
    )))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["dlp"]["mode"] == "redact"
    assert result["dlp"]["patterns"]["api_keys"] is True

def test_config_with_sessions():
    cfg = SecureConfig(server_config=ServerConfig(sessions=SessionsConfig(
        default_timeout="1h", idle_timeout="15m",
    )))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["sessions"]["default_timeout"] == "1h"

def test_config_with_audit():
    cfg = SecureConfig(server_config=ServerConfig(audit=AuditConfig(
        enabled=True, sqlite_path="/var/lib/agentsh/events.db",
    )))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["audit"]["enabled"] is True

def test_config_with_sandbox_limits():
    cfg = SecureConfig(server_config=ServerConfig(sandbox_limits=SandboxLimitsConfig(
        max_memory_mb=4096, max_cpu_percent=100,
    )))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["sandbox"]["limits"]["max_memory_mb"] == 4096

def test_config_with_package_checks():
    cfg = SecureConfig(package_checks=PackageChecksConfig(
        scope="new_packages_only",
        providers={"osv": ProviderConfig(enabled=True, priority=1)},
    ))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["package_checks"]["enabled"] is True
    assert result["package_checks"]["scope"] == "new_packages_only"

def test_config_with_env_inject():
    cfg = SecureConfig(server_config=ServerConfig(
        env_inject={"BASH_ENV": "/usr/lib/agentsh/bash_startup.sh"},
    ))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["sandbox"]["env_inject"]["BASH_ENV"] == "/usr/lib/agentsh/bash_startup.sh"

def test_config_sessions_base_dir_default():
    """Sessions base_dir defaults to /var/lib/agentsh/sessions."""
    cfg = SecureConfig()
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["sessions"]["base_dir"] == "/var/lib/agentsh/sessions"

def test_config_with_network_intercept():
    cfg = SecureConfig(server_config=ServerConfig(
        network_intercept=NetworkInterceptConfig(intercept_mode="all", proxy_listen_addr="127.0.0.1:0"),
    ))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["sandbox"]["network"]["intercept_mode"] == "all"

def test_config_with_logging():
    cfg = SecureConfig(server_config=ServerConfig(
        logging=LoggingConfig(level="info", format="text", output="stderr"),
    ))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["logging"]["level"] == "info"

def test_config_with_health():
    cfg = SecureConfig(server_config=ServerConfig(
        health=HealthConfig(path="/health", readiness_path="/ready"),
    ))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["health"]["path"] == "/health"
    assert result["health"]["readiness_path"] == "/ready"

def test_config_with_metrics():
    cfg = SecureConfig(server_config=ServerConfig(
        metrics=MetricsConfig(enabled=True, path="/metrics"),
    ))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["metrics"]["enabled"] is True

def test_config_with_approvals():
    cfg = SecureConfig(server_config=ServerConfig(
        approvals=ApprovalsConfig(enabled=False),
    ))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["approvals"]["enabled"] is False

def test_config_with_development():
    cfg = SecureConfig(server_config=ServerConfig(
        development=DevelopmentConfig(disable_auth=True, verbose_errors=True),
    ))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["development"]["disable_auth"] is True
    assert result["development"]["verbose_errors"] is True

def test_config_allow_degraded():
    cfg = SecureConfig(server_config=ServerConfig(allow_degraded=False))
    result = yaml.safe_load(generate_server_config(cfg))
    assert result["sandbox"]["allow_degraded"] is False
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_config.py -v -k "grpc or ptrace or fuse or cgroups or dlp or sessions or audit or sandbox_limits or package_checks or env_inject or network_intercept or logging or health or metrics or approvals or development or allow_degraded"`
Expected: FAIL

- [ ] **Step 3: Rewrite generate_server_config() to handle all fields**

Rewrite `config.py` `generate_server_config()` to accept both `SecureConfig` fields and the nested `ServerConfig`, mirroring the TS `generateServerConfig()`. The function should:

1. Build base config (server, auth, policies, sandbox defaults)
2. Add `sessions.base_dir` default
3. Handle `real_paths` in sessions
4. Process all ServerConfig fields: grpc, server_timeouts, logging, sessions, audit, sandbox_limits, fuse, network_intercept, seccomp_details, cgroups, unix_sockets, ptrace, env_inject, proxy, dlp, policies_override, approvals, metrics, health, development
5. Handle threat_feeds (existing) and package_checks (new)
6. Convert camelCase ProviderConfig fields to snake_case

Match the TS output exactly: FUSE disabled by default, seccomp disabled by default, network enabled by default, etc.

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_config.py -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: expand server config generation to match TS ServerConfigOpts"
```

---

### Task 4: Update integrity (version + checksums)

**Files:**
- Modify: `src/agentsh_secure_sandbox/core/integrity.py`
- Modify: `tests/test_integrity.py`

- [ ] **Step 1: Write failing test**

Add to `tests/test_integrity.py`:
```python
from agentsh_secure_sandbox.core.integrity import PINNED_VERSION, CHECKSUMS

def test_pinned_version_is_0_16_2():
    assert PINNED_VERSION == "0.16.2"

def test_checksums_include_0_16_2():
    assert "0.16.2" in CHECKSUMS
    assert "linux_amd64" in CHECKSUMS["0.16.2"]
    assert "linux_arm64" in CHECKSUMS["0.16.2"]
    assert not CHECKSUMS["0.16.2"]["linux_amd64"].startswith("placeholder")
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_integrity.py -v -k "0_16_2"`
Expected: FAIL

- [ ] **Step 3: Update integrity.py**

```python
PINNED_VERSION = "0.16.2"

CHECKSUMS: dict[str, dict[str, str]] = {
    "0.16.2": {
        "linux_amd64": "7ff357066a61694626d4c19afa92fdf368318bced9be90391cc2f3808976f995",
        "linux_arm64": "a48b3e4a60804cca98326619a68409e8ee83556d69ee2cf5d574e4361e0c19c6",
    },
    "0.14.0": {
        "linux_amd64": "placeholder_amd64_checksum_to_be_filled",
        "linux_arm64": "placeholder_arm64_checksum_to_be_filled",
    },
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_integrity.py -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: update PINNED_VERSION to 0.16.2 with real checksums"
```

---

### Task 5: Add RuntimeError + export updates

**Files:**
- Modify: `src/agentsh_secure_sandbox/core/errors.py`
- Modify: `src/agentsh_secure_sandbox/__init__.py`
- Modify: `tests/test_errors.py`

- [ ] **Step 1: Write failing test**

Add to `tests/test_errors.py`:
```python
from agentsh_secure_sandbox.core.errors import RuntimeError as AgentshRuntimeError

def test_runtime_error():
    err = AgentshRuntimeError(session_id="sess-1", command="ls", stderr="denied")
    assert err.session_id == "sess-1"
    assert err.command == "ls"
    assert err.stderr == "denied"
    assert "sess-1" in str(err)
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pytest tests/test_errors.py::test_runtime_error -v`
Expected: FAIL

- [ ] **Step 3: Add RuntimeError to errors.py**

Add to `core/errors.py`:
```python
class RuntimeError(AgentSHError):
    """agentsh exec runtime failure."""

    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})")
```

Note: This shadows the builtin `RuntimeError`. To match TS naming, we keep the name but users import it as `from agentsh_secure_sandbox.core.errors import RuntimeError as AgentshRuntimeError` or via the package namespace.

Add to `__init__.py` exports:
```python
from .core.errors import RuntimeError as RuntimeError  # re-export
```

And add to `__all__`.

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_errors.py -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: add RuntimeError exception class"
```

---

### Task 6: Update provisioning for ptrace/skipShim/version-check

**Files:**
- Modify: `src/agentsh_secure_sandbox/core/provision.py`
- Modify: `tests/test_provision.py`

- [ ] **Step 1: Write failing tests**

Add to `tests/test_provision.py`:
```python
async def test_provision_skip_shim():
    """When skip_shim=True, shell shim installation is skipped."""
    detect_json = json.dumps({"security_mode": "ptrace"})
    session_json = json.dumps({"session_id": "sess-ptrace"})

    def exec_side_effect(cmd, args=None, opts=None):
        full = f"{cmd} {' '.join(args or [])}"
        if "uname" in full:
            return ExecResult(stdout="x86_64", stderr="", exit_code=0)
        if "detect" in full:
            return ExecResult(stdout=detect_json, stderr="", exit_code=0)
        if "health" in full or ("curl" in full and "18080" in full):
            return ExecResult(stdout="ok", stderr="", exit_code=0)
        if "session" in full and "create" in full:
            return ExecResult(stdout=session_json, stderr="", exit_code=0)
        if "shim" in full:
            # Should NOT be called when skip_shim=True
            raise AssertionError("shim install-shell should not be called")
        return ExecResult(stdout="", stderr="", exit_code=0)

    adapter = _make_mock_adapter()
    adapter.exec = AsyncMock(side_effect=exec_side_effect)
    adapter.file_exists = AsyncMock(return_value=True)
    cfg = SecureConfig(
        install_strategy="preinstalled",
        skip_integrity_check=True,
        skip_shim=True,
    )
    result = await provision(adapter, cfg)
    assert result.session_id == "sess-ptrace"
    assert result.security_mode == "ptrace"

async def test_provision_ptrace_security_mode():
    """Provision detects ptrace security mode correctly."""
    detect_json = json.dumps({"security_mode": "ptrace"})
    session_json = json.dumps({"session_id": "sess-abc"})

    def exec_side_effect(cmd, args=None, opts=None):
        full = f"{cmd} {' '.join(args or [])}"
        if "uname" in full:
            return ExecResult(stdout="x86_64", stderr="", exit_code=0)
        if "detect" in full:
            return ExecResult(stdout=detect_json, stderr="", exit_code=0)
        if "health" in full or ("curl" in full and "18080" in full):
            return ExecResult(stdout="ok", stderr="", exit_code=0)
        if "session" in full and "create" in full:
            return ExecResult(stdout=session_json, stderr="", exit_code=0)
        return ExecResult(stdout="", stderr="", exit_code=0)

    adapter = _make_mock_adapter()
    adapter.exec = AsyncMock(side_effect=exec_side_effect)
    adapter.file_exists = AsyncMock(return_value=True)
    cfg = SecureConfig(install_strategy="preinstalled", skip_integrity_check=True)
    result = await provision(adapter, cfg)
    assert result.security_mode == "ptrace"
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_provision.py -v -k "skip_shim or ptrace_security"`
Expected: FAIL

- [ ] **Step 3: Update provision.py**

Key changes:
1. Add `skip_shim` support: read from `cfg.skip_shim`, skip `_install_shell_shim()` when True
2. Pass `server_config` and `package_checks` through to `generate_server_config()`:
   - Change config generation to merge `cfg.server_config` fields + top-level fields
3. Create sessions directory (`/var/lib/agentsh/sessions`) with proper permissions
4. Add FUSE `user_allow_other` setup
5. Pass `--workspace` and `--policy` to session create
6. Add session dir chmod after creation
7. Handle trace context setting after session creation

The generate_server_config signature changes: it now takes `SecureConfig` directly (which it already does), and internally extracts `server_config`, `package_checks`, etc.

- [ ] **Step 4: Run tests to verify they pass**

Run: `pytest tests/test_provision.py -v`
Expected: PASS

- [ ] **Step 5: Run full suite**

Run: `pytest tests/ -v --timeout=30`
Expected: All PASS

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: update provisioning for ptrace/skipShim support"
```

---

### Task 7: Create Modal adapter

**Files:**
- Create: `src/agentsh_secure_sandbox/adapters/modal.py`
- Modify: `src/agentsh_secure_sandbox/adapters/__init__.py`
- Modify: `pyproject.toml`
- Modify: `tests/test_adapters.py`

- [ ] **Step 1: Write failing tests**

Add to `tests/test_adapters.py`:
```python
from agentsh_secure_sandbox.adapters.modal import modal, modal_defaults

async def test_modal_exec_basic():
    mock_sb = MagicMock()
    proc = MagicMock()
    proc.wait = AsyncMock()
    proc.stdout = MagicMock()
    proc.stdout.read = AsyncMock(return_value="hello")
    proc.stderr = MagicMock()
    proc.stderr.read = AsyncMock(return_value="")
    proc.returncode = 0
    mock_sb.exec = AsyncMock(return_value=proc)
    adapter = modal(mock_sb)
    result = await adapter.exec("echo", ["hello"])
    assert result.stdout == "hello"
    assert result.exit_code == 0

async def test_modal_exec_drops_sudo():
    """Modal runs as root — sudo flag should be ignored."""
    mock_sb = MagicMock()
    proc = MagicMock()
    proc.wait = AsyncMock()
    proc.stdout = MagicMock()
    proc.stdout.read = AsyncMock(return_value="root")
    proc.stderr = MagicMock()
    proc.stderr.read = AsyncMock(return_value="")
    proc.returncode = 0
    mock_sb.exec = AsyncMock(return_value=proc)
    adapter = modal(mock_sb)
    result = await adapter.exec("whoami", opts=ExecOpts(sudo=True))
    # Should NOT have "sudo" in the command passed to sandbox.exec
    call_args = mock_sb.exec.call_args[0]
    cmd_str = " ".join(call_args)
    assert "sudo" not in cmd_str

async def test_modal_exec_detached():
    mock_sb = MagicMock()
    proc = MagicMock()
    proc.wait = AsyncMock()
    proc.stdout = MagicMock()
    proc.stdout.read = AsyncMock(return_value="")
    proc.stderr = MagicMock()
    proc.stderr.read = AsyncMock(return_value="")
    proc.returncode = 0
    mock_sb.exec = AsyncMock(return_value=proc)
    adapter = modal(mock_sb)
    result = await adapter.exec("sleep", ["100"], opts=ExecOpts(detached=True))
    assert result.exit_code == 0
    # Detached commands still call exec (with nohup wrapper)
    assert mock_sb.exec.called

async def test_modal_write_file():
    mock_sb = MagicMock()
    proc = MagicMock()
    proc.wait = AsyncMock()
    proc.stdout = MagicMock()
    proc.stdout.read = AsyncMock(return_value="")
    proc.stderr = MagicMock()
    proc.stderr.read = AsyncMock(return_value="")
    proc.returncode = 0
    mock_sb.exec = AsyncMock(return_value=proc)
    adapter = modal(mock_sb)
    await adapter.write_file("/tmp/test.txt", "hello world")
    assert mock_sb.exec.called
    # Should use base64 encoding
    call_args = mock_sb.exec.call_args[0]
    cmd_str = " ".join(call_args)
    assert "base64" in cmd_str

async def test_modal_read_file():
    mock_sb = MagicMock()
    proc = MagicMock()
    proc.wait = AsyncMock()
    proc.stdout = MagicMock()
    proc.stdout.read = AsyncMock(return_value="file content")
    proc.stderr = MagicMock()
    proc.stderr.read = AsyncMock(return_value="")
    proc.returncode = 0
    mock_sb.exec = AsyncMock(return_value=proc)
    adapter = modal(mock_sb)
    content = await adapter.read_file("/tmp/test.txt")
    assert content == "file content"

async def test_modal_stop():
    mock_sb = MagicMock()
    mock_sb.terminate = AsyncMock()
    adapter = modal(mock_sb)
    await adapter.stop()
    mock_sb.terminate.assert_called_once()

def test_modal_defaults_has_ptrace():
    cfg = modal_defaults()
    assert cfg.skip_shim is True
    assert cfg.real_paths is True
    assert cfg.install_strategy == "download"
    assert cfg.server_config is not None
    assert cfg.server_config.ptrace is not None
    assert cfg.server_config.ptrace.enabled is True
    assert cfg.server_config.cgroups.enabled is False
    assert cfg.server_config.unix_sockets.enabled is False

def test_modal_defaults_policy_has_system_paths():
    """Modal defaults should extend agent_default with system path allows for ptrace."""
    cfg = modal_defaults()
    assert cfg.policy is not None
    # Should have system lib allows prepended
    file_rules = cfg.policy.file
    first_rule = file_rules[0]
    assert hasattr(first_rule, "allow")
    allows = first_rule.allow if isinstance(first_rule.allow, list) else [first_rule.allow]
    # System libs should be in the first few rules
    all_allows = []
    for r in file_rules[:10]:
        if hasattr(r, "allow"):
            a = r.allow if isinstance(r.allow, list) else [r.allow]
            all_allows.extend(a)
    assert "/lib/**" in all_allows or any("/lib/" in a for a in all_allows)
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/test_adapters.py -v -k "modal"`
Expected: FAIL

- [ ] **Step 3: Create modal adapter**

Create `src/agentsh_secure_sandbox/adapters/modal.py`:

```python
"""Modal adapter — wraps Modal Sandbox for gVisor-based sandboxes.

Modal sandboxes run on gVisor which lacks seccomp user-notify and
full FUSE support. Use modal_defaults() for ptrace-optimized config.
"""
from __future__ import annotations

import base64
from typing import Any

from ..core.shell import shell_escape, fire_and_forget
from ..core.types import (
    ExecOpts,
    ExecResult,
    SecureConfig,
    ServerConfig,
    PtraceConfig,
    PtraceTraceConfig,
    PtracePerformanceConfig,
    FuseConfig,
    CgroupsConfig,
    UnixSocketsConfig,
    GrpcConfig,
    LoggingConfig,
    SessionsConfig,
    AuditConfig,
    SandboxLimitsConfig,
    NetworkInterceptConfig,
    DlpConfig,
    ApprovalsConfig,
    MetricsConfig,
    HealthConfig,
)
from ..policies.presets import agent_default
from ..policies.merge import merge_prepend


def modal(sandbox: Any) -> _ModalAdapter:
    """Wrap a Modal Sandbox object into a SandboxAdapter."""
    return _ModalAdapter(sandbox)


def modal_defaults() -> SecureConfig:
    """Return Modal/gVisor-optimized defaults for SecureConfig.

    Key differences from other providers:
    - ptrace enabled (gVisor blocks seccomp user-notify)
    - seccomp_prefilter disabled (gVisor blocks BPF injection)
    - FUSE deferred (may not be available on gVisor)
    - unix_sockets disabled (mutually exclusive with ptrace)
    - cgroups disabled (Modal handles resource limits)
    - allow_degraded enabled (graceful fallback for FUSE/seccomp)

    Usage::

        secured = await secure_sandbox(
            modal(sb),
            modal_defaults(),  # or override fields as needed
        )
    """
    server_config = ServerConfig(
        grpc=GrpcConfig(addr="127.0.0.1:9090"),
        logging=LoggingConfig(level="info", format="text", output="stderr"),
        sessions=SessionsConfig(
            default_timeout="1h",
            idle_timeout="15m",
            cleanup_interval="5m",
        ),
        audit=AuditConfig(enabled=True, sqlite_path="/var/lib/agentsh/events.db"),
        sandbox_limits=SandboxLimitsConfig(
            max_memory_mb=4096, max_cpu_percent=100, max_processes=256,
        ),
        allow_degraded=True,
        fuse=FuseConfig(
            deferred=True,
            deferred_marker_file="/tmp/.agentsh-fuse-enabled",
            deferred_enable_command=["/bin/chmod", "666", "/dev/fuse"],
        ),
        network_intercept=NetworkInterceptConfig(
            intercept_mode="all", proxy_listen_addr="127.0.0.1:0",
        ),
        cgroups=CgroupsConfig(enabled=False),
        unix_sockets=UnixSocketsConfig(enabled=False),
        ptrace=PtraceConfig(
            enabled=True,
            attach_mode="children",
            mask_tracer_pid="off",
            trace=PtraceTraceConfig(
                execve=True, file=False, network=True, signal=True,
            ),
            performance=PtracePerformanceConfig(
                seccomp_prefilter=False, max_tracees=500, max_hold_ms=5000,
            ),
            on_attach_failure="fail_open",
        ),
        env_inject={"BASH_ENV": "/usr/lib/agentsh/bash_startup.sh"},
        dlp=DlpConfig(
            mode="redact",
            patterns={"api_keys": True, "credit_card": True, "ssn": True},
        ),
        approvals=ApprovalsConfig(enabled=False),
        metrics=MetricsConfig(enabled=True, path="/metrics"),
        health=HealthConfig(path="/health", readiness_path="/ready"),
    )

    # Extend agentDefault with system paths needed for ptrace mode.
    policy = merge_prepend(agent_default(), {
        "file": [
            {"allow": ["/lib/**", "/lib64/**", "/usr/lib/**", "/usr/lib64/**"], "ops": ["read"]},
            {"allow": ["/bin/**", "/sbin/**", "/usr/bin/**", "/usr/sbin/**", "/usr/local/bin/**"], "ops": ["read"]},
            {"allow": ["/etc/ld.so.cache", "/etc/ld.so.conf", "/etc/ld.so.conf.d/**"], "ops": ["read"]},
            {"allow": "/usr/share/**", "ops": ["read"]},
            {"allow": ["/dev/null", "/dev/zero", "/dev/urandom", "/dev/random", "/dev/fd/**"], "ops": ["read", "write"]},
            {"allow": "/proc/self/**", "ops": ["read"]},
            {"allow": ["/tmp/**", "/var/tmp/**"], "ops": ["read", "write", "create"]},
            {"allow": ["/etc/ssl/**", "/etc/ca-certificates/**", "/etc/resolv.conf", "/etc/hosts", "/etc/hostname", "/etc/nsswitch.conf", "/etc/passwd", "/etc/group"], "ops": ["read"]},
        ],
    })

    return SecureConfig(
        policy=policy,
        install_strategy="download",
        real_paths=True,
        skip_shim=True,
        server_config=server_config,
    )


class _ModalAdapter:
    """Wraps a Modal Sandbox object into SandboxAdapter."""

    def __init__(self, sandbox: Any) -> None:
        self._sandbox = sandbox

    async def _run(self, cmd: str) -> ExecResult:
        try:
            proc = await self._sandbox.exec("sh", "-c", cmd)
            await proc.wait()
            stdout_read = proc.stdout.read if hasattr(proc.stdout, "read") else None
            stderr_read = proc.stderr.read if hasattr(proc.stderr, "read") else None
            stdout = (await stdout_read()) if callable(stdout_read) else (proc.stdout or "")
            stderr = (await stderr_read()) if callable(stderr_read) else (proc.stderr or "")
            exit_code = proc.returncode if hasattr(proc, "returncode") else 0
            if callable(exit_code):
                exit_code = exit_code()
            return ExecResult(stdout=stdout or "", stderr=stderr or "", exit_code=exit_code or 0)
        except Exception as exc:
            return ExecResult(
                stdout=getattr(exc, "stdout", "") or "",
                stderr=getattr(exc, "stderr", "") or getattr(exc, "message", str(exc)),
                exit_code=getattr(exc, "returncode", None) or getattr(exc, "exit_code", None) or 1,
            )

    async def exec(
        self,
        cmd: str,
        args: list[str] | None = None,
        opts: ExecOpts | None = None,
    ) -> ExecResult:
        opts = opts or ExecOpts()
        # Modal containers run as root — drop sudo
        command = shell_escape(cmd, args)

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

        return await self._run(command)

    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")
        safe_path = path.replace("'", "'\\''")
        result = await self._run(
            f"printf '%s' '{b64}' | base64 -d > '{safe_path}'"
        )
        if result.exit_code != 0:
            raise Exception(f"writeFile failed (exit {result.exit_code}): {result.stderr}")

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

    async def stop(self) -> None:
        terminate = getattr(self._sandbox, "terminate", None)
        if terminate:
            await terminate()

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

- [ ] **Step 4: Update adapters/__init__.py**

Add:
```python
from .modal import modal, modal_defaults
```

And add to `__all__`.

- [ ] **Step 5: Update pyproject.toml**

Add modal optional dependency:
```toml
modal = ["modal>=0.70"]
```

- [ ] **Step 6: Run tests to verify they pass**

Run: `pytest tests/test_adapters.py -v -k "modal"`
Expected: PASS

- [ ] **Step 7: Run full test suite**

Run: `pytest tests/ -v --timeout=30`
Expected: All PASS

- [ ] **Step 8: Commit**

```bash
git add -A
git commit -m "feat: add Modal adapter with ptrace-optimized defaults"
```

---

### Task 8: Final integration + export cleanup

**Files:**
- Modify: `src/agentsh_secure_sandbox/__init__.py`
- Modify: `tests/test_public_api.py`

- [ ] **Step 1: Write test for new exports**

Add to `tests/test_public_api.py`:
```python
def test_new_exports():
    from agentsh_secure_sandbox import RuntimeError as AgentshRuntimeError
    from agentsh_secure_sandbox.core.types import (
        ServerConfig, PtraceConfig, PackageChecksConfig,
    )
    from agentsh_secure_sandbox.adapters import modal, modal_defaults
    from agentsh_secure_sandbox.policies.schema import PackageRule, PackageMatch

    assert AgentshRuntimeError is not None
    assert ServerConfig is not None
    assert PtraceConfig is not None
    assert PackageChecksConfig is not None
    assert modal is not None
    assert modal_defaults is not None
    assert PackageRule is not None
    assert PackageMatch is not None
```

- [ ] **Step 2: Run test to verify it passes (should already pass from prior tasks)**

Run: `pytest tests/test_public_api.py -v`
Expected: PASS

- [ ] **Step 3: Run full test suite**

Run: `pytest tests/ -v --timeout=30`
Expected: All PASS

- [ ] **Step 4: Run ruff and mypy**

Run: `ruff check src/ tests/` and `mypy src/`
Fix any issues.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: update exports and verify full integration"
```

---

### Task 9: Version bump

**Files:**
- Modify: `pyproject.toml`
- Modify: `src/agentsh_secure_sandbox/_version.py` (if exists)

- [ ] **Step 1: Bump version to 0.2.0**

In `pyproject.toml`:
```toml
version = "0.2.0"
```

- [ ] **Step 2: Commit**

```bash
git add -A
git commit -m "chore: bump version to 0.2.0"
```
