Skip to content

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 ENABLED says 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.

  1. Decide whether you want it at all, before a fleet's first apply. agentctl host set host_shell false is fleet-wide with no per-host override.
  2. 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.
  3. Confirm /var/lib/agentctl/hostops exists and is 0700 root:root on every possible target. A host that has not applied since the feature shipped refuses its first session rather than proceeding unaudited.
  4. Verify /etc/agentctl/env/hostop-grant.env is 0600 root:root. A mode of 640 root:agents lets every agent on the box mint itself a root shell grant for any host, as does any hit for AGENTCTL_HOST_OP_*_SECRET in mcp-shared.env. Rotate the key everywhere.
  5. Read agentctl host shell audit periodically. 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.