Skip to content

Admit the reviewed native release workflow from its tag ref - #283

Merged
rmcdaniel merged 3 commits into
mainfrom
ci/957-native-tag-dispatch-policy
Nov 2, 2026
Merged

rmcdaniel merged 3 commits into
mainfrom
ci/957-native-tag-dispatch-policy

Conversation

@rmcdaniel

Copy link
Copy Markdown
Member

Central-policy prerequisite (1) for durable-workflow/server#957, per Security's review hold (server#957 comment 6588879997).

Why: server#957 dispatches native-release.yml from refs/tags/native-X.Y.Z, so the build attestation's source digest equals the release commit and releases no longer race main's tip. The central preflight refuses every privileged workflow_dispatch job that can run outside refs/heads/main, and that refusal is correct for #957 today.

Change: a new workflow_trust.privileged_tag_dispatch_workflows binding, used only by server/native-release.yml. It is not a general privileged-tag allowance.

  • A privileged dispatch job that isn't main-protected is admitted only when all of these hold:
    • it is one of the four listed jobs (aliases, build-amd64, build-arm64, publish);
    • it uses environment dockerhub-native;
    • it needs the plan job;
    • its if: contains github.ref == format('refs/tags/{0}', inputs.tag) as a top-level conjunct, with no || and no negation.
  • When any job relies on the binding, the whole workflow must also hold:
    • its only trigger is workflow_dispatch, with a required string tag input;
    • the plan job has no job condition and no environment, secrets or write permissions, so a wrong-ref dispatch fails in plan instead of skipping green.
  • The predicate is computed from tag_input in code, so policy data can't loosen it. A binding whose workflow or job is missing fails, like the existing workflow_run consumer binding.
  • Server main's current main-only native-release.yml still passes, because no job relies on the binding. This change doesn't break server CI before #957 merges.

Verification: in a python:3.12 container with jsonschema 4.23.0 and PyYAML 6.0.2:

  • New tests: tests.test_qualification_policy has 11 new tests and all 40 tests in the module pass. They cover:
    • the accepted shape;
    • refusal without the binding;
    • no transfer to another workflow or target;
    • a conditioned or privileged plan job;
    • five loosened predicates (|| true, negation, != main, startsWith, refs/heads format);
    • wrong environment, and a missing needs;
    • an unlisted privileged job;
    • extra triggers, and an optional tag input;
    • a missing bound workflow or job;
    • loosened or incomplete policy bindings.
  • Real Server workflows: all 26 workflows were scanned at server main 0d880547 and at #957's head. Both pass, and the four privileged jobs are reported. With the binding removed, #957 fails with privileged workflow_dispatch job 'build-amd64' can run outside refs/heads/main.
  • Full suite: python -m unittest has the same 3 failures and 47 errors on main in this container (environment-dependent tests). This branch adds 11 passing tests. Ruff finds 67 issues on both main and this branch, so none are new.

The environment policy (prerequisite 3: a native-[0-9]* tag rule on dockerhub-native, with the main rule removed after merge) is a separate admin setting. I'll apply it and read it back just before #957 merges.

🤖 Generated with Claude Code

server#957 dispatches native-release.yml from refs/tags/native-X.Y.Z so
the attestation source digest equals the release commit. The central
preflight refuses any privileged workflow_dispatch job outside
refs/heads/main, so it needs a narrow, reviewed exception.

Add workflow_trust.privileged_tag_dispatch_workflows, bound only to
server/native-release.yml's four dockerhub-native jobs. A job is
admitted only when it uses that environment, needs the plan job and
carries the exact tag-ref predicate as a top-level conjunct. When any
job relies on it, the workflow must be dispatch-only with a required
tag input, and the plan job must be unconditional and unprivileged so
a wrong ref fails loudly. The predicate is fixed by code, so policy
data cannot loosen it, and a missing bound workflow or job fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel rmcdaniel added the agent:general Pull request opened by General agent label Nov 2, 2026
@rmcdaniel

Copy link
Copy Markdown
Member Author

Security review hold at exact head 593e2f8547be67407ddbf978de91cc70a5ba4c34.

