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'shosted.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. Aconfigof the caller's own replaces it whole. The new action setsegress.write-policyfrom its ownegressinput, over the file, so the forwarder passesegress: write-policywhen 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.ymlpin the same commit of the new action; its comment names the image tag that commit runs, and CI'sci.test.cjsrefuses 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,binaryorbuild. 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-diagnosticsandsandbox.deny-runner-readsfollowsandbox.lock-runnerunless a configuration sets them, and setup andsandbox checkprint what each one set false leaves a later step. A host that runs no singleRunner.Workermust setdeny-runner-reads = false. In the pinned image each is off unless set true, and the forwarder'shosted.tomlsets 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 haveRunner.Workerdump its memory.- Under
egress.write-policy, the runner's storage writes go only to the twentyproductionresultssaNaccounts (#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 = falseskips 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:
sudo agentic-job sandbox setup --config FILEagentic-job sandbox checkNeither needs RUNNER_TEMP, the GITHUB_* environment or a
Runner.Worker process. What changes without them:
- With no
Runner.Worker,sandbox.unlink-worker-diagnosticshas no socket to unlink: setup prints that it skipped the step and why, andsandbox checknotes 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 setssandbox.deny-runner-reads = false; the loader refuses it otherwise and names this key.sandbox setupalone never loads the rule. - The check record that lets a later check in the same job skip its
probes (sandbox-check.md) needs
RUNNER_TEMPandGITHUB_RUN_ID,GITHUB_RUN_ATTEMPTandGITHUB_JOB. Without them there is no record, and every check makes its probes. sandbox timedis 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.
| Input | What it sets |
|---|---|
config | A 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 |
check | false skips sandbox check --record, whose passing record lets the run action's check skip its probes (sandbox-check.md). Default true |
image-archive | An 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:
| Topic | There |
|---|---|
| The contract, the caveats of running every step as the runner's user, the sandbox user | README |
| What a later step can count on, one trust level for the rest of the job, R4, R10 and R11 | threat model |
| The image, supported hosts, what setup does to the host, the lock, what a job has to know, containers, running it without the action | setup |
| The egress write policy, the proxy's policy file and domain allowlist, the network rules | egress |
| The access-time world-write policy, its loader, and what went with the walk | world-write |
| Reads between the runner's own processes, the Worker's diagnostic sockets | runner reads |
What sandbox check proves | probes |
| CI's run-time controls, where each probe has been seen to fail, timings, RHEL 10 | evidence |