Skip to content

Security model

What is isolated from what, and what an attacker who controls one agent reaches. The privilege mechanism itself is in agentd and the unit model.

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, per-tool MCP risk rules on pi, an in-process regex matcher; on claude, auto-mode policy prose read by a model no

Layers 1 and 2 decide whether it is safe to run two people's agents on one host. Layer 3 is a floor against a careless agent, not an adversarial one.

Agent isolation

Boundary What is enforced How
Linux account one uid per agent, no privileged group agents is the only supplementary group; no usermod/gpasswd in the tree
Home directory no agent reads another's home 0750 <name>:<name>, asserted at provisioning, not re-checked later
Root escalation nothing installs a path to root no sudoers drop-in, no setuid binary, no polkit rule, no capabilities
Login agentctl installs no way in no authorized_keys, no password; the account is not locked either
SSH reachability not asserted by agentctl a property of the host's sshd config — yours to harden
Process sandbox none no namespacing directive on any agent-facing unit
Loopback ports a guarded port is accepted only from the uid that owns it agentctl-portguard.service, nftables output hook, meta skuid match
Outbound network nothing agents.slice accounts CPU, memory, IO and tasks, and also caps pool-wide memory (MemoryHigh=20G) — none of that touches network
useradd --create-home --home-dir /home/<name> --shell /bin/bash --groups agents <name>

There is no sandbox on the agent process

# packaging/payload/units/agent/agent@.service — runtime/pi/pi-agent@.service is the same
MemoryMax=6G
CPUQuota=200%
Slice=agents.slice

The per-instance drop-in adds User= and WorkingDirectory=, and no containment directive. No agent-facing unit sets ProtectSystem, ProtectHome, PrivateTmp, NoNewPrivileges, CapabilityBoundingSet, SystemCallFilter or RestrictAddressFamilies. An agent sees all of /, the shared /tmp, and anything world-readable. The one exception is xvnc@, which carries IPAddressDeny=any / IPAddressAllow=localhost on the cgroup.

MemoryMax=6G on the per-instance unit is what really caps a single agent; the parent agents.slice additionally carries MemoryHigh=20G as a pool-wide soft cap across every agent on the host (a throttle, not a hard kill — added after ~10 always-on browser sessions plus concurrent claude sessions pushed a host into swap thrash with no pool ceiling to stop them).

Ports and egress

An agent's ports are base + uid − 1000. Band map, the uid-0 KasmVNC allowance and the rollback: Ports and the port guard § Host port map. A port-guard failure does not abort the apply — it records "port guard", finishes the pass, and returns exit 1. Read the exit code, not the last line.

The guard's chain policy is accept; it drops nothing but guarded ports. agentctl host proxy set writes HTTP_PROXY/HTTPS_PROXY into rendered env files — configuration a process may ignore, not enforcement. See Egress proxies and air-gap.

The privilege boundary

Element Fact
Caller identity the SO_PEERCRED uid, read on accept before the request; kernel-stamped, unforgeable
Request contents verb, args, optional stdin — no token, no caller field
Operator scope uid 0, or an agent flagged admin: true
Every other uid a self-scope allowlist switch with default deny
Socket 0660 root:agents — a coarse first gate, not the authorization

Verb classification, the self-scope table, the six denial paths and the two capability-signed request kinds: agentd and the unit model.

Secrets

Element Fact
Store sops+age shards in the GitOps repo, /opt/agentctl/secrets.d/ by default
Shards shared.sops.yaml plus one <agent>.sops.yaml each, all 0600 root-owned
Resolution the agent's own shard first, the shared shard as fallback
Key /etc/agentctl/age.key, 0600, age-keygen-generated, gitignored, never committed
Fleet trust a host registers only its public recipient, but the store re-wraps to every recipient
Write guarantee secret set re-wraps the shard it wrote to every registered fleet host before it reports success. A failed re-wrap exits non-zero and commits nothing, so nothing that only one host can decrypt is ever pushed. No follow-up apply is needed for the value to reach the fleet.
Drift audit agentctl health reports any shard whose recipient set does not match the fleet's, and names the apply that repairs it
Agent access every secret subcommand, get and list included, is Mutating — no local run
Admin overlay ACL recursive on the repo overlay; secrets.d/ re-denied recursively and by default ACL

Any fleet host's root decrypts the whole store. The encrypted store is not the confidentiality boundary — the key is root-only and an agent's process never holds it.

What agentd checks for a self-scoped read

A non-operator's bare secret get KEY is rewritten to --agent self, then refused when:

  • the key is root-only;
  • no --agent is present — a bare read targets the operator-only shared store;
  • the target is * or all;
  • the target names another agent.

