Before I let a coding agent loose on a box holding my SSH keys, I wanted to know what NVIDIA OpenShell really stops. The install is one command, yet my first attempt ended with a gateway that refused to start, a Docker snap it couldn’t talk to, and a sandbox that died every time I logged out. Once those were sorted, the part that matters held up: the agent’s container has no network at all, and every connection it makes goes through a policy you can change while it runs.
My notes cover installing OpenShell on Linux, the default-deny network policy, a policy that reloads live, the OCSF record for a denial, and OpenCode with a local Ollama model in the sandbox, plus every error I hit. I ran all of it in October 2026 on an Ubuntu 26.04 LTS VM in my lab with OpenShell 0.1.5 and Docker from the Ubuntu archive, plus a smaller Ubuntu 24.04 VM for the Podman and microVM runs, and every prompt and log line below comes from those two boxes.
What NVIDIA OpenShell actually enforces
OpenShell is an Apache-2.0 runtime that puts each agent in a sandbox and decides, per request, what it may reach. It is not a library you import. There are three moving parts: a gateway (the control plane, a systemd user service on 127.0.0.1:17670), a supervisor per sandbox that holds the policy and the credentials, and the workload where the agent runs. With the Docker driver, the supervisor and the workload are two separate containers, and I checked what the architecture page says by inspecting them on my box:
- Workload container:
network=none, all capabilities dropped,no-new-privileges, UID 1000 - Supervisor container: host network, runs as UID 65534, no shell in the image
- Inside the workload:
Seccomp: 2, and writes outside the workdir and/tmpfailing, as the Landlock policy says they should - Egress: a unix socket to the supervisor, which checks policy and opens the real connection
Egress through the supervisor is the reason I’d use it over a plain container with a firewall rule. The agent can’t route around the supervisor because there is no interface to route over, so the policy check isn’t something it can skip by setting its own proxy variables. The diagram shows the layout as it ran on box1, including the Docker snap that failed on my first try:

Agent traffic never touches the gateway itself. It creates sandboxes through the container runtime, hands the supervisor its policy and tokens, and collects logs. Everything the agent sends out goes through the red-bordered supervisor box, and anything without a matching rule stops there.
Sizing the VM and picking a runtime
OpenShell itself is light and needs no GPU, so a small VM is enough for the sandbox layer. What sizes the box is the agent and its model. The installation page asks for glibc 2.28 or newer plus one runtime, and these are the numbers I measured:
- Gateway process: about 233 MB RSS
- Supervisor container: 20 to 32 MiB each
- Workload container: 7 MiB for the curl image, 24 MiB for the OpenCode image
- Images: supervisor 82 MB, sandbox runtime 17 MB, default Ubuntu base 127 MB
- Ollama with qwen2.5 7B at a 16K context:
ollama psreports 6.0 GB
So the sandbox layer fits in very little: my 24.04 box ran Podman and microVM sandboxes on 2 vCPU and 4 GB. A local 7B model on CPU pushes you to 8 vCPU and 16 GB, which is what box1 had, and a 20 GB disk was 71% full by the end with two Ollama models pulled. The runtime choice matters more than the size:
| Runtime | Docs ask for | What happened in my lab |
|---|---|---|
| Docker | Docker Engine 28.0 or later | Ubuntu’s docker.io 29.1.3 worked on 26.04. The Docker snap 29.8.0 did not. |
| Podman | Podman 5.x, cgroups v2, user socket | Ubuntu 24.04’s Podman 4.9.3 still created and ran a sandbox |
| MicroVM | KVM on Linux | Worked on 24.04 after adding the user to the kvm group; never auto-detected |
Auto-detection checks Kubernetes, then Podman, then Docker, so a box with both Podman and Docker installed ends up on Podman unless you pin compute_driver. On Ubuntu 24.04, docker.io 29.1.3 sits in the noble-updates and noble-security pockets (the release pocket still carries 24.0.7, which is too old), so a 24.04 box with those pockets enabled clears the Docker 28 floor without Docker’s own repository.
Installing OpenShell on Ubuntu 26.04
Get the runtime in place first, because the installer starts the gateway right away and the gateway exits if it can’t find one. Use the docker.io package from the Ubuntu archive, not the snap (the snap failure is covered in the errors section):
sudo apt update
sudo apt install -y docker.io
docker --version
Ubuntu 26.04 gives you a version comfortably past the Docker 28 floor.
Docker version 29.1.3, build 29.1.3-0ubuntu4.1
The gateway runs as your user, not root, so your account needs the docker group. It also needs linger, otherwise systemd stops your user services, gateway and sandboxes included, the moment your last SSH session closes. I learned that one when my first sandbox vanished between two SSH commands. Restart the user manager afterwards so it picks up the new group:
sudo usermod -aG docker $USER
sudo loginctl enable-linger $USER
sudo systemctl restart user@$(id -u).service
Your current SSH shell still lacks the new group, so log out and back in (or run newgrp docker) before the docker commands later on. Keep in mind that the docker group is root-equivalent on the host, which is one more reason the agent belongs inside a sandbox and not in your login shell.
Now run the installer. Releases land every few days and 0.1.5 changed log file names and snap behaviour, so I pin the tag I tested; drop the variable to get the latest release instead:
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | OPENSHELL_VERSION=v0.1.5 sh # pin: tested release, newer tags at https://github.com/NVIDIA/OpenShell/releases
On Ubuntu the script downloads the .deb, verifies its checksum, installs the openshell package (the openshell, openshell-gateway and openshell-prover binaries), enables the openshell-gateway user service and registers it with the CLI. Check that the CLI can reach it:
openshell status
Status: Connected with mTLS authentication is what you want to see.
Server Status
Gateway: openshell
Server: https://127.0.0.1:17670
Status: Connected
Authentication: Authenticated (mTLS transport)
Version: 0.1.5
If it says the connection was refused instead, the gateway is in a restart loop and journalctl --user -u openshell-gateway -n 20 tells you why. Every failure I hit showed up there first, and the most common one is a runtime the gateway can’t reach.
Creating the first sandbox with openshell sandbox create
With no --from, a sandbox starts from nvcr.io/nvidia/base/ubuntu:24.04. I passed --detach and a sleep as the main process so the sandbox stays up while I run commands into it from the host:
openshell sandbox create --name demo --no-auto-providers --detach -- sleep infinity
The first run pulls the base image (the progress lines in the middle are cut here), and the whole create took under 10 seconds.
Created sandbox: demo
[0.0s] Requesting compute...
[0.0s] Sandbox allocated
[0.0s] Pulling image nvcr.io/nvidia/base/ubuntu:24.04
...
[8.5s] Pulling image Status: Downloaded newer image for nvcr.io/nvidia/base/ubuntu:24.04
[8.5s] Image pulled
[8.9s] Starting sandbox Container created
real 0m9.854s
Two containers appear in docker ps for every sandbox, one ending in -supervisor. I wanted to see the isolation flags rather than take them on faith, so I inspected both:
C=$(docker ps --format '{{.Names}}' | grep demo | grep -v supervisor)
docker inspect -f 'workload net={{.HostConfig.NetworkMode}} user={{.Config.User}} capdrop={{.HostConfig.CapDrop}} capadd={{.HostConfig.CapAdd}} secopt={{.HostConfig.SecurityOpt}}' "$C"
docker inspect -f 'supervisor net={{.HostConfig.NetworkMode}} user={{.Config.User}} capadd={{.HostConfig.CapAdd}}' "$C-supervisor"
So the workload has no network and no capabilities. The supervisor sits on the host network, which is why it can reach services on the host’s loopback later on:
workload net=none user=1000:1000 capdrop=[ALL] capadd=[] secopt=[no-new-privileges:true]
supervisor net=host user=65534:65534 capadd=[]
Then I looked around from the inside with sandbox exec, which runs one command in the workload and streams the output back:
openshell sandbox exec -n demo --no-tty -- bash -c 'id; which curl wget python3 git node; pwd; grep -E "Seccomp|NoNewPrivs|CapEff" /proc/self/status; touch /etc/x 2>&1; touch /usr/x 2>&1; touch /tmp/ok && echo tmp-ok'
The which line printed nothing at all, and that’s the thing most quickstarts skip: the default image has no curl, wget, Python, git or Node. You can’t test the network from it, and you can’t run an agent in it, so plan on your own image from the start:
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu)
/sandbox
CapEff: 0000000000000000
NoNewPrivs: 1
Seccomp: 2
Seccomp_filters: 6
touch: cannot touch '/etc/x': Permission denied
touch: cannot touch '/usr/x': Permission denied
tmp-ok
The working directory is /sandbox, owned by the sandbox user. Writes outside it and /tmp fail, which matches the default filesystem policy (read-only /usr, /etc, /lib and friends, read-write workdir and /tmp).
Watching the default-deny policy block a request
To test egress I built a tiny image on top of the same base with curl added. OpenShell uses images from the local Docker daemon, so there’s no registry to push to. Create a build directory and the Dockerfile:
mkdir -p ~/sandbox-curl && cd ~/sandbox-curl
vim Dockerfile
I built exactly this file. Its USER ubuntu line keeps the image non-root; the docs reject an image whose user is root unless the policy sets a non-root run_as_user, while images with no USER at all run as UID and GID 1000:
FROM nvcr.io/nvidia/base/ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
USER ubuntu
Build it and start a sandbox named web from it. Delete the demo sandbox while you’re there, since nothing else needs it and every sandbox keeps two containers running.
docker build -q -t sandbox-curl:1.0 .
openshell sandbox delete demo
openshell sandbox create --name web --from sandbox-curl:1.0 --no-auto-providers --detach -- sleep infinity
openshell sandbox list
With the image already local, the create finishes in under a second and the sandbox reports Ready.

