Job isolation
A job runs as runjet-job in a built environment and cannot reach the agent's key or its secrets.
Secrets are protected from Runjet: it stores only names, only names travel in a dispatch, and the values live on the agent. This page is about the other half — how they are protected from the jobs on the same host, and what follows from that for you as the machine's operator.
Anyone who can create a job has code execution on the host regardless; nothing here is a defence against a malicious operator. It matters at the moment a job runs code that is not entirely yours — a build, a third-party script — or is compromised through what it calls. Without isolation, one such job would give up the agent's identity and the secrets of every other job. Shrinking that reach is the goal.
- identity.json
- agent.env
- secrets
- HOME=/var/lib/runjet-job
- USER=runjet-job
- PATH, TZ, LANG, LC_*
- AGENT_PASS_ENV
- the secrets it asked for
Three planes
They are independent and they fail differently; each one is needed.
| Plane | The question | The mechanism |
|---|---|---|
| 1 | What can a job read from disk and from /proc? | Jobs run under an account of their own |
| 2 | What does the agent hand it in the environment? | A built environment, an allowlist |
| 3 | What reaches the run's log? | Masking on the agent |
The first does not subsume the second: the agent passes on its environment deliberately, and a different uid changes nothing about that. The third subsumes neither: a masked output does not stop a job using a token, it only stops it printing it.
An account of its own
A file cannot be hidden from a process running under the same uid. That is a
property of Unix rather than a gap in the code, and the only answer is for job
commands to run as somebody else. So installation
creates two accounts: the agent holds identity.json and the secrets file,
runjet-job runs the commands and can read neither — nor can it read the
agent's /proc/<pid>/environ.
The agent is not root while doing this. The unit gives it two capabilities,
CAP_SETUID and CAP_SETGID, and the bounding set holds "unprivileged plus
two" in place. Before taking any work the agent verifies the switch on a trivial
command; without the capabilities it will not start and says which line to add —
rather than the first sign being every job on the host failing at exec.
The runjet-job account has a home and jobs run in it. It belongs to them, so a
job writes there without anything being granted, and the agent, which cannot get
into it, has no need to. Supplementary groups are given to the job explicitly
from the runjet-job account, not inherited from the agent — left inherited,
they would quietly open to the job whatever they open to the agent.
Jobs are not separated from one another, and that is the model rather than
an omission: they all run as runjet-job and see each other exactly as two cron
jobs of one user always have. It matters only when a job runs code you did not
write; an account per job is what that would take, and this is not that.
The built environment
A job does not inherit the agent's environment; it is given one assembled in one place and nowhere else:
- derived from the job's account —
HOME,USER,LOGNAME,SHELL. Not inherited: the agent'sHOMEpoints into/var/lib/runjet-agent, which is exactly where a job may not go, andcd ~should not land there, - let through from the agent —
PATH,TZ,LANGand everythingLC_*, because a job that behaves one way by hand and another under the agent is an expensive surprise, - whatever
AGENT_PASS_ENVnames — exact names, no patterns (configuration), - the secrets it asked for, last, so that they win over anything of the same name above.
It is an allowlist rather than a striking-out of AGENT_* and RUNJET_*: a
variable added to the agent a year from now is closed to jobs by construction,
not by somebody remembering to extend a list. The agent also deletes its own
settings from the environment after startup — it has them all in memory by then,
and a copy a job could reach serves nothing.
What this means for you: a job that leaned on a host variable outside that base
breaks until you name it in AGENT_PASS_ENV. It is visible, deliberate, and the
fix is one setting.
Masking
The values of the secrets a run asked for are replaced in the output with ***
by the agent before the output leaves the host — not in Runjet, which would
have to hold the values to do it, and holding them is the thing this whole design
refuses. The output leaves in pieces, and a value cut across a piece boundary is
the classic hole in hand-written maskers; so the agent holds back the tail of a
buffer that might be the beginning of a secret and writes it once the rest
arrives. For ordinary log text that is zero bytes and nothing is delayed.
It is a net for accidents, not a boundary: a set -x in a script, a
curl -v with an Authorization header, a stack trace carrying a DSN. Two
limits:
- values shorter than five characters are not masked — a secret whose value is
prodwould asterisk out half the log; at startup the agent names the ones it skipped, echo $SECRET | base64walks straight past. What stops such a job is not the masking but that it cannot reach anything it was not given.
Masking is done from this run's secrets only, not from every secret the agent knows. A wider version would hand the job an oracle: print candidates, see which come back masked.
What a host's operator should take away
- Leave jobs under
runjet-job.AGENT_JOB_USER=(empty) is a choice the agent announces at every start and the agent list shows as not isolated. - Sandboxing in the systemd unit is yours, not Runjet's: every restriction you add there applies to your jobs as well, and only you know what your jobs need.
- Put secrets in a file rather than in the service's environment where you can — a file is what the agent can check, and what it can tell you is too short inside (secrets on the host).