Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fea2456
build: lock policy parser wheels for CPython 3.13 Linux
rmcdaniel Oct 22, 2026
3d782b0
security: require isolated hash-locked Server policy installation
rmcdaniel Oct 22, 2026
51a6945
test: verify the installed parser belongs to the isolated venv
rmcdaniel Oct 22, 2026
f4f0d8a
test: control the raw Python path-safety counterfactual
rmcdaniel Oct 22, 2026
47c5f0e
ci: isolate pip configuration in locked policy install
rmcdaniel Oct 22, 2026
f0bd99a
ci: capture policy inputs as bounded inert PR data
rmcdaniel Oct 22, 2026
1d9db9d
feat: add bounded read-only protected policy pilot client
rmcdaniel Oct 22, 2026
f0b3e47
Prepare independently identified protected policy check issuer
rmcdaniel Oct 22, 2026
85efbcd
Make timeout refusal assertion identify one failing call
rmcdaniel Oct 22, 2026
fa385da
Resolve issuer findings while preserving ASCII App identity
rmcdaniel Oct 22, 2026
1d5b3f2
Bind the protected policy pilot to exact base source and workflow con…
rmcdaniel Oct 23, 2026
72e1b95
Merge remote-tracking branch 'origin/main' into fix/237-protected-wor…
rmcdaniel Oct 23, 2026
ed2334a
Merge remote-tracking branch 'origin/main' into fix/237-protected-wor…
rmcdaniel Oct 23, 2026
c60969c
Reproduce local workflow hash normalization
rmcdaniel Oct 23, 2026
8dc2c84
Hash local workflow files without newline normalization
rmcdaniel Oct 23, 2026
9c14e04
Merge remote-tracking branch 'origin/main' into fix/237-protected-wor…
rmcdaniel Oct 23, 2026
6622b04
Reproduce repeated and cyclic YAML reference traversal
rmcdaniel Oct 23, 2026
bdf6864
Bound action reference traversal over YAML aliases
rmcdaniel Oct 23, 2026
619c0d5
Retain parser refusal for recursive YAML in controls
rmcdaniel Oct 23, 2026
7a5b404
Bind policy directory proofs and bound YAML alias scans
rmcdaniel Oct 23, 2026
733f603
Bind effective shells for policy execution proofs
rmcdaniel Oct 23, 2026
48ba23d
Refuse ignored failures in the policy result gate
rmcdaniel Oct 23, 2026
f82554f
Merge qualified main into protected policy source
rmcdaniel Oct 23, 2026
5c32ece
Close the reviewed preflight execution context
rmcdaniel Oct 23, 2026
d4fe8e0
Merge remote-tracking branch 'origin/main' into fix/237-protected-wor…
rmcdaniel Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,43 @@ concurrency:
cancel-in-progress: true

