agentic-job
agentic-job makes a CI job safe to run untrusted steps in, and then runs a coding agent as one of them.
Agent security is just CI security. A coding agent that reads an issue and writes a patch is the same kind of risk as a build script from a pull request or a dependency's install hook: code you did not write, steered by text you did not write, on a machine that holds your tokens. The defence is the same for all three. Take root away from that code, run it in a job that holds no token it could misuse, and have a different machine check what it produced before anything with a write token acts on it. agentic-job is those things as a setup step any job can use, and a workflow that runs an agent that way: as the runner's own user, with no write token, and outside the egress proxy's filter (what separates it).
What you get
A hardened runner, in one step. After secure-host runs, the
runner's user can no longer use sudo and the container daemon is
stopped, and a world-writable file can be changed only by its owner.
Every later step, the agent included, runs as the runner's user. The
step ends by probing these restrictions and fails the job if one does
not hold.
jobs: build: runs-on: ubuntu-26.04 steps: - run: sudo apt-get install -y libfoo-dev # root: before the step - uses: actions/checkout@v7 - uses: cgwalters-forge/actions/secure-host@COMMIT - run: make check # runner's user, no sudoThis repository's secure-host forwards to that action
(what changes at @main).
make check above lost root but still runs as the user that holds the
job's token; so does every uses: step. See
one trust level for the rest of the job.
Two limits to know up front. Names still resolve on a hosted runner, so a process that wants to can leak data through DNS lookups. And one small root-owned helper stays, for what the sandbox itself needs. The action's probes list what is probed, and its evidence what is not.
An agent run with no write credential in reach. The reusable workflow runs Claude Code, opencode or Vibe on that hardened runner, as the runner's user, on a task you give it. The agent holds no model key and no token that can write to the forge. It reaches the model through a credential broker on another machine, which holds the key and caps what the run can spend (inference, below); on main that is the only way the workflow gives a real agent a model. The agent cannot push, comment or open anything. It leaves a file of requests ("open a pull request with this patch", "comment on this issue"), which a second job checks on a clean machine and a third applies. The jobs that hold a write token, which are apply and, for runs started by an event, the ones that post the run's status, run nothing the agent wrote.
Run summaries include the redacted session by default, with tools and reasoning
collapsed. Set render-session = false in [agent] in the repository-owned
sandbox configuration to opt out. Long sessions keep assistant prose first in
the bounded summary; session.md in the agent-run artifact keeps the full
rendering. See reading a session.
Which tokens reach what
| Where | What it holds |
|---|---|
| The agent | the agent job's own GITHUB_TOKEN as GH_TOKEN, with contents, issues, pull-requests and actions read only, expiring with the job, unless github-reads: false (GitHub reads); the inference proxy's token for this one run. Not the identity-token request credential, not a model key, not an MCP server's credential, not SAFE_OUTPUTS_PAT |
| The runner's user in the agent job | the agent itself: so the reusable workflow's agent job passes on no model key or MCP credential, and run --runner-home refuses api-key and MCP servers (the agent job); the same read-only job token; under github-oidc, the run token the register job before it got, in the run step only (the agent job has no id-token: the run token); ACTIONS_RUNTIME_TOKEN in JavaScript actions; whatever the caller's own steps add, such as a network join. Losing root does not separate a step from these (what a later step can still do) |
policy, check | the job token with contents and actions read; no secret. With require-safe-outputs-pat, the policy call's apply-token job reads with SAFE_OUTPUTS_PAT in one step, in the apply environment |
apply | SAFE_OUTPUTS_PAT when the call passes a nonempty one, else the job token with what the call granted (the apply job and its token) |
activate, notify, conclude | the job token with what the call granted, for labels, reactions and status comments; notify and conclude prefer SAFE_OUTPUTS_PAT for status comments whenever supplied, including same-repository items |
SAFE_OUTPUTS_PAT is gh-aw's safe-outputs.github-token for
cross-repository safe outputs.
A pull request needs it: apply opens one from a fork of the account
the token belongs to.
jobs: agent: uses: cgwalters-forge/agentic-job/.github/workflows/agentic-job.yml@COMMIT with: id: ${{ github.run_id }} task: Fix the flaky test in tests/foo.rs repo: ${{ github.repository }} outputs: create_pull_request,noop allow: .github/agentic-job/allow.toml # what a run may ask for at all agent: claude inference-url: ${{ vars.INFERENCE_URL }} # the broker's root inference-audience: ${{ vars.INFERENCE_AUDIENCE }} agent-runner: ${{ vars.AGENT_RUNNER }} # a runner that reaches itThat is the shape, not the whole call: a caller also grants the job its
permissions. Try it has a whole caller, first with the
scripted agent, which needs no model. The call passes no model key: the
agent job refuses a provider's API key and GitHub's billing for now
(refused for now).
COMMIT may also be a released version's tag, such as v0.6.4, which
Dependabot can then upgrade: see
versions to pin.
The docs describe main: a tag lacks what was merged after it (after
v0.6.4, for example, the budget-usd input). v0.6.4 refuses a key in
the agent job as main does. The releases page
also lists a build-DIGEST release per source; those carry prebuilt
binaries for commit pins and are not versions.
The workflow is four pieces, and a caller whose agent job needs steps of its own, such as joining the inference proxy's network, calls three of them and writes that job itself: the pieces.
A run can also start from an event: a /command in a comment, a label,
a pull request, a schedule. The workflow decides who may trigger it
before anything untrusted runs, and hands the agent the event's text
fenced off as data. See docs/events.md.
Sandbox execution and runner deprivileging
Two separate things protect the agent's job, and they protect different steps.
- Runner deprivileging is
secure-host's, for every step after it, the agent included (unless its configuration setssandbox.lock-runner = false): no later step gets root. Unless the configuration setssandbox.deny-runner-metadata = true, later steps still reach the cloud metadata service through this repository's forwarder until its image has main's default for that rule (what main has). It does not separate those steps from the job's tokens. The action's contract and threat model say what it claims, on which hosts, and how it is probed. - Job separation is what keeps the agent's credentials apart: the
agent's job holds no secret, no write token and no
id-token, and what it hands back is checked on another machine before a job with a write token acts on it (workflow).
How the agent job uses the action is in
docs/secure-host.md, and the sandbox user, which
run without --runner-home starts the agent as, in
docs/sandbox.md.
Inference
A real agent needs a model, and never holds the key that pays for it.
On main the reusable workflow gives it one through a credential broker
alone (docs/inference.md): a key in the workflow is
refused for now.
The scripted agent (agent: fake) needs none.
A credential broker is a
praxis-credential-broker
that the agent's runner can reach, which registers each run with the
job's identity token, and caps and ends it. No long-lived key is in the
calling repository. (inference-register: token-file hands run a
token for some other proxy, but nothing then counts or ends the run, and
nothing here makes such a token.)
What the broker has to provide:
- its root URL, given as
inference-url, serving the Anthropic API at/anthropic(Claude Code), the OpenAI Responses API at/v1(opencode) and Mistral at/mistral/v1(Vibe); - the run API:
POST /v1/runswith the job's GitHub identity token for the audienceinference-audience(the broker's default ispraxis-credential-broker) answering 201 with a run token, thenGETandDELETE /v1/runs/self; - an admission entry in its
run-token policy:
called_workflowsnamingagentic-job.ymlat the commit the caller pins and the calling repository's numeric id, or, for an agent job of the caller's own,any_workflowfor that repository (which workflows a broker admits).
The sandbox user reaches the broker directly only at an IPv4 address in
100.64.0.0/10, that is on a tailnet. A host name, or a public https://
broker, goes through the egress proxy, whose write rules refuse the
agent's requests to it with a 403. The runner joins that network in a
privileged step before secure-host; GitHub's hosted runners cannot
reach a private broker without one.
Refused for now: a key in the reusable workflow
Do not use these modes with agentic-job.yml on main: its agent job
refuses them. By default the agent there is the runner's user, which
could read a key from run: run --runner-home refuses api-key and MCP
servers, and with either agent-user the workflow passes
INFERENCE_API_KEY and MCP_TOKEN to no job. Lifting the refusal for
the runner's user waits on
#1618.
What the binary does with them, in an agent job of your own that runs
the agent as the sandbox user (run without --runner-home), and only
the step that runs agentic-job run gets the key:
- A provider API key (
register = "api-key"):urlis the provider's API root andproviderthe API it speaks,anthropicfor Claude Code oropenaifor opencode.runchecks the key with the provider, then serves the agent a loopback proxy that takes a token made for the run and puts the key in its place, forwarding model requests only and counting them formax-requests. Nothing in the job bounds the key itself: it is long-lived, and its spending limits are the provider's. Enabled threat detection fails closed in this mode (a provider API key). - GitHub's billing (
register = "api-key",provider = "github"): the same proxy with a GitHub token as the key, a fine-grained token with the Copilot Requests permission or a job token withcopilot-requests: write, never the agent's own GitHub token (GitHub billing).
Try it
This caller runs the scripted agent, which needs no model and no broker.
It ignores the task, tries the things it must not be able to do, changes
one file, and the change comes back as a draft pull request from a fork
owned by the bot account below. example.yml
is the same caller as this repository runs it. The
dispatch trial adds editable sessions for patches,
comments and reviews, still without a model.
.github/workflows/agent.yml, in a public repository of your own:
name: agenton: workflow_dispatch: inputs: task: { type: string, required: true }permissions: {}concurrency: group: agent-${{ github.workflow }}jobs: run: # A full commit you have read, or a version tag (see above). uses: cgwalters-forge/agentic-job/.github/workflows/agentic-job.yml@COMMIT permissions: contents: read issues: read pull-requests: read actions: read id-token: write secrets: SAFE_OUTPUTS_PAT: ${{ secrets.SAFE_OUTPUTS_PAT }} with: id: ${{ github.run_id }} task: ${{ inputs.task }} repo: ${{ github.repository }} outputs: create_pull_request,noop allow: .github/agentic-job/allow.toml # The hardening for hosted ubuntu-26.04, read from agentic-job at # COMMIT: no copy to keep, and the pin covers it. config: .agentic-job-source/.github/agentic-job/hosted.toml apply-environment: agent-apply agent: fake # Nothing counts model requests without a broker, and a cap that # nothing counts is refused. max-requests: "0".github/agentic-job/allow.toml, with your repository in repos and
your default branch in bases:
hosts = ["github.com"]repos = ["OWNER/REPO"]bases = ["main"]max_outputs = 3max_patch_bytes = 1048576max_patch_files = 50
[outputs]create_pull_request = { max = 1 }noop = { max = 1 }missing_tool = { max = 1 }What else it needs, all generic:
- Accounts. Yours, owning the repository, and a dedicated bot account that does not own it, belongs to no organization and has write access to nothing but its own forks.
- Secret.
SAFE_OUTPUTS_PAT: a classic PAT of the bot account with therepoandworkflowscopes, stored in an environment namedagent-applythat is limited to your default branch (pull requests come from a fork). Your own PAT is refused in apply, after the agent has run, since its account owns the repository and has no fork of it (unless the repository accepts that); an empty secret fails there too. Setrequire-safe-outputs-pat: trueto have both refused before the agent runs. - Settings. Actions enabled, and, if the repository restricts which
actions run,
cgwalters-forge/agentic-joband the actions it pins allowed. The pull request comes from a fork, so its CI runs as an outside contributor's would, waiting for approval where the repository asks for it.
Commit both files to your default branch and start it:
gh workflow run agent.yml -f task='say hello'Recent main commits normally have a prebuilt binary, published automatically from their source. If you start before that build finishes, the run prints a notice and builds locally (about two minutes). Downloads are verified against a source-matching attested manifest; see where the binary comes from.
A real agent needs a credential broker (inference).
The caller replaces agent: fake with claude, drops
max-requests: "0" (the default caps at 150 requests, which the broker
counts), and adds the broker, with no secret beyond SAFE_OUTPUTS_PAT:
agent: claude inference-url: ${{ vars.INFERENCE_URL }} inference-audience: ${{ vars.INFERENCE_AUDIENCE }} agent-runner: ${{ vars.AGENT_RUNNER }}with three repository variables: INFERENCE_URL and
INFERENCE_AUDIENCE from the broker's operator (inference),
and AGENT_RUNNER, a runner label as JSON. A runner other than hosted
ubuntu-26.04 also needs a config of its own in place of hosted.toml
(a caller). The broker's admission entry
and the network route to it are the deployment's, not agentic-job's. In
this project's own deployment the broker is on a tailnet, and the route is
either a disposable self-hosted runner on it or a hosted ubuntu-26.04
job that joins it before secure-host, in the
composed form
or as review.yml's network step. Another
deployment brings its own. agentic-job provisions no runners: a
self-hosted one must take one job and then be thrown away, with
passwordless sudo, systemd 257 or later, Node, and apt-get or dnf
(runners). The
real agent checklist has the rest.
Why not GitHub Agentic Workflows
For most people, gh-aw is the right choice today. This project uses its safe outputs format and its unmodified, pinned code to check and apply requests.
The difference is where the agent runs. gh-aw puts it in a container
behind a firewall, with
VM isolation in preview.
That is a good boundary until the work itself needs containers: gh-aw's
mode for that
hands the agent the host's Docker socket, and its own docs warn that
this lets the agent around the firewall. agentic-job hardens the runner
VM instead and runs the agent as an ordinary unprivileged user on it,
with /dev/kvm, user namespaces and a login session of its own. That is
the work this was built for: operating system and container tooling.
Rootless Podman must be installed before hardening. Container build steps
that need the network also need --network=host and a read-only mount of
the egress proxy's CA bundle at the image's trust-bundle path; these are
not Podman defaults. See the container recipe.
Three smaller differences:
- The hardening is not tied to the agent. A build-and-test job with no
agent can use
secure-hostby itself. - The model key is never on the runner. gh-aw keeps it out of the agent's process, in an API proxy beside it on the same runner, and has keyless modes too. Here the runner gets a token good for one run, and the key stays on another machine.
- There is no compiler and no new workflow language. You write ordinary
workflow YAML that calls a reusable workflow.
The opt-in workflow checker enforces job shape;
this repository runs it in the required base-trusted ingress
vetjob.
gh-aw has much that this does not: more than twice as many output types, memory carried between runs, cache memory and many more triggers and engines. This has 24 output types, opt-in threat detection and checked but unstored memory. docs/background-ghaw.md compares them item by item, gh-cao's campaigns and dashboard included.
Why not Paperclip
They are different layers. Paperclip is an application that manages a team of agents: who works on what, with what budget, reporting to whom. Where its agents run is a separate matter, and its local adapters run them on the host unsandboxed. agentic-job is a machine made safe to hand to one agent for one task, and it has no opinion on who decides the task. Its sibling agent-board is the part that answers the questions Paperclip does, using a forge's own project board in place of a database.
What is real today
The binary is Rust; the workflow, the sandbox and the checks run on
same-repository pull requests here, end to end, with the scripted agent, on
GitHub's hosted Ubuntu 26.04 runners. Claude Code and opencode have each
run real tasks on it on RHEL 10 runners. It has been called from another
repository, cgwalters-bot/agentic-job-trial: runs
37587896350
and 37588497422
opened pull requests #1
and #2 there
(deployment record, #59).
Those runs predate pull requests from a fork: their branches were pushed
to the trial repository itself, a path apply no longer takes. The fork path
has run live once, from cgwalters-forge/tracker
(what has run); the
caller in Try it, from a fresh repository, has not.
The operator's runner repository, bootc-dev/cgwalters-devspace-sandbox,
calls the reusable workflow through cgwalters-forge/actions' wrapper,
with the agent job on bootc-dev's RHEL 10 runners. A research run on 16 cores
(39465607338)
posted its result as a comment. An implement run on 4 cores
(39456746786)
ended with no result: the caller names no fork, so it could open no pull
request. Neither run used the chat session.
Claude Code and opencode have also run through the reusable workflow on
hosted runners using its old built-in tailscale-* inputs to reach the
private inference proxy (retest record).
Those inputs have since been removed; see
Migrating from the Tailscale inputs.
For a private network join today, use the caller's own step in the
composed form.
This repository's review gate does that with opencode on hosted
ubuntu-26.04 (run 38666714558); no other caller has.
Not supported: most of gh-aw's catalogue. Private targets are refused unless the caller opts its own repository in under the conditions of private repositories. Pull requests from forks do not get the end-to-end jobs that write. The sandbox setup takes two to five minutes on a hosted runner. Nobody but its authors has used it.
Known gaps
Behaviour that does not match what a new user would expect, each with the issue that tracks it, where one is filed.
- Steps after
secure-hostlose root but keep the job's tokens and an open network (#503). No agent job hasid-token: a register job before it registers the run (the run token). - The egress proxy has no read allowlist: any host may be read (#116).
- Names resolve for the sandbox user on hosted runners, so data can leave through DNS (#21).
- The world-write policy is proven at run time on hosted Ubuntu 26.04 (CI's controls), not on RHEL 10 (#136).
- The reusable workflow takes no model key or MCP credential, so a real
agent needs a broker, until the runner's processes are kept from
reading
run's environment and memory (#1618; refused for now). Where an agent job of your own uses a key, nothing outside the provider caps what it spends. - A broker admits an agent job of the caller's own only by repository, any workflow of it (tracker#452).
- A missing, empty or owner's
SAFE_OUTPUTS_PATfails only in apply, after the agent has run, unless the call setsrequire-safe-outputs-pat(checking the token first). - A push to a stale fork with a PAT lacking the
workflowscope is untried (#426). - Setup takes minutes (#188).
Where to read more
- cgwalters-forge/actions/secure-host: what the setup step does to the host, its threat model, and what a job has to do before it.
- docs/secure-host.md: how agentic-job forwards to that action and how its agent job uses it.
- docs/sandbox.md: the sandbox user, and why a second user and not a container.
- docs/safe-outputs.md: how requests are checked and applied.
- specs/run-contract-spec.md: the run contract and its enforcement evidence.
- docs/inference.md: the broker, and what it needs.
- docs/mcp.md: a remote MCP server whose credential the agent never holds.
- docs/events.md: starting runs from events.
- docs/dispatch.md: fixed operator dispatch profiles and setup.
- docs/skills.md: the skills and subagents a run installs for its agent.
- docs/related-projects.md: other projects in this space.
- docs/stable-interfaces.md: what a caller may rely on across releases, and CHANGELOG.md: what each release changed.
- docs/plan.md: what is left.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.