A job holds credentials of two kinds, and they need different answers.
The built-in token is handed to every job whether it asks or not;
permissions: decides what it can do, and GitHub scopes it per job, never
per step.
A repository secret — a PAT, a webhook URL — is a different object, and
permissions: says nothing about it at all. A PAT is as privileged as whoever
issued it. So narrowing the built-in token in a job that also names a PAT
achieves nothing: it puts a narrow credential beside a wide one.
Both therefore land on the same structure, for the same reason — there is no per-step scoping — and it is the only structure available:
The credential lives in a job of its own that runs no code under review.
Everything else declares read and names no secret. The rest of this says where each credential is and what keeps it away from code this repository has not reviewed.
internal/ciguard enforces it under go test ./..., so none of it needs a
workflow step: untrusted_code_test.go for what the built-in token can do, and
credential_isolation_test.go for whether a secret is reachable from code under
review. The second reads the shell scripts a job invokes, not only the workflow,
because that is where two of the routes were.
Both decide their questions from a parsed expression, not from its text
(expression_test.go). That is not fussiness — a guard that reads text answers
wrongly in both directions:
Both questions turn on what an expression MEANS, not on how it is spelled, and
a credential may be read without the word secrets followed by a name. So an
expression is parsed, a condition is evaluated, and a secret reference is found
by walking the syntax tree for any read of the secrets context — in whichever
form GitHub resolves it. Workflow-level env: is part of the model, because
every job inherits it.
So a condition is evaluated in three-valued logic against scenarios that
bind the hostile facts. The scenarios are derived from the workflow's own
triggers, not chosen from a list: scenariosForTrigger builds one for every
trigger, including a trigger GitHub ships next year, so there is no enumeration
for a trigger to be exempt from — and a workflow_call workflow answers for
every caller event plus an unknown remainder that binds no event at all.
Anything the evaluator cannot know (needs.build.result, success()) is
unknown, and unknown is not false, so a condition only counts as refusing a
situation when it is provably false there.
The evaluator implements GitHub's semantics, not an approximation of them, and the differences are each a condition somebody can build:
&&and||return the selected operand, not a boolean. Reduced to booleans,(github.event_name == 'pull_request' && 'run' || '') == 'run'comparestruewith'run'and reads false; GitHub selects'run'and runs the job.==coerces across types. Operands of different types are both converted to numbers, so'' == 0,false == 0andtrue == 1all hold, and a non-numeric string is NaN and equals nothing.
What a scenario may bind is the soundness argument. A scenario has to answer
for every instance of its situation, not for one, so it binds only values the
event determines (github.event_name, the repository, the default branch).
A value the triggering party chooses — github.head_ref, an upstream run's
head_branch — is left unknown, because binding one guess makes a
comparison against any other literal read as definitely-false and accepts a job
that is perfectly reachable.
Some facts sit in between, and a sample is wrong for those too: the event fixes
github.ref to refs/pull/<n>/merge for a pull request and refs/tags/<name>
for a tag push, while the number and the name are chosen. Those are bound as a
domain — the prefix the event fixes — so a comparison outside it is false
(github.ref == 'refs/heads/main' cannot hold on a pull request) and one inside
it is unknown, since the event fixes only the prefix. Sampling a single ref
would answer for one instance and not for the event.
Where a scenario pins a party-chosen value on purpose, to oblige one particular
condition, it goes in a separate adversarial map with its reason, and a test
refuses any other binding of a party-chosen path.
Everything that cannot be read is refused rather than interpreted: an expression
that will not parse, a permissions: scalar or access level the reader does not
recognise, a job that cannot be re-encoded for the backstop, a repository-local
action with no loadable manifest. Each fails the guard that asked instead of
reporting "nothing found".
A job that receives a credential is registered, judged, and recorded — all three, and the third cannot stand in for the second.
internal/ciguard/job_templates_test.go asks the three in order and reports
every one that refused:
- Registered.
permittedCredentialJobslists the six jobs that hold a secret or a write token. A job that is not on it is refused rather than analysed, because the set of things a workflow can do to reach unreviewed code is not a set a guard can finish. - Judged. Either the job's condition is provably false in every hostile
situation its workflow's triggers admit, or the job establishes which tree
it executes. Evaluated — see the parser section above — not matched as text;
unknown is not false, and a construction the guard cannot read is a refusal.
Three of the six jobs rest on the second half alone:
goreleaseris a reusable workflow whose event the caller chooses, the composite publisher's test caller admits dispatches of arbitrary refs, and dependencypublishhas noif:at all. Each pins its checkout to the default branch or refuses a commit that is not on it. - Recorded. The job's shape — the job block plus the workflow-level
on,permissions,envanddefaultsin scope for every step in it — matches a digest inrecordedJobDigests. Every local composite manifest it calls, recursively, is included in that record, including inputs and outputs.
Why the order matters, stated plainly because this document previously
described (2) while the code did not perform it. For one release the decision
was (1) and (3) only: the guards computed the hostile situations, asserted the
set was non-empty, discarded it, and compared sha256 of the whole workflow
file against a constant. A contents: write job given a condition that admits
pull_request, a checkout pointed at the pull request's own branch and a
run: step from that tree then passed the entire suite after one 64-character
constant was re-recorded — which was the remediation the failure message
printed. Re-recording a digest moves a constant; it cannot make a condition
false or a tree reviewed, and (2) is now asked separately so it does not try.
What (3) is for. The residue (2) cannot read: the semantics of a shell
script, a third-party action's behaviour, a field no rule names. A change to
any of that stops matching until someone records the new shape. It is a
changelog with teeth rather than a gate — main's ruleset requires zero
approving reviews, so nothing obliges a human to read the line that changed
(see branch protection). It is scoped to the
job and what the job inherits for a related reason: hashing the whole file made
a Dependabot action-pin bump in an unrelated job produce the same failure, with
the same one-line fix, as a write token entering a job that runs pull request
code.
And the refusals written in shell are executed, not read. A job accepted
because it refuses a commit that is not on the default branch must name the
test that runs that refusal against real repositories
(jobTemplate.executedBy); the guard reads that the command is present,
unconditional and fatal, and reads nothing about whether it works.
The historical reusable dispatch publisher fixture retains that execution proof: a
workflow_dispatch condition is not provably false for every possible caller
of workflow_call. With no explicit checkout ref, the tree is the triggering
commit, so the first step after checkout must unconditionally refuse a
$GITHUB_SHA not reachable from the repository's own default branch, using its
fully qualified refs/remotes/origin/ ref. A step condition, non-fatal exit,
neutralising shell, or execution before that refusal invalidates the proof.
An ancestry refusal cannot establish a checkout of a party-chosen ref, nor a
later checkout or tree repoint to unreviewed content. The template must name a
test that executes the refusal. dispatch_publisher_test.go exercises this contract
with the real publisher YAML fixture under internal/ciguard/testdata/, runs
its shell against real repositories, and re-records every unsafe mutation's
digest before asserting that the judgement still refuses it.
The service-image publisher now runs as a composite action inside the caller's
own dedicated job. Its credential and packages: write permission belong to
that job; the action cannot declare either permissions or a timeout. The core
test caller is registered in place of the removed reusable job. External
service workflows must implement the same contract; core's tests cannot inspect
those repositories' job policy.
Local composites are expanded in execution order, including repeated calls,
and recursive/unreadable actions fail closed. Their caller cannot skip or
neutralise a nested refusal with if, continue-on-error, or an interpreter
changing environment. Loading a local manifest is itself tree execution: its
own refusal cannot make an unreviewed manifest safe to load. The test caller
therefore checks out main before loading its local action. Services use the
immutable remote action and check out their triggering commit. Once inside a
reviewed local composite, any nested checkout must be attributed and any
triggering-commit refusal must precede execution, exactly as at job level.
composite_publisher_test.go proves these boundaries with real manifests and
re-records the unsafe mutations before requiring rejection. The execution test
binding follows the caller through its composite to the exact refusal script.
The publisher isolates credentials from repository inspection. Its first step
receives github.token only to run ls-remote --symref against the GitHub
server/repository URL, from a fresh empty directory under runner.temp with an
empty inherited Git environment and system/global config disabled. It neither
fetches nor opens the checkout. The next step receives only the default-branch
SHA; it never starts with GH_TOKEN or another credential in its environment.
Removing export flags is insufficient because a process's initial environment
can still be read by its children. Callers must not inject credentials through
workflow, job or composite-call environment.
The inspection requires the triggering SHA to be reachable from that resolved
SHA and HEAD to equal the triggering SHA. Full checkout history is required; if
a concurrent default-branch advance is missing locally, the action refuses and
needs a fresh run. It rejects modified/staged/untracked/ignored files and
assume-unchanged/skip-worktree entries. Callers must commit all build inputs.
It checks effective local/worktree config and includes for stored credentials,
helpers, token/password keys, URL rewrites/credentials and credential indirection,
without logging values. Inspection disables global/system Git config and system
attributes. Config-defined hooks (hook.*), filter drivers and alternate
attributes files are refused in every remaining effective scope, including
local/worktree/includes and command config.
Any build-context path that Git assigns a filter attribute from in-tree
.gitattributes, Git info attributes or default user attributes is also
refused. The guard asks git check-attr filter for every tracked and
untracked path instead of parsing attribute files, so no spelling Git accepts
(an unspaced quoted pattern, a macro, a non-UTF-8 byte) can slip past it;
symlinked or unreadable attribute files are refused. Submodules are unsupported: declarations,
gitlinks and retained .git/modules/ metadata all refuse publication, preventing
unscanned submodule credentials or code from entering inspection or the build.
Repository fsmonitor settings are refused. Every inspection Git call disables
fsmonitor and file-based hooks with core.fsmonitor=false and
core.hooksPath=/dev/null.
origin must still match the GitHub server/repository identity. Only the
isolated remote read and registry login receive tokens; login runs after the
inspection and input validation. Ciguard attributes the exact isolated reader,
binds its output to the immediately following refusal, and rejects credential
injection at that boundary. The test caller uses a committed Dockerfile fixture
through the validated dockerfile input. See the
dispatch policy.
The execution check attributes every uses: and Git invocation. Checkout
reads repository, ref, and path: another repository or an additional
checkout directory cannot be erased by a later checkout of main. Unknown
actions and Git verbs are refused. Shell is parsed, including global Git
options, substitutions and line continuations. Only a preceding fatal ancestry
check can attribute git switch --detach or git checkout --detach.
Artifact downloads are untrusted content. The coverage badge therefore relies
on its event condition; making it PR-reachable is refused even with a checkout
of main. The dependency publisher has a separate, bounded data contract:
combined-branch goes outside the workspace, and its only consumer is the
publisher script whose bundle handoff is executed against real repositories.
Changing the path or adding any consumer invalidates that attribution.
Before a triggering-commit refusal, the parsed commands may only discover and
fetch the authority, format metadata, or report a refusal. A tree script in an
assignment is still execution. Workflow, job and step environments are checked
for interpreter/Git overrides such as BASH_ENV, ENV, GIT_CONFIG_* and
PATH. The publisher's isolated authority reader has a bounded source contract;
the historical command-scoped wrapper remains attributed for older fixtures.
The executedBy binding names an exact workflow, job and step; its executing
test lifts that same binding using its own running test identity. A test for
another job cannot be borrowed to certify a new refusal.
The workflow_run hostile scenarios include same-repository pushes to any
non-default branch as well as pull requests and fork pushes. An event and
repository condition alone therefore cannot certify an unmerged branch.
Every helper must be reachable from a test entry point or init
(reachable_helpers_test.go follows resolved Go objects transitively, including
through package values; dead cycles and shadowed names do not count as uses). That is not tidiness: the six functions this
rule was supposed to be built from sat in the tree with their explanations
intact and zero callers, and a careful reader concluded the evaluation was
happening.
| Workflow | The credential | What keeps it off unreviewed code |
|---|---|---|
go.yml |
coverage-badge pushes coverage.svg to the badges branch, which README.md renders |
Its own job, gated to a push of main. Build declares read and hands the profile over as an artifact. |
go.yml |
notify holds SLACK_WEBHOOK_URL |
Its own job, gated to a push of main, with permissions: {} and no checkout at all. It reads the outcome from needs.build.result. |
version-tag.yml |
tag creates the release tag and publishes the release |
Event and head-repository conditions; checks out the default branch; refuses a sha not reachable from it. |
go-service-release.yml |
goreleaser receives the caller's release PAT |
Gated to a pushed tag, which a pull request cannot produce; then refuses a tag whose commit is not reachable from the caller's default branch. The test job that runs the caller's suite names no secret. |
test-publish-service-image.yml |
publish holds its own GITHUB_TOKEN with contents: read and packages: write and passes it as the composite action's registry-token input |
Dispatch-only dedicated job, 30-minute timeout, checkout of main with persist-credentials: false before loading the local action. The action resolves the caller's remote default-branch SHA from an empty temporary directory using github.token, then inspects the checkout in a separate step with no credential in its environment before login/build. Its registry-token input reaches only login. Filters and submodules are unsupported. The token has the caller job's write grant throughout, including checkout and the refusal; it is not a separate read-only token. External services use the same action pinned to a core SHA in their own jobs so GHCR checks the service repository. |
combine-deps.yml |
publish holds DEPS_COMBINE_TOKEN |
A job of its own that never checks the combined tree out: the commits arrive as a git bundle and move into a ref by git fetch/git push. The plan job does the replaying, with the read-only built-in token and no secret. Both checkouts pin ref: main. |
Every other job in every other workflow declares contents: read and names no
secret. secrets.GITHUB_TOKEN is not a secret for this purpose: it is present
whether or not it is named, and permissions: governs it.
plan fetches refs/pull/<n>/head for every open Dependabot pull request,
replays them onto one branch, and runs the repository's optional
combine-deps-local.sh hook over the result. That is unreviewed code, so the
job has no secret and a read-only token.
publish pushes the branch and opens the combined pull request. It holds the
PAT and never checks the assembled tree out — the commits reach it as a bundle,
and git fetch moves a ref without moving HEAD.
internal/ciguard/combine_deps_handoff_test.go runs those two git commands,
lifted out of the scripts, against real repositories and asserts the
publisher's working tree never changes.
One limit this cannot fix: a workflow_dispatch against a branch takes the
workflow file from that branch too, so a branch that edits the workflow is
outside what any assertion about the file on main can reach. Restricting who
may dispatch is a repository setting — see
branch protection.
It is the only one that writes to a ref, so it is worth reading end to end.
The trigger is workflow_run on the test workflow, because a tag must not be
cut from a red commit and a tag is permanent once the module proxy has cached
it. That trigger is also the one that can be reached from a run this repository
did not produce, so three things hold rather than one:
- the
if:requires the triggering run to have succeeded, to have come from a push, and for its head repository to be this one; - the checkout takes the default branch — no
ref:— so the only code the job can execute is code already onmain; - the triggering sha is then used only after
git merge-base --is-ancestorshows it is reachable fromorigin/main. A sha that is not a commit here, or that is a commit on no branch, is refused rather than tagged.
Then the job selects that commit with git switch --detach and reads the
version file and the contract manifest from it, because the release has to
describe the commit that passed rather than whatever main has moved on to.
internal/ciguard/version_tag_selection_test.go lifts that refusal out of the
workflow and runs it against a real repository, including the case the existence
check cannot catch: a commit that exists locally but is reachable from no
branch.
The remaining gap is not in this repository: nothing stops a tag being created by some other identity. That is a tag-creation ruleset, which an operator applies — see branch protection.
Everything above is about credentials, and it is pinned by shape because the question it answers — could this job's credential reach code nobody reviewed — is one no analysis finishes. There is a second rule here that is not part of that model and does not want its machinery.
A run: script never interpolates a value chosen by whoever triggered the
run. A run: is the one field whose content is a program: GitHub assembles
it before any shell sees it, so ${{ inputs.setup-run }} written there is not
a value the script reads, it is a line of the script, already past every
quoting rule the author wrote. The value reaches the script through the
environment instead, which keeps the program fixed:
env:
SETUP_RUN: ${{ inputs.setup-run }}
run: eval "$SETUP_RUN"eval is not a workaround for the env indirection — it is what preserves the
"arbitrary shell command" contract the input promises while leaving the value a
value. Shell -e still applies inside it, so a failing command still stops the
step.
This rule is checked by reading the parsed script rather than by pinning a
shape, and that is affordable here because the set is closed: the contexts
whose content the triggering party chooses are inputs.*, github.event.* and
github.head_ref. runner.* and matrix.* are fixed by the runner and by
this repository, so they are permitted. env.* and steps.* are a level of
indirection this rule does not follow — a tainted value can still arrive
through them, and that is what the credential model is for.
It is separate for a reason worth recording. The credential allowlist pins the
six jobs that hold a secret or a write token, so it covers a reusable workflow
holding a credential and says nothing about one holding none. Reverting
go-service-ci.yml to run: ${{ inputs.setup-run }} therefore left
go test ./internal/ciguard/ green, while the identical change to
go-service-release.yml was refused by seven tests — the comments in both
files claimed the property, and only one of them had anything holding it.
internal/ciguard/run_interpolation_test.go now holds it for every workflow in
the directory, including ones added later, and proves on each case that it
fires rather than asserting a clean tree and being believed.
What it does not cover: the absence of a check. Deleting agent-ci.yml's
semver validation of codefly-cli-version is not an interpolation, and no
guard refuses it — that file holds no credential, so it cannot join the
allowlist, and pinning its bytes would be a mechanism for one line.
go-service-release.yml and go-service-ci.yml are workflow_call workflows
that other repositories invoke, so a change here is a change in their builds.
Three of them are breaking, and none of them was written down outside a commit
subject. The consumers are codefly-dev/service-dynamodb,
codefly-dev/service-redis and codefly-dev/service-postgres.
goreleaser-version was removed as an input. A caller that still passes it
fails workflow_call validation outright, before any job starts. The GoReleaser
version is pinned inside the job now (version: '~> v2'), because the version
selects which binary runs and a caller-chosen value is a caller-chosen program.
goreleaser is gated to a pushed tag (if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')). This is the one to watch, because of
how it fails: a caller that releases on workflow_dispatch, on a release
event, or on anything other than a tag push now skips the job — and a
skipped job reports success. Such a caller publishes nothing and its run is
green. There is no error to find. If a release stops appearing, check the
caller's trigger before anything else.
A release is refused unless its tag is on the repository's own default
branch. The job reads the authority from the remote's own HEAD (git ls-remote --symref origin HEAD) and refuses a commit that is not reachable
from it. A caller that tags off a release branch is now refused rather than
released. The authority is deliberately not an input: a value a caller supplies
could be omitted, defaulted or disagreed with, and each of those is a way to
choose which history counts as reviewed.
All three admit the consumers as they stand: service-dynamodb and
service-redis pin @main, trigger on a push of v*, pass no with: and
tag on their default branch; service-postgres pins a commit, so it is
unaffected until it bumps, and its release job is if: github.event_name == 'push' and passes only setup-run.
Also narrower than before: every job in agent-ci.yml, go-service-ci.yml
and go-service-release.yml now declares permissions: contents: read. A
declaration can only narrow the caller's grant, so a caller that needs more —
packages: read to pull a private package, say — gets a failure inside the
reusable job rather than at validation. No caller of agent-ci.yml exists in
either organisation today.
Declare permissions: contents: read at workflow scope and name no secret, and
stop. If a job needs to write or to hold a secret, give it its own
permissions: block, its own if: pinning it to a push of main or a tag,
and no checkout of anything a pull request supplied. Keep party-chosen values
out of every run: script, whether or not the job holds a credential.
Running go test ./internal/ciguard/ tells you whether you got it right, and
for a credential-bearing job it now tells you which of the three parts of the
rule refused and why. A failure naming only the record is a failure you may
clear by recording; one naming the judgement is not.