Skip to content

ci: pass untrusted pull-request fields through env in changeset-check - #122

Merged
willgriffin merged 3 commits into
mainfrom
claude/pdf-121-changeset-check-injection
Jul 31, 2026
Merged

ci: pass untrusted pull-request fields through env in changeset-check#122
willgriffin merged 3 commits into
mainfrom
claude/pdf-121-changeset-check-injection

Conversation

@willgriffin

Copy link
Copy Markdown
Contributor

Closes #121

What was wrong

The Check for changeset step interpolated two attacker-controlled pull-request
fields directly into its inline run: script:

run: |
  PR_TITLE="${{ github.event.pull_request.title }}"
  PR_BRANCH="${{ github.event.pull_request.head.ref }}"

${{ }} expansion happens before the shell ever reads the script, so the
substituted text becomes shell source. Anyone who can open a pull request could
run commands on the runner by choosing a title or branch name.

Reproduced locally by simulating GitHub's textual substitution with the title
fix: innocent"; touch pwned.txt; echo ":

form result
old (inline interpolation) pwned.txt created — injected command ran
new (env:) no side effect; title handled as data

The workflow's permissions: { pull-requests: read, contents: read } and its
pull_request (not pull_request_target) trigger keep the blast radius small —
no write-scoped token, no repository secrets beyond the read token — but they do
not stop arbitrary execution inside the job, and GH_TOKEN is in scope for the
step.

The fix

Pass both fields through the step's env: block, per GitHub's documented
mitigation, and drop the two assignments. Every existing use already quotes
"$PR_TITLE" / "$PR_BRANCH", so nothing else changed.

actionlint reports only the first untrusted expression per run: block, so it
flagged the title but not the head.ref line one row below — both are fixed.

Also quoted >> "$GITHUB_ENV" in the Get pnpm store directory step, clearing the
one pre-existing SC2086 in this file. actionlint now reports zero findings for
changeset-check.yml.

Behaviour

The gate's skip/pass/fail decisions are unchanged. Verified by running the
PR_TITLE / PR_BRANCH decision branches verbatim against a title/branch matrix:

PR title branch decision
fix: quote untrusted PR fields claude/… pass (patch)
feat(pdf): add thing claude/… pass (patch)
refactor!: drop legacy api claude/… pass (minor)
chore(release): v0.65.9 changeset-release/main skip (release PR)
chore(deps): bump foo renovate/foo-1.x skip (renovate)
chore: version packages claude/… skip (version packages)
docs: tweak readme claude/… falls through to changeset/label check

One incidental improvement: a title containing a double quote used to be truncated
at the quote
by the old assignment, so it could be classified on a mangled string.
It is now classified on the real title.

Sibling repo sweep

actionlint was run across .github/workflows/ in the repos that share these
copied workflows. No is potentially untrusted findings:

repo result
happyvertical/smrt clean
happyvertical/sdk clean — already passes PR_TITLE/PR_BRANCH via env: in on-pull-request.yml
happyvertical/happyvertical.com clean
happyvertical/iac clean

pdf was the only repo still carrying the pattern.

Validation

  • actionlint .github/workflows/changeset-check.yml — no findings
  • actionlint (repo-wide) — no is potentially untrusted findings
  • pnpm lint / pnpm typecheck / pnpm build — pass
  • pnpm test — 17 files, 162 tests pass
  • scripts/validate-conventional-commits.sh — commit range and PR title clean

This PR carries the skip-changeset label: it touches only .github/workflows/,
adds no changeset, and its ci: commit is not releaseable, so the label's
consistency checks all hold.

{
  "schema": "hv-agent-run:v1",
  "runtime": "claude",
  "session": "4e86f515-9c37-41bb-9228-2542cccbac3f",
  "issue": "121",
  "policy_revision": "1.0.0",
  "validation": [
    "actionlint .github/workflows/changeset-check.yml",
    "actionlint",
    "pnpm lint",
    "pnpm typecheck",
    "pnpm build",
    "pnpm test",
    "bash scripts/validate-conventional-commits.sh range origin/main HEAD"
  ]
}

`${{ }}` expansion happens before the shell ever reads the script, so
interpolating github.event.pull_request.title and .head.ref into the
`Check for changeset` step's inline run: block made a crafted PR title or
branch name execute as shell source on the runner.

