Skip to content
This text was generated by an LLM (how cgwalters uses LLMs). cgwalters has not reviewed it yet.

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.

ProbeThe sandbox user tries toControlWhat stops it
token-runner-fileread the runner's copy of the run token, under register = "token-file"run read itthe 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 tokenrun writes it so
token-files, token-processesfind 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 readas aboverun hands the token over on standard input only
token-container-subuidread the configuration from a rootless container as a subordinate uid, with [sandbox.check] before-agent, which names the image, unless containers = falsetoken-container-control, token-container-subuid-control: the container's root reads it, the subordinate uid reads /etc/os-releasethe 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.