Skip to content
This repository was archived by the owner on Jun 25, 2026. It is now read-only.
This repository was archived by the owner on Jun 25, 2026. It is now read-only.

feat(runner): add OpenShell sandbox backend as alternative to Docker #195

Description

@rtzll

Summary

Add NVIDIA OpenShell as an alternative execution backend for Rascal runs. OpenShell provides sandboxed, policy-controlled execution environments for AI agents with defense-in-depth security (Landlock filesystem isolation, seccomp syscall filtering, OPA/Rego network policies, and inference routing). This would let Rascal run agents with least-privilege access instead of the current unrestricted Docker containers.

Motivation

Today, Rascal launches agent containers via docker run with broad access: full network, raw API keys as env vars, and no filesystem restrictions beyond Docker's defaults. This works for trusted single-user setups but becomes a security concern when:

  • Running agents on repos from external contributors (e.g., triggered by PR comments)
  • Granting agents access to production credentials (GitHub tokens, API keys)
  • Operating in shared/multi-tenant environments
  • Compliance requires auditability of agent actions

OpenShell addresses all of these with:

  • Granular network policies — allowlist specific hosts/ports per agent run
  • Filesystem isolation — Landlock-based read/write path restrictions
  • Credential isolation — API keys injected at the proxy layer, never visible to the agent process
  • L7 traffic inspection — full visibility into HTTP requests agents make
  • OCSF audit trail — every network decision logged in a standard security format

Design

New Runner Backend: OpenShellLauncher

Implement the existing Runner interface (internal/runner/runner.go) with an OpenShell-backed launcher that communicates with an OpenShell gateway via gRPC.

// internal/runner/openshell.go

type OpenShellLauncher struct {
    GatewayAddr string            // OpenShell gateway gRPC address (e.g., "localhost:8080")
    TLSConfig   *tls.Config       // mTLS config for gateway auth (certs from ~/.config/openshell/)
    DefaultImage string           // Default sandbox container image
    PolicyPath   string           // Path to default sandbox policy YAML
    Providers    []string         // Provider names to attach (e.g., ["github", "anthropic"])
}

Interface Mapping

The Runner interface has four methods. Here's how each maps to OpenShell's gRPC API:

StartDetached(ctx, spec) → (ExecutionHandle, error)

  1. Build SandboxSpec from Rascal's runner.Spec:

    • spec.RunnerImageSandboxTemplate.image (or default image)
    • Policy from l.PolicyPathSandboxSpec.policy (parsed from YAML to proto)
    • l.ProvidersSandboxSpec.providers
    • Map Rascal env vars to SandboxSpec.environment (see env mapping table below)
  2. Call CreateSandbox RPC:

    CreateSandboxRequest {
      name: "rascal-<run_id>"  // sanitized, max 63 chars
      spec: SandboxSpec { ... }
    }
  3. Wait for SANDBOX_PHASE_READY via WatchSandbox RPC:

    WatchSandboxRequest {
      id: sandbox.id
      follow_status: true
      stop_on_terminal: true  // stops streaming at READY or ERROR
    }
  4. Start the agent via ExecSandbox RPC (fire-and-forget in a goroutine):

    ExecSandboxRequest {
      sandbox_id: sandbox.id
      command: ["/usr/local/bin/rascal-runner"]  // same binary, same entrypoint
      workdir: "/work"
      environment: { RASCAL_* env vars }
    }

    Stream stdout/stderr to {spec.RunDir}/runner.log.

  5. Return ExecutionHandle:

    ExecutionHandle{
      Backend: ExecutionBackendOpenShell,  // new constant
      ID:      sandbox.id,
      Name:    sandbox.name,  // "rascal-<run_id>"
    }

Inspect(ctx, handle) → (ExecutionState, error)

