Skip to content

The admin role

Admin is one boolean on an agent's ledger entry — admin: true in agents.d/<name>.yaml — and it means one thing: that agent's Linux uid gets operator scope at agentd, the same scope uid 0 has. Everything else on this page is a consequence of that sentence.

It exists because the operator of a fleet is usually an agent, not a person at a shell. A plain agent is confined to itself: it may log itself in, restart itself, add its own MCPs. Anything about another agent — reading its state, restarting it, changing its ledger entry, running a fleet-wide apply — is refused by the daemon's self-scope allowlist. An admin agent is not confined, so its owner can drive the whole host by talking to one agent instead of by holding an SSH key.

The mechanism this rides on is in agentd and the unit model — the SO_PEERCRED gate, the two scopes, and the self-scope allowlist an admin is exempt from. This page is about the role: how it is granted, what it reaches, and where the three enforcement points disagree about when a revoke takes effect. For the operator's one-paragraph version, see Operations § The admin role.

A role, not a seat

Any number of agents may hold it, and every holder has the identical scope. There is no first admin and no primary admin. agents admin <name> grants it and takes nothing from anyone; agents admin <name> --revoke removes it from exactly the agent it names.

This was not always true. The grant used to demote whoever already held the role, which made a second admin impossible to express and made agents admin b a silent revoke of a. The daemon never enforced that rule — ledger.AdminAgents has always returned a set and caller.operator() has always compared the caller's uid against every member — so removing the single-admin rule from the CLI moved no trust boundary. It made the CLI agree with the authorization that already held.

The consequence to keep in mind: nothing may resolve "the admin" to one name. notify admin is a fan-out to every holder, not a first match, because resolving it to one name would make whichever agent the ledger happened to list first the only operator who ever hears about a failed unit.

How it is granted

By default, to the first agent agents add designates the new agent admin when the ledger it reads lists no agents at all. On a solo host that is "the first agent on this host". On a host joined to a shared fleet ledger it is not — the ledger already lists the other hosts' agents, so a new agent there gets no role unless asked for.
Explicitly at add time agents add <name> --admin / --no-admin overrides the default in either direction.
Explicitly afterwards agentctl agents admin <name> on an agent that is already in the ledger; --revoke to remove.
By converging the ledger apply grants the role to every agent whose shard declares admin: true and lacks it. It is additive only: a ledger that declares no admin leaves the live roster untouched, so a half-failed ledger read or an agent-scoped run can never strip every admin and lock the operator out of their own authz path. Removing the role stays an explicit --revoke.

Granting is a ledger write plus a git commit, and the ledger is the same file every host in the fleet syncs. Two things travel with the grant beyond the flag itself:

  • The overlay-write ACL. setfacl -R -m u:<admin>:rwX /opt/agentctl (plus the matching default ACL, so newly created overlay files inherit it). The repo stays root-owned; the admin just gets a POSIX ACL. Without it the admin could read the world-readable overlay but not edit a skill, a hook or a cron — an admin in name only. The encrypted secret store is locked back to root-only on top of the recursive grant. Revoking the role revokes the ACL. The grant is skipped silently when the named admin has no unix account on this host, which is the normal state on a gateway-only host that holds no agents.
  • The terminal marker. apply writes /etc/agentctl/terminal/<agent>.admin for an agent whose entry carries admin: true, and removes it for one that does not. See The cockpit § The terminal.

An agent may not promote itself

agents admin refuses exactly one case: a non-operator agent granting the role to itself. Not "the caller must be an operator" — that broader check would be wrong here, and the reason is worth stating because it is not obvious.

agents admin is an admitted durable host-operation verb, and on both host-operation transports the child runs with the caller identity set to the target agent's own name. So a legitimate operator relaying agents admin X to X's host arrives at this gate looking like a non-admin agent named X — byte-identical, at that point in the program, to an attacker who controls X and wants X to become admin. An operator check would refuse the shipped capability without being able to tell the two apart.

Self-promotion is the slice that matches nothing legitimate, and it is the whole escalation: promoting some other agent buys a compromised agent nothing it did not already have. So that is what is refused. It costs a real operator one hop — agentctl agents admin X run as root, or from any agent that already holds the role, including locally on X's own box, still works, because the ledger is one git-synced file and the verb is not host-local.

How the daemon checks it

caller.operator() in internal/daemon/authz.go, on every request, before anything runs:

  1. uid 0 → operator. Done.
  2. Otherwise, parse the ledger, collect every agent flagged admin: true, and for each one resolve name → user.Lookup → uid and compare against the caller's SO_PEERCRED uid.
  3. No match → self scope, and the verb goes to the allowlist.

Three properties fall out of that shape:

  • It is live. The ledger is re-read per request, so a grant or a revoke takes effect on the next call with no daemon restart.
  • It fails closed. An unreadable or half-parsed ledger yields no admins, so every non-root caller drops to self scope. Nothing falls open.
  • It is resolved against the local passwd database. The admin roster is fleet-wide because the ledger is; the accounts it names are not. An admin agent that has no unix account on a given host matches no uid there, so it holds no scope at that host's agentd. Admin authority is per-host, and reaching another host is a separate mechanism (Architecture § Durable host operations).

Scope and identity stay separate. Only true root gets an empty AGENTCTL_CALLER; an admin agent has operator scope but keeps its own name, so its login self and agents restart-self still resolve to the right agent.

What it unlocks

