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
--agentis present — a bare read targets the operator-only shared store; - the target is
*orall; - 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>.envandmcp-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 toUser=<caller>),ws rmunder its own workspace,agent-messageto 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*,allor 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.