Skip to content

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.

Two accounts on one host. The key and the secrets file belong to the agent; the job is given its environment and the secrets it asked for, and its output passes through masking on the way out.

Three planes

They are independent and they fail differently; each one is needed.

PlaneThe questionThe mechanism
1What can a job read from disk and from /proc?Jobs run under an account of their own
2What does the agent hand it in the environment?A built environment, an allowlist
3What 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's HOME points into /var/lib/runjet-agent, which is exactly where a job may not go, and cd ~ should not land there,
  • let through from the agent — PATH, TZ, LANG and everything LC_*, because a job that behaves one way by hand and another under the agent is an expensive surprise,
  • whatever AGENT_PASS_ENV names — 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 prod would asterisk out half the log; at startup the agent names the ones it skipped,
  • echo $SECRET | base64 walks 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).