Profile Schema Reference
Source of truth: internal/profile/schema.go. This page mirrors it field by
field; if they drift, the Go source wins.
apiVersion: agentctl.dev/v1 # required, must be exactly this value
kind: Profile # required, must be exactly this value
metadata:
name: string # required
description: string # optional
spec:
network:
denyLAN: bool # default true in the embedded built-ins; blocks
# RFC1918 (10/8, 172.16/12, 192.168/16) and
# link-local (169.254/16) egress
allow: # egress allowlist; everything unmatched is denied
- domain: string # hostname, "*.example.com" wildcard (on Incus
# only the apex domain gets resolved and
# matched — see the security model doc), or
# IP address; not a URL. An IP address in a
# private range with `ports` set is permitted
# even under `denyLAN` (link-local and
# loopback never are)
ports: [int] # optional; TCP ports allowed for this domain
# (omitted: any port)
allowPresets: [string] # optional; built-in host groups added to the
# allowlist: apk, apt, github, gitlab, npm,
# pypi, and the agents claude, codex,
# cursor, gemini, opencode, pi
# (`agentctl profile presets` lists them;
# mirrors --allow-preset)
allowFile: string # optional; an allow file whose entries are
# added to `allow` when this file is loaded
# (format below; mirrors --allow-file)
ports: # host:guest port publishes, Docker-style
- host: int
guest: int
protocol: string # "tcp" (default) or "udp"
dns: # name-resolution egress; see "DNS" below
servers: [string] # empty (default) = allow DNS to any destination
disabled: bool # default false; true = no DNS egress at all
resources:
cpuCores: int # must be >= 0
memory: string # size string, e.g. "4GiB", "512MiB", "20GB"
diskSize: string # size string, same format as memory
mounts:
- hostPath: string # supports "~" expansion and a "{{.Name}}"
# template substituted with the instance name
guestPath: string # absolute; defaults to hostPath if omitted
writable: bool # default false (read-only), matching Lima
mountPolicy: # constrains which host directories may be
# mounted at all; see "Mount policy" below
allowedRoots: [string] # every mount must resolve under one of these
denyPaths: [string] # never mountable, even under an allowed root
allowHome: bool # default false; permits mounting $HOME itself
createMissing: bool # default false; mkdir 0700 a missing hostPath
console:
viewer: string # advisory: "spice" | "vnc" | "native" — the
# provider decides the actual mechanism it's
# capable of; see view-and-console.md
packages: [string] # tools installed into the guest during
# create; see "Packages" below
Allow presets
Each allowPresets name stands for a fixed list of allow rules, defined in
internal/profile/presets.go and added after the profile's own allow
entries when the policy is applied. Run agentctl profile presets to see
every preset's hosts and ports; Profiles & Policies
covers what each is for. A profile shows (profile show) and merges presets
by name, so a newer agentctl that updates a preset's hosts applies the
update to existing profiles.
Allow file format
allowFile (and create --allow-file) name a plain-text file with one
allowlist entry per line, in the same forms --allow accepts
(example.com:443, example.com:80,443, *.example.com,
[2001:db8::1]:443, https://example.com, ...; see
Profiles & Policies).
Blank lines are ignored and # starts a comment.
- A relative
allowFilepath is resolved against the directory of the profile or spec file that names it, and~/against your home directory. (--allow-filepaths are relative to the current directory.) - The file is read when the profile or spec is loaded: its entries are
appended to
allow, andallowFileis cleared — soagentctl profile showprints the merged rules — and a missing file or invalid line is a load error naming the file and line. - An embedded built-in profile can't use
allowFile; there's no directory for a relative path to be relative to.
Packages
packages: is the one field here that adds something to a sandbox rather
than restricting what it may do. It is not a security control and can't be
used as one — a profile author who wants to stop a sandbox installing
software has to do that through the network policy, not by leaving this
list short.
Entries are neutral tool names, not distro package names. agentctl asks
the guest which package manager it has and maps each name onto that
distribution's real package (internal/packages), so one profile installs
correctly on Ubuntu, Fedora and Alpine alike:
spec:
packages:
- git
- openssh-client # openssh-clients on Fedora, openssh-client elsewhere
- shellcheck # ShellCheck on Fedora
Run agentctl profile packages for the full table of names and what each
one installs per distribution. A name that isn't in the table is passed to
the guest's package manager verbatim, which works but ties the profile to
one distribution.
Supported package managers are apt, dnf and apk — the families the images used throughout these docs belong to. A guest with anything else fails the create with a message naming the three, rather than guessing at package names that were never verified.
Ordering, and what it means for the network policy
Packages are installed during create, in the window where the instance is
already briefly running to provision the non-root user, and before any
--agent install.
When a create requests packages, the network ACL is attached after that install rather than before it. This is deliberate. Distribution mirrors are CDN-backed and rotate their addresses; agentctl's allow rules are resolved host-side at create time and pin the addresses they saw, so a mirror that hands the guest a different edge address than the host got is simply unreachable. Rather than ask every profile to allowlist an unpinnable moving target, provisioning runs against the backend's default connectivity and the policy is attached as soon as it finishes.
The window is real and worth understanding: during it the guest has
whatever egress the host's bridge allows. Nothing runs in it except the
distribution's own package manager and agentctl's two embedded scripts; no
agent code and no user code has run yet, and the instance is not handed
over until the policy is on. agentctl create says so on stdout rather
than reordering silently, and --preview shows the reordered sequence.
A create with no packages keeps exactly the previous order — ACL first, then the first boot.
Size string format
Parsed by internal/profile.ParseSize. Accepts binary units (KiB, MiB,
GiB, TiB), decimal units (KB, MB, GB, TB), or a plain integer
byte count. Must be positive; zero and negative sizes are rejected.
Validation
internal/profile.Validate aggregates every problem found in one pass
(rather than stopping at the first) and checks:
apiVersion/kindmatch the constants abovemetadata.nameis non-empty- Every
allow[].domainis a valid hostname,*.-wildcard hostname, or IP address — not a URL, address range, orhost:port(all of which no backend could ever match); everyallow[].portsentry is in1..65535 - Every
allowPresetsentry names a built-in preset - Every
ports[].host/ports[].guestis in1..65535;protocolistcporudp; no twoportsentries publish the same host port + protocol resources.cpuCores >= 0;resources.memory/diskSize, if set, parse viaParseSize- Every
mounts[].hostPathis non-empty; everymounts[].guestPath, when set, is absolute
Everything else about a mount is checked at create/start time rather than at load time, because it depends on the instance name and the real filesystem — see below.
DNS
network.dns controls name-resolution egress, which is infrastructure rather
than part of the allowlist: a guest has to resolve a domain before it can
reach the address allow permits, so without DNS the allowlist cannot work at
all.
The default — an empty servers list — allows DNS to any destination on
port 53, over both UDP and TCP. That is deliberately permissive, and the cost
is real: DNS is a well-known exfiltration channel, since data can be encoded
into subdomain labels of an attacker-controlled zone. The alternative, allowing
only the bridge's own resolver, requires discovering that address per network
and per address family — and getting it wrong leaves the sandbox with no
working DNS at all, which is the failure this default exists to avoid.
Narrow it once you know your resolver addresses:
spec:
network:
dns:
servers: ["10.115.230.1"] # only this resolver
Or remove DNS egress entirely, which only makes sense when a proxy resolves on the sandbox's behalf:
spec:
network:
dns:
disabled: true
Merging is narrow-only, like mountPolicy: an override that names servers
replaces a broader base, and disabled is OR'd so the most restrictive layer
in the chain wins.
All of this is the Incus backend's. A Lima guest has no DNS egress of its
own — its queries are answered by Lima's host resolver, i.e. the Mac's own
DNS settings — so there is nothing for servers or disabled to narrow, and
agentctl can't switch that resolver off. A profile or spec that sets either
is refused at create on Lima rather than silently not applied; see
Lima setup.
There is deliberately no profile field for disabling network policy
wholesale. The --no-network-policy escape hatch is a per-invocation CLI
flag only, so a distributed profile can't quietly turn enforcement off for
everyone who applies it.
Mount policy
mountPolicy is the admin-facing half of mount policy. A sandbox sees nothing
of the host filesystem except the directories named in mounts or via
--mount, and mountPolicy bounds what may be named at all. There is
deliberately no CLI flag that widens it: --mount only ever produces
mounts entries, which are then checked against the policy.
internal/profile.ResolveMounts is the single place a host path is
interpreted. For each mount, in order:
~and{{.Name}}are expanded (an unknown template field is an error, not an empty string).- The path is made absolute and cleaned.
- A missing directory is created with mode
0700ifcreateMissingis set, otherwise it is an error. - The path is canonicalized with
EvalSymlinks, and every check below runs against the canonical path — otherwise a symlink inside an allowed root could point anywhere. /and$HOMEitself are refused unlessallowHomeis set. Directories inside$HOMEare unaffected — that is where the default workspace lives.- A built-in deny list is applied that profile authors cannot forget:
~/.ssh,~/.gnupg,~/.aws,~/.config/gcloud,~/.kube,~/.docker, plus the backends' own state directories (~/.lima,~/.local/share/incus,~/.config/incus) and~/.config/agentctl. Write access to a backend's state would let an agent rewrite the next sandbox's configuration, which escapes the sandbox without ever attacking the VM boundary. denyPathsis applied on top.- If
allowedRootsis non-empty, the path must live under one of them. Containment compares path segments, so/home/u/srcevilis not inside/home/u/src. guestPathdefaults to the host path (Lima's own behavior), must be absolute, and may not shadow/,/etc,/usr,/bin,/sbin,/lib,/boot,/dev,/proc,/sys,/varor/root.- Mounts that overlap — same guest path, nested guest paths, or nested host paths — are rejected.
Errors aggregate, so one run reports every bad mount rather than one per fix.
Strict decoding
Profiles are decoded with unknown fields rejected — see Profiles & Policies for why.
Merging
internal/profile.Merge(base, override Policy) Policy combines a loaded
profile with ad-hoc create flags:
network.allow,network.ports,mounts: additive union — override entries are appended after base'snetwork.allowPresets: union, each preset oncenetwork.allowFiledoesn't take part: by the time profiles are merged, each one's allow file has already been read into itsallowpackages: additive union, deduped — no layer can withdraw a tool an earlier one asked for, and two layers both asking forgitinstall it once. Plain accumulation rather thanmountPolicy's narrow-only asymmetry, because nothing here is load-bearing for security: packages add software, they don't relax a restrictionnetwork.denyLAN: override's value is taken as-is (the CLI resolves--deny-lan/--allow-lanagainst the profile's value before calling Merge)resources.*,console.viewer: override's value wins whenever it's non-zero/non-empty, otherwise base's value survivesmountPolicy: narrow-only — layering can tighten what is mountable but never loosen it.allowedRootsintersect (an empty override leaves the base's roots intact rather than clearing them),denyPathsunion, and forallowHomean explicitfalseanywhere in the chain wins over a latertrue. This asymmetry is the point: a personal profile must not be able to unlock a directory an org profile ruled out.
The Spec document (create --spec=<file>)
A separate, smaller document (internal/spec.Spec) describes one specific
create invocation rather than a reusable profile:
apiVersion: agentctl.dev/v1
kind: Spec
metadata:
name: string # informational; the CLI's <name> argument always wins
spec:
image: string # required
profiles: [string] # named profiles to apply, in order
overrides: Policy # same Policy shape as a profile's spec, layered on
# top of the named profiles via Merge
overrides is validated with the same rules as a profile's spec, and its
network.allowFile, if set, is resolved against the spec file's directory.