Notes · Article
A sandbox inside the sandbox: moving my open-source agent to Hermes on OpenShell
Forge, my open-source agent, clones strangers' repositories and runs their install scripts and test suites all day. When I started moving it to Hermes Agent inside NVIDIA OpenShell, I found the sandbox around the agent wasn't enough: inside one OpenShell sandbox, a process the agent starts gets the agent's network rules. So Forge's workers get a second fence inside the first, built from Landlock, a seccomp filter and a small relay that is their only way out. The move isn't finished. The cage works, and OpenShell taught me two rules I didn't expect.
On October 23 I wrote that NVIDIA's advice for agents starts with the sandbox, and that my own sandbox row was empty. The next day Hermes was running inside OpenShell on an old Dell laptop. This note is what happened after the install: where the keys live, what a child process inherits, and what a sandbox inside a sandbox has to block.
Forge runs strangers' code for a living
Forge is my open-source contribution agent. It looks for issues on popular projects in my areas, picks the ones worth building, and hands each one to a detached worker. Claude Code clones the repo, writes the fix and runs the project's tests. A gate re-runs the checks itself, a second model reviews the diff, and the fix goes to my fork. Anything said on a pull request after that waits for my ok.
It runs on a budget of $5 a day, with at most four pull requests open at once.
The risky step is the build. "Run the tests" means running whatever a repo's install scripts and test suites do, written by people I've never met. On the Mac mini, where Forge lives today as an OpenClaw agent, the workers run inside srt, a sandbox runtime built on macOS Seatbelt. Moving Forge to Linux meant building that fence again.
- 1ScoutFinds issues on popular projects in my areas and picks the ones worth building.
- 2WorkerClaude Code clones the repo, writes the fix, runs the install and the tests: code written by strangers.
- 3GateRe-runs the worker's checks itself, and sends it back once if they fail.
- 4ReviewerA second model reads the diff. A blocking finding gets one more fix.
- 5ShipPushes to my fork and opens the pull request. Anything said on it later waits for my ok.
TodayOpenClaw on the Mac mini, workers in srt. $5 a day, at most four open PRs.
Hermes moved in first
The host is an old Dell Inspiron laptop running Ubuntu Server 26.04 with the lid closed. NemoClaw, NVIDIA's installer for agents in OpenShell, set up the CLI and the gateway in 78 seconds, without sudo. I pinned version 0.0.137, which brings OpenShell 0.0.116, and picked Hermes as the agent. It came up as Hermes Agent v0.21.3, on Claude Sonnet 5.5 through OpenRouter.
The first wall was Docker. The Dell runs Docker rootless, and OpenShell's gateway can't live there: the sandboxes sit in rootless Docker's own network namespace while the gateway listens on the host, so a sandbox can't call it back. The rootless path that works on Linux is Podman, version 5 or later. It took one root step, a small system unit that adds an address on loopback. I ran it by hand, because my agents can't sudo on that box.
Out of the box the sandbox denies by default. With only the PyPI preset on, a request to example.com gets a 403. Hermes's API and dashboard listen on loopback, and NemoClaw owns Hermes's config file and checks its hash, so you don't edit it by hand. Forge will run as its own Hermes profile instead, with its own SOUL.md and a hook that sees every tool call before it runs. In a test, that hook blocked an rm.
Dell Inspiron · Ubuntu Server 26.04
Rootless Podman 5.7
OpenShell 0.0.116 · gateway, proxy, keys
Sandbox · deny by default
Hermes Agent v0.21.3Forge: its own profile, next
78 sNemoClaw install, no sudo
1root step: an address on loopback for Podman
403example.com, denied by default
Rootless DockerNo: sandboxes in its namespace, the gateway on the host, no way back.
The keys arrive as placeholders
My last note explained OpenShell's credential trick from the docs. Here's how it looks from inside. The sandbox's environment holds no API key, only a placeholder that starts with openshell:resolve:env:. When Hermes calls OpenRouter with that placeholder in its auth header, OpenShell's proxy names the program, checks the policy, and swaps in the real key on the way out. Hermes never sees it.
Then I put placeholders where they don't belong. One in a request body: the proxy dropped the connection. Another provider's placeholder in a header: 403.
Telegram goes one step further. NemoClaw binds the bot token to one host, one port and one path, the bot API, and anywhere else the placeholder stays a placeholder.
In the sandboxAPI_KEY=openshell:resolve:env:…
- 1HermesSends its request with the placeholder in the auth header.
- 2ProxyNames the program behind the connection.
- 3ProxyChecks the policy: this program, this host.
- 4ProxySwaps the real key in on the way out. It never enters the sandbox.
- 5ProxyIn a request body: connection dropped. Another provider's, in a header: 403.
TelegramThe bot token is bound to one host, port and path. Anywhere else it stays a placeholder.
Credentials follow the endpoint, not the program
OpenShell's network rules name both sides of a call: which program may connect, and to which host. I assumed credentials worked the same way, so a key meant for gh would only be swapped into requests from gh.
They don't. A provider's key is resolved for any process that some policy lets through to that provider's endpoint. I found out when my relay, which a test preset had let through to GitHub's API, got the GitHub token swapped into its request. It was my own probe, so nothing leaked. But it rewrote my design rule: a program a worker can drive must never have a policy on a host that has a credential.
What I assumed
Keys follow the program
gh→ api.github.comkey
relay→ api.github.complaceholder
vs
What OpenShell does
Keys follow the endpoint
gh→ api.github.comkey
relay→ api.github.comkey
The relay was allowed there by a test preset. That was enough.
The rule nowA program a worker can drive gets no policy on a host that has a credential.
A child gets its parent's network
The second rule: policies follow the live process tree. A process gets the network rules of its own program plus those of every ancestor, checked when it makes the request.
That matters because Forge's scripts are Python, and the sandbox's Python is the one Hermes runs on. Every script Forge starts matches Hermes's own rules, OpenRouter and Telegram, and so does everything those scripts start. A worker launched the obvious way inherits a path to the agent's model provider and its chat bot.
The way out of the inheritance is to stop being a descendant. A process that double-forks ends up a child of PID 1 and keeps only the rules of its own program. That's how Forge's relay starts: detached, on its own copy of Python so it doesn't match Hermes's either, watching the worker step that launched it and exiting when that step ends.
Hermesthe sandbox's Python
OpenRouterTelegram
Forge's scriptchild of Hermes, same Python
OpenRouterTelegram
Worker stepClaude Code, started by the script
OpenRouterTelegraminherited; its cage can't reach the proxy
Relay, detachedparent: PID 1, its own Python
RegistriesAnthropic
The second fence
Inside OpenShell, each worker step runs in a cage of its own. It has three parts, two from the Linux kernel and a relay of about two hundred lines of Python.
- Landlock, the kernel's unprivileged sandbox, at ABI 8: lists of what the worker may read, write and execute; TCP connections, except to OpenShell's proxy, its gateway and Hermes's ports; no signals or abstract unix sockets to anything outside the cage.
- A seccomp filter that refuses unix sockets and io_uring. Landlock doesn't check a connection to a socket file, and Hermes's gateway listens on one: under Landlock alone, my test reached it. io_uring goes too, because it could open that socket without the system call the filter watches.
- The relay: a CONNECT-only proxy with a host allowlist, the package registries and Anthropic's API. The sandbox has no direct route out, so the relay is the only door left. It forwards to OpenShell's proxy, which sees the relay as the caller.
So the worker can install packages and call Claude. Registries pass as plain TLS, with no inspection and no key to add. On the relay's calls to Anthropic, OpenShell adds Forge's key, for the Messages and token-counting endpoints only. The worker spends tokens without ever holding the key. The reviewer runs as its own program with its own rule for OpenRouter, on GLM 5.3.
I tested it end to end. claude -p inside the cage answered through the relay for $0.026 on Haiku, and the reviewer answered through OpenRouter.
- FilesLandlock: read, write and execute listsListed
- TCPLandlock: not OpenShell's proxy or gateway, not Hermes's portsBlocked
- Socketsseccomp: unix sockets and io_uringRefused
- Way outThe relay: CONNECT only, a host allowlistOne door
- KeysAdded by OpenShell on the relay's Anthropic callsNever inside
Testedclaude -p through the relay, $0.026 on Haiku. The reviewer on GLM 5.3.
Four traps worth knowing first
None of these is in a quickstart. Each one stopped me until I found it.
The error you don't see. Two policies that name the same host must give it the same shape, TLS mode, protocol and enforcement, wildcards included. OpenShell refuses a mismatch with "network endpoint ambiguity validation failed". NemoClaw only said "OpenShell did not confirm the policy submission". A wrapper script named openshell, first on PATH and logging every call, showed me the real error.
Programs need their loader. Under Landlock, a dynamically linked program also needs execute rights on the dynamic loader. So the execute list grants /usr, not single binaries.
The wrong Python. Inside the sandbox, python3 on PATH is Hermes's virtual environment. The worker's PATH leaves it out, and its config file is unreadable under Landlock anyway.
Two spellings of a proxy. curl prefers the lowercase https_proxy, so the worker sets both cases.
The error you don't see
"OpenShell did not confirm the policy submission"
Same host, two shapes. A logging
openshellwrapper showed "network endpoint ambiguity validation failed".Programs need their loader
A binary on the list still won't start
It needs execute rights on the dynamic loader too. Allow
/usr.The wrong Python
python3is Hermes's virtual environmentLeave it off the worker's PATH.
Two spellings of a proxy
curl ignores
HTTPS_PROXYwhenhttps_proxyis setSet both cases.
Where it stands
Hermes runs in OpenShell, the credential and inheritance rules are mapped, and the cage works. A Forge profile with its own SOUL.md and a tool-call hook answers through Hermes's API.
What's left is the bulk of it. Forge's scripts still have to be ported to the Dell. Then a dry run, with no pushes and no messages, and only then the cutover: the @forge Telegram bot moves, the keys go into OpenShell providers, and the copies on the mini are deleted. Forge's dashboard stays on the mini and pulls its data from the Dell.
Until then, Forge runs where it always did: OpenClaw on the Mac mini, workers in srt.
- HermesIn OpenShell on Podman, deny by default, Sonnet 5.5Live
- RulesPlaceholders, endpoints, inheritanceMapped
- CageLandlock, seccomp, relay;
claude -pand the reviewer answeredTested - ProfileIts own SOUL.md; a hook that blocked
rmTested - PortForge's scripts on the DellNot yet
- Dry runNo pushes, no messagesNot yet
- CutoverThe bot and the keys move; the mini's copies are deletedNot yet
Until thenForge runs on OpenClaw on the Mac mini, workers in srt.
Try it
NemoClaw is the short path: it installs OpenShell, sets up OpenClaw or Hermes inside a sandbox and starts you on a deny-by-default policy. Read the bootstrap script before you run it, pin a version, and on Linux without root, use Podman.
If you want more:
- OpenShell on GitHub, Apache 2.0, with the policy docs
- NemoClaw on GitHub, the installer I used
- Hermes Agent, from Nous Research
- Landlock in the kernel docs, the unprivileged sandbox under the cage
- Hope isn't security: the five parts of an enterprise agent, the note this one follows
- OpenClaw and Hermes Agent ship the same features with different defaults, where both run tools on the host by default
If your agent starts processes that run code you didn't write, look at what those children inherit before you trust the sandbox around the parent. And if you've fenced workers inside OpenShell a different way, tell me how. I'd like to compare cages.
Plain text: openshell-sandbox-inside-a-sandbox.md · Atom feed · All notes