Security & Threat Model
What agentctl defends against
Two distinct threats, both taken seriously:
- Host escape. The agent (or something it's tricked into running)
breaking out of its sandbox onto the machine running it. This is the
threat most "run an agent safely" tooling focuses on, and it's why
agentctluses real hardware-virtualized isolation (Incus VMs — not containers, not gVisor-style syscall interception) rather than a weaker boundary. - Lateral movement against the local network. The agent attacking other devices on the same network as the machine it's running on — printers, NAS boxes, internal admin panels, coworkers' laptops. Multiple sources in this space converge on the same point: isolation alone doesn't stop this. Two sandboxes sharing no kernel is great against kernel exploits, but if the sandbox has network reach, a compromised agent can still scan or attack other hosts on the LAN unless egress is locked down independently of the isolation layer itself.
That second threat is why agentctl treats network policy as a first-class,
independently capability-gated concern (ApplyNetworkPolicy is its own
Provider method, not folded into Create), and why the defaults are:
Default-deny egress, LAN blocked by default
--deny-landefaults to on. Every sandbox blocks egress to RFC1918 (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) and link-local (169.254.0.0/16) ranges unless explicitly opted out (--allow-lan). Opting out opens the whole LAN — every private address, on any port, on both backends — not just allowlisted hosts on it, so it should be a deliberate, reviewed decision — see Distributing Profiles Org-Wide.- Internet egress is default-deny with an explicit allowlist. A sandbox
can only reach domains listed in
--allow/a profile'sallowentries (or added by an allow preset, an allow file, or--agent); everything else is rejected. - How domain-based allow rules are matched depends on the backend.
- Incus: its ACLs (like any IP-based mechanism) can only match resolved
IP addresses, not domain names.
agentctlresolves each allowed domain at policy-apply time; if the target rotates IPs (common behind a CDN), the rule goes stale until the policy is re-applied, and anything else served from an allowlisted IP is reachable too. - Lima: egress goes through a per-instance filtering proxy (see
Lima's network enforcement),
which matches the hostname the client asked for and resolves it at
connection time — no staleness, and
*.example.comreally covers every subdomain. The flip side: only clients that use the proxy get out at all. - DNS is the one egress outside the allowlist, and it differs by
backend. On Incus, DNS to any server on port 53 is allowed by default
(narrowable or removable with a profile's
network.dns). On Lima, the guest has no DNS egress of its own: names are resolved by the Mac's own resolver through Lima's, andnetwork.dnsis refused because agentctl can't narrow that path. Either way, a resolver that recurses for the sandbox can carry data out in query names — see the DNS section of the Profile Schema. - Neither backend inspects TLS. Filtering is by destination (address or requested hostname), so domain fronting through a CDN shared with an allowlisted domain isn't prevented on either.
Lima network enforcement: host-side without an ACL object
Lima has no per-instance ACL to put this policy in, and a host pf rule on
the VM's network interface would be bypassable: Lima always gives the guest
a user-mode NIC whose traffic the hostagent process re-creates as ordinary
connections from the host, invisible to pf as guest traffic — a guest
with root could just route around such a filter. So agentctl puts the
enforcement on that process instead: limactl start (and with it the
hostagent) runs under a macOS sandbox profile that refuses every outbound
connection except to the instance's own egress proxy and SSH forward on the
host's loopback, and the proxy applies the allowlist and deny-LAN rules.
None of it runs inside the guest, so root in the guest doesn't help; and
agentctl checks after every start that a direct connection out from the
guest fails, stopping the instance if it doesn't. Details and trade-offs:
Lima setup.
The one way around it is not starting the instance through agentctl: a
plain limactl start, or launchd via limactl autostart, runs the
instance with unrestricted egress. agentctl refuses to start an instance
registered with launchd and refuses to adopt a running instance it didn't
start, but treat starting agentctl's Lima instances by other means as
switching their network policy off.
Host filesystem access is opt-in and named
A sandbox sees nothing of the host filesystem except directories explicitly
named at create or start time. The defaults that make that hold:
- Read-only unless asked otherwise. A mount's
writablefield defaults tofalse, matching Lima's own default. Be precise about what a writable mount is: a two-way channel out of the sandbox. Anything the agent writes lands on the host, and the VM boundary does not apply to it. $HOMEis never mounted unless a profile explicitly setsmountPolicy.allowHome. Directories inside$HOMEare fine — that is where the per-instance workspace lives.- Credentials and backend state are refused unconditionally.
~/.ssh,~/.aws,~/.kubeand friends, plus~/.limaand Incus's own state directories. That last group matters more than it looks: an agent that can write a backend's state can rewrite the next sandbox's configuration, escaping without ever attacking the VM boundary. - Symlinks are resolved before any check. Containment is enforced against the canonical path, so a symlink inside an allowed root cannot point elsewhere.
- Admins can bound it further.
mountPolicy.allowedRootsin an org-distributed profile constrains what anyone may name, and no CLI flag widens it; layering profiles can only tighten it. - Lima's own configuration can't add to it. Lima merges the mounts in
~/.lima/_config/default.yamlandoverride.yamlinto every instance, bypassing every check above, since agentctl never sees them.agentctl startcompares what Lima will actually mount with the instance's own configuration, and refuses to boot a sandbox that would see anything more — see Lima setup. - Nor can it open other channels to the host. A Lima reverse socket
forward would hand the sandbox a socket on the host (the Docker daemon's,
say), and
copyToHostwould let it decide what Lima writes to a host file. agentctl uses neither:createclears the template'scopyToHost, andstartrefuses a sandbox whose effective configuration has either — see Lima setup.
Two honest limits. First, read-only enforcement is per-provider — see the
capability matrix; on Incus it is asserted from the
disk device's documented readonly option and pinned by an integration test
rather than assumed. Second, mounts are sticky across boots, so a bare
agentctl start re-exposes whatever was last applied; that is why start and
status always print the active mount set.
Non-root by default inside the guest
shell/exec run as a provisioned non-root user by default, not root — see
Profiles & Policies
for the mechanism. This is defense-in-depth, not a replacement for the VM
boundary: if the agent process itself is compromised, it shouldn't
automatically inherit root inside the guest. Be precise about what this does
and doesn't buy: the provisioned user has passwordless sudo, so it's
hygiene and an audit signal (a sudo invocation is a distinct, loggable
event a future observability pipeline could act on), not a hard security
boundary — a genuinely malicious agent can still escalate via sudo. Root
remains available as an explicit, visible opt-out (--root), never removed.
--agent widens the egress allowlist, visibly
agentctl create --agent=<name> (see
Agent Provisioning) merges that agent's
required install/runtime domains into the instance's egress allowlist
before applying network policy — the same default-deny-with-explicit-allow
mechanism --allow already uses, not a separate or looser path. This is a
deliberate, visible widening tied to the specific agent you asked for
(e.g. --agent=claude allows claude.ai, downloads.claude.ai,
platform.claude.com and api.anthropic.com), not a
silent one: agentctl status/profile show-style introspection of the
resulting policy shows exactly what was added, same as any other --allow
entry. The full per-agent lists are in
Agent Provisioning. Note
that some are broad: --agent=opencode allows github.com, because that's
where opencode's releases are downloaded from, and with it everything else
on GitHub; --agent=gemini and --agent=pi allow registry.npmjs.org,
since both install from npm — a package registry is a broader grant than a
single vendor domain, and worth weighing if your threat model cares about
what an agent can pull in at install time. Two multi-provider agents
(opencode, pi) are documented as only guaranteeing their install (plus,
for pi, its default provider) — extending the allowlist further for a
different model provider is on you, the same as it would be without
--agent. cursor is partial for a different reason: its vendor spreads
runtime traffic across more hosts than the entry lists, so the same
"extend it yourself" caveat applies.
Presets and allow files are allowlist entries too
--allow-preset/allowPresets (named groups of package-source and git
hosts, e.g. apt, pypi, github) and --allow-file/allowFile expand
to ordinary allow rules — they're conveniences for writing an allowlist,
not a different enforcement path. Two things to keep in mind when
approving one:
- A preset opens whole hosts, both directions. Filtering is by host
and port, never by URL path or by what's sent, so the
githubpreset lets the sandbox push to any GitHub repository it has credentials for, not just clone public ones;pypi/npmallow uploads to those registries if the sandbox holds a publishing token. Treat any allowlisted host that accepts writes as a possible exfiltration channel. agentctl profile presetsprints exactly what each preset expands to, andprofile showprints a profile's allow file already merged in — review those, not just the preset names, when vetting a profile.
Why raw X11 forwarding is excluded
agentctl view never forwards X11. An X server has no isolation between
clients — any client on a display can generally read the keystrokes and
screen contents of every other client on that display. Forwarding a
potentially-compromised agent's X session onto a shared display would hand
it exactly the cross-boundary access this tool exists to prevent. See
Viewing a Sandbox for what agentctl view
does instead per provider.
The same goes for Lima's own ssh.forwardX11, and for ssh.forwardAgent,
which would let the sandbox use every key your SSH agent holds without ever
reading ~/.ssh. agentctl creates Lima sandboxes with both off, and refuses to
start one whose effective configuration turns either back on — see
Lima setup.
What's honest about current limitations
agentctl is explicit, via its capability model,
about what it can and can't yet enforce per backend — see the
capability matrix. Hyper-V is still a stub this
milestone; treat any claim of protection on that platform as not yet real
until its capability entries say Supported. On Lima, network.acl and
network.deny-lan are Supported on macOS hosts (see
above for the
mechanism and its one bypass: starting an instance outside agentctl), and
Not available on Linux hosts, where agentctl can't confine Lima's
processes — use Incus there.
Observability is explicitly postponed
Structured, correlated audit tooling — connecting an agent's high-level
intent (from LLM prompts/tool calls) to its low-level system effects
(network connections, file access, processes spawned) via boundary tracing —
is a substantial, separate effort (the kind of thing eBPF-based tools like
AgentSight do on Linux, with ETW/Sysmon as the natural analog on Windows and
Apple's Endpoint Security Framework on macOS). It is not built in this
milestone. agentctl logs exists as a command surface today, but every
provider currently reports it UnderDevelopment. This is deliberate scoping,
not an oversight — see the project's stated out-of-scope list.
Auth/RBAC
agentctl does not implement its own authentication or access control for
who may operate a shared backend. On Incus, this means whatever OS/Incus
group membership (incus-admin) already governs who can run incus
commands governs who can run agentctl against it.