Host maintenance shell¶
agentctl host shell opens a root maintenance shell on this host; agentctl host shell
<host> opens one on another host in the fleet. It needs a real TTY, so no cockpit mounts it,
and it is on by default. Whoever reaches it holds uid 0 on the machine every agent on
that host lives on: every secret, every agent identity, the fleet's configuration.
| systemd unit | agentctl-host-shell.service, one per host, not templated |
| shell runs as | root |
| reached from | a real TTY, CLI only |
| enabled by | the ledger's host.host_shell — fleet-wide and opt-out |
| default | on; an unset flag means enabled |
| marker file | /etc/agentctl/host-shell.enabled, root:root 0644 |
| socket | /run/host-shell/tty.sock, in a 0700 dir owned by root |
| tmux session | maint |
| backend | vendored static ttyd and tmux, started with -b /host-shell |
| audited | yes — a durable row before the PTY; a failure to audit refuses the session |
The unit has no [Install] section, so systemctl enable fails on it. It carries no
ProtectSystem, PrivateTmp or NoNewPrivileges.
The three gates¶
Independent, listed weakest first.
| # | gate | what it is |
|---|---|---|
| 1 | ledger flag host.host_shell |
a fleet-wide kill switch, not a grant. Unset, true, 1, yes or on all mean enabled |
| 2 | marker /etc/agentctl/host-shell.enabled |
AssertPathExists= in the unit; the enforcement that survives every line of Go being wrong |
| 3 | SO_PEERCRED operator scope |
uid 0, or the uid of an agent flagged admin: true — the predicate that gates host set |
| disabling | agentctl host set host_shell false, fleet-wide, at each host's next apply. No per-host override |
| unreadable ledger | reads as enabled; peercred scope and the 0700 socket directory still apply |
| what converge removes | the marker and /etc/systemd/system/agentctl-host-shell.service, with its own daemon-reload |
| how gate 3 is enforced | by absence: host shell up, grant and attach are in no case of authorize()'s self-scope switch |
| the result | a non-operator falls through to the default deny |
| test pinning that list | internal/daemon/authz_hostshell_test.go |
Containment beneath the gates. The socket sits in a 0700 root-owned runtime directory, so no agent uid can open it even holding the path. The gates stand between an operator and the shell, not between an arbitrary local process and it.
Converge also creates /var/lib/agentctl/hostops, 0700 root:root, where the audit store
lives. Turning the flag off removes the marker and the unit and leaves the audit
directory.
Unit differences from terminal@.service¶
no User=, no WorkingDirectory= |
the shell is root, and the runtime directory is root-owned |
session name maint |
a bare tmux ls can tell the two features apart |
| marker asserted | the host marker, not a per-agent one |
| not templated | concurrent operators attach to the same tmux session and see the same screen |
RuntimeDirectory 0700, RuntimeDirectoryPreserve=yes, Restart=no, KillMode=process,
RuntimeMaxSec=12h, TasksMax=256 and the absence of sandboxing are identical — see
Terminal access § Unit settings.
Opening one on this host¶
agentctl host shell
Order: check the marker → host shell up → attach → write the audit row → run the PTY. The
marker is checked first so that a disabled host names the ledger flag instead of failing on a
socket.
host shell up |
proto.Mutating, operator-only. The daemon authorizes the caller by peercred, then elevates to root to start the backend and hand the live connection back to the CLI |
| readiness probe | tri-state: up / down / unknowable. A non-root dial into the 0700 root-owned socket directory returns EACCES on a perfectly healthy backend, so "unknowable" is not a failure — it is containment working as designed |
| after the handoff | agentd is out of the path; an agentd restart mid-session leaves the shell undisturbed |
| root-side re-check | the marker, because a preserved runtime directory can outlive the revoke |
| a root caller | dials directly, never hops agentd, and so still works when the daemon is down |
Opening one on another host¶
This needs host.webview_gateway set — the fleet's only inbound channel to a target host is
that host's own agent connector.
The source mints a grant (operator-scope only) that names one target host and expires in
60 s. The gateway relays it to the target's own agent connector as a doorbell; that
relay hop runs as an ordinary agent uid, never root, so it cannot open a shell by itself —
it only carries the request. The target independently verifies the grant against its own
real hostname before it checks the marker, starts its backend, and splices the tunnel back;
a grant issued for the wrong host is refused there, not at the gateway. The token travels on
the mint's stdout, never as a CLI argument, so it does not appear in ps output on any
machine it passes through. An invalid or expired grant costs one refusal and starts no
process, and every refusal is reported back to the operator over the tunnel. The relay token
is single-use, so a scraped one is good for nothing more than reaching the real gateway,
where it still has to pass the target's own check.
The audit trail¶
A row is written before the PTY is joined, and a failure to audit refuses the session.
| store | /var/lib/agentctl/hostops/host-shell.db, in a 0700 root:root directory |
| id | shell-<hex>, so a query tells a shell session from a dispatched host operation |
| actor | from peercred (AGENTCTL_CALLER), never from argv |
| authz snapshot | JSON: caller, source, local, and a digest of the grant |
| empty digest | a local session, explicitly — nothing crossed a host boundary |
| end states | completed / failed / lost |
lost |
not a failure: a session killed by an OOM, a reboot or a SIGKILL |
| session bytes | recorded nowhere; the PTY stream is direct, ephemeral and unjournaled |
| read it | agentctl host shell audit — open sessions bare, or one session's trail by id |
The write hops agentd (host shell audit-open / audit-close, self-scope, no target
check) for any caller that cannot write the root-owned store directly. Minting a grant is
operator-scope.
Turning it off¶
agentctl host set host_shell false
sudo agentctl apply # ON EACH HOST
Until a host applies, it is still serving. Once it does, the marker and the unit file are both gone. A session that is already open survives the revoke; to end one now, on that host:
systemctl stop agentctl-host-shell.service
tmux -S /run/host-shell/tmux.sock kill-server
agentctl host set host_shell prints both of those itself. Restore the default with
agentctl host set host_shell "".
Diagnostics¶
agentctl host shell status [<host>]
Read-only. It walks the same gate sequence the real path walks, in the same order, and starts nothing.
- It reports the backend as "not observable as \<user>" rather than "not running" when the caller cannot see into the 0700 directory. That is the containment working; re-run as root for a real answer.
- For a remote host it prints the fleet-wide ledger flag and states that converged state is
not observable from here. The flag reading
ENABLEDsays nothing about whether that host converged the feature.
When a session fails¶
Each gate names its own remedy in the error it returns — read it literally rather than treating it as a generic "not working."
Local (agentctl host shell):
| gate | what you see | fix |
|---|---|---|
| 1 — ledger flag off | ... it is DISABLED FLEET-WIDE by host.host_shell = "false" in the ledger |
agentctl host set host_shell "", then sudo agentctl apply |
| 2 — marker missing | the maintenance shell is enabled fleet-wide but has not been converged on <host> yet — the marker ... does not exist |
sudo agentctl apply on that host |
| 3 — not operator scope | denied: agent "<name>" may not run "host shell up" ... |
run it as an admin agent, or from a root shell |
Cross-host (agentctl host shell <host>) adds checks on both ends of the tunnel:
| where | what you see | meaning |
|---|---|---|
| source, minting the grant | this host has no dispatch-grant key (...), so it cannot authorize a cross-host shell |
this host has not applied since the key was rendered — same fix as gate 2 |
| source, wrong caller | opening a shell on another host is operator-scope, and agentd refused this caller |
the same operator-scope check as local gate 3, run before a grant is even minted |
| target, wrong host | issued for target "X", presented at "Y" — a shell grant is bound to one host and cannot be replayed at another |
the grant was minted for a different host than the one it reached — check the target name |
| target, too slow | expired at <time> (now <time>) |
more than 60 s passed between minting the grant and the target verifying it; retry |
| no carrier | no agent from host "X" is dialed in to this gateway, or <host> is declared ... as its ... gateway, and a gateway host runs no agents |
the target host has no agent dialed in to relay the doorbell — a routing gap, not a permission refusal. Reach that box directly (console or ssh) until it has a carrier |
Every one of these is reported back to the caller over the tunnel. A hang instead of a
message usually means the target is running an agentctl build that predates this route.
host shell or host job run¶
host job run |
host shell |
|
|---|---|---|
| use it for | attributable, reproducible, cancellable actions | diagnosis and recovery |
| record kept | a receipt persisted before anything runs; output journaled per job | that a session happened, and who opened it |
| retention | 72 h of output, 14 d of metadata | audit rows only, no session bytes |
| survives | an agentd restart, a gateway bounce, a reconnect |
nothing; the PTY dies with its transport |
| cancellable by | the source that submitted it | — |
| from the cockpit | yes | no |
| needs | a connector and a capability key on the target | nothing, for a root caller on the local host |
A host missing /etc/agentctl/env/hostop-capability.env refuses every incoming job rather
than falling back. Every privileged action taken in a root shell is, by construction, an
action with no command-level record. See
Reaching another host.
Before you enable it¶
The shell is already on, so the first question differs from the per-agent terminal's.
- Decide whether you want it at all, before a fleet's first
apply.agentctl host set host_shell falseis fleet-wide with no per-host override. - Audit who holds the admin role. That set is exactly the set of non-root identities that can open a root shell on any host.
- Confirm
/var/lib/agentctl/hostopsexists and is 0700root:rooton every possible target. A host that has not applied since the feature shipped refuses its first session rather than proceeding unaudited. - Verify
/etc/agentctl/env/hostop-grant.envis0600 root:root. A mode of640 root:agentslets every agent on the box mint itself a root shell grant for any host, as does any hit forAGENTCTL_HOST_OP_*_SECRETinmcp-shared.env. Rotate the key everywhere. - Read
agentctl host shell auditperiodically. It is the only record that a root shell was opened, and nothing in it records what was done.
What the admin role adds here¶
| plain agent | admin agent | |
|---|---|---|
host shell up / grant / attach |
absent from the self-scope switch → default deny | allowed by caller.operator() |
| a root shell on a fleet host | none | this host, and any host reachable through the gateway |
Compromising an admin agent is equivalent to compromising the fleet. It can read every agent's secrets, mint host-shell grants and run privileged verbs against every host. It does all of that as a uid with no sudoers entry, no setuid binary and no capabilities. See The admin role and Terminal access.