agentd and the unit model¶
agentd is the root daemon that owns config, secrets and the reconcile, and is the privilege
boundary. An agent's unprivileged Linux user requests a root operation through it instead of
holding root or a sudoers drop-in. The CLI is a thin client to it.
| Binary | /usr/bin/agentd |
| Unit | /lib/systemd/system/agentd.service — Type=notify, Restart=on-failure |
| Enabled by | agentctl provision |
| Runs as | uid 0; refuses to start as anything else |
| Owns | /etc/agentctl, the age key, the reconcile |
| Socket | /run/agentctl.sock — root:agents, 0660 |
The socket¶
agentd creates the socket at startup, removes any stale one, then chowns it to group
agents and chmods it 0660. Agent users get useradd --groups agents. Without an
agents group — an unprovisioned host — the socket stays root:root.
The mode is a coarse first gate, not the authorization. A uid outside agents fails at
connect(2) with EACCES; the CLI reports that as an unreachable socket.
SO_PEERCRED: what actually identifies the caller¶
proto.Request carries a verb, args and optional stdin. There is no token, no caller field,
and no header.
On accept, before reading the request, the daemon calls
syscall.GetsockoptUcred(fd, SOL_SOCKET, SO_PEERCRED) and keeps Ucred.Uid. The kernel
stamped that uid at the client's connect(2); the client cannot set or change it. That uid is
the entire trust root.
The uid resolves to a username through the passwd database (best-effort). The name compares a
verb's target against the caller and is threaded into the reconcile as AGENTCTL_CALLER. It
never decides scope — the uid does.
| Scope | Caller | Verbs |
|---|---|---|
| Operator (full) | uid 0, or an agent flagged admin: true in agents.yaml (name → user.Lookup → uid) |
all |
| Self | every other uid | an allowlist, each verb naming the caller itself |
agents.yaml is re-read on every request, so a promote or demote takes effect without
restarting the daemon. See The admin role.
Scope and identity are separate. Only true root gets an empty AGENTCTL_CALLER. An admin
agent has operator scope but keeps its own name, so login self and agents restart-self
resolve to the right agent.
The verb surface¶
internal/proto/verbs.go classifies every canonical verb. Classification, not the daemon,
decides whether a call leaves the client's process at all.
| Class | Count | Where it runs |
|---|---|---|
Mutating |
110+ | Always through agentd. Authorized, queued, re-exec'd as root. |
PrivilegedRead |
48 | Locally for a self target; through agentd only for a peer or fleet-wide target. |
| everything else | — | In-process in the client, no daemon hop. |
Mutating is a large, actively-edited map — its exact size drifts as verbs are added, so treat
the count above as approximate and internal/proto/verbs.go as the exhaustive list.
PrivilegedRead is much smaller and changes rarely.
Two entries are easy to misread:
- Every
secretsubcommand isMutating, includinggetandlist.agentdowns the age key; running them locally in an agent's client would bypass the gate. provisionis deliberately absent fromMutating. It installs the daemon, so it runs locally as root.
A privileged read is answered outside the reconcile queue and outside the converge lock, so reads neither block nor are blocked by mutations. It is authorized to operator scope before re-exec as root. A non-admin agent reading a peer's home is denied at the daemon.
Six verbs are answered by the daemon itself with no reconcile: queue, ping, apply logs,
auth-register, auth-callback, host shell attach.
The self-scope allowlist¶
For a non-operator caller, authorize() is a switch over the verb with a default deny.
A verb that appears in no case is refused unconditionally, including for self. For
host shell up, host shell tunnel, host shell grant and host set, the absence is the
gate, and a test pins it.
What a plain agent may do:
| Verbs | Rule |
|---|---|
login, agents restart, agents restart-self, agents stop-self, agents trigger, chrome restart, terminal up, inject, notify, render, render cancel, apply, mcp add/remove, agents skills\|subagents\|hooks add/remove, subsession start/resume/clear/rm/rename/prompt, ws write/mkdir/rm/mv, attachment fetch |
Target must be self or the caller's own username. * and all are refused. |
cron add, cron remove |
--host refused; --agent <someone-else> refused. A self-scoped cron is bound to User=<caller> by the daemon. |
secret set, secret get, secret remove |
Must carry --agent self. A bare (unscoped) secret op targets the shared store and is operator-only. The two root-only keys that sign root executions are refused in every scope, including self. secret list is not self-scoped at all. |
agent-message |
Any recipient; only the fleet wildcard is refused. The sender is the peercred name, not an arg. |
host shell audit-open, host shell audit-close |
No target check. They append a row to the audit trail and start nothing; the actor is the peercred name. |
Catalog authoring (tool catalog add, the * catalog add verbs) is
operator-only. Agents activate from the catalog for themselves.
What happens if you talk to the socket as the wrong user¶
Every layer fails closed. The gate runs before the FIFO queue and before any state change.
| Condition | Result |
|---|---|
uid outside the agents group |
connect(2) fails with EACCES; the daemon is never involved |
SO_PEERCRED unreadable |
one stderr frame, one exit frame with code 1, before the request line is read |
| uid with no passwd entry | denied: uid N is not a resolvable user |
| non-operator, verb not in the allowlist | denied, with the full self-scope list in the message |
non-operator, allowlisted verb, target is another agent (or * / all) |
denied, naming both the caller and the refused target |
agents.yaml unreadable |
no agent has operator scope; every non-root caller falls to self scope |
A denial is one stderr frame and a terminal exit frame, exit code 1. Host-operation
denials use 126.
The two exceptions, and what replaces the uid check¶
Three request kinds are authorized by a signed capability rather than by the caller's uid.
The carrier is a courier — the target host's own agent connector, which runs under
agentctl-webview-dial@.service with User=%i, an ordinary agent uid. A peercred operator
check refuses exactly that process.
host shell tunnelroutes toauthorizeHostShellTunnelinstead ofauthorize(). The gate is ahostop.ShellGrantsignature bound to this host's own name. Root mints it on the source host throughhost shell grant, operator-only by its absence from the switch above. The gate also strips--gatewayfrom the caller's args: a grant permits a tunnel to this host, not a splice point for the root PTY.host op execand the durable host-op relay are answered beforeauthorize()and never call it. The gate is an HMAC capability keyed on/etc/agentctl/env/hostop-capability.env(0600 root:root), plus a check that the capability's target names the connected agent. Any agent can name itself, so the signature carries the whole gate — sound only while the key is unreadable by an agent. A host without the key refuses every incoming job rather than falling back. See Architecture § Reaching another host.
How a client reaches it¶
One request per connection, newline-delimited JSON both ways:
→ {"verb":"...","args":[...],"async":false,"stdin_b64":"..."} one line, then the client reads
← {"type":"stdout"|"stderr"|"info"|"accepted","data":"..."} zero or more frames
← {"type":"exit","code":N} terminal, carries the real exit code
agentctl picks its route per verb, in runNode:
AGENTCTL_DAEMON_EXEC=1set → run locally. This is the reconcileagentdspawned, already root; dialing back would recurse forever.Mutatingverb → dial the socket.--async/--no-waitasks the daemon to register the request and return a request id instead of streaming.- Privileged read naming a different agent → dial. If the socket is unreachable the client errors rather than falling through to a local read.
- Everything else → local.
An agent reaches the daemon by running the same agentctl binary as its own user: no sudo, no
setuid, no separate agent-facing API. The cockpit's op-relay is the same path once more
removed. The gateway asks a dialed-in agent's connector to run a whitelisted verb; the
connector runs it as that agent's uid.
Once authorized, agentd re-execs agentctl <verb> <args> as root in its own process group,
with AGENTCTL_DAEMON_EXEC=1 and AGENTCTL_CALLER=<name>, and streams the subprocess's
stdout and stderr back as frames. If the client disconnects, the daemon cancels the context
and kills the whole process group rather than orphaning the reconcile.
Serialization is a separate concern, covered in Architecture: an in-process
FIFO ticket, then a host-wide flock on /run/agentctl/reconcile.lock. A directly-run apply
and a durable host-op worker take the same lock.