Two-level check:

  1. Call GetSandbox RPC — check sandbox.phase:
    • SANDBOX_PHASE_READY → sandbox alive, check exec status
    • SANDBOX_PHASE_ERROR / SANDBOX_PHASE_DELETINGExecutionState{Running: false, ExitCode: &1}
    • SANDBOX_PHASE_PROVISIONINGExecutionState{Running: true}
  2. Check exec goroutine — track whether the ExecSandbox stream has received an exit event:
    • No exit event yet → ExecutionState{Running: true}
    • Exit event received → ExecutionState{Running: false, ExitCode: &exitCode}

Implementation note: The ExecSandbox response stream runs in a background goroutine started during StartDetached. The goroutine updates a shared, mutex-protected execState map keyed by sandbox ID. Inspect reads from this map.

Stop(ctx, handle, timeout) → error

  1. Call DeleteSandbox RPC:
    DeleteSandboxRequest { name: handle.Name }
    OpenShell handles graceful shutdown of the sandbox pod (Kubernetes termination grace period).

Note: OpenShell doesn't have a separate "stop" concept — sandboxes are deleted. The Kubernetes pod gets a SIGTERM and grace period before SIGKILL, similar to docker stop --time.

Remove(ctx, handle) → error

  1. Call DeleteSandbox RPC (same as Stop — idempotent):
    DeleteSandboxRequest { name: handle.Name }
  2. Silently succeed if sandbox already deleted (match Docker behavior).

New Execution Backend Constant

// internal/runner/runner.go
const (
    ExecutionBackendDocker    ExecutionBackend = "docker"
    ExecutionBackendNoop      ExecutionBackend = "noop"
    ExecutionBackendOpenShell ExecutionBackend = "openshell"  // NEW
)

Environment Variable Mapping

The DockerLauncher passes ~25 env vars to the container. The OpenShellLauncher should pass the same set via ExecSandboxRequest.environment and/or SandboxSpec.environment:

Docker Env Var OpenShell Mapping Notes
RASCAL_* (all) ExecSandboxRequest.environment Passed at exec time, not baked into sandbox
GH_TOKEN OpenShell Provider (github type) Injected by OpenShell proxy, never visible to agent
ANTHROPIC_API_KEY OpenShell Provider (anthropic type) Injected by OpenShell inference router
CLAUDE_CODE_OAUTH_TOKEN ExecSandboxRequest.environment Or create a claude provider
GOOSE_* ExecSandboxRequest.environment Agent-specific config
CODEX_HOME ExecSandboxRequest.environment Agent-specific config
GIT_TERMINAL_PROMPT=0 SandboxSpec.environment Static, set at sandbox level
GH_PROMPT_DISABLED=1 SandboxSpec.environment Static, set at sandbox level

Policy Configuration

A default policy file should be included in the repo (e.g., configs/openshell-policy.yaml) that Rascal uses for all sandbox runs:

version: 1

filesystem_policy:
  include_workdir: true
  read_only:
    - /usr
    - /lib
    - /etc
    - /proc
    - /dev/urandom
  read_write:
    - /tmp
    - /work              # repo checkout
    - /rascal-meta       # artifacts

landlock:
  compatibility: best_effort

process:
  run_as_user: sandbox
  run_as_group: sandbox

network_policies:
  github_api:
    name: github-api
    endpoints:
      - host: "*.github.com"
        port: 443
        protocol: rest
        tls: terminate
        enforcement: enforce
        access: read-write
      - host: "*.githubusercontent.com"
        port: 443
        tls: passthrough
        enforcement: enforce
    binaries:
      - path: /usr/bin/gh
      - path: /usr/bin/git
      - path: /usr/bin/curl

  anthropic_api:
    name: anthropic-api
    endpoints:
      - host: api.anthropic.com
        port: 443
        protocol: rest
        tls: terminate
        enforcement: enforce
        access: read-write
    binaries:
      - path: /usr/local/bin/claude

  openai_api:
    name: openai-api
    endpoints:
      - host: api.openai.com
        port: 443
        protocol: rest
        tls: terminate
        enforcement: enforce
        access: read-write
    binaries:
      - path: /usr/local/bin/codex

  package_registries:
    name: package-registries
    endpoints:
      - host: "**.pypi.org"
        port: 443
        tls: passthrough
        enforcement: enforce
      - host: registry.npmjs.org
        port: 443
        tls: passthrough
        enforcement: enforce

Users could override this via rascal config set openshell.policy <path>.

Configuration & Opt-In

Add new config fields to Rascal's server/CLI config:

// internal/config or equivalent
type OpenShellConfig struct {
    Enabled      bool   `json:"enabled"`                // default: false
    GatewayAddr  string `json:"gateway_addr"`           // e.g., "localhost:8080"
    CertDir      string `json:"cert_dir"`               // mTLS certs (default: ~/.config/openshell/)
    DefaultImage string `json:"default_image"`          // sandbox container image
    PolicyPath   string `json:"policy_path"`            // path to policy YAML
    Providers    []string `json:"providers"`             // provider names to attach
}

The Launcher field in the orchestrator's server config selects the backend:

func NewLauncher(cfg Config) runner.Runner {
    if cfg.OpenShell.Enabled {
        return &runner.OpenShellLauncher{
            GatewayAddr:  cfg.OpenShell.GatewayAddr,
            DefaultImage: cfg.OpenShell.DefaultImage,
            PolicyPath:   cfg.OpenShell.PolicyPath,
            Providers:    cfg.OpenShell.Providers,
            // TLS from cert dir
        }
    }
    return &runner.DockerLauncher{
        DefaultImage: cfg.RunnerImage,
        GitHubToken:  cfg.GitHubToken,
    }
}

CLI config commands:

rascal config set runner openshell          # switch backend
rascal config set openshell.gateway localhost:8080
rascal config set openshell.policy ./my-policy.yaml
rascal config set runner docker             # switch back

gRPC Client Setup

Generate Go client stubs from OpenShell's proto files:

proto/openshell.proto    → openshell/v1 service stubs
proto/sandbox.proto      → openshell/sandbox/v1 policy types
proto/datamodel.proto    → openshell/datamodel/v1 model types
proto/inference.proto    → openshell/inference/v1 (optional, for inference routing)

Use buf or protoc-gen-go + protoc-gen-go-grpc to generate. Place generated code in internal/openshell/gen/ or similar. Add proto files as a git submodule or vendor them.

Exec State Tracking

Since ExecSandbox is a streaming RPC (not a detached container), we need to track execution state ourselves:

type OpenShellLauncher struct {
    // ...
    mu        sync.Mutex
    execState map[string]*execTracker  // keyed by sandbox ID
}

type execTracker struct {
    running  bool
    exitCode *int
    err      error
    cancel   context.CancelFunc  // to cancel the exec stream
}

The StartDetached method spawns a goroutine that:

  1. Calls ExecSandbox RPC
  2. Reads the response stream, writing stdout/stderr to runner.log
  3. On receiving ExecSandboxExit, updates execTracker with the exit code
  4. On stream error, updates execTracker with error

Inspect reads from execState map. Stop calls cancel() and then DeleteSandbox.

Volume / File Access Strategy

Docker mounts host directories into the container. OpenShell sandboxes are Kubernetes pods — volume mounts work differently:

Option A: Shared filesystem (simpler)
If the OpenShell gateway runs on the same host as rascald, use Kubernetes hostPath volumes via SandboxTemplate.pod_template:

{
  "spec": {
    "volumes": [{"name": "meta", "hostPath": {"path": "/path/to/rundir"}}],
    "containers": [{"volumeMounts": [{"name": "meta", "mountPath": "/rascal-meta"}]}]
  }
}