Surface What changes Enforced where
Every Mutating verb at agentd Any target, including * and all. The self-scope switch is never reached. daemon
Privileged reads of a peer status, doctor, logs, top --json against another agent, and host units. daemon
Local read-only verbs naming a peer resolveSelf mirrors operator() client-side via isAdminCaller. Read-only verbs run in-process and never dial the socket, so without this mirror an admin could mutate the fleet but not inspect it. CLI, client-side
The bare, target-less form of a read usage, creds, timers, doctor, agents status with no target sweep every agent on the host for an admin; for a plain agent resolveTargets narrows the list to itself. CLI, client-side
The fidelity of a privileged read An admin's top --json, doctor, host disk, host units and their siblings are routed to agentd and re-exec'd as root; the same verb from a plain agent stays local and returns a degraded-but-true answer off what that uid can see. Admin changes the route, not just the permission. CLI routing
The fleet repo overlay Read+write on /opt/agentctl via the POSIX ACL above — skills, hooks, crons, the ledger itself. filesystem
The two root-only secret keys AGENTCTL_HOST_OP_SECRET and AGENTCTL_HOST_OP_GRANT_SECRET are refused to every other agent in every scope, self included. An admin may secret get, set and remove both. See below. CLI + daemon
notify admin Becomes a recipient of every fleet alert, alongside every other holder. CLI
Submitting a cross-host operation The gateway's first check on a host-op request is g.admins[source], ahead of the capability HMAC, the target roster and the verb allowlist. webview gateway
The cockpit's fleet surface adminScope/fleetView widen a login identity from one agent to the whole host: the fleet mini-app, per-agent debugging views, restarting any agent from the browser. webview gateway
/terminal/ A login shell as the agent's own user, in the browser. The verb that starts it (terminal up self) is self-scope for every agent; what the role gates is whether the marker terminal@.service asserts on exists at all. gateway + systemd
Host-scope verbs host shell up, host shell grant and host set are operator-only by their absence from the self-scope switch, so an admin reaches them and no other agent does. (host shell tunnel is the exception: it is answered ahead of authorize() against a signed grant, not against the caller's scope — see agentd.) daemon

The cockpit entries are worth reading twice. An admin agent's owner — the person the cockpit authenticates — inherits the fleet surface, so granting the role is also a grant to a human identity, and /terminal/ on an admin agent is, in the guide's own words, "every secret that agent can read, every system it is wired to, and on the admin agent the whole fleet". The launcher hiding an app from a non-admin viewer is a filter, never the boundary; the boundary is the scope gate on each endpoint the app calls.

Where the enforcement points disagree about time

Four independent readers answer "is this agent an admin", and they do not refresh together. This matters most for a revoke, where the slowest reader sets how long the old authority survives.

Reader Source When a change takes effect
agentd reads the ledger per request immediately
the CLI's local self-scope mirror reads the ledger per invocation immediately
the webview gateway the WEBVIEW_ADMINS env var, rendered by apply not until an apply re-renders the env and restarts the gateway
terminal@.service AssertPathExists= on the marker file, written by apply not until the next apply

The gateway reads a rendered value rather than the ledger on purpose: it may run on a host that holds no fleet ledger at all — the deployment where agents dial in to a standalone web-view host — and reading agents.yaml there would resolve to nothing and silently strip every admin of their rights on the one shape that most needs them. A missing or malformed WEBVIEW_ADMINS yields an empty set, which is the fail-closed direction.

The /terminal/ route narrows its own window: the proxy re-derives authority and re-checks the marker on every proxied request, so a revoke that has been applied closes a session already inside its cookie TTL; cross-host, the admin check runs on the agent's own host and is cached at the gateway for 30 seconds.

So a revoke is not one action. agents admin <name> --revoke closes the daemon and CLI paths at once; closing the cockpit's fleet surface and the terminal needs the apply that follows.

What it is not

It is not root — but the gap is narrower than the file modes suggest. An admin agent's uid is an ordinary agent uid with an ordinary agent's filesystem permissions, and it obtains root only by asking agentd, which authorizes the request and re-execs the verb as root itself. That distinction is real wherever something bypasses the hop: the host maintenance shell's socket lives in a 0700 root-owned runtime directory, so an admin agent dialing it directly gets permission denied — which is exactly what happened, on every host, until the connection itself was moved into a daemon verb that passes the descriptor back over SCM_RIGHTS.

It is not a boundary against the role, because everything the daemon will do as root it will do for an admin. The sharpest case is the two keys that sign root executions across the fleet. AGENTCTL_HOST_OP_SECRET and AGENTCTL_HOST_OP_GRANT_SECRET are rendered 0600 root:root and are refused to every other agent in every scope, including self — by the daemon's self-scope check and again by a local gate that deliberately does not stand down under AGENTCTL_DAEMON_EXEC. For an admin, both gates return allow: authorize() answered operator on its first line, and rootOnlySecretDenial exempts an admin caller. So agentctl secret get AGENTCTL_HOST_OP_GRANT_SECRET works from an admin agent.

Model the role as root-equivalent on its host. The file modes buy defence in depth against a bug elsewhere; they do not bound an admin.

It is not an authorization for the model. The role is held by an agent, and an agent's instructions come from text it reads. Anything that reaches an admin agent's context — a document, a web page, an inbound message — is reaching a principal with fleet scope, and per the paragraph above, most of the file-level protections do not stand against that principal. What still stands is what lives outside the agent entirely: the human floor on outbound sends and deletes, the audit trail every mutation commits to the ledger repo, and the fact that a second host's agentd resolves the roster against its own passwd database (Security model).

Which is the argument for granting the role narrowly, and for it being a role rather than a default: an agent that only ever needs to manage itself should not hold it.