Sandboxing
What --safe confines, what it does not, and which guarantees are conditional.
A tool is a program heddle runs on your machine. --safe confines those programs to an OS
sandbox, so a tool that reads more than it should, writes where it should not, or is not
the program you thought it was, is bounded by the kernel rather than by trust.
heddle run flow.json --tools-dir ./tools --safeConfinement lives entirely in the executor: a backend never changes what a tool is, it rewrites how the tool is invoked. Nothing in the graph, node or spec layers knows a sandbox exists.
Backends
| Backend | Platform | Mechanism |
|---|---|---|
bubblewrap | Linux | bwrap, with user, pid, ipc, uts and cgroup namespaces unshared |
seatbelt | macOS | sandbox-exec with a generated SBPL profile |
--sandbox auto is the default and picks the backend native to the platform. Naming one
explicitly fails rather than falling back: --sandbox bubblewrap on macOS is an error,
not a silent unconfined run. On any other platform --safe is refused outright, so there
is no Windows path here.
bubblewrap must be installed; heddle probes for bwrap on PATH and reports how to
install it if missing. seatbelt ships with macOS.
What a confined tool gets
- Read-only system paths.
/usr,/bin,/sbin,/lib,/lib64,/lib32,/etcand/opt, bound read-only. - A fresh
/tmpper invocation, as tmpfs. Nothing survives between tools. - The directory heddle was launched from, and the tool itself, read-only. A tool can be run; it cannot rewrite itself or its siblings, or the flow it came from.
- Its workspace, writable, and as its working directory.
Shared with the other tools in the same node's scope so they can hand each other files.
.heddleinside it is read-only, so a tool cannot rewrite a peer and have heddle run the result. - Nothing from the environment unless named with
--allow-env.
The workspace itself is not a --safe feature: a tool has one on every run, and --safe
is what makes its edges enforced rather than conventional. Under --safe the workspace is
the only place a tool can write; without it, the workspace is where a tool starts and what
its peers read, and nothing stops it going elsewhere.
Everything else is added explicitly:
| Flag | Grants |
|---|---|
--allow-read <path> | Read access to a path (repeatable) |
--allow-write <path> | Write access to a path; read is implied (repeatable) |
--allow-env <name> | Forwards one environment variable (repeatable) |
--deny-net | Removes network access |
Each of these is a hard error without --safe, naming the offending flag. They tune a
sandbox; they do not switch one on.
--mount and --workspace are deliberately not on
that list. They say what is in the run's working directory and where it lives, which is a
different question from what confines it, so they work with or without --safe, and
--safe changes only whether a ro mount is a boundary or a copy. A --workspace
directory adds itself to the write paths, since it is where the run's tools are about to
work.
The network is allowed by default, including under --safe. A tool that fetches a URL
keeps working when you add the flag, which is usually what you want and is worth knowing
if it is not. --deny-net removes it, and on Linux that is a network namespace: all or
nothing, not a per-host filter.
When plugins are loaded, the directory of every plugin-contributed tool that ships an
executable is added to the read paths automatically. An operator auditing their
--allow-read list is therefore looking at a narrower set than what is actually bound.
What a tool can reach
Every tool is in the workspace's bin, which is on $PATH, so a tool can run a peer by
the name the model uses for it. bash can call write_file, and a procedure written as
a sequence of tool calls can be written as a script instead.
A tool on PATH is a tool the model can reach without asking.
Every seam heddle offers over a tool call hangs on one call site: the place an agent
dispatches a call the model made. A tool that another tool exec'd goes around all of it.
The toolCall middleware is not consulted, tool_call and
tool_result are not emitted, and the run's own transcript stops being a complete account
of what ran.
So an approval gate refuses the calls the model makes. It does not refuse the calls a tool makes. On a flow whose agent has a shell, that is the difference between a gate and a note.
This is not a hole in the sandbox. The program that ran is one you installed, inside the same confinement, with the same read and write paths. What changed is who chose to run it and whether anybody was asked.
The rule worth carrying: a toolCall middleware is a control over the model, not over
the machine. A control over the machine is --allow-write, --deny-net and the tools
you install, the things the kernel enforces, which no shell can talk its way past.
bin goes last on $PATH, not first. heddle's tools are named after what they do, so
a flow with a tool called bash, and the shipped examples have one, would otherwise put a
JSON-on-stdin script where every other tool's bash -c expects the shell. Last means a tool
reaches a peer by name and nothing the system already provides is quietly replaced; what it
costs is overriding a system binary, which is not a thing to do by accident.
--no-mount-tools empties bin, so the only way to reach a tool is a call the model made.
It costs a tool the ability to run a peer and buys back the guarantee above; take it if you
are running an approval gate and want it to be one.
A tool a plugin answers over its own channel is not a program, so there is nothing to link.
It gets a one-line shim that says so and exits non-zero, rather than nothing at all:
command not found would tell the model the tool does not exist, and it does. Those tools
stay reachable only through a model tool call, and every one of those still passes the seam.
What it does not confine
Plugins loaded with --plugin as an ES module are not sandboxed. The CLI imports the
module into the heddle process, where it runs with full Node privileges: the environment,
the filesystem, the network, everything the process has. Only the tools it invokes through
ctx.runTool are confined. Load one only if you would run its author's code directly.
An out-of-process plugin is different: it is a subprocess, it gets an empty environment, and it is confined by the same sandbox as a tool when one is configured.
heddle itself is not sandboxed, and neither is the model call. --safe is about the
programs a flow causes to be executed.
Where a submitted spec may send requests
The sandbox is about programs a flow causes to run. There is a second outbound path it
says nothing about: heddle's own requests. A flow's llm_config.url becomes the model
client's base URL, and heddle connects to it.
That is the point of the field when the spec is yours. It is a different thing when the
spec arrived in an HTTP request, because then a stranger is choosing where this process
connects from inside your network. So under --allow-request-code, a submitted spec may
not name a loopback, link-local or private address:
| Refused by default | Why it matters |
|---|---|
169.254.169.254, fe80::/10 | Instance credentials on every major cloud |
127.0.0.1, localhost, ::1 | This server's own unauthenticated /v1/runs |
10/8, 172.16/12, 192.168/16, fc00::/7 | Whatever else is on the operator's network |
The public internet stays reachable. A flow calling a model API is the ordinary case, and
an allowlist of the internet is not a list. --allow-net <host> names a private host that
should be reachable anyway, which is the ordinary reason one is legitimate: a model server
on the operator's own network.
Specs you run yourself are unrestricted. heddle run against http://localhost:11434 is
an Ollama server, and refusing it would break the local case for nothing.
Redirects are refused, not followed. The policy is checked once, against the base URL,
before any connection is made, so on its own it would guarantee only that the first
address was allowed. An allowed host answering 302 Location: http://127.0.0.1/ would move
the request somewhere already refused, and a 302 turns the POST into a GET, which is the
shape a metadata service wants. Under a policy heddle therefore refuses any redirect
outright; no model API needs one to serve a completion.
This reads the address the spec wrote; it does not resolve DNS. A hostname that
resolves into a private range, whether metadata.google.internal or any name an attacker
controls and points at 127.0.0.1, is not caught. Closing that needs the resolved address
checked and then pinned for the connection, or the check is a different question from the
connection. It also says nothing about what a tool or an out-of-process plugin reaches,
since those connect through their own runtimes and heddle never sees them.
What it closes is the direct case. Real containment is a network-level egress rule, and this does not replace one.
On a server
heddle-server fixes confinement at startup: a request can neither ask for a sandbox nor
opt out of one. Under --allow-request-code a submitted plugin runs in its own process
with an empty environment and is killed when the run ends.
The full operator picture, covering what a submitted flow can reach, why $VAR resolution
is refused for caller-supplied specs, and what to restrict at the network layer, is in
DEPLOYMENT.md.
That file is the reference; this page is the mechanism.