The admin role¶
Admin is one boolean on an agent's ledger entry, and it means one thing: that agent's Linux
uid gets operator scope at agentd, the same scope uid 0 has.
# agents.d/<name>.yaml
admin: true
A plain agent is confined to itself — it may log itself in, restart itself, add its own MCPs.
An admin agent is not confined, so its owner drives the whole host through one agent instead
of an SSH key. The gate underneath (SO_PEERCRED, the two scopes, the self-scope allowlist)
is in agentd and the unit model; the operator's short version is in
The fleet view § The admin role.
A role, not a seat¶
Any number of agents may hold admin, and every holder has identical scope. There is no
first admin and no primary admin. agents admin <name> grants the role and takes nothing
from anyone; --revoke removes it from exactly the agent it names.
Nothing resolves "the admin" to one name. notify admin fans out to every holder rather than
matching the first, so an alert about a failed unit reaches every operator.
How it is granted¶
Only explicitly. Nothing grants the role on its own.
| At add time | agents add <name> --admin. Without the flag the new agent is not an admin, whatever else is or is not in the ledger. --no-admin is accepted and is the default. |
| Afterwards | agentctl agents admin <name> on an agent 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 read cannot strip every admin. Removing the role stays an explicit --revoke. |
A fleet may hold no admin agent at all¶
That is a supported state. uid 0 on each host is an operator, so sudo agentctl … does
everything an admin agent could, and nothing else depends on one existing:
host shellreaches a host with zero agents on it. It ridesagentctl-host-dial.service, a host-level unit authenticated with the host age key thatfleet adoptcreates before any agent does. See Reaching a host with no agents on it.- The fleet map is correct without one. The same host-level connection reports its own box's health as root, so a host reports its own truth whether or not any agent on it could, and a host with zero agents reports full health rather than appearing as a bare name. See The fleet view § Where a host's health comes from.
The role exists to let an agent act as an operator. That is a thing to ask for, not a default.
Reaching a host with no agents on it¶
agentctl host shell <host> opens a root shell on a fleet host, and reaching a host that is
not this one means reaching it through a connection that host already holds outbound to
the web-view gateway. There are two such connections, and the shell prefers the first:
| Carrier | Unit | Identity it proves |
|---|---|---|
| The host's own connector | agentctl-host-dial.service — one per host, no template, runs as root |
Possession of /etc/agentctl/age.key, whose public half is in the ledger's host_keys:. The gateway issues a per-connection challenge; asserting the hostname is not enough, because the recipient is public. |
| Any agent on that host | agentctl-webview-dial@<agent>.service — one per agent, User=%i |
A login identity (user_email) the gateway's rendered ledger maps to that agent. The hostname is a claim from a snapshot cache. |
The first carrier is what makes a host with no agents reachable — a gateway host that runs gateways and nothing else, or a host adopted an hour ago whose first agent has not been provisioned yet. That is exactly when an operator needs a shell, because the usual reason such a host has no agent is that provisioning one failed.
Neither carrier is a grant. Both deliver an opaque, signed, 60-second ShellGrant bound to
one target host; the target verifies it against its own hostname, checks the converged marker,
and only then starts the backend. None of the maintenance shell's three gates is on the
carrier — the fleet-wide host.host_shell flag, the marker agentctl-host-shell.service
asserts, and SO_PEERCRED operator scope on the source are all checked independently of it.
The carrier decides how a host is reached, never who may ask.
The gateway authenticates a host against WEBVIEW_HOST_KEYS, rendered from host_keys: by
apply — the same mechanism, and the same staleness rule, as WEBVIEW_ADMINS below: a newly
adopted host can register once an apply has re-rendered the gateway's env and restarted it.
Granting writes the ledger and commits it to git, and the ledger is the same file every host in the fleet syncs. Two things travel with the flag:
| Side effect | What it does |
|---|---|
| The overlay-write ACL | setfacl -R -m u:<admin>:rwX /opt/agentctl, plus the matching default ACL so new overlay files inherit it. The repo stays root-owned; the admin edits skills, hooks and crons through the ACL. The encrypted secret store is locked back to root-only on top. --revoke revokes the ACL. Skipped silently when the named admin has no unix account on this host. |
Not the terminal marker anymore. apply used to write /etc/agentctl/terminal/<agent>.admin
for an agent carrying admin: true and remove it for one that did not. That coupling is
gone: every applied agent now gets the marker unconditionally, so granting the role no
longer grants it, and revoking no longer takes it away. See
Terminal access § There is no revoke.
An agent may not promote itself¶
agents admin refuses exactly one case: a non-operator agent granting the role to itself.
The gate is not "the caller must be an operator". On both cross-host transports — host jobs
and agent administration — the child runs with the caller identity set to the target
agent's name, so a relayed agents admin X arrives looking like agent X.
Granting the role to a peer is allowed and buys a compromised agent nothing new. A real
operator pays one hop: agentctl agents admin X works as root, or from any agent that already
holds the role, including locally on X's own box.
How the daemon checks it¶
caller.operator() in internal/daemon/authz.go runs on every request, before anything else:
- uid 0 → operator. Done.
- Otherwise parse the ledger, collect every agent flagged
admin: true, resolve each name →user.Lookup→ uid, and compare against the caller'sSO_PEERCREDuid. - No match → self scope, and the verb goes to the allowlist.
| Property | Consequence |
|---|---|
| Live | The ledger is re-read per request, so a grant or a revoke takes effect on the next call with no daemon restart. |
| Fails closed | An unreadable or half-parsed ledger yields no admins, so every non-root caller drops to self scope. |
| Resolved against the local passwd database | The roster is fleet-wide because the ledger is; the accounts it names are not. An admin with no unix account on a host holds no scope at that host's agentd. Reaching another host is a separate mechanism (Architecture § Reaching another host). |
Scope and identity stay separate. Only true root gets an empty AGENTCTL_CALLER; an admin
agent keeps its own name, so login self and agents restart-self still resolve to the right
agent. (The cockpit's Connections panel is the same login <provider> <agent> call reached
without a terminal — see MCPs § Credentialed and uncredentialed
MCPs.)
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, plus 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 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 and host units 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. 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. An admin may get, set and remove both; every other agent is refused in every scope, self included. |
CLI + daemon |
notify admin |
Becomes a recipient of every fleet alert, alongside every other holder. | CLI |
| Submitting a cross-host operation | The gateway checks g.admins[source] first, 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 |
| Host-scope verbs | host shell up, host shell grant and host set are operator-only by their absence from the self-scope switch. (host shell tunnel is the exception: it is answered ahead of authorize() against a signed grant — see agentd.) |
daemon |
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. /terminal/ exposes every secret
an agent can read and every system it is wired to on any applied agent, not just an
admin one — and on an agent that also holds admin, that reach extends to the whole
fleet, because agentctl on that agent's own PATH resolves with that agent's own agentd
scope, not because the terminal route treats admin differently. The launcher no longer
filters this particular app at all; the boundary was always the scope gate on each endpoint,
never the icon. See Terminal access.
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 |
The gateway reads a rendered value rather than the ledger. 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. A
missing or malformed WEBVIEW_ADMINS yields an empty set, the fail-closed direction.
So a revoke is not one action. agents admin <name> --revoke closes the daemon and CLI
paths at once; the cockpit's fleet surface closes on the apply that follows. The
terminal used to be a third reader here and is not anymore — it has been decoupled from
the role entirely, so a revoke does not touch it at all, applied or not.
See Terminal access § There is no revoke.
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 ordinary filesystem permissions, and it reaches root only
through agentd, which re-execs the verb as root. The host maintenance shell's socket lives
in a 0700 root-owned runtime directory, so a direct dial gets permission denied; a daemon
verb passes the descriptor back over SCM_RIGHTS.
It is not a boundary against the role. Everything the daemon will do as root it will do
for an admin. AGENTCTL_HOST_OP_SECRET and AGENTCTL_HOST_OP_GRANT_SECRET are rendered
0600 root:root and refused to every other agent in every scope, self included. Two gates
enforce that: the daemon's self-scope check, and a local gate that does not stand down under
AGENTCTL_DAEMON_EXEC. Both return allow for an admin — authorize() answers operator on
its first line, and rootOnlySecretDenial exempts an admin caller.
# works from an admin agent
agentctl secret get AGENTCTL_HOST_OP_GRANT_SECRET
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. An agent's instructions come from text it reads.
Anything reaching an admin agent's context — a document, a web page, an inbound message —
reaches a principal with fleet scope. What still stands is outside the agent: the human floor
on outbound sends and deletes, and the audit trail every mutation commits to the ledger repo.
A second host's agentd resolves the roster against its own passwd database
(Security model).
Grant the role narrowly. An agent that only ever needs to manage itself should not hold it.