Skip to content

OS Sandbox

UGENT confines every process it spawns using OS-level primitives: sandbox-exec (Seatbelt) on macOS and bubblewrap on Linux. The kernel enforces the boundary for the whole process tree — not a pattern list.

Document Author: Uni Zhu

Why This Layer Exists

UGENT's application-layer defences — the injection firewall, the tool security validator, the capability classifier, the confirmation flow — all reason about tool calls. None of that survives contact with a shell: once bash is running, a command none of those layers anticipated can read anything you can read and write anything you can write.

The sandbox is the layer underneath. It does not replace the layers above it — the firewall still catches what the sandbox cannot see, and the validator still gives better error messages for structured tool calls. This is defence in depth, not a substitute.

How It Works

Containment is applied as a command transformation before the process is spawned:

text
bash -c "..."   ->   sandbox-exec -p <profile> bash -c "..."     # macOS
bash -c "..."   ->   bwrap <flags> -- bash -c "..."              # Linux

The sandbox is process-wide and applies at every spawn site: the bash tool (piped and PTY paths), background jobs, skill shell substitution, autorun commands, MCP servers over stdio, delegated claude/codex CLIs, and channel plugin workers.

Platform Support

PlatformBackendNotes
macOSSeatbelt (sandbox-exec)Built in. Nothing to install.
Linuxbubblewrap (bwrap)Install it: apt install bubblewrap or dnf install bubblewrap. Version 0.5.0 is the floor; 0.8.0 or later additionally blocks nested user namespaces.
Windowsnone yetauto warns and runs unconfined; required refuses to start.

The startup probe asserts enforcement, not presence

On startup UGENT runs a fast probe (~30 ms, once) that proves containment actually works: a permissive profile must launch, and an added read-deny must actually cause a denial. On Linux the probe runs the real generated argument list, so a flag missing from the builder fails at startup rather than silently leaving the sandbox escapable. /sandbox status reports the effective state, so a failed probe is visible instead of assumed.

Spawn Classes

Each class of spawned process gets its own policy, because trust levels and legitimate filesystem needs differ. One global profile cannot serve all five.

ClassCoversWritesCredential readsNetwork
bashThe bash tool and its background jobsworkspace, temp dirs, build cachesdeniedallowed by default
toolingSkill shell substitution, autorun gateworkspace, temp dirs, build cachesdeniedallowed by default
mcp_serverThird-party MCP servers over stdio~/.ugent/mcp onlydeniedallowed by default
external_agentThe claude and codex CLIs~/.claude, ~/.codex, build cachesdeniedallowed by default
pluginChannel plugin workersplugin state dirs only — no workspacedeniedallowed by default

Design notes worth knowing:

  • External agents keep their own config directories writable because denying them would simply break the CLIs. They do not get a credential-read exemption: delegated agents are prompt-injectable in their own right, and exempting them would turn delegation into a bypass for anything bash cannot read.
  • Plugins get blast-radius containment, not full containment. A channel plugin is third-party code from a public ecosystem holding channel credentials, and it needs the network to reach its platform. What the profile buys: one hostile plugin costs one channel rather than every credential on the machine. Exempt an individual plugin with sandbox = false on its plugins.toml entry.
  • Some paths are denied back out for every class, even inside write grants: PATH directories inside build caches (~/.cargo/bin, ~/.rustup/toolchains, ~/.local/bin, ~/.npm/_npx), ~/.ugent itself, and the config manifests that name a binary to spawn (plugins.toml, mcp.toml, cron.toml, ...). A write to those becomes unconfined execution later, so they are not writable by anything confined.
  • Credential directories are read-denied at the OS level (~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.docker, ~/.config/gh, ~/.config/gcloud, ~/.gcp, ~/.azure), derived from the same sensitive-path patterns the tool validator uses — with the difference that a shell command cannot walk past it. On Linux this list is enforced only at the tool layer (see Linux limitations).

Modes

ModeBackend availableBackend missing or probe fails
offno containmentno containment
auto (default)confinedwarn and continue unconfined
requiredconfinedrefuse to start

required exists so that a configuration claiming to sandbox cannot silently degrade to no enforcement. Use it when running unconfined is worse than not running.

Configuration

sandbox.toml is discovered like the other overlay files: workspace root, then <workspace>/.ugent/, then ~/.ugent/. Missing file means defaults (auto, network allowed).

toml
mode = "auto"                 # off | auto | required
network = "allow"             # allow | deny

# Widen writes for toolchains that write elsewhere. Entries must start with
# ~/ or / — bare relative paths are rejected.
extra_write_allow = ["~/.pnpm-store"]

# Add to the OS-enforced credential read-deny list (never replaces it).
extra_read_deny = ["~/company-secrets"]

# Per-class overrides. Lists ADD to the global values; network REPLACES it
# for that class only.
[profiles.mcp_server]
network = "deny"

[profiles.bash]
extra_write_allow = ["/opt/build-cache"]

Not hot-reloaded, deliberately

Unlike the other overlay files, sandbox.toml is not hot-reloaded. Re-reading it on a file change would let anything able to write the file relax containment before the next spawn. Applying an edit is an explicit human action: /sandbox reload, or a restart.

Workspace overlays can only tighten

A sandbox.toml inside a workspace is attacker-influenced — cloning a repository must not be able to disable your sandbox. A workspace-sourced file may tighten the mode, deny the network, and add read denials. Every write widening in it is dropped, and any attempt to loosen the mode or re-enable the network is ignored, with a warning. Put real widenings in ~/.ugent/sandbox.toml, which is yours.

Channel remote control

By default, channels (the web UI, chat plugins) may only inspect the sandbox. To let a channel change it, set:

toml
allow_remote_control = false   # default. true lets a channel turn it off.

The boundary lives in the host config on purpose: a gate in a frontend's own UI cannot enforce anything against someone holding the API key. Because the sandbox is process-wide, a channel turning it off would also remove containment for your local terminal, cron jobs, and every other channel — so disabling from a channel additionally requires a confirmation token written to the host log, and a host in required mode refuses the downgrade outright.

CLI Flags and Commands

bash
ugent --sandbox            # force auto
ugent --no-sandbox         # force off
ugent --sandbox-required   # refuse to start unconfined

The flags are mutually exclusive and outrank sandbox.toml in both directions — an operator at the terminal is more trusted than a file, and --no-sandbox must work as an escape hatch even when a config demands containment.

CommandDescription
/sandboxEffective sandbox status (defaults to status)
/sandbox statusEffective state and per-class policy summary
/sandbox profile <class>Resolved policy for bash, tooling, mcp_server, external_agent, or plugin
/sandbox testProve containment is enforced right now
/sandbox reloadRe-read sandbox.toml and rebuild
/sandbox on / /sandbox offToggle for this session only

Human-only

/sandbox is a REPL and TUI command, never an LLM tool — the model cannot turn the sandbox off, the same rule as /firewall approve.

/sandbox test is worth calling out. It attempts a write to an existing root-owned file and distinguishes the two failure modes: EPERM ("Operation not permitted") means the sandbox denied it, while EACCES ("Permission denied") means ordinary file permissions did and the sandbox is not enforcing. It writes nothing.

What Cannot Turn It Off

  1. The model cannot. /sandbox is never exposed as an LLM tool.
  2. A file write cannot. sandbox.toml is in the write-protected config list alongside ugent.toml and firewall.toml, and it is not hot-reloaded — even a successful write does nothing until a human reloads.
  3. A workspace cannot. Workspace overlays may only tighten.
  4. A confined process cannot. On macOS, Seatbelt profiles compose monotonically: a child can re-invoke sandbox-exec but only narrow, never widen. On Linux the remount escape is closed by an unshared user namespace plus --disable-userns, and the startup probe verifies the real argument list.

Limitations

Stated honestly, because a security boundary you misunderstand is worse than none:

  • Linux does not enforce read denials at the OS layer. bubblewrap cannot express "deny reads under ~/.ssh" without inverting the whole policy into a read-allowlist. extra_read_deny stays tool-layer-enforced there, and /sandbox profile marks the difference rather than implying protection that is not there.
  • Network is binary (allow or deny), with no domain allowlist — neither backend can express one. It defaults to allow so builds and package installs keep working. Phase 1 gives filesystem containment, not egress control.
  • Reads are permissive by default on macOS too. The profile is allow-by-default with writes and credential reads denied on top; a deny-by-default read policy breaks in new ways with each macOS release.
  • /etc stays readable — denying it breaks TLS, because curl cannot read /etc/ssl/cert.pem.
  • A hostile plugin still exfiltrates its own credentials. It is handed them and it has the network. The plugin profile bounds blast radius; it does not contain a malicious plugin.
  • Not a substitute for isolation. For genuinely untrusted work, run UGENT inside a container or VM. This layer confines the processes UGENT spawns; it does not confine UGENT itself.

See Also

Released under the Private Beta License.