← Back to all posts
Published on · by Renaud Deraison

OpenShell policies in Bromure, and the reasoning behind every connection

NVIDIA's OpenShell describes what a coding agent may touch in one policy file. Bromure Agentic Coding 5.4 enforces those policies in the agent's VM, with Landlock and seccomp inside and the network rules on your Mac, and watches the guest kernel with a sentry module. It then links every network connection to the processes that opened it, the tool call that started them, and the reasoning the agent gave for that call.

A coding agent needs real access to be useful, and a policy says how much. It works best as a single document you can read, check and enforce, instead of settings scattered across a firewall, mount options and the agent's own configuration.

NVIDIA's OpenShell

OpenShell is NVIDIA's open source runtime for agents, released under the Apache 2.0 license. We built on its policy format, where one YAML document covers four areas:

  • filesystem_policy and landlock: which paths the agent may read and write, enforced by the Landlock security module in the Linux kernel.
  • process: which user and group the agent runs as.
  • network_policies: which destinations each program may reach, and which requests it may send there, down to the HTTP method and path. Anything not allowed is denied: "OpenShell denies every outbound connection from a sandbox unless a rule in network_policies allows it."

OpenShell also ships a prover that uses an SMT solver to check whether one policy allows anything beyond another. An organization can set a boundary and get a yes or no on every change, instead of reading diffs.

It is a careful design from people who have clearly studied how agents fail, and it is open, so we started from it.

OpenShell in Bromure

In Bromure Agentic Coding 5.4, a workspace can use an OpenShell policy in place of Bromure's own firewall rules. We took the policy format and its semantics, not the code: the engine is our own, on the Mac, plus our own components in the Linux guest. To make sure the two agree, we ran our engine and OpenShell's reference engine on 125,666 generated cases across network rules, HTTP rules, MCP, GraphQL, WebSocket and destination address checks. They disagreed on none. That testing turned up path tricks such as ..%3b and double-encoded slashes (%252F), which we now handle the way OpenShell does.

Bromure differs from OpenShell in where enforcement runs. OpenShell starts sandboxes through a gateway and a container runtime. Bromure runs each workspace in its own disposable Linux VM on your Mac, so the policy is split between the two sides:

  • In the VM: Landlock for the filesystem, seccomp, no_new_privs and the switch to the policy's user. The next section covers how.
  • On the Mac: the network rules. Every connection leaves the VM through Bromure's virtual switch, which checks the destination and port, and its TLS proxy, which checks HTTP methods and paths, MCP, GraphQL and WebSocket messages. The Mac resolves and pins the addresses, so DNS can't be used to slip past an allowed_ips rule. Root inside the VM can't edit a rule that lives outside it.

