Isolate AI coding agents like Pi with kernel-level policies and a locally served vLLM model
Verify the OS, GPU, Docker, and Python are available on your device.
head -n 2 /etc/os-release
nvidia-smi
docker info --format '{{.ServerVersion}}'
python3 --version
Expected output should show Ubuntu 24.04 (or compatible DGX OS), a detected GPU, a Docker server version, and Python 3.12+.
NOTE
This playbook is validated against OpenShell v0.1.2 (Step 3 pins this version). If an OpenShell installation is already present on this machine, uninstall it first (see the OpenShell uninstall docs) before continuing.
Verify that the local user has Docker permissions:
docker ps
If you get a permission denied error (permission denied while trying to connect to the docker API at unix:///var/run/docker.sock), add your user to the Docker group:
sudo usermod -aG docker $USER
newgrp docker
Reboot (or log out and back in) so the group membership applies to all sessions.
Configure Docker to use the NVIDIA Container Runtime:
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
Verify GPU access inside a container:
docker run --rm --runtime=nvidia --gpus all ubuntu nvidia-smi
Install OpenShell with the official installer, which installs the CLI, the policy prover, and a local gateway in one step, then starts the gateway automatically. This playbook is validated against OpenShell v0.1.2, so pin that release:
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | OPENSHELL_VERSION=v0.1.2 sh
NOTE
To use a different release, set OPENSHELL_VERSION to another release tag, or OPENSHELL_VERSION=pre/dev for a prerelease/rolling build — but command behavior in this playbook (such as --upload and profile lint) is only confirmed against v0.1.2. See the OpenShell installation docs for macOS, Snap, and Kubernetes install paths. The installer is the only supported install route and is required for the gateway.
The installer picks a package for your platform (a .deb on Ubuntu, .rpm on Fedora/RHEL) and registers the openshell-gateway systemd user service. Open a new shell (or source ~/.bashrc) so openshell is on your PATH, then confirm the CLI can reach the gateway:
openshell status
Expected: Status: Connected.
The installer manages the gateway as a systemd user service, listening at https://127.0.0.1:17670 and reading ~/.config/openshell/gateway.toml.
TIP
To manage a gateway on remote hardware from a separate workstation, register it as a named gateway instead of connecting to 127.0.0.1: openshell gateway add https://<hardware-ip-or-hostname>:17670 --name <name> (mTLS gateways need the CLI client certificate in place first). See Manage Gateways for the full registration and authentication flow.
Confirm the service is running, and that the CLI can reach it:
systemctl --user status --no-pager openshell-gateway
openshell status
openshell status should report Status: Connected and Authentication: Authenticated. If the service is not running, start it:
systemctl --user start openshell-gateway
systemctl --user status --no-pager openshell-gateway
openshell status
To keep the gateway available after you log out:
sudo loginctl enable-linger $USER
Follow gateway logs in real time (press Ctrl+C to exit) using the following command in a new terminal window:
journalctl --user -u openshell-gateway -f
Set up an OpenAI-compatible API for local inference with the vLLM playbooks, which cover model and recipe selection for your hardware platform:
Keep --host 0.0.0.0 and port 8000 so sandboxes can reach the server.
NOTE
If you already started a vLLM endpoint, for example by following the Intermediate tab, reuse that endpoint instead of serving a different model. Set MODEL_HANDLE (exported below) to the exact model ID returned by curl http://localhost:8000/v1/models.
IMPORTANT
Do not bind the server to localhost only. Sandboxes reach host-local services through the host.openshell.internal DNS alias (Step 6), which resolves to the gateway host — not to 127.0.0.1 inside the sandbox's own network namespace.
Once the server is ready (its log reports Application startup complete (or OpenAI server is ready to accept requests with the Rust frontend); curl -sf http://localhost:8000/health returns HTTP 200), verify the localhost endpoint:
curl -sf http://localhost:8000/v1/models
Expected: a JSON "data" array listing your model handle. Export the reported id as an environment variable, used throughout the remaining steps:
export MODEL_HANDLE=your-model-id
Providers attach directly to a sandbox. The agent inside calls the provider's endpoint directly. This playbook runs inference against a self-hosted vLLM server on your own hardware, so it needs a provider profile declaring that specific host and port — OpenShell's example profiles (like openai, in the providers directory) are scoped to their public vendor endpoints (api.openai.com, etc.) and aren't meant to be redirected to a different host.
Save a profile declaring your vLLM server's host, port, and the binaries allowed to call it:
cat > local-vllm.yaml <<'EOF'
id: local-vllm
display_name: Local vLLM
description: Host-local vLLM OpenAI-compatible API
category: inference
inference_capable: true
credentials: []
endpoints:
- host: host.openshell.internal
port: 8000
protocol: rest
access: read-write
enforcement: enforce
binaries:
- /usr/local/bin/node
- /usr/bin/curl
- /usr/local/bin/curl
- /usr/bin/python3
- /usr/local/bin/python
- /sandbox/.uv/python/**
- /sandbox/.venv/**
EOF
NOTE
host.openshell.internal is the DNS alias sandboxes use to reach services running on the gateway host. The network policy only grants access to binaries named in this list, matched against the kernel-resolved target of the calling process (check with readlink -f <path> inside the sandbox if a symlinked interpreter is denied unexpectedly). /usr/local/bin/node covers Pi (Step 7); add or remove entries to match the agent image you build.
vLLM does not require an API key, so the profile declares no credentials (credentials: []), and the provider is created without any --credential flag.
Lint and import the profile, then create a provider from it:
openshell profile lint -f local-vllm.yaml
openshell profile import -f local-vllm.yaml
openshell provider create --name local-vllm --type local-vllm
Verify:
openshell provider list
This playbook uses Pi, a terminal coding agent, as its reference agent. Build an image containing Pi and pass it to sandbox create --from.
Save the Dockerfile:
cat > Dockerfile.pi <<'EOF'
FROM node:24-bookworm-slim
ARG PI_VERSION=latest
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates fd-find git ripgrep \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --ignore-scripts "@earendil-works/pi-coding-agent@${PI_VERSION}"
RUN mkdir -p /sandbox && chown node:node /sandbox
USER node
WORKDIR /sandbox
ENV PI_CODING_AGENT_DIR=/tmp/pi-agent
COPY --chown=node:node models.json /tmp/pi-agent/models.json
EOF
fd/ripgrep are baked in because the sandbox policy blocks Pi's fallback download of them on first use. PI_CODING_AGENT_DIR points at /tmp because that's one of the few paths the default sandbox policy lets Pi write to.
Next to Dockerfile.pi, save models.json, declaring your local vLLM server as a custom OpenAI-compatible provider. This uses the $MODEL_HANDLE you exported in Step 5:
cat > models.json <<EOF
{
"providers": {
"local-vllm": {
"baseUrl": "http://host.openshell.internal:8000/v1",
"api": "openai-completions",
"apiKey": "not-needed",
"models": [
{ "id": "$MODEL_HANDLE" }
]
}
}
}
EOF
Build the image:
docker build -t pi-agent:local -f Dockerfile.pi .
NOTE
If your gateway uses Podman, build with podman build -t localhost/pi-agent:local -f Dockerfile.pi . and use localhost/pi-agent:local below. If the gateway runs on different hardware than this shell, push the image to a registry the gateway can pull from instead.
Create the sandbox, attach the local-vllm provider from Step 6, and launch Pi as the sandbox's main process with the model preselected (no interactive /model picker needed):
export SANDBOX_NAME=openshell-demo
openshell sandbox create \
--name "$SANDBOX_NAME" \
--from pi-agent:local \
--provider local-vllm \
-- pi --model local-vllm/"$MODEL_HANDLE"
This uses the same $MODEL_HANDLE exported in Step 5 and baked into Step 7's models.json. OpenShell allocates a TTY automatically when both stdin and stdout are terminals; add --tty explicitly if you run this from a script or wrapper.
By default OpenShell attaches to Pi's session in this terminal and retains the sandbox after Pi exits (pass --no-keep for an ephemeral run instead).
Once connected to the sandbox, try a prompt allowed by the OpenShell policy defined in Step 6:
Explain what files are available in this workspace.
Then try a prompt that reaches outside the sandbox's granted network access, to confirm isolation is enforced and not just assumed:
Fetch the contents of https://example.com and summarize it.
Expect this one to fail with an Error: Connection error.. Step 6's local-vllm.yaml declares exactly one network endpoint — host: host.openshell.internal, port: 8000 — and the sandbox denies every destination not explicitly listed in an attached profile's endpoints, so example.com:443 is blocked before the connection leaves the sandbox. Confirm the exact reason with openshell logs "$SANDBOX_NAME" --tail (Step 9): look for a DENIED line citing transparent_tcp_policy_denied.
Detach without stopping Pi: press Ctrl-P then Ctrl-Q. Later steps reconnect to this same running session.
IMPORTANT
Don't type /quit yet. Quitting ends Pi as the sandbox's main process — the sandbox is retained for inspection (sandbox get, policy get, logs) afterward, but can no longer be reconnected to or exec'd into. openshell sandbox delete (Step 14) tears the sandbox down regardless of whether Pi was quit or just detached, so there's no need to quit manually before cleanup.
Open a second terminal to check status, effective policy, attached providers, and logs:
openshell sandbox list
openshell policy get "$SANDBOX_NAME" --full
openshell sandbox provider list "$SANDBOX_NAME"
openshell logs "$SANDBOX_NAME" --tail
openshell logs --tail streams outbound connections and policy decisions (allow/deny) in real time — useful for confirming Pi's requests are reaching host.openshell.internal:8000 and nothing else.
NOTE
A successful chat turn with Pi in Step 8 confirms inference connectivity. If Pi can't reach the model, check openshell sandbox provider list and the policy/logs commands above before assuming vLLM itself is unhealthy.
With Pi running, open openshell term for a live dashboard of sandbox status and the log stream:
openshell term
Confirm Pi's traffic to host.openshell.internal:8000 is allowed and that unrelated outbound traffic is denied.
TIP
Press f to follow live output, s to filter by source, and q to quit.
Grant Pi access to an additional destination — for example raw.githubusercontent.com:443, so Pi can fetch the OpenShell README directly from GitHub. This edits the local-vllm profile from Step 6, so the change applies everywhere that profile is attached, including the sandbox already running from Step 8.
NOTE
The profile scopes access by host and port, not by URL path — granting raw.githubusercontent.com permits reaching any repository's raw content on that host, not only the OpenShell repo.
The profile is where policy lives, so it's the only object you edit. Export it first to capture the resource_version that profile update requires:
openshell profile export local-vllm -o yaml > local-vllm.yaml
Add the new endpoint to the endpoints: list. profile export appends metadata fields (resource_version, source, scope) after binaries:, so appending to the end of the file would land outside endpoints: entirely — insert it right before the binaries: line instead:
sed -i '/^binaries:/i\
- host: raw.githubusercontent.com\
port: 443\
protocol: rest\
access: read-write\
enforcement: enforce' local-vllm.yaml
Update the profile. profile lint validates a profile as new-import input and rejects an id that already exists, so it isn't used here — profile update performs its own validation against the existing profile:
openshell profile update local-vllm -f local-vllm.yaml
The provider from Step 6 references the profile by id, so it automatically reflects the update. New sandboxes inherit it immediately; for the sandbox already running from Step 8, confirm it has synced:
openshell sandbox provider attach "$SANDBOX_NAME" local-vllm --wait
Network-policy changes like this hot-reload into a running sandbox, so Pi can retry a previously denied request without restarting. Credential value changes need a new process instead — a running process keeps the placeholder it started with, so exit and restart the agent (or use openshell sandbox exec) to pick up new credentials. Reconnect and retry the same style of prompt Step 8 showed being denied, now against the newly granted host:
openshell sandbox connect "$SANDBOX_NAME"
Fetch the contents of https://raw.githubusercontent.com/NVIDIA/OpenShell/main/README.md and summarize it.
This one succeeds — raw.githubusercontent.com:443 is now in local-vllm's endpoints, while every other destination (example.com included) stays denied.
To revoke local-vllm access from a sandbox without deleting the sandbox itself:
openshell sandbox provider detach "$SANDBOX_NAME" local-vllm --wait
Point Pi at your own code by uploading a project directory at sandbox-creation time. --upload copies the directory in before the sandbox starts, skipping anything .gitignore excludes. --upload cannot be combined with a trailing main command, so create the sandbox first and start Pi inside it as a separate step. Upload to /sandbox — the Dockerfile's WORKDIR and the only project directory writable by the upload mechanism under the default filesystem policy; /workspace returns Permission denied:
openshell sandbox create \
--name pi-project \
--from pi-agent:local \
--provider local-vllm \
--upload .:/sandbox
openshell sandbox exec -n pi-project --tty -- pi --model local-vllm/"$MODEL_HANDLE"
Pi's changes stay inside the sandbox. Copy them back with openshell sandbox download from another terminal while Pi is still running (see Step 13).
Reattach to Pi's running session at any time — this replays recent output and hands you back the same interactive process:
openshell sandbox connect "$SANDBOX_NAME"
Press Ctrl-P then Ctrl-Q to detach without stopping Pi. For a separate shell alongside Pi, or for scripted access, use:
openshell sandbox exec -n "$SANDBOX_NAME" --tty -- /bin/bash
openshell sandbox ssh-config "$SANDBOX_NAME"
Transfer files without attaching:
openshell sandbox upload "$SANDBOX_NAME" ./local-file /sandbox/destination
openshell sandbox download "$SANDBOX_NAME" /sandbox/file ./local-destination
Delete the sandbox(es) and provider while the gateway is still reachable:
openshell sandbox delete "$SANDBOX_NAME"
openshell provider delete local-vllm
To stop the gateway without uninstalling it:
systemctl --user stop openshell-gateway
To fully remove OpenShell (Ubuntu/Debian shown; see the uninstall docs for Fedora/RHEL, Homebrew, and Snap):
systemctl --user disable --now openshell-gateway
sudo apt remove openshell
rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/openshell"
If you enabled linger in Step 4, disable it:
sudo loginctl disable-linger $USER
NOTE
openshell gateway stop and openshell gateway destroy are not real subcommands (valid: add/remove/login/logout/select/info/list) — the gateway is a systemd service, not something the CLI starts or stops directly.
Stop and remove the vLLM container if you started one for this playbook (replace the name/image filter to match your launch):
docker rm -f vllm-server 2>/dev/null || true
openshell provider create, then pass --provider <name> when creating a sandbox. See the providers directory for ready-made profiles (GitHub, PyPI, hosted model APIs).openshell sandbox create --editor vscode or openshell sandbox connect <name> --editor cursor to open the sandbox workspace directly in an editor, or openshell sandbox ssh-config <sandbox-name> for manual SSH config.openshell service expose <sandbox> <port>.openshell logs <sandbox-name> --tail or openshell term to monitor agent activity and policy decisions.