Skip to content

Commit 64eb4b7

Browse files
fix: constrain assessment evidence references (#24)
## Summary - require completion-criterion evidence to use machine-resolvable plan references - keep model schemas synchronized with deterministic admission invariants - validate the deterministic evidence-state shape before any automated protected-path exception - require a GitHub OIDC/Sigstore attestation from the protected watcher, bound to the exact source commit, workflow run, action key, manifest, and file digest - wait only for the required no-change PR safety checks, preventing the bounded coordinator from timing out on optional native builds - suppress only the watcher’s own evidence-state commit from future wakeups while preserving wakeups for code and external-evidence changes - make no-change recording safe to retry without duplicate PRs or non-fast-forward branch failures - align the implementation merge timeout with the bounded native build and notify the owner on coordination failures - document the organization Actions PR setting and preserved owner-only control boundary ## Root causes 1. The model returned explanatory prose where admission requires evidence references such as `evidence[0]`. 2. The watcher no-change path writes a protected evidence-state file, but the generic owner-review gate had no securely authenticated automation exception. 3. A shared `github-actions[bot]` identity cannot prove which workflow produced a PR. ## Verification - `./scripts/test.sh` (15 tests) - exact-head A00-A20 parity passed against php-bin `5cae3543e1f11450ddcf4c2ade2d6351efd19d27` and mise-php `b0e3c11ca08ea82c47b87be57fdee56842d31586` - report digest: `sha256:e95586e45786af918788b8cb658f4cbeff62e7cef64af0b7f0bcc938839f5d62` - live watcher rerun required after merge ## Safety Admission remains fail closed. The evidence-state exception requires a same-repository bot PR, an exact protected watcher run ID, an in-progress run on the exact main base SHA, a direct-parent commit changing only the deterministic state file, the reviewed seven healthy captures, and a verified GitHub OIDC/Sigstore attestation from the protected watcher workflow. All other protected changes still require exact-head owner approval.
1 parent 67f5e2e commit 64eb4b7

13 files changed

Lines changed: 467 additions & 38 deletions

.github/codex/maintenance/investigation.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,10 @@ forms enforced by the output schema: `no_change`, `new_patch`, `new_branch`,
4141
`branch_eol`, `recipe_rebuild`, `repair`, `source_unhealthy`, `health_failed`,
4242
`policy_failure`, or `auth_failure` with the required version, date, attempt,
4343
or lowercase hexadecimal evidence suffix.
44+
45+
Every `completionAssessment.criteria[].evidence` entry is a machine-resolved
46+
reference, never explanatory prose. Use only `evidence[N]` for an item in the
47+
plan evidence array, `preconditions.phpBinHead`, `preconditions.misePhpHead`,
48+
`preconditions.supportPolicyDigest`, or `researchSources[N]` for an item in the
49+
research source array. Put explanations in the criterion status or plan summary,
50+
not in an evidence-reference array.

.github/maintenance-pins.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"actions/checkout": "3d3c42e5aac5ba805825da76410c181273ba90b1",
55
"actions/cache": "55cc8345863c7cc4c66a329aec7e433d2d1c52a9",
66
"actions/download-artifact": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c",
7+
"actions/attest": "f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6",
78
"actions/upload-artifact": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a",
89
"jdx/mise-action": "9e7f7633ff6f6d6048a9418a68d48f288f50eb14",
910
"openai/codex-action": "52fe01ec70a42f454c9d2ebd47598f9fd6893d56"

.github/workflows/maintenance-implementation.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ jobs:
303303
needs: [validate, validate-repair]
304304
if: always() && (needs.validate.outputs.passed == 'true' || needs['validate-repair'].result == 'success')
305305
runs-on: ubuntu-latest
306-
timeout-minutes: 20
306+
timeout-minutes: 130
307307
permissions:
308308
contents: write
309309
pull-requests: write

.github/workflows/maintenance-watch.yml

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,15 @@ jobs:
3737
- name: Decide whether an agent call is required
3838
id: decision
3939
run: |
40+
self_update=()
41+
if [[ "$(git diff-tree --no-commit-id --name-only -r HEAD)" == "maintenance-state/last-evidence.json" ]]; then
42+
self_update=(--self-evidence-update)
43+
fi
4044
./scripts/watch-maintenance-evidence \
4145
--manifest maintenance-run/evidence/evidence-manifest.json \
4246
--previous maintenance-state/last-evidence.json \
4347
--events maintenance-events \
48+
"${self_update[@]}" \
4449
--output maintenance-run/watch-decision.json
4550
echo "trigger=$(jq -r .trigger maintenance-run/watch-decision.json)" >> "$GITHUB_OUTPUT"
4651
- name: Record exact preconditions
@@ -139,7 +144,10 @@ jobs:
139144
timeout-minutes: 5
140145
permissions:
141146
actions: write
147+
artifact-metadata: write
148+
attestations: write
142149
contents: write
150+
id-token: write
143151
pull-requests: write
144152
issues: write
145153
steps:
@@ -156,15 +164,57 @@ jobs:
156164
--repo "${{ github.repository }}" \
157165
--name "maintenance-investigation-${{ github.run_id }}" \
158166
--dir maintenance-plan-download
167+
- name: Read unattended mutation state
168+
id: operator
169+
run: |
170+
test "$(jq -r .unattendedMutation .github/maintenance-operator.json)" = "enabled" \
171+
&& echo "enabled=true" >> "$GITHUB_OUTPUT" \
172+
|| echo "enabled=false" >> "$GITHUB_OUTPUT"
173+
- name: Prepare deterministic no-change evidence
174+
id: evidence
175+
if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true'
176+
run: |
177+
git checkout -B "maintenance/evidence-${{ github.run_id }}" origin/main
178+
mkdir -p maintenance-state
179+
jq -n \
180+
--arg manifestDigest "$(jq -r .manifestDigest maintenance-plan-download/evidence/evidence-manifest.json)" \
181+
--arg planDigest "$(jq -r .planDigest maintenance-plan-download/admission.json)" \
182+
--argjson captureDigests "$(jq '[.captures[] | {captureId,digest,status}]' maintenance-plan-download/evidence/evidence-manifest.json)" \
183+
'{schemaVersion:1,manifestDigest:$manifestDigest,planDigest:$planDigest,captures:$captureDigests}' \
184+
> maintenance-state/last-evidence.json
185+
if [[ -z "$(git status --porcelain -- maintenance-state/last-evidence.json)" ]]; then
186+
echo "already_recorded=true" >> "$GITHUB_OUTPUT"
187+
exit 0
188+
fi
189+
echo "already_recorded=false" >> "$GITHUB_OUTPUT"
190+
jq -n \
191+
--arg runId "${{ github.run_id }}" \
192+
--arg sourceSha "${{ needs.investigate.outputs.base_sha }}" \
193+
--arg actionKey "${{ needs.investigate.outputs.action_key }}" \
194+
--arg manifestDigest "$(jq -r .manifestDigest maintenance-plan-download/evidence/evidence-manifest.json)" \
195+
'{schemaVersion:1,runId:$runId,sourceSha:$sourceSha,actionKey:$actionKey,manifestDigest:$manifestDigest}' \
196+
> maintenance-plan-download/evidence-attestation-predicate.json
197+
- name: Attest deterministic no-change evidence
198+
if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' && steps.evidence.outputs.already_recorded == 'false'
199+
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
200+
with:
201+
subject-path: maintenance-state/last-evidence.json
202+
predicate-type: https://bigpixelrocket.dev/maintenance/evidence-state/v1
203+
predicate-path: maintenance-plan-download/evidence-attestation-predicate.json
159204
- name: Dispatch implementation or no-edit release
160205
env:
206+
EVIDENCE_ALREADY_RECORDED: ${{ steps.evidence.outputs.already_recorded }}
161207
GH_TOKEN: ${{ github.token }}
162208
run: |
163209
if [[ "$(jq -r .unattendedMutation .github/maintenance-operator.json)" != "enabled" ]]; then
164210
echo "Unattended mutation is paused; retained investigation remains read-only."
165211
exit 0
166212
fi
167213
action="${{ needs.investigate.outputs.action }}"
214+
if [[ "$action" == "no_change" && "$EVIDENCE_ALREADY_RECORDED" == "true" ]]; then
215+
echo "The exact deterministic evidence state is already recorded."
216+
exit 0
217+
fi
168218
if [[ "$action" == "blocked" || "$action" == "needs_human" ]]; then
169219
jq -n \
170220
--arg actionKey "${{ needs.investigate.outputs.action_key }}" \
@@ -198,30 +248,29 @@ jobs:
198248
fi
199249
200250
if [[ "$action" == "no_change" ]]; then
201-
:
202-
git checkout -B "maintenance/evidence-${{ github.run_id }}" origin/main
251+
branch="maintenance/evidence-${{ github.run_id }}"
203252
base="$(git rev-parse HEAD)"
204-
mkdir -p maintenance-state
205-
jq -n \
206-
--arg manifestDigest "$(jq -r .manifestDigest maintenance-plan-download/evidence/evidence-manifest.json)" \
207-
--arg planDigest "$(jq -r .planDigest maintenance-plan-download/admission.json)" \
208-
--argjson captureDigests "$(jq '[.captures[] | {captureId,digest,status}]' maintenance-plan-download/evidence/evidence-manifest.json)" \
209-
'{schemaVersion:1,manifestDigest:$manifestDigest,planDigest:$planDigest,captures:$captureDigests}' \
210-
> maintenance-state/last-evidence.json
211253
git add maintenance-state/last-evidence.json
212254
git -c user.name=maintenance-watcher -c user.email=maintenance@invalid \
213255
commit -m "chore: record reviewed maintenance evidence"
214256
head="$(git rev-parse HEAD)"
215257
record_digest="sha256:$(shasum -a 256 maintenance-state/last-evidence.json | awk '{print $1}')"
216258
gh auth setup-git
217-
git push origin HEAD
218-
url="$(gh pr create --base main --head "maintenance/evidence-${{ github.run_id }}" \
219-
--title "chore: record reviewed maintenance evidence" \
220-
--body "Opaque evidence state for a reviewed no-change result.")"
221-
number="${url##*/}"
222-
gh pr checks "$number" --watch --fail-fast --interval 10
223-
gh pr checks "$number" --json name,bucket,link > maintenance-plan-download/no-change-checks.json
259+
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
260+
git fetch origin "$branch:refs/remotes/origin/$branch"
261+
fi
262+
git push --force-with-lease origin "HEAD:refs/heads/$branch"
263+
number="$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')"
264+
if [[ -z "$number" ]]; then
265+
url="$(gh pr create --base main --head "$branch" \
266+
--title "chore: record reviewed maintenance evidence" \
267+
--body "Opaque evidence state for a reviewed no-change result.")"
268+
number="${url##*/}"
269+
fi
270+
gh pr checks "$number" --required --watch --fail-fast --interval 10
271+
gh pr checks "$number" --required --json name,bucket,link > maintenance-plan-download/no-change-checks.json
224272
jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' maintenance-plan-download/no-change-checks.json
273+
jq -e '[.[] | select(.name=="Protected controls") | .bucket] == ["pass"]' maintenance-plan-download/no-change-checks.json
225274
test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head"
226275
git fetch origin main
227276
test "$(git rev-parse origin/main)" = "$base"
@@ -299,9 +348,10 @@ jobs:
299348
url="$(gh pr create --base main --head "$branch" --title "chore: complete $action_key" \
300349
--body "Deterministic EOL completion bound to exact cross-repository readiness.")"
301350
number="${url##*/}"
302-
gh pr checks "$number" --watch --fail-fast --interval 10
303-
gh pr checks "$number" --json name,bucket,link > maintenance-plan-download/eol-checks.json
351+
gh pr checks "$number" --required --watch --fail-fast --interval 10
352+
gh pr checks "$number" --required --json name,bucket,link > maintenance-plan-download/eol-checks.json
304353
jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' maintenance-plan-download/eol-checks.json
354+
jq -e '[.[] | select(.name=="Protected controls") | .bucket] == ["pass"]' maintenance-plan-download/eol-checks.json
305355
test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head"
306356
git fetch origin main
307357
test "$(git rev-parse origin/main)" = "$base"
@@ -322,8 +372,8 @@ jobs:
322372
323373
notify-failure:
324374
name: Notify actionable watcher failure
325-
needs: investigate
326-
if: always() && needs.investigate.result == 'failure'
375+
needs: [investigate, coordinate]
376+
if: always() && (needs.investigate.result == 'failure' || needs.coordinate.result == 'failure')
327377
runs-on: ubuntu-latest
328378
timeout-minutes: 5
329379
permissions:

