Skip to content

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 secret subcommand is Mutating, including get and list. agentd owns the age key; running them locally in an agent's client would bypass the gate.
  • provision is deliberately absent from Mutating. 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 tunnel routes to authorizeHostShellTunnel instead of authorize(). The gate is a hostop.ShellGrant signature bound to this host's own name. Root mints it on the source host through host shell grant, operator-only by its absence from the switch above. The gate also strips --gateway from the caller's args: a grant permits a tunnel to this host, not a splice point for the root PTY.
  • host op exec and the durable host-op relay are answered before authorize() 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=1 set → run locally. This is the reconcile agentd spawned, already root; dialing back would recurse forever.
  • Mutating verb → dial the socket. --async / --no-wait asks 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.