Process sandboxing
Harn ships a default harn run worktree sandbox plus an OS-level
process sandbox that engages whenever a subprocess is spawned under an
active capability policy. The OS sandbox runs in addition to the
workspace-root path enforcement and the approval-policy DSL — defense
in depth, not a replacement.
Direct harn run invocations install a worktree capability policy
before the VM starts. The policy roots filesystem and process-cwd
access at the nearest harn.toml project root, or at the invocation
working directory when no manifest is present, and sets the
side-effect ceiling below network. Pass --no-sandbox to opt out
for a single run; the CLI prints a warning when that escape hatch is
used.
Sandbox profiles
The active CapabilityPolicy carries a
sandbox_profile field that selects how strictly the runtime confines
spawned processes. Pipelines and the process.exec host call set it
explicitly when they want stronger or weaker isolation than the
default.
| Profile | Path enforcement | OS confinement | When the spawn fails |
|---|---|---|---|
unrestricted | skipped | skipped | only on direct OS errors |
worktree (default) | required (workspace_roots) | best-effort | OS sandbox unavailability is logged once and ignored unless HARN_HANDLER_SANDBOX=enforce |
os_hardened | required | required | spawn returns tool_rejected if the platform mechanism is missing or rejects the call, regardless of HARN_HANDLER_SANDBOX |
wasi | enforced by the WASI runtime | enforced by the WASI runtime | testbench-only; the host spawn path is never reached |
The strictness ladder is unrestricted < worktree < wasi < os_hardened. CapabilityPolicy::intersect always picks the strictest
of the two profiles when a parent ceiling is composed with a child
request — so a lenient parent cannot weaken a child's os_hardened
ask.
HARN_HANDLER_SANDBOX={off,warn,enforce} controls fallback behavior
for the worktree profile. os_hardened ignores the env var on
purpose: a profile that means "the OS sandbox is required" cannot be
silently downgraded by an environment variable.
Process sandbox policy
CapabilityPolicy also carries a process_sandbox section. This policy is
process-only: it widens the generated OS child-process profile without widening
Harn file builtins. A compiler may need to load /Applications/Xcode.app or
update a per-user xcrun cache, but the agent still cannot call read_text on
those paths unless they are also in workspace_roots or read_only_roots.
{
"process_sandbox": {
"presets": [
"system_runtime",
"developer_toolchains",
"package_manager_config",
"user_temp"
],
"read_roots": ["/opt/vendor-sdk"],
"write_roots": ["/opt/vendor-cache"]
}
}
presets: null or an omitted presets field selects the runtime defaults:
system_runtime: host runtime directories needed to launch common binaries.developer_toolchains: standard compiler/toolchain locations such as Xcode, Command Line Tools, Homebrew, plus common home-dir toolchain managers and runtimes such as~/.local/share/uv,~/.rustup,~/.cargo,~/.pyenv,~/.nvm,~/.volta, and~/go.package_manager_config: read-only per-user npm, pip, cargo, git, and CA config/cache roots under$HOME, such as.npmrc,.gitconfig,.netrc,.config,.cache, and cargo config/registry paths.user_temp: scratch/cache roots used by developer tools. These roots are writable only when the active capability policy already allows workspace writes.
An explicit empty presets: [] disables every named preset. read_roots and
write_roots are for subprocesses only; write_roots are also gated by the
workspace-write capability. CapabilityPolicy::intersect narrows presets and
roots to their common set, so managed or parent ceilings can prevent a child
policy from adding host filesystem reach.
Running real toolchains in the sandbox
The local process sandbox is meant to run normal developer tools, including toolchains that depend on enterprise package-manager state. It constrains what the child process can open; it does not rewrite npm, pip, cargo, git, proxy, or CA configuration.
With the default developer_toolchains and package_manager_config presets,
the Unix desktop backends grant child processes read-only access to common
home-scoped toolchain and package-manager roots under the first absolute
$HOME. That includes user-managed runtimes such as .local/share/uv,
.cargo, .rustup, .pyenv, .nvm, .volta, and go, plus
package-manager config/cache paths such as .npmrc, .gitconfig, .netrc,
.yarnrc.yml, .config, .npm, .cache, .pip, .pypirc,
.cargo/config, .cargo/config.toml, .cargo/credentials,
.cargo/credentials.toml, .cargo/registry, and .cargo/git. These grants
are process-only: Harn file builtins still need workspace_roots or
read_only_roots, and the extra home-dir paths stay unwritable by the OS
profile.
Windows AppContainer confinement is more conservative for omitted presets:
granting a home-scoped root requires mutating filesystem ACLs recursively, so
the Windows backend does not materialize implicit default
developer_toolchains or package_manager_config roots on every spawn. A
policy that explicitly sets process_sandbox.presets still asks Windows to
grant those preset roots, and process_sandbox.read_roots / .write_roots
remain the preferred way to add the specific SDK, cache, or config directory a
subprocess needs.
Child processes spawned without env overrides inherit the parent environment.
That includes corporate proxy and CA variables such as HTTP_PROXY,
HTTPS_PROXY, ALL_PROXY, NO_PROXY, NODE_EXTRA_CA_CERTS,
SSL_CERT_FILE, SSL_CERT_DIR, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE,
GIT_SSL_CAINFO, and CARGO_HTTP_CAINFO. The sandbox does not special-case
those variables; the paths they reference must already be readable through the
workspace, the package-manager preset, a system read root, or an explicit
process_sandbox.read_roots entry. Calls that pass an env map can use
env_mode to replace or patch the inherited environment, and can remove
individual variables with env_remove.
For private registries, vendored SDKs, self-signed CA bundles outside the default roots, or offline caches, add process-only roots at the policy layer:
let policy = {
capabilities: {workspace: ["read_text"], process: ["exec"]},
workspace_roots: [project_root()],
process_sandbox: {
read_roots: [
"/opt/acme/npm-cache",
"/opt/acme/pip-wheelhouse",
"/opt/acme/certs",
],
},
}
process_sandbox.read_roots lets subprocesses read those paths without
granting Harn file builtins access to them. In air-gapped environments, seed
the registry config and cache before the run, point the inherited package
manager/proxy/CA variables at readable files, and keep network side effects
disabled; if a tool must reach a corporate proxy, the active policy still needs
to allow network side effects.
Writable vs. read-only roots
A policy declares two root lists. workspace_roots are read-write: a
path resolving under one passes every scope check, and the OS profile
grants it write when the workspace.write_text/workspace.delete
capability is present. read_only_roots are additive read-only scope:
a path under one passes read_text/list/exists checks but
write_text/delete are rejected with a tool_rejected "read-only
workspace root" violation, and the generated OS profile grants the root
read but never write — even when the policy otherwise allows workspace
writes. The two lists are intended to be disjoint. macOS additionally
re-denies write to each read-only root after the broad workspace
write allow (sandbox-exec is last-match-wins), so a read-only root
nested under a writable root stays hermetically unwritable; Linux
Landlock rules are purely additive (no deny), so a nested read-only
root under a writable parent inherits the parent's write grant — keep
the lists disjoint when targeting the Linux backend. This lets a caller
mount a reference tree (a shared memory dir, a persona bundle) that the
workload can read but cannot mutate. CapabilityPolicy::intersect
narrows each list to the roots common to both sides, with an empty list
on either side deferring to the other.
Selecting a profile
From a pipeline
A workflow author sets the profile on the CapabilityPolicy that
gates the agent loop or workflow (typically through the agent-session
or workflow-runtime constructors that accept a policy). The default
is Worktree, so pipelines that want the strongest confinement
include sandbox_profile: "os_hardened" in the policy literal:
let policy = {
capabilities: {workspace: ["read_text"], process: ["exec"]},
workspace_roots: [project_root()],
sandbox_profile: "os_hardened",
}
From a single host call
A single subprocess can be promoted (or demoted) without rewriting the
surrounding policy by passing sandbox_profile on the
process.exec host call. The override is scoped to that call:
host_call("process.exec", {
mode: "argv",
argv: ["./untrusted-tool", "input.json"],
cwd: project_root(),
sandbox_profile: "os_hardened",
})
From an embedder
Embedders that drive the runtime through harn-vm directly construct
a CapabilityPolicy with the desired profile:
let policy = CapabilityPolicy {
workspace_roots: vec![workspace.display().to_string()],
sandbox_profile: SandboxProfile::OsHardened,
..Default::default()
};
push_execution_policy(policy);
Capability → kernel-knob mapping
The runtime translates the active capability ceiling into per-platform knobs. The mapping is intentionally narrow — each capability maps to a small, named kernel feature, never an open-ended escape hatch.
Linux (crates/harn-vm/src/stdlib/sandbox/linux.rs)
| Capability / policy | Kernel knob | Effect |
|---|---|---|
workspace.read_text / workspace.list / workspace.exists | Landlock LSM LANDLOCK_ACCESS_FS_READ_FILE + _READ_DIR + _EXECUTE | reads under workspace_roots and the system_read_roots() allowlist (/bin, /lib, /lib64, /usr, /etc, /nix/store, /System) |
workspace.write_text | Landlock _WRITE_FILE + _REMOVE_* + _MAKE_* + (ABI ≥ 2) _REFER + (ABI ≥ 3) _TRUNCATE | writes scoped to workspace_roots |
workspace.delete | Landlock _REMOVE_DIR + _REMOVE_FILE | removes scoped to workspace_roots |
read_only_roots: [...] | Landlock _READ_FILE + _READ_DIR + _EXECUTE only | each read-only root is readable but never writable, regardless of the workspace.* capabilities |
process_sandbox.presets includes package_manager_config | Landlock read-only rules for existing npm, pip, cargo, git, and CA config/cache roots under $HOME | package managers can resolve real per-user config without granting Harn file builtin access or write rights |
process_sandbox.read_roots / .write_roots | Landlock read-only rules, plus writable rules only when workspace writes are allowed | process-only roots for SDKs/caches without widening Harn file builtins |
| standard process devices | Landlock grants read/write on /dev/null and read-only access on /dev/zero, /dev/random, and /dev/urandom; ABI ≥ 5 also handles _IOCTL_DEV but does not grant it to these device rules | language runtimes and test harnesses can open the devices they normally need without broad /dev access or device ioctl rights |
side_effect_level < network | seccomp-bpf blocklist on socket, socketpair, connect, accept, accept4, bind, listen, sendto, sendmsg, recvfrom, recvmsg (return EPERM) | network syscalls fail without taking down the process |
| always | seccomp-bpf blocklist on bpf, mount, umount2, init_module, delete_module, finit_module, kexec_*, ptrace, process_vm_readv/process_vm_writev, perf_event_open, swapon/swapoff, reboot, userfaultfd, fanotify_init, open_by_handle_at (return EPERM) | tier-1 dangerous syscalls are denied unconditionally |
| always | prctl(PR_SET_NO_NEW_PRIVS, 1) | no setuid escalation across exec |
The Landlock ruleset is built lazily from landlock_abi_version():
unknown access bits are masked off so a recent userspace stays
forward-compatible with older kernels. ABI 0 (no Landlock at all)
falls back to the warn/enforce decision documented above.
macOS (crates/harn-vm/src/stdlib/sandbox/macos.rs)
| Capability / policy | sandbox-exec rule | Effect |
|---|---|---|
| always | (deny default) | every operation requires an explicit allow |
| always | (allow process*) + (allow sysctl-read) + (allow mach-lookup) + (allow file-read-data (literal "/")) | minimum surface required to exec a binary |
| standard process devices | (allow file-read* ...) for /dev/null, /dev/zero, /dev/random, /dev/urandom, /dev/stdin, /dev/stdout, /dev/stderr, and /dev/fd; (allow file-write* ...) only for /dev/null, /dev/stdout, /dev/stderr, and /dev/fd | common stdio, entropy, and zero devices work without granting broad /dev writes |
process_sandbox.presets | named read/write rules for system_runtime, developer_toolchains, package_manager_config, and user_temp | default process reach for system binaries, Xcode/Homebrew/toolchains, read-only package-manager home config, and per-user developer-tool caches without granting Harn file builtin access |
workspace_roots: [...] / read_only_roots: [...] | (allow file-read* (subpath "<root>")) | workspace and read-only roots are readable |
workspace.write_text / workspace.delete (or empty capabilities) | writable user_temp, process_sandbox.write_roots, and workspace_roots, followed by (deny file-write* (subpath "<read_only_root>")) | scratch dirs, explicit process-write roots, and writable workspace_roots are writable; each read_only_roots entry is then re-denied write. sandbox-exec is last-match-wins, so the trailing deny keeps a read-only root nested under a writable root unwritable even though the two lists are nominally disjoint |
side_effect_level >= network | (allow network*) | otherwise outbound network is denied |
SwiftPM commands (swift build, swift test, swift run, and
swift package) run with Harn's outer sandbox as the enforcement layer.
Harn passes --disable-sandbox to avoid SwiftPM's nested
sandbox-exec call, which macOS rejects from inside an existing sandbox,
and points SwiftPM cache/config/security paths at .build/harn/swiftpm/
inside the workspace rather than granting default access to user-level
SwiftPM state.
sandbox-exec is officially deprecated but remains the platform
mechanism Apple ships for non-App-Store binaries. We track that
status in the file-level docstring and will switch to a supported
successor when one exists.
Windows (crates/harn-vm/src/stdlib/sandbox/windows.rs)
| Capability / policy | Win32 mechanism | Effect |
|---|---|---|
| always | CreateAppContainerProfile + STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES | the process runs inside a per-spawn AppContainer with no capability SIDs |
| always | GetAppContainerFolderPath plus child LOCALAPPDATA / TEMP / TMP overrides | child processes use AppContainer-owned profile and scratch directories instead of inheriting host-user temp paths that the AppContainer cannot access |
workspace.write_text / workspace.delete | icacls /grant *<sid>:(OI)(CI)M /T /C on each workspace_roots entry | the AppContainer SID gets Modify access on the roots; revoked on Drop |
read-only (denied workspace write, or any read_only_roots entry) | icacls /grant *<sid>:(OI)(CI)RX /T /C | the AppContainer SID gets ReadAndExecute; read_only_roots always use this grant even when workspace writes are allowed |
explicit process_sandbox.presets includes developer_toolchains / package_manager_config | icacls /grant *<sid>:(OI)(CI)RX /T /C on existing home-scoped preset roots | explicit preset requests get read-only access; omitted presets are not materialized as recursive ACL grants on Windows |
process_sandbox.read_roots / .write_roots | icacls /grant *<sid>:(OI)(CI)RX or Modify | process-only roots, with writes gated by workspace-write capability |
| always | CreateJobObjectW with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, _DIE_ON_UNHANDLED_EXCEPTION, _ACTIVE_PROCESS (cap 32), _PROCESS_MEMORY (cap 512 MiB) | resource caps and lifecycle binding |
| always | direct CreateProcessW with CREATE_NO_WINDOW, explicit handle list, STARTF_USESTDHANDLES, and Job Object UI restrictions | stdin/stdout/stderr inheritance is restricted to the three pipes the runtime created, console commands do not bind to an interactive desktop, and child UI escape surfaces stay disabled |
std::process::Command cannot carry an AppContainer
SECURITY_CAPABILITIES block, so Windows callers must use
process_sandbox::command_output(...) (which goes through
SandboxBackend::run_to_output). The std_command_for /
tokio_command_for helpers warn-or-error per the active fallback
policy.
OpenBSD (crates/harn-vm/src/stdlib/sandbox/openbsd.rs)
| Capability / policy | OpenBSD mechanism | Effect |
|---|---|---|
| always | unveil("/bin", "rx"), ("/usr", "rx"), ("/lib", "rx"), ("/etc", "r"), ("/dev", "rw") | minimum surface required to exec |
workspace_roots: [...] | unveil("<root>", "rwcx" | "rx") | rwcx when workspace.write_text / workspace.delete present, otherwise rx |
read_only_roots: [...] | unveil("<root>", "rx") | each read-only root is read+execute only, never write/create |
process_sandbox.read_roots / .write_roots | unveil("<root>", "rx" | "rwcx") | process-only roots, with writes gated by workspace-write capability |
| always | pledge("stdio rpath proc exec", NULL) | minimum process-exec promise set |
workspace.write_text / workspace.delete | adds wpath cpath dpath to pledge | filesystem mutation promises |
side_effect_level >= network | adds inet dns to pledge | network promises |
How spawns route
caller
│
▼
process_sandbox::{command_output, std_command_for, tokio_command_for}
│
├── if no orchestration policy is active → direct spawn
├── if profile == Unrestricted or Wasi → direct spawn
├── if HARN_HANDLER_SANDBOX=off (Worktree only) → direct spawn
└── otherwise → ActiveBackend
│
├── linux::Backend (pre_exec → seccomp + Landlock)
├── macos::Backend (wrap with sandbox-exec)
├── openbsd::Backend (pre_exec → unveil + pledge)
└── windows::Backend (CreateProcessW with AppContainer + Job Object)
The backend trait is defined in
crates/harn-vm/src/stdlib/sandbox/mod.rs; one impl is selected at
compile time via cfg-gated mod declarations. Adding a new
backend means writing one file under sandbox/ and adding the
mod plus type ActiveBackend lines.
Sandboxes are the runtime arm of a permission policy
A sandbox is the runtime answer to a declared permission policy. The
authoritative policy model (policy { read, write, exec, net }) lives
in harn-serve's permissions module; permissions::enforcement
lowers it into the two enforcement vocabularies described above:
to_capability_policyderives the in-VMCapabilityPolicyceiling —read/write/execbecomeworkspace/processcapabilities and theside_effect_level, threaded with a chosenSandboxProfile.to_sandbox_spec/to_network_policy(behind thehostlibfeature) turn thenetallowlist into aSandboxSpecegress policy for aSandboxBackendto provision against.
The pluggable backend contract — SandboxBackend, SandboxSpec,
ExecRequest/ExecResult, NetworkPolicy, mounts, and limits — lives
in harn-hostlib's sandbox module, alongside the LocalSandbox
backend. LocalSandbox does not reimplement OS confinement: it pushes
a CapabilityPolicy and runs each command through the same harn-vm
process sandbox documented here. Remote backends (Fly Machines, Modal,
E2B, …) implement the same trait from wherever they run.
Diagnostics from a script
Three Harn builtins surface backend identity for harn doctor-style
scripts and conformance fixtures:
| Builtin | Returns | Use |
|---|---|---|
sandbox_active_backend() | string | name of the compiled-in backend (linux, macos, windows, openbsd, noop) |
sandbox_backend_available() | bool | whether the platform mechanism behind the backend is reachable on the running host |
sandbox_active_profile() | string | profile carried by the current execution policy (worktree under default harn run, unrestricted if no policy is active) |
Replay fidelity
CapabilityPolicy round-trips through serde with sandbox_profile
included, so every RunRecord records which profile was active when
the run was made. harn replay evaluates the recorded fixture
without re-executing subprocess spawns — the process tape supplies
the captured Output — so the replay host does not need the same
sandbox mechanism the original run used. Re-execution flows
(harn test bench) push the recorded CapabilityPolicy back onto
the execution stack, so they re-apply the same profile and fail
loudly under os_hardened if the replay host lacks the platform
mechanism.
Out of scope
- gVisor / Firecracker / Kata containers — those belong to a remote
SandboxBackendimpl (Fly Machines, Modal, …), not the local runtime; the trait lives inharn-hostlib'ssandboxmodule. - Destination-level network egress allow/deny — use
egress_policy(...)orHARN_EGRESS_*once a host policy allows network side effects. - Sandboxing for in-process work (LLM calls, deterministic Harn evaluation). Capability ceilings and the approval policy are the enforcement layers there; the OS sandbox only kicks in when Harn spawns a subprocess.