No network policy is attached yet, so every outbound connection should fail. Try two hosts an agent would plausibly want, because a coding agent’s first moves are usually an API call and a package install.
openshell sandbox exec -n web --no-tty -- curl -sS --max-time 15 https://api.github.com/zen
openshell sandbox exec -n web --no-tty -- curl -sS --max-time 15 https://pypi.org/simple/ -o /dev/null
Both die in a few milliseconds with a plain connect error.
curl: (7) Failed to connect to api.github.com port 443 after 4 ms: Couldn't connect to server
curl: (7) Failed to connect to pypi.org port 443 after 2 ms: Couldn't connect to server
Is that the 403 the docs promise? Not quite, and it surprised me a little. NVIDIA’s first-policy tutorial shows curl: (56) Received HTTP code 403 from proxy after CONNECT for the same test, but my sandbox had no proxy variables set and the supervisor intercepted the connection transparently, so curl only saw a refused connect. If your agent’s error handling expects a 403 from a proxy, it won’t get one. The reason lives in the sandbox log, which you read from the host:
openshell logs web --since 5m --source sandbox | grep OCSF
Each attempt produced three events. DNS for the name is refused, the name gets a synthetic address from 198.18.0.0/15 for review, and the TCP open is denied with the binary that tried it:
[1792915561.055] [sandbox] [OCSF ] [ocsf] NET:REFUSE [MED] DENIED api.github.com [reason:policy_dns_ineligible]
[1792915561.055] [sandbox] [OCSF ] [ocsf] CONFIG:OBSERVATION [INFO] Policy DNS staged unapproved name api.github.com synthetic=198.18.0.2 for TCP policy review
[1792915561.058] [sandbox] [OCSF ] [ocsf] NET:OPEN [MED] DENIED /usr/bin/curl(0) -> api.github.com:443 [reason:transparent_tcp_policy_denied]
Filtering on DENIED gives the short version, which is what I’d grep for first when an agent reports a network failure:

The /usr/bin/curl(0) part matters for the policy: OpenShell attributes every connection to the executable that made it, and network rules are written per binary.
Writing an OpenShell network policy and reloading it live
A policy is one YAML file with a filesystem section and named network rules. I started from what openshell policy get web --base printed, kept its filesystem part unchanged, and added one rule that lets curl make read-only calls to the GitHub API. Open a new file:
vim ~/web-policy.yaml
Paste the policy. protocol: rest tells the supervisor to terminate TLS and inspect each HTTP request, and access: read-only allows GET, HEAD and OPTIONS only. Leaving out protocol gives you a plain TCP allow, which can’t tell a read from a write.
version: 1
filesystem_policy:
include_workdir: true
read_only:
- /bin
- /usr
- /lib
- /proc
- /dev/urandom
- /etc
- /var/log
read_write:
- /tmp
- /dev/null
landlock:
compatibility: best_effort
network_policies:
github_api:
endpoints:
- host: api.github.com
port: 443
protocol: rest
enforcement: enforce
access: read-only
binaries:
- path: /usr/bin/curl
Apply it to the running sandbox. --wait blocks until the supervisor reports it loaded the new revision, so you know the next request sees it:
openshell policy set web --policy ~/web-policy.yaml --wait
openshell policy list web
It took 9.1 seconds end to end, probably most of it the supervisor’s 10-second settings poll, with no restart and the sleep main process untouched:
✓ Policy version 2 submitted (hash: ec35d20221e1)
✓ Policy version 2 loaded (active version: 2)
VERSION HASH STATUS CREATED ERROR
2 ec35d20221e1 Loaded 1792915576147
1 a45a8ff3564f Superseded 1792915554602
Now a GET to the API goes through, while a POST to the same host and the unrelated pypi.org still fail.
openshell sandbox exec -n web --no-tty -- curl -sS --max-time 15 https://api.github.com/zen
openshell sandbox exec -n web --no-tty -- curl -sS --max-time 15 -X POST https://api.github.com/repos/octocat/hello-world/issues -H "Content-Type: application/json" -d '{"title":"oops"}'
openshell sandbox exec -n web --no-tty -- curl -sS --max-time 15 https://pypi.org/simple/ -o /dev/null
The first line is GitHub’s zen quote. The POST reaches the supervisor (the host is allowed) and gets a JSON 403 body that names the policy, the method and the missing rule. Pypi gets the same refused connect as before:
Half measures are as bad as nothing at all.
{"error":"policy_denied","policy":"github_api","rule":"POST /repos/octocat/hello-world/issues","detail":"POST /repos/octocat/hello-world/issues not permitted by policy","layer":"l7","protocol":"rest","method":"POST","path":"/repos/octocat/hello-world/issues","host":"api.github.com","port":443,"binary":"/usr/bin/curl","rule_missing":{"type":"rest_allow","layer":"l7","method":"POST","path":"/repos/octocat/hello-world/issues","host":"api.github.com","port":443,"binary":"/usr/bin/curl"},"next_steps":[]}
curl: (7) Failed to connect to pypi.org port 443 after 2 ms: Couldn't connect to server
That JSON body is worth reading closely, because it is what an agent sees when it tries something your rule doesn’t cover, and a decent agent can use rule_missing to tell you exactly what it wanted. The log shows the reload and both decisions:
[1792915584.677] [sandbox] [OCSF ] [ocsf] CONFIG:LOADED [INFO] Policy reloaded successfully [policy_hash:ec35d20221e13b263564d6efc166cb925e18e9fe8086e4ec7419e67ea215c828] [hash:ec35d20221e13b263564d6efc166cb925e18e9fe8086e4ec7419e67ea215c828]
[1792915598.709] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] ALLOWED /usr/bin/curl(0) -> api.github.com:443 [policy:github_api engine:opa]
[1792915598.773] [sandbox] [OCSF ] [ocsf] HTTP:GET [INFO] ALLOWED GET https://api.github.com/zen [policy:github_api engine:l7]
[1792915599.348] [sandbox] [OCSF ] [ocsf] HTTP:POST [MED] DENIED POST https://api.github.com/repos/octocat/hello-world/issues [policy:github_api engine:l7] [reason:L7_REQUEST deny POST api.github.com:443/repos/octocat/hello-world/issues reason=POST /repos/octocat/hello-world/issues not permitted by policy]
In the terminal the same reload looked like this, version 2 loaded and the GET answered.

