Files
trailofbits__skills/Makefile
Dan Guido 8ea3b6a700 Move the contribution checklist into machinery (#206)
* Add validator self-test, structural checks, and make check

The repo documented ~53 rules in AGENTS.md and machine-enforced 6 of them. This
closes the gap for the ones a machine can decide, and adds the guard that keeps
the checkers honest.

New error-level checks (all currently pass, so none of this blocks anyone today):
agent files must use `tools:` while skills use `allowed-tools:` (the loader
silently ignores the wrong key, so the restriction just does not apply);
subagent_type must be namespaced or the dispatch fails at runtime; plugin dir
names kebab-case and <=64 chars; plugin README present, listed rather than
stat'd so `Readme.md` fails on Linux CI the way it should; semver format; the
forbidden runtime sidecars AGENTS.md already banned but nothing checked; and
version-increment against the base branch, which is the gap that let debfb29
ship an allowed-tools fix across 25 plugins that no installed user received.

New warning-level checks, reported but not blocking: the two required SKILL.md
sections, the 500-line limit, and unresolved relative references. 55 warnings
across 40 plugins today, concentrated in testing-handbook-skills and
building-secure-contracts.

The point of the exercise is the three anti-vacuity guards. A checker that has
silently stopped matching reports a clean repo forever, and that failure mode
has shipped repeatedly: `--self-test` builds fixtures and asserts every checker
rejects a known-bad one; a full scan that resolves zero references exits 1
rather than declaring everything clean; and SELF_TEST_MINIMUM fails the
self-test if it runs fewer than 20 assertions, because the self-test is itself a
checker. It currently runs 26.

The reference extractor skips fenced and inline code so that skill-authoring
docs citing example paths do not generate warnings nobody reads.

Makefile mirrors CI as one `make check`. Its RUFF_VERSION must match the
ruff-pre-commit rev, and the self-test asserts that — verified by breaking it.

* Fix CI checks that could pass or fail without inspecting anything

Four of these are the same bug in different places: a check whose empty case is
indistinguishable from success.

- python-tests ran `python3 <file>` per file. A test file with no
  `if __name__ == "__main__"` block exits 0 having run nothing, which reads as
  a pass. All 10 current files happen to comply; nothing enforced it. Now pytest
  per directory, run via `python3 -m` from inside each one to preserve the
  sibling imports these suites rely on, with --import-mode=importlib because
  c-review and rust-review both ship scripts/test_split.py and the default
  import mode collides on the basename. 278 tests now run where the count was
  previously unknown.
- bats used --no-run-if-empty, so a broken glob was a silent pass. This repo
  ships bats suites; finding none is a failure.
- The SKILL.md frontmatter walk printed "All 0 SKILL.md files have valid
  frontmatter" if discovery broke. Now fails on zero.
- zeroize-audit's shell regression suites matched no CI glob and had never run.

Also: the validator self-test runs first, before validation. The hardcoded-path
grep now covers .sh, .bats, .yml and .toml — test fixtures and install scripts
are where absolute paths hide. The personal-email exclusion is anchored; an
unanchored '.git' also dropped any line containing '/github'. The two npm CLI
installs are pinned rather than @latest, which zizmor flagged and which made CI
able to break with no commit.

check_codex_loadability.py: tempdir cleanup raced the codex app-server and threw
`OSError: [Errno 39] Directory not empty: '.git'`, failing the job after every
loadability check had passed (seen on PR #148 today). A teardown race must not
be reported as a validation failure.

* Wire validators into pre-commit; fix two dead Dependabot entries

pre-commit: the three .github/scripts validators were CI-only, so the first
signal a contributor got was a red check after pushing — AGENTS.md asked people
to remember to run them by hand. Now they run locally, with the validator
self-test scoped to fire only when the validator itself changes. Adds
actionlint, zizmor, check-toml, check-merge-conflict, and detect-private-key.

detect-private-key earns its place: there is no secret scanning here at all,
which is how a live API key sat in an untracked config file in a working tree
this morning without anything noticing.

Markdown linting is deliberately absent, with the measurement recorded in the
config so the next person does not have to redo it: markdownlint reports ~12,400
violations across this repo (7,282 MD013 line-length alone). It would land
either permanently red or with so many rules disabled that it checks nothing.

Dependabot had two defects that made it quieter than it looked. The `pip` entry
at / had no manifest to resolve — there is no root pyproject.toml and uv.lock is
gitignored — so it reported nothing, indefinitely. And two script directories
with real dependencies were uncovered: trailmark's slicing-code-context
(trailmark>=0.5,<0.6) and yara-authoring's rule scripts (yara-x>=0.10.0).
Switched to the uv ecosystem per the house standard, grouped minor/patch so
majors still get their own CI run.

* Add automated PR review, inert until a key is configured

Two tiers: fast (effort low, every push, sticky comment) and deep (effort xhigh,
on a deep-review label). Per-job permissions with an empty workflow-level grant;
concurrency keyed per tier so a routine push cannot cancel an in-flight deep
review — GitHub will not re-fire `labeled` for a label already present, so that
would leave a PR sitting labeled with no review, looking reviewed.

The prompt is the substance. It forbids pre-filtering: current models follow
"only report high-severity issues" literally, investigating fully and then
declining to report what they judge below the bar, which reads as a capability
regression but is a prompt bug. It asks for everything ranked P1-P4 with a
concrete failure scenario each, and filters downstream. It also names the five
defect classes that actually reach main in a repo of markdown that instructs a
model, rather than asking for generic code review.

There is no ANTHROPIC_API_KEY secret on this repo, so every review step is gated
on the secret being present. Until someone adds it these workflows check out the
code and do nothing — they do not fail. A review workflow that goes red on every
PR for want of a credential teaches people to ignore red checks.

Fork review is a separate file with a separate decision attached. It needs
pull_request_target, because under `pull_request` a fork PR gets a read-only
token and the job could not post its comment at all. The usual exploit path is
closed by a maintainer-only label gate, a checkout pinned to the SHA as of the
labeling event (so a later force-push does not change what is reviewed), and a
tool allowlist with no general Bash, so fork code is read and never executed.
The residual prompt-injection risk is documented in the file header. Deleting
that one file costs nothing but fork review.

* Replace the PR checklist with make check

Deletes the 20-item PR checklist. Current model guidance is explicit that
verification scaffolding of that shape degrades output rather than improving it,
and that removing it costs no capability — so the response is to move each check
into machinery, not to restate it louder.

Every item that could be mechanized landed in the validator first, with a
fixture, before this prose was allowed to go. What replaces the checklist is two
lists: what the validator enforces so you do not have to, and what no tool can
check. The second list is the one that matters — whether the description
actually triggers, whether the version bump is the right size, whether the
README row is in the right section (the validator only checks the plugin appears
somewhere in that file).

Also corrects the frontmatter example, which showed `allowed-tools` as a YAML
list. Every SKILL.md in the repo uses the space-delimited form; debfb29
converted them deliberately. The doc was the outlier, and it caused a false
finding during review of #192. Agent files genuinely do use a YAML list, under
the `tools` key — both forms are now shown side by side with the reason the
distinction matters.

Two new sections. "Scripts a plugin ships" leads with the rule this whole change
is built around: a checker that inspects zero items must fail, not pass, with
the three worked examples. "Working effectively in this repo" covers effort
sweeps, a subagent cap (current models over-delegate, which is a reversal from
the previous generation), not adding verification scaffolding to prompts, and
not telling a reviewer to pre-filter — that last one reads as a capability
regression and is a prompt bug.

Scopes the claude-code-guide line to questions that cannot be answered by
reading this repo, and adds .opencode/ to the banned sidecar list now that the
validator enforces it.

* Fix three bugs CI caught in the guardrails themselves

All three were mine, and the first is the one that mattered.

1. The version-increment check ran against every plugin, not just the ones the
   branch touched — so it demanded a version bump from all 42 plugins on a PR
   that changed no plugin at all. Now scoped to plugins with file changes
   between the base ref and HEAD. Added three self-test assertions covering it
   (touched plugin errors, untouched plugin does not), and verified by reverting
   the fix and confirming the self-test goes red. The check had no coverage
   before, which is exactly why it shipped broken.

2. The new pre-commit hooks invoked `uv run`, which is not installed in the
   Pre-commit CI job. The validator declares no dependencies, so plain python3
   is correct and one less thing to install.

3. The review workflows called `claude` without installing it. Added a pinned
   install step, matching how validate.yml installs the same CLI.

Also corrects a factual claim in both review workflow headers. I wrote that they
were inert for want of an ANTHROPIC_API_KEY, based on `gh secret list` returning
empty — but that only lists repository secrets. An organization-level key is
visible here, so the fast tier is live on merge. The headers now say so. The
deep and fork tiers still cannot fire until their labels are created.

* Fix the findings from this PR's own automated review

The review posted on #206 found eight issues. Seven were real. Working through
them in severity order:

P1 — deleted claude-review-fork.yml. It checked out the fork tree and then ran
this repo's review script *from that tree*, so the script itself, and any
CLAUDE.md or .claude/hooks sitting beside it, was fork-authored and executed
with the org API key in the environment. My header claimed "fork code is read,
never executed" and that was simply wrong; the SHA pin and the tool allowlist do
not touch that path. Doing it safely means checking out base into the workspace
and the fork commit into a subdirectory, which is deliberate work rather than a
footnote to this change. Fork PRs get no automated review for now, and
claude-review.yml explains why.

P2 — the prompts instructed `gh pr comment --body-file <file>` while the tool
allowlist has no Write and no general Bash, so there was nowhere to put a file.
The reviewer hit this and fell back to `--body-file -`; since a give-up does not
fail the step, the failure mode was a green check with no review. Now uses a
heredoc on stdin. The deep tier also told the model to run the scripts a PR adds,
which its allowlist cannot do; it now says so explicitly.

While fixing that: both tier heredocs were unquoted so `${PR_NUMBER}` would
interpolate, which means backticks in the prompt body were command substitution.
Quoted them and moved substitution out to parameter expansion afterwards.
shellcheck caught this.

P2 — `no-version-bump` was a phantom feature: named in the validator's error
message and in AGENTS.md, read by nothing. Implemented via --allow-no-bump,
wired to the label through validate.yml.

P2 — AGENTS.md claimed both loadability checks run in `make check`. They do not
and cannot; they need two CLIs installed. It now says what runs locally, what is
CI-only, and how to run the loadability checks by hand.

P3 — the new shell-suites discovery reintroduced the exact bug this PR exists to
remove: it printed "No shell regression suites found" and exited 0, and used
`**` without globstar so it only ever matched one directory level. Now find-based
and fails on empty. It is a Makefile target but not part of `make check`,
because zeroize-audit's suite pipes to `python3 -` which the modern-python
shim rejects — filed as #207.

P3 — a failed `git diff` returned an empty changed-plugin set, silently
disarming the version check for every plugin. Now raises.

P3 — the `/home/user/` exclusion was a content filter applied across all of
plugins/, so a skill legitimately documenting that path would be dropped from
the results. Scoped to the shim bats fixtures that need it.

Not fixed, deliberately: the reviewer noted `_check_ruff_parity` covers one
pinned version. True, and it is the only version both files pin.

* Fix the second review pass: the review job could not detect its own silence

The marquee one: claude_review.sh ended with `claude --print` and nothing else,
so if the model finished without calling `gh pr comment` — a denied tool, a hit
timeout, or it simply summarising instead of posting — the script exited 0 and
the job went green with no review attached. That is defect class 1 from the
prompt this very script ships, in the script that ships it. It now timestamps
before the run and fails if no comment was created or updated since.

_check_ruff_parity returned None (= pass) when the Makefile or pre-commit config
was missing, while still counting toward SELF_TEST_MINIMUM. A vacuous pass
inside the anti-vacuity harness. Now returns an error string.

The scoping claim in AGENTS.md and the Makefile was backwards. I wrote that CI
scopes the validator to changed plugins while local scans everything, making
local a strict superset. Both scan everything; only the version-increment check
is scoped, and it is the one check CI runs that local cannot — so local is not a
superset at all. Corrected in both places.

README said `make check` "runs everything CI runs" and the Makefile echoed "this
is what CI will run". Neither is true: it omits both loadability checks, the
version-increment check, and every pre-commit hook except ruff/shellcheck/shfmt.
Both now say what they actually cover. AGENTS.md already had this right.

Stale count in a pre-commit comment ("these three" preceding two hooks).

Accepted without change, with reasoning: the reference resolver matches any file
in the plugin, so a link to a file that exists under a different skill resolves
when it should not — pinning a base directory produces a flood of false
positives, and the looser check is still worth having. The subagent-dispatch
check only knows its own plugin's agents, so a bare cross-plugin reference is
missed; catching that needs a repo-wide agent map, which is a larger change than
belongs here.

* Fix the third review pass, including two more silent-pass checks

The hardcoded-path step had the defect this PR exists to remove. It was written
as `if grep A | grep -v B | grep -v C`, so the exit status came from the last
`grep -v`. A first-stage failure — plugins/ renamed, a grep built without -P —
produced no output, exited 2, and the step printed "No hardcoded user paths
found" while inspecting nothing. Now counts the files it scanned, fails if that
is zero, and checks grep's own status via PIPESTATUS rather than the pipe's.

The self-test fixture named "empty scan returns non-zero" was passing for the
wrong reason: it deleted the plugin's files but left the directory, so
scan_plugins_directory still returned {"demo"} and the non-zero came from the
missing-README error. The guard it claimed to cover was never exercised. It now
rmtree's the directory and asserts the fixture actually emptied plugins/ before
relying on it. Same class of bug as everything else here, one level up.

Concurrency: every `labeled` event that was not `deep-review` resolved to the
`fast` key with cancel-in-progress. Adding `dependencies` mid-review cancelled
the running review and then skipped both jobs, leaving a cancelled check, no
review, and no event that would re-trigger one until the next push. Each label
now gets its own key.

The version-increment check read the old version at `base.sha` while
changed_plugins diffed `base...HEAD`. Those disagree once anything lands on main
after a branch forks, so a PR could fail for not out-bumping a sibling it never
saw. Both halves now use the merge base, and the error message says to rebase.

Smaller: the sidecar ban only matched `.agents/plugins/marketplace.json` while
AGENTS.md bans `.agents/` — widened, with fixtures for it and for
`plugins/*/.codex-plugin`, neither of which the harness covered. `make shell`
globbed only plugins/, so the one new shell script in this PR went unchecked
locally. The deep job's fork exclusion is now explicit rather than relying on
secrets being unavailable to fork events. changed_plugins' docstring said
working tree; it compares commits.
2026-07-29 19:52:28 -04:00

116 lines
4.7 KiB
Makefile

# Every target here mirrors a CI job. If `make check` passes and CI does not, that is a
# bug in this file — fix it here rather than working around it, or the local signal stops
# being trustworthy and everyone goes back to pushing and waiting.
#
# CI jobs covered: Lint (pre-commit: ruff, shellcheck, shfmt), Shell (bats),
# Python tests, and Validate plugins and skills.
#
# RUFF_VERSION must match the ruff-pre-commit rev in .pre-commit-config.yaml. The
# validator self-test asserts that; bump both together.
RUFF_VERSION := 0.14.13
.DEFAULT_GOAL := check
.NOTPARALLEL:
.PHONY: check self-test lint shell bats shell-suites python-tests validate fix help
## check: most of what CI runs (this is the one you want)
check: self-test lint shell bats python-tests validate
@echo ""
@echo "✓ check passed — most of CI, but not the loadability checks, the"
@echo " version-increment check, or the non-ruff pre-commit hooks."
## self-test: prove the validators still detect what they exist to detect
# Runs before validate, deliberately. A checker that has silently stopped matching
# reports a clean repo forever; that failure mode has shipped here more than once.
self-test:
@echo "→ validator self-test"
@uv run --no-project python3 .github/scripts/validate_plugin_metadata.py --self-test
## lint: ruff check + format, pinned to the version CI uses
lint:
@echo "→ ruff check"
@uvx ruff@$(RUFF_VERSION) check --output-format=concise
@echo "→ ruff format --check"
@uvx ruff@$(RUFF_VERSION) format --check
## shell: shellcheck + shfmt over every shell script
# plugins/ AND .github/scripts/ — globbing only plugins/ left the repo's own scripts
# unchecked locally, which is where they are most likely to be edited.
shell:
@echo "→ shellcheck"
@find plugins .github/scripts -name '*.sh' -type f \
-exec shellcheck --severity=warning -x {} +
@echo "→ shfmt"
@find plugins .github/scripts -name '*.sh' -type f -exec shfmt -i 2 -ci -d {} +
## bats: run plugin bats suites
# Fails when the glob matches nothing: this repo has bats suites, so finding none means
# the discovery broke, not that the shell code is clean.
bats:
@echo "→ bats"
@files=$$(find plugins -name '*.bats' -type f); \
if [ -z "$$files" ]; then \
echo " ✗ no .bats files found — discovery is broken (this repo ships bats suites)"; \
exit 1; \
fi; \
echo "$$files" | xargs bats
## shell-suites: run plugin shell regression suites (CI only, see note)
# Deliberately NOT in `check`. zeroize-audit's suite pipes a script to `python3 -`,
# which the modern-python plugin's shim intercepts and rejects, so this target fails
# on any machine with that plugin installed — for reasons that have nothing to do
# with the code under test. CI has no shims and runs it there. See the tracking
# issue: #207.
#
# find, not a glob: `**` needs globstar and degrades to `*` without it, so a suite
# one directory deeper would stop running with no signal.
shell-suites:
@echo "→ shell regression suites"
@suites=$$(find plugins -type f -path '*/tests/*' -name 'run_*.sh'); \
if [ -z "$$suites" ]; then \
echo " ✗ no shell regression suites found — discovery is broken"; \
exit 1; \
fi; \
for s in $$suites; do echo "$$s"; bash "$$s" || exit 1; done
## python-tests: run plugin Python test files
# pytest, not `python3 <file>` in a loop: a file with no `if __name__ == "__main__"`
# block exits 0 under the loop having run nothing, which reads as a pass.
# --import-mode=importlib is required — c-review and rust-review both ship
# scripts/test_split.py, and the default import mode collides on the basename.
python-tests:
@echo "→ python tests"
@dirs=$$(find plugins -type f \( -name 'test_*.py' -o -name '*_test.py' \) \
-exec dirname {} \; | sort -u); \
if [ -z "$$dirs" ]; then \
echo " ✗ no Python test files found — discovery is broken"; \
exit 1; \
fi; \
failed=0; ran=0; \
for d in $$dirs; do \
echo "$$d"; \
( cd "$$d" && uv run --no-project --with pytest python3 -m pytest -q \
--import-mode=importlib . ) || failed=1; \
ran=$$((ran + 1)); \
done; \
echo " ran $$ran test director(ies)"; \
exit $$failed
## validate: plugin metadata, structure, and cross-references
# Scans every plugin. CI scopes to the plugins a PR touches, so local is a strict
# superset and cannot pass where CI fails. Do not narrow it to match: the
# zero-reference guard only arms on a full scan.
validate:
@echo "→ validate plugin metadata"
@uv run --no-project python3 .github/scripts/validate_plugin_metadata.py
## fix: apply the formatting CI would otherwise reject
fix:
@uvx ruff@$(RUFF_VERSION) check --fix || true
@uvx ruff@$(RUFF_VERSION) format
@find plugins -name '*.sh' -type f -exec shfmt -i 2 -ci -w {} +
## help: list targets
help:
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## / /'