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

Securing the CI host: one setup step for any job

Agent security is just CI security. An agent is one more untrusted step of a job, like a build script from a pull request or a dependency's install hook, and what protects a job from one protects it from the others. So the hardening is a step of its own that any job can run before untrusted steps, whether or not an agent runs afterwards: cgwalters-forge/actions/secure-host, which runs the image this repository builds. Every step after it, the agent included, is the runner's user's.

That action's README and documents are the documentation of record for the host: what setup changes, the lock, the world-write policy, the rule against reading another runner process, egress and the probes (where each section went). This page keeps what concerns agentic-job alone: the forwarder, how the agent job uses the action, where the binary and image come from, and runner outbound observation. Those documents describe the image the action pins, which can lag this repository's main; they cover main's later host changes when the pin moves.

The public action forwards to cgwalters-forge/actions/secure-host

secure-host/action.yml forwards to cgwalters-forge/actions/secure-host at a pinned commit, which runs the attested, digest-pinned image (tracker#674). It passes on config, check and image-archive. A caller that names cgwalters-forge/agentic-job/secure-host at a commit from before the forward keeps the old action. At @main or a later commit:

  • The new action's contract has no second user to run steps as: every step after it is the runner's user's. The old action's outputs are gone, so a step that reads them gets an empty string.
  • Without a config, the forwarder passes this repository's hosted.toml, as the old action did, and not the new action's default, which turns egress filtering off: the v0.6 image's binary still creates its second user, and a step that runs code as it keeps writing only through the proxy's rules. A config of the caller's own replaces it whole. The new action sets egress.write-policy from its own egress input, over the file, so the forwarder passes egress: write-policy when the file asks for it, and refuses a file with a backslash outside a comment or with a multi-line string, either of which could ask for it in a way the forwarder does not read.
  • The forwarder and site.yml pin the same commit of the new action; its comment names the image tag that commit runs, and CI's ci.test.cjs refuses a pin whose two copies differ or whose tag is newer than the workspace's version.
  • The floor is ubuntu-26.04, the runner the new action is written for; the image admits exactly Ubuntu 26.04 and RHEL 10 (supported hosts).
  • Setting another input of the old action that chose the host fails the step and names the new action: lock, egress, deny-runner-metadata, egress-policy, stop-services, sandbox-user, runner-user, script, binary or build. Each asked for a host the new action would not set up, so the step does not ignore it. A configuration file says the same things.

What main has that the pinned image does not yet

The action's documents describe the image it pins (v0.6.3). Main has changed the host since, and a forwarder caller gets these only with the next image release:

  • sandbox.deny-runner-metadata, sandbox.unlink-worker-diagnostics and sandbox.deny-runner-reads follow sandbox.lock-runner unless a configuration sets them, and setup and sandbox check print what each one set false leaves a later step. A host that runs no single Runner.Worker must set deny-runner-reads = false. In the pinned image each is off unless set true, and the forwarder's hosted.toml sets none of them: unless the caller's configuration (the policy job's, for the agent job) sets them true, through the forwarder today every later step, the agent included, still reaches the cloud metadata service, can read the environment of a process an earlier step left running, and can have Runner.Worker dump its memory.
  • Under egress.write-policy, the runner's storage writes go only to the twenty productionresultssaN accounts (#1422), not to every Azure storage account.
  • The sandbox user is refused EC2's IPv6 metadata address unconditionally (#1467), not only with sandbox.deny-runner-metadata.
  • sandbox.create-user = false skips the sandbox user for a host that runs nothing as another user (what it adds); the pinned image predates the key and always makes the user.

On a host that is not a GitHub Actions runner

A self-hosted CI job, or a VM of its own, secures its host with the binary alone, as root and then as the user the later steps run as:

Terminal window
sudo agentic-job sandbox setup --config FILE
agentic-job sandbox check

Neither needs RUNNER_TEMP, the GITHUB_* environment or a Runner.Worker process. What changes without them:

  • With no Runner.Worker, sandbox.unlink-worker-diagnostics has no socket to unlink: setup prints that it skipped the step and why, and sandbox check notes the same rather than passing a probe. With one or more Workers, both behave as on a GitHub runner.
  • The runner-read rule exempts exactly one Runner.Worker, so where the secure-host image's loader runs, a host without one sets sandbox.deny-runner-reads = false; the loader refuses it otherwise and names this key. sandbox setup alone never loads the rule.
  • The check record that lets a later check in the same job skip its probes (sandbox-check.md) needs RUNNER_TEMP and GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT and GITHUB_JOB. Without them there is no record, and every check makes its probes.
  • sandbox timed is the GitHub action's wrapper, which times setup and the probes for the job summary; a plain host does not need it.

The agent job

The agent job (agent.yml, which agentic-job.yml and review.yml call) runs the forwarder, with the policy job's configuration but setup.script, and then the agent as the runner's user (run --runner-home, the run action's default agent-user: runner). The image would run setup.script as its second user, from the host's root, so the job runs it itself, after secure-host, in the agent's home. With agent-user: sandbox the forwarder gets the script too, by an absolute path (agentic-job config --host resolves a relative one), the image runs it as the sandbox user, and the agent is that user. The image also installs its own binary in /usr/local/bin over the one prepare put there, so run starts the policy job's binary from the job directory. The composed example (example-compose.yml) has the same steps, and CI's jobs that secure a host run the forwarder with this revision's image (image-archive).

The agent is then a later step like any other: no root, the metadata service only where the configuration leaves it open (above), and, as the action's threat model says, nothing between it and the job's own tokens. So the agent job holds none worth stealing. It has no id-token: a register job before it registers with the inference broker and passes on only the broker's run token, as a secret of the agent's job (the run token); review.yml calls the same agent job, after a register job of its own. The composed example runs only the scripted agent, which registers nothing, and holds no id-token either. What the agent hands back is checked on another machine before a job with a write token acts on it (workflow).

The egress write policy for an agent job

Without the egress write policy, the agent's writes go anywhere but the metadata service. It is off by default here too. Turn it on with the workflow's egress-write-policy input, or egress.write-policy in its config file, when the agent must not send what it reads where it likes. It holds the agent, the setup script and every later step of the job.

It holds the agent less than the sandbox user is held. The agent is the runner's uid, so it gets the runner's own write rules: it can write to the Actions service with a token of its own, such as another repository's runtime token, and upload what it read there, and the pinned image's storage rule admits every Azure storage account (above). Only a second user would close that channel, so the agent job still refuses a private caller or target with the write policy on (private-repositories.md).

run's own requests are held too. The one built-in exception is egress.direct, which agentic-job config fills with an inference.url on the tailnet when the file names none, so that the broker is reached at its own address; a provider's API under register = "api-key", or a broker at a public address, needs a [[write]] rule of the caller's. The proxy's authority reaches the agent two ways: the system's store, which curl, git and OpenSSL read, and NODE_EXTRA_CA_CERTS and REQUESTS_CA_BUNDLE, which run --runner-home adds to the agent's otherwise cleared environment. CI's e2e-write-policy job runs the scripted agent this way: it reads through the proxy with curl and Node, is refused a POST, and its hand-back is uploaded and applied.

Runner outbound observation (opt-in)

The observation workflow gathers outbound destinations on a fresh hosted Ubuntu 26.04 runner, with DNS names from observed queries/answers, visible TLS SNI, ports and sampled socket owners. Enable the repository-owned Actions variable RUNNER_EGRESS_TRACE=true to run it on relevant pull requests or by manual dispatch; it is off by default. There are no dispatcher-controlled configuration inputs or extra permissions. This is measurement, not a security control: it does not stop exfiltration or change secure-host rules. It informs the later host-wide egress rules slice, whose threat is untrusted steps bypassing the proxy to exfiltrate directly.

tshark observes IP traffic, including DNS replies and background services, after an anonymous checkout (public repositories only) and tool installation. Only selected metadata fields are written: no raw capture, HTTP headers, URLs, tokens, process command lines or environment. It does not perform reverse DNS. Capture is capped at 10,000 packets, 480 seconds and 4 MiB of field output; each packet is truncated to 4,096 bytes. Socket ownership is sampled with ss every 500 ms, with a separate 4 MiB cap. Reaching a size cap stops capture; the JSON records why collection stopped. Tool failures fail the post step after publishing diagnostic JSON, including the failed command, exit status/signal and the last 4,096 characters of stderr (JSON-quoted). Successful captures do not retain stderr. The job has a ten-minute bound.

Each destination records address, port, transport, ip_protocol, dns_names, tls_sni and processes (PID and executable name, not a proven opener). Non-TCP/UDP traffic is retained with a null port and its IP protocol number (IPv6 next-header value), rather than silently omitted. dns_queries on resolver destinations are the names requested, not names of the resolver. DNS answers are associated with subsequent destination traffic; there is no retroactive guess for earlier traffic. A socket owner matches the local/remote address and port and transport within one second of a packet. Missing names and missed sockets stay empty, rather than being inferred from an IP's current reverse lookup or the currently running step.

The workflow uploads and downloads a fixture, then explicitly saves and restores a run-unique cache entry (never restoring another run's data). The cache actions use v6.1.0's Node 24 runtime on the hosted runner; path, key and fail-on-cache-miss keep their existing meanings. This does not grant cache access to the sandbox or change the reusable workflow's policy. A local JavaScript action fetches public repository metadata in its main and post steps, without credentials. The observer registers first, so its post runs last. That post stops collection, writes the versioned JSON report, adds a bounded preview to the job summary, and invokes the already downloaded, pinned upload action's bundle to publish runner-egress-observations with one-day retention. The fixture artifact alone is not the trace. No repository write token or OIDC permission is added; cache writes remain scoped by Actions.

DNS and SNI are unauthenticated observations, not an allowlist. A shared IP can serve several names, and a peer can claim a name in DNS/SNI. Names themselves can contain sensitive metadata: run this only on the fresh, secret-free probe job, not a workload carrying secrets. Source review and repeated hosted observations are needed before designing rules.

Known gaps for the rules slice: startup/checkout precede the trace; caps can end it early; cached or encrypted DNS, ECH, truncated or segmented TLS handshakes, short-lived sockets and unexercised paths can leave gaps. Socket sampling cannot prove which process originally opened a shared or inherited connection, and it does not assign a step from timing alone. Coverage ends at the final JavaScript post snapshot, before the JSON upload: the runner's completion traffic after the last step is not observed. A green probe is not proof of whole-job coverage. The observed list must be added in a fix round after this PR's hosted CI runs; no observed endpoints are claimed from local unit tests.

The merge-queue failure during the v6 update was unrelated to cache: both analysis policy jobs received HTTP 404 for a published fake-agent asset. Those CI callers now set build-binary: true to test this checkout without relying on release completeness. Release downloads still fail closed when a manifest exists but an asset is missing; repairing that published release is separate.

Inputs and outputs

The forwarder's inputs are the new action's; every one is optional.

InputWhat it sets
configA TOML file with the [sandbox], [egress] and [setup] tables: the lock, egress, the metadata refusal, the daemons to stop. Default: the forwarder's own, for GitHub's hosted ubuntu-26.04, with the egress proxy on. Another image listens on other sockets: sandbox check names each one left
checkfalse skips sandbox check --record, whose passing record lets the run action's check skip its probes (sandbox-check.md). Default true
image-archiveAn image archive, as podman load takes it, run in place of the pinned image, unverified: for a job that built it, as CI's do

It has no outputs. The action knows nothing of inference or sessions: an agent job checks its inference proxy before this step, and the run action reads a session's endpoint after it (workflow).

Where the binary and image come from

The host's binary is the image's. A release pushes ghcr.io/cgwalters-forge/secure-host:vVERSION, an image FROM scratch with the static binary and the compiled world-write object world_write.bpf.o (Containerfile), and attests it by its digest from release.yml on main; what the image does on the host is in the action's setup. A new package on ghcr.io is private until an owner makes it public.

The policy job gets the binary an agent job runs with binary.sh, a shell script since no agentic-job exists yet, which computes the source digest at the pinned commit and fetches build-FULL_DIGEST. It verifies the release manifest's GitHub attestation before checking the source digest and the checksums of the binaries and the world-write object, whose SHA-256 is in the attested release.json next to the binaries'; a manifest without it is refused whole. A missing release falls back to a source build with a notice (installing clang with musl-tools when missing); failed verification stops the job. Main publishes these releases automatically, without a pin PR. See the workflow's binary trust model for the verification and publishing boundaries. Fetching needs bash, sha256sum, jq and gh with attestation support, which it installs where it is missing. It uses the job's read-only token and retries signaled GitHub throttling within two minutes. Building needs rustup. The policy job's build-binary: true builds even when a prebuilt exists. The reusable workflow's agent job gets the object with the binary from the policy job's upload, and prepare installs it beside /usr/local/bin/agentic-job.

Where the host documentation went

Each topic is a document of cgwalters-forge/actions/secure-host:

TopicThere
The contract, the caveats of running every step as the runner's user, the sandbox userREADME
What a later step can count on, one trust level for the rest of the job, R4, R10 and R11threat model
The image, supported hosts, what setup does to the host, the lock, what a job has to know, containers, running it without the actionsetup
The egress write policy, the proxy's policy file and domain allowlist, the network rulesegress
The access-time world-write policy, its loader, and what went with the walkworld-write
Reads between the runner's own processes, the Worker's diagnostic socketsrunner reads
What sandbox check provesprobes
CI's run-time controls, where each probe has been seen to fail, timings, RHEL 10evidence