Skip to content

Per-run containment

The sandbox and permission modes set the outer boundary for every agent. Per-run containment narrows that boundary for a single launch: you declare exactly which tools and which hosts one run may use, and hearth plants decoys that catch a run reaching for credentials it was never asked to touch.

Everything here is declared at launch (in the cockpit or over the API) and travels with the run through the same channel as agent credentials.

Capability manifests (tool allowlists)

A launch can declare a set of tools, and the run may use ONLY those tools, in every permission mode including bypass. An unlisted tool is a hard deny: it is filtered out of the tool list the model sees, and it is refused even if the model tries to call it by emitting a raw tool-call as text.

In the cockpit launch panel, fill the “allowed tools” field (read_file,web_fetch). Over the API, pass tools on /run or /session, or run hearth-loop --allowed-tools read_file,web_fetch (or set HEARTH_ALLOWED_TOOLS).

A mission manager propagates its manifest to the specialists it spawns, so a scoped mission stays scoped all the way down.

Egress allowlists

A launch can declare a set of allowed hosts, and the run may reach ONLY those hosts. An entry matches itself and its subdomains (github.com allows api.github.com), and loopback is always allowed so local APIs and Ollama keep working. In the cockpit, use the “allowed hosts” field; over the API pass allowed_hosts, or set HEARTH_ALLOWED_HOSTS.

Enforcement happens at two layers.

The tool layer (since v1.1)

The web tools (web_fetch, web_search, http_request, fetch_to_kb) check every request against the allowlist. A blocked request returns an error the model can learn from instead of a network failure. Every outbound attempt, allowed or blocked, is recorded to the egress_log audit table and readable at GET /egress. This layer stops accidents and naive prompt injection, and it makes all egress visible.

The OS layer (since v1.4)

With the tool layer alone, a run that shells out to curl never touches a hearth tool and slips past the allowlist. The OS layer closes that gap: when a spawned run declares allowed_hosts, hearth also programs per-run nftables rules before the run starts. A dedicated table inet hearth holds one chain per run, and packets are matched by the run’s systemd cgroup (system.slice/hearth-agent@<id>.service), so the rules bind only that run’s processes, shell children included. The chain allows loopback, DNS, and the declared hosts’ resolved addresses; everything else is dropped at the kernel with a log record. The rules are removed when the unit stops.

Kernel drops are picked up from the journal by the hearth-egress-watch bridge and written to the same egress_log table (tool os, allowed 0), so GET /egress shows tool-layer and OS-layer events side by side.

The OS layer is fail-open by design: if programming the rules fails (nftables unavailable, a transient error), the run still launches and the tool layer still enforces. A firewall hiccup should never brick launches; the wall is an extra layer, not a single point of failure.

Turn it on with the NixOS module:

hearth.egress.enable = true;

It is off by default. See Networking for how the table coexists with the regular firewall and how to inspect live rules.

Honest scope

OS enforcement covers spawned runs: background runs, missions, swarm children, and the queue path, each of which runs in its own hearth-agent@<id>.service unit. Interactive cockpit sessions run inside the mapd service cgroup, so they keep tool-layer enforcement only. And an empty allowed_hosts still means allow-all; the wall only goes up for a run that declares hosts.

Honeyfile tripwires

Every agent workspace is seeded with a few convincing fake secrets files (.aws/credentials, .env.production, secrets/api_keys.txt). Each embeds a unique canary token. A well-behaved agent never reads unrequested credentials; a prompt-injected or confused one that does trips the alarm.

Detection has two layers:

  • A direct read of a decoy by path, caught before the contents ever reach the model.
  • A canary token surfacing in any tool’s output, which catches a shell cat, grep, or similar through run_command.

On a trip, the run is flagged and by default killed, a row is written to the tripwires audit table, the agent enters the TRIPPED state (the world map flashes red with an alarm), and a Telegram DM is sent if configured. Set HEARTH_TRIPWIRE=flag to record and warn without killing, or HEARTH_DECOYS=off to disable planting for a run.

Beyond the workspace, modules/tripwire.nix plants world-readable bait under /var/lib/hearth/decoys and a sudo-only decoy inside the secrets directory, so a full-machine agent that goes looking for secrets on the box finds bait first.

Honest limits

Tripwires catch reads that pass through a hearth tool or surface a canary in tool output. A raw file open by a shell process that never prints the contents is not caught by these layers; kernel-level auditd watch rules on the decoy paths are the planned follow-on. Output scanning can also be defeated by an agent that deliberately encodes the token. The point of a tripwire is to catch the common case (an injected agent grabbing what looks like credentials) loudly and auditably, not to be an unbypassable control.

The security scoreboard

GET /security and the cockpit security panel show what containment is active on the box right now: remote auth, the rate limit, capability-manifest support, egress activity (logged and blocked counts), tripwire status and trip count, and daemon health. It is the one place to answer “is this box actually contained right now?”