cora is a tool-using, comment-posting (and optionally patch-proposing) LLM agent that you point at pull requests. PRs are attacker-controlled input. This document is the threat model, the controls cora ships, and the wiring you must (not) use. Read it before running cora anywhere a stranger can open a PR.
Three threats dominate for an LLM PR reviewer:
- Cost / denial-of-wallet. Every PR (and every push to one) fires an LLM call. On a public repo, spam PRs fan out into unbounded GPU/API spend.
- Prompt injection. The PR title, body, diff, commit messages, file paths, and comments are untrusted input into a tool-using agent. An injected instruction can try to steer tool calls (exfil via a fetch tool), launder false verdicts, or — where write capability exists — weaponise the patch-proposal path.
- Pwn-request. The classic GitHub Actions foot-gun: a
pull_request_target-triggered workflow that checks out fork code with secrets in the environment. This is a workflow wiring mistake, not an engine behaviour — and the most important thing this document tells you is don't do it.
cora never checks out and executes PR code. It reads the diff and
repo content as data — grep_repo and git_show are read-only
lookups, and the review runs against the checkout your workflow already
made. There is no npm install, no test execution, no build of the
PR's code inside the reviewer. That single property is what makes
reviewing fork PRs tractable at all.
The packaged system prompts carry an explicit untrusted-input section: PR content is data to review, never instructions to follow; injection attempts are to be flagged as Blockers, not obeyed. Treat the prompt framing as harm reduction — the trigger policy below is the actual gate.
ReviewerConfig.trigger carries a TriggerPolicy
(cora.trigger.TriggerPolicy). Enforced, it is default-deny with a
capability ceiling:
- An author is trusted when ANY of: their
author_associationis inallowed_associations(defaultOWNER/MEMBER/COLLABORATOR); their login is inallowed_authors; or a maintainer applied the opt-in label (approve_label, defaultcora:approved— label application is permission-gated by GitHub, so it's a human vouch). - Everyone else gets
untrusted_action:skip(default — the run posts a skip comment, the verdict check-run concludescancelled, and a verdict-gating merge aggregator stays red) orcomment-only— a degraded run: quick mode (no MCP tools, no local repo tools),propose_patchdispatch suppressed. The verdict comment and check-run are the only writes a degraded run makes. - Fork PRs get a ceiling on top (
fork_action, defaultcomment-only): even a trusted author's fork PR runs without tools or writes.skiprefuses fork PRs outright;fulldefers to the trust rules. - Rate caps (
max_runs_per_hour,max_runs_per_author_per_hour) bound the spend a burst can cause, counted against recent runs of the same workflow. The probe is best-effort (it soft-fails open on API errors) — treat the caps as a cost guard, not a security boundary. The deny gate above is the security boundary, and it fails closed (an author whose association can't be fetched is untrusted).
Environment wiring (all optional; ReviewerConfig.from_env()):
| env var | field |
|---|---|
REVIEW_TRIGGER_ENFORCE |
enforce (false to opt out) |
REVIEW_TRIGGER_ALLOWED_ASSOCIATIONS |
csv → allowed_associations |
REVIEW_TRIGGER_ALLOWED_AUTHORS |
csv → allowed_authors |
REVIEW_TRIGGER_APPROVE_LABEL |
approve_label |
REVIEW_TRIGGER_UNTRUSTED_ACTION |
untrusted_action (skip / comment-only) |
REVIEW_TRIGGER_FORK_ACTION |
fork_action (full / comment-only / skip) |
REVIEW_TRIGGER_MAX_RUNS_PER_HOUR |
max_runs_per_hour |
REVIEW_TRIGGER_MAX_RUNS_PER_AUTHOR_PER_HOUR |
max_runs_per_author_per_hour |
REVIEW_PROPOSE_PATCH_DISPATCH |
patch writes (true to enable) |
enforce defaults to true. Private deployments that need the legacy
pre-gate behaviour during migration can set REVIEW_TRIGGER_ENFORCE: "false", but public repositories should leave it enabled.
Safe shape — pull_request trigger, no fork secrets exposure, policy
enforced:
name: cora
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
permissions:
contents: read
issues: write # sticky review comment
pull-requests: write # PR metadata and optional Review API path
checks: write # the verdict check-run
jobs:
review:
# With `pull_request`, fork PRs run with a read-only token and NO
# secrets — the job below would skip at the missing-key preflight.
# This gate just avoids burning a runner to find that out.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # YOUR trusted ref, not fork code
- run: pip install cora && python -m cora
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LLM_GATEWAY_KEY: ${{ secrets.LLM_GATEWAY_KEY }}
REVIEW_TRIGGER_ENFORCE: "true"
REVIEW_TRIGGER_MAX_RUNS_PER_HOUR: "30"
REVIEW_TRIGGER_MAX_RUNS_PER_AUTHOR_PER_HOUR: "6"To review fork PRs from untrusted authors, keep the pull_request
trigger and accept the skip, or route through the approval label: a
maintainer reads the PR, applies cora:approved, and the labeled
event re-fires the workflow — now passing the gate (still under the
fork comment-only ceiling).
Never wire cora (or anything with secrets) like this:
on: pull_request_target # ⚠️ secrets + write token, attacker event
# ...
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ⚠️ fork codepull_request_target runs with your secrets and a write token on an
event the attacker controls; checking out the fork head hands your
credentials to any PR that edits a build script. cora never needs fork
code checked out — it reads the diff over the API. If you believe you
need pull_request_target, you are almost certainly reaching for the
approval-label flow above instead.
-
Write ceiling.
propose_patch(inline suggestions / draft fix PRs) dispatch is opt-in viaREVIEW_PROPOSE_PATCH_DISPATCH=true. Even then it only dispatches on non-degraded runs, and its own validator denies.github/paths so a proposed patch can't edit workflows. Degraded runs post the verdict comment + check-run, nothing else. -
Token scope. The engine works with the workflow's
GITHUB_TOKENusing the least privilege in the canonical workflow:contents: read,pull-requests: write, andchecks: write. In GitHub Actions,pull-requests: writeis enough for PR metadata/diff reads and PR review comments, whileissues: writeis usually covered by the repository-scopedGITHUB_TOKENfor issue comments. For a fine-grained PAT or GitHub App token used via localGH_TOKEN, grant:capability fine-grained permission read PR metadata, labels, author association, head/base refs Pull requests: read fetch PR diffs Pull requests: read read repository file contents for patch validation Contents: read read check-runs / CI context Checks: read create/update the verdict check-run Checks: read/write post/edit the sticky review comment Issues: read/write remove labels such as automergeor add escalation labelsIssues: read/write post inline PR review comments / suggestions Pull requests: read/write The optional GitHub App token (
CORA_GH_TOKEN) is needed when you want comments and check-runs attributed to the App instead ofgithub-actions[bot], and for draft-fix PR dispatch. In GitHub Actions, store the App credentials asCORA_APP_IDandCORA_APP_PRIVATE_KEY; the workflow mintsCORA_GH_TOKENfrom those secrets and falls back toGITHUB_TOKENwhen they are absent. If you enable draft-fix PRs, that token also needscontents: read/writeto create/update branches andpull-requests: read/writeto open the draft PR. -
Merge gating. The verdict check-run concludes
cancelledon a policy-denied run, so a required-check aggregator that gates on it (a required-check aggregator workflow) keeps blocking; a human override label is the documented escape hatch.
Open a GitHub security advisory on this repository (Security → "Report a vulnerability"), or email the maintainer privately. Please don't open a public issue for an exploitable report.