Skip to content

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. Xvnc runs -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@.service carries AssertPathExists=/etc/agentctl/terminal/<agent>.admin, a root-owned marker the converge writes only for a ledger agent with admin: 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>.env and in mcp-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 to User=<caller> by the daemon), ws rm anywhere under its own workspace, and agent-message to 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 *, all or 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.env is 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.