A cross-platform Rust library for running LLM-generated code in secure sandboxes with native OS-level isolation.
Docker is a great tool for running containers, with isolation provided by the Linux kernel. However, it has to rely on virtualization to provide isolation on non-Linux platforms, which blocks it from providing GPU and NPU access.
Heel is built on top of native OS-level isolation mechanisms: sandbox-exec on macOS, Landlock and Seccomp on Linux, and AppContainer on Windows. It gives up some isolation strength in exchange for being lightweight and for keeping hardware access available.
Heel is designed for running LLM-generated code in a secure environment. It is not designed to be a general sandbox for arbitrary untrusted code.
| Platform | Backend | Status |
|---|---|---|
| macOS | sandbox-exec with SBPL profiles |
✅ Fully implemented |
| Linux | Landlock (ABI v4) + Seccomp | ✅ Implemented (kernel 6.7+) |
| Windows | AppContainer + job objects | ✅ Implemented (Windows 10+) |
- The working directory is the only writable place. It is created for the sandbox and removed when the sandbox is dropped. Temporary files are directed into it through
TMPDIRon Unix andTEMP/TMPon Windows, where an AppContainer is additionally given a private temporary directory of its own that nothing outside the container can read. - Nothing writable is executable. The working directory, paths granted
Access::WRITEand the shared temp directories are all denied execute, so sandboxed code cannot drop a payload and run it. Each backend spells that differently: SBPL and Landlock withhold execute directly, while on NTFS "run this file" and "enter this directory" are one bit, so directories and files are granted separately. The one exception isAccess::WRITE | Access::EXECon the same path, which — like every explicit grant — beats the default protection: the path may be a file or a directory, and a directory grant covers everything beneath it on all three backends. Asking for both is therefore a deliberate choice to give up the write-then-execute guarantee for that path, and only for it. A build cache such as Cargo'starget/, which writes test binaries under hashed names and runs them immediately, is what this is for. - Network access goes through a policy. Under the default
DenyAllno proxy exists and the kernel refuses outbound connections; any other policy routes every connection through a local proxy that applies the policy per request. - Reads are opt-in above a baseline. Every path outside the working directory is granted explicitly, as a path plus the
Accessthe sandbox has to it, and an explicit grant always beats a default protection. - Resource limits apply to the whole process tree, installed with
setrlimitbetween fork and exec on Unix, and carried by a job object on Windows. - Scheduling priority can only go down.
--nice 1..19lowers the sandbox by that many steps relative to the host, likenice -n, capped at the kernel's lowest priority, and--io-priority utility|backgroundmakes it yield disk bandwidth; both are inherited by everything it spawns. The mechanism issetpriorityplussetiopolicy_npon macOS,setpriorityplusioprio_seton Linux, and the job object's priority class on Windows, where the class is already defined against normal and there is no documented way to lower another process's I/O priority.
The CLI exposes three levels through --isolation:
| Level | Reads | Writes | Exec |
|---|---|---|---|
strict |
System files only; all user data and shared temp denied | Working directory only | Not from writable locations |
default |
System files, unprotected user files, shared temp | Working directory only | Not from writable locations |
permissive |
Everything | Everything | Unrestricted apart from shared temp |
permissive is not available on Windows: an AppContainer cannot make the whole
filesystem writable, so a sandbox with writable_file_system set fails to start
with an UnsupportedSetting error instead of running more restricted than asked.
The shared temp directories are never writable outside permissive: the sandbox
has its own writable directory and exports it as TMPDIR, so nothing needs to
write there. permissive gives up the write-then-execute guarantee by
definition — that is what makes it permissive.
On macOS, strict denies /Users, /home, /Volumes, /Network and the shared temp directories outright. System files stay readable in every level because the macOS runtime layout — dyld caches, cryptexes, sealed system volumes — is not stably enumerable across releases, so an allow list of system paths would break on the next OS update. On Linux, Landlock is default-deny and only the listed paths are granted at all.
- Native OS sandboxing - platform isolation mechanisms rather than a VM
- Network policy enforcement - all traffic routes through a local proxy with configurable filtering
- Type-safe network policies -
Sandbox<N: NetworkPolicy>composes policies at compile time - Fine-grained protections - home directories, credentials, cloud configs, browser data, keychains
- Typed IPC - a narrow, audited escape hatch from the sandbox to the host
- Python virtual environments - created on the host before the sandbox starts
- Async-first, runtime-agnostic - works with any
executor-coreruntime (smol, tokio) - Automatic cleanup - working directories and child processes are released on drop
[dependencies]
heel = "0.1"Install the CLI from the same crate:
cargo install heeluse heel::Sandbox;
#[tokio::main]
async fn main() -> heel::Result<()> {
// Network access is denied by default.
let sandbox = Sandbox::new().await?;
let output = sandbox
.command("/bin/echo")
.arg("Hello from the sandbox")
.output()
.await?;
println!("stdout: {}", String::from_utf8_lossy(&output.stdout));
Ok(())
}use heel::{AllowList, Sandbox, SandboxConfig};
// Wildcards match subdomains: *.github.com covers api.github.com but not github.com.
let config = SandboxConfig::builder()
.network(AllowList::new(["api.example.com", "*.github.com"]))
.build();
let sandbox = Sandbox::with_config(config).await?;Available policies:
DenyAll— deny everything (default). No proxy runs; the kernel does the denying.AllowAll— allow everythingAllowList— allow specific domains, matched case-insensitively, with*.example.comwildcardsCustomPolicy<F>— decide per request with an async handlerAudited<N>— wrap any policy to record every decision as JSON lines, into one file (NetworkAuditLog::file) or a daily-rotated set of them (NetworkAuditLog::rolling_daily)
The proxy refuses to forward to addresses local to the host, so a policy that allows a hostname cannot be used to reach services on the machine running the sandbox.
One list of grants says what the sandbox may do with each path. Access::WRITE
and Access::EXEC include read, since no platform can express a path that is
writable or executable but not readable, and a path granted twice keeps the
union of both grants.
use heel::{Access, ResolvedPath, SandboxConfig};
let config = SandboxConfig::builder()
.readable(ResolvedPath::new("/usr/share")?) // Access::READ
.writable(ResolvedPath::new("/var/spool/work")?) // Access::WRITE, denied exec
.grant(ResolvedPath::new("/opt/cache")?, Access::WRITE | Access::EXEC) // written, then run
.build();A granted path is a ResolvedPath: absolute, already resolved and spelled
without . or .. components, which ResolvedPath::new checks. The sandbox
never follows a symbolic link to honour one: building a sandbox checks every
component of each path again without following links and fails with
Error::GrantRedirected if one has become a link, so a process that can
replace a granted directory with a link cannot move the grant to the link's
target. Paths a user wrote are resolved once, where they enter, with
heel::resolve_paths: it follows the links the user's own spelling crosses
(/tmp, a symlinked home) but refuses a link inside a directory the
configuration makes writable, and a writable path that is itself a link, which a
sandbox of an earlier run may have planted. "Inside" is decided by file
identity, so no spelling of the writable directory hides the link. The heel
CLI and the Node bindings do this for you. A supplied working directory is
resolved the same way.
The working directory is not a grant: it is always writable, never executable, and no configuration changes that.
use heel::{SandboxConfig, SecurityConfig};
let security = SecurityConfig::builder()
.protect_user_home(true) // Deny ~/
.protect_credentials(true) // Deny ~/.ssh, ~/.gnupg
.protect_cloud_config(true) // Deny ~/.aws, ~/.azure, ...
.allow_gpu(false) // Deny GPU access
.build();
let config = SandboxConfig::builder().security(security).build();SecurityOverrides carries a partial set of the same toggles, for layering a config file and command-line flags onto a preset without restating every switch.
Registered commands appear inside the sandbox as ordinary executables on PATH. The command value holds host-side state; each request carries typed arguments.
use heel::ipc::{IpcCommand, IpcContext, IpcError, IpcRouter};
use heel::{Sandbox, SandboxConfig};
use serde::Deserialize;
struct WebSearch {
client: SearchClient,
}
#[derive(Deserialize)]
struct WebSearchArgs {
query: String,
}
impl IpcCommand for WebSearch {
const NAME: &'static str = "web_search";
// Lets sandboxed code write `web_search "rust"` for `--query rust`.
const POSITIONAL_ARGS: &'static [&'static str] = &["query"];
type Args = WebSearchArgs;
type Response = Vec<String>;
// `context.working_dir()` is the caller's directory when it ran the command.
async fn handle(
&self,
args: WebSearchArgs,
context: IpcContext,
) -> Result<Vec<String>, IpcError> {
self.client
.search(&args.query)
.await
.map_err(IpcError::remote)
}
}
let router = IpcRouter::new().register(WebSearch { client });
let config = SandboxConfig::builder().ipc(router).build();
let sandbox = Sandbox::with_config(config).await?;Transport is a Unix domain socket with owner-only permissions, in a private directory outside the working directory so that sandboxed code cannot unlink the endpoint it depends on. IPC opens no network port.
use heel::{PythonConfig, Sandbox, SandboxConfig, VenvConfig, VenvManager};
let venv = VenvConfig::builder()
.path("/tmp/my-venv")
.packages(["requests", "numpy"])
.build();
// Created on the host: installing packages needs the network the sandbox denies.
VenvManager::create(&venv).await?;
let config = SandboxConfig::builder()
.python(PythonConfig::builder().venv(venv).build())
.build();
let sandbox = Sandbox::with_config(config).await?;
let output = sandbox.run_python("import sys; print(sys.version)").await?;The environment is read-and-execute only inside the sandbox unless allow_pip_install(true) is set, so sandboxed code cannot mutate an environment that outlives it.
# Run a command in the sandbox
heel run echo hello
# Interactive shell
heel shell
# Python script with a virtual environment
heel python script.py
# Tighten or loosen isolation
heel run --isolation strict cat /etc/hosts
heel run --network allow-list --allow-domain '*.crates.io' cargo fetch
# Record this run's network decisions as JSON lines. It needs a policy that runs
# the proxy: under the default deny-all the kernel refuses connections and there
# is nothing to record.
heel run --network allow-list --allow-domain example.com \
--audit-log ./run.jsonl curl https://example.com
# A directory that is written and then executed: `rwx`, deliberately
heel run --grant ./target=rwx cargo test
# Protections take an optional value, so a config file can be overridden either way
heel run --protect-credentials=false ssh-add -lConfiguration can also come from a TOML file passed with --config; command-line
arguments take precedence, list settings concatenate, and a path granted by both
keeps the union of its modes.
network = "allow-list"
allow-domains = ["*.crates.io"]
[grants]
"/usr/share" = "r"
"./target" = "rwx"--readable, --writable and --executable are shorthand for --grant PATH=r,
=rw and =rx.
MIT OR Apache-2.0