Option B: File upload/download (portable)
Use OpenShell's --upload flag or exec-based file transfer:

  1. Before exec: upload instructions, context, credentials via ExecSandbox with stdin
  2. After exec: download artifacts (meta.json, agent.ndjson, etc.) via ExecSandbox running cat
  3. This is more complex but works when gateway is remote

Recommendation: Start with Option A (hostPath) for parity with Docker. Document Option B as a future enhancement for remote gateways.

Session Directory Support

Rascal's session persistence mounts a host directory for agent state across runs. For OpenShell:

  • Use Kubernetes PersistentVolumeClaim or hostPath for session directories
  • Map spec.TaskSession.TaskDir → PV mount at the appropriate container path (/rascal-goose-session, /rascal-codex-session, /rascal-claude-session)
  • Session key/name logic (internal/runner/session.go) remains unchanged

Surfacing Audit Logs

OpenShell logs every network policy decision. Surface these in Rascal:

  1. After run completion, call GetSandboxLogs RPC:
    GetSandboxLogsRequest {
      sandbox_id: sandbox.id
      lines: 1000
      sources: ["sandbox"]  // sandbox-side logs include policy decisions
    }
  2. Write to {runDir}/openshell-audit.log
  3. Surface in rascal logs <run_id> --audit (new flag)

Implementation Plan

Phase 1: Core Backend (MVP)

  1. Vendor/generate proto stubs — Add OpenShell proto files, generate Go gRPC client code
  2. Add ExecutionBackendOpenShell constant — Update internal/runner/runner.go
  3. Implement OpenShellLauncher — New file internal/runner/openshell.go implementing all four Runner methods
  4. Add exec state tracking — Goroutine-based tracking for ExecSandbox stream
  5. Add config fieldsOpenShellConfig struct, NewLauncher factory
  6. Add default policy fileconfigs/openshell-policy.yaml
  7. Wire into orchestrator — Update Server initialization to use config-selected launcher
  8. Update CLIrascal config set runner openshell and related config commands

Phase 2: Credential Isolation

  1. Provider auto-setup — On first rascal deploy with OpenShell enabled, create OpenShell providers for GitHub, Anthropic, etc. from Rascal's credential store
  2. Remove raw token passing — When OpenShell is active, don't pass GH_TOKEN / API keys as env vars; rely on OpenShell's proxy injection
  3. Update worker — Detect OpenShell environment and adjust credential loading (e.g., gh auth works via proxy, no explicit token needed)

Phase 3: Audit & Observability

  1. Fetch and store audit logsGetSandboxLogs after run completion
  2. Add --audit flag to rascal logs — Display OpenShell policy decisions
  3. Policy violation notifications — If agent hits a network deny, surface in GitHub PR comment

Phase 4: Advanced Features

  1. Per-repo policies — Allow repos to ship .rascal/openshell-policy.yaml that overrides the default
  2. Hot-reload policies — Use UpdateConfig RPC to adjust policies mid-run
  3. Remote gateway support — File upload/download for non-local gateways (Option B above)
  4. GPU support — Pass gpu: true in SandboxSpec for ML workloads

Prerequisites

  • An OpenShell gateway must be running and accessible from the rascald host
  • Gateway setup: openshell gateway start (embeds K3s, runs as a single Docker container)
  • mTLS certificates from ~/.config/openshell/ must be accessible to rascald
  • Providers must be pre-configured in OpenShell (openshell provider create ...)

Open Questions

  1. Runner image compatibility — Rascal's current runner images are built for Docker. OpenShell sandboxes use their own base images. Do we build a combined image, or run rascal-runner binary inside OpenShell's base image?
  2. K3s overhead — OpenShell embeds a full K3s cluster. Is this acceptable for Rascal's single-server deployment model, or should we explore a lighter integration?
  3. Session storagehostPath PVs work for single-node but not multi-node. Is this acceptable for the MVP?
  4. Policy authoring UX — Should rascal init generate a default OpenShell policy, or should users bring their own?

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions