# Dependabot Audit

> Audit an automated dependency-bump PR and produce an evidence-backed merge recommendation — verify lockfile artifact hashes against the registry, cross-check the true latest version, read changelogs for security and behavior changes, reproduce the repo's own checks in an isolated worktree, and report. Verifies uv.lock, GitHub Actions and pre-commit hooks end to end; any other ecosystem gets the ecosystem-independent phases and a stated boundary rather than an improvised recipe. Use when the user asks to review, audit, check, or decide on a Dependabot or Renovate PR, a dependency bump, a lockfile PR, or asks "is this safe to merge".

- Skill: `machai-kydoimos/dependabot-audit` (Agent Skill, multi-file: 12 files)
- Install (CLI): `npx skillmds@latest add machai-kydoimos/dependabot-audit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/machai-kydoimos/dependabot-audit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: Machai-Kydoimos (https://skillmd.com/u/machai-kydoimos)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/machai-kydoimos/dependabot-audit

---


# Dependabot Audit

**This skill reports; it does not decide.** It ends in a recommendation plus the
evidence behind it, and stops. Do not merge, approve, close, comment on, rebase,
or push to the PR. Print the merge command; do not run it.

`disallowed-tools` removes `Edit`, `Write`, and `NotebookEdit` from the pool while
this skill is active, but `Bash` remains and can reach `gh pr merge`. That
restraint is a **contract, not a sandbox** — honor it. There is no exception:
Phase 8 hands its memory entry back rather than writing it, precisely because
reaching for `Bash` to do what the withheld tools would have done makes the
withholding theatre.

## This audit executes the code it audits

The contract above governs what *this skill* writes. It says nothing about what
the audited code does, and two phases run it:

- **Phase 5** installs frozen and runs the PR's own test suite from the PR's tree.
  `uv sync` builds any sdist in the resolution, which runs `setup.py` or the
  project's build backend.
- **Phase 4** runs the repo's gates at a version taken from the diff under audit.
- **`gate_diff.py`** passes its `--run` commands to a shell, and those commands are
  transcribed from the audited repo's CI config, which an actions bump legitimately
  modifies.

**The worktree isolates the user's working tree from the audit. It does not
isolate the machine from the PR.** Nothing here is a sandbox; if you need one, it
has to come from outside — a container, a throwaway VM, or a Landlock confinement
— and this skill cannot verify that you have one.

The ordering is the mitigation available inside the skill, and it is worth being
exact about what that buys. **Phase 1 is a gate**: if the diff reaches beyond the
manifest and lockfile, or provenance fails, stop there. Do not continue into the
phases that execute. A procedure whose thesis is "verify before you trust" must
not run the artifact before it has finished deciding whether to trust it.

**What the gate catches is a lockfile edited after it was written honestly** — a
hash, size, URL or yank status that disagrees with the registry — and a diff that
reaches into source. **It does not catch a malicious release.** Phase 1 compares
the lockfile against what the registry serves *today*, so when the attacker
published the artifact, the record and the lockfile agree — and agreement is the
entire test. A bump to a version whose maintainer account was compromised passes
Phase 1 clean and arrives at Phase 5's install with the gate's blessing.

The one signal that speaks to it is PEP 740 build provenance: `PUBLISHER CHANGED`
means the release being adopted was built somewhere the previous one was not.
Coverage is partial and version-dependent, so where there is no attestation there
is no signal. Read the ordering as what it is — it removes the cases it can see,
and `--no-execute` is the answer for the rest.

**`--no-execute`** runs Phases 0–3 and 6–7 only. Every one of those is a network
read: provenance, currency, changelogs, OSV, CI state. That is most of this
procedure's value, and it is the right default for a PR you have no reason to
trust yet. Use it when the user asks, and when Phase 0 classifies the PR as one
the bots did not open. Say in the report which phases did not run.

**That claim is a property of each phase's commands, not of its number**, and it
has already been false once: until 0.34.0 the `uv.lock` recipe for Phase 3 was
`uv run --with pip-audit …`, which syncs the project — installing it editable and
building any sdist in the resolution — so the mode that exists for a PR you do not
trust ran that PR's build code. A phase in this set that gains a command has to be
checked against this sentence, which is what `tests/test_skill_prose.py` now does
mechanically.

## Arguments

`/dependabot-audit <PR> [--no-execute] [--comment]`, and the same words said in
prose. The PR number is the only one that is required.

**If no PR number arrived, ask which one before starting.** Do not reach for the
most recent bump, and do not audit whatever branch happens to be checked out.
Both read as helpful, and both audit something the user did not ask about — the
report that comes back is then about the wrong PR while looking exactly like a
report about the right one.

- **`--no-execute`** — as above: Phases 0–3 and 6–7, and name the skipped phases
  in the report.
- **`--comment`** — produce the report and print it, then *offer* to post it.
  Posting is a separate action the user asks for explicitly; the flag requests
  the offer, not the post.
- **Anything else is not a flag this procedure knows.** Say so, rather than
  inferring what it might have meant.

## Why this procedure exists

The failure modes that bite are not "is this package malicious" — they are a
proposal that is already stale, a gap containing a fix no vulnerability database
knows about, and a bump that changes a *default* rather than a behavior. All
three are observed, not hypothetical. Phases 2 and 4 exist for them, and each
carries the measurement it came from.

## Phase 0 — Discover the repo (derive every run; never cache)

Never persist the answers to these. Required checks get added, CI jobs get
renamed, and a cached profile silently audits a repo that no longer exists.
Deriving costs one call each.

**Derive with the script; mutate by hand.** `scripts/discover.py` answers every
derivable question and tags each answer **derived / absent / underivable**. It is
read-only — no fetch, no worktree, no local `git` at all — so the two things Phase
0 changes in the user's repository stay visible in this file, where a plugin whose
contract is "reports, never merges" should keep them.

```bash
D="${CLAUDE_PLUGIN_ROOT}/skills/dependabot-audit/scripts/discover.py"
REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
# OUTSIDE the repo, and the SAME directory on every later call — derived, not remembered
SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}/dbaudit-${REPO/\//-}-<N>}"; mkdir -p "$SCRATCH"

python3 "$D" --repo "$REPO" --number <N>                              # the report
python3 "$D" --repo "$REPO" --number <N> --shell > "$SCRATCH/phase0.env"
. "$SCRATCH/phase0.env"
```

Source the outputs rather than transcribing them. Four of them are
40-character SHAs, and a wrong one is not detectable downstream: a truncated
`$HEAD_SHA` matches no CI run and reads exactly like *CI never ran*, and a wrong
`$BASE_SHA` gives a scope diff that is wrong rather than empty. What the file
defines:

```bash
#   SCRIPTS=<abs path>     this plugin's own scripts/ — the one output not about
#                          the PR. Derived from discover.py's own location, so a
#                          reference can name a script; see below
#   DEFAULT=<branch>       the repo's default branch, derived
#   HEAD_SHA=<40 hex>      the commit under audit
#   BASE_REF=<40 hex>      GitHub's own base for the PR
#   BASE_SHA=<40 hex>      the merge base, from GitHub's compare endpoint
#   OWNER=<owner>          for Phase 6's GraphQL variables
#   NAME=<name>
#   CREATED_AT=<iso8601>   when the PR was opened — Phase 2's cooldown test
#   BRANCH_POINT=<ok|rewritten|suspect|underivable>
#   MAY_EXECUTE=<yes|no>   whether Phases 4 and 5 are authorised
#   HUMAN_COMMITS=<shas>   non-bot commits on the branch — a finding, not a gate
#   ECOSYSTEM=<uv.lock|github-actions|unsupported|unknown|underivable>
#   SCOPE_GATE=<clean|beyond|underivable>   Phase 1's gate, already decided
```

**An underivable output is emitted commented-out, so it stays unset.** That is
deliberate: a later phase then fails loudly on an empty value instead of quietly
on a plausible one, which is the distinction this whole phase exists to preserve.

**Exit 2 means it could not run; exit 1 means it ran and found something.** Never
read one as the other. Exit 1 here does not stop the audit — it means the shape of
the audit changes, and the report has to say how.

Read out of its output: `$DEFAULT`, `$HEAD_SHA`, `$BASE_SHA`, `$PERMS`, whether
the merge base is the **branch point**, and whether Phases 4 and 5 may run at all.
Take `$BASE_SHA` from it rather than from `git merge-base`, and the reason is the
next section.

Then the part that changes state, which is yours:

```bash
# Fresh call: nothing survives one, so re-derive $SCRATCH and re-source Phase 0.
REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner); SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}/dbaudit-${REPO/\//-}-<N>}"
. "$SCRATCH/phase0.env" || { echo "no handoff in $SCRATCH — re-run Phase 0" >&2; exit 2; }

git worktree prune          # a previous run's registrations, if $SCRATCH is gone
git fetch origin "pull/<N>/head:pr-<N>" "$DEFAULT"

# The fetch is the first thing that can disagree with the pin, so assert it here
# rather than trusting it. Exit 1: the block ran and found something, and what it
# found changes the shape of the audit.
FETCHED=$(git rev-parse "pr-<N>") || { echo "pr-<N> did not resolve after the fetch" >&2; exit 2; }
[ "$FETCHED" = "$HEAD_SHA" ] || {
  echo "the head moved: discover.py pinned $HEAD_SHA, the fetch resolved $FETCHED" >&2
  echo "re-run Phase 0 from the start — the pin, the base and the scope move together" >&2
  exit 1; }

git worktree add "$SCRATCH/pr-<N>" "pr-<N>"
git worktree add --detach "$SCRATCH/base-<N>" "$BASE_SHA"   # Phase 4 measures here
```

**That assertion is not defensive clutter, and the interval it guards is seconds.**
A bot can rebase between `discover.py` reading the head and the fetch resolving it,
and then every row of the report is about a commit that is no longer the PR.
Measured on this plugin's own #99: `discover.py` ran at `23:08:39Z`, Dependabot
rebased at `23:08:57Z`, and the run's next read returned a different head **and a
different base** — `$BASE_SHA` moved with it, because the rebase moved the branch
onto current `main`.

**It was caught that time by accident**, which is the argument for the assertion.
`git worktree add` prints an abbreviated SHA, a reader noticed it disagreed with
`$HEAD_SHA`, and the audit recovered. Nothing in the procedure compared the two;
the guard below fires only when `worktree add` *refuses*, which it does not do
here — the path was free and the add succeeded, at the wrong commit.

**Re-run Phase 0 whole, never just the pin.** The head, the base, the scope gate
and the branch-point verdict are all derived from the same read, and a rebase
moves more than one of them. Patching `$HEAD_SHA` alone leaves `$BASE_SHA` pointing
at the pre-rebase base, which is the rewritten-base failure arriving by a different
route.

**`prune` first, and it is not defensive clutter.** `$SCRATCH` lives under
`$TMPDIR`, so a reboot or a tmp sweep between two audits of the same PR deletes
the worktrees and leaves their registrations behind. Git then refuses **the
fetch** — one command before any `worktree add` — with a message naming a
directory that is not there:

```
fatal: refusing to fetch into branch 'refs/heads/pr-<N>' checked out at '<a path that no longer exists>'
```

That is this, not a permissions or ref problem, and the stale-worktree paragraph
below does not reach it: that paragraph is keyed to `git worktree add` refusing,
and Phase 0 never gets that far. `prune` is a no-op when state is clean, needs no
path argument, and clears `pr-<N>` and `base-<N>` together.

**Create the worktrees only where Phase 4 or Phase 5 will run.** They are the two
phases that need a tree; every other read here reaches the PR through
`git show` at a ref. Five paths run neither, and the condition is the phases
rather than any one of the five:

| Condition | Already on disk as | Why neither phase runs |
|---|---|---|
| an actions bump | `$ECOSYSTEM=github-actions` | `references/actions.md` reads the diff with `git show "pr-<N>:…"` throughout, Phase 4 reads release notes, and Phase 5's substitute is `gh run list` |
| an ecosystem this plugin does not cover | `$ECOSYSTEM`, `$SCOPE_GATE=beyond` or `underivable` | Phase 1's boundary stops the audit before either |
| a `pull` tier, a non-bot author, or a cross-repository head | `$MAY_EXECUTE=no` | both phases open by testing it for `yes` |
| `--no-execute` | **not** `$MAY_EXECUTE` — the flag is yours, and `discover.py` never sees it | the arguments section defines the run as Phases 0–3 and 6–7 |
| Phase 1 finding anything | `$SCOPE_GATE=beyond` | both phases name it as a reason to skip |

Every row's input exists before the decision: `discover.py` writes `$ECOSYSTEM`,
`$SCOPE_GATE` and `$MAY_EXECUTE` one command earlier, and the flag is a word in
the invocation. **This rule used to name the ecosystem instead of the property**,
and two live runs deviated from it independently — each reasoning out that an
uncovered ecosystem consumes no worktree either, and each writing the gap up
rather than acting on it (#111). Naming one of five conditions needed four more
exceptions; the property is the same in all five, so it is stated once.

The **fetch** stays on every path: `git show` needs the ref, Phase 6's merge
simulation needs `pr-<N>`, and Phase 7 still has a branch to remove. Where the
ecosystem is not yet known, the scope diff settles it and costs one command.

And the part no script can read for you — the bot's configuration, which decides
whether a currency gap in Phase 2 is lag or a deliberate hold, and the repo's
**own** verification commands. That stays here for the same reason the mutations
do: `pytest` may be `uv run pytest`, `tox`, `nox`, or a `make` target, and only
the workflow says so.

**Read every one of them at a ref.** The rest of Phase 0 is pinned to the PR and
these were not — they ran in the user's checkout, so the answers came from
whatever branch happened to be there:

```bash
# Fresh call: nothing survives one, so re-derive $SCRATCH and re-source Phase 0.
REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner); SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}/dbaudit-${REPO/\//-}-<N>}"
. "$SCRATCH/phase0.env" || { echo "no handoff in $SCRATCH — re-run Phase 0" >&2; exit 2; }

# Derive the workflow list before reading it, at both refs. A repo can have
# several, and a guessed `ci.yml` either fails loudly on a repo that spells it
# `tests.yml` or — worse — succeeds and silently narrows the gate list to one.
git ls-tree --name-only "pr-<N>:.github/workflows/";    echo "pr list exit: $?"
git ls-tree --name-only "$BASE_SHA:.github/workflows/"; echo "base list exit: $?"

git show "pr-<N>:.github/dependabot.yml" 2>/dev/null || git show "pr-<N>:renovate.json"
git show "pr-<N>:.pre-commit-config.yaml"

# Then every name each list gave, at its own ref. Not one file: `<workflow>`
# stands for the whole list, and Phase 6 asks for the same list narrowed to what
# the diff touched.
git show "pr-<N>:.github/workflows/<workflow>"       # the gates Phase 5 reproduces
git show "$BASE_SHA:.github/workflows/<workflow>"    # the gates Phase 4 measures with
```

`ls-tree` on a directory that is not there exits **128** and says so, rather than
printing nothing at exit 0 — so a repo with no workflows is distinguishable from
a read that failed, which is the distinction the two lists exist to support.

**Each phase's gates come from the tree it runs them in**, and the two trees are
not the same one: Phase 5 reproduces in `$SCRATCH/pr-<N>`, Phase 4 measures in
`$SCRATCH/base-<N>` — or `$SCRATCH/tip-<N>`. One list run in both manufactures
this phase's own worst outcome, an **exit 2** that reads downstream as a gate
*failure*. Observed auditing a merged bump: the checkout's `ci.yml` listed
`uv run actionlint`, which arrived three PRs later, so in the PR's worktree it
could not spawn — "could not run" reported as "ran and found something", by the
procedure that is most careful about that distinction everywhere else.

**A gate on only one side of the bump is itself a finding**, and it is quiet in
both directions. A gate since *removed* runs against a tree that never had it; a
gate the PR *adds* never runs at all — and the second is the one that matters,
because an actions or tooling bump can legitimately add its own. Diff the two
lists and report the difference rather than picking a side.

If `git worktree add` refuses because the path already exists, a previous run
left it there. **Prove it still points at this PR's head before reusing it** — a
stale worktree silently audits the wrong commit and every result downstream is
wrong. Compare against the pinned SHA rather than eyeballing a log line:

```bash
# Fresh call: nothing survives one, so re-derive $SCRATCH and re-source Phase 0.
REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner); SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}/dbaudit-${REPO/\//-}-<N>}"
. "$SCRATCH/phase0.env" || { echo "no handoff in $SCRATCH — re-run Phase 0" >&2; exit 2; }

test "$(git -C "$SCRATCH/pr-<N>" rev-parse HEAD)" = "$HEAD_SHA"
git -C "$SCRATCH/pr-<N>" status --porcelain                       # must be empty
```

If either check fails, remove it and re-add — with
`python3 "$SCRIPTS/cleanup.py" --scratch "$SCRATCH" --pr <N>` rather than a bare
`git worktree remove`. The second check fails precisely when the tree is dirty,
and a plain `remove` refuses on exactly that, so the remedy would fail on the
state it was written for — and a previous run's Phase 5 residue would be
discarded unread rather than saved.

**Phase 0 outputs.** Every later phase consumes these and nothing else:

| | |
|---|---|
| `$SCRIPTS` | this plugin's `scripts/` directory, absolute. **This is how `references/*.md` name a script**, and the only output that is not about the PR — `discover.py` derives it from its own location. See the paragraph below for why the references cannot use `${CLAUDE_PLUGIN_ROOT}` and Phase 0 can |
| `$DEFAULT` | the repo's default branch, derived |
| `$SCRATCH` | scratch directory, outside the repo — and **derived, so every later call resolves it to the same place**. Nothing else here survives a call boundary, so every consuming block re-derives this and re-sources `phase0.env` before reading any row below |
| `$HEAD_SHA` | the full 40-character commit under audit |
| `$BASE_SHA` | the merge base, from GitHub's own `compare` endpoint — never a local `git merge-base` against `$DEFAULT`, which collapses onto the head once the PR lands. **And whether it is the bot's branch point**, which is a separate answer |
| `pr-<N>` | the fetched branch, registered in the **user's** repo |
| `$SCRATCH/pr-<N>` | worktree at the PR's head — Phase 5 reproduces in it. **Created only where Phase 4 or Phase 5 will run**; the table above says how that is read off `$ECOSYSTEM`, `$SCOPE_GATE` and `$MAY_EXECUTE` before either phase is reached |
| `$SCRATCH/base-<N>` | worktree at the merge base — **Phase 4 measures in it**, and the reason is below. Same condition, and not a second exception |
| the repo's gates | read at a ref, **once per tree they will run in**: `pr-<N>` for Phase 5, `$BASE_SHA` for Phase 4. A gate on only one side is a finding |
| `$OWNER`, `$NAME` | the repo's owner and name, for Phase 6's GraphQL variables |
| `$CREATED_AT` | when the PR was opened, ISO-8601. **Phase 2 compares release publish times against it** — the cooldown asks whether a release was three days old *then*, not now |
| `$BRANCH_POINT` | `ok`, `rewritten`, `suspect` or `underivable` — **the tip-worktree block below gates on it**, and the table there says what each one means |
| `$MAY_EXECUTE` | `yes` or `no` — **Phases 4 and 5 gate on it**, and the gate tests for `yes` so an unset value refuses. The classification below is what sets it |
| `$ECOSYSTEM` | `uv.lock`, `github-actions`, `pre-commit`, `unsupported`, `unknown` or `underivable` — **which Phase 1, 3, 4 and 5 method applies**, derived from the files the bump changed rather than inferred from the PR |
| `$SCOPE_GATE` | `clean`, `beyond` or `underivable` — **Phase 1's gate, already decided** from the bot's own commits. That is the invariant the gate is about, and it is why `$BOT_COMMITS` does not cross: the loop that consumed it is now the script's |
| `$HUMAN_COMMITS` | every non-bot commit on the branch, merges included. Its files are a **finding to report**, never a Hold |

**`$SCRIPTS` is on that list because `${CLAUDE_PLUGIN_ROOT}` reaches only this
file.** The token is substituted into `SKILL.md`'s *text* at skill load — which
is why the `D=` line above resolves, and why the variable itself measures empty
in every shell (`ROOT=[]`, marketplace install and `--plugin-dir` alike). A
reference file is never injected: the model reads it off disk, so the token
arrives at the shell intact and the path collapses to
`/skills/dependabot-audit/scripts/…`. Two lines of `references/uv-lock.md`
shipped that way from 0.15.0 until the first `uv.lock` replay ran them.

So the bootstrap happens **once, here**, and everything downstream derives from
it. `discover.py` reports its own directory, and a path taken from the file that
just ran cannot name a different copy than the one running — which is also the
answer to the stale-cache hazard, where an invented
`export CLAUDE_PLUGIN_ROOT=…/0.22.1` pins a release into a cache that keeps every
older version and then audits with it, silently and successfully.

**`$PERMS` is not on that list, and the distinction is the point.** It is read off
`discover.py`'s report *here in Phase 0*, where the execution gate and the
actionability question both use it. It is **not** written to `phase0.env`: the
shell handoff carries `MAY_EXECUTE`, which is the decision `$PERMS` was consulted
to make. So a later phase that sources the handoff and reads `$PERMS` gets the
empty string, and the table above is the list that crosses — anything else is
Phase 0's own working state.

The reason is that `$PERMS` is a set of flags rather than a value: `$PERMS.push`
is how the gate below addresses it, and there is no shell form of that. Reducing
it to the one bit later phases actually branch on is what `MAY_EXECUTE` is.

**And they branch on it, rather than being trusted to remember this table.** The
blocks in Phases 4 and 5 that run the audited repo's code open with

    [ "${MAY_EXECUTE:-}" = yes ] || { echo "not authorised" >&2; exit 2; }

quoted here as an illustration — the runnable copies live in
`references/uv-lock.md` § Phase 4 and § Phase 5, which is where they are read
from.

Tested **for** `yes`, never against `no`, and the difference is the whole guard:
a block whose handoff did not load sees the empty string, and `!= no` is true of
it. The one direction this must never fail in is open.

**Diagnostics — emitted, deliberately unread:** `BASE_REF`. It is Phase 0's own
cross-check on the `compare` call and no later phase consumes it. Every *other*
name the emitter writes is read by a block; that is the rule, and an exemption is
a decision written down here rather than a name nobody happened to use.

If a later phase needs something not on this list, it belongs here rather than
there. A phase that consumes what a later phase creates cannot be run in order,
and that has now shipped twice — `tests/test_skill_prose.py` is what stops the
third.

**An output that could not be derived is not an output.** Every row above has
*three* states, not two: **derived**; **absent**, which is often a finding in its
own right; and **underivable**, where the call failed or its precondition did not
hold. `discover.py` tags each one and leaves an underivable output *unset* rather
than emitting a plausible value.

The third state is the dangerous one because the ways it happens do not raise —
they produce a real-looking answer that travels downstream as fact, and the
report then says something false with full confidence. A phase handed an
underivable input says so in its evidence row instead of proceeding on the value,
and Phase 7 does not print a row whose input was never established. "Could not
check" is a legitimate thing for this procedure to report. "Checked, found
nothing" when you could not check is not.

**Classify the PR before trusting it enough to run it.** Dependabot and Renovate
push their branches *into* the repository, so a dependency bump arriving from a
fork did not come from the bot:

| Observation | Meaning |
|---|---|
| `isCrossRepository: false`, author `dependabot[bot]` or `renovate[bot]`, `push: true` | the ordinary case |
| `isCrossRepository: true` | a fork PR — neither bot opens one |
| any other author | a human PR shaped like a bump, which it may well be, and may not |
| **`$PERMS.push` false** | **not a repository you control.** You cannot merge this PR, so nothing is gained by letting it run on your machine |

Any of the last three is a **finding** in its own right, and each changes the
default: run `--no-execute`, report what the read-only phases found, and let the
user decide whether to authorise Phases 4 and 5. Say plainly that those phases
would run the PR's code.

The `push` row is the one easiest to argue away, so name the asymmetry it rests
on. A bot PR on a repo you control proposes code you were going to run anyway,
under gates you already trust — your own CI would run it too. A PR on a repo you
cannot merge into proposes code you had no plan to run, and the comparison to CI
stops holding: CI runs it in a fresh container with a scoped token, and this
procedure runs it on your workstation with your credentials in the environment.
`$PERMS` is already derived above, so this costs nothing to check.

**`$PERMS` has the same three states, and the script gates on the call rather
than the value.** A failed `repos/:owner/:repo` writes its error body to
**stdout**, so a capture succeeds and holds `{"message": "Not Found", ...}` — at
which point `push` is not `true` and reads exactly like a `pull`-only account.
The exit code is 1, which is what separates this from the branch-protection trap
below where the same shape arrives at exit 0. Failing closed is right; the report
saying "you lack `push` here" when the audit could not tell is not, and
`discover.py` prints `underivable` rather than a permission set.

**Pin the head SHA here and audit that one commit everywhere.** The lockfile
Phase 1 reads, the worktree Phase 5 reproduces in, and the CI run Phase 6 checks
must all describe the *same* commit, or the report's evidence table asserts a coherence it
does not have. Bots rebase, so this is not hypothetical: a rebase mid-audit leaves
Phases 1–5 describing a commit that no longer exists while Phase 6 reports on the
new one. Fetching once and working from `pr-<N>` makes them consistent by
construction, and Phase 7 re-checks the SHA before you write.

**Never audit the working tree.** Whatever branch the user happens to have checked
out is not the PR, and a lockfile read from it is indistinguishable from one read
from the PR — it just quietly reports no changes. That holds for the repo's
**config** as much as its content: the gate list and the bot config are read
above at a ref for exactly this reason, and they were the last two reads here that
were not.

**`SCRATCH` has two requirements, and only one of them is about where it is.**
Never place it inside the repo under audit — it pollutes `git status`, and a gate
that walks the tree (a linter, a formatter, a test collector) will descend into a
full second copy of the project and report on it. And it must resolve to the
**same directory on every later call**, because `$SCRATCH/phase0.env` is written
by one call and sourced by another, and both worktrees are addressed the same way.

`SCRATCH=${SCRATCH:-$(mktemp -d)}` satisfied the first and failed the second, and
that is why the line above derives the name instead. Measured against this
harness, two separate calls: an `export` in the first is **unset** in the second,
shell functions likewise, and each call is a new shell process. So `${SCRATCH:-…}`
found `SCRATCH` unset every time, `mktemp -d` returned a new directory, and the
next call sourced a `phase0.env` that was not there — leaving `$BASE_SHA`,
`$HEAD_SHA` and `$DEFAULT` silently empty downstream rather than erroring.

**The working directory does survive, and is still not a way out.** It carried
between calls when it stayed inside the project; a call that ends outside has its
cwd reset back. `SCRATCH` is required to be outside the repo, so it can never be
reached that way. Nothing ambient crosses the boundary — only a path each call
can recompute from what it already has, which is the repo and `<N>`.

**Recomputable is not recomputed, and that distinction shipped as a defect.**
Deriving `$SCRATCH` made the handoff *findable* from a later call; it did not make
any later call go and find it. So every block below that consumes a Phase 0 output
opens with the same three lines, and they are not decoration:

```bash
# Fresh call: nothing survives one, so re-derive $SCRATCH and re-source Phase 0.
REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner); SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}/dbaudit-${REPO/\//-}-<N>}"
. "$SCRATCH/phase0.env" || { echo "no handoff in $SCRATCH — re-run Phase 0" >&2; exit 2; }
```

Repeated rather than stated once, because a step that is merely implied is one
that gets skipped — the same argument that put the gate list at a ref. The `||`
is the load-bearing half: a `.` on a missing file returns 1 and keeps going, so
without it the block runs on with every output empty, which is the state this
whole phase exists to make impossible.

**What that empties, measured**, running each consuming block in a fresh call with
nothing sourced: Phase 1's lockfile read, Phase 4, Phase 6 and Phase 7's cleanup
all fail loudly — a `Permission denied`, two exit 2s, an exit 128 — and Phase 1's
**authorship gate passed silently**, because `for c in $BOT_COMMITS` over an unset
variable iterates zero times and handed the gate an empty file list. The gate is
`discover.py`'s from 0.29.0 and no longer reads that variable, but the class is
unchanged and the handling belongs in the shell rather than only in the paragraph
that describes it: an unset `$SCOPE_GATE` compares equal to nothing, so Phase 1's
`[ "$SCOPE_GATE" = clean ]` fails **closed** into a stop rather than open into a
pass.

`git` state is the exception and needs no reload. The `pr-<N>` ref Phase 0 fetches
is in the repository, so `git show "pr-<N>:…"` works from any call. Only the shell
handoff is lost.

A harness-provided `SCRATCH` still wins, and now for a reason: if one is exported
into every call's environment it is stable by definition. The derived default is
what applies when it is not.

Re-running the same audit reuses the directory rather than littering a new one,
so Phase 7's cleanup is addressable from any call — but a stale worktree from an
interrupted run is then in the way, which is a thing to remove rather than to
work around.

**Why the base comes from `compare` and not from `git merge-base`.** Once a PR
has landed its head *is* an ancestor of the default branch, so the merge base of
the two is the head — and auditing a merged PR is a supported thing to do here:
Phase 6 has a row for it, `references/actions.md` has a paragraph, and every
replay this project's own gate asks for is one. Measured on `cli/cli`'s merged
bumps #14147, #14091, #13981 and #14049: `git merge-base trunk pr-<N>` returns
the PR's own head for all four, so the scope diff is **0 files** where GitHub
reports 4, 2, 3 and 2. GitHub's `compare` endpoint returns the real branch point
in both states, which is why the script uses it and why no phase runs a local
merge base at all.

**Prove the merge base is where the bot branched.** A merge base always exists,
and when the base branch has been rewritten under an open PR it is far too old —
silently, with every later phase consuming it as fact. `discover.py` decides this
and prints which case fired; what matters here is that the three cases are *not*
interchangeable:

| `BRANCH_POINT` | What fired | What you do |
|---|---|---|
| `ok` | no force-push, and nothing anomalous above the base | proceed |
| `rewritten` | a `base_ref_force_pushed` event — GitHub says so, with an actor and a timestamp | **substitute**, and report the rewritten base as its own finding |
| `suspect` | a non-bot one-parent commit above the base on a **bot** PR, with no force-push event | corroboration without the authority. Read the commits before deciding; do not substitute on it alone |
| `underivable` | the event list could not be read | say so; do not proceed as though it were `ok` |

The substitutions, when `rewritten` fires:

- **Phase 1** needs no substitution: its gate reads the bot's own commits, and a
  commit carries its own diff with no range to be wrong about. Where the
  authorship split is *also* underivable there is nothing safe to fall back to —
  the whole-diff range is the entire divergence — so the gate answers
  `underivable` rather than guessing.
- **Phase 4** measures in `$SCRATCH/tip-<N>` rather than `$SCRATCH/base-<N>`,
  because the tree this PR would land on is the default branch's tip.

```bash
# Fresh call: nothing survives one, so re-derive $SCRATCH and re-source Phase 0.
REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner); SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}/dbaudit-${REPO/\//-}-<N>}"
. "$SCRATCH/phase0.env" || { echo "no handoff in $SCRATCH — re-run Phase 0" >&2; exit 2; }

[ "$BRANCH_POINT" = rewritten ] || { echo "base not rewritten — no tip worktree" >&2; exit 0; }
git fetch origin "$DEFAULT"
git worktree add --detach "$SCRATCH/tip-<N>" "origin/$DEFAULT"
```

Say both substitutions in the report. "The base branch was rewritten under this
PR" is a true and useful finding; "this bump reaches beyond the manifest and
lockfile" is not, and they produce the same diff.

**A two-parent head is not a moved base**, and the script will not treat it as
one. Measured on `cli/cli` #14049, whose head is *"Merge branch 'trunk' into
dependabot/…"* by a maintainer above the bot's own commit: zero force-push
events, and a correct two-file scope diff from the merge base. Read as a moved
base it would substitute the `pr-<N>^` diff — 20 files, 1,101 lines — and halt
the audit on a bump that changes four workflow lines. There, `pr-<N>^` is the
branch *tip*, not the branch point.

Observed at the other end: a two-file `Cargo.toml` / `Cargo.lock` bump whose
merge-base diff was 14 files and 3,682 deletions, appearing to delete the repo's
entire vendored `supply-chain/` tree. The base had been force-pushed eleven
minutes after the PR opened, and the merge base fell back nineteen months.

**`gh pr view --json files` is not a cross-check on any of this**, which is why
Phase 0 does not fetch it. GitHub computes the PR's file list from the merge base
too, so on the force-pushed bump above it reported the same wrong 14 files. It
agrees with the wrong answer rather than correcting it.

**`$PERMS` decides two separate things, and conflating them gets both wrong.**
The tier that can *merge* is `push`; the tier that can read branch protection is
`admin`. The common case — a maintainer with `push` but not `admin` — sits
between them, and at `pull` only the verdict becomes a recommendation the reader
cannot act on, so offer `--comment` text instead.

Do **not** try to read the required checks here at any tier. That question moved
to Phase 6, which asks it per-PR in a form readable at `pull`. The two endpoints
that look like they answer it both fail into a plausible value, in opposite
directions: `branches/<b>/protection` needs `admin` and answers a bare `404` that
is indistinguishable from an unprotected branch, while `rules/branches/<b>` reads
at any tier and reports **rulesets only**, so classic protection returns `[]` and
manufactures a false "nothing enforced". Both measured, in `CONTRIBUTING.md`.

Recalled project memory may already name landmines for this repo (Phase 8 writes
them). Treat those as leads to check, not as facts — verify before repeating.

## Phase 1 — Scope and provenance

*Requires from Phase 0: `$SCRATCH`, `$ECOSYSTEM`, `$SCOPE_GATE`, `$HUMAN_COMMITS`, `pr-<N>`.*

**Phase 0 derived this gate; this phase acts on it.** `discover.py` reads the
files **`$BOT_COMMITS`** changed — never the branch's, because a maintainer can
land the fixup the bump *requires* on the bot's own branch, and gated on the
union that produces a Hold in language that reads exactly like a bump reaching
into source. It answers in Phase 0's three states:

| `$SCOPE_GATE` | What it established | What this phase does |
|---|---|---|
| `clean` | every changed file is the manifest and the lockfile, or every changed **line** is a `uses:` pin | continue |
| `beyond` | the diff reaches past the pin — the report output names the files or the lines | a finding. Report it and **stop before Phase 4** |
| `underivable` | the gate could not be evaluated: a patch the API withheld, a file list at its cap, a lockfile from an ecosystem this plugin does not cover, or a rewritten base with no authorship split to fall back from | **not a clean scope.** Report what could not be established, and stop |

The count of files is not the invariant and never was: an action is pinned in
every workflow that uses it, and a grouped bump moves several actions at once, so
ordinary merged bumps touch two, three or four files. `references/actions.md` has
the measurements, and the rule for reading the versions out of that diff rather
than off the title.

**`underivable` is not `clean`, and the asymmetry is the point.** Every way this
gate fails quietly arrives as *no objection* rather than as an error — an unset
`$BOT_COMMITS` iterating zero times, an empty file list, a capped page hiding
file 301 — and it is the gate that refuses Phases 4 and 5 a shell. Where the
split is underivable the script falls back to the whole `$BASE_SHA..pr-<N>` diff
and prints which source it used; read that line before quoting the verdict.

```bash
# Fresh call: nothing survives one, so re-derive $SCRATCH and re-source Phase 0.
REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner); SCRATCH="${SCRATCH:-${TMPDIR:-/tmp}/dbaudit-${REPO/\//-}-<N>}"
. "$SCRATCH/phase0.env" || { echo "no handoff in $SCRATCH — re-run Phase 0" >&2; exit 2; }

echo "ecosystem: $ECOSYSTEM"
# a maintainer's commit on the bot's branch: read it, report it, do not Hold on it
HUMANS=$(for c in $HUMAN_COMMITS; do git show --name-only --format= "$c" || exit 1; done) \
  || { echo "cannot read a commit in \$HUMAN_COMMITS" >&2; exit 2; }
printf '%s\n' "$HUMANS" | sort -u
# Last, so the half above still reports. Unset compares equal to nothing, so an
# empty $SCOPE_GATE stops here rather than reading as clean.
[ "$SCOPE_GATE" = clean ] \
  || { echo "scope $SCOPE_GATE — report it, and STOP before Phase 4" >&2; exit 1; }
```

A merge commit is in that list and normally prints nothing — its content arrived
from the branch it merged. What it *does* print is what the merge itself changed,
which is the one thing worth seeing there.

A provenance discrepancy stops the audit here too. Phases 4 and 5 execute the
PR's code, and the point of running the cheap read-only checks first is that they
can refuse to hand it a shell; continuing anyway spends the ordering for nothing.
Stopping here is not a failed audit but a complete one that reached a verdict
early — write the report with the phases that ran, and say which did not.

**The method is per-ecosystem; the gate above is not.** Each reference is
sectioned by phase, so read the section for this one:

| Ecosystem | Method |
|---|---|
| `uv.lock` | `references/uv-lock.md` § Phase 1 — `scripts/audit.py` verifies every pinned artifact's hash, size, URL and yank status against the live registry, plus PEP 740 build provenance |
| GitHub Actions | `references/actions.md` § Phase 1 — no lockfile and no artifact hash, so the question becomes whether the pin is **immutable**: a 40-hex SHA, or a tag someone else can revoke. The scope gate keys on `uses:` lines, never on a count of files |
| `pre-commit` | `references/pre-commit.md` § Phase 1 — a `rev:

…(truncated)
