runeward — end-to-end local testing¶
A hands-on walkthrough for exercising the whole stack on your laptop: the Docker and Kubernetes backends, deny-by-default and strict (L3) egress, the governed REST API, snapshots, multi-agent fleets, and wiring the MCP server into Claude Desktop, Cursor, and VS Code.
It also covers the governance/ops surface added since: the framework adapters (9), offline CLI tooling — profile validate/lint, policy scaffold + unit tests, profile signing, the runtime doctor, session replay (10) — API auth & RBAC (11), cost/loop guardrails and the usage API (12), secrets injection (13), audit sinks + offline transcript verification (14), anomaly detection (15), argv-aware policy and the CEL engine (16), and observability: metrics, structured logs, telemetry (17).
It closes with the latest hardening + governance surface: deny-by-default policy (18), custom audit scrubbing (19), workspace-confined file tools (20), the browser SSRF allowlist (21), isolation-preserving snapshot restore (22), profile safety — footgun lint, exempt-UID guard, signed-at-load (23), full RBAC tenant isolation (24), short-lived session tickets (25), policy simulation (26), the egress explorer + budget burn-down (27), the OTLP/SIEM audit sink (28), the Podman backend (29), the BYO-model gateway profile (30), SDK transport hardening (31), and build-time supply-chain hardening (32). Sections 1–8 are the base stack; 9–32 exercise everything added since; 33 cleans up.
Everything below assumes macOS + OrbStack (which gives you both Docker and a one-click Kubernetes cluster), but any Docker + kubectl setup works.
0. Prerequisites¶
| Tool | Why | Check |
|---|---|---|
| Go ≥ 1.25 | build the binaries | go version |
| Docker / OrbStack | Docker backend | docker info |
| Kubernetes (OrbStack/kind/minikube) | K8s backend | kubectl get nodes |
curl, jq |
drive the REST API | jq --version |
| Node/Python (optional) | adapter smoke tests | — |
Pre-pull the images the example profiles use so the first run is fast:
docker pull debian:stable-slim
docker pull rancher/mirrored-library-busybox:1.37.0 # egress-demo (has wget)
docker pull curlimages/curl:8.11.0 # egress-strict (k8s)
Build¶
cd /path/to/sandbox
go build -o bin/runeward ./cmd/runeward
go build -o bin/runeward-egress ./cmd/runeward-egress # only needed for k8s egress image
./bin/runeward version
Handy environment variables¶
| Var | Purpose | Default |
|---|---|---|
RUNEWARD_CONFIG_DIR |
pin the profile search dir | (unset; use --config-dir) |
RUNEWARD_STATE_DIR |
where the audit ledger is written | OS cache dir |
RUNEWARD_KUBE_CONTEXT |
kube-context for the k8s backend | current context |
RUNEWARD_K8S_NAMESPACE |
namespace for sandbox pods | runeward |
RUNEWARD_EGRESS_IMAGE |
egress sidecar/init image ref | runeward-egress:latest |
RUNEWARD_API_TOKEN |
bearer token for serve --token and REST clients |
(unset; open local serve) |
The backend is chosen per profile by
[host].type(container→ Docker,kubernetes→ K8s). There is no global backend switch — you pick by which profile you run.
Use a scratch state dir so test runs are isolated and easy to wipe:
The example profiles live in [examples/](https://github.com/Runewardd/runeward/tree/main/examples):
| Profile | Backend | Demonstrates |
|---|---|---|
dev |
Docker | open profile, interactive shell |
governed |
Docker | policy deny + human-in-the-loop approval |
rego |
Docker | OPA/Rego policy engine (policy_engine = "rego") |
policy-bundle |
Docker | signed OCI policy bundle ([policy_bundle]) |
egress-demo |
Docker | deny-by-default egress (cooperative proxy) |
egress-strict |
K8s | strict L3 egress (iptables + transparent proxy) |
fleet-demo |
Docker | multi-agent fleet + atomic task board |
ns-auto |
Docker | fully worked autonomous-agent contract |
k8s |
K8s | minimal Kubernetes-backed sandbox |
1. CLI smoke test (Docker)¶
# Inspect a profile's resolved, secret-redacted contract
./bin/runeward --config-dir examples print ns-auto
# List discoverable profiles
./bin/runeward --config-dir examples list
# Run a one-off command in a throwaway sandbox, then tear it down
./bin/runeward --config-dir examples dev -- uname -a
# Drop into an interactive shell (Ctrl-D to exit + clean up)
./bin/runeward --config-dir examples dev
Expected: uname -a prints a Linux kernel string (the container's), and the
interactive shell gives you a prompt inside /workspace.
2. Control plane + dashboard (Docker)¶
Start the governed control plane (REST API + web dashboard on :8080). Pick
one of the auth modes below — the rest of this guide assumes $BASE and
$AUTH from 2a.
Without auth (default local dev; serve logs a warning, no dashboard login):
With auth (dashboard login + protected API; required for --bind 0.0.0.0):
export RUNEWARD_API_TOKEN=dev-local # pick any secret; reuse it in the dashboard
./bin/runeward --config-dir examples serve --token "$RUNEWARD_API_TOKEN"
# => auth=true — paste the same token into the dashboard sign-in form
Open http://localhost:8080/ — you get the dashboard (create a sandbox, live
terminal, files, shell/code, audit timeline, approvals inbox, and the Fleets
view). Leave serve running and drive it from a second terminal.
2a. Sandbox lifecycle over REST¶
In a second terminal, set the API base URL and auth header (match how you
started serve above):
BASE=http://localhost:8080
# Without auth:
AUTH=()
# With auth (same value as --token / RUNEWARD_API_TOKEN):
# export RUNEWARD_API_TOKEN=dev-local
# AUTH=(-H "Authorization: Bearer $RUNEWARD_API_TOKEN")
All curl examples use "${AUTH[@]}" — leave AUTH empty (AUTH=()) when
serving without a token. /healthz is always open; everything under /v1,
/mcp, and /metrics requires auth when a token is configured.
# Create a sandbox from the dev profile
SID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"dev"}' | jq -r .id)
echo "sandbox=$SID"
# Run a shell command
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SID/shell/exec \
-d '{"command":["echo","hello from runeward"]}' | jq
# Run Python (NB: the sandbox image must ship python3 — the `dev` profile's
# debian:stable-slim does not. See the note below to test the code runner.)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SID/code/python \
-d '{"code":"print(6*7)"}' | jq '.stdout'
# Write + read a file (read returns content under .content)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SID/file/write \
-d '{"path":"note.txt","content":"remember me"}' | jq '.verdict'
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SID/file/read \
-d '{"path":"note.txt"}' | jq -r '.content'
# List sandboxes (array is under .sandboxes)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes | jq '.sandboxes[].id'
# Tear down
curl -s "${AUTH[@]}" -X DELETE $BASE/v1/sandboxes/$SID | jq
Every response carries a verdict (allow/deny/require-approval),
exit_code, stdout/stderr, and duration_ms — when a call fails, pipe
through | jq (not | jq '.stdout') so you see the real error body.
Sandbox ids are in-memory — restarting
servedrops every session, so re-create the sandbox and re-capture$SIDif a call reportssandbox not found.The Python/code runner shells out to
python3inside the sandbox, so it needs a Python image (thedevprofile'sdebian:stable-slimhas none) — point a profile at e.g.python:3.12-slimand restartserveto see it return42.
2b. Audit ledger + tamper-evidence¶
# This sandbox's events (events are under .events)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SID/audit | jq '.events[].tool'
# Verify the hash chain across the whole ledger
curl -s "${AUTH[@]}" $BASE/v1/audit/verify | jq
# => {"ok":true,"signed":true}
You can also confirm the on-disk ledger grows and is append-only (requires
RUNEWARD_STATE_DIR from 0 — if unset, ls errors with "No such file"):
3. Policy + human-in-the-loop approvals (Docker)¶
Use the governed profile, which denies rm and requires approval
for writes under config/. File tools are workspace-confined (20), so use a
path relative to the workspace* like config/app.conf (an absolute path is
rejected before policy runs and never creates an approval).
# NB: don't name the variable GID/UID/EUID — those are read-only in zsh.
SB=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"governed"}' | jq -r .id)
echo "sandbox=$SB"
# Denied outright by policy -> HTTP 403 + verdict "deny"
curl -s "${AUTH[@]}" -o /dev/null -w "%{http_code}\n" \
$BASE/v1/sandboxes/$SB/shell/exec -d '{"command":["rm","-rf","/tmp/x"]}'
# => 403
# Requires approval: blocks up to 5 min, then returns 202 + approval_id.
# Run in the background (&) so you can approve from another command while it waits.
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SB/file/write \
-d '{"path":"config/app.conf","content":"reviewed"}' &
# See the pending approval (list is under .approvals)
sleep 1
curl -s "${AUTH[@]}" $BASE/v1/approvals | jq
# => {"approvals":[{"id":"…","sandbox_id":"…","tool":"file.write",…}]}
AID=$(curl -s "${AUTH[@]}" $BASE/v1/approvals | jq -r '.approvals[0].id')
echo "approval=$AID"
# Approve it (or /deny) — the blocked background call now proceeds
curl -s "${AUTH[@]}" -X POST $BASE/v1/approvals/$AID/approve | jq
# => {"ok":true}
# The background file/write job prints {"bytes":…,"verdict":"allow"} when done
In the dashboard, the same flow shows up in the Approvals drawer (the count badge in the header increments); approve/deny there and watch the call resolve.
3b. Alternative policy engines (CEL / Rego)¶
The same verdicts can be expressed in CEL or OPA/Rego by setting policy_engine
in the profile. examples/rego.toml mirrors governed's rules in Rego:
RID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"rego"}' | jq -r .id)
# Denied by the Rego module (data.runeward.decision)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$RID/shell/exec \
-d '{"command":["rm","-rf","/tmp/x"]}' | jq '{verdict,reason}'
# => {"verdict":"deny","reason":"destructive command blocked by policy"}
# Allowed (default decision)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$RID/shell/exec \
-d '{"command":["echo","hi"]}' | jq '{verdict,exit_code}'
# => {"verdict":"allow","exit_code":0}
The Rego query (default data.runeward.decision) returns either a bare verdict
string or {"verdict":..., "reason":...} over the input {tool, arg}. Modules
use Rego v1 syntax (if/contains).
3c. Governed browser tool¶
The browser runs headless Chromium inside the sandbox, so it obeys the same
policy verdicts and egress allowlist. The profile's image must ship Chromium
and the runeward-browser driver, and it must not carry an ENTRYPOINT that
fights runeward's sleep infinity keep-alive. Use the browser-stateful
profile (examples/browser-stateful.toml, image runeward-sandbox:dev built
from [deploy/Dockerfile.sandbox](https://github.com/Runewardd/runeward/blob/main/deploy/Dockerfile.sandbox)),
which ships both binaries and keeps the container alive.
On a deny-by-default profile,
runeward-browsertransparently injects the egress proxy's credentials for Chromium (whose--proxy-servercan't carry them). Use an image built for this (runeward-sandbox:dev); ad-hoc images with their ownENTRYPOINT(e.g.zenika/alpine-chrome) fight runeward's keep-alive and exit immediately. If a call returns{"verdict":null,…}, drop the| jq '{…}'filter to see the real error body.
Build the sandbox image once, then create a browser-stateful sandbox:
docker build -f deploy/Dockerfile.sandbox -t runeward-sandbox:dev . # one-time
BID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"browser-stateful"}' | jq -r .id)
echo "browser sandbox=$BID"
# Sanity check the container stayed up (Status should be "Up …", not "Exited"):
docker ps --filter "label=runeward.profile=browser-stateful" --format '{{.Status}}'
One-shot render — fetch a single URL (the profile allows example.com):
# mode "text" returns rendered DOM in .stdout
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$BID/browser \
-d '{"url":"https://example.com/","mode":"text"}' | jq -r '.stdout' | head
# => <!doctype html> … <title>Example Domain</title> … <h1>Example Domain</h1> …
# mode "screenshot" returns a base64 PNG in .stdout — decode it to a file:
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$BID/browser \
-d '{"url":"https://example.com/","mode":"screenshot"}' \
| jq -r '.stdout' | base64 -d > shot.png
file shot.png # => shot.png: PNG image data, … (a real PNG, not 0 bytes)
open shot.png # macOS (Linux: xdg-open shot.png)
Stateful, CDP-driven session — a persistent Chromium page the control plane drives across calls (cookies/DOM/storage persist between actions). Each action is individually governed and audited:
# open -> returns a session id
SESS=$(curl -s "${AUTH[@]}" -X POST $BASE/v1/sandboxes/$BID/browser/sessions | jq -r .session_id)
# act: navigate, then evaluate JS / extract text / screenshot on the SAME page
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$BID/browser/sessions/$SESS/act \
-d '{"action":"navigate","url":"https://example.com/"}' | jq '{verdict,exit_code}'
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$BID/browser/sessions/$SESS/act \
-d '{"action":"title"}' | jq -r .stdout # => Example Domain
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$BID/browser/sessions/$SESS/act \
-d '{"action":"eval","expr":"document.querySelectorAll(\"a\").length"}' | jq -r .stdout # => 1
# screenshot returns a base64 PNG in .stdout — decode the same page to a file:
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$BID/browser/sessions/$SESS/act \
-d '{"action":"screenshot"}' | jq -r .stdout | base64 -d > session-shot.png
file session-shot.png # => PNG image data …
# close -> shuts down the in-sandbox Chromium
curl -s "${AUTH[@]}" -X DELETE $BASE/v1/sandboxes/$BID/browser/sessions/$SESS | jq
Over MCP the session is runeward_browser_open → runeward_browser_act
(action one of navigate|eval|text|html|screenshot|click|type|wait|title|url)
→ runeward_browser_close; the one-shot render is runeward_browser. A
deny-by-default profile constrains what the browser can reach — the session's
HTTP(S) egress proxy is threaded into Chromium via --proxy-server (through the
credential-injecting loopback forwarder described above), so browser traffic is
subject to the same allowlist and audit as shell/file egress.
3d. Signed OCI policy bundles¶
Ship a policy as a signed, versioned OCI artifact and have a profile pull +
verify it. This uses a throwaway local registry, so add plain_http = true to
the profile's [policy_bundle] block for the test.
On macOS the registry is mapped to host port
5001because AirPlay Receiver holds:5000(and answers with a403that looks like a registry error).
# 1. Local registry + signing key (host 5001 -> registry 5000; avoids AirPlay)
docker run -d --rm -p 5001:5000 --name rw-registry registry:2
./bin/runeward bundle keygen --out /tmp/rw-keys # writes bundle.key + bundle.pub
# 2. Author a policy and push it (rego here; --engine cel also works)
cat > /tmp/prod.rego <<'EOF'
package runeward
import rego.v1
default decision := "allow"
decision := {"verdict": "deny", "reason": "destructive command blocked by bundle"} if {
input.tool == "shell"
contains(input.arg, "rm ")
}
EOF
./bin/runeward bundle push oci://localhost:5001/policies:v1 \
--policy /tmp/prod.rego --engine rego --key /tmp/rw-keys/bundle.key --plain-http
# 3. Verify the pull independently (tamper the tag or key to see it fail closed)
./bin/runeward bundle pull oci://localhost:5001/policies:v1 \
--verify-key /tmp/rw-keys/bundle.pub --plain-http
# 4. A profile that consumes it (examples/policy-bundle.toml as a template):
# [policy_bundle]
# ref = "oci://localhost:5001/policies:v1"
# verify_key = "<contents of /tmp/rw-keys/bundle.pub>"
# plain_http = true
# Creating a sandbox from it pulls+verifies the bundle before the first action;
# an invalid signature or wrong key fails sandbox creation (fail-closed).
docker rm -f rw-registry
4. Deny-by-default egress (Docker, cooperative proxy)¶
The egress-demo profile denies all egress except example.com. runeward
starts an in-process host proxy and points the container at it via
HTTP(S)_PROXY.
EID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"egress-demo"}' | jq -r .id)
# Allowed host -> succeeds
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$EID/shell/exec \
-d '{"command":["wget","-qO-","http://example.com/"]}' | jq '.exit_code'
# => 0
# Disallowed host -> blocked by the proxy (non-zero exit)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$EID/shell/exec \
-d '{"command":["wget","-qO-","http://api.github.com/"]}' | jq '.exit_code'
# => non-zero
Docker enforcement is cooperative: an app that ignores
HTTP(S)_PROXYcould bypass it. For bypass-resistant enforcement, use strict mode on Kubernetes (6).
5. Snapshots (Docker)¶
SID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"dev"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SID/file/write -d '{"path":"state.txt","content":"v1"}' >/dev/null
# Capture a snapshot of the workspace
SNAP=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SID/snapshot -d '{"name":"v1"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/snapshots | jq
# Restore into a brand-new governed sandbox
RID=$(curl -s "${AUTH[@]}" -X POST $BASE/v1/snapshots/$SNAP/restore | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$RID/file/read -d '{"path":"state.txt"}' | jq -r .stdout
# => v1
6. Kubernetes backend¶
Point runeward at your cluster (OrbStack exposes it as context
orbstack). Create the namespace and, for strict egress, allow the
privileged capabilities the iptables init container needs:
export RUNEWARD_KUBE_CONTEXT=orbstack # or: kind-kind, minikube, ...
kubectl create namespace runeward
# Only needed for strict L3 egress (NET_ADMIN in an init container):
kubectl label namespace runeward \
pod-security.kubernetes.io/enforce=privileged --overwrite
6a. Basic k8s sandbox¶
Add a k8s profile (or reuse egress-strict without the network block). Quick
inline test using the CLI against a k8s profile — create examples/k8s.toml:
Then:
./bin/runeward --config-dir examples serve # same control plane, now k8s-capable
KID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"k8s"}' | jq -r .id)
# Watch the pod come up
kubectl -n runeward get pods
# Exec through the governed API (goes via client-go remotecommand)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$KID/shell/exec -d '{"command":["hostname"]}' | jq -r .stdout
# => runeward-<id> (the pod name; matches `kubectl -n runeward get pods`)
curl -s "${AUTH[@]}" -X DELETE $BASE/v1/sandboxes/$KID # deletes the Pod + PVC
6b. Strict L3 egress (iptables + transparent SNI proxy)¶
Build the egress image with iptables and make it available to the cluster (on OrbStack/Docker Desktop the local Docker images are shared with the built-in Kubernetes, so no push is needed):
Run a sandbox from the egress-strict profile (allowlist: example.com,
*.githubusercontent.com):
SID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"egress-strict"}' | jq -r .id)
# Inspect the pod — you should see an init container (egress-init) and the
# egress sidecar alongside the sandbox container:
kubectl -n runeward get pod -l runeward.profile=egress-strict -o jsonpath='{.items[0].spec.initContainers[*].name}{"\n"}{.items[0].spec.containers[*].name}{"\n"}'
# Allowed host succeeds (traffic is transparently redirected through the proxy,
# which reads the TLS SNI / HTTP Host and matches the allowlist):
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SID/shell/exec \
-d '{"command":["curl","-sS","-o","/dev/null","-w","%{http_code}","https://example.com/"]}' | jq -r .stdout
# Disallowed host is dropped even though nothing set HTTP_PROXY inside the app:
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SID/shell/exec \
-d '{"command":["curl","-sS","--max-time","8","https://api.github.com/"]}' | jq '.exit_code'
# => non-zero (connection refused/reset by the proxy)
# See the proxy's allow/deny decisions:
kubectl -n runeward logs -l runeward.profile=egress-strict -c egress
Strict mode is Linux/Kubernetes-only. The init container needs
NET_ADMIN; the transparent proxy runs as uid1337and iptables exempts it, so the sandbox container must run as any other uid (the stock images do).
6c. Declarative CRDs + controller (runeward up)¶
Instead of the imperative REST API, you can manage sandboxes and fleets as Kubernetes custom resources reconciled by the runeward controller.
Install the CRDs and controller in one command (idempotent, server-side apply):
# Build the image so the controller Deployment can start (shared with OrbStack k8s)
docker build -f deploy/Dockerfile -t runeward:latest .
# Install CRDs + namespace + RBAC + controller Deployment
./bin/runeward up
# Give the controller profiles to resolve
kubectl -n runeward create configmap runeward-profiles --from-file=examples/
kubectl -n runeward rollout restart deploy/runeward-controller
Or install just the CRDs and run the controller locally (handy for iterating):
./bin/runeward up --crds-only
RUNEWARD_K8S_NAMESPACE=runeward ./bin/runeward --config-dir examples controller
Then drive it declaratively (create examples/k8s.toml per 6a first):
cat <<'EOF' | kubectl apply -f -
apiVersion: runeward.dev/v1alpha1
kind: Sandbox
metadata: { name: demo, namespace: runeward }
spec: { profile: k8s }
EOF
# The controller provisions the pod and fills in .status
kubectl -n runeward get sandboxes
kubectl -n runeward get sandbox demo -o jsonpath='{.status}' ; echo
# => {"phase":"Running","sandboxId":"...","backend":"k8s","image":"debian:stable-slim"}
kubectl -n runeward get pods
# A Fleet works the same way
cat <<'EOF' | kubectl apply -f -
apiVersion: runeward.dev/v1alpha1
kind: Fleet
metadata: { name: crew, namespace: runeward }
spec: { profile: fleet-demo }
EOF
kubectl -n runeward get fleets
# Deleting a CR tears down the backing pods/fleet via a finalizer
kubectl -n runeward delete sandbox demo
Via Helm instead of runeward up (use one installer — they manage the same
objects, so helm install into a namespace that already ran runeward up fails
on ownership; tear the other down first):
helm install runeward deploy/helm/runeward -n runeward --create-namespace \
--set image.tag=latest
helm lint deploy/helm/runeward # what CI runs
# The chart mounts profiles from a Secret named "runeward-profiles" (the
# `runeward up` path uses a ConfigMap); without it the controller reports
# `profile not found`. Create it and restart:
kubectl -n runeward create secret generic runeward-profiles --from-file=examples/
kubectl -n runeward rollout restart deploy/runeward-controller
Uninstall: helm uninstall runeward -n runeward (Helm), or for runeward up:
kubectl delete namespace runeward
kubectl delete clusterrole,clusterrolebinding runeward-controller --ignore-not-found
kubectl delete crd sandboxes.runeward.dev fleets.runeward.dev clusterpolicies.runeward.dev clustersandboxes.runeward.dev clusterfleets.runeward.dev
6d. Org-wide policy defaults (ClusterPolicy + admission webhook)¶
Install the third CRD and enable the webhook via Helm (it self-registers its validating/mutating configs and mints its own serving cert on startup):
./bin/runeward up --crds-only # installs clusterpolicies.runeward.dev too
helm upgrade --install runeward deploy/helm/runeward -n runeward --create-namespace \
--set image.tag=latest --set webhook.enabled=true
kubectl -n runeward rollout status deploy/runeward-webhook
Apply an org policy, then watch it default and gate Sandbox/Fleet creation:
cat <<'EOF' | kubectl apply -f -
apiVersion: runeward.dev/v1alpha1
kind: ClusterPolicy
metadata: { name: org-defaults }
spec:
allowedProfiles: ["k8s", "fleet-*"]
deniedProfiles: ["*-admin"]
defaultProfile: "k8s"
requiredLabels: ["owner"]
EOF
# Missing spec.profile -> mutating webhook fills in defaultProfile ("k8s")
cat <<'EOF' | kubectl apply -f -
apiVersion: runeward.dev/v1alpha1
kind: Sandbox
metadata: { name: defaulted, namespace: runeward, labels: { owner: alice } }
spec: {}
EOF
kubectl -n runeward get sandbox defaulted -o jsonpath='{.spec.profile}'; echo # => k8s
# A denied profile is rejected by the validating webhook
cat <<'EOF' | kubectl apply -f -
apiVersion: runeward.dev/v1alpha1
kind: Sandbox
metadata: { name: blocked, namespace: runeward, labels: { owner: alice } }
spec: { profile: super-admin }
EOF
# => admission webhook "...": profile "super-admin" is denied by ClusterPolicy ...
The validating webhook fails closed (
failurePolicy: Fail): if it is unreachable, admission is blocked rather than silently allowed. Only the mutating defaulting path is best-effort (failurePolicy: Ignore), so a defaulting outage never blocks legitimate work. The decision logic (defaulting, allow/deny globs, namespace + required-label checks) is unit-tested ininternal/webhook.
6e. Cluster-scoped cells (ClusterSandbox / ClusterFleet)¶
For org-shared cells that shouldn't belong to any single team namespace, the
controller also reconciles the cluster-scoped ClusterSandbox / ClusterFleet.
runeward up installs all five CRDs; the controller watches the cluster-scoped
ones cluster-wide (via a ClusterRole) regardless of --all-namespaces:
The examples carry an
ownerlabel to satisfy therequiredLabels: ["owner"]from the 6dClusterPolicy(if you enabled the webhook); omit it and admission denies the request.
cat <<'EOF' | kubectl apply -f -
apiVersion: runeward.dev/v1alpha1
kind: ClusterSandbox
metadata: { name: shared-cell, labels: { owner: alice } }
spec: { profile: k8s }
EOF
kubectl get clustersandboxes # short name: csbx
kubectl get clustersandbox shared-cell -o jsonpath='{.status.phase} {.status.sandboxId}'; echo
# A ClusterFleet fans out the profile's [fleet] replicas onto a shared board.
# Use a k8s-backed fleet profile (fleet-k8s) — the in-cluster controller has no
# Docker daemon, so container-backed fleet-demo would fail with "docker CLI not
# found in PATH".
cat <<'EOF' | kubectl apply -f -
apiVersion: runeward.dev/v1alpha1
kind: ClusterFleet
metadata: { name: shared-crew, labels: { owner: alice } }
spec: { profile: fleet-k8s }
EOF
kubectl get clusterfleets # short name: cflt
# Deleting the CR tears down the backing sandbox/fleet via its finalizer.
kubectl delete clustersandbox shared-cell
kubectl delete clusterfleet shared-crew
Cluster-scoped cells carry no namespace; the controller provisions their
backing Pods/PVCs in its own namespace. When the admission webhook is enabled,
ClusterPolicy governs them too (profile allow/deny + required labels; the
allowedNamespaces constraint is skipped for cluster-scoped resources).
7. Multi-agent fleets¶
A fleet is N sandboxes sharing one atomic task board.
# Create the fleet (fleet-demo => 2 sandboxes seeded with 3 tasks)
FID=$(curl -s "${AUTH[@]}" $BASE/v1/fleets -d '{"profile":"fleet-demo"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/fleets/$FID | jq '{sandboxes, stats}'
# A worker atomically claims the next task
TASK=$(curl -s "${AUTH[@]}" $BASE/v1/fleets/$FID/claim -d '{"owner":"worker-1"}')
echo "$TASK" | jq
TID=$(echo "$TASK" | jq -r '.task.id')
# Complete it (or /fail with {"error":"...","requeue":true})
curl -s "${AUTH[@]}" -X POST $BASE/v1/fleets/$FID/tasks/$TID/complete -d '{"result":"done"}' | jq
# Add a new task and list the board
curl -s "${AUTH[@]}" $BASE/v1/fleets/$FID/tasks -d '{"payload":"extra work"}' | jq
curl -s "${AUTH[@]}" $BASE/v1/fleets/$FID/tasks | jq '.tasks[] | {id, state, owner}'
# Tear the whole fleet down
curl -s "${AUTH[@]}" -X DELETE $BASE/v1/fleets/$FID | jq
The dashboard's Fleets view shows the same board live, with per-task claim/complete/fail controls.
8. MCP integration (Claude Desktop, Cursor, VS Code)¶
runeward speaks the Model Context Protocol over stdio (for editor/desktop
clients) or streamable HTTP (mounted at /mcp on serve, or standalone via
runeward mcp --http). Tools exposed:
- Sandboxes:
runeward_create_sandbox,runeward_shell,runeward_python,runeward_node,runeward_browser,runeward_read_file,runeward_write_file,runeward_list_files,runeward_search_files,runeward_list_approvals,runeward_kill_sandbox. - Fleets:
runeward_create_fleet,runeward_list_fleets,runeward_list_tasks,runeward_add_task,runeward_claim_task,runeward_complete_task,runeward_fail_task,runeward_kill_fleet.
Use an absolute path to the binary and profiles in client configs. Get them:
runeward allows only one writer per audit ledger, so give each concurrent instance its own
RUNEWARD_STATE_DIR(as the configs below do). A second instance on the default dir fails withledger: ... already in use.
8a. Quick HTTP sanity check (no client needed)¶
The streamable-HTTP transport requires the MCP initialize handshake before any
other call, and replies as Server-Sent Events. The handshake returns an
Mcp-Session-Id header you must echo on subsequent requests:
./bin/runeward --config-dir examples mcp --http --port 8081 &
# runeward: MCP (streamable HTTP) at http://localhost:8081/mcp
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
# 1) initialize — grab the session id from the response headers
SID=$(curl -s -D - -o /dev/null http://localhost:8081/mcp "${H[@]}" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
| tr -d '\r' | awk -F': ' 'tolower($1)=="mcp-session-id"{print $2}')
# 2) tell the server we're initialized
curl -s -o /dev/null http://localhost:8081/mcp "${H[@]}" -H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3) list tools — expect all sandbox + fleet tools
curl -s http://localhost:8081/mcp "${H[@]}" -H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| sed -n 's/^data: //p' | grep -o '"name":"runeward_[a-z_]*"' | sort -u
(Real MCP clients — Claude, Cursor, VS Code — do this handshake for you; the manual steps above are just for a dependency-free smoke test.)
8b. Claude Desktop (stdio)¶
Edit ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"runeward": {
"command": "/ABSOLUTE/PATH/sandbox/bin/runeward",
"args": ["--config-dir", "/ABSOLUTE/PATH/sandbox/examples", "mcp"],
"env": { "RUNEWARD_STATE_DIR": "/ABSOLUTE/PATH/runeward-state" }
}
}
}
Restart Claude Desktop → the runeward tools appear under the tools (plug) icon.
Try: "Create a runeward sandbox from the dev profile and run uname -a."
8c. Cursor (stdio)¶
Create ~/.cursor/mcp.json (global) or .cursor/mcp.json (per-project):
{
"mcpServers": {
"runeward": {
"command": "/ABSOLUTE/PATH/sandbox/bin/runeward",
"args": ["--config-dir", "/ABSOLUTE/PATH/sandbox/examples", "mcp"],
"env": { "RUNEWARD_STATE_DIR": "/ABSOLUTE/PATH/runeward-state" }
}
}
}
Open Cursor → Settings → MCP and confirm runeward is connected (green),
then ask the agent to use a runeward tool.
8d. VS Code (Copilot agent mode, stdio)¶
Create .vscode/mcp.json in the workspace:
{
"servers": {
"runeward": {
"type": "stdio",
"command": "/ABSOLUTE/PATH/sandbox/bin/runeward",
"args": ["--config-dir", "/ABSOLUTE/PATH/sandbox/examples", "mcp"],
"env": { "RUNEWARD_STATE_DIR": "/ABSOLUTE/PATH/runeward-state" }
}
}
}
Open the Chat view, switch to Agent mode, press the tools icon and
enable runeward. (Requires VS Code ≥ 1.102 with MCP support.) You can also
point any HTTP-capable client at serve's /mcp endpoint instead of stdio.
9. Framework adapters¶
The [adapters/](https://github.com/Runewardd/runeward/tree/main/adapters)
directory has thin, typed clients over the REST API plus lazy-loaded
agent-framework tool factories. Neither package ships its own test suite (TS has
build/typecheck only), so we smoke-test them against a running serve.
Start one first (from 2) on http://localhost:8080 with $BASE / $AUTH set,
then verify:
curl -s $BASE/healthz # {"status":"ok"} — always open
curl -s "${AUTH[@]}" $BASE/v1/profiles # array of profile names (needs $AUTH when auth is on)
9a. Python (runeward)¶
cd adapters/python
pip install -e . # core client — pure stdlib, no runtime deps
# Importing the top-level package must NOT need any framework installed:
python -c "from runeward import RunewardClient; print('ok:', RunewardClient)"
Smoke-test the governed surface (the client defaults to
http://localhost:8080; pass token=os.environ.get("RUNEWARD_API_TOKEN") when
serve uses --token):
python - <<'PY'
import os
from runeward import RunewardClient
tok = os.environ.get("RUNEWARD_API_TOKEN") # None when serving without a token
rw = RunewardClient("http://localhost:8080", token=tok)
sbx = rw.create_sandbox("dev")
sid = sbx["id"]
r = rw.shell(sid, ["echo", "hello from python adapter"])
print("verdict:", r["verdict"], "exit:", r["exit_code"], "out:", r["stdout"].strip())
print("audit verifies:", rw.verify_audit())
rw.kill_sandbox(sid)
PY
Expected: verdict: allow exit: 0 out: hello from python adapter and
audit verifies: True. A denied action raises RunewardDenied; a gated one
raises RunewardApprovalRequired (carrying approval_id) — governance is
surfaced as exceptions, not swallowed. Methods mirror the MCP tools:
create_sandbox, shell, python, node, read_file, write_file,
list_files, search_files, list_approvals, approve, deny,
kill_sandbox, audit, verify_audit.
Framework tool factories are lazy-loaded — install only the extra you need, then
each submodule exposes make_runeward_tools(client):
pip install -e ".[langchain]" # or: crewai | llamaindex | openai-agents | strands
python -c "
from runeward import RunewardClient
from runeward.langchain_tools import make_runeward_tools
print(len(make_runeward_tools(RunewardClient('http://localhost:8080'))), 'tools')
"
Import paths: runeward.langchain_tools, runeward.crewai_tools,
runeward.llamaindex_tools, runeward.openai_agents_tools,
runeward.strands_tools.
9b. TypeScript (@runeward/sdk)¶
cd adapters/typescript
npm install # installs only `typescript`; the framework peers are optional
npm run build # tsc -> ./dist (builds cleanly with no optional peers installed)
npm run typecheck # tsc --noEmit
The framework adapters import their optional peers dynamically, so
build/typechecksucceed with none of them installed — you only need a framework package at runtime if you call itsmakeRunewardTools.
Smoke-test against the running control plane (methods are camelCase):
node --input-type=module -e "
import { RunewardClient } from './dist/index.js';
const token = process.env.RUNEWARD_API_TOKEN; // omit when serving without a token
const rw = new RunewardClient({ baseUrl: 'http://localhost:8080', token });
const sbx = await rw.createSandbox('dev');
const r = await rw.shell(sbx.id, ['echo', 'hello from ts adapter']);
console.log(r.verdict, r.exit_code, r.stdout.trim());
console.log('audit verifies:', await rw.verifyAudit());
await rw.killSandbox(sbx.id);
"
Framework factories live at subpath exports and are async (dynamic
import()), so peer deps are only needed if you use them:
npm install ai zod # Vercel AI SDK; or @langchain/core zod, or @strands-agents/sdk zod
node --input-type=module -e "
import { RunewardClient, makeRunewardTools } from './dist/index.js'; // ai-tools re-exported from root
const tools = await makeRunewardTools(new RunewardClient({ baseUrl: 'http://localhost:8080' }));
console.log(Object.keys(tools));
"
Subpaths: @runeward/sdk/ai-tools (re-exported at root), @runeward/sdk/langchain-tools,
@runeward/sdk/strands-tools. See Adapters and adapters/README.md.
10. CLI governance tooling (no server required)¶
These commands are static/offline — they never launch a sandbox, so they run anywhere and are ideal in CI.
10a. Validate & lint profiles¶
# Lint every resolvable profile; exits non-zero on any error-severity finding.
./bin/runeward --config-dir examples validate
# Lint specific profiles; --strict also fails on warnings.
./bin/runeward --config-dir examples validate dev governed --strict
Each profile prints ok or a table of [error]/[warn] findings (missing
image, unresolved secret refs, dead egress/policy rules).
10b. Scaffold & unit-test policy¶
# List ready-made policy templates, then print one (paste into a profile).
./bin/runeward policy scaffold --list
./bin/runeward policy scaffold package-approval
# Assert a profile's policy against a table of cases (great for CI).
cat > /tmp/cases.toml <<'EOF'
[[case]]
name = "block rm -rf"
tool = "shell"
action = "rm -rf /"
expect = "deny"
[[case]]
name = "allow echo"
tool = "shell"
action = "echo hi"
expect = "allow"
EOF
./bin/runeward --config-dir examples policy test governed --cases /tmp/cases.toml
# => PASS lines + "2 passed, 0 failed"; exit non-zero if any case fails.
# Inline cases also work (repeatable --case):
./bin/runeward --config-dir examples policy test governed \
--case 'tool=shell,action=rm -rf /,expect=deny'
Bundle-backed profiles (
[policy_bundle]) can't be simulated offline — the policy is remote — andpolicy testsays so explicitly.
10c. Sign & verify a profile (provenance)¶
./bin/runeward bundle keygen --out /tmp/rw-keys # ed25519 keypair
./bin/runeward profile sign examples/governed.toml --key /tmp/rw-keys/bundle.key
# writes examples/governed.toml.sig
./bin/runeward profile verify examples/governed.toml --verify-key /tmp/rw-keys/bundle.pub
# => verified: <key-id>
# Tamper check: edit the profile, re-verify -> non-zero "signature mismatch".
10d. Hardened-runtime doctor (gVisor / Kata)¶
./bin/runeward runtime check # reports what's registered with Docker/k8s
./bin/runeward runtime guide # full gVisor/Kata setup walkthrough
./bin/runeward runtime install gvisor # DRY-RUN by default — prints the plan, mutates nothing
runtime check is a read-only diagnostic (exit 0 unless --strict and nothing
is available). Then wire it in via runtime_class = "gvisor" under [host].
10e. Replay a recorded terminal session¶
Governed interactive terminals are captured as asciinema v2 .cast files, but
recording is opt-in — enable it on serve, then drive a terminal to produce one:
# 1) (Re)start serve with recording on (only one serve can hold the ledger/:8080).
pkill -f 'runeward.*serve'
RUNEWARD_RECORD_TERMINALS=1 ./bin/runeward --config-dir examples serve &
# 2) Open a terminal so a session is recorded: dashboard → pick a sandbox →
# Terminal, run a few commands, close the drawer.
# 3) Grab the newest cast under the state dir (default ~/Library/Caches/runeward
# on macOS, ~/.cache/runeward on Linux):
RECDIR="${RUNEWARD_STATE_DIR:-$HOME/Library/Caches/runeward}/recordings"
CAST="$RECDIR/$(ls -t "$RECDIR" 2>/dev/null | head -1)"
# 4) Replay it instantly or with the original timing:
./bin/runeward replay "$CAST" --no-timing
./bin/runeward replay "$CAST"
Recording is best-effort and output-only (keystrokes are not captured), and never blocks the live session.
11. API authentication & RBAC¶
2 shows the day-to-day with/without token flow ($AUTH on every curl).
This section goes deeper: what is protected, how tokens are presented, and
multi-principal RBAC.
By default serve binds 127.0.0.1 and is unauthenticated (it logs a warning).
Binding a non-loopback interface requires a credential or serve refuses to
start.
11a. Single API token¶
export RUNEWARD_API_TOKEN=s3cret
./bin/runeward --config-dir examples serve --token "$RUNEWARD_API_TOKEN" --no-ui &
BASE=http://localhost:8080
AUTH=(-H "Authorization: Bearer $RUNEWARD_API_TOKEN")
curl -s -o /dev/null -w '%{http_code}\n' $BASE/v1/sandboxes # 401 without $AUTH
curl -s "${AUTH[@]}" $BASE/v1/profiles | jq . # 200
curl -s $BASE/healthz # 200 (always open)
curl -s -o /dev/null -w '%{http_code}\n' "${AUTH[@]}" $BASE/metrics # 200 with auth; 401 without
Tokens may be presented as Authorization: Bearer <t>, X-Runeward-Token: <t>,
or ?token=<t> (the last is for the WebSocket terminal). The dashboard stores
the token in-browser only and sends it as a bearer header on API calls.
11b. Multi-principal RBAC¶
cat > /tmp/authz.json <<'EOF'
{"principals":[
{"name":"root","token":"tok-admin","admin":true},
{"name":"dev","token":"tok-dev","allowed_profiles":["dev"]}
]}
EOF
RUNEWARD_AUTHZ_FILE=/tmp/authz.json ./bin/runeward --config-dir examples serve --no-ui &
curl -s -H "Authorization: Bearer tok-dev" $BASE/v1/whoami | jq .
# => {"authenticated":true,"rbac":true,"principal":{"name":"dev","allowed_profiles":["dev"],...}}
# dev may launch "dev"...
curl -s -H "Authorization: Bearer tok-dev" -d '{"profile":"dev"}' $BASE/v1/sandboxes | jq .id
# ...but not "governed" -> 403
curl -s -H "Authorization: Bearer tok-dev" -d '{"profile":"governed"}' $BASE/v1/sandboxes | jq .
# => {"error":"not authorized to launch profile governed"}
Principal fields: token, admin, can_approve, allowed_profiles (globs).
When an RBAC file is loaded it supersedes --token. A non-loopback bind is
satisfied by --token, RUNEWARD_API_TOKEN, or RUNEWARD_AUTHZ_FILE.
12. Cost & loop guardrails + usage API¶
Guardrails live in [limits] and are enforced after policy passes; a breach
returns HTTP 403 with verdict:"deny" and a reason, and is recorded in the
ledger. Create a scratch profile:
cat > examples/limits-demo.toml <<'EOF'
[host]
type = "container"
image = "debian:stable-slim"
[limits]
max_execs = 1 # 12a
loop_window = "60s" # 12b (max_execs stays high enough not to fire first)
loop_threshold = 3
max_cost_usd = 0.01 # 12c
EOF
# restart serve so it re-reads examples/
Each subsection uses its own fresh sandbox so one guardrail doesn't mask the
next. max_execs = 1 is intentionally tiny for 12a; 12b/12c create sandboxes
where the loop/budget limit is the one that fires (the exec count stays under 1
call for the budget test and the loop trips on failures, not the exec cap — bump
max_execs to e.g. 50 in a copy if you want to hammer the loop harder).
12a. max_execs¶
SB=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"limits-demo"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SB/shell/exec -d '{"command":["echo","1"]}' | jq .verdict # allow
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SB/shell/exec -d '{"command":["echo","2"]}' | jq '{verdict,reason}'
# => {"verdict":"deny","reason":"policy: max execs exceeded"}
12b. Runaway-loop detection¶
Use a profile where the loop limit fires before max_execs. Add one:
cat > examples/loop-demo.toml <<'EOF'
[host]
type = "container"
image = "debian:stable-slim"
[limits]
max_execs = 50
loop_window = "60s"
loop_threshold = 3
EOF
# restart serve, then repeat a *failing* command (non-zero exit = a failure) on
# the same key tool|arg (here shell|false):
LB=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"loop-demo"}' | jq -r .id)
for i in 1 2 3; do curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$LB/shell/exec -d '{"command":["false"]}' | jq -r .verdict; done
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$LB/shell/exec -d '{"command":["false"]}' | jq '{verdict,reason}'
# => {"verdict":"deny","reason":"policy: runaway loop detected"}
12c. Budget via the usage API¶
Agents report token/cost usage; the next governed call is blocked once a budget
is exhausted (reporting itself is never blocked). Use a fresh sandbox from a
budget-only profile so max_execs doesn't fire first:
cat > examples/budget-demo.toml <<'EOF'
[host]
type = "container"
image = "debian:stable-slim"
[limits]
max_cost_usd = 0.01
EOF
# restart serve
BB=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"budget-demo"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$BB/usage -d '{"tokens":0,"cost_usd":0.02}' | jq . # 200, cumulative
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$BB/shell/exec -d '{"command":["echo","blocked"]}' | jq '{verdict,reason}'
# => {"verdict":"deny","reason":"cost budget exhausted: $0.0200/$0.0100 spent"}
# Usage also feeds Prometheus:
curl -s "${AUTH[@]}" $BASE/metrics | grep runeward_usage
Other keys: wall_clock (duration), egress_requests (browser/net only),
max_tokens, plus backend caps memory/cpus. An invalid duration string fails
sandbox creation (400).
13. Secrets injection (fail-closed)¶
Profile [[env]] entries inject secrets resolved fresh per session. Sources are
selected by the op scheme; env:// needs zero external deps and is perfect for
a local test. value (literal) and file (host file) are the non-op forms.
export MY_SECRET=hello-local
cat > examples/secret-env.toml <<'EOF'
[host]
type = "container"
image = "debian:stable-slim"
[[env]]
name = "MY_SECRET"
op = "env://MY_SECRET"
EOF
# restart serve (so it inherits MY_SECRET and re-reads examples/)
SB=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"secret-env"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SB/shell/exec -d '{"command":["printenv","MY_SECRET"]}' | jq -r .stdout
# => [REDACTED] — the value is live inside the sandbox but scrubbed from output.
# Prove it's actually set without leaking it (length only, never the value):
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SB/shell/exec \
-d '{"command":["sh","-lc","test -n \"$MY_SECRET\" && echo present len=${#MY_SECRET}"]}' | jq -r .stdout
# => present len=11
Secrets from
op/filesources (or literals flaggedsecret = true) are scrubbed from both returned stdout/stderr and the audit ledger, so an injected credential never leaks back in cleartext. A plainvalue = "..."literal is not scrubbed and prints as-is.
Fail-closed: point op at an unset var and creation aborts with 400:
# op = "env://DOES_NOT_EXIST" -> POST /v1/sandboxes returns
# {"error":"env \"...\": resolve \"env://DOES_NOT_EXIST\": ... unset or empty"}
Other schemes (need their backend): vault://kv/path#field
(VAULT_ADDR/VAULT_TOKEN), aws://name/key#field, gcp://secret#version.
op:// (1Password) is intentionally not built in and always fails closed.
14. Audit sinks + offline transcript verification¶
14a. Stream every event to a file / webhook¶
Sinks are configured by env vars (a bad URL/file fails serve at startup):
# File sink (JSON Lines, mode 0600):
RUNEWARD_AUDIT_FILE=/tmp/rw-audit.jsonl ./bin/runeward --config-dir examples serve --no-ui &
# ...run any governed action...
tail -1 /tmp/rw-audit.jsonl | jq '{seq,tool,verdict,hash}'
# Webhook sink — capture with a throwaway listener:
python3 -c "
from http.server import HTTPServer, BaseHTTPRequestHandler
class H(BaseHTTPRequestHandler):
def do_POST(self):
n=int(self.headers.get('Content-Length',0)); print(self.rfile.read(n).decode())
self.send_response(200); self.end_headers()
HTTPServer(('127.0.0.1',9999),H).serve_forever()" &
RUNEWARD_AUDIT_WEBHOOK_URL=http://127.0.0.1:9999/hook \
RUNEWARD_AUDIT_WEBHOOK_HEADER="Authorization: Bearer hook-secret" \
./bin/runeward --config-dir examples serve --no-ui
Each event is POSTed as JSON (Content-Type: application/json, retried 3x).
Both sinks can run at once.
14b. Export a signed bundle and verify it offline¶
# Export the whole signed transcript (embeds the public key) from a live serve:
curl -s "${AUTH[@]}" $BASE/v1/audit/export -o /tmp/bundle.json
# Verify with no server running — checks the hash chain AND signatures:
./bin/runeward audit verify /tmp/bundle.json
# => ok: N events verified (hash chain + signatures intact)
GET /v1/audit/verify (live) and GET /v1/audit/pubkey complement this.
15. Anomaly detection¶
The detector is always on; detections are emitted as structured WARN logs on
the serve logger (not audit events or API fields), rate-limited to one per
(session, kind) per minute. Lower a threshold to trip it fast:
RUNEWARD_ANOMALY_EXEC_BURST=3 RUNEWARD_ANOMALY_WINDOW=1m \
./bin/runeward --config-dir examples serve --no-ui 2>&1 | tee /tmp/serve.log &
SB=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"dev"}' | jq -r .id)
for i in 1 2 3 4 5; do curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SB/shell/exec -d '{"command":["echo","'$i'"]}' >/dev/null; done
grep 'anomaly=exec_burst' /tmp/serve.log
Kinds/knobs: exec_burst (RUNEWARD_ANOMALY_EXEC_BURST, window
RUNEWARD_ANOMALY_WINDOW), novel_host (RUNEWARD_ANOMALY_MAX_HOSTS),
denial_spike (RUNEWARD_ANOMALY_MAX_DENIES).
16. argv-aware policy & the CEL engine¶
16a. argv matching (evasion-resistant)¶
match globs the joined command; match_argv globs the real executable
(argv[0], its basename, and the -c payload of sh/bash/… wrappers), so it
catches sh -c "rm ..." evasion:
cat > examples/argv-guard.toml <<'EOF'
[host]
type = "container"
image = "debian:stable-slim"
[[policy]]
tool = "shell"
match_argv = "rm"
verdict = "deny"
reason = "no rm via argv"
EOF
# restart serve
SB=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"argv-guard"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SB/shell/exec -d '{"command":["sh","-c","rm -rf /tmp/x"]}' | jq '{verdict,reason}'
# => {"verdict":"deny","reason":"no rm via argv"}
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$SB/shell/exec -d '{"command":["ls","-la"]}' | jq -r .verdict # allow
16b. CEL policy engine¶
examples/cel-policy.toml mirrors governed's rules with policy_engine = "cel"
and [[cel]] rules over the tool/arg variables:
CID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"cel-policy"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/shell/exec -d '{"command":["rm","-rf","/tmp/x"]}' | jq '{verdict,reason}'
# => deny (destructive command blocked by CEL policy)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/shell/exec -d '{"command":["echo","hi"]}' | jq -r .verdict # allow
You can also unit-test CEL/Rego/argv rules offline with policy test (10b).
17. Observability: metrics, logs, telemetry¶
# Prometheus metrics (same listener as the API; protected when auth is on):
curl -s "${AUTH[@]}" $BASE/metrics | grep -E '^runeward_(actions_total|sandboxes_created_total|build_info)'
# Structured JSON logs to a shipper:
RUNEWARD_LOG_FORMAT=json RUNEWARD_LOG_LEVEL=debug ./bin/runeward --config-dir examples serve --no-ui
# => {"time":"...","level":"INFO","msg":"request","method":"POST","path":"/v1/sandboxes","status":200,...}
# Telemetry is OFF by default; startup logs its state. It only sends when BOTH
# are set (and DO_NOT_TRACK always wins):
# RUNEWARD_TELEMETRY=1 RUNEWARD_TELEMETRY_ENDPOINT=https://collector.example/ingest
/metrics and /healthz are excluded from the access log. A ready-made Grafana
dashboard (deploy/grafana/) and Prometheus alerts (deploy/prometheus/) ship
in-repo — see Observability.
18. Deny-by-default policy mode¶
Turn the biggest authz footgun (unmatched action ⇒ allow) into a fail-closed default, per-profile or operator-wide.
# Per-profile: policy_default is a top-level profile key.
cat > examples/deny-default.toml <<'EOF'
policy_default = "deny"
[host]
type = "container"
image = "debian:stable-slim"
workdir = "/workspace"
EOF
# restart serve, then an unmatched action is denied (no explicit [[policy]] needed):
CID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"deny-default"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/shell/exec -d '{"command":["echo","hi"]}' | jq -r .verdict
# => deny
# Operator-wide switch (no profile edit) — flips the fallback for every profile;
# an explicit profile policy_default still overrides it:
RUNEWARD_POLICY_DEFAULT=deny ./bin/runeward --config-dir examples serve --no-ui
# validate warns when a builtin-policy profile relies on the implicit allow:
./bin/runeward --config-dir examples validate dev --strict # => [warn] implicit allow-by-default …
19. Custom audit scrub patterns¶
Operators can add their own secret shapes on top of the built-in scrubber; a bad regex fails closed at creation.
cat > examples/scrub-demo.toml <<'EOF'
[host]
type = "container"
image = "debian:stable-slim"
workdir = "/workspace"
[audit]
scrub_patterns = ["ACME-[A-Z0-9]{6}"]
EOF
# Invalid regex is rejected at sandbox creation (fail-closed):
# scrub_patterns = ["("] -> POST /v1/sandboxes => 400 "audit.scrub_patterns \"(\": …"
# restart serve; the pattern is masked in returned output AND in the ledger:
CID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"scrub-demo"}' | jq -r .id)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/shell/exec -d '{"command":["echo","key ACME-AB12CD done"]}' | jq -r .stdout
# => the ACME-AB12CD token is replaced with a mask (raw value never returned)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/audit | jq '.events[-1].args' # masked here too
Built-in coverage now also masks sk-ant-/sk-proj-/sk-… (OpenAI/Anthropic),
AWS secret keys, ghp_/github_pat_…, Google AIza…, Slack xox…, and generic
high-entropy blobs — even when the secret was never declared.
20. Workspace-confined file tools¶
The control-plane file.* tools are confined to the sandbox workdir, so policy
is no longer the only thing standing between an agent and /etc/passwd.
CID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"dev"}' | jq -r .id) # dev sets workdir=/workspace
# Absolute path -> refused
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/file/read -d '{"path":"/etc/passwd"}' | jq -r .error
# => path "/etc/passwd" must be relative to workspace
# `..` escape -> refused
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/file/read -d '{"path":"../../etc/passwd"}' | jq -r .error
# => path "../../etc/passwd" escapes workspace root
# In-workspace relative path -> works
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/file/write -d '{"path":"note.txt","content":"hi"}' >/dev/null
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/file/read -d '{"path":"note.txt"}' | jq -r .content # => hi
21. Browser URL-scheme / SSRF allowlist¶
The browser navigate path only accepts http/https (and about:blank) and
blocks loopback, link-local (cloud metadata 169.254.169.254), and private
targets by default.
# (Uses a browser-capable sandbox image; the guard rejects before navigating.)
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/browser -d '{"url":"file:///etc/passwd","mode":"text"}' | jq -r .error
# => scheme "file" not allowed
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/browser -d '{"url":"http://169.254.169.254/","mode":"text"}' | jq -r .error
# => blocked host (link-local / private)
# Allow a trusted internal target only when you must:
# RUNEWARD_BROWSER_ALLOW_PRIVATE_ADDRS=1
The in-container browser control socket also requires a shared secret
(RUNEWARD_BROWSER_CONTROL_TOKEN). Chromium launches with --no-sandbox by
default (both the one-shot render and the stateful driver) because a container
usually lacks the user namespaces Chromium's own sandbox needs; on a host that
provides them (userns) or stronger isolation (gVisor/Kata) keep Chromium's
sandbox with RUNEWARD_BROWSER_NO_SANDBOX=0.
22. Snapshot restore re-derives isolation¶
A restore now runs the full profile→spec path, so egress/hardening/limits come back on the restored cell (it is not an open container).
SID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/snapshot -d '{"name":"v1"}' | jq -r .id)
NID=$(curl -s "${AUTH[@]}" $BASE/v1/snapshots/$SID/restore | jq -r .id)
# The restored sandbox re-applies the profile's egress allowlist:
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$NID/shell/exec \
-d '{"command":["sh","-c","curl -sS https://disallowed.example >/dev/null; echo $?"]}' | jq -r .stdout
# => non-zero (egress enforced, not open)
# If the snapshot's source profile is gone, restore fails closed instead of
# running with no governance:
# => {"error":"load snapshot profile \"…\": …"}
23. Profile safety: footgun lint, exempt-UID guard, signed-at-load¶
# host.user = "1337" collides with the strict-egress exempt uid (a full egress
# bypass). It is now a lint error AND rejected at sandbox creation:
cat > examples/footgun.toml <<'EOF'
[host]
type = "container"
image = "debian:stable-slim"
user = "1337"
[network]
default = "deny"
enforce = "strict"
[[network.rule]]
verdict = "allow"
hostname = "*.debian.org"
EOF
./bin/runeward --config-dir examples validate footgun # => [error] host.user 1337 …
./bin/runeward --config-dir examples validate footgun --strict # also shows isolation-footgun warnings
# Signed-profile enforcement at load — tampered on-disk profiles fail closed.
# (Reuses the keypair + sign flow from 10c.)
./bin/runeward bundle keygen --out /tmp/rw-keys
./bin/runeward profile sign examples/governed.toml --key /tmp/rw-keys/bundle.key
RUNEWARD_REQUIRE_SIGNED_PROFILES=1 RUNEWARD_PROFILE_VERIFY_KEY=/tmp/rw-keys/bundle.pub \
./bin/runeward --config-dir examples print governed # loads only if the .sig verifies
# now edit examples/governed.toml and re-run -> load fails closed (signature mismatch)
24. RBAC tenant isolation across every resource¶
With multi-principal RBAC (11b) a non-admin token is now scoped on fleets,
snapshots, approvals, and audit — not just /v1/sandboxes/{id} — and fleet
create enforces CanLaunch like sandbox create.
# (DEV_TOKEN is a non-admin principal from 11b; ADMIN is an admin token.)
curl -s -H "Authorization: Bearer $DEV_TOKEN" $BASE/v1/fleets | jq # only dev's fleets
curl -s -H "Authorization: Bearer $DEV_TOKEN" $BASE/v1/snapshots | jq # only dev's snapshots
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $DEV_TOKEN" $BASE/v1/audit/export
# => 403 unless the principal is permitted
# Fleet create honors the allowed-profile list:
curl -s -H "Authorization: Bearer $DEV_TOKEN" $BASE/v1/fleets -d '{"profile":"governed"}' | jq -r .error
# => not authorized to launch profile governed
# Fleet complete/fail reject an empty owner (no more hijack with {}):
curl -s -o /dev/null -w '%{http_code}\n' "${AUTH[@]}" $BASE/v1/fleets/$FID/tasks/$TID/complete -d '{}'
# => 400 (owner required)
25. Short-lived session tickets¶
Long-lived bearer tokens no longer need to ride in URLs or localStorage.
# Mint a single-use, short-TTL ticket for the terminal WebSocket:
curl -s "${AUTH[@]}" $BASE/v1/tickets -d '{"kind":"terminal","sandbox_id":"'$CID'"}' | jq
# => {"ticket":"…","expires_at":"…","scope":{"kind":"terminal","sandbox_id":"…"}}
# The socket accepts GET /v1/sandboxes/$CID/terminal?ticket=<one-time>; a second
# use of the same ticket is rejected. Downloads work too:
curl -s "${AUTH[@]}" $BASE/v1/tickets -d '{"kind":"download","path":"/v1/audit/export"}' | jq -r .ticket
# (POST /v1/sandboxes/{id}/terminal-ticket still works and delegates to this store.)
26. Policy simulation (dry-run) over the API¶
"What would happen?" without touching a sandbox — it runs the real engine (builtin/CEL/Rego) and returns a first-match trace. The dashboard "Policy" panel calls the same endpoint.
curl -s "${AUTH[@]}" $BASE/v1/policy/simulate -d '{
"profile_name":"governed",
"actions":[
{"tool":"shell","command":["rm","-rf","/"]},
{"tool":"shell","command":["echo","hi"]}
]}' | jq '.results[] | {tool, verdict, matched_rule}'
# => rm -rf … -> deny (with matched rule); echo hi -> allow
# Inline profile bodies are accepted too (use "profile" instead of "profile_name").
27. Egress explorer + budget burn-down¶
# Per-sandbox egress decisions (host, ip, allow/deny, reason) for the dashboard
# "Egress" panel — sourced from the in-process/cooperative proxy decision log:
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID/egress | jq '.decisions[] | {host, ip, allow, reason}'
# (Strict out-of-process sidecar traffic is not represented by this buffer.)
# Configured limits are now returned on the sandbox object, feeding a read-only
# budget burn-down panel (usage vs caps):
curl -s "${AUTH[@]}" $BASE/v1/sandboxes/$CID | jq '.limits'
# => {"max_tokens":…, "max_cost_usd":…, "max_execs":…, "egress_requests":…, "wall_clock":…}
28. OTLP / SIEM audit sink¶
Fan the ledger out to an OpenTelemetry collector alongside the webhook/file sinks (14), so events land in Datadog/Splunk/Elastic with trace context.
RUNEWARD_AUDIT_OTLP_ENDPOINT=http://localhost:4318 \
RUNEWARD_AUDIT_OTLP_HEADERS="authorization=Bearer XYZ" \
RUNEWARD_AUDIT_OTLP_SERVICE_NAME=runeward \
./bin/runeward --config-dir examples serve --no-ui
# Each governed action is exported as an OTLP log record with attributes:
# session/sandbox, profile, tool, action, verdict, exit_code, duration, reason
# (+ trace context when the event carries one). Emit is non-blocking; on overflow
# the oldest event is dropped and counted, never stalling the action path.
# Other knobs: RUNEWARD_AUDIT_OTLP_INSECURE, RUNEWARD_AUDIT_OTLP_RESOURCE_ATTRS.
Signature verification also tightened: when a signing key is configured,
GET /v1/audit/verify (and runeward audit verify) require every record to
be signed — an unsigned record now fails verification instead of passing.
29. Podman / rootless backend¶
# Force Podman (auto-detected when the docker CLI is absent but podman is present):
RUNEWARD_CONTAINER_RUNTIME=podman ./bin/runeward --config-dir examples serve --no-ui
# Everything in 1–5 works the same; rootless Podman uses host.containers.internal
# for the cooperative proxy host mapping.
podman ps -a --filter "label=runeward.profile" # sandboxes show up under podman
# An invalid value is rejected at startup:
# RUNEWARD_CONTAINER_RUNTIME=nope -> error: unsupported container runtime "nope"
30. Local / self-hosted model profile¶
A ready template that pins an agent's model traffic to a local runtime
(Ollama / vLLM / llama.cpp / LM Studio / LiteLLM — anything exposing an
OpenAI-compatible /v1) with deny-by-default egress. "OpenAI" here is the wire
protocol, not the vendor; nothing leaves for a hosted API.
./bin/runeward --config-dir examples validate byo-model-gateway # => passes (env:// refs warn)
# Point at your local runtime (Ollama's default port shown); the key is a
# placeholder for servers that ignore auth. Injected under OPENAI_BASE_URL,
# OPENAI_API_BASE, and OPENAI_API_KEY so OpenAI SDK / LangChain / LiteLLM agents
# all pick it up:
export OPENAI_BASE_URL=http://host.docker.internal:11434/v1 OPENAI_API_KEY=local
CID=$(curl -s "${AUTH[@]}" $BASE/v1/sandboxes -d '{"profile":"byo-model-gateway"}' | jq -r .id)
# Only the runtime host is reachable; everything else is denied egress.
Full walkthrough (air-gapped, still-audited fleets): Bring your own model.
31. SDK adapter transport hardening¶
# Python: cleartext http:// to a non-loopback host is refused unless opted in.
python3 - <<'PY'
from runeward import RunewardClient
try:
RunewardClient(base_url="http://10.0.0.5:8080") # refused
except Exception as e:
print("refused:", e)
RunewardClient(base_url="http://10.0.0.5:8080", allow_insecure=True) # explicit opt-in
RunewardClient(base_url="http://localhost:8080") # loopback is fine
PY
# TypeScript: new RunewardClient({ baseUrl, allowInsecure: true }); env
# RUNEWARD_ALLOW_INSECURE_HTTP=1 opts in for both. Sandbox/approval IDs are now
# percent-encoded in request paths.
32. Supply-chain hardening (build-time)¶
# Base images are pinned by digest and agent tool versions are fixed:
grep -n '@sha256:' deploy/Dockerfile deploy/Dockerfile.egress deploy/Dockerfile.sandbox deploy/Dockerfile.agent
# Helm ships governance-on by default, with split ServiceAccounts + scoped RBAC:
helm template runeward deploy/helm/runeward | grep -E 'kind: (ServiceAccount|ClusterRole|Role)$'
helm template runeward deploy/helm/runeward | grep -c 'ValidatingWebhookConfiguration' # => 1 (enabled by default)
# install.sh verifies the cosign signature over checksums.txt (bypass only via an
# explicit RUNEWARD_INSECURE_SKIP_* env); CI scanners (gosec/Trivy/CodeQL) now gate.
grep -n 'cosign verify-blob' install.sh
33. Cleanup¶
# Stop `serve` / `mcp` with Ctrl-C.
# Docker: remove leftover sandbox containers (labeled by runeward)
docker ps -a --filter "label=runeward.profile" -q | xargs -r docker rm -f
# Kubernetes: drop the namespace (Pods, PVCs, ConfigMaps go with it)
kubectl delete namespace runeward
# Remove the scratch profiles created above (6a, 12, 13, 16, 18-23, py.toml)
rm -f examples/{limits-demo,loop-demo,budget-demo,secret-env,argv-guard,k8s,py}.toml \
examples/{deny-default,scrub-demo,footgun}.toml examples/governed.toml.sig
# Wipe the audit ledger / snapshots and any scratch files for this run
rm -rf "$RUNEWARD_STATE_DIR" /tmp/rw-keys /tmp/cases.toml /tmp/authz.json \
/tmp/rw-audit.jsonl /tmp/bundle.json /tmp/serve.log
Troubleshooting¶
| Symptom | Likely cause / fix |
|---|---|
serve errors creating a sandbox |
Docker/OrbStack not running (docker info), the profile's image isn't pulled, or docker/podman isn't on PATH in the shell that started serve. |
K8s sandbox stuck Pending |
No default StorageClass for the workspace PVC, or the image can't be pulled by the cluster. kubectl -n runeward describe pod .... |
Strict egress: init container CreateContainerError/denied |
Namespace PodSecurity blocks NET_ADMIN — label it privileged (6). |
Strict egress: runeward-egress ImagePullBackOff |
Build the image (deploy/Dockerfile.egress) or set RUNEWARD_EGRESS_IMAGE to a pushed ref. |
| Strict egress: allowed host still blocked | The app connected by raw IP with no SNI/Host — add a cidr rule, or use a hostname. Check the egress sidecar logs. |
| MCP client shows no tools | Use absolute paths in the config; check the client's MCP logs; verify bin/runeward mcp runs standalone. |
| Approval call never returns | Approve/deny it via GET /v1/approvals + POST /v1/approvals/{id}/approve, or the dashboard drawer. |
Everything returns 401 |
Auth is on. Use AUTH=(-H "Authorization: Bearer $RUNEWARD_API_TOKEN") and "${AUTH[@]}" on every curl (2a); a bare $AUTH="-H Authorization:Bearer …" word-splits the token. |
curl ... \| jq prints nothing (no error) |
curl -s swallows connection errors — usually $BASE is unset (§2a) or serve isn't up. Re-run with -sS to see the real error. |
serve refuses to start on --bind 0.0.0.0 |
Non-loopback binds require a credential — add --token, RUNEWARD_API_TOKEN, or RUNEWARD_AUTHZ_FILE. |
A tool call denies with policy: … exceeded / budget exhausted |
Not a bug — a [limits] guardrail fired (12). Raise the limit or start a fresh sandbox. |
POST /v1/sandboxes returns 400 mentioning resolve "…" |
A [[env]] secret couldn't be resolved (fail-closed, 13). Set the source var/creds, or fix the op scheme. |
| New profile not picked up | serve reads profiles at startup — restart it after editing examples/. Or run runeward validate first (10a). |
policy test says it can't simulate |
The profile sources policy from an OCI bundle; bundle-backed policy is remote and can't be tested offline (10b). |
file.* returns must be relative to workspace / escapes workspace root |
Not a bug — file tools are confined to the sandbox workdir (20). Use a path relative to the workspace. |
Browser navigate returns scheme … not allowed / blocked host |
The URL-scheme/SSRF allowlist fired (21). Only http/https public hosts are allowed; set RUNEWARD_BROWSER_ALLOW_PRIVATE_ADDRS=1 for a trusted internal target. |
| Unmatched action denied unexpectedly | Deny-by-default is on via the profile policy_default or RUNEWARD_POLICY_DEFAULT=deny (18). Add an explicit [[policy]] allow rule. |
| OTLP sink: no records arrive | Check RUNEWARD_AUDIT_OTLP_ENDPOINT/_HEADERS and collector reachability; the sink is non-blocking and drops (and counts) on overflow (28). |
unsupported container runtime "…" at startup |
RUNEWARD_CONTAINER_RUNTIME must be docker or podman (29). |
ledger: "…/ledger.jsonl" is already in use by another runeward process |
One writer per audit ledger. Another serve/mcp holds the default state dir — give this instance its own RUNEWARD_STATE_DIR (8), or stop the other process. |
runeward mcp --http → bind: address already in use |
A previous mcp/serve still owns the port. pkill -f 'runeward.*mcp' (or stop serve), or start with a different --port. |