Operator-Driven Elevation: Why the Linux Agent Isn't Root
The standard model for an RMM agent on Linux is: install it as root, give it a long-lived API token, and trust the cloud not to issue bad commands. The threat model justifies this design: "we control the cloud, the cloud is well-defended, the agent's authority is bounded by what the cloud will ask it to do." When that assumption holds, the design works.
The 2026-03 RMM-abuse incident I wrote about earlier showed the assumption doesn't always hold. The threat actors were legitimate operators of their own ET Ducky tenants. They signed up, paid, deployed agents to victim machines, and used the agent's remote-shell feature as a C2 channel. The cloud was not compromised. The agents did exactly what they were told. The problem: the agents could be told to do anything.
The Linux side of ET Ducky now has a different design.
The agent runs unprivileged
The Linux agent installs as the etducky user under a hardened systemd unit. It does not have a passwordless sudoers entry. It cannot read /etc/shadow. It cannot install packages. It cannot modify /etc/fstab. It cannot stop or start arbitrary services. The capability bounding set in the systemd unit restricts it to what eBPF capture and operator-elevated commands actually require. cgroup limits cap CPU at 50%, memory at 512 MB, and task count at 256.
The narrowed sudoers drop-in (/etc/sudoers.d/etducky-rmm) grants NOPASSWD only for read-only diagnostics: smartctl, dmidecode, lshw, lspci, lsusb, journalctl, dmesg, and the agent's own service-restart command (systemctl restart etducky-agent). Everything else - every command that would change the state of the host - routes through the operator-elevation flow described below.
This is not a hardening tweak on top of a root agent. It is the only mode the agent runs in.
The elevation flow
When an AI live session generates a privileged command (the operator asks "rename this host to web-04" and the AI responds with sudo hostnamectl set-hostname web-04), the agent does not just run the command. It posts an elevation request to the cloud API with the command summary, the full command text, and a nonce. The backend persists the request to a per-organization table with status pending and a 5-minute expiry, then pushes a Server-Sent Event to the operator's dashboard.
The dashboard pops a modal showing the command and asking for the host's sudo password. The password is a host-local credential, not a cloud credential; it is the same password the operator would have typed if they were SSHing in directly.
The operator types the password and clicks Authenticate. The dashboard POSTs (nonce, password) to the cloud API. The backend validates the nonce age and that the operator's Clerk org matches the agent's org, stamps the row with the operator's Clerk user id, source IP, and user agent, and forwards the password to the agent over the same authenticated SSE channel that delivered the original command. Encryption in transit; never logged; zeroed in agent memory after use.
The agent receives the grant, runs the command via sudo -S bash -c '<cmd>' with the password fed in over stdin, and POSTs the result back to the cloud (exit code, error summary). The audit row's status walks pending - granted - executed or pending - granted - failed. Status never goes back and becomes an immutable record.
What the audit row contains
| Column | Captures |
|---|---|
Nonce | UUID; uniquely identifies the request and is never reused. |
AgentId, OrganizationId, SessionId | Tenant scoping. RLS enforces that operators only see rows from their own org. |
CommandSummary, CommandText | What the operator approved. Both stored, summary for fast display, full text for forensics. |
CreatedAt, ExpiresAt, AuthenticatedAt, ExecutedAt, CompletedAt | Full lifecycle timeline. Lets an operator reconstruct exactly what happened when. |
AuthenticatedClerkUserId | The Clerk user id of whoever typed the password. The forensic anchor. |
AuthenticatedFromIp, AuthenticatedUserAgent | Where they typed it from. Detects "operator account compromised, attacker authenticated from somewhere else". |
Status | pending, granted, denied, executed, failed, or expired. Walks forward; never goes back. |
ExitCode, ErrorSummary | Outcome. The dashboard surfaces these inline so the operator sees whether the command worked. |
Passwords are never stored. Anywhere. Not in the audit row, not in the cloud database, not in agent memory after the command completes. The audit row is metadata about who authenticated what; it is not a credential record.
What this changes about the threat model
If an attacker compromises the agent's service account (stolen bearer token, container escape from a workload running on the same host, lateral movement from another compromised process running as etducky), they get access to what the agent has access to: the host's read-mostly diagnostic surface, the bearer token authenticating it to one specific organization on the cloud API, and the eBPF event stream the agent itself sees. They do not get root.
To run a privileged command, the attacker has to convince a real operator to type a password into the dashboard. That is a human in the loop, with full audit attribution, and the operator sees the command summary before authenticating. An attacker with full agent compromise issuing sudo rm -rf / shows up to the operator as a modal with sudo rm -rf / visible in plain text. Most operators will notice.
If an attacker compromises an operator's dashboard account (stolen Clerk session, phished credentials), they can authenticate elevations from that account. The audit row captures exactly that: the rows for those elevations bear the compromised user's Clerk id, the source IP the attacker was using, and the timestamps. The forensic trail does not point at the agent or the cloud; it points at the compromised account, which is where the response should be focused.
Neither compromise is good. The point is they have different blast radii than an agent running as root with a long-lived NOPASSWD sudoers entry. The audit trail makes the difference observable.
Why password-over-the-wire and not polkit
The current implementation passes the operator's sudo password over the SSE channel and feeds it to sudo -S. That password is encrypted in transit, never logged, and zeroed after use. It is not, however, the design with the smallest blast radius.
It's the current design because it covers every command an operator might want to run. The next iteration replaces sudo -S for systemd-managed actions (hostnamectl, systemctl, timedatectl, networkctl, journalctl) with polkit rules and D-Bus calls. The dashboard still prompts the operator at the moment of action, but the answer becomes "yes/no" instead of a credential. This removes the password from the wire for the most common cases. The follow-up does the same for package management via PackageKit's D-Bus interface.
Both of those land in the next round. The current design is the floor of the security story, not the ceiling.
The Windows side
The Windows agent runs as LocalSystem for ETW kernel access. Operator-attributed elevation on Windows is on the same roadmap as the polkit migration on Linux: the request/grant/audit shape is the same, the on-host execution mechanism becomes UAC consent (for interactive operator approval) or Windows Service Manager APIs (for service-managed actions). The audit row with Clerk-user attribution, source IP, and exit code becomes the same shape regardless of OS.
For now, the Windows agent's privilege model is the standard "service runs as LocalSystem, trust the cloud not to issue bad commands" model. The Linux side leads on this work because the unprivileged-user model is more natural on Linux, but the goal is feature parity.
Try the agent yourself
Deploy in minutes. The Linux agent runs unprivileged from the moment it installs.
Get Started Free