Authoring Profiles & Policies
A profile is a named, reusable bundle of network policy, resource limits,
and mount policy — author it once, reference it by name from create
--profile=<name>. See Profile Schema for
the full field reference; this page walks through the important parts.
Examples use Incus image references (images:ubuntu/24.04,
images:fedora/44). On macOS with the Lima backend, use a template reference
instead (--image=template://ubuntu-lts; limactl template ls lists them) —
see Lima setup. --image=… stands for
either form.
Network policy
spec:
network:
denyLAN: true
allow:
- domain: "api.anthropic.com"
ports: [443]
allowPresets: [apt, pypi, github]
allowFile: team-hosts.txt
ports:
- host: 8080
guest: 8080
protocol: tcp
denyLAN: true(the default) blocks egress to RFC1918 and link-local ranges, so the sandbox can't reach other devices on your network — printers, NAS boxes, internal admin panels, coworkers' laptops. Only set this tofalse(or pass--allow-lan) if you specifically need the sandbox to reach something on your LAN, and understand the tradeoff.allowis an egress allowlist; everything not matched is denied. On Incus, domains are resolved to IP addresses when the policy is applied — a snapshot of the domain's current IPs, not dynamic DNS-aware filtering; if a target rotates IPs (common behind a CDN), re-apply the policy to refresh it. On Lima, the sandbox's traffic goes through a filtering proxy that matches the requested hostname on every connection instead, so only clients that use the proxy (most do — see Lima setup) get out at all.allowPresetsadds built-in groups of hosts for common package sources and git hosts — see Allowing package sources and git hosts.allowFileadds the entries of a plain-text file, one per line — see Allow files.portsare Docker-style host:guest port publishes, for when you need to reach something the sandbox is running (e.g. a web UI) from your host.
Egress allowlist
On the command line, each --allow takes one entry; in an allow file, each
line is one entry:
| Entry | Allows |
|---|---|
example.com:443 |
example.com, port 443 |
example.com:80,443 |
example.com, ports 80 and 443 |
example.com |
example.com, any TCP port |
*.example.com:443 |
on Lima, example.com and all its subdomains; on Incus, example.com only (see below) |
203.0.113.7:443, [2001:db8::1]:443 |
one IP address |
https://gitlab.example.com |
gitlab.example.com, port 443 — a URL's host, on the URL's port, or 443 for https:// and 80 for http:// |
In a profile, the same thing is written as domain: plus ports: (see the
example above).
- Hosts, not URLs. Egress is filtered by destination host and port,
never by URL path (with HTTPS, nothing between the sandbox and the
server can even see the path), so an allow rule can't be narrower than a
whole host. An entry with a path,
like
https://github.com/org/repo, is rejected with the host-level entry to use instead (github.com:443) — rather than silently allowing all of GitHub while looking like it allows one repository. - Redirects count. Downloads are often served from a different host
than the one you request: GitHub release downloads, for instance,
redirect to
release-assets.githubusercontent.com. Every host in the chain needs to be allowed — the presets below already include them for the sources they cover. - Wildcards differ by backend. On Lima the proxy matches hostnames, so
*.example.comcovers every subdomain. On Incus, allow rules become IP addresses, and only the wildcard's apex (example.com) is resolved, so list the subdomains you need by name if you use Incus or want a profile to work on both. agentctl's own presets, agent lists and built-in profiles do exactly that, and never use wildcards. - Internal hosts by name mean opening the LAN. Deny-LAN refuses private
(RFC1918/link-local) addresses even when an allowlisted hostname resolves
to them, so a self-hosted GitLab or package mirror reached by name at a
10.x.x.xaddress stays unreachable unless you list it by address (next point) or create the instance with--allow-lan(ordenyLAN: false). Know what that opens:--allow-lanmakes your whole LAN reachable on any port — every private address, on both backends — not just the internal host you had in mind. Internet egress stays default-deny either way. - One internal host: list it as an IP address with its ports. An
allow entry written as an IP address in a private range (
10/8,172.16/12,192.168/16,fc00::/7), with its ports, is honoured under deny-LAN on both backends, for exactly those ports. For a local model server:--allow=10.20.30.40:8080.createsays so when it applies the entry, and the rest of the LAN stays refused. Still refused, even when listed: link-local addresses (169.254/16,fe80::/10, where cloud metadata services live), loopback, and any hostname that resolves into the LAN. A LAN address without ports is an error atcreate: list the ports you need, or use--allow-lanif you really want the whole LAN.
Allowing package sources and git hosts
Installing packages or cloning code usually involves more hosts than the
one you'd think of — pip install downloads from files.pythonhosted.org,
not pypi.org. Allow presets are named, built-in groups of those
hosts:
| Preset | For | Hosts (ports) |
|---|---|---|
apt |
Ubuntu and Debian packages, default mirrors | archive.ubuntu.com, security.ubuntu.com, ports.ubuntu.com (arm64, e.g. Apple silicon), deb.debian.org, security.debian.org (80, 443) |
apk |
Alpine packages | dl-cdn.alpinelinux.org (80, 443) |
pypi |
pip, uv, poetry | pypi.org, files.pythonhosted.org (443) |
npm |
npm, pnpm, yarn | registry.npmjs.org, registry.yarnpkg.com (443) |
github |
git over HTTPS, the API and gh, raw files, archives, release downloads |
github.com, api.github.com, codeload.github.com, raw.githubusercontent.com, release-assets.githubusercontent.com (443) |
gitlab |
the same, for GitLab.com | gitlab.com (443) |
claude, codex, cursor, gemini, opencode, pi |
a coding agent's installer, sign-in and default model provider — exactly what --agent=<name> allows |
see Agent Provisioning |
Use them on create (repeatable, or comma-separated), in a profile, or in a
spec's overrides, and mix them freely with --allow:
$ agentctl create demo --image=images:ubuntu/24.04 --agent=claude \
--allow-preset=apt,pypi,github --allow=https://gitlab.example.com
spec:
network:
allowPresets: [apt, pypi, github]
agentctl profile presets prints exactly which hosts and ports each preset
allows (add --output=json for a machine-readable list). What presets
don't cover:
- Other mirrors and repositories.
aptcovers the mirrors stock Ubuntu and Debian images use. An image configured for a regional mirror (us.archive.ubuntu.com), a PPA, or a vendor's apt repository needs those hosts added with--allow—apt-get update's errors name them. Fedora'sdnfpicks mirrors dynamically from a mirror list, so no fixed set of hosts covers it; point it at a fixed mirror and allow that one. - Git over SSH.
github/gitlabare HTTPS only — use HTTPS remotes, or add--allow=github.com:22(on Lima, ssh also needs aProxyCommand; see Troubleshooting). Git LFS objects and GitHub's container and package registries (ghcr.io,*.pkg.github.com) are served from other hosts too. - Self-hosted servers. For your own GitLab, Gitea, Artifactory or
Nexus, allow the host itself —
--allow=https://gitlab.example.com— plus--allow-lanif it has a private address (see above for what that opens).
Allowing a host allows everything on it, uploads included — the github
preset lets the sandbox push to any repository it has credentials for. See
the Security & Threat Model.
Allow files
An allow file is a plain-text list of allowlist entries, one per line, in
the same forms --allow takes. Blank lines are ignored, and # starts a
comment:
# team-hosts.txt
nexus.example.com:8443 # internal package mirror
https://gitlab.example.com
Use one with --allow-file=team-hosts.txt on create (repeatable; a
relative path is relative to the current directory), or with
allowFile: team-hosts.txt in a profile or a spec's overrides, where a
relative path is relative to that profile or spec file's directory, so it
keeps working wherever agentctl runs from (~/ means your home directory
in either). Every entry is checked when the file is read: a bad line fails
the create with the file name and line number, rather than being
skipped. agentctl profile show prints a profile with its allow file's
entries already merged into allow.
Resources and mounts
spec:
resources:
cpuCores: 2
memory: "4GiB"
diskSize: "20GiB"
mounts:
- hostPath: "~/agentctl/workspaces/{{.Name}}"
guestPath: "/workspace"
writable: true
mountPolicy:
createMissing: true
hostPath supports ~ expansion and a {{.Name}} template substituted with
the instance name — the built-in profiles use this so each instance gets its
own workspace directory automatically.
Host filesystem access
A sandbox sees nothing of your filesystem except the directories you name.
Out of the box that is one directory: the per-instance workspace above.
writable defaults to false, so a directory is read-only unless you say
otherwise, and $HOME itself can never be mounted.
Name directories with --mount, on create or on start:
$ agentctl create demo --image=… --mount ~/src/project:/workspace:w
$ agentctl start demo --mount ~/src/other:/workspace # read-only
$ agentctl start demo --mount-none # no host access at all
The syntax is hostPath[:guestPath][:w|:ro]. Omit the guest path and it
defaults to the host path; omit the suffix and the mount is read-only. This
mirrors Lima's own --mount, --mount-only, --mount-none and
--mount-writable flags.
One sandbox, many projects
Mounts apply per boot, so a single sandbox can be rebound to a different project instead of building one VM per project:
$ agentctl create work --image=… # no project bound to it
$ agentctl start work --mount ~/src/alpha:/workspace:w
$ agentctl stop work
$ agentctl start work --mount ~/src/beta:/workspace:w # same VM, new project
The guest path stays /workspace, so tooling inside the guest doesn't need to
know which project it's looking at. Mounts are sticky: a bare agentctl
start work reuses whatever was applied last. Because of that, start and
status always print the directories currently exposed — a sticky mount you've
forgotten about is exactly the failure worth making visible.
Constraining what may be mounted (for admins)
mountPolicy bounds which host directories anyone may name. No CLI flag can
widen it, and layering profiles can only ever tighten it:
spec:
mountPolicy:
allowedRoots: ["~/src", "~/agentctl/workspaces"]
denyPaths: ["~/src/production-secrets"]
createMissing: true
Credentials directories (~/.ssh, ~/.aws, ~/.kube, …) and the backends'
own state directories are refused regardless of configuration — see the
Profile Schema for the full
resolution order.
Default non-root user
agentctl create provisions a non-root user inside every new instance and
makes it the default for shell/exec — not root. This is defense-in-depth:
the VM boundary is still the primary protection against host escape, but if
the agent process itself is compromised (a malicious dependency, a
prompt-injected shell command), it shouldn't automatically get root inside
the guest.
- The user has passwordless
sudo, so anything that genuinely needs root (apt install, service management, ...) still works — this is hygiene and an audit signal (asudoinvocation is a distinct, loggable event), not a hard security boundary against a truly malicious agent. - The username is
agentby default, or matches the agent name if you used--agent=<name>(see Agent Provisioning) — so--agent=claudeprovisions and runs as aclaudeuser. - Root is always an explicit, visible opt-out, never removed:
agentctl shell <name> --root/agentctl exec --root <name> -- <cmd>. - This only affects
shell/exec(the agent-mediated command channel).agentctl view's console access is unaffected — it doesn't go through a guest user context at all.
Tools in the guest: packages: and the terminal/gui tiers
Base images are deliberately minimal — images:ubuntu/24.04 has no git,
no compiler, no editor. A profile's packages: list (and the matching
repeatable --package flag) installs tools into the guest during create,
before any --agent install runs:
$ agentctl create demo --image=images:fedora/44 --package=git --package=ripgrep
Names are neutral, not distro package names: agentctl asks the guest
which package manager it has and maps each one onto that distribution's
real package, so the same profile works on Ubuntu, Fedora and Alpine.
agentctl profile packages prints the table.
Two built-in profiles bundle sensible sets:
| Profile | What it installs |
|---|---|
terminal |
git, curl, ca-certificates, openssh-client, ripgrep, jq, unzip, tar, less, vim, procps, make, gcc, python3, shellcheck |
gui |
everything in terminal, plus xorg, xinit, openbox, xterm |
$ agentctl create demo --image=images:fedora/44 --profile=terminal --agent=claude
Both carry exactly the same network, resource and mount policy as
default — they differ only in what they install, and neither relaxes
denyLAN. Neither inherits from the other: each is a complete policy you
can read top to bottom.
Language-specific linters are deliberately absent from terminal.
shellcheck is there because shell is the one language every guest already
runs; eslint, ruff, golangci-lint and friends belong to their own
ecosystem's package manager, and pinning distro versions of them would be
worse than letting a project install what it needs.
packages: is not a security control
It is the one field in a profile that adds capability rather than restricting it. A short list doesn't stop a sandbox installing software — the network policy does. See the schema reference for how requesting packages changes when the network ACL is attached.
What gui gives you
A display that can be driven, not a desktop that comes up on its own. There is no display manager and no autologin, so nothing starts X for you:
$ agentctl view demo # opens the console
# ...log in, then:
$ startx
A display manager is a much larger surface to add to a sandbox, and adding one implicitly wasn't worth it. If you're pointing this at a GUI coding agent, check first that the agent actually ships a Linux build — several of the desktop agents are macOS/Windows only, with Linux packaging done by third parties.
Combining a profile with ad-hoc flags
Flags on create always win over a profile's settings, and list-valued
fields (allow — including presets and allow files — ports, mounts)
accumulate rather than replace:
$ agentctl create demo --image=… --profile=default --allow-preset=github --port=9000:9000
This applies the default profile's full allowlist plus the github
preset's hosts, and publishes port 9000 in addition to anything the profile
already defined.
Where profiles live
agentctl profile list/show/set search, in order:
./.agentctl/profiles/<name>.yaml(project-local)~/.config/agentctl/profiles/<name>.yaml(personal)- the configured
profileDir(org-distributed — see Distributing Profiles Org-Wide) - embedded built-ins (
default,strict,terminal,gui)
Strict decoding
Profile YAML is parsed with unknown fields rejected as an error — a typo like
denyLan instead of denyLAN fails to load rather than silently being
ignored. This is a security policy file; failing loudly on a typo is the
correct default.