Skip to content

Workflow hardening: no pull-request job reads a secret or holds a new write scope - #682

Closed
seathatflowsinourveins wants to merge 9 commits into
mainfrom
foundation/pr-workflow-secrets-tripwire-20261004
Closed

seathatflowsinourveins wants to merge 9 commits into
mainfrom
foundation/pr-workflow-secrets-tripwire-20261004

Conversation

@seathatflowsinourveins

@seathatflowsinourveins seathatflowsinourveins commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Scope

  • What this PR changes, in one or two sentences: adds PullRequestReachableJobsTests to tests/test_workflow_hardening.py. It is a regression tripwire: no job a pull request can start may reference a repository secret, pass secrets:, declare environment:, call a remote reusable workflow, fall back to the repository-default token, or hold a write scope beyond the two SARIF upload jobs in security-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.
  • Base commit: 3bdacabd5b7bbc1b8c39f4e5fa3c694951a85751, with origin/main at ba0e8c48f (Harden the three-group OSV policy and SARIF upload paths (port #555) #673) merged in during the review round.
  • Lane: lane:foundation
  • Owned paths touched: tests/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 with host_receipts.register_file, in the branch's last commit per docs/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 ordinary pull_request run 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 at 3bdacab, but nothing in the tree pinned it.

What the class checks (tests/test_workflow_hardening.py:2434). The constants are PULL_REQUEST_EVENTS at :2245, PULL_REQUEST_WRITE_ALLOWLIST at :2249, PULL_REQUEST_SECRET_SOURCE_ALLOWLIST at :2262 (empty) and YAML_SUBSET_LINE at :2288.

  • Reachability. A workflow is reachable when its on: names pull_request, pull_request_target, pull_request_review, pull_request_review_comment, issue_comment or workflow_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 its if: says. At 3bdacab that 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 unread on: could hide reachability. Every YAML-significant line (that is, outside |/> block-scalar content) must match YAML_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:

    • non-empty flow mappings, such as a job or 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);
    • anchors, aliases, tags and merge keys;
    • quoted keys and multi-line scalars;
    • any backslash in a double-quoted scalar.

    Every on: must yield events. Each reachable workflow must keep the job layout jobs() reads: block headers at two spaces, keys at four, and a header count equal to what jobs() 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 the secrets context (secrets.X, secrets['X'], toJSON(secrets)) fails, including in comment lines, because a # line inside run: | is script text. So does every secrets: key, including secrets: inherit. Workflow-level env: 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 unless PULL_REQUEST_SECRET_SOURCE_ALLOWLIST lists 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 equal PULL_REQUEST_WRITE_ALLOWLIST in 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-all fails, 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.

  • GitHub Docs, Events that trigger workflows, pull_request > "Workflows in forked repositories": "With the exception of GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository. The GITHUB_TOKEN has read-only permissions in pull requests from forked repositories." On the same page, a workflow_run workflow "is able to access secrets and write tokens, even if the previous workflow was not".
  • GitHub Docs, Workflow syntax, permissions:
    • "How permissions are calculated for a workflow job": first the repository default, then the workflow level, then the job level. Fork pull requests get write changed to read unless "Send write tokens to workflows from pull requests" is selected.
    • "all of those that are not specified are set to none".
    • The read-all, write-all and {} forms and jobs.<job_id>.secrets.inherit.
  • GitHub Docs, Managing GitHub Actions settings for a repository, "Enabling workflows for forks of private repositories": "Available to private repositories only"; "Send write tokens to workflows from pull requests"; "Send secrets to workflows from pull requests".
  • GitHub Docs, Troubleshooting Dependabot on GitHub Actions: Dependabot runs "receive a read-only GITHUB_TOKEN and do not have access to any secrets that are normally available"; "the only secrets available to the workflow are Dependabot secrets".
  • GitHub Docs, Reuse workflows, "Using inputs and secrets in a reusable workflow": "If you include environment in the reusable workflow at the job level, the environment secret will be used, and not the secret passed from the caller workflow."
  • GitHub Docs, Reusing workflow configurations: "YAML anchors and aliases", including whole-job reuse; a called workflow's token "can be only downgraded (not elevated)".
  • GitHub Docs, Contexts reference, context availability and Using secrets: where the secrets context can be used ("Secrets cannot be directly referenced in if: conditionals").
  • YAML 1.2.2:
    • 5.7 Escaped Characters: 20 escapes including \x, \u and \U; "Each escape sequence must be parsed into the appropriate Unicode character".
    • 7.3.1: double-quoted is "the only style capable of expressing arbitrary strings".
    • 7.3.2: a repeated ' "is the only form of escaping performed in single-quoted scalars".
    • 7.3.3: plain style "provides no form of escaping".
  • OpenSSF Scorecard docs/checks.md at f1ebd76756593c0454d782b4ba36ebc33ad131b6: Token-Permissions and Dangerous-Workflow.
  • zizmor docs/audits.md at v1.30.1 (99a054ed9283c90abdd2d5b9fb5101d27dde9783), the version .github/requirements-ci.txt pins: excessive-permissions, secrets-inherit, secrets-outside-env, dangerous-triggers.
  • Implementation reference: this module's own text helpers (jobs, permission_blocks, scopes, uncommented) and the cross-workflow pattern of NoWorkflowApprovesPullRequestsTests. The cross-check used PyYAML 6.0.3 from PyPI in a throwaway venv.

Evidence-class table

Claim Evidence class Command / receipt
At 3bdacab (and after merging ba0e8c48), 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 passes local_integration python3 -m unittest tests.test_workflow_hardening exit 0 (114 tests)
Each of 34 planted mutations fails the class with a line-numbered message. The unmutated copy and 5 accepted variants pass: workflow-level permissions: {} with and without job grants, job-level permissions: {}, env: {}, and #681's workflows at de0b0043 synthetic mutation harness (outside the repo), exit 0, 40 cases
With #681 (de0b0043) trial-merged, both PRs' workflow test modules pass together local_integration scratch clone of c230d80e + de0b0043; tests.test_workflow_hardening, tests.test_workflow_policy, tests.test_workflow_security_coverage and tests.test_sota_sources_gate: 162 tests OK (4 skipped)
The 16 mutations added for the review findings all passed the class at 5e2f85e5 synthetic same harness, old-class column
A planted ${{ secrets.X }} step (R1) and contents: write on a PR job (R3) pass all 109 earlier tests in the module and zizmor --offline --persona regular 1.30.1 synthetic full module in a clone at 6e166809 with R1 or R3 applied: exactly 1 failure, in the new class; zizmor exit 0
The PyYAML cross-check runs and passes on the tree and on the planted trigger forms, and fails under a sabotaged event reader local_integration venv with PyYAML 6.0.3
GitHub's behaviour for same-repository, fork and Dependabot pull requests, environments in reusable workflows, and YAML escapes source_review sources above
Hosted CI first run on 5e2f85e5; this head is next this PR's checks

Local commands run

All runs used a scratch TMPDIR outside the checkout and nice -n 19, on head 6e166809.

$ python3 -m unittest tests.test_workflow_hardening
Ran 114 tests                           # exit 0; OK (skipped=3): empty HASH_FROZEN and the two PyYAML cross-checks
$ python3 scripts/validate.py
{"components": 69, "hashed_files": 9808, "profiles": 4, "receipts": 194, "status": "passed"}   # exit 0
$ python3 scripts/evidence_manifest.py --check
{"files": 9808, "status": "passed"}     # exit 0
$ git diff --check origin/main...HEAD   # exit 0
$ python3 scripts/component_matrix.py --check; python3 scripts/new_host_grand_list.py --check
# exit 0; exit 0, so the hot-file protocol's --write steps were no-ops
$ python3 harness.py <worktree> 5e2f85e5=FGH 846cb03e=AMX   # mutation harness, kept outside the repository
expected-pass cases: 6; expected-fail cases: 34
HARNESS RESULT: PASS (every expected-pass case green; every mutant red with its expected, line-numbered message)   # exit 0
$ bash full-module.sh <worktree>        # R1 / R3 against all 114 tests in a clone of the branch
R1 EXIT=1 (1 failure, new class) / R3 EXIT=1 (1 failure, new class) / control EXIT=0

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):

  • R1 ${{ secrets.X }} in a step: validate.yml:57 (job validate; on: pull_request) reads the secrets context: ${{ secrets.PLANTED_TOKEN }}
  • R2 secrets: inherit: validate.yml:597 (job planted-call; on: pull_request) reads the secrets context: secrets: inherit
  • R3 contents: write on a PR job: validate.yml:570 (job sota-sources; on: pull_request) holds contents: write, which PULL_REQUEST_WRITE_ALLOWLIST does not grant it
  • R4 a third security-events: write: security-scan.yml:38 (job osv-scanner; on: pull_request) holds security-events: write, ...
  • R5 no permissions anywhere: dependency-review.yml:20 (job dependency-review; on: pull_request) sets no permissions and neither does its workflow, so its token gets the repository default
  • E1–E9: pull_request_target flow form, issue_comment with write-all, workflow_run with 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 (now security-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 the permissions: line.
  • F1–F2, G1–G11, H1–H3: the repair-round cases are listed with their messages in the review-round comment on this PR. Each passed the class at 5e2f85e5 and 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 those main changed 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

  • New/changed GitHub Actions are pinned to a full commit SHA with a
    version comment (no floating tags). This PR changes no workflow or action; the merge brings in only main's own security-scan.yml change from Harden the three-group OSV policy and SARIF upload paths (port #555) #673.
  • New/changed workflows declare top-level permissions: contents: read
    (or a narrower, explicitly justified addition). The new test pins this for every workflow a pull request can start.
  • No secrets are printed, logged or committed; no new required secret was
    added without a documented owner.
  • No new paid hosting, subscription or billing surface was introduced.
  • Peer-owned untracked files and worktrees were preserved (not deleted,
    moved or overwritten).

Open points

  • The trigger set is a superset of the brief: pull_request_review and pull_request_review_comment are included, since both are pull_request* events.
  • if: conditions are deliberately not credited. Both SARIF uploads skip pull_request by if:, and they are allowlisted explicitly instead.
  • secrets.GITHUB_TOKEN is forbidden as well. The failure's Fix: line points to github.token, which the write-scope check bounds.
  • A reachable job may not declare environment: or call a remote reusable workflow unless PULL_REQUEST_SECRET_SOURCE_ALLOWLIST (empty) lists it. A remote workflow is not read, and an environment brings secrets the caller never passes.
  • The YAML subset applies to every workflow file, not only reachable ones, because an unread 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.
  • push is outside the event set. All 9 push triggers are filtered to main or v* tags today (measured), but nothing pins that filter. This is a follow-up candidate.
  • The zizmor comparison ran offline. CI runs zizmor online, which adds only pin and ref audits.
  • OpenHands resolver: host driver, one owner issue to one draft PR (stages 1-2, offline-tested) #489 adds no workflow file (it is a host driver), so this tripwire does not conflict with it. main moved to ba0e8c48 (Harden the three-group OSV policy and SARIF upload paths (port #555) #673) during the review round and was merged in per docs/lanes.md. The class passes against Harden the three-group OSV policy and SARIF upload paths (port #555) #673's security-scan.yml change, which adds two if: ${{ !cancelled() }} upload guards and no permission.
  • CI least privilege: permissions {} by default, cache-mode none on pull requests, workflow policy tripwire #681 (CI least privilege, de0b0043) moves every workflow to permissions: {}. The class passes on its 21 workflows: 11 reachable workflows, 24 jobs. A trial merge of both branches auto-merges tests/test_workflow_hardening.py; only the manifests/evidence.json hot 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's tests/test_workflow_policy.py already enforces most of this class's rules; it does not cover environment: or remote reusable calls in pull-request jobs, secrets in issue_comment or pull_request_review* workflows, or secrets.GITHUB_TOKEN. The compatibility comment on this PR has the probe results.

🤖 Generated with Claude Code

Scout and others added 2 commits October 4, 2026 01:28
… 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]>
@seathatflowsinourveins seathatflowsinourveins added the lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers label Oct 4, 2026
Scout and others added 3 commits October 4, 2026 02:08
… 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]>
@seathatflowsinourveins