secret list has no self-scope case and hits the default deny.

The two keys refused in every scope

AGENTCTL_HOST_OP_SECRET and AGENTCTL_HOST_OP_GRANT_SECRET sign root executions (Architecture § Reaching another host). They live at 0600 root:root in /etc/agentctl/env/, are subtracted from the rendered env files, and are gated at the daemon in every scope including self. Either key at 640 root:agents, or in mcp-shared.env, is a fleet-wide compromise and a rotation on every host.

Rendered plaintext

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

Containing directories are 0750/0710 root:agents. The files are on disk, not tmpfs; nothing scrubs them on a schedule; they go when the agent goes. A credential two agents must not share belongs in a per-agent shard. The agent's unit sources these files, so assume any credential wired to an agent is a credential that agent holds.

The human floor on sends and deletes

Generated from the MCP catalog's risk: declarations and reconciled onto every agent on every apply — one declared fact, two rendered policies.

Runtime Mechanism Behaviour
pi runtime/pi/extensions/irreversible-floor.ts hooks tool_call, regex-matches the command raises an Allow/Deny card; fails closed on deny, timeout, unreachable operator, throwing dialog
pi 109 shipped rules — 85 action: "ask", 24 action: "block"; PI_FLOOR_OFF=1 an ask rule raises the card; a block rule refuses outright, with no override — not even the owner's; the kill switch is off by default
claude autoMode.soft_deny and hard_deny prose, read by a model each turn sends are human-performed (draft and stage); the 22 blocked deletes are declared never-permitted, with or without a confirmation. Still prose for a classifier, not a matcher — see the next row
claude permissions.deny empty, Bash allowed with no pattern, defaultMode: auto not a matcher — no Go code refuses a send

packaging/payload/defaults/pi-permission-gate.json splits its rules into two lists: rules (14) is hand-authored — everything with no mcp.yaml to live in (rm -rf, DROP TABLE, a force-push, docker prune), plus generic patterns for MCPs whose tool names aren't literal in this repo. mcp_rules (95) is generated by internal/cli/gate_generate.go from each shipped MCP's risk: block: one rule per declared tool, a raw-api-access rule per credentialed host, plus a raw-api-<verb> rule for each HTTP verb an MCP tiers in risk.api_method_tiers (internal/cli/gate_apiverbs.go) — TestShippedMCPRulesAreUpToDate fails the build if any of it drifts from the declarations.

24 of those 95 rules are block, and that tier is a different thing from ask. 22 are destructive google-workspace and ms365 tools (gmail_delete_email, drive_delete_item, calendar_delete_event, delete-mail-message, delete-onedrive-file, …); the other two are the raw-api-delete verb rules for those same two MCPs. A blocked tool is refused on every surface that reads the catalog, and there is no approval path — an owner standing at the cockpit saying "yes, delete it" cannot get one through. He deletes it himself in Gmail, Drive or Outlook. The reversible alternative each blocked tool names in its reason (gmail_modify_email, drive_move_item, move-mail-message, update-calendar-event, …) stays auto-allowed. The raw-api-access rules themselves are ask for every MCP, including the two that block tools: that rule matches a credentialed host, not an operation, so a plain read would otherwise have become unappealable.

What the floor does not cover

Gap Detail
Credentials both runtimes match tool names and command strings, never credentials
curl against a vendor API an agent using its own token on the REST API matches no MCP tool name
raw-api-access rules the mitigation: curl/wget/an interpreter/openssl s_client plus a credentialed MCP's API host
Its limit a regex over a command string, defeated by any shape its pattern list does not name
Gate config on pi ~/.pi/agent/permission-gate.json is agent-owned 0644; an agent can empty enabled until the next apply
Permission journal written into the agent's home, as the agent, best-effort, failures swallowed
Audit trail /var/lib/agentctl/hostops (0700 root:root) audits root host shells only

Destructive verbs

Verb Confirmation
agents remove, apply --prune, destructive agentctl docker refuse and require --yes
ws rm --recursive, secret remove, subsession rm none; ws rm guards only the workspace root, and is self-scoped

The cockpit is one session, and it is the whole boundary

Element Fact
Cockpit authentication none — it trusts X-User-Email absolutely, on a loopback-only listener
Front proxy agentctl-webview-login.service, driver basic or oidc
Session HMAC-signed wv_session cookie: HttpOnly, Secure, SameSite=Lax, 30-day default TTL
basic credentials one shared list per host (WEBVIEW_BASIC_USERS), 8 attempts/min, keyed on the TCP peer unless it's a trusted proxy (WEBVIEW_TRUSTED_PROXIES)
Per-agent separation authorization after the fact: the verified principal is mapped through the ledger
No header at all refused — the empty scope, authorised for nothing. A login proxy in front is required, not advisory: without one nobody can log in
Forged header still believed — the listener runs no peer check, so any local uid that reaches :8731 can assert an address it does not own

See The webview gateway § Cockpit login for the full rate-limit and trusted-proxy detail.

Two routes hand out more than a dashboard, both behind that one session and neither with a second factor:

Route Reaches Extra containment
/browser/ keyboard and mouse on a persistent logged-in Chrome, plus a file picker in the agent's home none; Xvnc runs -SecurityTypes None, held shut by three loopback bindings and the port guard
/terminal/ a login shell as the agent's uid — every secret it reads, every system it is wired to AssertPathExists=/etc/agentctl/terminal/<agent>.admin, root-owned, written for every applied agent unconditionally

That assertion means systemd itself refuses the shell for an agent apply has never run for, even if the cockpit is bypassed — it is no longer an admin-role check (the .admin marker name predates that change). The route's destination is a unix socket in a 0700 runtime directory, so it has no port to reach by other means. Details: Terminal access.

Mini-app data plane

Capability Default Opt-in
MCP calls (ctx.mcpRead and ctx.mcpCall) any tool on an MCP the agent is wired for, reads and writes alike, unless the MCP's mcp.yaml declares it risk: block. Bounded by viewer-to-agent scope and by the agent's own per-user MCP socket, not by the tool name —
Workspace writes denied writes: true in the manifest, re-checked server-side
Manifest actions: true legacy, ignored. It used to gate ctx.mcpCall; both MCP methods are attached to every app —
Manifest admin: true a launcher filter that hides an icon, not a boundary endpoints gated separately by adminScope
Manifest supervisor: true the same, for the fleet-activity oversight role endpoints gated separately by supervisorScope

A tool declared risk: ask is reachable with no confirmation step. The confirmation lives in the agent's turn, and this plane has no turn — the human's click is the confirmation. A short list of argument KEYS is refused whatever the tool's tier, because they hand a turnless caller the local disk or a policy override: filePath on the browser's captures, bypassReason on ADO's PR write, approval_password on GitLab's approve, plus evaluate_script and upload_file outright.

The read-only tool-name gate was removed (mini-apps are written by agent owners and by customers' own developers, and every interactive app they wanted died on a naming heuristic). The consequence to hold onto: a mini-app installed on an agent can call any tool on any MCP that agent is wired for, and nothing distinguishes a read-shaped data flow from one that mutates. The risk: block backstop is real only where it is declared — 22 destructive google-workspace and ms365 tools today, and nothing in the other eight risk-bearing MCPs — so a panel must not be built around a delete on either of those two, and everywhere else the tier is still a floor being built rather than a wall already there. A mini-app bundle is therefore as trusted as a skill on that agent — review it the same way, and note that ~/.claude/apps/ is agent-writable, so anything with the agent's uid can install one.

The same gate governs the mcp read and mcp call CLI verbs, which are two names for one command. mcp read is additionally relayable cross-agent by the admin agent through the daemon's operator scope, and it runs as the TARGET agent — so an admin can invoke any non-blocked tool with a peer's own credentials, indistinguishably from that peer acting. That is a deliberate grant, not a gap, and it is the one verb on the privileged-read plane that is not side-effect-free.

What a compromised agent reaches

Assume 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 mcp-shared.env — every shared secret on the host — and every external system they reach, over the network, outside every tool-name gate.
  • Unrestricted outbound network; anything world-readable on the host; the shared /tmp.
  • 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>), ws rm under its own workspace, agent-message to any named peer.
  • 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.

An admin agent has operator scope at the daemon while running as an unprivileged uid. Compromising one is equivalent to compromising the fleet: every agent's secrets, host-shell grants, privileged verbs on every host. Grant it to as few agents as the work needs — see The admin role.

What this model does not defend against

Threat Why it is not covered
A hostile agent credentials, gate config and journal all sit in the agent's own home
Prompt injection reaching a credential content an agent reads is untrusted input to a process holding live tokens
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 escape or local privilege escalation there is no sandbox on the agent process
Whoever holds a cockpit session one credential list per host, no second factor, /browser/ and /terminal/ behind it

It is safe to run one owner's agents on a host, and safe to run several owners' agents while each owner is trusted not to attack the others. It is not a multi-tenant isolation boundary against a hostile tenant.