Skip to content

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 shell reaches a host with zero agents on it. It rides agentctl-host-dial.service, a host-level unit authenticated with the host age key that fleet adopt creates 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:

  1. uid 0 → operator. Done.
  2. Otherwise parse the ledger, collect every agent flagged admin: true, resolve each 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.
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.