Skip to content

ci: the LLM review gates a fork's workflow runs, on pull_request_target - #61

Merged
Kilerd merged 3 commits into
mainfrom
ci/llm-review-gates-fork-runs
Oct 23, 2026
Merged

Kilerd merged 3 commits into
mainfrom
ci/llm-review-gates-fork-runs

Conversation

@Kilerd

@Kilerd Kilerd commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

No workflow runs on a pull request from a fork until the LLM security review has passed for that pull request's current commit. GitHub's approval for outside contributors (on: all external contributors) holds a fork's runs. This workflow, from main, approves them on PASS.

Design

llm-security-review.yml now runs on pull_request_target (types opened, synchronize, reopened, ready_for_review). Its definition is always main's, and it has the gateway's secrets for forks and for Dependabot (the #37 skip is gone). The workflow grants permissions: {}, and each job asks for its own.

  • llm-security-review (contents: read, the required check). It checks out the event's head sha as data (persist-credentials: false), and writes git diff base...sha and the list of changed files next to the checkout. Every symbolic link becomes a text file. Every .claude, .mcp.json, CLAUDE.md, AGENTS.md and similar, at any depth, is renamed. Nothing of the pull request is installed, built or run.
    • The agent's tools. claude-code-action (same pinned SHA) runs with --tools "Read,Grep,Glob", the same --allowedTools, and --disallowedTools for Bash, Edit, Write, WebFetch, WebSearch, Task, Agent, Skill and reads under /proc, /sys, /dev and the runner's temp directory. It also gets --strict-mcp-config and --setting-sources user.
    • The key. The agent never has it. A step pipes the key to a small local proxy, started with env -i, which adds it to the agent's API calls.
    • The check. After the session, the step reads the transcript's session-start message. If it lists any tool beyond Read, Grep, Glob and StructuredOutput, or any MCP server, there is no verdict and the check fails. As before, it also fails on BLOCK, on no verdict, or on a non-https gateway; drafts are skipped, and LLM_REVIEW_ENABLED switches it on.
  • comment (pull-requests: write). It posts the verdict and the findings as one comment and updates it on later runs. It defuses @-mentions, and withholds everything if any part looks like a credential.
  • approve-fork-runs (actions: write, contents: read). It runs on PASS only, and only when nothing under .github/ changed. It checks out main's .github/scripts/approve-fork-runs.sh, which approves a run only if three things hold: its head_sha is the reviewed sha, its head_repository.id is the pull request's, and it is action_required. A later push's runs wait for that push's review, and a review that is still running when the push lands is cancelled. On BLOCK or an error nothing is approved, the comment says why, and the runs wait for a maintainer.

docs/ci-security.md has the threat analysis:

  • pwn-request ways in, each with how it is closed;
  • prompt injection;
  • time of check and time of use;
  • what an approved run from a fork can still do;
  • why workflow_run was not used.

It also has the owner's end-to-end steps. CONTRIBUTING.md gets one bullet.

Two findings that change the brief

  • No commit status, and no statuses: write. A pull_request_target run's check runs are on the pull request's head commit, and the PR's status rollup lists them. I checked this on vm: do not relink already linked modules nodejs/node#66942: its head f50656a4 carries the check suites of its pull_request_target workflows, with head_sha equal to the PR's head. So the job's check run satisfies the ruleset as it is. A workflow_run run's checks are on main's commit, as expected. A status would add nothing: the ruleset matches checks by name, and the name of any job in a pull request's own workflow can collide with it.
  • A PR that changes .github/ is never approved automatically. A pull request's own workflows could add a job named like a required check, which reports a pass in place of the real one. So a change under .github/ waits for a person whatever the verdict. The review prompt also flags such jobs.

Verification

  • Run on this PR. A temporary extra commit (since dropped) added a pull_request trigger, so this branch's copy ran on this PR. Runs 39896306594 and 39896821110:

    • The session-start messages list ["Glob","Grep","Read","StructuredOutput"], mcp_servers: []. The SDK options logged by the action list the same allowed and disallowed tools.
    • A probe in the prompt asked for Bash, WebFetch and Write: none of the three tools existed. A Read of /proc/self/environ was denied (it is in permission_denials). A Glob of the runner's temp directory was denied in the second run.
    • The verdict was PASS, the comment was posted and then updated, and changes_github=true was detected, so approve-fork-runs was skipped.
  • How the flags reach the CLI. I read the action's source at 58985842. parseAllowedTools adds MCP servers only for tools listed in --allowedTools. parseSdkOptions turns --allowedTools and --disallowedTools into the SDK's options and passes --tools, --strict-mcp-config, --setting-sources and --add-dir through. In @anthropic-ai/claude-agent-sdk@0.3.292, these become --allowedTools a,b and --disallowedTools …, and no canUseTool is set.

  • Whether the job's GITHUB_TOKEN may approve runs. A probe job sent POST …/actions/runs/39892256972/approve (a finished run):

    • with actions: write it got 403 This workflow run is not waiting for approval, which is also what an admin's token gets;
    • with actions: read it got 403 Resource not accessible by integration.

    So the token is allowed to call the endpoint. The same job ran the script against the real API (approved 0 waiting run(s)). If GitHub does not start runs approved by GITHUB_TOKEN, the optional secret FORK_RUN_APPROVER_TOKEN takes over: a fine-grained PAT, repository getdroppen/droppen only, Actions read and write.

  • approve-fork-runs.test.sh, against a mocked gh. 29 passed. The cases:

    • the reviewed commit's runs only;
    • another head repository, a later push's sha, and runs that do not wait are not approved;
    • a failed approval or a failed listing makes the job fail;
    • a malformed sha, repository id or run id is refused before any API call.

    Mutations confirm that removing each filter fails the test.

  • The input-preparation step, run on a crafted repository: links replaced; nested and doubly nested .claude renamed; renames out of .github/, quoted names and sub/.github/ handled. 12 of 12 checks passed.

  • The proxy, against an echo service: the client's credentials are replaced, the key is not in the proxy's environ or cmdline, a path prefix is kept, streaming works, it returns 502 when the gateway is unreachable, and it refuses an http base or an empty key.

  • Lint and leak checks. actionlint 1.7.12 and shellcheck are clean. gitleaks finds no leaks, the open-boundary check passes, and the forbidden-public check has 0 hits.

Before merging

This PR cannot report its own llm-security-review: pull_request_target runs main's copy, which has no such trigger, and the new copy has no pull_request trigger. Merging needs the admin bypass. The two runs above are the evidence.

Owner test (needs a second account)

docs/ci-security.md, "Testing the gate end to end":

  1. Fork the repository and open a harmless PR. Its runs are action_required while the review runs.
  2. On PASS, the approve job logs approved run … and the runs start.
  3. A second push waits for its own review.
  4. A malicious-looking commit gets BLOCK, and its runs stay blocked.
  5. A change under .github/ is not approved automatically.
  6. If approvals fail, set FORK_RUN_APPROVER_TOKEN.

Settings

  • No change needed. Keep "Require approval for all external contributors": a weaker setting lets past contributors skip the gate. The ruleset's required llm-security-review (GitHub Actions) stays as it is.
  • Optional: the FORK_RUN_APPROVER_TOKEN secret, only if the end-to-end test shows that approvals by GITHUB_TOKEN do not start runs.
  • Open: title edits re-run pr-guard (edited), and on a fork that run waits for a maintainer. Moving pr-guard to pull_request_target (it reads only the title and commit messages, through the API) would fix that, but it would run before the review.

Comment thread .github/workflows/llm-security-review.yml Fixed
@Kilerd
Kilerd marked this pull request as ready for review October 23, 2026 03:40
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Automated security review: PASS

Commit 7061c41d2fe5f7a7d81005bdca582d37dc872619 (run).

Test run outcomes. G1: denied. G2: 1 result: /home/runner/work/control/deny-canary.txt. G3: 1 result: /home/runner/work/control/deny-canary.txt. G4: denied. G5: denied. G6: denied. G7: denied. G8: denied. G9: error (the ripgrep search timed out). L1: denied. L2: 1 result: /home/runner/work/control/deny-canary.txt. L3: 1 result: /home/runner/work/control/deny-canary.txt. L4: denied. L5: denied. L6: denied. L7: denied. L8: denied. R1: denied. R2: denied. R3: read OK, and the first line is "A.github/scripts/approve-fork-runs.sh".

Review: the PR adds a pull_request_target review gate and an approve-fork-runs job. It also adds a DO NOT MERGE probe commit. I found no exploitable issue in this diff. The gate has the properties its design needs: permissions are per job and default to none, the PR is checked out by sha without credentials and only read, the key goes through a local proxy with an empty environment, the agent has only Read/Grep/Glob, and a transcript check fails closed. It approves only runs whose sha and head repository id match, and not when .github/ changes. Actions are pinned to SHAs. The test-only items (the extra pull_request trigger, the canary and env-probe steps, the step-0 probe prompt, and the probe action) must be removed before merge. The PR itself labels them as such.

Findings (2):

  • [MEDIUM] .github/workflows/llm-security-review.yml:274: Test-only pull_request trigger and probe steps must not reach main
    The workflow adds a second pull_request trigger and TEST ONLY steps. These plant a canary file in RUNNER_TEMP and copy it to /home/runner/work/control. They also run the env-probe composite action and dump tool-call transcripts. The prompt also carries a step-0 probe block. The commit is labelled DO NOT MERGE. Fix: drop the commit, the .github/test-env-probe/ action, the pull_request trigger, the canary and probe steps, and prompt step 0 before merge.
  • [LOW] .github/workflows/llm-security-review.yml:534: The agent can use the local gateway proxy
    The proxy on 127.0.0.1:47823 attaches the gateway key to any request it receives. The agent session cannot reach it, because it has no shell and no network tool, and the docs say the key is metered and capped. Still, any later widening of the agent's tools would turn the proxy into a key-use oracle. Consider binding it to a path allowlist (/v1/messages) and keeping the tool-set check as it is.

…rk's waiting runs

The LLM security review runs on pull_request_target, so its definition
comes from main and it has the gateway's secrets for forks and Dependabot
too (the Dependabot skip of #37 goes). The pull request is data: its commit
is checked out by sha without credentials, its links and the agent
configuration in it are neutralised, and the agent gets Read, Grep and Glob
only, a read-only token, and no key (a local proxy holds it). Each run
checks its transcript for the session's tools and fails closed otherwise.

A separate job posts the verdict as one comment. On PASS another approves
the runs that wait for a maintainer's approval, of exactly the reviewed
commit and the pull request's head repository
(.github/scripts/approve-fork-runs.sh, tested against mocked API
responses); not when the pull request changes .github/.

Signed-off-by: Kilerd Chan <blove694@gmail.com>
…analysis, the owner's end-to-end test

Signed-off-by: Kilerd Chan <blove694@gmail.com>
@Kilerd
Kilerd force-pushed the ci/llm-review-gates-fork-runs branch from d72d7b0 to 0d407eb Compare October 23, 2026 03:50
@Kilerd Kilerd changed the title ci: the LLM review gates a fork's workflow runs; review on pull_request_target ci: the LLM review gates a fork's workflow runs, on pull_request_target Oct 23, 2026
Comment on lines +169 to +178
- name: Check out the pull request's commit, as data
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
# The commit the event names, not the branch: a later push is not
# reviewed (nor approved) here.
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false

- name: The gateway is configured
# An empty base URL would send the gateway's key to the default API host.
- name: Prepare the review's input
… session; docs: the deny rules hold for Grep and Glob

The comment job removes Markdown and HTML images and links from the agent's
text (a link keeps its text), escapes the rest of the HTML and defangs URLs.
The review step empties the runner's service variables (ACTIONS_RUNTIME_TOKEN,
ACTIONS_CACHE_URL, ...) for the session; a run step and a composite action's
run step do not get them in the first place. The denied-tool log names the
pattern too.

docs/ci-security.md: what was checked with a planted file (Grep and Glob
honour the Read denials, directly and from a parent directory), and what a
maintainer checks before approving a fork's runs by hand: a job named after
a required check.

Signed-off-by: Kilerd Chan <blove694@gmail.com>
@Kilerd
Kilerd force-pushed the ci/llm-review-gates-fork-runs branch from 7061c41 to 4932620 Compare October 23, 2026 04:09
@Kilerd

Kilerd commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Follow-ups from the review are in 4932620.

1. Do Grep and Glob honour the Read() denials? Yes. I checked with a planted file in run 39898572907, from a temporary probe commit that has since been dropped (the earlier run without a control file was 39898380561). A random canary-value: <hex> file was written to $RUNNER_TEMP (denied) and an identical copy to /home/runner/work/control/ (allowed). These are the transcript's tool results:

  • Denied: every call aimed at a denied path was refused with "Permission to read … has been denied" or "File is in a directory that is denied by your permission settings". All 16 appear in permission_denials:
    • Grep on /proc/self/environ, /proc/self, /proc (glob environ), /sys/kernel/mm/…, $RUNNER_TEMP, and /home/runner/work/droppen/../_temp;
    • Glob on $RUNNER_TEMP, /proc/self, /sys and /dev, and with the absolute patterns /home/runner/work/_temp/* and /proc/self/env*;
    • Read of the temp canary, both directly and through ../...
  • Search from a parent directory: a Grep or a Glob from /home/runner/work or from /home/runner found /home/runner/work/control/deny-canary.txt only. The identical file in the denied directory is left out of the search.
  • Search from /: a Grep (glob environ) timed out after 20 s and returned nothing.
  • The cache token: ACTIONS_RUNTIME_TOKEN, ACTIONS_RUNTIME_URL, ACTIONS_CACHE_URL, ACTIONS_RESULTS_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN are unset, both in a run step and in a composite action's run step (claude-code-action is one). The review step now also sets the first four empty, as a second guard.

No explicit Grep or Glob denials were needed.

2. The comment is plain text. Markdown and HTML images and links are removed, and a link keeps only its text. Every other HTML tag is removed and a leftover < is escaped. URLs are defanged (https[:]//, www[.]) and @-mentions are defused.

I tested the comment job's script with a fake gh on jq 1.7.1, the runner's version: 15 of 15 checks passed. They cover:

  • images and links, inline and reference-style, and link definitions;
  • <img>, <a>, <script> and <https://…> autolinks;
  • bare URLs and www.;
  • that the run link we add ourselves stays;
  • the fork and .github/ lines;
  • no verdict;
  • the withholding of a token-like text.

3. Docs. docs/ci-security.md now records this evidence, and tells a maintainer who approves a fork's runs by hand to look for jobs named after required checks first.

actionlint, shellcheck, approve-fork-runs.test.sh (29/29), gitleaks, the boundary check and the forbidden-terms check are all clean.

@Kilerd
Kilerd merged commit 4758c43 into main Oct 23, 2026
14 of 15 checks passed
@Kilerd
Kilerd deleted the ci/llm-review-gates-fork-runs branch October 23, 2026 04:16
Kilerd added a commit that referenced this pull request Oct 26, 2026
…time contributor's (#163)

* ci: let the LLM review check out a fork's commit

actions/checkout v4.4.0 (the pinned sha) refuses a fork pull request's
commit on pull_request_target unless the step sets
allow-unsafe-pr-checkout, so since #61 every fork's review failed at the
checkout and its runs were never approved (#150). The review job only
reads the commit, so it opts in; docs/ci-security.md says what that
leaves as the guard.

When the checkout (or any step before the review) fails, the retry no
longer runs in an empty directory, and the verdict says the review did
not run instead of blaming the session's tools.

Signed-off-by: Kilerd Chan <blove694@gmail.com>

* ci: a first-time contributor's LLM review waits for a maintainer

Anyone can open a pull request from a fork and push to it again and
again, and each push started a review on the gateway's key. A fork's pull
request is now reviewed at once only when its author has had a commit
merged here (author_association CONTRIBUTOR or above). Any other's review
fails at its first step, before the checkout and the gateway proxy, and
the comment says it waits for a maintainer, who starts it with Re-run all
jobs: only someone with write access can re-run a run, so a second
attempt is the go-ahead.

Not a skip, because a skipped job passes a required check; and not a
label, because every labeled event (Dependabot adds its own) would start
a run that cancels the review in progress and passes the check skipped.

Signed-off-by: Kilerd Chan <blove694@gmail.com>

---------

Signed-off-by: Kilerd Chan <blove694@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants