- Remove patch_localhost.py (one-off patch script, served its purpose) - Remove RECOVERY.md + docs/RECOVERED_COMMITS.txt (July-6 incident artifacts) - Move completed audits and Jan-era strategy docs to docs/audits/ - Update .gitignore for relocated UI_CONSISTENCY_AUDIT.md
24 KiB
ServerKit Agent Subsystem — Security & Completeness Audit
Migration note: The Go agent source code has moved to its own repository at
github.com/jhd3197/serverkit-agent. This audit remains historically accurate but the file paths it references underagent/no longer exist in the ServerKit monorepo.
Date: 2026-06-12
Scope: The multi-server agent fleet end-to-end — Go agent (agent/), panel
gateway/registry/long-poll (backend/app/agent_gateway.py,
services/agent_registry.py, api/agent_poll.py), pairing/enrollment, the
panel→agent command APIs (api/servers.py, fleet.py, agent_plugins.py),
install/packaging, the fleet UI, and docs/CI.
Method: Direct read of the security-critical paths plus four parallel
breadth scans. Every load-bearing finding below was verified in source; one
widely-reported claim (committed MSI bloat) was checked and rejected as a
false positive (see Appendix B).
TL;DR
The agent's core security primitives are genuinely well built — HMAC+nonce replay protection on both transports, fail-closed signature verification on the panel, symlink-resolved file allowlisting, shell-metacharacter rejection on every command validator, exec/terminal gated off by default, a localhost-bound IPC API with a constant-time bearer check, and a solid pairing flow (Ed25519 proof-of-possession, lockout, one-shot credential delivery).
The problems are concentrated in three areas:
- The self-update path is the weakest link and bypasses every other control — no code-signing, and checksum verification is optional and fail-open. Whoever can send a command to an agent can run code as root on it, even with exec and package management disabled.
- An entire advertised feature surface is non-functional — the Agent Plugin system and Server Template drift/remediation call a panel function that does not exist, and the agent has no handlers for them. The API, DB, and UI imply a working sandboxed-plugin capability that silently does nothing.
- Authorization is thinner at the edges than in the core — the Socket.IO layer has no per-object/role checks, and a few state-changing endpoints are under-gated.
No agent code is exercised by CI.
Severity counts: 3 High, 5 Medium, 7 Low/Informational.
Remediation status (2026-06-12)
- M1 — fixed.
sockets.pynow resolves the JWT to a live, active user at connect and records the role;subscribe_terminaland the genericjoin_room(for_terminal:rooms) require admin/developer, matching the REST@developer_requiredon terminal creation. Job/cloudflared streaming is unaffected. - M2 — corrected + hardened. The compose half was a false alarm: every
compose handler already runs the symlink-resolved AllowedPaths check
(
a.validateFileAccess) before calling the docker client (see the corrected M2 entry below). Added a doc-contract comment onvalidateProjectPathso a future caller can't bypass it. Cron is intentionally not AllowedPaths- restricted (that would break legitimate jobs like/usr/bin/certbot renew); its blast radius is addressed by M3 instead. - M4 — fixed. Added
.github/workflows/agent-ci.yml(go vet + go test + cross-compile build matrix for linux amd64/arm64 and windows/amd64 — darwin excluded because the tray dep can't cross-compile from Linux) andbackend-ci.yml(full pytest suite). Also addedbackend/tests/test_agent_poll_e2e.py, an end-to-end test that drives the real panel↔agent command loop (HMAC auth → register → queue → poll → result → the synchronous waiter resolves) over the poll transport, which shares the WebSocket path's auth/registry/routing code. This is the "prove it actually works" net the subsystem lacked. - M5 — fixed. The
registercommand now reads the token (and connection-string / server URL) from the environment (SERVERKIT_REGISTRATION_TOKEN, etc.) when the flags aren't given, so the installers pass the token via env instead of argv — env is owner/root-only (/proc/<pid>/environ), whereas argv is world-readable (ps,/proc/<pid>/cmdline).install.sh(env-passed register,evalremoved) andinstall.ps1(token via env, made optional) updated accordingly, and both accept the token from the environment so it need not appear in thecurl | bash -s -- --token …line / shell history. Verified: the agent reads the env token at runtime, both scripts pass syntax checks. - M3 — fixed. The DEB/RPM units now run as the dedicated unprivileged
serverkit-agentaccount with the same hardening asinstall.sh(NoNewPrivileges=yes,ProtectSystem=strict,ProtectHome=yes,PrivateTmp=yes). Tradeoff: packaged agents now match the script installer's capability set — Docker + metrics + file access — and no longer do systemd/package management or agent self-update by default (both need root). Self-update being disabled also blunts H1 for packaged installs: updates flow through the OS package manager's signed path. To keep system-management by default, run as root deliberately (revertUser=in the build scripts) or configure passwordless sudo for the account.
Highs
- H1 — fixed (verification side).
updater.gonow fails closed: an update with no checksums URL is refused;matchChecksumerrors when no entry matches this platform (was a silent skip); the checksums fetch checks HTTP status; andrequireSecureURLrejects non-HTTPS update/checksum URLs (dev override viaSERVERKIT_INSECURE_TLS). Covered byinternal/updater/updater_test.go(6 tests). Still recommended: cryptographic release signing (minisign/cosign) with a public key pinned in the agent — that needs the project's signing key + a release-pipeline change, so it's the remaining H1 step. (Packaged installs are already protected: non-root agents can't self-update, so updates go through the OS package manager.) - H2 — fixed (made honest). Removed the dead
get_agent_gateway()calls inagent_plugin_service.pyandserver_template_service.py. Plugin install now reportsSTATUS_ERROR("Agent-side plugin support is not implemented yet") instead of hanging ininstalling; template drift-check/remediate reportunknown+ an explanatorydrift_report.error; local-only ops (disable, uninstall, configure) just drop the dead dispatch. Covered bytests/test_agent_features_honest.py(3 tests). Implementing the feature for real (agent handlers + sandbox) remains future work, but it no longer lies. - H3 — fixed (earlier this session):
POST /<id>/agent/updateis now@developer_required; test intests/test_agent_update_authz.py.
Lows
- L1 — fixed. Pairing
/claimand/lookupnow require developer role (tests/test_pairing_authz.py), matchingPOST /servers. - L2 — documented.
SECURITY.mdnow states explicitly thatagent.keyencryption is off-host/tamper protection, not confidentiality against local root, and that the0600perms are the real control. - L3 — fixed. Removed the dead "Add Version" / per-row Edit / Delete buttons
from
AgentFleet.jsx(no backend edit/delete route exists); version list intact, unused imports cleaned, no new lint errors. - L4 — accepted (by design). Capabilities are OR-merged and not cleared until restart on purpose, to avoid feature flicker from transient probe failures; reverting would reintroduce that bug.
- L5 — accepted (documented). The single-worker requirement is stated in
SECURITY.md/ARCHITECTURE.md/CLAUDE.mdand enforced byrun.py's-w 1; reliable in-process detection of a multi-worker misconfig isn't practical, so it stays a documented operational constraint. - L6 — accepted (low). The Windows updater builds a
.batviafmt.Sprintfwith local, non-attacker-controlled paths; not worth the churn. - L7 — clarified. The
verify_agent_auth"TODO" is reworded to an accepted limitation (per-message token validation is defence-in-depth, not a bug; the channel is TLS-authenticated and connect is HMAC+nonce verified).
HIGH
H1 — Agent self-update has no code-signing and checksum verification is fail-open (supply-chain RCE)
The update path is the single highest-leverage hardening target because it runs
as the agent process itself (root on packaged installs) and is reachable
even when Features.Exec=false and package handlers are unavailable — it
bypasses every other feature gate.
Three independent weaknesses in agent/internal/updater/updater.go:
- Checksum is optional.
DownloadUpdateonly verifies when a URL is present:Empty// updater.go:167 if info.ChecksumsURL != "" { if err := u.verifyChecksum(ctx, archivePath, info.ChecksumsURL); err != nil { ... } }checksums_url→ install with no verification. - Verification is fail-open. When no checksum line matches the file, it
logs a warning and returns success:
// updater.go:367-370 if expectedHash == "" { u.log.Warn("Could not find checksum for downloaded file, skipping verification") return nil } - The checksum fetch ignores HTTP status (
updater.go:316-320). A 404 HTML body parses into an empty map →expectedHash == ""→ skipped.
Even when it runs, the checksum file is fetched over the same channel as the
binary with no signature (no GPG/cosign/minisign), so anyone who can swap
the binary can swap the checksums to match. The download URL is fully
attacker-controlled — handleAgentUpdate takes it straight from the command:
// agent/internal/agent/agent.go:1755-1785
var p struct { Version, DownloadURL, ChecksumsURL string; Force bool }
...
err := u.UpdateTo(context.Background(), p.Version, p.DownloadURL, p.ChecksumsURL)
(The stale comment at agent.go:1781-1783 — "In a real implementation, we would
use the provided URLs … For now, we'll let the updater handle it" — contradicts
the code, which already uses them.)
Blast radius: A compromised or malicious panel, or an on-path attacker when
SERVERKIT_INSECURE_TLS=true, installs an arbitrary binary as root on every
connected server. The checksum gives a false sense of integrity.
Fix: Sign release archives (minisign/cosign), pin the public key in the
agent binary, and verify the signature over the binary before install. Make
checksum verification mandatory and fail-closed. Constrain download_url to the
expected panel/GitHub origin. Check the HTTP status on the checksum fetch.
H2 — Agent Plugin system & Server Template remediation are non-functional stubs (missing function + missing handlers)
Two backend services dispatch agent commands through a helper that does not exist anywhere in the codebase:
# agent_plugin_service.py:159,189,218,254 and server_template_service.py:324,366
from app.agent_gateway import get_agent_gateway # ← no such symbol
gw = get_agent_gateway()
gw.send_command(server.agent_id, 'plugin_install', {...})
agent_gateway.py exports init_agent_gateway and AgentNamespace — there is
no get_agent_gateway. Every call raises ImportError, which is swallowed by a
bare except Exception and logged as a warning. Compounding it:
- The agent registers no
plugin_install/plugin_uninstall/plugin_configure/plugin_disable/config_drift_check/config_remediatehandlers (grep: zero matches inagent/). - The call passes
server.agent_idwhere the realagent_registry.send_commandexpectsserver_id— wrong argument even if the symbol existed.
Effect: Plugin install/uninstall/configure and template drift-check/remediate
are silent no-ops; DB rows are set to STATUS_INSTALLING / CHECKING /
REMEDIATING and never progress. Yet the API (api/agent_plugins.py), the
models, the marketplace ai/plugin category, and the AgentFleet UI
(install + bulk-install buttons) all present this as a working,
permission-scoped (filesystem/network/docker/process/system) sandboxed
capability. It is entirely hollow below the API layer.
Fix: Either implement the panel glue + agent handlers + sandbox, or remove the UI/API and mark the feature explicitly "planned." At minimum, surface the failure to the user instead of swallowing it, and stop stranding DB rows in a pending state.
H3 — trigger_agent_update lets any authenticated user (including read-only viewers) update/restart the fleet
# backend/app/api/servers.py:2116-2117
@servers_bp.route('/<server_id>/agent/update', methods=['POST'])
@jwt_required() # ← no @developer_required / @admin_required
def trigger_agent_update(server_id):
Every other state-changing server route is @developer_required or
@admin_required; this one is authentication-only. A viewer (the explicit
read-only role) can force agent binary updates and the consequent restarts
across every server. In this endpoint the URL is panel-derived from GitHub
(so not arbitrary-binary RCE on its own), but it is an unintended privileged
action for a read-only account and an availability lever over the whole fleet.
Fix: Gate behind @developer_required (or @admin_required, matching
/fleet/*).
MEDIUM
M1 — Socket.IO layer has no authorization beyond "the JWT decodes"
backend/app/sockets.py authenticates the connection but performs no role or
object-level checks thereafter:
- Connect only decodes the token — no
is_active, no role (sockets.py:49-58). A deactivated user keeps socket access until JWT expiry. - Generic
join_roomjoins any room by name (sockets.py:199-205):Room names are discoverable (@socketio.on('join_room') def handle_join_room(data): room = data.get('room') if room: join_room(room) # no check that the caller may see this roomserver_<id>_metrics,logs_<app_id>,build_<app_id>; server IDs come straight fromGET /servers). subscribe_terminaldoesn't gate on role/ownership (sockets.py:120-144) — any authenticated socket (including aviewer) can join a remote terminal's output room given the session id, even though creating a terminal is@developer_required. Output can include a root shell's contents.
Bounded today by role homogeneity and unguessable session UUIDs, but it's a
defense-in-depth gap inconsistent with the REST authz and a real cross-role
information-exposure path. Fix: validate room ownership/required role inside
the join/subscribe handlers; re-check is_active+role at connect; restrict
terminal subscription to the role that can create one.
M2 — Compose path allowlisting (compose already enforced; cron intentionally not) — CORRECTED
The original finding overstated this. On closer review the compose half was
already mitigated: every compose handler in internal/agent/agent.go
(handleDockerComposePs/Up/Down/Logs/Restart/Pull, lines 1285/1300/1333/1361/
1388/1415) calls a.validateFileAccess(p.ProjectPath) — the same
symlink-resolved AllowedPaths allowlist used by file:read/write/list — and
that is the only call path to the docker client's Compose* methods. The
weaker validateProjectPath in docker/client.go is reached only after that
check passes, so it isn't a vulnerability; it was just inspected in isolation.
A doc-contract comment was added there so a future caller can't mistake it for
the allowlist boundary.
Cron (cron/cron_linux.go validateCommand) requires an absolute path and
rejects shell metacharacters but is deliberately not restricted to
AllowedPaths — doing so would break legitimate jobs (/usr/bin/certbot renew, /usr/bin/apt …). Its real risk is "runs as whatever the agent runs
as," which M3 addresses by moving packaged agents off root (cron entries then
land in the unprivileged account's crontab, limiting blast radius).
M3 — Packaged DEB/RPM run the agent as root with hardening disabled, contradicting install.sh
agent/packaging/deb/build.sh and rpm/build.sh ship a unit with User=root
and NoNewPrivileges=false, while agent/scripts/install.sh lays down a
dedicated serverkit-agent user with NoNewPrivileges=yes,
ProtectSystem=strict, ProtectHome=yes, PrivateTmp=yes. The packaged path is
both inconsistent and maximizes the H1 update-RCE blast radius (root, no
privilege containment). Fix: align the packaged units with the hardened
install.sh posture, or document why root is required and tighten the sandbox
directives.
M4 — No CI runs agent Go tests, go vet, or any Go SAST
.github/workflows/agent-release.yml only builds. security-scan.yml is
Python/Bandit over backend/**; nothing runs go test/go vet. Five agent
test files exist and cover the most security-sensitive logic
(security_test.go — command-blocking, exec-timeout, file-access allowlist &
symlink escape; file_handlers_test.go; connstring_test.go;
ipc/auth_test.go; jobs/jobs_test.go) but never run in CI, so a
regression in path-allowlisting or exec-blocking would ship unverified.
Fix: add a workflow running go vet ./... + go test ./... (and ideally
govulncheck/golangci-lint) on PRs touching agent/.
M5 — Install flow exposes the registration token in the process table / shell history
agent/scripts/install.sh passes the token as a CLI argument to
serverkit-agent register --token "$TOKEN" (visible in ps during
registration), and the documented curl … | sudo bash -s -- --token sk_reg_…
one-liner lands the token in shell history. Tokens are single-use and
short-TTL, which limits exposure, but a captured token is usable until consumed.
Fix: accept the token via env var or stdin; note HISTFILE hygiene in the
docs.
LOW / Informational
- L1 — Pairing claim and a few reads are authentication-only.
pairing.py/claimand/lookupare@jwt_required()(not@developer_required), so a viewer who has the physically-displayed pair code- passphrase can enroll a server. Minor authz-consistency item; the two-factor physical secret is the real gate.
- L2 —
agent.key"encryption" is host-derived obfuscation, not a secret.config.go:380-396derives the AES-GCM key fromhostname + machine-id/COMPUTERNAME— values available to anything on the host. Real protection is the0600perms; the encryption only resists off-host exfil (e.g. a leaked backup). Worth documenting as such so it isn't mistaken for at-rest secrecy against local root. - L3 — Dead/placeholder UI.
AgentFleet.jsxversion-table Edit/Delete and "Add Version" buttons have no handlers;Terminal.jsxshows "isn't available yet" for remote log/journal/process listing (feature-gated, not implemented). Honest placeholders, but they read as working controls. - L4 — Capabilities are OR-merged and never cleared until restart
(
recapabilities.go). An agent that briefly saw Docker advertisesdocker=truefor the rest of its run even if Docker goes away — intentional, but can mislead the target picker. - L5 — Single-worker requirement is enforced only by convention. The
registry, nonce store, and anomaly service are per-process in-memory
singletons; running the panel with
-w >1silently misroutes commands and desyncs nonces/rooms. Documented in CLAUDE.md/SECURITY.md but nothing fails loudly at runtime if misconfigured. - L6 — Windows update writes a
.batviafmt.Sprintfwith binary paths (updater.go:254-261). Local-only and not attacker-influenced today; would break on paths containing quotes. Low risk. - L7 — Stale TODO in
verify_agent_auth(agent_registry.py:846): per-message session-token validation is noted as unimplemented. The session token is issued but not verified per message; auth currently rests on the TLS channel + initial HMAC. Acceptable given the model; track it.
What's done well (don't "fix" these)
- Agent auth signs
agent_id:timestamp:noncewith a fresh random nonce on both WS (ws/client.go:287) and poll (poll/client.go:173) transports; the panel verifies HMAC before consuming the nonce and fails closed when the secret is missing/undecryptable (agent_registry.py:789-851), with a 60 s skew window and per-IP rate limiting shared across both transports. - TLS verification is on by default;
InsecureSkipVerifyis uniformly env-gated (SERVERKIT_INSECURE_TLS) across all five dial sites and logged loudly at startup. - File access is symlink-resolved against an allowlist, denies all when
unconfigured, masks SUID/SGID/sticky on write, and re-validates parents for
create_dirs(file_handlers.go). - Command validators reject shell metacharacters for packages, systemd
units, container/image/volume/network names, cron specs, and tunnel
names; container create bans
privileged/cap_add/devices; exec requires an absolute path + blocklist check; output is bounded; jobs are capped. - exec and terminal are off by default —
system:execisn't registered unlessFeatures.Exec, and the terminal manager is only constructed whenFeatures.Execis true (agent.go:133), so the terminal isn't an exec bypass. - Local IPC binds to localhost (forced), requires a
0600bearer token with a constant-time compare, and exempts only/health(ipc/server.go). - Pairing uses Ed25519 proof-of-possession over the enrollment id,
per-agent lockout with exponential backoff, bcrypt-hashed passphrase, code
TTL/rotation/freeze, and one-shot encrypted credential delivery
(
pairing_service.py). - Registry reconnect/heartbeat handling re-validates socket identity before eviction and fails in-flight commands on reconnect — the subtle races are handled.
Recommended priority order
- H1 — sign agent releases + make update verification mandatory/fail-closed (highest leverage; neutralizes the one RCE primitive that bypasses every gate).
- H2 — implement or remove the Plugin/Template features; stop swallowing the missing-function error and stranding DB rows.
- H3 + M1 — close the authz gaps (update endpoint role; socket room/role checks).
- M3 — align packaged service hardening with install.sh.
- M4 — wire agent tests into CI so the good validators don't silently rot.
- M2 / M5 / L-series — allowlist compose+cron paths; runtime guard or louder doc for single-worker; clean up placeholders and the obfuscation-vs-encryption wording.
Appendix A — Authorization map (server routes)
Reads are @jwt_required() (any active user incl. viewer); writes are
@developer_required; fleet ops are @admin_required. Unauthenticated by
design: /register (token-gated, 5/min), /install.sh, /install.ps1,
/agent/version, /agent/version/check, /agent/download/<os>/<arch> (302 →
GitHub), /agent/checksums. Authz outliers: /<id>/agent/update is
auth-only (H3); pairing /claim + /lookup are auth-only (L1).
Appendix B — Claims checked and rejected
- "~370 MB of committed MSI binaries in
agent/packaging/msi/output/." False.git ls-filesshows zero tracked.msi/.wixpdb, andgit check-ignoreconfirms the directory is ignored. They are local build artifacts, correctly untracked. The only committed binaries underagent/are small required assets (.sysoresource objects, tray/setup.ico/.png). - "Agent defines HMAC/nonce/timestamp helpers but never calls them."
False. Both transports call
SignMessageWithNoncewith a fresh nonce; the panel verifies and records it. (The helper-only view came from grepping definitions without call sites.)