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)
-
Build SandboxSpec from Rascal's runner.Spec:
spec.RunnerImage → SandboxTemplate.image (or default image)
- Policy from
l.PolicyPath → SandboxSpec.policy (parsed from YAML to proto)
l.Providers → SandboxSpec.providers
- Map Rascal env vars to
SandboxSpec.environment (see env mapping table below)
-
Call CreateSandbox RPC:
CreateSandboxRequest {
name: "rascal-<run_id>" // sanitized, max 63 chars
spec: SandboxSpec { ... }
}
-
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
}
-
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.
-
Return ExecutionHandle:
ExecutionHandle{
Backend: ExecutionBackendOpenShell, // new constant
ID: sandbox.id,
Name: sandbox.name, // "rascal-<run_id>"
}
Inspect(ctx, handle) → (ExecutionState, error)
Two-level check:
- Call
GetSandbox RPC — check sandbox.phase:
SANDBOX_PHASE_READY → sandbox alive, check exec status
SANDBOX_PHASE_ERROR / SANDBOX_PHASE_DELETING → ExecutionState{Running: false, ExitCode: &1}
SANDBOX_PHASE_PROVISIONING → ExecutionState{Running: true}
- 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
- 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
- Call
DeleteSandbox RPC (same as Stop — idempotent):
DeleteSandboxRequest { name: handle.Name }
- 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:
- Calls
ExecSandbox RPC
- Reads the response stream, writing stdout/stderr to
runner.log
- On receiving
ExecSandboxExit, updates execTracker with the exit code
- 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:
- Before exec: upload instructions, context, credentials via
ExecSandbox with stdin
- After exec: download artifacts (
meta.json, agent.ndjson, etc.) via ExecSandbox running cat
- 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:
- After run completion, call
GetSandboxLogs RPC:
GetSandboxLogsRequest {
sandbox_id: sandbox.id
lines: 1000
sources: ["sandbox"] // sandbox-side logs include policy decisions
}
- Write to
{runDir}/openshell-audit.log
- Surface in
rascal logs <run_id> --audit (new flag)
Implementation Plan
Phase 1: Core Backend (MVP)
- Vendor/generate proto stubs — Add OpenShell proto files, generate Go gRPC client code
- Add
ExecutionBackendOpenShell constant — Update internal/runner/runner.go
- Implement
OpenShellLauncher — New file internal/runner/openshell.go implementing all four Runner methods
- Add exec state tracking — Goroutine-based tracking for
ExecSandbox stream
- Add config fields —
OpenShellConfig struct, NewLauncher factory
- Add default policy file —
configs/openshell-policy.yaml
- Wire into orchestrator — Update
Server initialization to use config-selected launcher
- Update CLI —
rascal config set runner openshell and related config commands
Phase 2: Credential Isolation
- Provider auto-setup — On first
rascal deploy with OpenShell enabled, create OpenShell providers for GitHub, Anthropic, etc. from Rascal's credential store
- Remove raw token passing — When OpenShell is active, don't pass
GH_TOKEN / API keys as env vars; rely on OpenShell's proxy injection
- Update worker — Detect OpenShell environment and adjust credential loading (e.g.,
gh auth works via proxy, no explicit token needed)
Phase 3: Audit & Observability
- Fetch and store audit logs —
GetSandboxLogs after run completion
- Add
--audit flag to rascal logs — Display OpenShell policy decisions
- Policy violation notifications — If agent hits a network deny, surface in GitHub PR comment
Phase 4: Advanced Features
- Per-repo policies — Allow repos to ship
.rascal/openshell-policy.yaml that overrides the default
- Hot-reload policies — Use
UpdateConfig RPC to adjust policies mid-run
- Remote gateway support — File upload/download for non-local gateways (Option B above)
- 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
- 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?
- 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?
- Session storage —
hostPath PVs work for single-node but not multi-node. Is this acceptable for the MVP?
- Policy authoring UX — Should
rascal init generate a default OpenShell policy, or should users bring their own?
References
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 runwith 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:OpenShell addresses all of these with:
Design
New Runner Backend:
OpenShellLauncherImplement the existing
Runnerinterface (internal/runner/runner.go) with an OpenShell-backed launcher that communicates with an OpenShell gateway via gRPC.Interface Mapping
The
Runnerinterface has four methods. Here's how each maps to OpenShell's gRPC API:StartDetached(ctx, spec) → (ExecutionHandle, error)Build
SandboxSpecfrom Rascal'srunner.Spec:spec.RunnerImage→SandboxTemplate.image(or default image)l.PolicyPath→SandboxSpec.policy(parsed from YAML to proto)l.Providers→SandboxSpec.providersSandboxSpec.environment(see env mapping table below)Call
CreateSandboxRPC:CreateSandboxRequest { name: "rascal-<run_id>" // sanitized, max 63 chars spec: SandboxSpec { ... } }Wait for
SANDBOX_PHASE_READYviaWatchSandboxRPC:WatchSandboxRequest { id: sandbox.id follow_status: true stop_on_terminal: true // stops streaming at READY or ERROR }Start the agent via
ExecSandboxRPC (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.Return
ExecutionHandle:Inspect(ctx, handle) → (ExecutionState, error)Two-level check:
GetSandboxRPC — checksandbox.phase:SANDBOX_PHASE_READY→ sandbox alive, check exec statusSANDBOX_PHASE_ERROR/SANDBOX_PHASE_DELETING→ExecutionState{Running: false, ExitCode: &1}SANDBOX_PHASE_PROVISIONING→ExecutionState{Running: true}ExecSandboxstream has received anexitevent:ExecutionState{Running: true}ExecutionState{Running: false, ExitCode: &exitCode}Implementation note: The
ExecSandboxresponse stream runs in a background goroutine started duringStartDetached. The goroutine updates a shared, mutex-protectedexecStatemap keyed by sandbox ID.Inspectreads from this map.Stop(ctx, handle, timeout) → errorDeleteSandboxRPC:DeleteSandboxRequest { name: handle.Name }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) → errorDeleteSandboxRPC (same as Stop — idempotent):DeleteSandboxRequest { name: handle.Name }New Execution Backend Constant
Environment Variable Mapping
The
DockerLauncherpasses ~25 env vars to the container. TheOpenShellLaunchershould pass the same set viaExecSandboxRequest.environmentand/orSandboxSpec.environment:RASCAL_*(all)ExecSandboxRequest.environmentGH_TOKENgithubtype)ANTHROPIC_API_KEYanthropictype)CLAUDE_CODE_OAUTH_TOKENExecSandboxRequest.environmentclaudeproviderGOOSE_*ExecSandboxRequest.environmentCODEX_HOMEExecSandboxRequest.environmentGIT_TERMINAL_PROMPT=0SandboxSpec.environmentGH_PROMPT_DISABLED=1SandboxSpec.environmentPolicy Configuration
A default policy file should be included in the repo (e.g.,
configs/openshell-policy.yaml) that Rascal uses for all sandbox runs:Users could override this via
rascal config set openshell.policy <path>.Configuration & Opt-In
Add new config fields to Rascal's server/CLI config:
The
Launcherfield in the orchestrator's server config selects the backend:CLI config commands:
gRPC Client Setup
Generate Go client stubs from OpenShell's proto files:
Use
buforprotoc-gen-go+protoc-gen-go-grpcto generate. Place generated code ininternal/openshell/gen/or similar. Add proto files as a git submodule or vendor them.Exec State Tracking
Since
ExecSandboxis a streaming RPC (not a detached container), we need to track execution state ourselves:The
StartDetachedmethod spawns a goroutine that:ExecSandboxRPCrunner.logExecSandboxExit, updatesexecTrackerwith the exit codeexecTrackerwith errorInspectreads fromexecStatemap.Stopcallscancel()and thenDeleteSandbox.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 KuberneteshostPathvolumes viaSandboxTemplate.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
--uploadflag or exec-based file transfer:ExecSandboxwithstdinmeta.json,agent.ndjson, etc.) viaExecSandboxrunningcatRecommendation: 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:
PersistentVolumeClaimorhostPathfor session directoriesspec.TaskSession.TaskDir→ PV mount at the appropriate container path (/rascal-goose-session,/rascal-codex-session,/rascal-claude-session)internal/runner/session.go) remains unchangedSurfacing Audit Logs
OpenShell logs every network policy decision. Surface these in Rascal:
GetSandboxLogsRPC:GetSandboxLogsRequest { sandbox_id: sandbox.id lines: 1000 sources: ["sandbox"] // sandbox-side logs include policy decisions }{runDir}/openshell-audit.lograscal logs <run_id> --audit(new flag)Implementation Plan
Phase 1: Core Backend (MVP)
ExecutionBackendOpenShellconstant — Updateinternal/runner/runner.goOpenShellLauncher— New fileinternal/runner/openshell.goimplementing all fourRunnermethodsExecSandboxstreamOpenShellConfigstruct,NewLauncherfactoryconfigs/openshell-policy.yamlServerinitialization to use config-selected launcherrascal config set runner openshelland related config commandsPhase 2: Credential Isolation
rascal deploywith OpenShell enabled, create OpenShell providers for GitHub, Anthropic, etc. from Rascal's credential storeGH_TOKEN/ API keys as env vars; rely on OpenShell's proxy injectiongh authworks via proxy, no explicit token needed)Phase 3: Audit & Observability
GetSandboxLogsafter run completion--auditflag torascal logs— Display OpenShell policy decisionsPhase 4: Advanced Features
.rascal/openshell-policy.yamlthat overrides the defaultUpdateConfigRPC to adjust policies mid-rungpu: trueinSandboxSpecfor ML workloadsPrerequisites
rascaldhostopenshell gateway start(embeds K3s, runs as a single Docker container)~/.config/openshell/must be accessible torascaldopenshell provider create ...)Open Questions
rascal-runnerbinary inside OpenShell's base image?hostPathPVs work for single-node but not multi-node. Is this acceptable for the MVP?rascal initgenerate a default OpenShell policy, or should users bring their own?References
openshell.proto,sandbox.proto,datamodel.proto,inference.protoRunner,Spec,ExecutionHandle,ExecutionState