Repository navigation
Workflow hardening: no pull-request job reads a secret or holds a new write scope - #682
seathatflowsinourveins wants to merge 9 commits into
Conversation
… write scope A pull_request run from a same-repository branch, where the OpenHands resolver (#489) pushes agent-written commits, receives the repository's secrets and its job's GITHUB_TOKEN scopes; only a fork's pull request has them withheld. PullRequestReachableJobsTests pins that every job reachable from pull_request*, issue_comment or workflow_run (and any local reusable workflow such a job calls) reads no secrets context, passes no secrets:, never falls back to the repository default, and that the jobs holding a write scope are exactly PULL_REQUEST_WRITE_ALLOWLIST: security-scan.yml's two SARIF uploads with security-events: write. The text reader fails closed on forms it cannot parse; a PyYAML cross-check runs where PyYAML exists. The decision record gains a dated note. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…on record Hot-file protocol (docs/lanes.md): the two files the previous commit changed carry sha256 and bytes in manifests/evidence.json, re-registered with scripts/host_receipts.py register_file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… review of #682) One repair round for the read-only GPT-6.1 Sol review of #682 at 5e2f85e: - P1-1: a flow-form job was invisible to jobs() and the permissions scan. Every workflow must now stay in a plain block YAML subset (YAML_SUBSET_LINE), and each reachable workflow in the job layout jobs() reads, with block headers at two spaces, keys at four, and a header count that must equal what jobs() read. - P1-2: double-quoted escapes hid a secrets read, an event or a permissions key. Any backslash in a double-quoted scalar is refused, since every YAML 1.2.2 section 5.7 escape changes what a source scan sees. Plain and single-quoted scalars have none. - P1-3: a reachable job may not declare environment: or call a remote reusable workflow unless PULL_REQUEST_SECRET_SOURCE_ALLOWLIST (empty) lists it; either brings secrets the caller never passes. - P2: the diagnostic and the decision note now qualify GitHub's guarantee (default fork behaviour, the private-repository fork setting, Dependabot runs). Every failure names its workflow line. Sixteen new planted mutations passed the class at 5e2f85e and fail it now. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…nd decision record Hot-file protocol (docs/lanes.md): the two files the previous commit changed carry sha256 and bytes in manifests/evidence.json, re-registered with scripts/host_receipts.py register_file. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…anch
main's OSV split-scan hardening port changed security-scan.yml (two
`if: ${{ !cancelled() }}` upload guards, no permission change), the same
test module and decision record (merged cleanly), and
manifests/evidence.json. Per docs/lanes.md's no-force-push path: main's
evidence.json was taken and this branch's two files re-registered at
their merged content.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Claude session native-agent-stack-5f: review round for the cross-family read of #682 at P1-1: flow-form jobs were silently omitted.
P1-2: YAML escapes evaded the scans.
P1-3: environments and remote reusable workflows.
P2: the fork guarantee was overstated.
Re-run on
Residuals.
|
…fore the final hot-file commit Co-Authored-By: Claude Opus 5.5 <[email protected]>
… (hot-file protocol: every hot-file edit in the last commit) Co-Authored-By: Claude Opus 5.5 <[email protected]>
… {}` (compatibility with #681)
The YAML subset rejected every flow mapping, including the empty `{}` that
GitHub documents as "disable permissions for all of the available
permissions". #681 moves every workflow to `permissions: {}`, so whichever
PR landed second would break the other.
- YAML_SUBSET_LINE accepts an empty flow mapping `{}` (an empty `[]`
already passed). Either holds no key, so it cannot hide a grant, a
secret or an event. A non-empty flow mapping still fails, now labelled
as such.
- `permissions: {}` at workflow or job level reads as an explicit grant of
no scope. It still counts as the job's or the workflow's own key, so it
is never the repository default.
- The decision note's subset sentence and evidence counts are amended in
this branch's own appended section.
On #681's 21 workflows at de0b004 the class failed 30 times, every one on
`permissions: {}`, and now passes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…on record after the `{}` compatibility fix
Hot-file protocol (docs/lanes.md): the two files the previous commit
changed carry sha256 and bytes in manifests/evidence.json, re-registered
with scripts/host_receipts.py register_file. This is the branch's last
commit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Claude session native-agent-stack-5f: compatibility fix for #681, on head Defect. Fix.
Evidence.
With #681.
Overlap with #681's
|
…d a ParseScalar-exact guard check (#686) * Workflow policy: pull_request reachability rules from #682, ParseScalar-exact guard check Fold the assertions of #682 that tests/test_workflow_policy.py did not yet hold into it as rules on the parsed workflows, each with planted negative controls and accepted variants: - pull-request-environment: no `environment:` on a job that a pull_request-family event reaches (#682's event set; local ./ and $/ reusable-workflow calls followed transitively; then a workflow_call trigger alone), whatever its if: - pull-request-remote-workflow: such a job calls no reusable workflow except through ./.github/workflows/ or $/.github/workflows/ - comment-event-secret: no secret other than GITHUB_TOKEN in a workflow that issue_comment, pull_request_review or pull_request_review_comment triggers (the pull-request-secret reference set: decoded values, raw text, inherit) Repair round 2, the P2 residuals on #681: excludes_pull_request now follows actions/runner's TemplateReader.ParseScalar (d7bc179baf11). Literal whitespace around a whole expression, including the line break a `|` block scalar keeps, no longer counts as excluding pull requests, and a `}}` inside a string literal no longer refuses a valid guard. Qualify the Dependabot token sentence in docs/decisions/2026-09-22-github-automation-closure.md (read-only by default; it can be raised) and record the change in docs/decisions/2026-10-04-ci-least-privilege.md. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Registry: re-register the workflow policy test and the two decision records Hot-file protocol (docs/lanes.md): main's manifests/evidence.json with the three files this branch changed re-registered through host_receipts.register_file; component_matrix.py --write and new_host_grand_list.py --write changed nothing. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Workflow policy: delimit secrets expressions as ParseScalar does; GITHUB_TOKEN by index Cross-family review of #686 at aeb5ba2 (one repair round): - P1: secret_references() found expressions with the regex \$\{\{(.*?)\}\}, which stops at a `}}` inside a string literal, so `${{ format('a}}b{0}', toJSON(secrets)) }}` and the same with secrets['NPM_TOKEN'] passed comment-event-secret for all three comment and review events, and pull-request-secret on pull_request. It now reads each expression as expressions() delimits it, with expression_end(), the actions/runner TemplateReader.ParseScalar scan (d7bc179baf11); an unclosed expression is read to the end. - P2: secrets['GITHUB_TOKEN'] was a violation while secrets.GITHUB_TOKEN passed. Index access by one string literal (SECRET_INDEX, single or double quotes) now names the secret it indexes, so the token passes in both forms; the whole context and an index that is not one literal stay violations. New planted controls: the reviewer's env form under issue_comment, pull_request_review and pull_request_review_comment, an indexed secret after a quoted `}}` in a called workflow's secrets mapping, a computed index, and the env form under pull_request. New accepted variants: the token by single- and double-quoted index. The decision record's section gains the review round. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Scout <scout@local> Co-authored-by: Claude Opus 5.5 <[email protected]>
|
Superseded by #686, merged as 07a6a0a. That PR folds the assertions of this tripwire that #681's |
Scope
PullRequestReachableJobsTeststotests/test_workflow_hardening.py. It is a regression tripwire: no job a pull request can start may reference a repository secret, passsecrets:, declareenvironment:, call a remote reusable workflow, fall back to the repository-default token, or hold a write scope beyond the two SARIF upload jobs insecurity-scan.yml(security-events: write). Every workflow must also stay in the plain block YAML subset that these text checks read in full. The PR appends a dated note to the GitHub automation decision record.3bdacabd5b7bbc1b8c39f4e5fa3c694951a85751, withorigin/mainatba0e8c48f(Harden the three-group OSV policy and SARIF upload paths (port #555) #673) merged in during the review round.lane:foundationtests/test_workflow_hardening.py;docs/decisions/2026-09-22-github-automation-closure.md(append-only section at the end;main's file is an unchanged prefix);manifests/evidence.json(my two entries re-registered withhost_receipts.register_file, in the branch's last commit perdocs/lanes.md).Why: the OpenHands resolver (#489) will push agent-written commits to same-repository branches, and #489's pre-push gate keeps the agent out of
.github/**. An ordinarypull_requestrun from a same-repository branch receives the repository's secrets and the job's token scopes as written. By default GitHub withholds secrets from a fork's pull request and turns its write scopes to read; only a private repository can choose to send them. A Dependabot-triggered run gets a read-only token and only Dependabot secrets. The remaining condition is that pull-request workflows hold no secrets and no write scopes. That holds at3bdacab, but nothing in the tree pinned it.What the class checks (
tests/test_workflow_hardening.py:2434). The constants arePULL_REQUEST_EVENTSat:2245,PULL_REQUEST_WRITE_ALLOWLISTat:2249,PULL_REQUEST_SECRET_SOURCE_ALLOWLISTat:2262(empty) andYAML_SUBSET_LINEat:2288.Reachability. A workflow is reachable when its
on:namespull_request,pull_request_target,pull_request_review,pull_request_review_comment,issue_commentorworkflow_run. Any local reusable workflow (uses: ./.github/workflows/<file>) called by a reachable job is followed transitively; no in-repo workflow calls one today. Every job of a reachable workflow counts, whatever itsif:says. At3bdacabthat is 24 jobs in 11 workflows, which matches the coordinator's audit.test_every_workflow_is_written_in_the_form_the_checks_read(:2452). This applies to every workflow file, because an unreadon:could hide reachability. Every YAML-significant line (that is, outside|/>block-scalar content) must matchYAML_SUBSET_LINE: a plain key, or a sequence entry, whose value is a plain scalar, a single-quoted scalar, a double-quoted scalar with no backslash, a one-line flow sequence of those, or a block-scalar indicator. The test refuses:permissions:written as{...}(an empty{}or[]holds no key and is accepted:permissions: {}, GitHub's form that disables every scope, reads as an explicit grant of no scope, never as the repository default);Every
on:must yield events. Each reachable workflow must keep the job layoutjobs()reads: block headers at two spaces, keys at four, and a header count equal to whatjobs()read. The tree used none of the refused forms (0 of 1,907 YAML-significant lines).test_no_reachable_job_reads_a_secret(:2488). Every${{ }}expression naming thesecretscontext (secrets.X,secrets['X'],toJSON(secrets)) fails, including in comment lines, because a#line insiderun: |is script text. So does everysecrets:key, includingsecrets: inherit. Workflow-levelenv:and called workflows are covered.test_no_reachable_job_declares_an_environment_or_calls_a_remote_workflow(:2499). Either one brings secrets the caller never passes, and a remote workflow is not read here. The test fails unlessPULL_REQUEST_SECRET_SOURCE_ALLOWLISTlists the declaring line; that allowlist is empty, and no current job needs either.test_only_the_sarif_upload_jobs_hold_a_write_scope(:2525). The set of (workflow, job) pairs whose effective permissions hold a write scope must equalPULL_REQUEST_WRITE_ALLOWLISTin both directions, so a stale entry also fails. A job's own block replaces the workflow's. A job with neither fails as "repository default".write-allfails, and so does any workflow-level write.test_event_reader_misses_no_event_yaml_reads_when_available(:2477). This is a PyYAML cross-check that may skip, as the module's existing job-parser cross-check does. CI installs no PyYAML.Each failure names workflow and line, then ends with:
Why: an ordinary pull_request run from a same-repository branch, where the OpenHands resolver (#489) pushes agent-written commits, receives the repository's secrets and its job's token scopes as written; GitHub withholds them by default from a fork's pull request (a private repository can choose to send them), and a Dependabot-triggered run gets a read-only token and only Dependabot secrets.A
Fix:line follows. Changing either allowlist means editing a commented constant and the decision record.SOTA sources
All sources were read on 2026-10-04. GitHub Docs were fetched at 05:16:08Z, with the reuse, Dependabot and repository-settings pages at 05:58:01Z. Scorecard and zizmor were fetched at 05:17:07Z and byte-verified at the pins below. The YAML spec was fetched at 06:07:09Z.
pull_request> "Workflows in forked repositories": "With the exception ofGITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository. TheGITHUB_TOKENhas read-only permissions in pull requests from forked repositories." On the same page, aworkflow_runworkflow "is able to access secrets and write tokens, even if the previous workflow was not".permissions:none".read-all,write-alland{}forms andjobs.<job_id>.secrets.inherit.GITHUB_TOKENand do not have access to any secrets that are normally available"; "the only secrets available to the workflow are Dependabot secrets".environmentin the reusable workflow at the job level, the environment secret will be used, and not the secret passed from the caller workflow."secretscontext can be used ("Secrets cannot be directly referenced inif:conditionals").\x,\uand\U; "Each escape sequence must be parsed into the appropriate Unicode character".'"is the only form of escaping performed in single-quoted scalars".docs/checks.mdatf1ebd76756593c0454d782b4ba36ebc33ad131b6: Token-Permissions and Dangerous-Workflow.docs/audits.mdatv1.30.1(99a054ed9283c90abdd2d5b9fb5101d27dde9783), the version.github/requirements-ci.txtpins:excessive-permissions,secrets-inherit,secrets-outside-env,dangerous-triggers.jobs,permission_blocks,scopes,uncommented) and the cross-workflow pattern ofNoWorkflowApprovesPullRequestsTests. The cross-check used PyYAML 6.0.3 from PyPI in a throwaway venv.Evidence-class table
3bdacab(and after mergingba0e8c48), 11 workflows and 24 jobs are reachable, no secret is referenced, no job declares an environment or calls a remote workflow, and only the two SARIF uploads hold a write scope; the class passeslocal_integrationpython3 -m unittest tests.test_workflow_hardeningexit 0 (114 tests)permissions: {}with and without job grants, job-levelpermissions: {},env: {}, and #681's workflows atde0b0043syntheticde0b0043) trial-merged, both PRs' workflow test modules pass togetherlocal_integrationc230d80e+de0b0043;tests.test_workflow_hardening,tests.test_workflow_policy,tests.test_workflow_security_coverageandtests.test_sota_sources_gate: 162 tests OK (4 skipped)5e2f85e5synthetic${{ secrets.X }}step (R1) andcontents: writeon a PR job (R3) pass all 109 earlier tests in the module andzizmor --offline --persona regular1.30.1synthetic6e166809with R1 or R3 applied: exactly 1 failure, in the new class; zizmor exit 0local_integrationsource_review5e2f85e5; this head is nextLocal commands run
All runs used a scratch
TMPDIRoutside the checkout andnice -n 19, on head6e166809.The harness builds each case in a fresh root holding real copies of
tests/and.github/plus read-only symlinks for the rest of the tree. It mutates the copy and runs the class. The first-round cases each still fail with these messages (E5 and E8 are reworded):${{ secrets.X }}in a step:validate.yml:57 (job validate; on: pull_request) reads the secrets context: ${{ secrets.PLANTED_TOKEN }}secrets: inherit:validate.yml:597 (job planted-call; on: pull_request) reads the secrets context: secrets: inheritcontents: writeon a PR job:validate.yml:570 (job sota-sources; on: pull_request) holds contents: write, which PULL_REQUEST_WRITE_ALLOWLIST does not grant itsecurity-events: write:security-scan.yml:38 (job osv-scanner; on: pull_request) holds security-events: write, ...dependency-review.yml:20 (job dependency-review; on: pull_request) sets no permissions and neither does its workflow, so its token gets the repository defaultpull_request_targetflow form,issue_commentwithwrite-all,workflow_runwith a workflow-level env secret, a secret in a called workflow, a whole-job anchor and alias, a quoted'write', a write after a blank line in a block, a stale allowlist entry (nowsecurity-scan.yml:162 (job osv-sarif-upload) holds no write scope a pull request can reach, but PULL_REQUEST_WRITE_ALLOWLIST grants it ...), and a comment on thepermissions:line.5e2f85e5and fails it now.Decision record
docs/decisions/2026-09-22-github-automation-closure.md, section "Pull-request secrets and write-scope tripwire (2026-10-04)". It is appended after the existing sections, including thosemainchanged in #673. It records what is pinned, the evidence class, the cross-family review round, the alternatives (zizmor's general audits and Scorecard's checks) and the overturn conditions.Host evidence
Not applicable: no file under
evidence/hosts/changes.Checklist
version comment (no floating tags). This PR changes no workflow or action; the merge brings in only
main's ownsecurity-scan.ymlchange from Harden the three-group OSV policy and SARIF upload paths (port #555) #673.permissions: contents: read(or a narrower, explicitly justified addition). The new test pins this for every workflow a pull request can start.
added without a documented owner.
moved or overwritten).
Open points
pull_request_reviewandpull_request_review_commentare included, since both arepull_request*events.if:conditions are deliberately not credited. Both SARIF uploads skippull_requestbyif:, and they are allowlisted explicitly instead.secrets.GITHUB_TOKENis forbidden as well. The failure'sFix:line points togithub.token, which the write-scope check bounds.environment:or call a remote reusable workflow unlessPULL_REQUEST_SECRET_SOURCE_ALLOWLIST(empty) lists it. A remote workflow is not read, and an environment brings secrets the caller never passes.on:could hide reachability. Future workflows must therefore use plain block YAML; any other form fails with a line-numbered message naming the form. A maintained YAML parser in CI would let this give way to checks on parsed YAML.pushis outside the event set. All 9pushtriggers are filtered tomainorv*tags today (measured), but nothing pins that filter. This is a follow-up candidate.mainmoved toba0e8c48(Harden the three-group OSV policy and SARIF upload paths (port #555) #673) during the review round and was merged in perdocs/lanes.md. The class passes against Harden the three-group OSV policy and SARIF upload paths (port #555) #673'ssecurity-scan.ymlchange, which adds twoif: ${{ !cancelled() }}upload guards and no permission.de0b0043) moves every workflow topermissions: {}. The class passes on its 21 workflows: 11 reachable workflows, 24 jobs. A trial merge of both branches auto-mergestests/test_workflow_hardening.py; only themanifests/evidence.jsonhot file conflicts, and both PRs' workflow test modules pass together. CI least privilege: permissions {} by default, cache-mode none on pull requests, workflow policy tripwire #681'stests/test_workflow_policy.pyalready enforces most of this class's rules; it does not coverenvironment:or remote reusable calls in pull-request jobs, secrets inissue_commentorpull_request_review*workflows, orsecrets.GITHUB_TOKEN. The compatibility comment on this PR has the probe results.🤖 Generated with Claude Code