Process privileges
By default a process component inherits everything from the agent: same uid, full
capability set, NoNewPrivs=0. And the agent usually has to run as root — it
writes systemd units, installs into /usr/local/bin, touches /etc. So without
this feature, every component runs as unconfined root, and any memory-safety bug in
any of them is a full host compromise.
Like you’re five
The cook has the keys to the whole house. That does not mean every helper needs them. Each helper gets exactly one key — the one for the cupboard they work in — and they are told they may never pick up another key, ever.
The recipe block
These are the same names as the systemd directives they replace — User=,
NoNewPrivileges=, AmbientCapabilities= — so an existing unit can be copied
across.
Two details worth knowing:
- Omitting
capabilitiesleaves capabilities alone. Declaring it as[]drops all of them.niland[]differ on purpose. - Giving only a user (
user = "svc") uses that user’s primary group and its supplementary groups, as systemd does. A bare numeric uid with no entry in the user database is rejected rather than silently defaulting to group 0.
Process components only. Containers are confined through
[lifecycle.run.container]. Declaring [lifecycle.run.security] on a container
component is an error, and so is setting container.user / container.privileged
on a process component. A misplaced restriction is refused, never ignored.
How it is enforced
Dropping the capability bounding set and setting PR_SET_NO_NEW_PRIVS are
per-process and irreversible. Doing them in the agent would confine the agent
itself and everything it ever starts afterwards.
So the agent re-executes its own binary as a shim:
sequenceDiagram
participant A as agent
participant S as privdrop shim
participant C as component
A->>S: exec self with the restrictions
Note over S: keepcaps, bounding set,<br/>setuid, capset, ambient,<br/>no_new_privs
S->>S: verify against the kernel
S->>C: execve, same PIDexecve replaces the process image, so the PID does not change — supervision,
metrics and GET /v1/components are unaffected.
Why that order
flowchart TB
A["PR_SET_KEEPCAPS"] --> B["close the bounding set"]
B --> C["setgroups, setgid, setuid"]
C --> D["capset to the allow-list"]
D --> E["raise ambient caps"]
E --> F["PR_SET_NO_NEW_PRIVS"]
F --> G["verify against the kernel"]
G --> H["execve the component"]Each step is where it is for a reason:
PR_SET_KEEPCAPS— otherwise the uid change wipes the permitted set and there would be nothing left to grant.- Close the bounding set —
PR_CAPBSET_DROPneedsCAP_SETPCAPin the effective set, andsetuidclears the effective set. It must happen first. setgroups→setgid→setuid— always set the supplementary groups, including to empty. Inheriting the agent’s groups (root’s) would hand the component access the recipe never asked for.capset, then raise ambient — the ambient set is what survivesexecvefor a binary without file capabilities, which is the normal case. Skipping it leaves the component with nothing.PR_SET_NO_NEW_PRIVS, then verify, then exec.
Fail-closed and verified
If any requested restriction cannot be applied, the shim exits non-zero and the component fails to start like any other start failure. An unconfined process is never the fallback.
Verification reads the result back from the kernel — getuid, capget,
PR_CAP_AMBIENT_IS_SET, PR_GET_NO_NEW_PRIVS — rather than trusting that the
syscalls returned 0. That check earned its keep during development: an early
version had every syscall succeed while the capability never reached the ambient
set, so the component would have exec’d with no capabilities at all. Checking
permitted and effective did not catch it; checking ambient did.
Two cases behave deliberately:
- Asking for a capability the agent does not hold fails with an explanation. A process can only ever narrow its own capabilities.
- A non-root agent cannot narrow the bounding set (
PR_CAPBSET_DROPneedsCAP_SETPCAP). Withno_new_privileges = truethis is logged and accepted, because the bounding set can only be exploited by anexecvethat grants privileges — precisely whatno_new_privilegesforbids. Without it, the hole is real and the component is refused.
Verify it on a device
Do this once per confined component the first time you deploy it. The guarantee is only worth what you have checked.
Not covered
Filesystem and namespace confinement — the equivalent of ProtectSystem=,
PrivateTmp=, mount namespaces — is not implemented. If you need it, run the
component as a container, or wrap it in a systemd scope.