For quick one-off rules there’s also openshell policy update, which adds an endpoint without a file, but I prefer the full YAML in git: policy set replaces the whole policy, so the file is always the complete truth about what a sandbox may reach.
Reading the OCSF JSONL record for a denial
Shorthand lines like those are for people. For a SIEM you want the full OCSF JSON, which is off by default. Turn it on for one sandbox; it takes effect on the next settings poll, roughly 10 seconds later:
openshell settings set web --key ocsf_json_enabled --value true
The JSON lands in two places: the supervisor’s stderr as lines tagged OCSF-JSON, and the file /var/log/openshell-ocsf.YYYY-MM-DD.jsonl inside the supervisor container. That file used to end in .log, and the rename is one of the breaking changes in this release. Reading it is awkward with Docker: the supervisor image has no shell, and docker cp came back with an empty directory because /var/log is a tmpfs. What worked was going through the container’s root in /proc as root on the host:
S=$(docker ps --format "{{.Names}}" | grep web | grep supervisor)
P=$(docker inspect -f "{{.State.Pid}}" $S)
sudo ls -l /proc/$P/root/var/log/
Both files sit side by side, named by date. They rotate daily and keep three files each, and because they live on tmpfs they vanish when the supervisor container stops, so ship them out before you delete a sandbox you care about.
total 16
-rw-r--r-- 1 nobody nogroup 5612 Oct 25 08:07 openshell-ocsf.2026-10-25.jsonl
-rw-r--r-- 1 nobody nogroup 8098 Oct 25 08:07 openshell.2026-10-25.log
A glob like openshell-ocsf.*.jsonl fails here because your own shell expands it before sudo runs, and it can’t read that directory, so use the file name from the listing. This pulls the last denied pypi.org connection and pretty-prints it:
sudo cat /proc/$P/root/var/log/openshell-ocsf.2026-10-25.jsonl | grep pypi | grep '"Denied"' | tail -1 | python3 -m json.tool
A collector would ingest this record, OCSF class 4001 (Network Activity) with severity Medium.
{
"class_uid": 4001,
"class_name": "Network Activity",
"category_uid": 4,
"category_name": "Network Activity",
"activity_id": 1,
"activity_name": "Open",
"type_uid": 400101,
"type_name": "Network Activity: Open",
"time": 1792915638375,
"severity_id": 3,
"severity": "Medium",
"status_id": 2,
"status": "Failure",
"message": "Transparent TCP denied before relay: endpoint pypi.org:443 is not allowed by any policy",
"status_detail": "transparent_tcp_policy_denied",
"metadata": {
"version": "1.8.0",
"product": {
"name": "OpenShell Sandbox Supervisor",
"vendor_name": "OpenShell",
"version": "0.1.5"
},
"profiles": [
"security_control",
"network_proxy",
"container",
"host"
],
"uid": "20d193fe-611e-447d-9228-7315237dcd92"
},
"device": {
"hostname": "box1",
"type_id": 99,
"type": "Sandbox",
"os": {
"name": "Linux",
"type_id": 200,
"type": "Linux"
}
},
"container": {
"name": "web",
"uid": "a80594dc-cd8c-417a-ae4b-89969945ea3e"
},
"dst_endpoint": {
"domain": "pypi.org",
"ip": "198.18.0.3",
"port": 443
},
"proxy_endpoint": {
"ip": "127.0.0.1",
"port": 3128
},
"actor": {
"process": {
"name": "/usr/bin/curl",
"pid": 0,
"cmd_line": "/dev/null"
}
},
"action_id": 2,
"action": "Denied",
"disposition_id": 2,
"disposition": "Blocked",
"is_src_dst_assignment_known": true
}
Two fields didn’t match what I expected. pid is 0 and cmd_line is /dev/null, which is the last argument of the curl command rather than the command line. The binary name is right, and that’s what the policy keys on, but I wouldn’t build an alert that depends on cmd_line yet. The ip is the synthetic address, not pypi.org’s real one, because the name was never resolved. For a live feed, docker logs <supervisor> 2>&1 | grep OCSF-JSON gives the same records without root, one compact object per line.
Running OpenCode with Ollama inside the sandbox
NVIDIA’s own first-agent example runs OpenCode against a free OpenRouter model. My lab VMs don’t get hosted model keys, so I pointed it at Ollama on the same host instead, which also means no credentials are involved at all. If you don’t have Ollama yet, the Ollama setup on Ubuntu 26.04 covers it in more depth; the short version is:
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:7b
Raise Ollama’s context window before going further. OpenCode’s system prompt plus its tool definitions run far past Ollama’s CPU default of 4096 tokens, and with the default my runs failed in strange ways: the 3B model printed tool calls as plain JSON, and the 7B one tried to write to /path/to/current/directory/fizzbuzz.py. At 16384 tokens the same 7B model made real tool calls:
sudo mkdir -p /etc/systemd/system/ollama.service.d
printf "[Service]\nEnvironment=OLLAMA_CONTEXT_LENGTH=16384\n" | sudo tee /etc/systemd/system/ollama.service.d/context.conf
sudo systemctl daemon-reload
sudo systemctl restart ollama
Leave Ollama on its default 127.0.0.1:11434. Sandboxes reach the host through the name host.openshell.internal, and with the Docker driver the supervisor runs on the host network, so the log showed that name resolving to 127.0.0.1. I did try OLLAMA_HOST=0.0.0.0 early on, because NVIDIA’s DGX Spark playbook warns not to bind the model server to localhost, then removed that drop-in again. Every successful agent run below hit an Ollama that ss -ltn showed on 127.0.0.1:11434 only, so with Docker on Linux there was no need to expose it to the LAN.
OpenShell grants model access through a provider profile: which host and port, and which binaries may call it. Create the profile file:
vim ~/ollama-openai.yaml
I took the Ollama example from the inference docs and cut the binaries down to the one OpenCode actually runs as, /usr/local/bin/opencode in its image:
id: ollama-openai
display_name: Ollama
description: Host-local Ollama OpenAI-compatible API
category: inference
inference_capable: true
credentials: []
endpoints:
- host: host.openshell.internal
port: 11434
protocol: rest
access: read-write
enforcement: enforce
binaries:
- /usr/local/bin/opencode
Lint it, import it, and create a provider instance from it. There are no credentials to pass, because Ollama doesn’t authenticate.
openshell profile lint -f ~/ollama-openai.yaml
openshell profile import -f ~/ollama-openai.yaml
openshell provider create --name ollama --type ollama-openai
Each step confirms on one line. Nothing is attached to a sandbox yet, so creating the provider changes no policy on its own; it only takes effect when a sandbox is created with --provider ollama.
Provider profile lint passed.
Imported 1 provider profile.
✓ Created provider ollama
OpenCode needs to know about the Ollama endpoint too. The sandbox’s home is /sandbox, so its config goes to /sandbox/.config/opencode/opencode.json. Write it on the host first:
mkdir -p ~/oc/.config/opencode
vim ~/oc/.config/opencode/opencode.json
The model has to be declared in this file, or OpenCode stops with an UnknownError the moment you ask for it with -m. I hit that by switching models without updating the file:
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama on box1",
"options": {
"baseURL": "http://host.openshell.internal:11434/v1"
},
"models": {
"qwen2.5:7b": {
"name": "Qwen2.5 7B"
}
}
}
},
"model": "ollama/qwen2.5:7b"
}
Create the sandbox from the OpenCode image with the provider attached, then upload the config. --upload on sandbox create refuses to combine with a command after --, so the upload is a separate step:
openshell sandbox create --name coder --from ghcr.io/anomalyco/opencode:latest --provider ollama --detach -- sleep infinity
openshell sandbox upload coder ~/oc/.config/opencode/opencode.json /sandbox/.config/opencode/opencode.json
The OpenCode image declares no user, so OpenShell ran it as UID 1000, exactly as the default policy docs describe. The provider shows up in the effective policy as its own rule, _provider_ollama, scoped to the opencode binary. Run the agent:
openshell sandbox exec -n coder --no-tty --workdir /sandbox -- opencode run "Create fizzbuzz.py in the current directory. It should print FizzBuzz for the numbers 1 to 15."
My early runs spent much of their time on denied requests. The log showed OpenCode trying to reach registry.npmjs.org and models.opencode.ai over and over, and one run made no progress until I killed it and dealt with the denials. So was the agent stuck for good? No, because OpenShell’s policy advisor turns every denial into a drafted rule waiting for review.
openshell rule get coder --status pending
Two proposals came back, both checked by the prover with no findings, each with a hit count.
Network Rules: (version 1, 2 chunks)
Chunk: 7355af82-3a6a-4483-8178-cc631ebec357
Status: pending
Rule: allow_registry_npmjs_org_443
Binary: /usr/local/bin/opencode
Confidence: 65%
Rationale: Allow opencode to connect to registry.npmjs.org:443 (HTTPS).
Prover: prover: no new findings
Candidate: b83496997c4c
Endpoints: registry.npmjs.org:443 [L4]
Binaries: /usr/local/bin/opencode
Hits: 5 (first seen 2026-10-25 08:13:29, last seen 2026-10-25 08:21:28)
Chunk: 8b2b87ab-f87e-4556-a444-21e19f14a4ec
Status: pending
Rule: allow_models_opencode_ai_443
Binary: /usr/local/bin/opencode
Confidence: 75%
Rationale: Allow opencode to connect to models.opencode.ai:443 (HTTPS).
Prover: prover: no new findings
Candidate: 985d332eba41
Endpoints: models.opencode.ai:443 [L4]
Binaries: /usr/local/bin/opencode
Hits: 9 (first seen 2026-10-25 08:13:27, last seen 2026-10-25 08:20:33)
I approved npm, since OpenCode fetches its provider SDK packages from there at startup, and rejected the model catalogue because my config already lists the one model it may use. Use the chunk IDs from your own output:
openshell rule approve coder --chunk-id 7355af82-3a6a-4483-8178-cc631ebec357
openshell rule reject coder --chunk-id 8b2b87ab-f87e-4556-a444-21e19f14a4ec --reason "Model list comes from opencode.json"
The approval landed as policy version 2, but npm still failed, now one layer deeper. The drafted rule was plain L4, and the supervisor’s HTTP parser rejected scoped package URLs such as @ai-sdk%2fprovider:
[1792917753.667] [sandbox] [OCSF ] [ocsf] NET:OPEN [MED] DENIED registry.npmjs.org:443 [policy:allow_registry_npmjs_org_443 engine:http-parser] [reason:HTTP request-target rejected: request-target contains an encoded '/' (%2F) which is not allowed on this endpoint; passthrough credential relay uses strict path parsing, so configure this endpoint with protocol: rest and allow_encoded_slash: true for encode...]
The message names its own fix. I replaced the drafted rule with a read-only REST rule that allows encoded slashes, in a policy file for this sandbox:
vim ~/coder-policy.yaml
Only the network rule is new; the filesystem part is the same default as before.
version: 1
filesystem_policy:
include_workdir: true
read_only:
- /bin
- /usr
- /lib
- /proc
- /dev/urandom
- /etc
- /var/log
read_write:
- /tmp
- /dev/null
landlock:
compatibility: best_effort
network_policies:
npm_registry:
endpoints:
- host: registry.npmjs.org
port: 443
protocol: rest
enforcement: enforce
access: read-only
allow_encoded_slash: true
binaries:
- path: /usr/local/bin/opencode
Apply it live, the same way as before. The sandbox keeps running, so the next npm request OpenCode makes is checked against the new rule without restarting the agent.
openshell policy set coder --policy ~/coder-policy.yaml --wait
Version 3 loaded, and openshell policy get coder --full showed the provider rule still in place next to the new one, because provider rules are a separate layer that policy set doesn’t replace:
✓ Policy version 3 submitted (hash: 535729a89342)
✓ Policy version 3 loaded (active version: 3)
With npm reachable, the npm downloads show up as allowed GETs in the log. The first run after the fix printed its tool call as JSON again and wrote nothing; the second one, below, wrote the file and finished in 1 minute 30 seconds on 8 CPU cores.
> build · qwen2.5:7b
← Write fizzbuzz.py
Wrote file successfully.
Created `fizzbuzz.py` in the current directory. It prints FizzBuzz for the numbers 1 to 15 as requested.
real 1m29.924s
Model calls went through the provider rule, npm downloads through the read-only npm rule, and nothing else left the box.