The policy can also name the program allowed to make a connection. With strict mode on, Bromure checks which binary opened the connection, by hash: curl may reach api.github.com, and a Python script may not. Combined with an HTTP rule such as GET-only on api.github.com/repos/**, a DELETE is refused at the proxy. Landlock can't do any of this: it only confines files.

When an organization sets a boundary from bromure.io, Bromure checks every workspace policy against it with NVIDIA's openshell-prover when it is installed, and with a stricter built-in check when it isn't. A policy that leaves the boundary can't be saved.

We made one deliberate change: each section applies only if it is present. A policy without filesystem_policy leaves Landlock off, and one without process switches no user. OpenShell fills in defaults because its drivers always send a full document. In Bromure, adopting OpenShell never silently breaks a workspace. That gives three levels:

The policy hasApplied in the VMWhat it guarantees
Network rules onlyNothing; the agent keeps sudoThe network policy, and observation by the kernel sentry
filesystem_policyLandlock, applied while still rootFilesystem confinement that holds even against root
A process section, or strict modeLandlock, seccomp, no_new_privs, a non-root user; sudo and docker revokedAll of the above, no path to root, and no way to stop Bromure's own processes

A few fields are accepted and not enforced. The credential rewrite and signing fields are the main ones: Bromure already keeps real credentials on the Mac and swaps them in on the way out, so the agent only ever holds stand-ins.

Inside the VM: Landlock, seccomp and a privilege drop

The guest half is a reimplementation of the enforcement code in OpenShell's sandbox-linux crate. It is written in Python with ctypes, because it has to run inside the VM at boot, where there is no Rust toolchain. It makes the same system calls the Rust landlock and seccompiler crates make, and a differential harness builds OpenShell's real crates and compares the two path by path and system call by system call.

A root supervisor, bromure-sandboxd, starts every agent session. It moves the session into its own cgroup, the sandbox cgroup, then confines it in three steps.

Landlock. Landlock lets an unprivileged process restrict itself, and every process it starts, to a list of paths. The supervisor:

  1. calls landlock_create_ruleset with the rights the ruleset governs. On the guest's 6.8 kernel (Landlock ABI 4) that is executing, reading and writing files, listing directories, creating and removing each kind of file, renaming across directories (REFER) and truncating;
  2. opens each path in read_only and read_write with O_PATH and adds it with landlock_add_rule, as a PATH_BENEATH rule carrying the read rights or all of them;
  3. calls landlock_restrict_self.

From then on, any access the ruleset governs and no rule grants fails with EACCES, whoever asks. Landlock checks the task, not the user, so sudo doesn't widen it. A process can stack more rulesets but never remove one, and that is why a filesystem change needs a VM restart while network rules apply live. landlock.compatibility decides what happens on a kernel without the ABI: best_effort carries on with what the kernel offers, and hard_requirement refuses to start the session.

seccomp. In strict mode, or when the policy has a process section, the supervisor sets no_new_privs and installs OpenShell's three seccomp filters:

  • The main filter allows by default and blocks what can get around the sandbox. That includes ptrace, bpf, io_uring_setup, userfaultfd, perf_event_open, process_vm_readv and process_vm_writev, the whole mount family, and setns. It blocks memfd_create and execveat with AT_EMPTY_PATH, because running code from an anonymous file bypasses Landlock. It blocks unshare and clone with CLONE_NEWUSER, because a new user namespace hands out capabilities. And it blocks sockets in the packet, Bluetooth and vsock families, and netlink other than routing.
  • A compatibility filter answers clone3 with ENOSYS. A seccomp filter can read register values but not follow pointers, and clone3 passes its flags through a pointer. ENOSYS makes glibc fall back to clone, whose flags the main filter can check.
  • A hardening filter stops the session from reaching its supervisor, which runs as the same user: no ptrace, process_vm_*, pidfd_getfd or kcmp, and no signals to the supervisor's process or group.

We diverge from OpenShell twice, and only in what gets reported or how signals are aimed. Our filters carry SECCOMP_FILTER_FLAG_LOG, so a refused system call reaches the kernel's audit hook, where the sentry sees it. And the signal rule is narrowed so that shell job control (kill %1) still works in an interactive terminal.

The privilege drop. run_as_user switches the session to the policy's user, with none of the default user's groups, so no sudo and no docker. The user's home is mounted into the session through an idmapped mount, so files the session creates still belong to the default user on disk, and the agent's configuration still applies.

A sentry in the guest kernel

To record what an agent actually did, Bromure loads its own kernel module into the VM, the sentry. Its design assumes the agent can become root inside the VM, so nothing root could stop counts as evidence: capture, buffering and transport all run in the kernel.

Capture. The sentry registers kprobes from kernel space:

  • program starts, at security_bprm_committed_creds, with the command line and the process's start time;
  • credential changes: the setuid and setgid families, capset and commit_creds;
  • ptrace, module loads and unloads, bpf, kexec_load, mount, unshare and setns;
  • new network flows: TCP when the first SYN goes out (tcp_connect), UDP on the first send to a destination, and ICMP and raw sockets, each with the chain of up to eight processes behind it;
  • refused system calls, at audit_seccomp;
  • refused file operations, through kretprobes on the kernel's file security hooks: security_file_open, and the security_path_* hooks for mkdir, rmdir, unlink, symlink, link, rename and truncate. When a hook returns EACCES or EPERM for a process in the sandbox cgroup, that's a denial. Executing a file Landlock doesn't allow shows up as a refused open with FMODE_EXEC.

The supervisor gives the sentry the sandbox cgroup's ID once, through a module parameter that can be set but never changed. Root can't point it somewhere else and have the agent's activity labelled as Bromure's own.

Transport. A kernel thread opens an AF_VSOCK socket to the Mac with sock_create_kern. The socket has no file descriptor, so it doesn't appear under any process in /proc and nothing in user space can close it. Events go through a 256-slot ring, and each kind is rate-limited to 64 a second, with bursts of up to 256. Each frame is a 4-byte length followed by JSON.

Staying loaded. The module has no exit function, so the kernel refuses to unload it, and it holds a reference to itself on top. Right after loading it, the loader raises the kernel's lockdown to integrity, or confidentiality when the workspace requires the sentry. From then on root can't load an unsigned module, write kernel memory through /dev/mem or kexec into a kernel without the sentry.

Proving it is alive. Every second the sentry sends a heartbeat with a sequence number, counts of dropped and rate-limited events, and how many probes are armed. That count alone can be fooled: switching off every kprobe at once through debugfs leaves each probe's own flags untouched, so the count still looks healthy while nothing fires. To catch that, the sentry keeps a kprobe on an empty function of its own and calls it once per heartbeat. If the hit counter doesn't move, probes aren't firing, whatever the reason, and the heartbeat says so.

Proving it is the sentry. Each load generates a 32-byte secret. The first hello carries it, and the Mac pins it for that boot. A reconnect never repeats the secret. It sends sha256(secret, boot_id, connection number) instead, so a process that read the secret out of memory after the first hello still can't impersonate the module. A second sentry, a wrong secret, a reused connection number, a gap in the sequence or 15 seconds of silence all count as tampering. Root's remaining move is to crash or power off the VM, which the Mac sees as silence.

Distribution. Modules aren't built in the VM. CI builds one for each of the 20 Ubuntu 24.04 kernel releases, twice, and the two builds have to match byte for byte. Each module and the catalog listing them are signed with an ed25519 key, and the app refuses a module whose signature or hash doesn't check out.

One lesson from building it: for a while, the file-denial probe compared an unsigned return value against -EACCES and never fired once. The tests only checked that the probe was registered, which it always was. They now check that a denial produces an event.

Judging the events. On the Mac, events are judged by what they mean, not by which system call fired. Each one adds weight to a score, and 20 within 60 seconds trips the alarm:

EventWeight
Tampering with the sentry, or root gained from inside a strict sandbox20
An unsigned module, a BPF load, a kexec, an attempt to lower lockdown; one program refused 20 times in a minute10
A ptrace, or a system call refused by seccomp3
A mount or a namespace change; dropped events2
A file operation refused by the sandbox1
A sudo in a workspace that allows sudo0, shown on the timeline

An alarm that fires in the first minute teaches people to ignore the real ones. So Bromure's own housekeeping runs outside the sandbox and never shows up as a denial, and events from Bromure assembling the VM at boot are counted, not alarmed on, within a fixed boot window. When the alarm trips, Bromure can quarantine the workspace: it cuts the VM's network and pauses it.

Lineage: the reasoning behind every connection

A network log shows that something in the VM connected to a host, but not why. When an agent does the work, the why is what you need to know.

Bromure can tell you, because it sees both ends. Its AI proxy sits between the agent and the model provider and reads each response before the agent acts, so it knows every tool call, the command it will run, and what the model wrote just before it. The sentry reports every program the VM starts, with its command line, and every new connection, with the chain of processes behind it. Bromure joins the two.

Say you ask the agent to add a health check that retries your payments API. It decides it needs an HTTP client and runs npm install axios. npm fetches the package from registry.npmjs.org, which the policy allows. Then a postinstall script in the download starts and tries to reach telemetry.example.net, which no rule allows. The connection is refused at the proxy on the Mac, and the lineage shows how it happened:

Bromure lineage view for a blocked connection to telemetry.example.net: the prompt, the agent's reasoning and stated intent, the Bash tool call npm install axios marked as an exact match, five processes from claude through bash, npm and sh to node running scripts/postinstall.js, the TCP flow, and the decision Blocked by the request policy, with the allowed flow to registry.npmjs.org listed below
The lineage of a blocked connection: the request, the reasoning, the tool call, the five processes down to the postinstall script, the flow and the decision. The allowed download from registry.npmjs.org is listed under the same tool call.

The agent never typed a command that reached telemetry.example.net. A plain network log would show a Node process talking to an unknown host; the lineage shows it was a dependency's install script, under the npm install the agent ran for a stated reason.

The join works in three steps:

  1. Tool call. The AI proxy keeps every tool call from the model's response for ten minutes: its ID (tool_use_id, or OpenAI's call_id), the command, and up to 2,000 characters of what the model wrote before it in the same turn.
  2. Shell. From the sentry's program starts, the Mac keeps a process table for each VM, keyed by pid and start time, so a reused pid can't inherit someone else's history. A shell started under the agent within 30 seconds of the response is tied to the tool call when the command appears in its command line, once the agent's own quoting is undone. Claude Code, for instance, runs commands as bash -c '… eval '"'"'<command>'"'"' …'. That is an exact match. When the agent rewrote the command, Bromure falls back on the first word, the timing and the ancestry, and labels the match as fuzzy.
  3. Decision. A flow is matched to the switch's allow or deny on its protocol, destination, port and source port within 5 seconds, and to the proxy's decision when it inspected the request.

Every connection below the shell inherits the link. That includes traffic through Bromure's proxy bridge, which would otherwise all look like connections to localhost. The bridge starts each stream to the Mac with a line naming the client's source port, so the Mac can map the local connection to the real destination.

In the Security window, Show the Whole Chain on a network row opens that view.

Some models go straight to the tool call without writing anything first. Then the view shows the prompt and the call's stated intent instead. Background services and anything started outside a tool call are marked as not traced to an agent.

Lineage also changes what happens after a block. An agent that only sees a connection reset or a permission error takes it for a fault and tries another host, port or tool, which from the outside looks like an agent going rogue. Bromure tells the agent, in its own session, that the policy refused the request and why, and to ask you rather than work around it. The Sandbox drawer of a running workspace lists what was blocked, with an Allow button for each, and the policy advisor drafts rules from the denials for you to approve or not.

The Sandbox drawer of a running workspace using OpenShell + Sentry, listing four blocked destinations with how often and how recently each was tried, an Allow button for each, and the note that network changes apply live
The Sandbox drawer lists blocked destinations. Network changes apply live.

The agent's reasoning is always shown on your own Security Timeline. It is sent to an organization's console on bromure.io only when that organization turns it on, which it is not by default, and never for private profiles. Tokens on command lines are redacted before anything leaves the Mac.

A 24-second loop on a demo workspace. The Security Timeline records what the sentry and Landlock refused: a Node process reaching for SSH and AWS keys, and a ptrace. Then the blocked connection opens into its lineage, read from the packet back to the agent's reasoning.

Turning it on

OpenShell policies, the kernel sentry and lineage arrive in Bromure Agentic Coding 5.4. They are off by default. To turn them on for a workspace, open Settings, then the workspace's Sandbox Policy, and set Sandbox engine to OpenShell + Sentry. You can write the policy there, or import an existing OpenShell YAML file.

Bromure workspace settings, Sandbox Policy pane, with the sandbox engine set to OpenShell + Sentry and an OpenShell policy allowing github.com, read-only access to registry.npmjs.org, and read-only access to pypi.org
A workspace on OpenShell + Sentry. The starter policy allows GitHub, and read-only access to npm and PyPI: installs work, publishing is blocked.
  • Network rules apply live. Filesystem changes take effect at the next VM start, because Landlock can only narrow what a running process may do.
  • The sentry runs in best-effort mode by default. Set it to Required and Bromure raises the kernel's lockdown to its strictest level, and treats a sentry that never connects as tampering.
  • With the sentry required, a workspace's shell is ready in 4 seconds when its kernel's module is cached, and in 6 seconds when Bromure has to fetch it.

To see it at work, watch Claudia's three episodes: she writes a policy, tunes it while an agent works, and traces a 2 AM supply-chain attack back to the agent's reasoning.

Thank you to the OpenShell team at NVIDIA for publishing the policy format, the reference engine and the prover in the open. We built on all three.