Skip to content
Back to skills

External Pr Trust

ASecurity

Authorship as a precondition of merging — bot vs stranger classification, what a merge executes, the merge-guard hook. Use when bulk-merging PRs, or when a merge is denied for a non-self author.

  • 58 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 25, 2026
toolsrustgoshellbashnodegitapidocumentation

Works with

  • claude code
  • cli
  • api
  • mcp

Security analysis

A100/100

Scanned September 25, 2026

npx -y skills add laurigates/claude-plugins --skill external-pr-trust --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of External Pr Trust?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for External Pr Trust
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/laurigates-external-pr-trust/badge)](https://www.skillsdirectory.com/skills/laurigates-external-pr-trust)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: external-pr-trust
description: Authorship as a precondition of merging — bot vs stranger classification, what a merge executes, the merge-guard hook. Use when bulk-merging PRs, or when a merge is denied for a non-self author.
allowed-tools: Read, Grep, Glob, Bash(gh pr view *), Bash(gh pr list *), Bash(gh pr diff *), Bash(gh api *), Bash(gh search *), TodoWrite
created: 2026-09-24
modified: 2026-09-24
reviewed: 2026-09-24
---

# External-Contributor PRs — Authorship Is a Precondition of Merging

Promoted from the always-loaded `external-contributor-prs.md` portfolio rule,
whose stub keeps the gate lines.

Public repos accept PRs from anyone. Merge tooling, though, is usually built
for bulk-merging **your own and bots'** PRs: a picker such as `ghsq` → `^a`
(select all) → `a` (all remaining) lands a queue of release-please and feature
PRs in one pass. A stranger's PR riding that reflex is merged code nobody read.

> **The law:** before merging, know **who wrote it**. `author.login == you` or a
> bot is the fast path. Anything else is a review, not a merge.

## When to Use This Skill

| Use this skill when... | Use something else when... |
|---|---|
| Clearing a queue of PRs in one pass | Checking whether a merge did what you think → `git-merge-hazards` |
| The external-PR merge guard denied a `gh pr merge` | Opening a PR against someone else's repo → `upstream-pr-contribution` |
| A PR adds a GitHub Action, hook, or dependency | Writing CI that runs on fork PRs → `github-actions-plugin:claude-code-github-workflows` |

## Why it is not merely "review your PRs"

A PR is not inert text — merging can *execute* code:

| Path touched | What merging does |
|---|---|
| `.github/workflows/**`, `.github/actions/**` | Runs in CI, with whatever `permissions:` the workflow grants itself |
| `**/hooks/**`, `*.sh` | Runs on **your machine** (Claude Code hooks, git hooks, justfile recipes) |
| `.claude/**` | Changes agent permissions, rules, or settings |
| `package.json`, `pyproject.toml`, `mise.toml`, `.pre-commit-config.yaml` | Changes what gets installed and run |

Two things make a third-party CI addition worse than it looks — check them
explicitly on any PR that adds an action:

- **A floating tag is mutable.** `uses: owner/action@v0` resolves at dispatch
  time, so the publisher (or whoever takes over their account) can retarget the
  tag after you merge and change what runs, with no diff on your side.
- **`runs.using` does not tell you what it does.** A `node20` or `composite`
  action can download and execute a remote binary — and often does: the
  canonical case (claude-plugins#2231) was a `node24` action whose entire job
  was to resolve the `latest` release of a *different* repo at run time, fetch a
  native binary from it, `chmod 0755`, and execute it. Pinning the action to a
  SHA does not pin that second fetch. Read the action's `src`/`action.yml` at
  the pinned SHA rather than trusting its declared type.

## Classify the author correctly — bots render three different ways

Getting this wrong in either direction breaks the guard: flag the bots and it
becomes noise you train yourself to click through; miss a stranger and it does
nothing. The trap is that a bot author looks different depending on which `gh`
subcommand produced it:

| Source | `login` | `is_bot` | `type` |
|---|---|---|---|
| `gh pr list` / `gh pr view` | `app/laurigates-release-please` | `true` | — |
| `gh search prs` | `laurigates-release-please[bot]` | **`false`** | `Bot` |

So `is_bot` **alone silently misclassifies every App bot in search results as a
stranger**. Accept any of: `is_bot == true`, `type == "Bot"`, a `*[bot]` login,
or an `app/*` login. (`authorAssociation` is *not* a field on `gh pr list`/`gh pr
view --json` — read it from `gh api repos/O/R/pulls/N --jq .author_association`.)

## The two guards

| Actor | Mechanism | Behavior |
|---|---|---|
| **The human**, in the shell | `_gh_trust_gate` / `_gh_confirm_external` in the dotfiles `dot_zshrc.tmpl`, used by the `ghsq` / `ghrb` / `ghrp` pickers | External PRs are **hidden from the picker** (a stderr banner counts them; `ghsq -x` includes them, marked `⚠` white-on-red). Merging one prints author + association + which touched paths execute, is **never** swept up by `a`=all, and requires typing `merge` — not a keypress. |
| **Claude**, in any repo | `hooks-plugin/hooks/external-pr-merge-guard.sh` (PreToolUse) | **Denies** `gh pr merge`, `gh api .../pulls/N/merge`, and MCP `merge_pull_request` for a non-self, non-bot author. Also denies when authorship can't be established. |

Both fail **closed**: if the author cannot be read, the merge is refused rather
than allowed. "Cannot verify" is not "safe."

## What Claude does when the guard fires

Do not look for another merge route — the hook covers `gh pr merge` in every
flag order, the `gh api` path, and the MCP tool, and `--admin` does not bypass
it. Instead:

1. **Report what the PR actually changes.** Read the diff. For an added action
   or dependency, say where the code comes from, whether the ref is a **commit
   SHA or a mutable tag**, and what it downloads or transmits at runtime.
2. **Hand the merge back.** `ghsq -x` (which will demand the typed confirmation),
   or `gh pr merge <n> --squash --delete-branch` run by the human.

## A bulk merge must issue one literal PR number per call

The guard resolves the PR selector **structurally**, and an unresolvable
`$var` selector lands in its "not determinable" row — which is denied, because
"cannot verify" is not "safe". So the obvious way to clear a queue of bot PRs
is refused before it starts, even though every PR in it is a bot's:

```
# Denied — the hook cannot tell whose PR $n is
for n in 2133 2134 2135; do gh pr merge $n --squash --delete-branch; done
```

```
# Allowed — each literal number is checked on its own
gh pr merge 2133 --squash --delete-branch
gh pr merge 2134 --squash --delete-branch
```

Wrapping the loop does not help: `xargs`, `find -exec`, `parallel`, `timeout`,
`bash -c`, and command substitution are all resolved through to the `gh pr
merge` inside them.

This bites a **Renovate onboarding sweep** specifically, because the usual
workflow there is bucket-then-bulk-act: categorize the PRs by check state, then
merge the whole `NOCHECKS`/`PASS` bucket. Categorize in a loop all you like;
emit the merges one literal call at a time. Read the buckets out first
(`gh pr list --json number,author`) so the numbers are in hand as literals
before the first merge.

The constraint is a feature, not friction to route around: it is what stops a
stranger's PR from riding the same reflex. Do not build a helper that hides the
number from the hook — that is the bypass the guard exists to prevent.

The denial names the fix — "resolve shell variables to their literal values
first … then merge with literal values one at a time" — so the recovery costs
one re-read of the error, not a diagnosis.

Never prefix `CLAUDE_HOOKS_DISABLE_EXTERNAL_PR_MERGE=1` onto a command — it is
only honored from the operator's own shell environment, precisely so an agent
cannot self-serve the bypass (`handling-blocked-hooks.md`).

## Evidence

`claude-plugins#1222` (2026-05, a `FIRST_TIME_CONTRIBUTOR`) was
merged with **no review and no comments**. It rewrote a Claude Code Stop hook —
code that runs locally — and shipped a broken TODO scan that had to be repaired
three weeks later in #1229. It also flipped the hook's file mode `100755 →
100644`, which is still un-restored. One drive-by merge, two defects, neither
noticed at merge time. That is the failure mode both guards exist to stop.

## Related

- `git-plugin:git-merge-hazards` — the *mechanics* of merging (squash-merge detection, stacked-PR auto-close, `UNSTABLE` vs `BLOCKED`). This skill is the *trust* axis: that one asks "did the merge do what I think?", this one asks "should I be merging this at all?"
- `git-plugin:upstream-pr-contribution` — the mirror image: the gates *others* impose on PRs you send them
- `hooks-plugin/README.md` § external-pr-merge-guard.sh — the hook's own documentation

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…