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.
applywrites/etc/agentctl/terminal/<agent>.adminfor an agent whose entry carriesadmin: 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:
- uid 0 → operator. Done.
- Otherwise, parse the ledger, collect every agent flagged
admin: true, and for each one resolve name →user.Lookup→ uid and compare against the caller'sSO_PEERCREDuid. - 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.