sandbox check and the agent
agentic-job sandbox check runs as the runner's user after sandbox setup. What each of its probes tries, its control and what stops it are
in the action's
probes,
and where each has been seen to fail in its
evidence.
This page keeps how run uses the check, the probes that are run's
own, and the helper the agent is entered through.
By default run does not repeat the check: once the run token is in
place it probes only where that token is. With [sandbox.check] before-agent = true it makes all of the check's probes again after its own
commands as the sandbox user (the clone), before it registers the run,
and, where the sandbox user has podman, reads each file holding the run
token from a rootless container, as its root and as a subordinate uid,
which must fail; any failure stops the run before the agent starts.
That costs a full check's time. A probe or a control that comes out
wrong fails the check: exit state 1.
The run action calls it again, as sandbox check --unless-passed,
before the agent starts. Secure-host's check, sandbox check --record,
leaves a record in RUNNER_TEMP, agentic-job-sandbox-checked, when
all its probes passed: the job's run ID, attempt and name, the binary's
version and a fingerprint of its bytes, and the installed configuration
it probed. A record means exactly that secure-host's check passed and
nothing has entered the sandbox user since, and it spares at most one
check: only --record writes one, every check removes it before
anything else, whether it takes it or not, and run removes it before
the agent runs anything. A check with
--unless-passed that finds the same record makes no probe; without it
(secure-host ran with check: false, the configuration or the binary
changed, a step of the sandbox user's or an agent ran, or the run is in
another job) it probes as usual, and leaves no record, so a second
run in the job probes again. A step of the sandbox user's (sandbox exec -- make check) or an agent can leave behind what the probes look
for, a process or a detached container. The threat is the sandbox user writing a record of a
check that never passed: RUNNER_TEMP is closed to it, which the
check that wrote the record proved
(the job's work directories).
A record is taken only from a
file of the runner's user in a directory of its own that no one else can
write in, which is where those probes look, and a link in its place is
not followed.
What the record does not stop is the runner's user itself. Steps of
that user between secure-host and run run unprobed, and any of them
can write a record run takes, since everything in it is
known to that user, or loosen what the probes proved (the home or
workspace modes, a loopback listener) without run noticing. Before the
record, run caught such a step; now it is the caller's to avoid, or
to catch with before-agent. Such a
step already holds the job's credentials, so this loses a second look,
not a privilege. In the reusable workflow nothing runs in between. A
caller job that runs repository code as the runner's user before run
(make check, say) should
give secure-host check: false, so that run probes after that code.
The egress policy and containers
With egress.proxy, the check reads the policy the proxy enforces,
/opt/egress-proxy/policy/policy.toml as sandbox setup installed it,
with the Rust proxy's parser (mitmproxy's egress/policy.py decides
alike), and fails before any probe if it cannot read or parse it. For
egress-git (git ls-remote of [sandbox.check] fetch-repo, by
default https://github.com/bootc-dev/bootc), egress-cargo and
egress-npm it asks the policy whether it admits the requests each
makes. Where it does, the fetch must succeed. Where it does not, the
probe becomes egress-git-refused, egress-cargo-refused or
egress-npm-refused: the first refused request, made with curl, must
meet the proxy's own 403 (X-Egress-Denied). So an allowlist that
admits no GitHub host is tested as it is, not loosened for the check.
[sandbox.check] control-url is the control of the probes that expect
a refusal, and the check fails, naming that key, if the policy refuses
a read of it. The threat feed is not consulted: a host on it fails the
probe that expected success, rather than passing.
[sandbox.check] containers = false skips every probe that pulls or
runs container-image: container-pull, container-build,
container-built-run, container-control, container-metadata, the
egress-container-* and tailnet-container* probes, and in run the
token-container-* and broker-egress-container probes. Each skipped
probe is named in a note and none counts as passed. The podman unshare probes of the filesystem view pull nothing and stay. The
consequence: rootless Podman stays usable to the sandbox user, and
whether its subordinate uids are held by the network rules (no
metadata service, nothing around the proxy) and kept from the run
token goes unprobed. A host that does not want that should remove
podman rather than turn the probes off; turning them off is accepting
it.
run's token probes
sandbox check has no run token, so it makes none of these. They are
run's, once it has written the agent's configuration and before the
agent starts, and only with a sandbox user: under --runner-home, the
reusable workflow's default, none runs
(sandbox.md). The test that removes
protections runs them for its token cases. run's other probes, of
the provider's key, the MCP credential and token and the broker under
inference.front, print ids too (api-key-processes,
mcp-token-files, broker-egress and the like) and have no rows here.
Every line of output starts with ok or FAIL and the probe's name in
brackets. The names are stable; the test that removes protections
matches on them.
| Probe | The sandbox user tries to | Control | What stops it |
|---|---|---|---|
token-runner-file | read the runner's copy of the run token, under register = "token-file" | run read it | the runner's home is closed |
token-config-mode, token-config-dir-mode | (none: the agent's configuration, and a child agent's, must be its own, mode 600, in a directory of mode 700) | token-config-control: the configuration holds the token | run writes it so |
token-files, token-processes | find the token in any other file under its home, /run/user/UID, /tmp, /var/tmp or /dev/shm, or in any environment or command line it can read | as above | run hands the token over on standard input only |
token-container-subuid | read the configuration from a rootless container as a subordinate uid, with [sandbox.check] before-agent, which names the image, unless containers = false | token-container-control, token-container-subuid-control: the container's root reads it, the subordinate uid reads /etc/os-release | the modes above |
The helper
The lock takes root from the runner's user
(the lock).
What a run still needs root for is small: enter the
sandbox (run0 as the sandbox user), stop that user's every process,
read the egress proxy's log, and the controls of a few probes
(pkexec as root, tailscaled's status as root, undoing a probe's
linger or crontab). Three ways were weighed. A sudoers rule for fixed
subcommands of a root-owned binary: the simplest, with sudo's own
checks that the file is root's, and the binary's own validation as the
boundary. A root service behind a socket with a peer-credential check:
sounder in theory (the kernel says who is calling, no sudo involved),
but a daemon for the job's length, a protocol, and passing the agent's
standard streams across a socket. polkit rules for run0: polkit sees
the unit and the verb, not the command, so a rule could not say "as the
sandbox user only". The first was chosen. agentic-job helper is a
hidden command of the binary; the sudo rule names the copy setup
installed at /usr/local/libexec/agentic-job, and setup makes that
path, the configuration's and the fixed PATH's directories root's alone
(no group write but root's, no other write) or refuses. The interface is
closed:
no operation takes a path, a user or a command that root then acts on.
The sandbox user comes from the root-owned configuration; enter runs
the caller's command as that user, in a directory that must be under
its home; the log read is of one fixed file from a byte offset; every
program root runs is looked up once, on a fixed PATH whose directories
must be root's alone, and runs with none of the caller's environment;
the configuration must be root's alone too, and the proxy's log is
opened without following a link and must be the proxy user's plain
file. The enter command line is built
from the configuration as before (sandbox::enter), and the helper
becomes run0 with the caller's sockets as its standard streams, so
the session's wrapper is sudo helper enter ... -- where it was sudo run0 ... --. On a host where setup never ran (the tests), the same
operations go through sudo directly (sandbox::root).
The steps after setup. In the reusable workflow every step of the
agent job after setup is this repository's (in an agent job of the
caller's own, its later steps follow): sandbox check, run, the public-repository check, three uploads and the
result. None needs root; the uploads read only what run moved out
(the upload gate). Of the actions' post steps, actions/checkout's cleans
its own credentials as the runner's user, the artifact actions have
none, and a caller's network-join action whose post step logs out with
sudo now fails there: it has to warn and go on, and its node has to
be one that expires by itself. The hosted runner's own
completion needs no root. A self-hosted runner that is not ephemeral
would keep a locked machine: agent-runner must be thrown away after
the job, as workflow.md requires. A caller who does not want this
sets sandbox.lock-runner = false, and gives up what the action's
R4
promises.