heddle

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 --safe

Confinement 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

BackendPlatformMechanism
bubblewrapLinuxbwrap, with user, pid, ipc, uts and cgroup namespaces unshared
seatbeltmacOSsandbox-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, /etc and /opt, bound read-only.
  • A fresh /tmp per 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. .heddle inside 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:

FlagGrants
--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-netRemoves 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 defaultWhy it matters
169.254.169.254, fe80::/10Instance credentials on every major cloud
127.0.0.1, localhost, ::1This server's own unauthenticated /v1/runs
10/8, 172.16/12, 192.168/16, fc00::/7Whatever 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.