Experimental and educational — not for security-sensitive use. agentbox is an unaudited personal project written without professional expertise in Go or low-level container/VM sandboxing, and its security reasoning has not been independently reviewed. The model described below is design intent under active development, not a vetted guarantee. Do not rely on it to contain untrusted or malicious code, or to protect real secrets. Assume escapes and mistakes are possible.
This page is required reading before running an agent unattended. It ships
with the tool as agentbox-security(7) and here.
agentbox does not make it "safe" to run an agent unattended, and its two isolation tiers are not equivalent.
The accurate claim is narrower: the blast radius of an agent mistake is
reduced to one workspace tree and one egress allowlist, both inspectable
beforehand (agentbox status, and the config file itself) and logged
after (agentbox logs --denied). That is what makes skipping interactive
approval a defensible engineering decision rather than a reckless one. It is
not the same claim as safety, and the two tiers do not reduce that radius
equally.
In scope. An agent that is careless, overconfident, or acting on a
prompt injection from content it read. A workspace dependency with a
malicious install script attempting credential theft or exfiltration.
Accidental destruction outside the workspace. Under the vm tier,
additionally: an exploit achieving container escape.
Out of scope. Hypervisor escape. Under the container tier, kernel and
runtime escape. Side channels. A determined adversary who already has code
execution and specifically targets this configuration. Anything reachable
via the workspace's own git remote and CI credentials if those are mounted
in.
| Property | container | vm |
|---|---|---|
| Kernel boundary | Shared with host | Private guest kernel |
| Escape class in scope | — | Container escape |
| Escape class out of scope | Kernel or runtime vulnerability | Hypervisor vulnerability |
| Guest root available | No, by construction | Optional, [security.vm].guest_root |
| Mask enforcement point | Host mount namespace | Host mount namespace or share daemon |
| Mask survives guest root | Not applicable | Yes |
| Mid-session file masked | No (view) |
Yes (filter) |
| Nested container engine | Forbidden — equals host root | Permitted, guest-local |
| Capability restriction | cap_drop: ALL |
Not applicable |
| Setuid removal | In the generated baseline; the image is the project's | Same, sudo excepted under guest_root = "allow" |
| Read-only root | Yes | Guest-local, weaker meaning |
| Resource limits | Host cgroup, agent cannot raise | Guest-visible; a guest-root agent may raise in-guest limits |
| Egress control | Sidecar proxy, reached by name | Sidecar proxy, reached by pinned IP |
| Inter-box reachability | Denied by network topology | Denied by network topology |
| Cross-box state access | Denied — separate volumes and mask views | Denied — separate disks and mask views |
| Startup cost | ~100 ms | seconds |
| Memory model | Shares host page cache | Private kernel and page cache |
| Requires KVM / nested virt | No | Yes |
Implementation status: this release implements both tiers. The
vmtier runs on an OCI-consuming VM runtime — Kata Containers, via docker; Podman is not offered for this tier because nothing can enter a box it created — and requires/dev/kvm; where the runtime or KVM is missing,min_isolation = "vm"exits 69 naming what would fix it. The Layer 3 filtered share (mask_mode = "filter") ships in this release: undervm,autoresolves tofilterwhere the seam is deliverable (/dev/fusepresent, plus either agentbox running as root or the setuidfusermount3helper withuser_allow_otherenabled —agentbox statusreports which wayautoresolves on this host) and falls back toviewwith a warning otherwise; an explicit"filter"that cannot be delivered is an error, never a downgrade. The host-side mask view likewise needs agentbox to run with CAP_SYS_ADMIN; without it, mask mounts are expressed in the guest and the degradation is warned about — never silent.
Under both tiers, agentbox refuses to construct a box with host networking,
a host PID namespace, a mounted host container-runtime socket, or
seccomp: unconfined. Any one of these makes the rest decorative; a mounted
host runtime socket in particular is equivalent to handing the agent root on
the host.
Three of the four have no key at all — there is nothing in agentbox.toml
that asks for them. The fourth does: [security.container].seccomp exists so
a project can supply a stricter profile of its own, and "unconfined" there
is a configuration error (exit 78) naming the key, rejected again by the
backend so that no path into box creation can reach the engine with it.
Boxes cannot reach one another, over the network or through shared state. Each box gets its own network, its own persistent home, and its own mask view: four boxes are four independent blast radii, not one shared one. Because a box is identified by its workspace root, two boxes are necessarily two directories — so they do not share a working tree either, and there is no configuration that makes them.
Masking constructs the masked view of the workspace on the host side of the filesystem boundary and presents that to the guest, so enforcement does not depend on the guest's privilege level.
Under view mode, matched files read as empty and refuse writes; matched
directories appear empty, with writes landing in memory and never reaching
the host. Under filter mode (vm tier), the workspace is served by
agentbox's own share daemon — which may run unprivileged, mounting through
the setuid fusermount3 helper, without weakening any of what follows: the
enforcement point is the daemon's position outside the guest, not the
privilege it holds. It evaluates the mask patterns at every
lookup: a masked path answers ENOENT and is omitted from directory
listings — indistinguishable from absence — and creating, linking, or
renaming onto a masked name is refused loudly (EPERM). Renaming a
directory that hides a masked entry is likewise refused (EBUSY), so an
anchored pattern cannot be dodged by moving an ancestor out from under it —
the same move view mode blocks because the masked path is a live
mountpoint. The daemon compiles its patterns from contents frozen host-side
at box start, so an agent editing .agentignore inside the box cannot
unmask anything.
What each mode guarantees:
| Guarantee | container + view | vm + view | vm + filter |
|---|---|---|---|
| Existing matched file unreadable | yes | yes | yes |
| Existing matched directory appears empty | yes | yes | yes |
| Matched path absent from listings | no | no | yes |
| Survives a root process in the guest | n/a — no guest root | yes | yes |
| File created after box start is masked | no | no | yes |
| Masked directory imposes no size cap | no | no | yes |
| Writes to masked paths never reach the host | yes | yes | yes |
Its documented limitations:
- Mounts are fixed at creation (
viewmode only). A path that does not exist when the box is created cannot be masked; a.envthe agent writes mid-session is readable. Underfiltermode this does not apply: the filter is evaluated live, so a matching file is masked the moment it appears. - A masked directory is a small writable ramdisk, not a hole (
viewmode only). Masking a directory the toolchain builds into —node_modules,obj/,.venv/— fails the build once it exceeds[masking].tmpfs_size. Underfiltermode this does not apply; there is no tmpfs, and a masked directory simply does not exist in the guest. .git/should not be masked. It costs diff, commit, and history for almost no gain. It is deliberately absent from the scaffolded patterns.- Masking is not a secrets manager. For credentials that need not sit
in the tree at all, prefer
[variables.passthrough]or keeping the secret host-side. Masking exists for the files an application genuinely needs on disk to run — a dev TLS key, a seeded database — where deleting them to run an agent would cost the ability to debug at all.
In the default proxy mode the guest has no route to any external
address; all egress goes through a policy proxy evaluating a default-deny
allowlist. The allowlist is a policy layer over a topology that already
denies, so a tool that ignores HTTPS_PROXY fails closed rather than
escaping. TLS is never intercepted — the proxy matches on the CONNECT
target, leaving certificate validation end-to-end. Every request, including
denials, is logged (agentbox logs --denied). deny entries are evaluated
first and win.
The allowlist is the project's, written in [network].allow. agentbox ships
no curated bundles: it cannot know what a third-party tool talks to, a list it
maintained would grant egress nobody wrote down, and the entries that aged
worst were wildcards — one release shipped .blob.core.windows.net, which is
every Azure storage account in existence, inside a bundle named nuget.
An allowed domain is allowed for HTTP and for CONNECT to port 443, and for
nothing else — the generated configuration carries an explicit http_access deny CONNECT !SSL_ports. This is written out because agentbox replaces
squid's shipped squid.conf in its entirety, so squid's own default of the
same shape is not in force unless it appears in the generated file. Without
it, allowing github.com for HTTPS would also permit CONNECT github.com:22.
Known limitation: an HTTP CONNECT proxy cannot carry SSH, and agentbox does
not allow the port that would. Git operations must use HTTPS remotes in
proxy mode.
These are properties of CONNECT-based filtering, not bugs, and they bound what the feature is worth. Each was verified against a running box, not reasoned about.
It constrains who, never what. An allowlist is a blast-radius control, not
a data-exfiltration control. If github.com is allowed, an agent can push your
tree to a repository it controls, and every byte of that is a permitted
request. Nothing about the mode changes that: assume anything reachable is a
place data can go.
A tunnel to an allowed domain can carry a different name. squid resolves
the CONNECT target and then hands over an opaque TLS tunnel; it does not
inspect SNI, because doing so would mean terminating TLS and breaking
end-to-end certificate validation. So any hostname served by the same front as
an allowed domain is reachable: CONNECT raw.githubusercontent.com:443 with
SNI and Host set to gist.githubusercontent.com gets an answer from the gist
vhost. Prefer exact hostnames over leading-dot wildcards, and prefer
infrastructure you control over shared CDNs, but understand that the boundary
is coarser than the list looks.
The host is not on the far side of the proxy. An --internal bridge
removes the route off the bridge, but on engines older than Docker 28 the
bridge still carries the host's own address on the same segment — so anything
the host has bound to 0.0.0.0 (a dev server, a local model runner, a
resolver, an engine socket) answers from inside a proxy-mode box, without
passing the proxy and without appearing in its log. agentbox now creates the
bridge with gateway_mode_ipv4=isolated, which leaves the host no address on
it at all; where the engine does not support that, the box is created anyway
and warns on every invocation that the host is reachable. Recreating the
box on a newer engine closes it.
Blocked is not blind. What the box cannot do in proxy mode, verified:
reach an address directly (no route exists — ENETUNREACH, not a filtered
packet), resolve an external name (the embedded resolver cannot reach upstream
from an internal bridge), reach a raw IP or a non-allowed domain through the
proxy, spoof the Host header past dstdomain matching, or use the sidecar as
an IP router even with CAP_NET_ADMIN inside the guest (nothing NATs the
guest's source address, so replies never return).
Both tiers use the same topology, which is what lets one allowlist mean one
thing: a per-box --internal bridge with no route off it, and a squid
sidecar straddling that bridge and a second, routable network. The policy
engine, its generated configuration and its hardening are shared code.
The vm tier differs in exactly one respect, for one reason. A Kata guest
can reach a sibling container over an ordinary bridge — but it cannot
reach the engine's embedded DNS resolver, which the engine publishes at
127.0.0.11 inside the network namespace while the guest's loopback is its
own. Container names therefore do not resolve from inside a VM, so the vm
tier addresses its sidecar by a pinned IP instead of by name.
This has a pleasant consequence. In proxy mode the guest needs no resolver
at all: it sends CONNECT example.com:443 to an IP, and the proxy does the
resolving. The box has no DNS channel of its own, and so no DNS-shaped
exfiltration path. Only open mode needs a resolver, and there agentbox
bind-mounts a generated /etc/resolv.conf carrying the host's real upstream
nameservers. (--dns does not achieve this: on a user-defined network the
engine still writes 127.0.0.11 into the container's resolv.conf and
merely redirects the embedded resolver's upstream, which the guest still
cannot reach.)
After starting a proxy-mode box, agentbox probes from inside the guest that the sidecar is actually reachable. A failed probe warns rather than failing the box: the policy is enforced at the proxy, so an unreachable sidecar is a box with no egress, not one with widened egress. What it must never do is stay silent — a box whose every network call hangs is the most confusing failure this tier has.
Under the container tier, guest root is denied by construction: the backend
refuses to build a container-tier box that asks for guest root, drops every
capability, and sets no_new_privileges, so a setuid binary in the image
cannot raise privilege even if one is there. The image's own half of this —
no sudo, setuid bits stripped — is done by the baseline agentbox generates
and by the Dockerfile agentbox init scaffolds, but the image belongs to the
project: an image you supply with [image].ref or your own Dockerfile carries
whatever you put in it, and agentbox does not audit it. The reasoning: a
guest process with CAP_SYS_ADMIN could unmount a mask and read through it,
converting every mask from a boundary into a suggestion — silently, with no
error and no log line. Agents frequently want root to install packages; the
vm tier is where that request can be granted, because mask enforcement
lives outside the guest.
agentbox.toml is committed, so cloning a repository and running agentbox
applies configuration you did not write, above your own user configuration.
The schema is deliberately shaped so that this cannot be worse than a weaker
box. There are no hooks — nothing in a workspace config causes agentbox to
execute anything on your host — and no host-path mounts, so a repository
cannot ask for ~/.ssh to appear in the guest. What it can do is request
a lower isolation floor, an open network, or a weaker mask mode.
It is allowed to, and it cannot do so quietly. Any workspace value that loosens a ranked security key relative to a lower layer produces a warning on every invocation, naming the key, both values, and the file responsible:
$ agentbox
warning: security.min_isolation: /src/myproject/agentbox.toml lowered this
from "vm" to "container" (~/.config/agentbox/config.toml set "vm"); the
workspace value is in effectTightening is silent, because a project asking for a stronger box is a project config doing its job. No flag silences the warning, because no flag reaches the key. The ranked keys are listed in configuration.md.
There is no trust prompt or TOFU gate. Trust would exist to gate host-side code execution; with hooks and host mounts absent from the schema there is nothing for it to gate, and a prompt that must be answered before the tool will run is a prompt people learn to dismiss.
One configuration file drives two non-equivalent boundaries, so divergence must be loud:
- Every invocation that starts a box prints the backend, tier, egress mode
and mask mode in force;
statusprints the same on demand. - An exit-69 refusal names each unavailable backend and why.
- This property table ships in the documentation (
agentbox-security(7)and here), not buried in code. - Backend-specific keys are scope-checked statically — a
[security.container]key at the wrong level fails identically on every machine, regardless of which backend is active. min_isolationis honored without downgrade. Neither silent nor warned downgrade occurs, and there is no flag that lowers it: the floor moves only when the config does.- A workspace config that weakens a ranked security key warns on every invocation, naming the key and both values.
The failure mode defended against is concrete: a developer writes and verifies a configuration on a bare-metal workstation, a teammate runs it inside a cloud VM, and the boundary silently becomes weaker while every command still succeeds. Every rule above exists to make that impossible or loud.
See SECURITY.md in the repository root.