Repository navigation
ci: the LLM review gates a fork's workflow runs, on pull_request_target - #61
Conversation
Automated security review: PASSCommit 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):
|
…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>
d72d7b0 to
0d407eb
Compare
| - 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>
7061c41 to
4932620
Compare
|
Follow-ups from the review are in 1. Do Grep and Glob honour the
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 I tested the comment job's script with a fake
3. Docs. actionlint, shellcheck, |
…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>
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.ymlnow runs onpull_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 grantspermissions: {}, 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 writesgit diff base...shaand the list of changed files next to the checkout. Every symbolic link becomes a text file. Every.claude,.mcp.json,CLAUDE.md,AGENTS.mdand similar, at any depth, is renamed. Nothing of the pull request is installed, built or run.--tools "Read,Grep,Glob", the same--allowedTools, and--disallowedToolsfor Bash, Edit, Write, WebFetch, WebSearch, Task, Agent, Skill and reads under/proc,/sys,/devand the runner's temp directory. It also gets--strict-mcp-configand--setting-sources user.env -i, which adds it to the agent's API calls.LLM_REVIEW_ENABLEDswitches 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: itshead_shais the reviewed sha, itshead_repository.idis the pull request's, and it isaction_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.mdhas the threat analysis:workflow_runwas not used.It also has the owner's end-to-end steps.
CONTRIBUTING.mdgets one bullet.Two findings that change the brief
statuses: write. Apull_request_targetrun'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 headf50656a4carries the check suites of itspull_request_targetworkflows, withhead_shaequal to the PR's head. So the job's check run satisfies the ruleset as it is. Aworkflow_runrun'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..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_requesttrigger, so this branch's copy ran on this PR. Runs 39896306594 and 39896821110:["Glob","Grep","Read","StructuredOutput"],mcp_servers: []. The SDK options logged by the action list the same allowed and disallowed tools./proc/self/environwas denied (it is inpermission_denials). A Glob of the runner's temp directory was denied in the second run.changes_github=truewas detected, soapprove-fork-runswas skipped.How the flags reach the CLI. I read the action's source at
58985842.parseAllowedToolsadds MCP servers only for tools listed in--allowedTools.parseSdkOptionsturns--allowedToolsand--disallowedToolsinto the SDK's options and passes--tools,--strict-mcp-config,--setting-sourcesand--add-dirthrough. In@anthropic-ai/claude-agent-sdk@0.3.292, these become--allowedTools a,band--disallowedTools …, and nocanUseToolis set.Whether the job's
GITHUB_TOKENmay approve runs. A probe job sentPOST …/actions/runs/39892256972/approve(a finished run):actions: writeit got403 This workflow run is not waiting for approval, which is also what an admin's token gets;actions: readit got403 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 byGITHUB_TOKEN, the optional secretFORK_RUN_APPROVER_TOKENtakes over: a fine-grained PAT, repositorygetdroppen/droppenonly, Actions read and write.approve-fork-runs.test.sh, against a mockedgh. 29 passed. The cases:Mutations confirm that removing each filter fails the test.
The input-preparation step, run on a crafted repository: links replaced; nested and doubly nested
.clauderenamed; renames out of.github/, quoted names andsub/.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
environorcmdline, 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.
actionlint1.7.12 andshellcheckare 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_targetruns main's copy, which has no such trigger, and the new copy has nopull_requesttrigger. 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":action_requiredwhile the review runs.approved run …and the runs start..github/is not approved automatically.FORK_RUN_APPROVER_TOKEN.Settings
llm-security-review(GitHub Actions) stays as it is.FORK_RUN_APPROVER_TOKENsecret, only if the end-to-end test shows that approvals byGITHUB_TOKENdo not start runs.pr-guard(edited), and on a fork that run waits for a maintainer. Movingpr-guardtopull_request_target(it reads only the title and commit messages, through the API) would fix that, but it would run before the review.