Copy link
Copy Markdown
Collaborator Author

Claude session native-agent-stack-5f: review round for the cross-family read of #682 at 5e2f85e5 (3 P1, 1 P2). All four findings are fixed at head 33bae2e9, which consists of the repair commit 60345dea, the manifest re-registration 3adadb50, and a merge of main at ba0e8c48 (#673) via the docs/lanes.md no-force-push path.

P1-1: flow-form jobs were silently omitted.

  • Fix, part 1: syntax_lines() with YAML_SUBSET_LINE. Every YAML-significant line of every workflow must be in a plain block subset; block-scalar content is excluded. Flow mappings are refused everywhere, so a job, permissions:, env: or on: written as {...} fails.
  • Fix, part 2: job_layout_problems(). Each reachable workflow needs block job headers at two spaces and job keys at four. The header count must equal len(jobs()), and zero headers fails.
  • Mutant F1, your flow-form job as a PR workflow's only job:
    • planted-flow-job.yml:6 is outside the YAML subset these checks read (a flow mapping): planted: {runs-on: ubuntu-latest, permissions: {contents: write}, steps: [{run: 'true'}]}
    • planted-flow-job.yml:5 (on: pull_request) has no block job header at two spaces, so no job would be checked
  • Mutant F2, a job whose keys sit at six spaces and call a remote workflow: validate.yml:596 (on: pull_request) starts a job whose own keys are not at four spaces, where the job-level checks read them

P1-2: YAML escapes evaded the scans.

  • Choice: reject any backslash in a double-quoted scalar, with an empty allowlist, rather than decode.
  • Evidence:
    • YAML 1.2.2 section 5.7 lists 20 escapes, and "Each escape sequence must be parsed into the appropriate Unicode character".
    • \x, \u and \U spell any character, and an escaped line break joins two lines.
    • Even \t before a name hides where the name starts from an identifier scan (G11).
    • Sections 7.3.2 and 7.3.3 say single-quoted scalars (only '') and plain scalars have no backslash escapes, so those are read as written.
    • The tree has no backslash in any double-quoted scalar, so the empty allowlist costs nothing.
  • Mutants: every one passed the class at 5e2f85e5 and fails it now.
    • G1–G3, s / \x73 / \U00000073 in a secrets read: validate.yml:57 is outside the YAML subset these checks read (a double-quoted scalar with a backslash escape or a line break): PLANTED: "${{ secrets.PLANTED_TOKEN }}" (and the \x73, \U00000073 forms)
    • G4–G6, _ / \x5f / \U0000005f in pull_request: planted-escaped-event.yml:2 ... on: ["push", "pull_request"]. Before the repair this workflow looked unreachable, so its secret and write scope went unchecked.
    • G7–G9, p / \x70 / \U00000070 in a job's permissions key: validate.yml:570 ... "permissions":
    • G10, an escaped line break: validate.yml:57 ... PLANTED: "${{ sec\ (and line 58)
    • G11, \t: validate.yml:57 ... PLANTED: "${{ \tsecrets.PLANTED_TOKEN }}"

