Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Process sandboxing

What this page covers: which directories a script can read and write, and what a command the script spawns is allowed to do. Three related boundaries are documented elsewhere, and mixing them up is the usual source of confusion:

If you are asking…Go to
Which environment variables and secrets can the script see?Environment policies and grants
Which hosts may the Harn runtime itself call over the network?Egress policy (HARN_EGRESS_ALLOW, harness.net.egress_policy(...))
What is a capability policy, and who sets one?Host boundary

harn run confines a script to its own project directory before the VM starts. You do not turn the sandbox on. It is already on, and this page is mostly about how to give a script the one extra path or socket it needs without switching it off.

Three terms show up throughout, so it helps to pin them down first:

  • A capability policy is the record of what one run is allowed to do: which directories are readable, which are writable, and how far its side effects may go. harn run builds one for you; see Host boundary for the full reference.
  • A root is one directory that a policy opens up, along with everything beneath it.
  • The side-effect ceiling is the highest class of effect a run may reach, ordered from harmless to far-reaching. The default stops just below network, so a script can touch files in its project but cannot open a socket.

Confinement then comes in two layers. Harn checks every path itself, against the roots in the active policy. Separately, when a script spawns a subprocess, the operating system confines that child using whatever mechanism the platform provides: Landlock on Linux, sandbox-exec on macOS, AppContainer on Windows.

Both layers stay in force alongside approval policy, the separate rule set that decides which risky operations have to ask a human first. None of the three replaces the others, so a path can be refused by any one of them.

On Unix, scoped content writes walk parent directories without following symlinks and carry the resulting parent file descriptor into the final file open. This defends against user-creatable symlink redirection inside a workspace; it does not defend against bind mounts installed by a privileged host administrator. The workspace sandbox therefore treats host root access as outside its threat model.

What a default run can do

A direct harn run installs the worktree capability policy before the VM starts. That policy:

  • roots filesystem and process-cwd access at the nearest harn.toml project root, falling back to the working directory you launched from when there is no manifest;
  • holds the side-effect ceiling below network.

So a script can read and write inside its own project and nowhere else, and neither it nor anything it spawns can open a socket.

Pass --allow-process-network to allow network access for the Harn run and its child processes. Filesystem and process confinement remain active.

harness.net.egress_policy(...) does not grant network access. It restricts the destinations available to a run that already has network access, so a script can install its policy before the network grant. See the egress-policy reference for the policy fields.

A matching harness.testing.http_mock is an in-process fixture, so it does not require a network grant. An unmatched HTTP call still requires network access and passes through the active egress policy.

Widening the sandbox for one path

Scripts often need a single path outside the project. Writing a status file to a host-level state directory, say, or reading a sibling checkout. Grant that one path rather than reaching for --no-sandbox:

FlagEffect
--write-root <path>Adds a writable root for Harn and its subprocesses.
--read-only-root <path>Adds a readable root for Harn and its subprocesses.
--sandbox-write-root <path>Adds a writable root for subprocesses only.
--sandbox-read-root <path>Adds a readable root for subprocesses only.

The first two widen the active profile and leave the rest of the sandbox intact. The last two populate process_sandbox.write_roots and .read_roots, so a compiler or package manager gets the path while Harn's own filesystem builtins still cannot touch it. Reach for those when only the child needs access.

All four are rejected alongside --no-sandbox, which would make them meaningless.

A run that grants a root prints one line to stderr naming exactly what it widened:

sandbox active; extra write root: /path

That line replaces the blanket --no-sandbox warning. A run that grants nothing prints nothing.

Opting out for a single run

--no-sandbox removes the confinement entirely for one invocation, and the CLI warns when you use it. Prefer a scoped root whenever you can name the path you actually need.

Network grants are coarser than they look

--allow-process-network grants socket access to the Harn runtime and its child processes. HARN_EGRESS_ALLOW and harness.net.egress_policy(...) restrict calls made by Harn, including HTTP, provider, and connector calls. The child-process sandbox can allow or deny sockets, but it cannot enforce hostname rules for an arbitrary child process.

Once you pass --allow-process-network, that child can talk to anything. Use it only for a command whose network behavior you already trust and have scoped some other way.

Environment variables are a separate boundary

Sandbox roots control where a script reaches. What it can read out of the environment is governed independently, by the session's environment policy: inherited snapshots the launcher environment, isolated admits runtime essentials only, and granted adds the --grant mappings you declare. The resolved values govern harness.env, providers, and subprocesses alike.

The two boundaries never widen each other, and environment policy stays active under --no-sandbox. See Environment policies and grants.

Sandbox profiles

The active CapabilityPolicy carries a sandbox_profile field. Pipelines and the process.exec host call set it explicitly when they want stronger or weaker isolation than the default.

A profile decides two independent questions:

  • Path enforcement — do Harn's own in-process checks apply? This covers the harness.fs.* builtins scoping against workspace_roots and read_only_roots, plus the launch-cwd check for subprocesses. It is portable and deterministic, because Harn performs it itself.
  • OS confinement — is a platform mechanism (Linux Landlock+seccomp, macOS sandbox-exec, Windows AppContainer) applied to spawned subprocesses? This depends on a mechanism that may be unavailable, and it is the only axis that can deny a child something Harn never asked about.
ProfilePath enforcementOS confinementWhen the spawn fails
unrestrictedskippedskippedonly on direct OS errors
workspace_pathsrequired (workspace_roots)skippedonly on direct OS errors
worktree (default)required (workspace_roots)best-effortOS sandbox unavailability is logged once and ignored unless HARN_HANDLER_SANDBOX=enforce
wasirequirednot applied on the host pathtestbench-only; the host spawn path is never reached
os_hardenedrequiredrequiredspawn returns tool_rejected if the platform mechanism is missing or rejects the call, regardless of HARN_HANDLER_SANDBOX

workspace_paths is for callers that own the code they are running and want its writes confined, but must let it shell out freely — a test runner isolating cases from each other, a build driver invoking a toolchain. OS confinement would buy nothing there, since the subprocess is as trusted as its parent, while costing portability. Do not use it for foreign or untrusted code: a subprocess it spawns is unconfined, so path scoping alone is not containment.

Top-level agent_loop sessions install an empty-ceiling os_hardened carrier by default, so agent subprocess tools require the OS sandbox even when the caller did not pass an explicit capability policy. Direct scripts and process calls keep the worktree default unless their caller selects a stricter profile.

The strictness ladder is unrestricted < workspace_paths < 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, and a child asking for workspace_paths cannot shed an OS sandbox its parent imposed.

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, Linux vendor installs under /opt, 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 need mutable build and package-manager state. Restricted worktree profiles create .harn-toolchain-cache/ under the writable workspace and point HOME, XDG/cache variables, Go caches, pip/uv caches, npm/Yarn/pnpm caches, and Python's user base into it. Explicit per-command environment values win. The directory self-ignores its contents and can be deleted safely. CARGO_HOME, RUSTUP_HOME, and existing package-manager config files keep pointing at their narrowly scoped toolchain/config preset roots so installed toolchains and private-registry configuration remain usable.

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.

Other admitted child variables still come from 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:

const policy = {
  capabilities: {workspace: ["read_text"], process: ["exec"]},
  workspace_roots: [harness.fs.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.

The harn run boundary also grants subprocesses read/execute access to the exact Harn runtime executable for that invocation. This file-scoped grant lets scripts delegate back to the verified runtime through wrappers such as sh or /usr/bin/time, including when the binary came from a temporary CI artifact. It does not expose the runtime's containing directory and does not grant Harn filesystem builtins any additional authority.

Direct CLI runs expose the same process-only policy without requiring a manifest edit:

harn run \
  --sandbox-read-root /opt/acme/sdk \
  --sandbox-write-root /opt/acme/cache \
  main.harn

Both flags are repeatable and incompatible with --no-sandbox.

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.

Roots that sit outside the working tree

Two kinds of directory are writable without appearing in the policy, because a workload cannot function without them and neither is under the workload's control:

  • Git's real dir and shared common dir for a linked worktree. Git keeps both outside the working tree, and every git subprocess fails without read-write scope on them.
  • Harn's own runtime directories, once relocated. HARN_STATE_DIR, HARN_RUN_DIR, and HARN_WORKTREE_DIR let an operator move Harn's state, run records, and managed worktrees anywhere. The runtime writes there from Rust either way; scoping harness.fs.* out of them would leave the state root readable and unwritable from Harn code, which is what pushes stdlib features into rebuilding a cwd-relative .harn and silently ignoring the override. Scripts should ask harness.fs.runtime_paths() for these paths rather than joining .harn onto a directory themselves.

Only directories genuinely outside the declared roots are added, and only once they exist — in the default layout Harn's directories already sit inside the workspace, so nothing widens. Runtime directories become available to Harn code after the runtime materializes them. The overrides are read from the process environment, which a sandboxed script cannot write, so the grant follows the same principal that configured the sandbox. Granting a relocated state root does not grant its parent or its siblings.

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:

const policy = {
  capabilities: {workspace: ["read_text"], process: ["exec"]},
  workspace_roots: [harness.fs.project_root()],
  sandbox_profile: "os_hardened",
}

From one process capability call

A single subprocess can be promoted (or demoted) without rewriting the surrounding policy by passing sandbox_profile on the process request. The override is scoped to that call:

harness.process.run({
  program: "./untrusted-tool",
  args: ["input.json"],
  cwd: harness.fs.project_root(),
  sandbox_profile: "os_hardened",
})

std/command.command_run accepts the same per-call override in either the command spec or options. Use workspace_paths for a trusted compiler, package manager, or nested Harn invocation that must retain Harn's workspace-root path checks without recursively installing an OS process sandbox:

import { command_run } from "std/command"

const checked = command_run(
  harness.tools,
  [harn_bin, "check", script],
  {
    cwd: harness.fs.project_root(),
    sandbox_profile: "workspace_paths",
  },
)

workspace_paths is not containment for untrusted child code. Use worktree or os_hardened when the child itself needs OS confinement. The override changes only the profile for that spawn; surrounding workspace roots and capability ceilings remain in force, and the parent profile is restored when the command returns.

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);

Workspace-local temp dir (TMPDIR/TMP/TEMP)

Every sandboxed child spawned under a restricted profile gets TMPDIR, TMP, and TEMP pointed at a .harn-tmp/ directory inside the first writable workspace_root (created lazily, with a self-.gitignore so its churn never leaks into a diff, PR, or eval grading). Compiler linkers (rustc/cc/ld, Go, Swift, …) and many other toolchains write intermediate object/temp files to $TMPDIR, defaulting to the system /tmp when it is unset — which is outside the writable workspace roots, so those writes are denied and a build that would otherwise succeed FALSE-FAILS for an infrastructure reason (could not write output to /tmp/rustcXXXX/…: Permission denied). Anchoring temp at an already-writable workspace location fixes this for any TMPDIR-honoring toolchain without widening the sandbox. A TMPDIR/TMP/TEMP the caller sets explicitly (via the process call's env) is respected; only the otherwise-inherited, non-writable value is overridden.

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 / policyKernel knobEffect
workspace.read_text / workspace.list / workspace.existsLandlock LSM LANDLOCK_ACCESS_FS_READ_FILE + _READ_DIR + _EXECUTEreads under workspace_roots and the system_read_roots() allowlist (/bin, /lib, /lib64, /usr, /etc, /nix/store, /System)
workspace.write_textLandlock _WRITE_FILE + _REMOVE_* + _MAKE_* + (ABI ≥ 2) _REFER + (ABI ≥ 3) _TRUNCATEwrites scoped to workspace_roots
workspace.deleteLandlock _REMOVE_DIR + _REMOVE_FILEremoves scoped to workspace_roots
read_only_roots: [...]Landlock _READ_FILE + _READ_DIR + _EXECUTE onlyeach read-only root is readable but never writable, regardless of the workspace.* capabilities
process_sandbox.presets includes package_manager_configLandlock read-only rules for existing npm, pip, cargo, git, and CA config/cache roots under $HOMEpackage managers can resolve real per-user config without granting Harn file builtin access or write rights
process_sandbox.read_roots / .write_rootsLandlock read-only rules, plus writable rules only when workspace writes are allowedprocess-only roots for SDKs/caches without widening Harn file builtins
standard process devicesLandlock 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 ruleslanguage runtimes and test harnesses can open the devices they normally need without broad /dev access or device ioctl rights
side_effect_level < networkseccomp-bpf allowlist excludes addressable socket openers: socket, connect, accept, accept4, bind, listen. socketpair, sendto, sendmsg, recvfrom, and recvmsg stay allowlisted for inherited anonymous local IPCaddressable-socket / egress syscalls fail with EPERM, while local IPC keeps working
alwaysseccomp-bpf default-deny allowlist omits tier-1 dangerous syscalls including bpf, mount/module/kexec/sysctl families, ptrace, process_vm_readv/process_vm_writev, io_uring_*, perf_event_open, userfaultfd, fanotify_init, and open_by_handle_atunknown and dangerous syscalls fail with EPERM
alwaysprctl(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 / policysandbox-exec ruleEffect
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/fdcommon stdio, entropy, and zero devices work without granting broad /dev writes
process_sandbox.presetsnamed read/write rules for system_runtime, developer_toolchains, package_manager_config, and user_tempdefault 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 / policyWin32 mechanismEffect
alwaysCreateAppContainerProfile + STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIESthe process runs inside a per-spawn AppContainer with no capability SIDs
alwaysGetAppContainerFolderPath plus child LOCALAPPDATA / TEMP / TMP overrideschild processes use AppContainer-owned profile and scratch directories instead of inheriting host-user temp paths that the AppContainer cannot access
workspace.write_text / workspace.deleteicacls /grant *<sid>:(OI)(CI)M /T /C on each workspace_roots entrythe 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 /Cthe 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_configicacls /grant *<sid>:(OI)(CI)RX /T /C on existing home-scoped preset rootsexplicit preset requests get read-only access; omitted presets are not materialized as recursive ACL grants on Windows
process_sandbox.read_roots / .write_rootsicacls /grant *<sid>:(OI)(CI)RX or Modifyprocess-only roots, with writes gated by workspace-write capability
alwaysCreateJobObjectW 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
side_effect_level >= networkAppContainer internetClient and privateNetworkClientServer capability SIDspublic-network client access plus private-network client and server (inbound) access; otherwise the AppContainer receives no network capability
alwaysdirect CreateProcessW with CREATE_NO_WINDOW, explicit handle list, STARTF_USESTDHANDLES, and Job Object UI restrictionsstdin/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 / policyOpenBSD mechanismEffect
alwaysunveil("/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_rootsunveil("<root>", "rx" | "rwcx")process-only roots, with writes gated by workspace-write capability
alwayspledge("stdio rpath proc exec", NULL)minimum process-exec promise set
workspace.write_text / workspace.deleteadds wpath cpath dpath to pledgefilesystem mutation promises
side_effect_level >= networkadds inet dns to pledgenetwork 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_policy derives the in-VM CapabilityPolicy ceiling — read/write/exec become workspace/process capabilities and the side_effect_level, threaded with a chosen SandboxProfile.
  • to_sandbox_spec / to_network_policy (behind the hostlib feature) turn the net allowlist into a SandboxSpec egress policy for a SandboxBackend to 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:

BuiltinReturnsUse
harness.system.sandbox_active_backend()stringname of the compiled-in backend (linux, macos, windows, openbsd, noop)
harness.system.sandbox_backend_available()boolwhether the platform mechanism behind the backend is reachable on the running host
harness.system.sandbox_active_profile()stringprofile 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 SandboxBackend impl (Fly Machines, Modal, …), not the local runtime; the trait lives in harn-hostlib's sandbox module.
  • Destination-level network egress allow/deny — use harness.net.egress_policy(...) or HARN_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.