Files
hermes-agent/.github/workflows/plugin-catalog-ci.yml
Teknium cdce56faf2 plugin-catalog CI: enforce rule 17 — scan pinned entries for unscannable payloads
The admission gate ran the install scanner (dangerous fails) but nothing refused
a compiled binary, committed bytecode, or an archive hiding either — the shapes
the scanner cannot read. This adds that enforcement to pinned-source-validate.

- scripts/scan_plugin_payloads.py (stdlib-only, so the no-install validator and
  third-party CI can run it): flags compiled binaries by magic header (ELF,
  Mach-O, PE/MZ, WebAssembly — not by extension, which a rename defeats),
  .pyc/.pyo bytecode (incl. sourceless/hash-forged caches that run instead of
  the .py beside them), and archives whose members are not reviewable source.
  Plain-text source bundles and inert assets (fonts, images) pass.
- plugin-catalog-ci.yml: after `hermes plugins validate`, each changed entry is
  scanned; a finding fails it. An entry may list `attested_binaries:` paths,
  each verified with `gh attestation verify --repo <repo> --source-digest <sha>`
  against the pinned commit; verified paths are dropped before the scan, any
  other binary still fails. Repo-controlled filenames are newline-stripped in
  the log so none can forge a workflow command.
- validate_plugin_catalog.py: `attested_binaries` is a known key, shape-checked
  as a list of relative paths.
- Tests: unit tests for the detector and two new workflow-driven bypass cases
  (bundled .exe, committed .pyc) in the confinement harness, which runs the real
  `run:` block.