Pass both through the step's env: block instead, per GitHub's documented
mitigation. Every existing use already quotes "$PR_TITLE" / "$PR_BRANCH",
so the gate's skip/pass/fail decisions are unchanged.

Also quote $GITHUB_ENV in the pnpm store step so actionlint reports no
findings for this file (SC2086).
@willgriffin willgriffin added the skip-changeset No package release required label Jul 30, 2026
@willgriffin

Copy link
Copy Markdown
Contributor Author

lifecycle / diagnostic is red for a pre-existing reason, not this change

This PR touches only .github/workflows/changeset-check.yml. The lifecycle failure is
repo state that predates it:

ERROR /home/runner/work/pdf/pdf/AGENTS.md: stale or edited managed policy block
ERROR /home/runner/work/pdf/pdf/AGENTS.md: generated policy block was edited

AGENTS.md carries the generation-17 managed kernel block
(sha256=9a4cf725…), but this repo now resolves to the stable policy channel at
generation 21 (sha256=2524e6ee…), whose kernel text differs. The repo is at the
diagnostic lifecycle stage, so the compatibility grace for a previous-release kernel
does not apply.

Evidence that it is not caused by this branch:

  • PR ci: cancel superseded Changeset Check runs #120, opened before this one and unrelated, fails both lifecycle and
    lifecycle / diagnostic with the same two errors.
  • git diff origin/main...HEAD touches one file, and it is not AGENTS.md.
  • The channel manifest lists this repository among the generation-21 smoke
    targets, so the pin advanced without a matching AGENTS.md refresh.

happyvertical/sdk is in the same state. happyvertical/smrt is already current —
it was refreshed by its own dedicated PR (happyvertical/smrt#2141,
chore(policy): refresh generation 21 kernel).

Remedy

Regenerating the managed block is mechanical: the expected bytes are derived from the
cosign-verified pinned artifact that CI itself loads, so the result is exactly
reproducible. It is deliberately not folded into this PR — the managed kernel block
is policy content, which the drive-by allowance excludes — and it needs its own claimed
cycle, as smrt did.

Everything else here is green or pending; notably Check for Changeset passes,
which exercises the edited workflow with the fix in place.

@github-actions

Copy link
Copy Markdown
Contributor

Release Preview

This PR title will not trigger an automatic release on squash merge.

Use a releaseable conventional PR title like fix(pdf): ..., feat(pdf): ..., or chore(deps): ... if this change should publish automatically.

Otherwise, add a manual changeset or apply the skip-changeset label if no release is needed.

@willgriffin

Copy link
Copy Markdown
Contributor Author

Correction to my note above: the remedy is already in flight — #124
(ci(pdf): vendor generation 21 policy, AGENTS.md only). Its
lifecycle / diagnostic passes, which confirms the diagnosis. Nothing is needed here.

Sequence to green this PR:

  1. ci(pdf): vendor generation 21 policy #124 merges.
  2. Re-run lifecycle / lifecycle / diagnostic on this PR. The pull_request checkout
    resolves refs/pull/122/merge, so the refreshed AGENTS.md from main is picked up
    without a rebase commit — though a strict up-to-date ruleset may still want an
    update-branch first, which would need a re-claim and re-release for the new head.

Everything in this PR's own scope is green: Check for Changeset (the workflow this PR
edits) pass, Run Tests pass, Validate Conventional Commits pass,
release evidence recorded.

Equivalent generation-21 refreshes are open in the sibling repos too — sdk#1179 and
sdk#1174, happyvertical.com#143, smrt#2157.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the changeset-check GitHub Actions workflow against shell-injection by preventing attacker-controlled pull request fields from being expanded into an inline run: script, while keeping the changeset gate’s behavior unchanged.

Changes:

  • Pass github.event.pull_request.title and github.event.pull_request.head.ref into the step via env: and remove the inline shell assignments that previously allowed injection.
  • Quote "$GITHUB_ENV" in the pnpm store step to avoid unsafe word-splitting/globbing in the redirect target.

@willgriffin
willgriffin added this pull request to the merge queue Jul 31, 2026
Merged via the queue into main with commit 933e37d Jul 31, 2026
9 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset No package release required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

changeset-check.yml interpolates untrusted PR title and head ref into an inline shell script

2 participants