Skip to content

Security & Threat Model

What agentctl defends against

Two distinct threats, both taken seriously:

  1. 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 agentctl uses real hardware-virtualized isolation (Incus VMs — not containers, not gVisor-style syscall interception) rather than a weaker boundary.
  2. 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-lan defaults 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's allow entries (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. agentctl resolves 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.com really 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, and network.dns is 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 writable field defaults to false, 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.
  • $HOME is never mounted unless a profile explicitly sets mountPolicy.allowHome. Directories inside $HOME are fine — that is where the per-instance workspace lives.
  • Credentials and backend state are refused unconditionally. ~/.ssh, ~/.aws, ~/.kube and friends, plus ~/.lima and 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.allowedRoots in 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.yaml and override.yaml into every instance, bypassing every check above, since agentctl never sees them. agentctl start compares 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 copyToHost would let it decide what Lima writes to a host file. agentctl uses neither: create clears the template's copyToHost, and start refuses 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 github preset lets the sandbox push to any GitHub repository it has credentials for, not just clone public ones; pypi/npm allow 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 presets prints exactly what each preset expands to, and profile show prints 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.