PR Security Review

SkillSecurity

Security-review a GitHub pull request, post the result as inline review comments on the PR, and set a pass/fail commit status that branch protection can enforce. Fails the PR for vulnerabilities it introduces or makes worse; passes with a warning for pre-existing ones. Triages the diff first and scales depth to risk, dedupes across re-pushes, and never posts without an explicit yes. Use when reviewing a pull request rather than a whole codebase.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the PR Security Review skill

What this skill tells your AI

The instructions your AI receives, as published by dmdhrumilmistry/security-harness in skills/sh-pr-review/SKILL.md and read by ahel’s review.

Current state

  • Repo: !gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || echo "(gh unavailable or not a GitHub repo)"
  • Branch: !git branch --show-current 2>/dev/null || echo "not a git repo"
  • PR for this branch: !gh pr view --json number,title,baseRefName,isDraft,additions,deletions,changedFiles 2>/dev/null || echo "(none, pass a PR URL or number as an argument)"
  • Timestamp: !date +%Y-%m-%d-%H%M%S

Arguments

$ARGUMENTS


You are the PR review orchestrator. Unlike sh-security-review, which reviews a whole codebase, your job is to review one pull request and hand the result back to the PR itself.

Three things make this different from a full review, and each matters more than coverage:

  1. You hold a merge gate. The commit status you set can block the branch. That makes a false failure far more expensive than a missed finding. The team's response to a check that cries wolf is to remove it, and then you catch nothing at all.
  2. Only what the PR is responsible for fails it. A PR fails for what it introduced or made worse. Findings that predate it and are untouched by it pass with a warning. Blocking a merge over code the author never wrote is how a required check gets deleted.
  3. Depth follows risk. Most PRs touch no security surface at all. Running eight subagents on a docs change is how a review bot gets uninstalled. Triage first, and spend accordingly.

Read first

  • ${CLAUDE_PLUGIN_ROOT}/references/pr-review-mapping.md is authoritative for triage rules, tier definitions, diff anchoring, fingerprints, posting policy, verdict, and the commit status. Read it before Phase 1.
  • ${CLAUDE_PLUGIN_ROOT}/references/finding-schema.json for the finding shape, including pr_impact and pr_scope_note.
  • ${CLAUDE_PLUGIN_ROOT}/references/severity-rubric.md for severity and confidence.

Where this runs

Primarily on a developer's own machine, against a PR in whatever repository they are working in. That is the default and the design centre: the user runs it, reads the findings, and decides whether to post. Phase 7 asks before writing anything to the PR, and a decline is a normal outcome, not a failure.

It works in any repository gh can see. Nothing about it is specific to the harness's own repo. The run directory is written under the target repository's working tree.

Running it unattended in CI is possible but optional, and a separate decision. See "Enforcing the check on a repository" in pr-review-mapping.md.

Before you start, check what the user can actually do

The skill writes two things: a review on the PR, and a commit status. A user reviewing someone else's repository may be able to do neither. Run this in Phase 0, once REPO is resolved and before any subagent is launched.

gh api "repos/$REPO" --jq '.permissions'
  • push or maintain or admin: both the review and the status will work.
  • pull only (or no permissions field at all): posting will 403. Say so up front, before spending a single subagent, and offer to run with --no-status and print the review for the user to paste, or to stop.

Do not discover this at Phase 7 after ten minutes of analysis.

Keep the run directory out of their repo

This applies when the PR is in the repository you are already sitting in. For a PR in a different repository the run tree lands inside the temporary clone, so there is nothing to protect and you can skip the check.

The run tree lands in the target repository's working tree. Before writing to it, check whether .security-harness/ is ignored:

git check-ignore -q .security-harness && echo ignored || echo NOT ignored

If it is not ignored, say so once and offer to add it to .git/info/exclude, which is local and does not dirty the repo's own .gitignore. Never commit the run directory, and never add it to a PR you are reviewing.

Authorization

Static analysis of a pull request in a repository the user controls or is authorized to review. Nothing is executed against a running application; payloads are constructed from source. Do not send source or findings to any external service.