.github/workflows/protected-controls.yml

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
pull_request_target:
55

66
permissions:
7+
attestations: read
78
contents: read
89
pull-requests: read
910

@@ -23,15 +24,29 @@ jobs:
2324
REPOSITORY: ${{ github.repository }}
2425
PR_NUMBER: ${{ github.event.pull_request.number }}
2526
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
27+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
28+
HEAD_REF: ${{ github.event.pull_request.head.ref }}
29+
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
30+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
2631
PROTECTED_REVIEWER: ${{ vars.MAINTENANCE_OWNER }}
2732
run: |
2833
python3 - <<'PY'
2934
import fnmatch
35+
import base64
3036
import json
3137
import os
3238
import pathlib
39+
import re
3340
import subprocess
3441
import sys
42+
import tempfile
43+
import time
44+
45+
from maintenance.control import (
46+
ControlError,
47+
validate_evidence_attestation_predicate,
48+
validate_evidence_state_record,
49+
)
3550
3651
def api(path):
3752
result = subprocess.run(
@@ -45,9 +60,22 @@ jobs:
4560
raise RuntimeError("GitHub API returned an invalid paginated response")
4661
return [item for page in pages for item in page]
4762
63+
def api_one(path):
64+
result = subprocess.run(
65+
["gh", "api", path],
66+
check=True,
67+
text=True,
68+
stdout=subprocess.PIPE,
69+
)
70+
return json.loads(result.stdout)
71+
4872
repo = os.environ["REPOSITORY"]
4973
number = os.environ["PR_NUMBER"]
5074
head = os.environ["HEAD_SHA"]
75+
base = os.environ["BASE_SHA"]
76+
head_ref = os.environ["HEAD_REF"]
77+
head_repo = os.environ["HEAD_REPOSITORY"]
78+
author = os.environ["PR_AUTHOR"]
5179
reviewer = os.environ["PROTECTED_REVIEWER"].lower()
5280
manifest = json.loads(pathlib.Path("maintenance/protected-paths.json").read_text())
5381
patterns = manifest["patterns"]
@@ -61,6 +89,106 @@ jobs:
6189
print("No protected control path changed.")
6290
raise SystemExit(0)
6391
92+
evidence_run = re.fullmatch(r"maintenance/evidence-(\d+)", head_ref)
93+
if (
94+
protected == ["maintenance-state/last-evidence.json"]
95+
and evidence_run
96+
and author == "github-actions[bot]"
97+
and head_repo.lower() == repo.lower()
98+
):
99+
commit = api_one(f"repos/{repo}/commits/{head}")
100+
run = api_one(f"repos/{repo}/actions/runs/{evidence_run.group(1)}")
101+
content = api_one(
102+
f"repos/{repo}/contents/maintenance-state/last-evidence.json?ref={head}"
103+
)
104+
try:
105+
encoded = content["content"].replace("\n", "")
106+
decoded = base64.b64decode(encoded, validate=True)
107+
record = json.loads(decoded)
108+
validate_evidence_state_record(record)
109+
except (KeyError, ValueError, json.JSONDecodeError, ControlError) as error:
110+
print(f"Invalid deterministic evidence state: {error}", file=sys.stderr)
111+
raise SystemExit(1) from error
112+
direct_parent = [parent.get("sha") for parent in commit.get("parents", [])] == [base]
113+
trusted_run = (
114+
run.get("path") == ".github/workflows/maintenance-watch.yml"
115+
and run.get("event") in {"schedule", "workflow_dispatch"}
116+
and run.get("head_branch") == "main"
117+
and run.get("head_sha") == base
118+
and run.get("status") == "in_progress"
119+
)
120+
if direct_parent and trusted_run:
121+
try:
122+
with tempfile.NamedTemporaryFile() as evidence_file:
123+
evidence_file.write(decoded)
124+
evidence_file.flush()
125+
command = [
126+
"gh",
127+
"attestation",
128+
"verify",
129+
evidence_file.name,
130+
"--repo",
131+
repo,
132+
"--signer-workflow",
133+
f"{repo}/.github/workflows/maintenance-watch.yml",
134+
"--source-ref",
135+
"refs/heads/main",
136+
"--source-digest",
137+
base,
138+
"--predicate-type",
139+
"https://bigpixelrocket.dev/maintenance/evidence-state/v1",
140+
"--deny-self-hosted-runners",
141+
"--format",
142+
"json",
143+
]
144+
verification = None
145+
for attempt in range(3):
146+
candidate = subprocess.run(
147+
command,
148+
check=False,
149+
text=True,
150+
stdout=subprocess.PIPE,
151+
stderr=subprocess.PIPE,
152+
)
153+
if candidate.returncode == 0:
154+
verification = candidate
155+
break
156+
time.sleep(2**attempt)
157+
if verification is None:
158+
raise RuntimeError(
159+
f"attestation was unavailable after bounded retries: {candidate.stderr}"
160+
)
161+
attestations = json.loads(verification.stdout)
162+
if not isinstance(attestations, list):
163+
raise TypeError("attestation verifier returned a non-array result")
164+
predicates = [
165+
item["verificationResult"]["statement"]["predicate"]
166+
for item in attestations
167+
]
168+
except (
169+
KeyError,
170+
RuntimeError,
171+
TypeError,
172+
json.JSONDecodeError,
173+
) as error:
174+
print(f"Evidence attestation verification failed: {error}", file=sys.stderr)
175+
raise SystemExit(1) from error
176+
for predicate in predicates:
177+
try:
178+
validate_evidence_attestation_predicate(
179+
predicate,
180+
run_id=evidence_run.group(1),
181+
source_sha=base,
182+
action_key=f"no_change:{record['manifestDigest'].removeprefix('sha256:')[:16]}",
183+
manifest_digest=record["manifestDigest"],
184+
)
185+
except ControlError:
186+
continue
187+
print(f"Protected deterministic evidence state approved from attested watcher run {run['id']}.")
188+
raise SystemExit(0)
189+
print("No attestation matched the exact watcher run and evidence state.", file=sys.stderr)
190+
raise SystemExit(1)
191+
64192
reviews = api(f"repos/{repo}/pulls/{number}/reviews")
65193
approved = any(
66194
review.get("state") == "APPROVED"

docs/admin-state/php-bin-after.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
},
7272
"url": "https://api.github.com/repos/Bigpixelrocket/php-bin/branches/main/protection"
7373
},
74-
"capturedAt": "2026-07-28T09:06:41Z",
74+
"capturedAt": "2026-07-28T13:05:02Z",
7575
"environments": {
7676
"environments": [
7777
{
@@ -182,12 +182,12 @@
182182
],
183183
"verified_allowed": false
184184
},
185-
"snapshotDigest": "sha256:3919519486d0614af629c4fba227981a104ba2668e974deb7aecc3702b808eba",
185+
"snapshotDigest": "sha256:3d0ed7f751c408e71033bdc593f7eeba2161470658277281d1c409a7b0572797",
186186
"variables": [
187187
"MAINTENANCE_OWNER"
188188
],
189189
"workflowPermissions": {
190-
"can_approve_pull_request_reviews": false,
190+
"can_approve_pull_request_reviews": true,
191191
"default_workflow_permissions": "read"
192192
}
193193
}

0 commit comments

Comments
 (0)