P1-3: environments and remote reusable workflows.

  • Fix: a new test, test_no_reachable_job_declares_an_environment_or_calls_a_remote_workflow.
    • It is backed by PULL_REQUEST_SECRET_SOURCE_ALLOWLIST, which is empty and commented. It is compared in both directions, so a stale entry fails.
    • No current job needs either: 0 environment: keys and 0 job-level uses: in the tree.
    • The class docstring no longer says caller-passed secrets are the only source. It now cites "If you include environment in the reusable workflow at the job level, the environment secret will be used, and not the secret passed from the caller workflow" (reuse workflows).
  • Mutants:
    • H1, a PR job with environment: preview: validate.yml:570 (job sota-sources; on: pull_request) has environment: preview, which brings secrets its caller does not pass and PULL_REQUEST_SECRET_SOURCE_ALLOWLIST does not list
    • H2, a remote call: validate.yml:596 (job planted-remote; on: pull_request) has uses: octo-org/other-repo/.github/workflows/reusable.yml@0123..., ...
    • H3, an environment inside a called local workflow: sota-sources-gate.yml:24 (job sota-sources; called by validate.yml:planted-call) has environment: preview, ...

P2: the fork guarantee was overstated.

Re-run on 33bae2e9.

  • Harness: 31 cases, exit 0. The control passes. All 30 mutants (R1–R5, E1–E9, F1–F2, G1–G11, H1–H3) fail with line-numbered messages. All 16 new ones passed the class at 5e2f85e5.
  • Module: 114 tests, OK (3 skipped), exit 0.
  • Checks: validate.py 0, evidence_manifest.py --check 0, git diff --check origin/main...HEAD 0, component_matrix.py --check 0, new_host_grand_list.py --check 0.
  • Full module in a clone: R1 and R3 each fail exactly one test, in the new class; the control passes.
  • PyYAML 6.0.3 cross-check: 6 of 6 pass.

