Brahim Bousnguar

Notes · Article

A sandbox inside the sandbox: moving my open-source agent to Hermes on OpenShell

By · · 8 min read

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.

  1. 1ScoutFinds issues on popular projects in my areas and picks the ones worth building.
  2. 2WorkerClaude Code clones the repo, writes the fix, runs the install and the tests: code written by strangers.
  3. 3GateRe-runs the worker's checks itself, and sends it back once if they fail.
  4. 4ReviewerA second model reads the diff. A blocking finding gets one more fix.
  5. 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.

One job, start to finish. Step 2 is the one that needs a fence, so it lights red.

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.

Each box holds the next. The keys sit in the OpenShell layer, outside the sandbox.

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:…

  1. 1HermesSends its request with the placeholder in the auth header.
  2. 2ProxyNames the program behind the connection.
  3. 3ProxyChecks the policy: this program, this host.
  4. 4ProxySwaps the real key in on the way out. It never enters the sandbox.
  5. 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.

One request that gets its key, then one that doesn't. The square turns cobalt when the key joins, red when it's refused. Variable name shortened.

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.

The same two calls under two models. The red key is the one I didn't expect.

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.

  1. Hermesthe sandbox's Python

    OpenRouterTelegram

  2. Forge's scriptchild of Hermes, same Python

    OpenRouterTelegram

  3. Worker stepClaude Code, started by the script

    OpenRouterTelegraminherited; its cage can't reach the proxy

  4. Relay, detachedparent: PID 1, its own Python

    RegistriesAnthropic

Indent is parentage. The relay sits at the root on purpose: double-forked, it inherits nothing.

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.

  1. FilesLandlock: read, write and execute listsListed
  2. TCPLandlock: not OpenShell's proxy or gateway, not Hermes's portsBlocked
  3. Socketsseccomp: unix sockets and io_uringRefused
  4. Way outThe relay: CONNECT only, a host allowlistOne door
  5. 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.

Two fences: OpenShell around everything, the cage around the worker. The relay is the one door between them.

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 openshell wrapper 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

    python3 is Hermes's virtual environment

    Leave it off the worker's PATH.

  • Two spellings of a proxy

    curl ignores HTTPS_PROXY when https_proxy is set

    Set both cases.

What each trap looks like, then what fixed it.

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.

  1. HermesIn OpenShell on Podman, deny by default, Sonnet 5.5Live
  2. RulesPlaceholders, endpoints, inheritanceMapped
  3. CageLandlock, seccomp, relay; claude -p and the reviewer answeredTested
  4. ProfileIts own SOUL.md; a hook that blocked rmTested
  5. PortForge's scripts on the DellNot yet
  6. Dry runNo pushes, no messagesNot yet
  7. 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.

The state on October 24. The red rows are the part that takes the longest.

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:

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

Contact

Say hello.

b.bousnguar@gmail.com

SAP Commerce Cloud, MuleSoft and Salesforce integration · Nantes · EN / FR