Security
systemg spawns and supervises arbitrary processes, often as root in system mode. That makes it a high-value target for local privilege escalation, so we treat it like one. This page states the trust model explicitly, documents the guarantees at each privilege boundary, and lists the knobs you use to confine services.
Trust model
Two things are trusted by design:
- The config file. Commands, users, and health-check URLs in it are intended execution. If you can write the config, you can already run code as whoever runs the supervisor — that's the point of a supervisor, not a vulnerability.
- A same-UID caller of the control socket. The socket is owner-only (
0600) inside an owner-only (0700) directory, and every connection is authenticated withSO_PEERCRED/getpeereid. Only the supervisor's own user (and root, which can bypass any check anyway) may issue commands.
Everything else is untrusted and is where we spend our effort: the control socket and its IPC protocol, filesystem permissions, and every cross-privilege boundary — privilege dropping, capabilities, namespaces, cgroups, inherited file descriptors, signal delivery, and environment handling.
Guarantees at the boundary
These hold with no configuration on your part.
Authenticated control channel. The control.sock peer UID is checked on every accept; connections from other local users are rejected before any command is read. A single framed command is capped (1 MiB) so one connection can't exhaust supervisor memory.
Owner-only runtime state. State and log directories are created 0700; the socket, PID file, and config hint are 0600. Service topology, PIDs, and config paths are not readable by other local users.
Race-free config loads. Configs supplied over the socket (restart --config, project add) are opened once with O_NOFOLLOW and validated on the open descriptor (fstat) — the file that passes the ownership/permission check is the exact file that's parsed and executed. No stat-then-reopen window, no symlink swap. Group/other-writable or foreign-owned configs are refused.
Correct privilege drop. Before exec, a dropped service goes through: namespace unshare → rlimits → nice/affinity → capability trim → setgroups/setgid/setuid → ambient caps. Supplementary groups are always reset on a UID switch, so a service dropped to nobody does not retain root's docker/wheel/sudo membership.
Clean environment across the boundary. A service that switches user or group starts from a cleared environment by default — the supervisor's variables (secrets, LD_*, PATH) do not leak into a lower-privileged child. Opt back into inheritance per service with inherit_env: true.
Confined dynamic spawns. Children created through the socket's spawn path inherit their parent service's privilege context — same user, caps, limits, and cgroup — so a spawn can never be more privileged than the service that authorized it.
No descriptor leaks. Socket-activation FDs inherited via LISTEN_FDS are marked FD_CLOEXEC, so listening sockets don't survive exec into services.
No traversal from socket input. Service names arriving over the socket are validated and every resolved log path is confined to the log directory, so a crafted name can't make the supervisor read or create files outside it.
Confining a service
Least privilege is a configuration you write, not a mode you toggle.
services:
web:
command: "./server"
user: "www-data" # drop root after the supervisor binds
capabilities:
- CAP_NET_BIND_SERVICE # keep only what's needed; drop the rest
limits:
nofile: 65536
nproc: 1024
cgroup:
memory_max: "2G"
cpu_max: "100000 50000" # 1 CPU
isolation:
network: true # private netns
pid: true # private process tree
mount: true
user: true
Capabilities (Linux) — the service retains only the listed capabilities; everything else, including the bounding set, is cleared. Grant CAP_NET_BIND_SERVICE for low ports, not root.
Resource limits — nofile, nproc, memlock, and cgroup memory_max / cpu_max cap what a service (or a runaway fork bomb) can consume.
Namespace isolation — network, pid, mount, and user namespaces separate a service from the host. Namespace creation may need CAP_SYS_ADMIN; cgroup writes inside containers may need limits.cgroup.root pointed at a writable path.
Environment — session-scoped variables (SSH_*) are stripped from long-lived services by default; add more with strip, and use inherit_env: false (the default under a privilege drop) to start from a clean slate.
Deployment guidance
- Run in user mode unless you need root.
sysg startruns unprivileged; reservesudo sysg --sys startfor services that genuinely require system-level features. - Drop privileges at the service, not the supervisor.
user:drops the child after the supervisor has done any privileged setup (e.g. binding port 80).--drop-privilegesgoverns child spawning; it does not change privileges for read-only control commands. - Isolate untrusted workloads with a non-root
user:, network and PID namespaces, and a cgroup memory cap.
On the roadmap
| Feature | Status | Purpose |
|---|---|---|
private_devices | In progress | Device isolation |
private_tmp | In progress | Per-service /tmp |
seccomp | Planned | Syscall filtering |
apparmor_profile | Planned | Mandatory access control |
selinux_context | Planned | SELinux labels |
These are declared in config today and currently warn rather than enforce; systemg does not silently pretend a profile is applied.
Reporting and audits
systemg is audited adversarially against the trust model above, and each review verifies the disposition of every prior finding. If you find a security issue, please report it privately via the repository's security contact rather than a public issue.
See also
- Kernel Mode — system mode and the privilege-drop sequence
- Configuration — every security option
- State — runtime files and their permissions