The repository/workflow/job/environment binding, required tag input, dispatch-only trigger, unconditional/unprivileged plan shape, missing-bound-source refusal and derived exact tag predicate are all appropriately narrow. Two fail-open gaps remain in the durable central exception:

  1. A bound publishing job can bypass a failed plan. _requires_exact_tag_ref accepts any top-level conjunction containing the exact tag predicate. It therefore accepts always() && github.ref == format(...) (and equivalent failure/cancellation status expressions). A GitHub job condition with a status function can override the implicit success() requirement on needs: plan, so the environment/write job may run even though the verifier plan failed. Reject status-check forms that can run after a failed/skipped plan (including always(), failure(), cancelled(), !cancelled(), or nested combinations), or bind the exact reviewed condition per named job. Add counterfactual controls proving every bound publishing job stays skipped when plan fails.

  2. The policy does not require the plan to run the reviewed verifier. An unconditional, read-only plan containing only echo ok is admitted. The tag predicate alone proves neither github.sha == release_commit nor main containment, so this loses the source-trust property that justifies the exception. Bind and require an exact, unconditional plan step invoking scripts/ci/verify-native-release-dispatch.sh (or an equivalently immutable reviewed command). Reject plan/job or verifier-step continue-on-error, a conditional/skipped verifier step, a missing verifier, and a changed command. The normal checkout may precede it; any predecessor failure remains fail closed.

Independent counterexample run against this exact head, in a disposable Python 3.12 container with jsonschema 4.23.0/PyYAML 6.0.2:

status-bypass=ACCEPTED
missing-verifier=ACCEPTED

The current Server #957 source itself does not contain either bypass; this finding is about the central exception’s future enforcement. Keep #283 unmerged and #957’s central prerequisite open until both counterexamples refuse and the accepted real workflow still passes.

Security review of #283 found two bypasses. A publishing condition such
as always() && <tag gate> replaces the implicit success() check, so it
could run after a failed plan. And an unconditional plan job with its
verifier removed or replaced still passed.

Admitted jobs may no longer use always(), failure() or cancelled(), and
no admitted job may continue on error. The binding now records the
plan verifier's exact run command and env. The plan job must first
check out the dispatch commit and then run that exact step, with no
condition, continue-on-error, shell or working-directory change. The
plan job may not continue on error, and neither the workflow nor the
plan job may override run defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Second bounded Security review at exact head 9a84676c8b21d0f4bb0789dc915fa026f1a404d9: the exact checkout/verifier/env binding and continue-on-error/defaults refusals close the missing-verifier finding, and the original always() counterexample now refuses.

One equivalent status bypass remains. The new pattern omits success(), so this valid publishing condition is admitted:

if: success() == false && github.ref == format('refs/tags/{0}', inputs.tag)

An explicit status-check function suppresses GitHub’s implicit dependency success(); after a failed plan, success() == false is true, so the environment/write job may run. Independent exact-head result:

original-always=REFUSED
missing-verifier=REFUSED
success-false=ACCEPTED

Conservatively include success in the case-insensitive status-function refusal, since the reviewed Server workflow needs no status function. Add controls for success() == false, expression-wrapped !success(), whitespace and case variants. Then re-run the real #957 workflow and module tests. This is the same first finding, not a new scope expansion.

An explicit success() also suppresses the implicit success dependency,
so 'success() == false && <tag gate>' could run publishing after a
failed plan. Refuse every status function in admitted job conditions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Security re-review clear at exact a4c2d464bcbf68c7d67e9a644e96eb05e000cce6.

The bounded correction closes the remaining status-function bypass: the refusal now covers always, failure, cancelled, and success case-insensitively with whitespace before (. Admitted jobs therefore rely on the implicit dependency-success condition. Added controls cover success() == false, expression-wrapped !success(), whitespace/case variation, and redundant explicit success().

Independent disposable Python 3.12 verification:

  • full tests.test_qualification_policy: 43/43 pass;
  • exact Server #957 head fb673862da0186135fce51b53fd9fbd691c6ab72 validates against this policy;
  • prior negatives remain refused: always(), missing/replaced verifier, success() == false, and wrapped !success().

The central boundary remains narrow: one workflow, four named jobs, one environment, exact checkout and verifier invocation, no status functions, continue-on-error, or run-default override. Server owns the reviewed verifier script content. Source clearance only; hosted checks and the environment-policy readbacks remain separate gates before #957 can merge or dispatch.

@sonarqubecloud

sonarqubecloud Bot commented Nov 2, 2026

Copy link
Copy Markdown

@rmcdaniel
rmcdaniel merged commit 02cf66c into main Nov 2, 2026
7 checks passed
@rmcdaniel
rmcdaniel deleted the ci/957-native-tag-dispatch-policy branch November 2, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:general Pull request opened by General agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant