This is an audit of NVIDIA's OpenShell source on GitHub,
read at commit bc4c8f0 on 23 October 2026, together with NVIDIA's
August 2026 security bulletin
for the project. We read the code, the documentation and the design RFCs. We did not build or
run it, so every finding below is either something we read in a code path or something the
project's own documentation states, and the text says which. The runtime is NVIDIA's work. The
reading, and the judgement about what matters, is ours.
Three weeks ago we wrote that one sentence in NVIDIA's OpenShell announcement was doing more work than the rest combined: enforcement happens outside the agent's process, where it cannot be prompted away. That note, OpenShell puts the enforcement outside the agent, was a reading of the product page. It said plainly that we had not looked further.
So we looked further. The source is public under Apache 2.0: about 610,000 lines of Rust across 38 crates, moving at roughly eight commits a day. We split it into the four places the announcement makes promises (the sandbox, the network policy engine, the credential path, and the policy prover) and read each one against the claim made for it.
The short version is that the sentence we praised holds up in code. The architecture is real, it is careful, and in most places it fails closed. What does not hold up is everything a team inherits by not setting a field. The design is strong and several defaults are not.
We also owe a correction. NVIDIA published a security bulletin for OpenShell on 25 August, five weeks before our first note, listing six vulnerabilities including two rated 9.9. Our first note did not mention it. It should have, and section 7 covers it.
1. What we read, and how
Each of the four areas was read against two questions: what does the announcement or the documentation claim, and what does the code path actually do. A finding counts as verified only where we read the code that implements it. Where only the documentation says something, we say so.
| Area | Main crates | Claim tested |
|---|---|---|
| Sandbox | openshell-sandbox, compute drivers | Kernel controls confine files, syscalls and network |
| Network | openshell-supervisor-network, openshell-binary-identity | Every request is checked by binary, host, method and path |
| Credentials | openshell-core, openshell-providers, credential drivers | Agents never see real credentials |
| Prover | openshell-prover, gateway policy service | Formal verification flags risky policy changes |
The project is young and moving fast. Version 0.1.0 was tagged on 25 September, 0.1.5 on 20 October, and the history since mid-June holds close to a thousand commits. Anything below can be out of date within weeks, in either direction. That is a reason to read it as a snapshot and to check the commit you deploy.
2. The core claim holds
Start with what is right, because most of it is.
On Linux the workload process runs under three controls, and the sandbox refuses to start
without all of them. A startup qualification step checks for a non-root identity, an empty
capability set, no_new_privs, a working Landlock filesystem sandbox at ABI 3 or newer, a
working seccomp notification channel, and socket confinement that the workload cannot undo. If
any check fails, the process exits. We found no flag, environment variable or development mode
that switches the sandbox off.
Network access is not left to the agent's good manners. A seccomp filter allows only Unix,
route-netlink and internet sockets, so raw sockets, packet sockets and ICMP are refused. Every
internet socket the workload opens is intercepted by a broker in the supervisor and bound to the
loopback device before the workload can use it. That binding survives fork and exec, and
clearing it needs a capability the sandbox does not hold. DNS goes to a relay that answers only
for names the policy allows.
That is the sentence from the announcement, implemented. The agent can be talked into anything, and what it is talked into still arrives as a request at a supervisor it cannot reach.
Three qualifications belong next to that.
The outer fence is the runtime's, not OpenShell's. The sandbox process does not create its
own network namespace. Its source says direct egress is rejected "by Docker network-none,
Kubernetes NetworkPolicy, or a NIC-less VM". On Docker and Podman that is set by the driver
and is solid. On Kubernetes the documentation is explicit: the cluster's network plugin must
enforce NetworkPolicy, and "without it, sandbox pods can bypass OpenShell network policy".
OpenShell does not check that your plugin does. The security guide also describes a network
namespace with a veth pair, which is not what the Docker and Podman path we read does, so treat
that page as describing one deployment rather than all of them.
The syscall filter is a denylist. Its own module comment calls it "a default-allow policy
with targeted blocks". The list is well chosen (ptrace, bpf, io_uring, mount and the new
mount API, userfaultfd, user namespace creation and more), and a denylist is a normal choice
for running arbitrary developer tools. It still means safety depends on the list being complete.
Filesystem policy degrades by default. The mandatory baseline is a hard requirement and
protects OpenShell's private directory. The user's own filesystem policy defaults to
compatibility: best_effort, and if applying it fails the code logs "Running WITHOUT filesystem
restrictions" and continues on the baseline alone, which grants access to every top-level path
except OpenShell's own. A high-severity event is emitted when this happens. Setting
hard_requirement turns it into a startup failure.
3. The default that matters most
Here is the finding we would put first on any team's list.
The network policy can inspect individual HTTP requests and allow a read while refusing a write on the same API. That was the granularity we singled out three weeks ago. In code, those rules have an enforcement mode, and the mode is chosen like this:
let enforcement = match enforcement_value.as_str() {
"enforce" => EnforcementMode::Enforce,
"" | "audit" => EnforcementMode::Audit,
An empty field means audit, and in audit mode a request that breaks a rule is logged and then
forwarded. So a policy that says access: read-only and leaves enforcement out does not
prevent writes. It records them. The documentation states this, and recommends starting in
audit and switching later. The shipped provider profiles and the quickstart do set enforce,
which is to their credit. A team writing its own rule from the schema reference gets audit.
Two more defaults compound it. An endpoint with no protocol field gets no request rules at
all: host, port and binary are checked, and then any method and any path pass. And for
WebSocket upgrades on such an endpoint, the code's own log line is "raw bidirectional relay (L7
enforcement no longer active)".
The prover agrees with us. Its formal containment check refuses to vouch for a REST endpoint
whose enforcement is anything other than enforce, and reports it as unsupported. The one part
of the system built to reason about what a policy permits does not accept audit mode as a
permission boundary. Neither should anyone deploying it.
"Nothing is permitted by default" is true at the connection. It is not true of the method and path rules that make the announcement interesting, unless you write one extra word.
4. A binary is a tool, not an intent
Every network rule names the binaries allowed to use it, and the identity check is careful. The
executable is read from /proc/<pid>/exe, so symlinks and argv[0] cannot fake it, and its
SHA-256 is pinned the first time it is seen. A later mismatch is denied.
Two properties of that design matter in practice.
Ancestry grants access. A rule matches if the connecting executable, or any process above it,
is listed. List /usr/bin/claude and every shell, script and tool that the agent spawns passes
the binary check. List curl and anything that shells out to curl passes. The documentation is
candid about interpreters: a rule that lists the Python interpreter so that pip can install
packages means "any Python program in the sandbox can use it". Binary scoping tells you which
tool made a request. It cannot tell you why.
The pin trusts whatever arrives first. Policies name paths, never hashes, and the hash is
whatever is observed on first use. That is sound for system paths the sandbox cannot write. The
shipped PyPI profile also lists interpreter paths under /sandbox, which is the writable working
directory. We have not tested it, but it follows from the code that an agent which writes its
own executable to one of those paths before first use gets its own binary pinned as trusted.
Keep authorised binaries out of writable paths.
5. The credential is guarded on the way out, not on the way back
The outbound half is as good as the announcement says. The agent's environment holds a
placeholder of the form openshell:resolve:env:<key>, never the value. The supervisor
substitutes the real value only on endpoints the provider is bound to, after the policy check,
and a placeholder that cannot be resolved fails the request rather than leaking it. Static
credentials have been bound to their provider's endpoints since a change merged on 10 August.
Three findings narrow what that guarantees.
The agent chooses where the key goes. Substitution applies to the request line, every query parameter and every header value, with no restriction on header name. On a bound endpoint the agent can put the real key into any header or parameter it likes, and where request body rewriting is enabled, into any field of the body.
Nothing scrubs the response. The response relay is never given the resolver and does no matching. The provider documentation says it directly: "The proxy does not rewrite cookies, response content, unsupported request bodies, or WebSocket binary frames." An approved API that echoes a header, reflects a key in an error message, or stores a field and serves it back will hand the real value to the agent. The shipped Codex profile places OAuth refresh tokens in the sandbox as placeholders, and a token refresh response carries new tokens back the same way. We could not determine from the repository how that client behaves in practice.
The real values are inside the sandbox runtime. They are held by the supervisor process,
separated from the workload by the ptrace and memory-read blocks and a non-dumpable process.
That is a reasonable boundary. It is a process boundary, and section 7 is a reminder that process
boundaries get escaped.
So the sentence we wrote last time, that a fully compromised agent cannot exfiltrate a secret it never held, needs a clause. It cannot send the key anywhere the provider is not bound. It can still see the key if a bound endpoint shows it, and it can still send any data at all to any endpoint it is allowed to reach. The security guide says that last part itself: each allowed endpoint "is a potential data exfiltration path". Request bodies are not inspected by default, and the one built-in body filter describes itself as "not a secret scanner".
6. Two provers, and only one of them is formal
The announcement describes a prover that uses formal verification to check whether a policy stays inside a declared boundary. In the code that is two separate engines, and they answer different questions.
The containment checker is the real thing. It encodes network policy into the Z3 solver using string, regular expression and integer theories, and asks whether any action the candidate policy allows falls outside the boundary policy. Where the answer is yes, it produces a concrete action and replays it to confirm. Anything it cannot model (middleware, GraphQL, MCP, credential settings, audit mode, unusual globs) is reported as unsupported rather than ignored, and a solver timeout is reported as inconclusive rather than as a pass. Eleven tests check it against the runtime's actual policy rules. That is good engineering. Two limits: filesystem and process rules are compared by exact match rather than reasoned about, and it is a command line tool. The project's own README says gateway callers "continue to use the proposal-risk API". The containment checker is not in the approval path.
The engine that gates approvals is a fixed checklist. When a sandbox is blocked and a rule is proposed, the gateway runs a proposal-risk check. It loads facts into Z3 as constants asserted true, and the real decisions happen in ordinary code. It looks for four things: link-local reach, a credentialed host reachable by a binary that bypasses inspection, new credential reach, and new capability. It does not model paths. And by design, in the code's own words: "Un-credentialed reach is not a tracked risk."
That matters because approval can be automatic. The default is manual, which is right. An
operator can set the approval mode to auto, and then a proposal that clears the four checks is
merged with nobody looking, including rules OpenShell drafts itself from blocked connections.
The advisor documentation warns about exactly this: turn it on only if you accept that binaries
in the sandbox can gain access to public hosts without your review.
The design RFC for agent-driven policy said automatic application should happen only when the prover, validation and an organisation-wide ceiling all succeed. What shipped has no ceiling and no containment step. Three weeks ago we called the prover the first time we had seen formal methods shipped as part of an agent product. That was half right. They shipped, as a tool you run. They are not yet what decides.
7. The bulletin we should have cited
NVIDIA's security bulletin 5872, released 25 August, covers NemoClaw and OpenShell. The six OpenShell entries all affect versions up to 0.0.33, are fixed in 0.0.34, and were found externally.
| CVE | Score | What NVIDIA describe |
|---|---|---|
| CVE-2026-65093 | 9.9 | Sandbox escape on Linux, uncontrolled search path element |
| CVE-2026-65083 | 9.9 | Sandbox provisioning API, incomplete list of disallowed inputs |
| CVE-2026-65091 | 8.8 | Command injection by a malicious gateway, all platforms |
| CVE-2026-65092 | 8.5 | Path traversal that bypasses the L7 REST network policy |
| CVE-2026-65086 | 6.8 | Command injection in the sandbox exec handler |
| CVE-2026-65085 | 5.2 | Improper output encoding in the inference proxy |
None of this argues against the architecture. Fast disclosure and a fix in the next release is what a responsible project does, and the history around it shows the same classes being hardened: path dot-segment canonicalisation on 17 August, trailing-dot host normalisation on 24 August, a fix to stop rules inheriting authorisation they never declared on 10 August.
One entry is worth dwelling on. CVE-2026-65092 is a path traversal past the REST policy, and the
containment checker's model of a request simply forbids such paths. Its domain asserts that a
path never contains //, /./ or /../, which is correct only if the proxy normalises paths
before matching. That assumption is reasonable, and it is exactly where a real bypass lived. We
cannot tie that CVE to a specific commit. The point stands regardless: a proof is a statement
about a model, and the model's assumptions about the enforcement layer are the part nobody
proves.
8. The scorecard
| Claim | What the code does | Verdict |
|---|---|---|
| Enforcement outside the agent's process | Supervisor brokers every socket; startup fails closed on missing kernel controls | Holds |
| Nothing permitted by default | Connections deny by default; L7 rules default to audit and forward | Holds at connection, not at request |
| Checked by binary, host, method, path | Binary by exe and hash, ancestors included; method and path only where protocol is set | Holds, with wide edges |
| Agents never see real credentials | Never in the environment; not scrubbed from responses | Holds outbound only |
| Formal verification of policy changes | Real SMT checker as a CLI; approvals use a four-item checklist | Overstated |
| Runs everywhere | Linux kernel controls; Kubernetes relies on the cluster's network plugin | Holds, conditionally |
9. If you run it
Most of what we found is fixed by configuration, which is the good news. In priority order:
- Set
enforcement: enforceon every inspected endpoint, and give every API endpoint aprotocol. Treat a missing field as a review failure. - Run 0.0.34 or later. The 0.1 series is current.
- Leave approval mode on manual. If you use auto, accept in writing that public hosts can be added without review.
- Keep authorised binaries out of writable paths, and prefer the specific tool over the interpreter or the agent binary. Remember that ancestors authorise descendants.
- Set filesystem
compatibility: hard_requirement. - On Kubernetes, prove your network plugin enforces
NetworkPolicybefore relying on any of the above, and enable user namespaces where the cluster supports them. They are off by default. - Prefer exact hosts to wildcards. The documentation notes a wildcard can open a DNS-label exfiltration channel.
- Write a boundary policy and run the containment checker against every change in CI. It is the strongest component in the project and nothing calls it unless you do.
- Decide on telemetry. It is anonymous, documented, and on by default.
10. What this changes about the argument
Our first note argued that the right place for an agent's permission decision is somewhere the agent's text cannot reach. The source confirms OpenShell puts it there, and does so with more care than most systems we have read, including the ones we argued from in the boundary is the product and the real attack surface.
What the audit adds is the other half. Moving enforcement out of the model changes who decides. It does not change what is decided, and what is decided is mostly set by defaults. A runtime that cannot be prompted away will still forward a write it was configured only to log, still hand back a key an approved API echoes, and still approve a host if someone switched approvals to automatic to stop the prompts. None of those failures involves the model at all.
That is the same lesson as code orchestrates and governed autonomy, seen from the infrastructure side. Deterministic enforcement is necessary. It enforces whatever it was given, and the review that matters is of what it was given.
11. Sources
- NVIDIA, OpenShell on GitHub, read at commit
bc4c8f0, including the security best practices, policy schema, network rules, providers, advisor and prover documentation underdocs/, and RFCs 0001, 0002, 0009 and 0012. Every path and quotation in this note refers to that commit. - NVIDIA PSIRT, Security Bulletin: NVIDIA NemoClaw and OpenShell, August 2026, bulletin 5872 and its CVE records, for section 7.
- NVIDIA, the OpenShell product page, for the claims tested in section 8.
- Our own OpenShell puts the enforcement outside the agent, the reading this audit extends and in two places corrects.
We read the code and did not run it. Where a finding is marked as following from the code rather than tested, such as the binary pin in section 4 and the token refresh in section 5, it is an inference and should be tested before being relied on. The project moves at around eight commits a day, so check any finding against the version you deploy.
This note sits in our Engineering track, alongside the agentic, governance and system design threads. The full library is at Research.