Residuals.

  • The YAML subset applies to every workflow file, not only reachable ones, which constrains future authors to plain block YAML. A failure names the line and the form.
  • Unfiltered push remains outside the event set; all 9 push triggers are filtered today, but nothing pins that.
  • The first hosted run on this head is pending.

Scout and others added 2 commits October 4, 2026 02:17
…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]>
Scout and others added 2 commits October 4, 2026 02:33
… {}` (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]>
@seathatflowsinourveins

Copy link
Copy Markdown
Collaborator Author

Claude session native-agent-stack-5f: compatibility fix for #681, on head 6e166809 (the fix is c230d80e; the registry is the last commit, 6e166809).

Defect. YAML_SUBSET_LINE rejected the empty flow mapping, so GitHub's documented permissions: {} (and env: {}) failed the class. On #681's workflows at de0b0043 the class failed 30 times, every one on that form: 19 subset lines plus 11 workflow-level "form not read" lines. No concurrency, cache-mode or new if: line from #681 was rejected.

Fix.

  • An empty {} is now accepted as a value; an empty [] already was. Either holds no key, so it cannot hide a grant, a secret or an event.
  • permissions: {} at workflow or job level reads as an explicit grant of no scope, never as the repository default.
  • A non-empty flow mapping still fails, now labelled "a non-empty flow mapping".

Evidence.

  • New harness cases:
  • All five accepted cases (A1–A4, X681) failed the class at 846cb03e.
  • The harness has 40 cases (6 expected-pass, 34 mutants) and exits 0.
  • The module runs 114 tests, OK, exit 0. validate.py, evidence_manifest.py --check and git diff --check origin/main...HEAD all exit 0.

With #681.

Overlap with #681's tests/test_workflow_policy.py, probed on #681's own workflows with its hygiene rules satisfied.

seathatflowsinourveins added a commit that referenced this pull request Oct 4, 2026
…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]>
@seathatflowsinourveins

Copy link
Copy Markdown
Collaborator Author

Superseded by #686, merged as 07a6a0a. That PR folds the assertions of this tripwire that #681's tests/test_workflow_policy.py did not yet hold into that module, as three rules on the parsed workflows: pull-request-environment, pull-request-remote-workflow and comment-event-secret. It keeps this PR's reachability computation, including local workflow_call chains, and adds planted negative controls. #686's description maps each assertion here to its new location. It passed a cross-family full read, one repair round and a delta read (ACCEPT at the merged head). Closing per the coordinator's Option-1 decision.

@seathatflowsinourveins
seathatflowsinourveins deleted the foundation/pr-workflow-secrets-tripwire-20261004 branch October 8, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant