MergeJudge security model
MergeJudge is a GitHub App + Action that triages pull requests. It needs write access to post reviews and (optionally) store learning data. This document explains what it can write, why, and how to constrain it.
Threat model (short)
| Trust boundary | Rule |
|---|---|
| PR title, body, paths, diffs | Untrusted. Wrapped in <untrusted_pr_data>; instructions inside are ignored. |
.mergejudge.yml, policy files, rubric config | Trusted, read from the base branch tip only (never the PR head). A PR cannot loosen its own rules. |
| LLM output | Validated (known paths, diff-hunk lines, evidence quotes). Can only raise risk, never lower it. |
| Learning feedback | Only users with write access; bot actors ignored. Stored on a dedicated branch. |
What the App can write
With the recommended App permissions (pull_requests: write, contents: write, checks: write, issues: write, metadata: read):
| Write | Where | Why |
|---|---|---|
| Sticky summary + inline review comments | The PR under review | Product output |
Check run mergejudge/lane | The PR head SHA | CI signal |
| APPROVE / dismiss own approval | The PR (when approval config allows) | Optional bot approval |
| Resolve/unresolve own review threads | The PR | Clear stale findings (contents: write required by GitHub) |
| Append lessons JSONL | Only learning.branch (default mergejudge-learning) | Learning loop |
| Proposal file + branch | Only mergejudge/proposal-* | Suggested config edits (PR opened, never merged by MergeJudge) |
MergeJudge never merges PRs itself. It never pushes to main / master / release*, and it refuses a learning.branch that equals the default branch or matches those names.
Code enforcement
All git/contents writes go through a single guard (src/writes.ts → createGuardedWriter). Calls to repos.createOrUpdateFileContents / git.createRef / git.deleteRef outside that wrapper are rejected by test/write-guard.lint.test.ts.
Recommended repository rulesets
Use these where your GitHub account supports repository rules (public repos, and some private ones). Replace APP_ID with your MergeJudge GitHub App id.
Learning branch (allow App writes; block humans / force-push / delete)
{
"name": "MergeJudge learning branch",
"target": "branch",
"enforcement": "active",
"bypass_actors": [
{ "actor_id": APP_ID, "actor_type": "Integration", "bypass_mode": "always" }
],
"conditions": {
"ref_name": { "include": ["refs/heads/mergejudge-learning"], "exclude": [] }
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "update", "parameters": { "update_allows_fetch_and_merge": false } }
]
}Default branch (no MergeJudge bypass)
Keep your normal default-branch ruleset without adding the MergeJudge app as a bypass actor. The Action already refuses writes to the default branch in code.
Free private repositories
GitHub returns HTTP 403 for rulesets/classic branch protection on free private repos (GitHub asks you to change the account type or make the repository public). Options:
- Move to an account type that supports them, or make the repo public, then apply the JSON above; or
- Rely on the in-code write guard (always on) and restrict who has repo write access.
Untrusted content & config
- Diffs/titles/bodies are sanitized and delimiter-spoof-resistant before they reach the LLM.
- Policy files are injected in a separate trusted
<repo_policy>block and cannot make a file safer than the rules. - Config and policy paths are fetched with
ref = base branch name(current tip), falling back to the base SHA only on fetch error — never from the PR head.
Model key policy (hosted Worker)
A repo's .mergejudge.yml chooses provider.type, base_url and api_key_env, so the config is treated as untrusted when picking a key. The Worker (packages/worker/src/keypolicy.ts) applies these rules:
Allowlist. Only the Worker secrets named in
WORKER_MODEL_KEYScan ever be model keys:ANTHROPIC_API_KEY,OPENAI_API_KEY,GLM_API_KEY,ZAI_API_KEY,OPENROUTER_API_KEY. No other env value (SESSION_SECRET,MASTER_KEY,GITHUB_APP_PRIVATE_KEY,GITHUB_WEBHOOK_SECRET,GITHUB_OAUTH_CLIENT_SECRET, ...) is passed to provider code at all.Pinned hosts. Each allowed secret is only sent to its provider's official host, over
httpson the default port, matched on the exact hostname:Worker secret Allowed host ANTHROPIC_API_KEYapi.anthropic.comOPENAI_API_KEYapi.openai.comGLM_API_KEY,ZAI_API_KEYapi.z.ai(paygo/api/paas/v4and coding/api/coding/paas/v4)OPENROUTER_API_KEYopenrouter.aiThe host is taken from the endpoint the provider will actually call (
provider.base_url, else the type's default). If it points anywhere else, Worker secrets are not used.Custom endpoints use BYOK only. For
openai-compatibleor any custombase_url, only the installation's own key stored in D1 (provider_keys, AES-GCM withMASTER_KEY) can be used. A D1 key with the requested name always wins over a Worker secret, and works on any endpoint.Refusal. If no usable key exists, the review runs rules-only (the normal missing-key path). The Worker logs a warning and the PR comment says why. Both name the key, never its value.
workers-aiuses theAIbinding and needs no key, so these rules do not affect it.
The GitHub Action (src/main.ts) is unchanged. It reads keys from process.env in the user's own workflow, where the user decides which secrets are exposed to the step.
Reporting issues
If you find a way for a PR to write outside the learning / proposal branch prefixes, or to lower its own lane via prompt injection, please open a security advisory on the MergeJudge repository.