Full-content re-sweep of all 543 pins: only tabro carries a native binary (it is
delisted in the companion PR). Three entries (crawl4ai, hermes-vision-inline,
mermail) commit __pycache__/*.pyc build litter and would fail rule 17 on their
next pin bump — pre-existing, not touched here.
2026-10-11 03:03:39 -07:00

362 lines
18 KiB
YAML

name: Plugin Catalog CI
# Admission gate for plugin-catalog entries. Fires ONLY on PRs touching
# plugin-catalog/** so it can never go red on unrelated PRs.
#
# Two gates:
# structural — cheap schema check, no hermes install needed
# pinned-source-validate — supply-chain gate: the pinned sha MUST be
# reachable in the entry's repo, and the plugin
# at that exact commit must pass
# `hermes plugins validate`.
on:
pull_request:
paths:
- "plugin-catalog/**"
permissions:
contents: read
jobs:
structural:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
with:
persist-credentials: false # this job is read-only
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
- name: Install ruamel.yaml
uses: ./.github/actions/retry
with:
command: pip install ruamel.yaml==0.18.17
- name: Validate catalog files (structural)
run: |
set -euo pipefail
# Validating the whole directory is simpler than diffing and keeps
# the invariant that EVERYTHING in plugin-catalog/ stays valid.
python3 scripts/validate_plugin_catalog.py plugin-catalog/
pinned-source-validate:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
with:
# Full commit graph + tags, blobs on demand: the diff below needs the
# merge commit's first parent, and Hermes resolves its own version
# from the release tags merged into HEAD. A shallow checkout reports
# "unknown", which plugins checking the host version reject (omh).
fetch-depth: 0
filter: blob:none
# `hermes plugins validate` below runs pinned-repo code; keep the
# job's token out of .git/config where that code could read it.
persist-credentials: false
- name: Find changed catalog entries
id: changed
run: |
set -euo pipefail
# HEAD is the PR merge commit: diffing it against its first parent is
# exactly what the PR would land. Refuse anything else rather than
# silently diffing a single commit.
if ! git rev-parse -q --verify 'HEAD^2' >/dev/null; then
echo "::error::expected the pull_request merge commit as HEAD"
exit 1
fi
# Added + modified + renamed + typechanged entry files only; deletions
# and removed.yaml have nothing to clone. A rename or file->symlink
# change that slipped past this list would skip the supply-chain gate.
CHANGED=$(git diff --name-only --diff-filter=AMRT 'HEAD^1' HEAD \
-- 'plugin-catalog/*.yaml' 'plugin-catalog/*.yml')
# The grep can only shrink the list and runs on its own so a git diff
# failure still fails the step instead of looking like "no changes".
CHANGED=$(printf '%s' "$CHANGED" | grep -v '/removed\.yaml$' || true)
if [ -n "$CHANGED" ]; then
# Admission PRs must be data-only: the gate's own enforcement code
# (this workflow, the validator, the composite actions, the built
# hermes) resolves from the PR checkout, so an entry PR that also
# touches tooling could neuter the gate vetting it. Contributor
# email maps are data the gate never executes; README/policy/CI PRs
# with no entry change skip this check.
ALL=$(git diff --name-only 'HEAD^1' HEAD)
OUTSIDE=$(printf '%s' "$ALL" | grep -vE '^(plugin-catalog|contributors/emails)/' || true)
if [ -n "$OUTSIDE" ]; then
echo "::error::catalog entry PRs may only touch plugin-catalog/** and contributors/emails/**, found:"
# Filenames are PR-controlled; a newline in one could smuggle a
# workflow command into the log.
printf '%s' "$OUTSIDE" | tr '\0\n' ' '
echo
exit 1
fi
fi
echo "Changed catalog entries:"
echo "${CHANGED:-<none>}"
{
echo 'files<<__EOF__'
echo "$CHANGED"
echo '__EOF__'
} >> "$GITHUB_OUTPUT"
- name: Prepare Hermes from the PR's own checkout
if: steps.changed.outputs.files != ''
uses: ./.github/actions/setup-pm
with:
toolchain: python
extras: '[]'
- name: Clone each entry at its pinned sha and validate
if: steps.changed.outputs.files != ''
env:
CHANGED_FILES: ${{ steps.changed.outputs.files }}
# A hostile repo URL must never block the job on a credential prompt.
GIT_TERMINAL_PROMPT: "0"
# `gh attestation verify` (README rule 17) reads the attestation from
# GitHub and needs a token. Read-only default token; the step runs
# pinned-repo code, so nothing broader is exposed here.
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
FAILED=0
WORKROOT=$(realpath "$(mktemp -d)")
# Entries arrive on fd 3, not stdin: `hermes plugins validate` executes
# plugin-declared install code that could otherwise read stdin and
# silently drain the remaining entry list.
while IFS= read -r entry <&3; do
[ -z "$entry" ] && continue
echo "::group::validate $entry"
# A symlinked or missing entry file is not a catalog entry.
if [ -L "$entry" ] || [ ! -f "$entry" ]; then
echo "::error file=$entry::catalog entry must be a regular file"
FAILED=1; echo "::endgroup::"; continue
fi
# Parse repo / sha / subdir from the entry yaml. These fields are
# PR-controlled, so the entry is rejected here before anything uses
# them -- the clone must be remote-only and the subdir a plain
# relative path, or the pinned sha ends up validating files outside
# the pinned commit. `python3 -I` keeps the PR checkout's own
# modules off sys.path (cwd is the PR tree) so it cannot shadow the
# parser's imports; ruamel.yaml comes from installed packages.
PARSED=$(python3 -I - "$entry" <<'PYEOF'
import re
import shlex
import sys
from ruamel.yaml import YAML
try:
with open(sys.argv[1], encoding="utf-8") as fh:
data = YAML(typ='safe').load(fh)
except Exception:
data = None
repo = str(data.get('repo', '')) if isinstance(data, dict) else ''
sha = data.get('sha', '') if isinstance(data, dict) else ''
subdir_raw = data.get('subdir', '') if isinstance(data, dict) else ''
if subdir_raw is None:
subdir_raw = '' # `subdir:` with no value means the repo root
subdir = subdir_raw if isinstance(subdir_raw, str) else ''
bad = ""
if data is None:
bad = "entry yaml is empty or unparseable"
elif not isinstance(data, dict):
bad = "entry yaml must be a mapping"
elif not re.fullmatch(r"https://\S+", repo):
# No whitespace either: the value is echoed into the log, where a
# newline would smuggle workflow commands like ::error::.
bad = "repo must be an https:// git URL"
elif not isinstance(sha, str):
bad = "sha must be a string"
elif not re.fullmatch(r"[0-9a-f]{40}", sha):
# A ref name would validate the branch tip, not the pin; a leading
# '-' would become a checkout option.
bad = "sha must be exactly 40 lowercase hex characters"
elif not isinstance(subdir_raw, str):
# Never coerce: a falsy non-string (0, false) would silently mean
# the clone root while the structural gate rejects the entry.
bad = "subdir must be a string"
elif subdir and (
not re.fullmatch(r"[A-Za-z0-9._/-]+", subdir)
or any(seg in ("", ".", "..") for seg in subdir.split("/"))
):
bad = "subdir must be a relative path without '.' or '..' segments"
print(f"REPO={shlex.quote(repo)}")
print(f"SHA={shlex.quote(str(sha))}")
print(f"SUBDIR={shlex.quote(subdir)}")
print(f"BAD={shlex.quote(bad)}")
PYEOF
) || PARSED="BAD='catalog entry parser failed'"
# Reset before eval: a killed or truncated parser must not let this
# entry inherit a previous entry's repo/sha and pass on its content.
REPO="" SHA="" SUBDIR="" BAD=""
eval "$PARSED"
# Check before echo: a rejected value must never reach the log, where
# a newline in it would smuggle workflow commands like ::error::.
if [ -n "$BAD" ]; then
echo "::error file=$entry::$BAD"
FAILED=1; echo "::endgroup::"; continue
fi
echo "repo=$REPO sha=$SHA subdir=$SUBDIR"
CLONE_DIR=$(realpath "$(mktemp -d -p "$WORKROOT")")
# Full clone (no --depth 1): the pinned sha may not be the branch tip.
# 600s: listed repos reach >1 GB (hermes-jackal-verified 1.43 GB,
# hindsight 741 MB); 120s would false-fail a re-pin on a slow runner.
if ! timeout 600 git clone "$REPO" "$CLONE_DIR" </dev/null; then
echo "::error file=$entry::clone failed for $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
# The pin must name a commit object -- a tag-object sha would peel
# to a commit the reviewer never declared as the pin.
if [ "$(git -C "$CLONE_DIR" cat-file -t "$SHA" 2>/dev/null)" != "commit" ]; then
echo "::error file=$entry::pinned sha $SHA is not a commit object in $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
# SUPPLY-CHAIN GATE: the pinned sha must be reachable in the repo.
if ! git -C "$CLONE_DIR" checkout --detach "$SHA" </dev/null; then
echo "::error file=$entry::pinned sha $SHA is not reachable in $REPO"
FAILED=1; echo "::endgroup::"; continue
fi
PLUGIN_DIR="$CLONE_DIR${SUBDIR:+/$SUBDIR}"
# A symlinked directory inside the clone could still point outside
# it, so confine the resolved path under the clone, not the lexical one.
RESOLVED_DIR=$(realpath -m -- "$PLUGIN_DIR")
if [ "$RESOLVED_DIR" != "$CLONE_DIR" ] && [ "${RESOLVED_DIR#"$CLONE_DIR"/}" = "$RESOLVED_DIR" ]; then
echo "::error file=$entry::subdir '$SUBDIR' resolves outside the pinned clone"
FAILED=1; echo "::endgroup::"; continue
fi
# Confinement is per-symlink too, across the whole clone: find does
# not descend into a symlinked directory, so an allowed in-clone
# link like plugin/ext -> ../lib would otherwise hide lib/evil ->
# /outside from a PLUGIN_DIR-only scan while reads through it still
# escape the pin.
if ! find "$CLONE_DIR" -path "$CLONE_DIR/.git" -prune -o -type l -print0 | while IFS= read -r -d '' link; do
target=$(realpath -m -- "$link")
case "$target" in "$CLONE_DIR"|"$CLONE_DIR"/*) ;; *) exit 1 ;; esac
done; then
echo "::error file=$entry::a symlink in the repo resolves outside the pinned clone"
FAILED=1; echo "::endgroup::"; continue
fi
# Native plugin.yaml OR portable Agent Plugins v1 plugin.json
# (#81196; native manifest wins when both exist).
if [ ! -f "$PLUGIN_DIR/plugin.yaml" ] && [ ! -f "$PLUGIN_DIR/plugin.yml" ] && [ ! -f "$PLUGIN_DIR/plugin.json" ]; then
echo "::error file=$entry::no plugin.yaml or plugin.json at subdir '$SUBDIR' of $REPO@$SHA"
FAILED=1; echo "::endgroup::"; continue
fi
# Manifest schema + declared-vs-registered capability check.
if ! timeout 600 hermes plugins validate --install-deps "$PLUGIN_DIR" </dev/null; then
echo "::error file=$entry::hermes plugins validate failed"
FAILED=1; echo "::endgroup::"; continue
fi
# UNSCANNABLE-PAYLOAD GATE (README rule 17). A reviewer and the install
# scanner must be able to read everything shipped at the pin as source:
# no compiled binaries (by header), committed bytecode, or archives
# hiding either. Entries that genuinely need a native component list
# each one under `attested_binaries:` and the binary must carry a
# GitHub build-provenance attestation tying it to this repo's own
# workflow at the pinned source digest; those paths are exempted here
# and verified below. No stdin: the parser must not drain the entry fd.
ATTESTED=$(python3 -I - "$entry" <<'PYEOF' </dev/null
import re, shlex, sys
from ruamel.yaml import YAML
try:
with open(sys.argv[1], encoding="utf-8") as fh:
data = YAML(typ="safe").load(fh)
except Exception:
data = None
paths = data.get("attested_binaries") if isinstance(data, dict) else None
if not isinstance(paths, list):
paths = []
ok = []
for p in paths:
# Relative path, no '.'/'..' segments, no absolute/newline: it is
# joined onto the clone and echoed into the log.
if (isinstance(p, str) and re.fullmatch(r"[A-Za-z0-9._/-]+", p)
and not any(seg in ("", ".", "..") for seg in p.split("/"))):
ok.append(p)
else:
print("BAD=" + shlex.quote(f"attested_binaries entry {p!r} is not a plain relative path"))
sys.exit(0)
print("PATHS=" + shlex.quote("\n".join(ok)))
PYEOF
) || ATTESTED="BAD='attested_binaries parser failed'"
ATT_BAD=""; ATT_PATHS=""
eval "${ATTESTED/PATHS=/ATT_PATHS=}"; eval "${ATTESTED/BAD=/ATT_BAD=}" 2>/dev/null || true
if [ -n "${ATT_BAD:-}" ]; then
echo "::error file=$entry::$ATT_BAD"
FAILED=1; echo "::endgroup::"; continue
fi
# Verify each declared binary's provenance against THIS repo at the
# pinned commit; a verified path is dropped from the clone so the
# detector that follows ignores it, and any other binary still fails.
ATT_OK=1
if [ -n "${ATT_PATHS:-}" ]; then
OWNER_REPO=$(printf '%s' "$REPO" | sed -E 's#^https://github\.com/##; s#\.git$##; s#/$##')
while IFS= read -r bpath; do
[ -z "$bpath" ] && continue
BFILE="$PLUGIN_DIR/$bpath"
if [ ! -f "$BFILE" ] || [ -L "$BFILE" ]; then
echo "::error file=$entry::attested_binaries path is not a regular file: $bpath"
ATT_OK=0; break
fi
if ! timeout 120 gh attestation verify "$BFILE" \
--repo "$OWNER_REPO" --source-digest "$SHA" \
--predicate-type https://slsa.dev/provenance/v1 </dev/null; then
echo "::error file=$entry::no build-provenance attestation for $bpath at $OWNER_REPO@$SHA (README rule 17)"
ATT_OK=0; break
fi
rm -f -- "$BFILE"
done <<EOF_ATT
$ATT_PATHS
EOF_ATT
fi
if [ "$ATT_OK" -ne 1 ]; then
FAILED=1; echo "::endgroup::"; continue
fi
if ! PAYLOAD=$(python3 "$GITHUB_WORKSPACE/scripts/scan_plugin_payloads.py" "$PLUGIN_DIR" </dev/null); then
# Filenames are repo-controlled: strip newlines so none can forge a
# workflow command in the log.
echo "::error file=$entry::unscannable executable payload (README rule 17): $(printf '%s' "$PAYLOAD" | tr '\n' ';')"
FAILED=1; echo "::endgroup::"; continue
fi
# SELF-UPDATER GATE (README rule 3): the pin is the only update path.
# A desktop bundle that both fetches from GitHub AND writes/renames
# plugin files is a self-updater; either half alone is fine (a
# plugin may read the API, or manage its own data files).
# NUL-delimited both ways: committed filenames with spaces or
# newlines must not split into grep options or forged log lines.
SELF_UPDATE=$(grep -rlZE 'releases/latest|releases/download|raw\.githubusercontent\.com|codeload\.github\.com' \
--include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' \
--include='*.jsx' --include='*.tsx' --include='*.mts' --include='*.cts' \
"$PLUGIN_DIR" \
| xargs -r0 grep -lZE 'writeTextFile|renamePath|writeFile\(|writeFileSync|renameSync|copyFileSync|cpSync|createWriteStream' || true)
SELF_UPDATE=$(printf '%s' "$SELF_UPDATE" | tr '\0\n' ' ')
if [ -n "$SELF_UPDATE" ]; then
echo "::error file=$entry::self-updating code in catalog build (fetches GitHub AND writes plugin files): $SELF_UPDATE"
FAILED=1; echo "::endgroup::"; continue
fi
echo "✅ PASS: $entry"
echo "::endgroup::"
done 3<<< "$CHANGED_FILES"
rm -rf "$WORKROOT"
if [ "$FAILED" -ne 0 ]; then
echo "❌ FAIL: one or more catalog entries failed pinned-source validation"
exit 1
fi
echo "✅ PASS: all changed catalog entries validated at their pinned shas"