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

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.agents from npm tarballs pinned in the binary (src/sandbox/setup/agents.toml): every package of each agent by its SHA-512 integrity, unpacked by agentic-job into /opt/agentic-job/agents with 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.tools installer 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 every run0 command;
  • runs setup.script as 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=oneshot
ExecStart=/usr/local/bin/agentic-job sandbox setup --config /etc/agentic-job-setup.toml
[Install]
WantedBy=multi-user.target

Not 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 and sandbox.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 from run0 with 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=strict makes the hierarchy read-only except /dev, /proc and /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=yes with ProtectSystem=strict leaves the unit's private /tmp and /var/tmp writable.
  • A root-installed drop-in on user@UID.service configures a system service, even though its executable is systemd --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 for ReadOnlyPaths=/ReadWritePaths= implicitly enables PrivateUsers= and requires unprivileged user namespaces. The sandboxing overview says most filesystem namespacing settings need PrivateUsers=true in a user manager.
  • run0's --property= sets properties on its newly created transient service. --user=NAME selects 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 = false the sandbox user's network is open but for the metadata service and, when egress.direct has 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-user says 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.npm runs the packages' install scripts as root, which is why the list is the caller's and never the task's. setup.agents runs none, and installs only what a release of this repository reviewed.