I’ll be straight about the model, though. With the 16K context, qwen2.5 7B wrote the file in two of three runs; the third printed the tool call as JSON instead of calling it. The file it wrote also contained literal \n sequences on one line, so it isn’t valid Python, and the Alpine-based OpenCode image has no python3 to catch that anyway. None of that is a fault in OpenShell, but a 7B model on CPU is a demo of the plumbing, not a coding partner. For real work, attach a hosted provider and keep the same sandbox and policy. If you’re new to the agent itself, the OpenCode setup guide and the OpenCode command reference cover what it can do outside a sandbox.
Running Claude Code in an OpenShell sandbox
Claude Code follows the same pattern with the claude-code example profile from the OpenShell repository, which allows api.anthropic.com plus Anthropic’s telemetry and Sentry hosts for the claude binary. I got as far as the provider:
export OPENSHELL_TAG=v0.1.5 # pin: profile from the tested release, newer tags at https://github.com/NVIDIA/OpenShell/releases
curl -sLo ~/claude-code.yaml https://raw.githubusercontent.com/NVIDIA/OpenShell/${OPENSHELL_TAG}/providers/claude-code.yaml
openshell profile lint -f ~/claude-code.yaml
openshell profile import -f ~/claude-code.yaml
openshell provider create --name claude --type claude-code --from-existing
Without an API key in the shell, the last command stops.
Error: × no existing local credentials/config found for provider type 'claude-code'
--from-existing reads the credential from ANTHROPIC_API_KEY or CLAUDE_API_KEY, which is what the profile’s discovery block lists, so a Claude subscription login isn’t something this profile picks up. Export a Console API key first. I didn’t have one on this lab box, so the Claude Code run itself is untested here. Two other things to check: the profile names /usr/bin/claude and /usr/local/bin/claude, and an npm global install of the CLI lands elsewhere, so match the binaries to your image. The Claude Code install guide shows where each install method puts the binary, and once the agent runs inside a sandbox, its hooks still work as a second layer of control inside the boundary.
Trying Podman and microVM mode on Ubuntu 24.04
I wanted the other two runtimes on the older LTS, so box2 got Ubuntu 24.04 with 2 vCPU and 4 GB. Its Podman is 4.9.3, below the Podman 5 the docs ask for, and I expected the gateway to refuse it. Install Podman, start its user socket, then run the same installer:
sudo apt install -y podman
systemctl --user enable --now podman.socket
sudo loginctl enable-linger $USER
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | OPENSHELL_VERSION=v0.1.5 sh # pin: tested release, newer tags at https://github.com/NVIDIA/OpenShell/releases
It did not refuse. The gateway journal shows it picking Podman and connecting rootless.
INFO openshell_server: Using compute driver driver=podman
INFO openshell_driver_podman::driver: Connected to Podman cgroup_version=v2 network_backend=netavark rootless=true rootless_network_cmd= apparmor_enabled=false
Then I created a sandbox from the default image and poked at it. The image has no curl, so bash’s /dev/tcp stands in for the network test:
openshell sandbox create --name t1 --detach -- sleep infinity
openshell sandbox exec -n t1 --no-tty -- bash -c "id; grep PRETTY /etc/os-release; grep Seccomp: /proc/self/status; (exec 3<>/dev/tcp/1.1.1.1/443) 2>&1 | head -2"
The sandbox reached Ready, ran under seccomp, and the raw TCP connect was refused, which is the same isolation I saw with Docker.
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu)
PRETTY_NAME="Ubuntu 24.04.5 LTS"
Seccomp: 2
bash: connect: Permission denied
bash: line 1: /dev/tcp/1.1.1.1/443: Permission denied
That’s one smoke test, not a support statement, so on 24.04 I’d still use Docker from noble-updates or a newer Podman for anything that matters. The Podman guide for Ubuntu 26.04 gets you Podman 5 on the newer release.
MicroVM mode puts each sandbox in its own lightweight VM through KVM, so the agent doesn’t share the host kernel. The gateway never picks it on its own; you set it in the gateway config. A gateway drives one runtime at a time, so delete the existing sandboxes first. I ran openshell sandbox delete t1 a second before the switch, the restarted gateway finished removing it from its store on its first reconcile, and podman ps -a afterwards showed no leftover containers.
mkdir -p ~/.config/openshell
vim ~/.config/openshell/gateway.toml
Per the configuration docs, the file must start with the [openshell] table and version = 2, or preflight rejects it with missing_version:
[openshell]
version = 2
[openshell.gateway]
compute_driver = "vm"
Validate it and restart the gateway. Preflight never changes the file, so it’s safe to run before every restart, and it also checks the host tools the VM driver uses to build root disks.
openshell-gateway config preflight --path ~/.config/openshell/gateway.toml
systemctl --user restart openshell-gateway
Preflight lists the three e2fsprogs tools it found.
VM host tool mke2fs: /usr/sbin/mke2fs (1.47.0)
VM host tool debugfs: /usr/sbin/debugfs (1.47.0)
VM host tool e2fsck: /usr/sbin/e2fsck (1.47.0)
My first microVM sandbox went to Error after 5 seconds because the gateway user couldn’t open /dev/kvm (the fix is in the errors section). After adding the user to the kvm group, a sandbox came up in 7 seconds using the cached root disk:
openshell sandbox create --name vm1 --detach -- sleep infinity
openshell sandbox exec -n vm1 --no-tty -- bash -c "uname -r; id; nproc; free -m | head -2"
The kernel inside is 6.12.109 while the host runs 6.8, which is the proof that this is a separate VM and not a container. Each microVM got 2 vCPU and about 2 GB by default.
6.12.109
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu)
2
total used free shared buff/cache available
Mem: 2026 81 1968 0 21 1945
Box2 is itself a Proxmox VM with cpu: host, so this ran nested. If your VM doesn’t expose /dev/kvm, stay on Docker or Podman. On Fedora and RHEL the installer pulls the .rpm packages instead of the .deb; I didn’t test that path for these notes.
Errors I hit and how I fixed them
Every one of these came up on the two lab boxes, in roughly this order. Most of them trace back to three causes:
- The gateway user can’t reach the runtime
- systemd stops user services at logout
- A rule that matches the host but not the request
They’re listed by the exact text you’ll see, since that’s what you’ll be searching for.
Error: “no compute driver configured and auto-detection found no suitable installed driver”
The installer finished, then printed the gateway journal with this and curl: (7) Failed to connect to 127.0.0.1 port 17670. I’d run it on a box with no Docker or Podman, and /dev/kvm being present doesn’t help because the VM driver is never auto-detected. Install a runtime, give your user access to it, restart the user manager, then systemctl --user restart openshell-gateway. You don’t need to rerun the installer.
Error: “failed to query Docker daemon version: Error in the hyper legacy client: client error (Connect)”
You get this one from the Docker snap. With sudo snap install docker, auto-detection still found nothing, and forcing OPENSHELL_COMPUTE_DRIVER=docker in ~/.config/openshell/gateway.env produced this error, even after I added my user to the docker group and restarted the user manager. dmesg showed AppArmor’s unprivileged_userns profile denying the gateway’s connect to run/docker.sock with info="Failed name lookup - disconnected path". The installer’s own help says the Docker snap isn’t compatible, and now I know why. Remove it with sudo snap remove --purge docker, install docker.io, delete the gateway.env override, and the gateway connects on its own.
Error: “Could not get lock /var/lib/dpkg/lock-frontend”
On a freshly cloned cloud image, unattended-upgrades was running a dist-upgrade when I started the installer, so the .deb install failed halfway. Wait until pgrep -a apt shows nothing and run the installer again; it picked up cleanly.
Sandbox gone after logging out (“Stopped sandbox during gateway shutdown”)
My demo sandbox kept disappearing and the CLI returned Connection refused (os error 111) between SSH sessions. The journal had Shutdown signal received; stopping gateway followed by Stopped sandbox during gateway shutdown: without linger, systemd tears down the user manager when your last session ends, and the gateway goes with it. sudo loginctl enable-linger $USER fixed it for good.
Error: “cannot open /dev/kvm: Permission denied (os error 13)”
The microVM sandbox showed sandbox entered error phase while provisioning: ProcessExited: VM process exited with status 1, and the gateway journal had the real cause: “KVM access is required to run microVMs on Linux.” /dev/kvm is root:kvm mode 660 on Ubuntu. Run sudo usermod -aG kvm $USER, restart the user manager with sudo systemctl restart user@$(id -u).service (which also stops the gateway and any running sandboxes), delete the failed sandbox and create it again.
Error: “no existing local credentials/config found for provider type ‘claude-code'”
openshell provider create --from-existing found no ANTHROPIC_API_KEY or CLAUDE_API_KEY in the environment. Export the key in the shell that runs the command. OpenShell stores it and hands the sandbox only a placeholder, substituting the real key on requests to the endpoints the profile allows.
Error: “request-target contains an encoded ‘/’ (%2F) which is not allowed on this endpoint”
An approved rule for registry.npmjs.org still blocked scoped npm packages. Rewrite the rule with protocol: rest and allow_encoded_slash: true, as in coder-policy.yaml above, and apply it with openshell policy set.
Upgrading across 0.1.x releases
OpenShell shipped 0.1.2, 0.1.3, 0.1.4 and 0.1.5 between 28 September and 20 October 2026, so upgrades are a regular chore, and 0.1.5 has breaking changes. These are four of the breaking changes listed in the 0.1.5 release notes; only the first two touch a single-host setup like mine:
- Supervisor OCSF files renamed from
openshell-ocsf.*.logtoopenshell-ocsf.*.jsonl; old files aren’t migrated - The snap enables the user gateway only for the user who ran the installer
- Kubernetes sandbox templates with pod labels or annotations need an operator allowlist
- Go SDK
Create()takes functional options instead of a labels argument
Anyone shipping logs should worry about the first one: a collector glob on *.log goes quiet after the upgrade without an error, so update it before you upgrade and copy any history you need first. To move to a newer release, the installer takes the new tag in OPENSHELL_VERSION; I haven’t run an upgrade on these boxes yet. NVIDIA’s inference docs ask for every pre-upgrade sandbox to be recreated when migrating off the old managed inference routes, and I’d make that a habit for every upgrade anyway, since my sandboxes are disposable. If you’re weighing OpenShell against simply picking a more careful agent, the comparison of AI coding agents for DevOps covers how the agents themselves behave, and an agent that needs real secrets beyond a model key pairs well with machine identities in Infisical.
What I still don’t know
A few things didn’t line up with the docs, and I couldn’t settle them in one morning of lab time:
- Why the OCSF record had
pid: 0and acmd_lineof/dev/null - Whether Podman 4.9 holds up beyond one smoke test, given the docs ask for 5.x
- Whether
host.openshell.internalstill maps to loopback with Docker’s bridge mode - How the Fedora .rpm path compares, which I haven’t run
The loopback question matters most. If you switch the Docker driver’s supervisor_network_mode to bridge, the docs say host services must listen on bridge-reachable addresses, which would bring back the LAN exposure I avoided. I’d test that before relying on a localhost-only model server. What I am sure about is the core: on all three runtimes I tried, the agent had no network of its own, every request left through a rule I could read, and I changed those rules on a running sandbox without restarting the agent.