Checking a repository's workflow files
agentic-job workflows check [--root DIR] [--exemptions FILE] gives
ordinary, hand-written workflow files the job shape guarantee that the
compiler used to give. The rules come from workflow-compiler's
src/shape.rs and lib/gha.ncl.
Those files checked compiled output independently of its source. This
command checks the files themselves, so nothing is generated. It reads
every .yml and .yaml file directly in ROOT/.github/workflows, the
only place GitHub reads, with the extension in any case. It exits 0
when nothing is refused and 1 when something is, including a workflow
file it cannot read or parse. It exits 2 when the exemptions file or the
directory cannot be read (see interface.md).
This repository runs it in the required vet job on every pull request and
merge group, from base-branch code with base exemptions. Callers opt in by
installing a trusted check; nothing is enabled in their repositories by default.
What each job must be
Every job must do one of three things. It can call the reusable workflow
cgwalters-forge/agentic-job/.github/workflows/agentic-job.yml at a
40-hex commit, which holds the agent, check and apply jobs. It can
have steps that include secure-host, named as
cgwalters-forge/agentic-job/secure-host or by its standalone path
cgwalters-forge/actions/secure-host, to which the first now forwards.
Or it can be a deploy-only Pages job. Each
refusal below names the file, the job and the step (by number, with its
name, action or first script line), and says what to write instead.
| Refused | The threat it stops |
|---|---|
| A job that neither runs secure-host nor calls the reusable workflow, and is not a deploy-only Pages job | Untrusted steps on a host whose runner's user still has root, and whose other users' files are open |
A secure-host step with if: or continue-on-error:, or inputs outside the closed allowlist below | A job that goes on without hardening, or redirects hardening so the runner's user keeps root |
A post-hardening condition with a status function other than standalone success(), or continue-on-error other than literal false on the job or a pre-hardening step | Later steps running after an earlier failure skipped hardening, or a hardening failure being silently ignored |
A uses: before secure-host other than GitHub's checkout, cache, cache/restore, download-artifact and setup-{dotnet,go,java,node,python}, including a local ./ action | An action running with root before hardening, which can leave a root process or sudo rule behind that secure-host does not undo |
Any step after actions/checkout and before secure-host, other than another checkout | The checked-out tree, which may be anyone's, run with root before hardening by a script or by an action that reads it |
id-token other than none in the permissions of a job that runs secure-host | Its untrusted steps minting OIDC tokens for any audience; ask for them in a separate job |
A secrets expression anywhere in a job that runs secure-host, an if: or the workflow's env: included | A secret readable by every step after secure-host, which all run as one user; use it in a separate job that takes artifacts as data |
A uses: not pinned to a 40-hex commit (local ./ actions after secure-host are the job's own checkout) | A moved tag or branch that swaps the code a step runs |
container:, services: or a docker:// action in a job that runs secure-host | Nothing a check needs to stop: secure-host stops the container daemon, so these would fail at run time anyway |
A job without its own permissions: map | A job token with the workflow's or the repository's default grant, which may be write |
Any expression outside the closed execution-context allowlist in the fields below, including every env.* expression; any expression in uses | Text supplied by others becoming code or selecting code run with the job's token; env shadowing cannot bypass this rule |
actions/checkout without persist-credentials: false | The job's token left in .git/config, where every later step and anything it hands the sandbox can read it |
An agent step outside the reusable workflow: the prepare and run actions of this repository, Claude Code, Codex and Gemini CLI actions, or agentic-job run in a script | An agent running as the runner's user with the job's token and files, with no sandbox and no check of its outputs on another machine |
A key these rules read (uses, run, permissions, container and the like) spelled in another case | A file that GitHub might read differently from this check |
| Case-folded duplicate action inputs or env keys | GitHub choosing a different value from the checker; action inputs are read case-insensitively, including lock, config, script and persist-credentials |
Deploy-only Pages jobs
Publishing to GitHub Pages needs actions/deploy-pages in a job with
pages: write and id-token: write, and a job that runs secure-host may
not hold id-token. So a job without secure-host that has an
actions/deploy-pages step is held to a narrower shape instead: a build
job runs secure-host and uploads the site with
actions/upload-pages-artifact, and the deploy job only takes that
artifact as data; deploy-pages fetches it itself. The OIDC request token
and the Pages write grant are safe there because nothing the repository
or a proposal wrote runs beside them. Expression, key-case,
permissions: map and step-key rules are those of other jobs.
| Refused in a deploy-only job | The threat it stops |
|---|---|
A step other than uses: of actions/deploy-pages, including actions/checkout, a run: step, a local ./ or docker:// action, and actions/download-artifact | Repository or proposal code, or an action that runs it, executing with the OIDC request token and the Pages grant and no hardening; download-artifact's path: can extract over the action's own code, and its repository: fetch another repository's files |
An env: in the workflow, the job or a step | NODE_OPTIONS or LD_PRELOAD loading code into the action's own process |
A deploy-pages input other than artifact_name, error_count, preview, reporting_interval and timeout, or any value that is not a literal | Another token or a value others supplied steering the deployment |
A uses: not pinned to an allowlisted upstream commit | A moved ref or a fork-network commit that swaps the code holding those credentials |
A grant other than pages: write, id-token: write and contents: read | A job that runs no secure-host holding more than deploying needs |
container: or services: | An image the repository names, running beside the deploy steps |
A secrets expression anywhere in the job, an if: or the workflow's env: included | A secret handed to the action, which needs none |
This trusts GitHub's action at the pinned commit to serve the artifact
as data, as it is documented to. Only upstream v5.0.1,
368f82528645a54fb793d4d04e342629a3f51346, and v4.0.5,
d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e, are admitted. Additions to
the allowlist need review as credential-bearing code: GitHub also
resolves fork-network commits under the upstream repository name. The
check does not judge runs-on:: a reused self-hosted runner could hold
a process an earlier job left behind.
Execution surfaces
The table-driven rule covers these fields from
GitHub's workflow syntax.
Every row uses the same expression allowlist, except uses, where GitHub
does not permit expressions and the checker refuses every expression.
Container shorthand strings are images. Container and Docker fields are
checked even though secure-host jobs also refuse containers outright.
The hardening boundary must use one of the two secure-host paths at a
40-hex commit; a local action is not certified. Its only admitted input is
check set to literal true (a YAML boolean or string), the one input that
means the same to the forwarder and to the old action at a commit before the
forward. Omitting inputs keeps the action's defaults. All other inputs are
refused: the forwarder fails on the old action's lock, egress,
deny-runner-metadata and build, and at an old commit their admitted values
only restated its defaults or chose how to get the binary; image-archive
runs an unverified image; runner-user
and the old action's other user redirect identities; stop-services replaces the daemon
list; binary replaces trusted code; egress-policy and script select
unchecked files. config is also refused, even with lock: true: this checker
does not validate configuration files, which can redirect the runner lock.
Unknown inputs, expressions and other values fail closed.
The checker is not told which repository it checks, so even the repository
that defines secure-host cannot name it as ./secure-host: it exempts the
workflow files that do.
| Scope | Field |
|---|---|
| Step | run |
| Step | shell |
| Step | working-directory |
| Step | uses |
| github-script step | with.script (input names are case-insensitive) |
| Docker step | with.entrypoint |
| Docker step | with.args |
| Job | uses |
| Job | defaults.run.shell |
| Job | defaults.run.working-directory |
| Workflow | defaults.run.shell |
| Workflow | defaults.run.working-directory |
| Job | container (shorthand image) |
| Job | container.image |
| Job | container.options |
| Job | services.<id>.image |
| Job | services.<id>.options |
Unknown step keys and unknown keys in job/workflow defaults or
defaults.run are refused, so a future execution field cannot silently
bypass the rule. Defaults accept only run, and its map accepts only
shell and working-directory. Docker entrypoint and args inputs are
checked conservatively on all actions, since an action can hide its Docker
implementation. Arbitrary other action inputs are not interpreted as code;
the checker cannot determine what arbitrary action implementations execute.
Execution expressions must be a single direct property reference. The closed
allowlist is runner.{os,arch,name,temp,tool_cache,debug,environment},
github.{sha,run_id,run_number,run_attempt,repository,repository_id,repository_owner_id},
steps.<id>.{outcome,conclusion}, job.status, and matrix.<key> only
when the matrix is an object with no expressions anywhere in its values.
Functions, operators, indexing, unknown properties and all other contexts
are refused, including env, inputs, needs, secrets, vars, event
numbers and step outputs. Pass values through step env: and read quoted
shell variables (or process.env in github-script); no env taint tracking
is needed. run: steps before secure-host and before any checkout are
allowed: they are the job's own commands, such as installing packages,
and run with root. Only actions/checkout starts the ordering rule: code
fetched by a run: step, a downloaded artifact or a restored cache before
secure-host is not recognized, so keep those after it. The secrets rule matches the word in any expression,
from the first ${{ of a value to its end, so one in a string literal or
after the expression is refused too. The agent rule is a list of known actions and one
command, not a way to recognize any agent. A docker action named through
action.yml and not docker:// is not detected; it fails at run time
once the daemon is stopped. After secure-host, conditions without status
functions retain GitHub's implicit success() guard. Conditions with status
functions are conservatively refused unless they are just success();
even success() || always() can run without hardening. The check does not
otherwise judge conditions, triggers or what an allowed action does. The
ingress check and review do.
Exemptions
A workflow file may be exempted only by its name, with a non-empty reason,
in .github/agentic-job/workflows.toml:
[exempt]"ci.yml" = "This repository's CI: tests this revision's source, naming it by local path."An exempt file is not parsed or checked. Its name and reason are printed,
and an entry for a file that does not exist is refused. When the files
come from a proposal, pass the base branch's list with --exemptions
so that the proposal cannot exempt itself. Without the option, the file
under --root is read, and a missing one exempts nothing.
Deleting or renaming an exempt workflow is therefore refused, including with
an explicit --exemptions file. A maintainer must land the exemption removal
first, then delete or rename the workflow in a subsequent proposal.
This repository's own workflow files are all listed, each with its reason. They call the reusable workflow and secure-host by local path to test this revision, or are the reusable workflow's own jobs, or its build, release and CI. A caller names the same files by commit and passes.
Bounds
Workflow files are read without following a link (a link is refused), up to 1 MiB each and 500 in all. The exemptions file is read the same way, up to 64 KiB. YAML is read from the parser's events into a tree built here, not through the parser's loader. Nesting stops at 64 levels, and a file stops at 100,000 nodes or 8 MiB of scalar text (keys included). YAML anchors, aliases, merge keys and explicit tags are refused. Use ordinary mappings and sequences instead of sharing YAML nodes. Mapping keys must be strings; duplicate keys and multiple documents are also refused. Refusals name the line. This deliberately smaller YAML subset prevents alias expansion from bypassing the depth bound. The command checks workflow files, not action manifests.
Installing a caller check
The threat is a proposal exempting itself or replacing the checker with code
that always passes. Keep the checker, script and exemptions outside the proposed
tree. For example, copy workflow/ingress.py from the same source revision as
this document into the caller's base branch (review and land it there first),
then use this job. The separate binary pin below supplies the existing CLI;
it does not supply the ingress script. This example runs only the
shape checker; this repository's ingress workflow
also installs and runs pinned actionlint and zizmor.
name: workflow-policyon: pull_request_target: types: [opened, synchronize, reopened, edited] merge_group: types: [checks_requested]permissions: contents: readjobs: workflow-policy: permissions: contents: read runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ github.event.merge_group.base_sha || github.sha }} fetch-depth: 0 persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: repository: cgwalters-forge/agentic-job ref: 23e74c719b7a50c9f576a5de4e630eeeb13ea357 path: checker persist-credentials: false - run: cargo build --locked --release -p agentic-job working-directory: checker - env: BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} HEAD: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} PR: ${{ github.event.pull_request.number }} run: | [[ "$BASE" =~ ^[0-9a-f]{40}$ && "$HEAD" =~ ^[0-9a-f]{40}$ ]] git fetch --no-tags origin "$BASE" "$HEAD" PYTHONPATH=workflow python3 -c 'import os; from ingress import check_workflows; raise SystemExit(0 if check_workflows(".", os.environ["HEAD"], "checker/target/release/agentic-job", ".github/agentic-job/workflows.toml") else 1)'The binary pin above contains the checker command. Fetching commits
by SHA works for public repositories; for PR heads use refs/pull/$PR/head if
the forge does not allow fetching the head SHA directly. No proposed checkout,
action, build script or exemption runs. Permissions remain contents: read;
pass no secrets. The temporary workflow tree is limited to 500 regular files
of 1 MiB each, and analyzer output is quoted against workflow-command injection.
This job neither runs secure-host nor calls the reusable workflow, so the
checker refuses it. Exempt it in the base branch's
.github/agentic-job/workflows.toml, as this repository exempts its own
ingress workflow: it runs base-branch code only and reads the proposal as data.
[exempt]"workflow-policy.yml" = "Runs the base branch's checker on proposed workflow files as data, with a read-only token."Land this job and the exemptions through maintainer review, run it once, then
require workflow-policy in the branch ruleset for pull requests and merge
groups. Prefer a ruleset-required trusted workflow: a required status name
alone can be imitated by a proposal's job. Keep human approval of the current
head before merge-queue admission; see ingress limitations.