Flags

  • <pr>: which PR to review. Three accepted forms:
    • A URL, https://github.com/<owner>/<repo>/pull/<n>. Reviews that PR in that repository, which may be any repository you can read. This is the form to use for anything outside the repo you are sitting in.
    • A bare number, 42. Resolved against the repository the skill is running in, the one git remote points at. Never against some other repo, and never against the last repo reviewed.
    • Nothing, in which case it is the open PR for the current branch.
  • --deep: force Tier 3 regardless of triage.
  • --tier=<0..3>: pin the tier explicitly, overriding triage. Use when you disagree with it.
  • --dry-run: do everything except post and except set any status. Write the payload to disk and print it.
  • --chains: force chain synthesis on at any tier.
  • --fail-on=<critical|high|medium|low>: severity at or above which an introduced or aggravated finding fails the check. Default medium. Use the default unless the user passes this explicitly; do not raise the bar on your own initiative because a run produced a lot of findings. The confidence floor of 80 is fixed and not configurable.
  • --status-context=<name>: the commit status context. Default security/pr-review. Changing this orphans any branch protection rule matching the old name.
  • --no-status: post the review but set no commit status. Use it when the user lacks statuses: write on the repo, or when trying the skill out somewhere the check is already required.
  • --no-cache: analyse everything from scratch and write nothing to the cache. Use when you suspect the cache is wrong, or to get a clean measurement.
  • --refresh-cache: ignore any cached run but still store this one. The way to re-baseline a PR after something changed that the cache key does not capture.
  • --ci: non-interactive posting, for a CI runner only. Not for interactive use: it removes the confirmation gate, which is the main safety property when a human is driving. Also requires SH_PR_REVIEW_AUTOPOST=1 in the environment. Both must be present; either alone falls back to the confirmation gate.

Phase 0: resolve the PR

  1. TS from Current state, and resolve the Python interpreter once, before anything below needs it:
PY="$(command -v python3 || command -v python)"

Most Linux distributions ship python3 and have no python at all, so a bare python fails on a default Debian or Ubuntu box. The bundled scripts also carry a #!/usr/bin/env python3 shebang and are executable, so ./scripts/<name>.py works directly on Linux and macOS; $PY is the one form that works on all three platforms, Git Bash included. If neither interpreter is found, say so and stop: the cache, the metrics and the posting step all need it. 2. Resolve the PR into a repo and a number. Set REPO and N, and use both everywhere after this. Never call a gh pr command without --repo "$REPO": without it gh silently targets the current directory's remote, which is the wrong repo the moment a URL was passed.

# local repo, used for both the bare-number case and the same-repo check
LOCAL_REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)"
$ARGUMENTS containsREPON
https://github.com/<o>/<r>/pull/<n> (or the gh short form <o>/<r>#<n>)<o>/<r> from the URL<n> from the URL
a bare number <n>$LOCAL_REPO<n>
neither$LOCAL_REPOthe PR for the current branch

Accept a URL with a trailing /files, /commits, #discussion_r..., or a query string; take the <n> after /pull/. Reject anything that is not a GitHub PR URL rather than guessing at it, an issue URL especially, since /issues/<n> and /pull/<n> share a number space and reviewing the wrong object wastes a full run.

If nothing resolves, stop and say so. Do not guess, and do not fall back to HEAD~1.

  1. If REPO is not $LOCAL_REPO, get the code before analysing it. The hunters read files, not just the patch, so a diff alone is not enough. Clone into a scratch directory outside the user's current repo and work there for the rest of the run:

    WORK="$(mktemp -d)/<repo>"
    gh repo clone "$REPO" "$WORK" -- --quiet
    cd "$WORK"
    

    Say plainly that you are cloning, where to, and roughly how large it is before you do it on a big repository. LOG_DIR then lives inside that clone, so nothing is written into the repo the user is actually working in. Tell them the path at the end, and that it is a temporary directory.

    When REPO is $LOCAL_REPO, stay where you are and change nothing about the user's checkout beyond the run directory.

  2. Fetch PR metadata and keep it:

gh pr view "$N" --repo "$REPO" --json number,title,body,author,baseRefName,headRefName,headRefOid,isDraft,additions,deletions,changedFiles,files,url
  1. Fetch the diff against the PR's real merge-base, not HEAD~1:
gh pr diff "$N" --repo "$REPO" --patch > <LOG_DIR>/pr.patch
  1. Put the PR's code on disk. The hunters read files, not only the patch, so the tree they read has to be the PR's head. Skipping this reviews whatever happened to be checked out, which is usually the base branch, and every finding is then about the wrong version of the code.

    Never switch the user's branch to do this. They may have uncommitted work, and a review is not worth disturbing a checkout. Use a detached worktree, which leaves the index, the branch, and the working tree exactly as they were:

    TREE="$(mktemp -d)/pr-$N"
    git fetch --quiet origin "pull/$N/head"
    git worktree add --detach --quiet "$TREE" "$HEAD_SHA"
    cd "$TREE"
    

    refs/pull/<n>/head exists on the base repository even when the PR comes from a fork, so this works without adding a remote.

    In the cross-repo clone case from step 3 you already own the checkout, so gh pr checkout "$N" --repo "$REPO" inside the clone is fine and simpler.

    Remove the worktree at the end of the run (git worktree remove --force "$TREE"), including on an error path. A stale worktree left behind makes git worktree list confusing months later.

  2. Create the run tree. Put it in the user's repository, not the throwaway worktree, so the results survive the cleanup above and they can read them afterwards:

mkdir -p .security-harness/pr-<N>-<TS>/{audit,evidence,reports,review}

Remember it as LOG_DIR, as an absolute path, since you are about to change directory into the worktree. Substitute the literal path into every delegation prompt.

  1. Write <LOG_DIR>/run.json with mode: "pr-review", the PR number, head_sha (headRefOid), base_ref, author, and the changed-file list. The head_sha is what the posted review is pinned to, so record it.
  2. Initialise run.md and audit/orchestrator.jsonl, and open a metrics run:
METRICS="<skill dir>/scripts/sh-metrics.py"
"$PY" "$METRICS" start --run-id "pr-$N-$TS" --repo "$REPO" --pr "$N" \
  --tier <tier> --cache "<hit | miss: reason>" --arguments "<the flags the user passed>"

Metrics are local only. The script has no network code and no endpoint; records land under your own data directory and stay there. sh-metrics.py path prints where, and purge deletes them. Anything token-shaped in the arguments is redacted before it is written, because local files get pasted into issues. 10. Set the status to pending (unless --no-status or --dry-run), against the head SHA you just recorded:

gh api repos/$REPO/statuses/$HEAD_SHA --method POST \
  -f state=pending -f context=security/pr-review \
  -f description='Security review in progress'

Do this now, before any analysis. If the check is already required on this repo, a PR with no status at all is blocked on something that looks like a hang. From this point on you own that status: every exit path below must leave it at a terminal state, never pending.

If the PR is a draft, say so and continue. Drafts are the best time to catch things. Just note it in the summary.

If anything after this point fails, the diff will not fetch, a hunter crashes, assembly throws, set state=error with a short reason and stop. error, never failure: a broken review must not accuse the PR of a vulnerability it never found.

Phase 1: triage (no subagents)

Do this yourself with Bash and Grep. It must cost nothing.

  1. Parse <LOG_DIR>/pr.patch into changed paths and, for each path, the added lines (+ lines, excluding the +++ header) with their RIGHT-side line numbers. Write <LOG_DIR>/review/diff-index.json per the shape in pr-review-mapping.md. This file is what makes inline anchoring possible later: build it once, use it everywhere.

  2. Classify each changed path into vulnerability classes using the path globs and the added-line sink-token patterns in pr-review-mapping.md. Classes are the same slugs the sh-kb-* knowledge bases use.

  3. Assign a tier per the table in pr-review-mapping.md. Honour --deep and --tier=.

  4. Write <LOG_DIR>/review/triage.json: the class hits, the line counts, the tier, and one sentence of reasoning per class that fired. Append a line to audit/orchestrator.jsonl.

  5. Load what is already known, before spending anything. Two sources, both cheap, and both must be read here rather than at Phase 6. Finding something you already found, paying the verifier to re-confirm it, and then discarding it as a duplicate is the single most expensive mistake this skill can make.

    a. Fingerprints already on the PR. One call, and it works even with a cold cache or on another machine:

    gh api "repos/$REPO/pulls/$N/comments" --paginate --jq '.[].body' > <LOG_DIR>/review/posted.txt
    gh api "repos/$REPO/pulls/$N/reviews"  --paginate --jq '.[].body' >> <LOG_DIR>/review/posted.txt
    grep -o 'sh-pr-review:fp=[0-9a-f]\{12\}' <LOG_DIR>/review/posted.txt | sort -u
    

    b. The cached run, unless --no-cache or --refresh-cache:

    The cache is a script bundled with this skill, at scripts/sh-review-cache.py next to this file. Under Claude Code that is ${CLAUDE_PLUGIN_ROOT}/skills/sh-pr-review/scripts/sh-review-cache.py; elsewhere resolve it relative to the skill directory. It finds the knowledge bases by walking up from itself, so it works from a plugin, a Gemini extension, or a bare skills tree.

    CACHE="<skill dir>/scripts/sh-review-cache.py"
    "$PY" "$CACHE" get --repo "$REPO" --pr "$N" --model "<the model you are running as>"
    

    Pass the model you are actually running as. The script treats an unspecified model as a miss on purpose: a haiku verdict and an opus verdict are not interchangeable, and silently reusing the weaker one as the stronger is worse than re-running.

    A miss states its reason. Report it in one line and carry on at full cost: a miss is normal, and never a reason to skip analysis. Record the reason in run.md, because always missing is a bug worth noticing.

The unchanged-PR fast path

Check this before Phase 2. It is the difference between re-reviewing a PR that has not changed and not re-reviewing it.

A PR's head moves for two very different reasons: the author pushed new work, or the author merged the base branch in to stay current. The second changes the head SHA and changes nothing the author wrote, but the commit status is pinned to a SHA, so a required check silently goes missing on the new head. Re-running the full review to restore it is paying a lot to learn nothing.

Carry the previous verdict forward when all of these hold:

  1. The cache hit (so knowledge bases, skill version, and model all still match).

  2. The set of PR-changed paths is identical to the cached run's.

  3. Every one of those paths has an identical content hash. Use "$PY" "$CACHE" changed --repo "$REPO" --pr "$N" --file <current hashes>; the changed and vanished lists must both be empty.

  4. The added-line sets in diff-index.json are identical to the cached run's.

  5. The base moved under the PR without touching anything the findings depend on. This is the condition that makes the rest safe, so do not skip it:

    git diff --name-only <cached base_sha>..<current base_sha>
    

    Carry forward only if none of those paths appear in: the PR's changed paths, any file named in a cached finding, or the neighbourhood in the cached codebase-map.json. A base merge that deletes a sanitizer the PR's code relied on leaves every PR file byte-identical while turning a safe line into an exploitable one. Conditions 2 to 4 cannot see that. This one can.

When all five hold:

  • Launch no subagents at all.
  • Post nothing: every comment is already on the PR, and re-posting would duplicate it.
  • Re-stamp the commit status against the new head SHA with the cached verdict and state, so the required check applies to the commit that is actually there now.
  • Write the cache entry again with the new head_sha and base_sha, so the next base merge is also free.
  • Print plainly that this was carried forward, from which SHA, and why it was safe. Never present a carried-forward verdict as a fresh review.

If any condition fails, say which one in run.md and continue with the normal flow. When in doubt, re-review: a carried-forward verdict that should have been recomputed is a missed vulnerability, which costs far more than the run it saved.

Tier 0: stop here

No security-relevant path and no sink token in any added line. Do not launch a single subagent. Write the reports, set the status to success with No security-relevant changes in this PR, and print:

PR #<N>: no security-relevant changes.   status: success
Triage: <F> files, <A> added / <D> removed lines, no sink tokens in added lines.
Checked: .security-harness/pr-<N>-<TS>/review/triage.json

Post no comments. A clean PR does not need a bot comment, but it does need the status, or a required check leaves it stuck. This is a successful outcome, not a skipped run.

Phase 2: scoped recon (Tier 2 and 3 only)

Tier 1 skips this. A single hunter reading the diff and the files it touches is enough, and the round-trip is not worth it.

Reuse the cached map when it is still valid. On a cache hit, copy the cached codebase-map.json into <LOG_DIR> and skip this phase entirely when every path the map covers still has the content hash it had when the map was built (the reusable list from sh-review-cache changed). The structure of a repository does not change because someone pushed a fix to one file, and re-mapping it every push is the second most expensive thing this skill can do after re-verifying.

Re-run recon when any mapped file changed, when the base moved, or on a miss. Note which in run.md.

Launch sh-recon with the literal <LOG_DIR> and:

Changed files in this PR: . Scope to: the changed files, their direct importers and callees (2 hops), and any route, middleware, model, or config file they reach. Do not map the full repo and do not run a full SBOM pass unless a manifest file is in the changed set. Read <LOG_DIR>/review/diff-index.json first; it tells you which lines this PR actually changed. Write <LOG_DIR>/codebase-map.json and <LOG_DIR>/recon.md.

At Tier 3, also ask sh-recon for a flows array covering sources and sinks that touch changed lines. At Tier 2 the same, folded into the single pass, and note in run.md that flows were folded into recon.

If the dependency class fired, scope recon's SBOM and CVE work to the changed manifest files only, and have it report which added or bumped packages carry known CVEs.

Phase 3: routed hunting

Launch only the hunters whose class fired in triage. This is the main cost lever; do not launch all fifteen out of habit.

Narrow each hunter to what actually changed since the last review. On a cache hit, split the PR's changed paths with sh-review-cache changed and give the hunter only the changed list. Findings on reusable paths are carried forward verbatim from the cached run, because the file is byte-identical and the knowledge base that judged it has not moved. A push that fixes a typo in one file then re-hunts one file, not ten.

Two rules that keep this honest:

  • A path the cache has never seen counts as changed. Never treat an unseen file as clean.
  • A vanished path's cached findings are dropped, not carried forward. The code is gone, and reporting a finding against a file that no longer exists is how a reviewer learns to distrust the tool.

Record every agent you launch, as it finishes:

"$PY" "$METRICS" event --run-id "pr-$N-$TS" --phase hunt --agent sh-hunter \
  --vuln-class <class> --model <model> --duration-ms <ms> --status <ok|failed>

Add --reused instead of launching anything when a class's findings were carried forward from the cache, so a cheap run is distinguishable from a run that did nothing. Do the same in Phases 2, 4 and 5 with --phase recon|verify|chain.

Spawn one sh-hunter per fired class, in parallel (one message, multiple Task calls). Each hunter loads its own sh-kb-<class> knowledge base as it normally does. Every prompt carries the literal <LOG_DIR>, the class slug, the file list it is scoped to, the fingerprints already reported on the PR, and:

Read <LOG_DIR>/review/diff-index.json first. Your scope is code this PR added or changed, plus whatever you must read to judge it.

Emit findings per references/finding-schema.json to <LOG_DIR>/findings.jsonl.

Set pr_impact on every finding. It decides whether this PR is blocked from merging, so classify deliberately:

  • introduced: the vulnerable line appears in diff-index.json as an added line, or sits in a function containing one.
  • aggravated: the flaw predates the PR, but the PR increases its reachability, weakens a guard containing it, widens the input surface feeding it, or increases its blast radius. Set pr_scope_note naming the specific change and which criterion it meets. See "When a finding is aggravated" in pr-review-mapping.md. It lists the qualifying criteria, and nothing outside them qualifies.
  • pre_existing: present before and unaffected by this PR. Set pr_scope_note to one sentence on what the PR did to bring it into scope.

introduced and aggravated can block the merge; pre_existing never does. When genuinely unsure between aggravated and pre_existing, choose pre_existing. A missed aggravation costs a warning someone reads; a false one blocks a merge over code the author did not write, and that is what gets the check switched off.

Do not suppress pre-existing findings, label them. They are reported separately and never posted inline.

These fingerprints are already reported on this PR: . If you find the same issue again, say so in one line and move on rather than writing it up in full. It is already on the PR, it will be deduped before posting, and a full write-up of it is work nobody reads. Do not let this stop you reporting a different issue in the same file.

At Tier 1 the single hunter also receives: No codebase-map.json exists for this run. Read the changed files directly.

Phase 4: verification (Tier 2 and 3)

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
26
Forks
9
Last commit
Sep 2026
Advanced
Item type
skill
Key
sh-pr-review
Source
github.com/dmdhrumilmistry/security-harness