Security model¶
This page is the end-to-end view: what is isolated from what, which boundaries the kernel enforces and which are policy, and — for each boundary — what an attacker who already controls one agent can and cannot reach. The mechanism of the privilege boundary itself is in agentd and the unit model; this page links there rather than restating it.
There are three layers, and they are not equally strong:
| Layer | Enforced by | Holds against an adversarial agent? |
|---|---|---|
| One Linux uid per agent, uid/mode separation, nftables port bands | the kernel | yes |
agentd's SO_PEERCRED scope check on every privileged verb |
the kernel supplies the identity; the daemon's default-deny switch decides | yes |
| The runtime permission gate — the send/delete floor, the per-tool MCP risk rules | on pi, a deterministic regex matcher in-process; on claude, auto-mode policy prose read by a model | no |
The first two layers are the ones to reason about when asking whether it is safe to run two agents, or two people's agents, on one host. The third layer is a floor against an agent that is careless or over-eager, not against one that is trying to get around it — the code says so itself, and the section below quotes where.
What isolates one agent from another¶
One Linux user, and nothing more privileged than that¶
An agent is created by exactly one command, identical on both runtimes
(internal/cli/provision_native.go:111, internal/cli/provision_pi_native.go:103):
useradd --create-home --home-dir /home/<name> --shell /bin/bash --groups agents <name>
agents is the only supplementary group, and because there is no --gid, the primary group
is the per-user group useradd creates. No agent is ever added to docker, sudo, adm or
any other group that carries privilege — there is no usermod or gpasswd call anywhere in
the tree. No sudoers drop-in is written; the only mention of /etc/sudoers.d/agentctl-<name>
in the codebase is in the removal list, cleaning up a file the pre-daemon model used to
create. No setuid binary and no polkit rule is installed.
The home directory is mode 0750, owned <name>:<name>. The group bits therefore apply to
the agent's own per-user group, which no other agent is in, and the world bits are empty. One
agent cannot read another's home, its ~/.claude state, its workspace or its browser profile
— by file mode alone, with no daemon involved. That mode is asserted once at provisioning and
is not re-checked by later reconciles.
The account has a real login shell (/bin/bash), which the cockpit terminal route depends
on. agentctl installs no way to log into it: it writes no authorized_keys and sets no
password. It also does not lock the account. Whether an agent uid is reachable over SSH is a
property of the host's sshd configuration, not of anything agentctl asserts — treat that as
a host-hardening decision you still own.
There is no sandbox on the agent process¶
This is the most commonly mis-assumed part of the model, so it is stated plainly. The per-agent unit's entire containment surface is:
MemoryMax=6G
CPUQuota=200%
Slice=agents.slice
(packaging/payload/units/agent/agent@.service; runtime/pi/pi-agent@.service is the same.)
User= comes from the per-instance drop-in rendered by the converge, which adds User= and
WorkingDirectory= and no containment directive at all.
Across every agent-facing unit — agent@, pi-agent@, browser@, openbox@, the watchers —
there is no ProtectSystem, no ProtectHome, no PrivateTmp, no NoNewPrivileges, no
CapabilityBoundingSet, no SystemCallFilter, no RestrictAddressFamilies. The absence is a
recorded decision rather than an omission; terminal@.service carries the reasoning, and its
key sentence is the honest summary of the whole tier: "the agent's own processes are not
sandboxed either."
So an agent process runs as an ordinary unprivileged user with a full view of /, a shared
/tmp, and read access to anything world-readable on the host. Isolation here is classic Unix
uid and mode separation plus cgroup accounting, not namespacing. If two agents on one host
belong to parties who must not learn anything about each other, the thing protecting them is
file permissions on the specific paths that matter — not a sandbox.
Two agent-tier units are exceptions worth knowing. xvnc@ (the private X server) does carry
IPAddressDeny=any / IPAddressAllow=localhost, kernel-enforced on the cgroup.
terminal@ deliberately does not, and says why: the whole point of that shell is running
agentctl, git and apt.
Loopback ports are fenced by uid¶
Every agent's local ports are a pure function of its uid (base + uid − 1000), and the bands
are enforced rather than merely allocated. agentctl-portguard.service loads
table inet agentctl on the nftables output hook, accepting a guarded port only when the
originating meta skuid owns it and dropping it otherwise. This exists because it was
measured: before it, any local user could reach any agent's unauthenticated Chrome CDP port
and drive its logged-in browser.
The full band map, the single cross-uid allowance (uid 0 on the KasmVNC band, for the root web-view gateway that reverse-proxies the cockpit's display view) and the rollback are in Operations § Host port map and per-agent port bands.
One operational caveat that matters for a security control: a port-guard failure does not
abort the apply. apply prints a failure mark, records "port guard" in its host-failure
list, continues with the rest of the pass, and returns exit 1 at the end
(internal/cli/apply.go:1493). A run that ends non-zero may have converged everything else
while leaving the ruleset unloaded, so read the exit code, not the last line.
Outbound network is unrestricted¶
An agent has no egress restriction. No agent unit sets IPAddressDeny, PrivateNetwork or
RestrictAddressFamilies; agents.slice sets only CPU, memory, IO and task accounting. The
port guard is on the output hook but only ever drops traffic to the guarded per-agent ports —
its chain policy is accept.
The proxy support (agentctl host proxy set) writes HTTP_PROXY/HTTPS_PROXY and friends
into rendered env files. That is configuration a process is free to ignore, not enforcement.
If you need enforced egress control, it has to come from the host or the network around it;
see Egress proxies and air-gap.
The privilege boundary¶
Everything an agent cannot do for itself, it asks agentd to do. The request carries no
token and no caller field: on accept, before reading the request, the daemon reads the
caller's uid from SO_PEERCRED, which the kernel stamped at connect(2) and the client
cannot forge. That uid is the entire trust root. Operator scope is uid 0 or an agent flagged
admin: true; every other uid falls to a self-scope allowlist that is a switch with a
default deny, so a verb nobody wrote a case for is refused even for self.
The socket is 0660 root:agents, so every agent on the host can reach the daemon and nothing
else can — but that mode is a coarse first gate, not the authorization.
The full treatment — the verb classification, the self-scope table, the six denial paths, and the two request kinds authorized by a signed capability instead of by uid — is in agentd and the unit model. What the admin flag unlocks is in The admin role.
Secrets¶
Storage. Secrets are sops-encrypted with age. The store is a directory of shards under the
GitOps repo (/opt/agentctl/secrets.d/ by default): one shared.sops.yaml plus one
<agent>.sops.yaml per agent, each written mode 0600 and owned by root. A read resolves the
agent's own shard first and falls back to the shared shard.
The key. The age identity is /etc/agentctl/age.key, mode 0600, generated on the host by
age-keygen and never committed — init writes it into .gitignore under a
# host identity — NEVER commit heading. Each host registers only its public recipient in
the ledger, and the store is re-wrapped to every fleet recipient, so any fleet host's key
decrypts everything. That is the same trust boundary as the shared repo, and it is worth
stating explicitly: adding a host to a fleet gives that host's root the whole fleet's
secrets.
What the encryption is and is not for. The code is direct about this
(internal/cli/overlay_acl.go:17): the key that decrypts the store is root-only, so the
encrypted store is not the confidentiality boundary — encryption is defence in depth against
a store that leaks off-host or gets tampered with in the repo. The confidentiality boundary is
that an agent's process never holds the key and every secret subcommand — including get
and list — is classified Mutating and therefore cannot run locally in an agent's client at
all. An admin agent gets a recursive ACL on the repo overlay; secrets.d/ is then explicitly
re-denied to it, recursively and by default ACL.
What agentd checks for a self-scoped read. A non-operator's bare secret get KEY is
rewritten to --agent self, then refused if the key is root-only, if no --agent is present
(a bare read targets the shared store and is operator-only), if the target is */all, or if
the target names another agent. secret list has no self-scope case at all and hits the
default deny.
The two keys refused in every scope, including self, are AGENTCTL_HOST_OP_SECRET and
AGENTCTL_HOST_OP_GRANT_SECRET. They sign root executions, they live at 0600 root:root in
/etc/agentctl/env/, and they are subtracted from the rendered env files as well as gated at
the daemon. Both halves are needed: the grant key was excluded from the render and was still
readable fleet-wide through secret get plus the shared-shard fallback until the daemon-side
check landed. If either key ever appears at 640 root:agents, or shows up in
mcp-shared.env, that is a fleet-wide key compromise and a rotation on every host, not a
permissions nit — see
Architecture § Durable host operations for what
the capability signs.
Rendered plaintext. Secrets reach an agent as plaintext env files on disk, sourced by its unit:
| File | Mode / owner | Reachable by |
|---|---|---|
/etc/agentctl/env/agent-<name>.env |
0640 root:<name> |
that one agent (its own primary group) |
/etc/agentctl/env/mcp-shared.env |
0640 root:agents |
every agent on the host |
Neither is world-readable, and the containing directories are 0750/0710 root:agents. They
are on disk, not tmpfs, and nothing scrubs them on a schedule — they are removed when the
agent is removed. The practical consequence of the second row: a shared secret is shared
with every agent on the host. If two agents must not have the same credential, it has to go
in a per-agent shard, not in the shared one.
An agent can read its own credentials. This is by design and it is the ceiling on every
gate above it. The agent's unit sources those env files, so the values are in its own process
environment and readable from its own shell. internal/cli/gate_rawapi.go:32 states the
position without hedging: "THE FIX HERE IS A MITIGATION, NOT THE CURE. The cure is that the
agent should not be able to read the credential at all — MCP servers running as a different
unix user than the agent's own shell. That is a larger change and is deliberately out of
scope." Assume that any credential you wire to an agent is a credential that agent holds.
The human floor on sends and deletes¶
There is a floor, it is generated from the MCP catalog's risk: declarations and reconciled
onto every agent on every apply, and it is two different mechanisms with different
strengths. internal/cli/gate_generate.go:10 names the split: "ONE declared fact, TWO
rendered policies."
On pi it is deterministic and fails closed. runtime/pi/extensions/irreversible-floor.ts
hooks tool_call, matches regexes against the bash command string, and on a hit raises an
Allow/Deny card in the cockpit. A deny blocks; a timeout blocks; an agent with no reachable
operator blocks. A throwing dialog is treated as a non-answer and a non-answer is a no. All 64
shipped rules are action: "ask" — the block action exists in the code path but no shipped
rule uses it. PI_FLOOR_OFF=1 is a documented operator kill switch, off by default.
On claude it is a classifier. permissions.deny in the shipped settings.json is empty,
Bash is allowed with no command pattern, and defaultMode is auto. The floor lives in
autoMode.soft_deny as two prose blocks — outbound send is human-performed, the agent
drafts and stages but does not send; deleting the operator's own data is human-performed,
prefer archive/trash/re-label — which a model reads at runtime and decides whether the action
in front of it is covered. That is generated and reconciled by code rather than being a line
somebody put in a steering file, and it is Claude Code's own auto-mode machinery, but it is
not a matcher and no Go code will refuse a send.
Both are anchored on tool names and command strings, not on credentials. An agent that
reads its own API token out of its environment and calls the vendor's REST API with curl
matches neither call send-mail nor mcp__ms365__send-mail, so it is gated by nothing. The
codebase records this happening in production: an agent at a client fleet, blocked by an ms365
gate, announced it would "bypass the MCP" because "there's a valid access token in the cache",
read the token file, and drove Graph directly. The mitigation is a set of generated
raw-api-access rules — one per credentialed MCP — matching a network-invocation token
(curl, wget, an interpreter, an openssl s_client) together with the MCP's own API host on
one command line, rendered for both runtimes. It is a regex over a command string and is
defeated by any exfiltration shape its pattern list does not name.
The gate's own configuration is agent-writable on pi. The per-agent rule selection file is
~/.pi/agent/permission-gate.json, owned by the agent, mode 0644 — deliberately, because the
extension runs as the agent and reads it every turn. An agent can empty its own enabled array
and run with no rules until the next agentctl apply reconciles it back. Likewise, the
permission-decision journal is written into the agent's own home by a process running as the
agent, best-effort, with every failure swallowed; there is no root-owned, append-only or
off-host copy. The one tamper-resistant audit store in the tree is for root host shells
(/var/lib/agentctl/hostops, 0700 root:root, where a failure to audit refuses the session) —
that is a different surface and should not be read as an audit trail of agent actions.
The honest summary: this is a floor against an agent that is careless, not one that is adversarial.
Two CLI-level notes on destructive verbs, since they are adjacent. agents remove,
apply --prune and the destructive agentctl docker subcommands refuse and tell you to
re-run with --yes. ws rm --recursive, secret remove and subsession rm take no
confirmation at all; ws rm guards only against deleting the workspace root itself, and it
is self-scoped, so an agent can reach it.
The cockpit is one session, and it is the whole boundary¶
The cockpit itself performs no authentication. It trusts the X-User-Email request header
absolutely, on the basis that its listener is loopback-only. Authentication happens in a
separate front proxy, agentctl-webview-login.service, which authenticates with a pluggable
driver (basic or oidc), mints an HMAC-signed wv_session cookie
(HttpOnly, Secure, SameSite=Lax, 30-day default TTL), sets the header, and proxies. The
basic driver's credential list is one shared list per host (WEBVIEW_BASIC_USERS), rate
limited to 8 attempts per minute; per-agent separation happens afterwards, as authorization,
by mapping the verified principal through the ledger to an agent. A request that arrives with
no header at all is treated as an unscoped operator — which is correct for the loopback
top --web case and is the reason the login proxy must be the only thing in front of the
cockpit port.
Two routes hand out more than a dashboard, and both are gated by that same session:
/browser/proxies the agent's KasmVNC transport: keyboard and mouse on a persistent, logged-in Chrome. Every live session in that profile, a password manager that may already be unlocked, and a file picker rooted in the agent's home.Xvncruns-SecurityTypes None, so the transport is unauthenticated to every local uid and is held shut by three independent loopback bindings plus the port guard. There is no second factor and no confirmation step./terminal/proxies a login shell running as the agent's own uid — not root. It is the same route shape and the same identity gate, and it is strictly more dangerous: every secret that agent can read and every system it is wired to. It has one containment layer/browser/does not:terminal@.servicecarriesAssertPathExists=/etc/agentctl/terminal/<agent>.admin, a root-owned marker the converge writes only for a ledger agent withadmin: true. systemd itself refuses a non-admin agent's shell even if the cockpit is bypassed or replaced. The destination is a unix socket in a 0700 runtime directory, so there is no port on the route to reach by other means.
Details of both are in The cockpit.
Mini-app data plane: read-only by default (ctx.mcpRead, checked against a read-only tool-name
predicate in the browser and again server-side), with two per-app opt-ins declared in the
manifest and independently re-checked server-side — writes: true for the workspace write seam
and actions: true for the mutating action plane. A manifest's admin: true is a launcher
filter that hides an icon; it is explicitly not a security boundary, and the endpoints an admin
app reads are gated separately by adminScope. Note that on the action plane a tool declared
risk: ask is reachable with no confirmation step — the confirmation lives in the agent's
turn, and the turnless action plane has none.
What a compromised agent reaches¶
Assume an attacker has arbitrary code execution as one agent's uid.
Reachable:
- That agent's entire home: sessions, transcripts, skills, memory, workspace, Chrome profile, any MCP token cached there.
- Every credential in
agent-<name>.envand inmcp-shared.env— the latter being every shared secret on the host — and therefore every external system those credentials reach, directly over the network, outside every tool-name gate. - Unrestricted outbound network.
- Every self-scope verb through
agentd: restarting itself, wiring or removing its own MCPs and tools, adding its own skills, hooks and subagents, its own crons (bound toUser=<caller>by the daemon),ws rmanywhere under its own workspace, andagent-messageto any named peer. - Anything world-readable on the host, and the shared
/tmp. - Its own permission-gate configuration and its own permission journal.
Not reachable:
- Any other agent's home, rendered env file, MCP socket, or loopback band ports (the KasmVNC band additionally accepts uid 0, and nothing else).
- The age key, the encrypted store, and any secret belonging to another agent or to the shared
store through
secret get. - The two root-only signing keys, in any scope.
- Any operator-scope verb: catalog authoring,
host set,host shell up|tunnel|grant, anything targeting*,allor a named peer. - Root, by any path agentctl installs — no sudoers, no setuid, no polkit, no capabilities.
The interesting middle case is the admin flag. An admin agent has operator scope at the daemon while still running as an unprivileged uid, so compromising an admin agent is equivalent to compromising the fleet: it can read every agent's secrets, mint host-shell grants, and run privileged verbs against every host. Grant it deliberately and to as few agents as the work needs. See The admin role.
What this model does not defend against¶
Stated so you can decide whether it matches your situation:
- A hostile agent. The runtime floor is prose-or-regex over tool calls; the credentials are in the agent's own environment; the gate config and the audit journal are in the agent's own home. Layers 1 and 2 hold; layer 3 does not.
- Prompt injection reaching a real credential. Content an agent reads — a web page, an email, a document — is untrusted input to a process holding live tokens. The floor asks a human before a send or a delete on the paths it recognises, and does not on the paths it does not.
- A shared secret being shared.
mcp-shared.envis readable by every agent on the host. - A fleet member's root. Any fleet host's age key decrypts the whole store.
- Kernel-level escape or a local privilege-escalation bug in the OS. There is no sandbox on the agent process, so an unprivileged local exploit is unmitigated by anything here.
- Whoever holds a cockpit session. One credential list per host, no second factor, and
/browser/and/terminal/behind it.
A fair reading of where this sits: it is safe to run one owner's agents on a host, and safe to run several owners' agents on a host as long as each owner is trusted not to attack the others, because the kernel-level separation between them is real. It is not a multi-tenant isolation boundary against a hostile tenant, and it is not designed to be.