agentd and the unit model¶
agentd is the small root daemon that owns config and secrets, runs the reconcile, and is
the privilege boundary. It exists so that an agent's unprivileged Linux user can request
a root-requiring operation without holding root, and without a sudoers drop-in that would
give it one. The CLI is a thin client to it.
It ships as /usr/bin/agentd with /lib/systemd/system/agentd.service (Type=notify,
Restart=on-failure), enabled by agentctl provision. It refuses to start as anything but
uid 0 — it owns /etc/agentctl, the age key, and the reconcile.
The socket¶
agentd creates /run/agentctl.sock itself at startup, after removing any stale one, then
chowns it to group agents and chmods it 0660. Agent users are created with
useradd --groups agents, so every agent on the host can connect and nothing else can.
If the agents group does not exist — an unprovisioned host — the socket is left
root:root. That is the safe direction: only root connects.
The mode is a coarse first gate, not the authorization. A uid outside the agents group
fails at connect(2) with EACCES before the daemon sees anything; the CLI reports that as
the socket being unreachable and suggests checking whether agentd is running, which is
misleading wording for a permission failure but is a closed door either way.
SO_PEERCRED: what actually identifies the caller¶
There is no token, no caller field, and no header. proto.Request carries a verb, args, and
optional stdin — nothing that names who is asking.
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 onto the socket at the client's connect(2); the client cannot set it,
change it, or lie about it. That uid is the entire trust root.
The uid is then resolved to a username through the passwd database (best-effort). The name is
used for two things: comparing a verb's target against the caller, and being threaded into
the executed reconcile as AGENTCTL_CALLER. It is never used to decide scope — the uid is.
Two scopes, resolved per request:
- Operator (full) scope — uid 0, or the uid of an agent flagged
admin: trueinagents.yaml. Admin resolution goes name →user.Lookup→ uid comparison, andagents.yamlis re-read on every request, so a promote or demote takes effect without restarting the daemon. See The admin role. - Self scope — every other uid. An allowlist of verbs, each of which must name the caller itself.
Scope and identity are deliberately separate. Only true root gets an empty
AGENTCTL_CALLER; an admin agent has operator scope but keeps its own name, so its
login self / agents restart-self still resolve to the right agent.
peerCred has a non-Linux build-tag stub that always returns an error. The daemon only runs
on Linux — SO_PEERCRED has no portable equivalent — and the stub exists so the package
compiles and tests on a macOS dev box. It fails closed: no verifiable uid, nothing
authorized.
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 |
108 | 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. |
Two entries in that table are load-bearing and easy to misread:
- Every
secretsubcommand isMutating, includinggetandlist. Not because they mutate, but becauseagentdowns the age key: if they ran locally in an agent's client they would bypass the gate entirely. The classification is the security property. provisionis deliberately absent fromMutating. It installs the daemon, so it can never route through it. It runs locally as root.
A privileged read is answered outside the reconcile queue and outside the converge lock — reads must neither block nor be blocked by mutations — and is authorized to operator scope before being re-exec'd as root. A non-admin agent asking to read a peer's home is denied at the daemon, mirroring the CLI's own local self-scope gate.
Six verbs are answered by the daemon itself with no reconcile at all: queue, ping,
apply logs, auth-register, auth-callback, and 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. That is not
an oversight in the verbs it omits; for host shell up, host shell tunnel,
host shell grant and host set, the absence is the gate, and a test pins it so a future
edit cannot quietly add a case.
What a plain agent may do, and the shape of each check:
| 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, tool 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 — the verb exists to address a peer. Only the fleet wildcard is refused. The sender is not in the args; it is the peercred name. |
host shell audit-open, host shell audit-close |
Allowed with no target check. They append a row to the audit trail and start nothing; the actor is derived from the peercred name, not from the args. |
Catalog authoring (tool catalog add, tool wrap-mcp, the * catalog add verbs) stays
operator-only. Agents activate from the catalog for themselves; the operator curates it.
What happens if you talk to the socket as the wrong user¶
Every layer fails closed, and none of them execute anything first — the gate runs before the FIFO queue and before any state change.
- uid outside the
agentsgroup —connect(2)fails withEACCES. The daemon is never involved. SO_PEERCREDunreadable — onestderrframe, oneexitframe with code 1, before the request line is even 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, but the target is another agent (or
*/all) — denied, naming both the caller and the refused target. agents.yamlunreadable — the admin lookup returns nothing, so no agent has operator scope and 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 instead.
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. This is not a relaxation of the boundary; it is the boundary answering a question the uid cannot answer.
The reason is the same in all three cases. The only process that can legitimately carry these
requests is a courier — the target host's own agent connector, which runs under
agentctl-webview-dial@.service with User=%i, i.e. an ordinary agent uid, deliberately and
non-negotiably (an always-on connector must not run as root). A peercred operator check
refuses exactly that process, and did: every cross-host shell hung silently until the gate
moved.
host shell tunnelis routed toauthorizeHostShellTunnelinstead ofauthorize(). The gate is ahostop.ShellGrantsignature bound to this host's own name, minted on the source host by root throughhost shell grant— which is operator-only, by its absence from the switch above. So the operator check was relocated to the end of the chain where an operator actually stands, not deleted. The gate also strips--gatewayfrom the caller's args: a grant says a tunnel may be opened to this host, and says nothing about where the resulting root PTY may be spliced to.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. The target check alone is answerable by any agent naming itself, so the signature carries the whole gate — which is sound only while the key is unreadable by an agent. A host that has upgraded but not applied has no key, and refuses every host operation rather than falling back. See Architecture § Durable host operations.
How a client reaches it¶
The wire protocol is one request per connection: the client writes a single JSON line
({verb, args, async, stdin_b64}) and reads newline-delimited frames back —
stdout / stderr / info / accepted — until a terminal exit frame carrying the real
exit code.
agentctl picks its route per verb, in runNode:
AGENTCTL_DAEMON_EXEC=1set → run locally. We are the reconcileagentdspawned, already root. Dialing back into the socket 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 that would return empty on permissions.
- Everything else → local.
An agent reaches the daemon by running the same agentctl binary as its own user. There is
no sudo, no setuid, and no separate agent-facing API — the agent and the operator use one
client and are told apart by the kernel. 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, and it meets this gate like any other call.
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 — Ctrl-C, hangup — the daemon
cancels the context and kills the whole process group rather than orphaning the reconcile.
Serialization is a separate concern from authorization and is covered in
Architecture: an in-process FIFO ticket, then a host-wide flock on
/run/agentctl/reconcile.lock that a directly-run apply and a durable host-op worker take
as well.
TODO: needs authoring¶
A page-level treatment of the unit model specifically: the full set of
units a provisioned host carries and which layer owns each one (agentd.service;
per-agent agent@<name>.service / pi-agent@<name>.service, browser@, xvnc@,
openbox@, the agentctl-*-watch@ watchers, the per-agent agentctl-<job>@ timers; the
host units — agentctl-hostcron-<label>, agentctl-notify-failure@,
agentctl-portguard, the gateways, agentctl-hostop-coordinator and the
agentctl-hostop@ / agentctl-subsession@ templates), where each unit file comes from
(/usr/share/agentctl/units/{agent,host}/), how the override template is rendered, and
what apply restarts versus leaves alone. Grep internal/converge/host.go and
packaging/payload/units/ before writing this.