The sandbox user: the agent's side of a hardened host
What agentic-job sandbox setup adds to a host for an agent, and why.
The host itself (what setup changes, the lock, the network rules and the
egress proxy) is documented with the action that runs it:
setup,
egress
and the
threat model.
The code is crates/agentic-job/src/sandbox/ and egress/
(layout); run's probes are in
sandbox-check.md, the requirements it serves in
requirements.md, and how a workflow calls it in
workflow.md.
The agent is treated as a hostile process. It reads text that anyone can
write (issues, pull requests, CI logs, a repository's own files), so the
design assumes prompt injection succeeds. What then matters is what the
process can reach. The reusable workflow runs the agent as the runner's
user (run --runner-home); run without it starts the agent as the
sandbox user, a second Unix user on the same kernel that setup creates
by default, and that is what this page is about.
This protects against the sandboxed process, not an untrusted workflow running outside the sandbox. A merge queue executes proposed workflow files and code with write and OIDC permissions before landing. The ingress checker vets each merge group, but the group's own workflow definition runs it; human approval before enqueueing is the trust gate, including for workflow steps that build or execute the proposed binary outside the sandbox.
Why not a container
gh-aw runs the agent in a container behind a firewall, with the
container runtime's socket hidden. The work this was built for is on
bootable containers: it builds images with rootless podman and boots
VMs through /dev/kvm. Those need the host, or a container weakened
until it is no longer the boundary
(background). So
the host is hardened and the agent gets an ordinary login session on it.
What the code does for those two is small. For podman, setup requires
that the sandbox user has subordinate uids and names them in the network
rules, and run0 gives each command the session rootless podman relies
on; podman itself is a package the caller lists. For KVM, the sandbox
user's groups default to kvm, and setup refuses when a listed group
does not exist. Nothing checks that /dev/kvm is there, and nothing in
this repository boots a VM as the sandbox user.
What setup does for an agent
Besides the host's setup, sandbox setup:
- installs
setup.npm, as root and on the open network; npm versions must be exact; - installs
setup.agentsfrom npm tarballs pinned in the binary (src/sandbox/setup/agents.toml): every package of each agent by its SHA-512 integrity, unpacked byagentic-jobinto/opt/agentic-job/agentswith no npm and no install script, its programs linked in/usr/local/bin; a tarball that does not match its pin is refused, and the pins are for Linux on x86-64 with glibc; - if configured, runs the caller-pinned
setup.toolsinstaller as the sandbox user, without runner credentials, before network lockdown; - with
sandbox.filesystem-view = true, installs the read-only view (below) on the user's manager and everyrun0command; - runs
setup.scriptas the sandbox user, never as root.
Start setup from a root shell, or from a systemd unit after boot, such as:
[Unit]After=multi-user.target
[Service]Type=oneshotExecStart=/usr/local/bin/agentic-job sandbox setup --config /etc/agentic-job-setup.toml
[Install]WantedBy=multi-user.targetNot from cloud-init's runcmd: on CentOS Stream 10, setup started there
failed when run0 could not start a transient service unit to run
setup.script as the sandbox user, while the same setup worked from a
unit like the one above. Why is not yet known
(#1810);
setup does not refuse cloud-init, but that failure's message says this.
egress.direct lists endpoints the sandbox user reaches without the
proxy, each an IPv4 address and port on the tailnet; it is for the
inference proxy, so that the run token never crosses the
egress proxy. For a session run, the sandbox user
is also rejected on loopback at the session bridge's port and at the
node's own tailnet addresses, before any of its accepts. Otherwise the
agent could reach the bridge itself and claim the operator's login.
On main the sandbox user is refused EC2's IPv6 metadata address
unconditionally
(#1467);
the image the action pins refuses it only with
sandbox.deny-runner-metadata.
Past DNS, the egress proxy never connects to the ranges in
PROXY_DENIED_V4 and PROXY_DENIED_V6
(crates/secure-host/src/network.rs): loopback, the private ranges,
link-local, the tailnet, multicast, some reserved ranges and Azure's
WireServer. The rules for its uid refuse them whatever a name resolves
to, so a name that resolves there gets a 502 from the proxy, as an
in-cluster inference name does
(#1810).
Under an [egress] allowlist the policy refuses an address literal
anyway, unless --refuse refuses it first. An inference broker is
reached outside the egress proxy: by the sandbox user at its tailnet
IPv4 address in egress.direct, or, under inference.front, by run
as the runner's user at an address neither on loopback nor in
egress.direct (inference). A forge on a private network has no way
in: run clones the target as the sandbox user through the proxy, from
the https:// (or allow_http) URL policy admitted for a host of the
caller's bounds, and nothing prepares a clone for it before network
lockdown. Whether to allow a named private destination is open
(#1812).
Every command in the sandbox is then started through the root-owned
helper, which invokes run0 --pipe --user=…. This creates a transient
service with its own PAM session
and none of the caller's environment. It gets a fixed PATH and LANG,
the proxy's variables and sandbox.env; a variable named ACTIONS_*
(the job's identity-token request is two of them) is refused by name in
the configuration and again at entry. --pipe needs systemd 257; setup
refuses a run0 whose --version names an older one.
What the sandbox user adds
The lock, the world-write policy, the metadata, Worker-diagnostics and runner-read rules and the egress proxy harden the runner's own user, and every later step runs as it. The sandbox user is a second uid on top of that, for code that must not share the runner's: an agent. Over the runner's hardening it adds that the code:
- cannot read the runner's processes, their environments and memory
(
hidepid, Yama and the uid split), nor the runner's home andsandbox.private-dirs, which are closed to it; - has no sudo at all, not even the helper's rule, and no polkit action;
- never gets the job's variables: no
ACTIONS_*and no OIDC request credential, since its commands start fromrun0with a fixed environment; - reaches the network only as the rules for its uids allow: never the metadata service or the tailnet, and only through the egress proxy when that is on;
- leaves nothing running after the run: cron, at and lingering are denied,
and the supervisor stops every process of its uid (
helper reap).
There is one way in: helper enter, which the runner's one sudo rule
allows and which runs a command as the sandbox user through run0, in a
directory under its home, with the configured variables and nothing else.
The account has no password, so su and logins do not open it.
Setup makes the user by default, so that a job can start an agent without
setting it up again. sandbox.create-user = false skips it for plain CI
that runs nothing as another user: setup then makes no user, subordinate
uids, view or probe directory of its group, and its sudoers file holds the
runner's lines alone; the rest is as above. It refuses what would run as
the user or shape it (setup.tools, setup.script, an agent,
sandbox.filesystem-view, sandbox.groups, sandbox.allow-existing-user),
and a host where that user already exists, which nothing would confine.
sandbox check then probes only the runner's user and the host.
useradd makes the user from an empty, root-only skeleton in /tmp rather
than the image's /etc/skel. Under SELinux that directory takes the
label of /etc/skel first, since useradd_t may not read the caller's
tmp type; a missing label on /etc/skel fails setup. CI has no
SELinux-enforcing image, so this is verified by hand: on a RHEL-family
host with SELinux enforcing, run sandbox setup once from a systemd
service and once from a root shell, then ausearch -m AVC,USER_AVC -ts recent
should show no useradd_t denial.
The read-only view
The view is opt-in, sandbox.filesystem-view = true, on top of the
host's
world-write policy.
It confines the sandbox user's processes, not the runner's subsequent
steps, supervision or cleanup.
A root-owned drop-in on the system unit user@UID.service
and properties on each transient run0 service set ProtectSystem=strict
and ProtectHome=read-only, with
ReadWritePaths= for the user's home, /run/user/UID, /tmp and /var/tmp.
User services inherit the manager's mount namespace: clearing their own path
properties does not undo it. This is not a sandbox setting on a slice or scope.
The kernel API trees /proc, /sys and /dev are exceptions, as defined by
ProtectSystem=strict: making /proc read-only prevents writing uid/gid maps,
and read-only cgroups prevent the delegated user manager and Podman managing
their units. They remain governed by kernel and ordinary permissions, not
an exception for arbitrary host storage. /dev/shm is shared temporary storage.
Device access, including KVM, remains subject to ordinary permissions. The
view does not grant permissions or make a read-only filesystem writable.
No PrivateUsers, PrivateDevices, mount syscall filter or NoNewPrivileges
is added: those would hide KVM or break Podman's subordinate-id helpers.
There is one trusted exception: systemd 257 forks (sd-pam) before applying
the mount namespace. It runs systemd/PAM code, not agent-supplied code. The
probes require that another process of the sandbox uid cannot attach with
ptrace, write its memory, open its memory file or use its /proc/PID/root.
They test both run0 and manager handlers, with positive memory-write and
ptrace controls and the separate host ptrace_scope check. hidepid hides
these nondumpable handlers from their own uid, so the probe finds them in
the control groups of the manager and of the run0 command. A hosted test
kills a sacrificial run0 handler and verifies that its surviving command
still cannot write outside the view. Killing the handler does not necessarily
end its parent session in systemd 257; it offers no new execution path.
The read-only view is not a kernel boundary and does not block Unix socket IPC. The existing local-service/socket allowlist probes still apply. Trusted host code must not propagate new writable host mounts after setup: systemd's path restrictions do not make such later mounts read-only. The untrusted user has no privilege to create those mounts in the host namespace.
Hosted setup logs remain the timing source, including the
filesystem-view stage.
Why a systemd read-only view has not replaced the walk
The walk has since been replaced, by the access-time world-write policy,
not by the view. The kernel applies the policy by uid to every process,
whatever its mount namespace, (sd-pam) included. What follows is why
the view alone did not meet that condition, and why it stays an opt-in
layer.
The proposal in #188
would prevent writes to world-writable host paths outside the sandbox's own
directories without inventorying the image. It must cover every process running
as the sandbox user, not just the command run launches. The
proposed two-unit guard does not meet that condition.
In systemd 257, the execution documentation distinguishes these cases:
ProtectSystem=strictmakes the hierarchy read-only except/dev,/procand/sys.ReadOnlyPaths=/instead selects the root explicitly.ReadWritePaths=can carve out the home, runtime and temporary directories; it preserves ordinary permissions, not grants access, and cannot override a read-only filesystem superblock.PrivateTmp=yeswithProtectSystem=strictleaves the unit's private/tmpand/var/tmpwritable.- A root-installed drop-in on
user@UID.serviceconfigures a system service, even though its executable issystemd --user. Its children inherit its mount namespace. This is different from asking that user manager to apply filesystem settings to individual user units, which the user controls. For those user units, the documented availability rule forReadOnlyPaths=/ReadWritePaths=implicitly enablesPrivateUsers=and requires unprivileged user namespaces. The sandboxing overview says most filesystem namespacing settings needPrivateUsers=truein a user manager. - run0's
--property=sets properties on its newly created transient service.--user=NAMEselects the Unix identity, not the per-user manager. The properties are not a policy on every process with that uid, nor execution settings on a slice or a session scope.
There is a specific process outside both proposed views: (sd-pam).
run0 always uses the systemd-run0 PAM stack; the upstream
user manager unit
also has PAMName=systemd-user. The execution documentation describes the
long-lived PAM session handler. In the v257
executor,
exec_invoke() calls setup_pam() before apply_mount_namespace().
setup_pam() forks (sd-pam), drops it to the target uid/gid with
fully_set_uid_gid(), and leaves it waiting to close the session. Only the
parent continues to install the unit's mount restrictions and exec the command.
Thus both PAM handlers retain the system executor's filesystem view, not the
restricted view of the command or user manager. Adding the same properties to
both units does not change that order. This is source evidence of an uncovered
process, not a demonstrated way for agent code to take over the PAM handler
or access its /proc/PID/root; no such exploit was tested here.
Compatibility is also not proved merely by accepting the properties.
PrivateUsers=true maps only root and the unit identity (units launched by a
non-root per-user manager omit root); identity maps only the first 65,536 ids.
Neither provides the subordinate-id mappings needed by this project's rootless
Podman setup.
NoNewPrivileges=yes prevents setuid/file-capability elevation by newuidmap
and newgidmap; denying @mount prevents container mount operations. Those
are not suitable blanket fixes while retaining rootless Podman. Keeping host
/dev avoids hiding /dev/kvm, whereas PrivateDevices=yes would hide it.
Read-only path settings do not block Unix socket IPC, and writable mounts
propagated later from the host remain writable inside the unit; the manual
explicitly warns of both limitations. Namespace support may also be gracefully
disabled, so a successful unit start is not proof of enforcement.
For the all-process requirement, the view stopped at the PAM-handler exception rather than replace the walk behind a partial guard. The world-write policy that did replace it is a boundary of that kind: the kernel checks it at access time, whichever process attempts the write, so it holds before PAM forks.
What is tested
sandbox check probes the host as the sandbox user, each probe with
a positive control
(probes).
run does not repeat it: before the agent starts
it runs only its own probes that the run token is nowhere but the
agent's configuration (the token-* probes of
sandbox-check.md). Those need a sandbox
user: under --runner-home, which the reusable workflow passes unless
agent-user: sandbox, the token is in run's own environment, which the agent can read, and
nothing probes for it. CI's sandbox job runs setup and the check for
real on hosted ubuntu-26.04, then removes each
protection in turn and requires that the matching probes fail
(tests/sandbox_host.rs), after showing that setup refuses a machine
that is not fresh (tests/sandbox_fresh.rs). What that runner cannot
show, and what was tried by hand elsewhere, is in the action's
evidence.
What it does not do
What the host does not stop is in the action's threat model and egress. For an agent in particular:
- The agent can read whatever is world-readable on the host, and connect to whatever listens for every local user unless the caller stops it.
- With
egress.proxy = falsethe sandbox user's network is open but for the metadata service and, whenegress.directhas entries, the tailnet. - The boundary is a second Unix user on one kernel, on a VM used for one
job. Setup refuses, before its first change, a sandbox user that is
already there unless
sandbox.allow-existing-usersays the image ships it, and a home left without its user. - A container the agent starts has no network of its own. It reaches the proxy only on the host's network, and trusts it only if the certificate is mounted into it, as the probes do; nothing configures podman to do either. Use the container recipe for networked builds and runs.
setup.npmruns the packages' install scripts as root, which is why the list is the caller's and never the task's.setup.agentsruns none, and installs only what a release of this repository reviewed.