jobs:
policy-dependencies:
name: Locked policy wheels (${{ matrix.runner }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 5
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.13'
- name: Create a disposable policy-client venv
run: |
python -I -m venv "$RUNNER_TEMP/policy-venv"
printf '%s\n' "$RUNNER_TEMP/policy-venv/bin" >> "$GITHUB_PATH"
- uses: ./.github/actions/bootstrap-patched-pip # local
- name: Prove isolated installation and wheel integrity
run: python -I tests/test_policy_dependency_install.py

contracts:
name: Shared contract tests
permissions:
contents: read
pull-requests: read
needs: [policy-dependencies]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Require both native policy-wheel controls
env:
POLICY_RESULT: ${{ needs.policy-dependencies.result }}
run: test "$POLICY_RESULT" = success
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6

- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
Expand Down Expand Up @@ -49,12 +81,15 @@ jobs:

- name: Install Python test dependencies
run: >-
python -m pip install --only-binary :all: jsonschema==4.23.0 PyYAML==6.0.2
python -I -m pip install --only-binary :all: jsonschema==4.23.0 PyYAML==6.0.2

- name: Test retained shared contracts
run: >-
python -m unittest
tests.test_qualification_policy
tests.test_protected_policy_inputs
tests.test_protected_policy_check
tests.test_protected_policy_issue
tests.test_regression_corpus
tests.test_regression_evidence_schema
tests.test_server_codec_counterfactual_schema
Expand All @@ -66,6 +101,18 @@ jobs:
tests.test_cli_effective_trust
tests.test_composer_trust_contract

- name: Exercise the read-only policy client on this public PR
if: github.event_name == 'pull_request'
env:
GITHUB_TOKEN: ${{ github.token }}
POLICY_PULL_REQUEST: ${{ github.event.pull_request.number }}
POLICY_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
python -I scripts/protected_policy_check.py \
--pull-request "$POLICY_PULL_REQUEST" --base-sha "$POLICY_BASE_SHA" \
> "$RUNNER_TEMP/protected-policy-read.json"
cat "$RUNNER_TEMP/protected-policy-read.json"

- name: Test actual Composer trust selection
run: COMPOSER_BINARY="$(command -v composer)" phpunit --configuration phpunit-composer-trust.xml --do-not-cache-result

Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/protected-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Protected workflow policy

on:
pull_request_target:
branches: [main]
types: [opened, reopened, synchronize, ready_for_review]

permissions:
contents: read
pull-requests: read

jobs:
protected-policy:
name: Validate bounded workflow inputs
if: >-
github.repository == 'durable-workflow/.github' &&
github.ref == 'refs/heads/main' &&
github.event_name == 'pull_request_target'
runs-on: ubuntu-24.04
timeout-minutes: 10
environment: protected-policy-gate
steps:
- name: Check out the immutable protected base
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.13'
- name: Create the isolated policy runtime
run: |
python -I -m venv "$RUNNER_TEMP/protected-policy-python"
printf '%s\n' "$RUNNER_TEMP/protected-policy-python/bin" >> "$GITHUB_PATH"
- name: Select verified dependency and API trust
uses: ./.github/actions/bootstrap-patched-pip # local
with:
clients: pip,python,curl,git
- name: Install only the protected hash-locked parser
run: >-
python -I -m pip --isolated install --require-hashes
--only-binary=:all: --no-deps -r qualification/requirements.lock
- name: Require protected source and the configured App identity
env:
POLICY_GATE_APP_ID: ${{ vars.POLICY_GATE_APP_ID }}
shell: bash
run: |
set -euo pipefail
test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA"
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
[[ "$POLICY_GATE_APP_ID" =~ ^[1-9][0-9]{0,18}$ ]]
- name: Create the pilot-only checks token
id: app
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
env:
NODE_OPTIONS: --use-openssl-ca
NODE_EXTRA_CA_CERTS: ''
NODE_USE_SYSTEM_CA: '0'
NODE_TLS_REJECT_UNAUTHORIZED: '1'
with:
app-id: ${{ vars.POLICY_GATE_APP_ID }}
private-key: ${{ secrets.POLICY_GATE_APP_KEY }}
owner: durable-workflow
repositories: .github
permission-checks: write
- name: Validate data and issue the bound App check
env:
GITHUB_TOKEN: ${{ github.token }}
POLICY_CHECKS_TOKEN: ${{ steps.app.outputs.token }}
POLICY_GATE_APP_ID: ${{ vars.POLICY_GATE_APP_ID }}
POLICY_PULL_REQUEST: ${{ github.event.pull_request.number }}
POLICY_BASE_SHA: ${{ github.sha }}
POLICY_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: >-
python -I scripts/protected_policy_issue.py
--pull-request "$POLICY_PULL_REQUEST" --base-sha "$POLICY_BASE_SHA"
--expected-head "$POLICY_HEAD_SHA"
4 changes: 2 additions & 2 deletions .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ jobs:
--coverage-clover build/composer-trust-coverage.xml
- name: Produce source coverage
run: |
python -m pip install --only-binary :all: coverage==7.10.7 jsonschema==4.23.0 PyYAML==6.0.2
coverage run --source=scripts,conformance/cancellation/temporal,.github/actions/bootstrap-patched-pip -m unittest tests.test_qualification_policy tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema tests.test_visual_evidence tests.test_temporal_runtime tests.test_sdk_metrics_contract tests.test_ci_trust tests.test_pip_effective_trust tests.test_cli_effective_trust tests.test_composer_trust_contract
python -I -m pip install --only-binary :all: coverage==7.10.7 jsonschema==4.23.0 PyYAML==6.0.2
coverage run --source=scripts,conformance/cancellation/temporal,.github/actions/bootstrap-patched-pip -m unittest tests.test_qualification_policy tests.test_protected_policy_inputs tests.test_protected_policy_check tests.test_protected_policy_issue tests.test_regression_corpus tests.test_regression_evidence_schema tests.test_server_codec_counterfactual_schema tests.test_visual_evidence tests.test_temporal_runtime tests.test_sdk_metrics_contract tests.test_ci_trust tests.test_pip_effective_trust tests.test_cli_effective_trust tests.test_composer_trust_contract
coverage xml -o coverage.xml
- uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0
env:
Expand Down
129 changes: 128 additions & 1 deletion qualification/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,140 @@ scanner, with the analysis token only on the final scanner step. It may produce
coverage first using trusted repository code and approved actions. Other steps
cannot reference secrets; existing cache restrictions still apply. Forks do
not run this job.
The scanner rejects `pull_request_target`, mutable container
The scanner rejects `pull_request_target` outside the single content-bound
public pilot described below, mutable container
images, unreviewed `workflow_run` consumers, and privileged artifact consumers
without exact producer and digest provenance.

Product repositories own their tests, documentation deployment, publication,
and deployment workflows. Editorial wording is outside this policy.

## Isolated, hash-locked policy bootstrap

Server's policy preflight requires action revision
`fea245682582d0620054e8547a5f3c968f9ef0f0` and exactly:

```sh
python -I -m pip --isolated install --require-hashes --only-binary=:all: --no-deps -r .central-action-policy/qualification/requirements.lock
```

The reviewed central checkout supplies the lock. It permits only the reviewed
PyYAML 6.0.2 CPython 3.13 glibc Linux wheels for amd64 and arm64. Native CI checks
both wheel installations, a tampered hash, and working-directory `pip.py` and
`pip/` shadows. Missing isolation/integrity/binary/no-dependency flags, another
requirements path, extra options, a missing bootstrap or an older bootstrap
revision fail the Server contract. The same command is available for staged
adoption in other targets. Their legacy forms remain explicit migration work,
not evidence of the locked-policy contract. This source check does not establish
an independent protected execution boundary; that gate requires its own issuer
and repository-rule acceptance.

### Protected gate input layer

`scripts/protected_policy_inputs.py` captures an open public pull request against
the expected repository ID and exact `main` commit. It reads the candidate's
exact commit/tree identities and regular workflow blobs, plus literal referenced
helper blobs needed by the existing Composer inspection. Paths, file counts,
individual sizes, total bytes and encoding are bounded. Symlinks, submodules,
truncated trees and a changed PR identity refuse. Materialized files are data
with mode `0600`; candidate commands are never executed by this layer.

The caller must supply a bounded read-only GitHub API client from protected
source, keep candidate data outside the interpreter's import path, and call
`require_current` again immediately before issuing a result. This module does
not create a Check Run, grant merge authority, configure an Environment or prove
an issuer binding. Those live integration requirements remain open. Private
repositories are explicitly refused until their separate gate is qualified.

### Read-only protected-policy pilot client

`scripts/protected_policy_check.py` connects that input layer to the reviewed
validator for the public `.github` pilot. It binds the immutable repository ID,
requires the expected protected `main` SHA, and rechecks main and PR identity
after validation. Candidate workflow/helper files are temporary nonexecutable
data outside Python's import path. The policy always comes from the reviewed
central checkout; there is no candidate policy-path override.

After the authenticated bootstrap exports its CA file, empty additional-root
directory and digest, a read-only caller uses:

```sh
python -I scripts/protected_policy_check.py \
--pull-request "$POLICY_PULL_REQUEST" --base-sha "$POLICY_BASE_SHA"
```

The client requires curl 8.4 or newer, an explicit matching CA bundle and no
additional roots. Transfers are HTTPS GETs to the fixed GitHub API host, with
no redirects or automatic content decoding. Each response is capped at 8 MiB;
there is a 15-second request budget and a shared 120-second API budget. JSON
depth/node counts, endpoint shapes and request counts are also bounded. A read
token, when supplied as `GITHUB_TOKEN`, travels on stdin rather than process
arguments. The caller grants only Contents read and Pull requests read. Never
give the reader the App signing key or its Checks-write token.

Success prints a bounded identity/digest report. Failure prints escaped JSON
and exits nonzero; candidate text cannot become a GitHub workflow command.
Ordinary PR CI exercises the real read client and validator. That candidate
controlled job remains a component test. Protected base execution, the App
issuer, immediate identity recheck, environment-denial tests and issuer-bound
repository rules still require separate live acceptance before enforcement.

### Prepared App issuer component

`scripts/protected_policy_issue.py` prepares the checks-only issuer for that
same public pilot. It runs the validator itself rather than accepting a
candidate-supplied report. The read-only `GITHUB_TOKEN` and separate
`POLICY_CHECKS_TOKEN` installation token have different roles; the issuer never
accepts or reads an App signing key. `POLICY_GATE_APP_ID` must name the reviewed
installation's App. The App must have Checks write and implicit Metadata read
only, installed solely on the pilot repository.

The intended protected caller uses:

```sh
python -I scripts/protected_policy_issue.py \
--pull-request "$POLICY_PULL_REQUEST" --base-sha "$POLICY_BASE_SHA" \
--expected-head "$POLICY_HEAD_SHA"
```

It checks the protected-main workflow context and exact event head, validates
bounded candidate data, creates one incomplete Check Run, and verifies the
returned check name, original head and App ID. Only then does it re-read the
protected main and complete PR identity immediately before completing that
same check successfully. Read and write credentials use stdin, with explicit
reviewed TLS roots and the existing transfer/time/JSON bounds. The App client
has no arbitrary endpoint or repository input. Refusal exits nonzero and never
reports success; a check already created may remain incomplete. A later head
cannot inherit an earlier head's successful check.

Caller environment markers are defense in depth, not proof of secret denial.
The prepared workflow checks out `github.sha` from protected main, verifies
both the workflow SHA and actual checkout match it, installs the hash-locked
parser, then uses the pinned official App-token action. Its token is restricted
to Checks write on the one public pilot. The key is an Environment secret and
never enters the reader/issuer program. The token action selects only the
verified OpenSSL CA file and empty additional-root directory; bundled, extra
and additional system roots are excluded for that credentialed Node step.

The policy permits this one exact UTF-8 workflow SHA-256, target and path.
Another path/repository, changed checkout/permission/credential handling,
different content or additional exception refuses. Updating that root requires
another explicit source review. All other `pull_request_target` workflows
remain forbidden. This does not validate arbitrary changes to central policy
code; root-of-trust changes still require independent maintainer/Security review.

This bootstrap revision is prepared source, not a live enforcement claim. It
cannot authorize its own adoption. Security must accept the exact source;
the account holder must establish the App and key; and the live pilot must
prove Environment denial, wrong-issuer nonqualification, candidate nonexecution,
stale-head refusal and a safe successful check before the required-check rule
is bound to that App. The known Environment configuration alone is not proof.
No App key, live write or private-repository adoption is exercised by the
constructed-response tests. Existing Server/publication holds remain.

The transport uses GitHub's [create and update Check Run API](https://docs.github.com/en/rest/checks/runs)
and checks the documented [workflow environment variables](https://docs.github.com/en/actions/reference/workflows-and-actions/variables).

## Clean trust for dependency requests

Linux jobs that fetch Composer dependencies use this order after checkout:
Expand Down
13 changes: 12 additions & 1 deletion qualification/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
"actions/configure-pages": {
"45bfe0192ca1faeb007ade9deae92b16b8254a0d": "v6"
},
"actions/create-github-app-token": {
"64ab729a6294000f5aa4a4bc0b73d1f32bc45510": "v3.3.0"
},
"actions/deploy-pages": {
"cd2ce8fcbc39b97be8ca5fce6e763baed58fa128": "v5"
},
Expand Down Expand Up @@ -73,6 +76,7 @@
"4d407b29186a635f0cc27475ef0bc0ae605a8866": "1.86.0"
},
"durable-workflow/.github": {
"fea245682582d0620054e8547a5f3c968f9ef0f0": "clean-trust",
"75587fc33ff40c34f32ef777293dad56bb6f48c7": "clean-trust",
"ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10": "patched-pip"
},
Expand Down Expand Up @@ -132,7 +136,13 @@
"validator_runner": "ubuntu-latest"
},
"privileged_workflow_run_consumers": {},
"pull_request_target_exceptions": []
"pull_request_target_exceptions": [
{
"target": "github-control-plane",
"path": ".github/workflows/protected-policy.yml",
"sha256": "642d204ea939ca5eba7ef51be9e4ae56a35177413caabe849315086d3d9d2d31"
}
]
},
"targets": {
"ai": {
Expand Down Expand Up @@ -262,6 +272,7 @@
"workflows": [
{
"action_policy_preflight": true,
"locked_policy_dependency_required": true,
"matrix_independent": false,
"path": "phpunit-feature.yml",
"required_check": "PHPUnit feature suite"
Expand Down
5 changes: 5 additions & 0 deletions qualification/requirements.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# PyPI PyYAML 6.0.2 wheels for CPython 3.13 on glibc Linux amd64/arm64.
# Source: https://pypi.org/pypi/PyYAML/6.0.2/json
PyYAML==6.0.2 \
--hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \
--hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133
15 changes: 14 additions & 1 deletion qualification/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,17 @@
"type": "object"
},
"pull_request_target_exceptions": {
"maxItems": 0,
"items": {
"additionalProperties": false,
"properties": {
"target": {"const": "github-control-plane"},
"path": {"const": ".github/workflows/protected-policy.yml"},
"sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
},
"required": ["target", "path", "sha256"],
"type": "object"
},
"maxItems": 1,
"type": "array"
}
},
Expand Down Expand Up @@ -300,6 +310,9 @@
"workflow": {
"additionalProperties": false,
"properties": {
"locked_policy_dependency_required": {
"type": "boolean"
},
"action_policy_preflight": {
"type": "boolean"
},
Expand Down
Loading
Loading