diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 58b9c09..67a3cf2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -89,6 +89,15 @@ repos: pass_filenames: false always_run: true + - id: check-executables-allow-sync + name: Check executables allow key sync + description: Verify root apm.yml's executables.allow key names kyberforge's actual version -- apm matches that key by exact "#" lookup, so a version bump on one side alone silently stops deploying kyberforge's hooks/ and bin/ and lets the apm install go stale (see ADR-0019) + entry: bash scripts/check-executables-allow-sync.sh + language: system + stages: [pre-push] + pass_filenames: false + always_run: true + - id: apm-marketplace-check name: apm marketplace check description: Validate every marketplace.packages[] entry resolves, including network reachability of remote refs -- catches stale/unreachable remote package references that check-manifests.sh deliberately skips (local-source checks only) diff --git a/scripts/check-executables-allow-sync.sh b/scripts/check-executables-allow-sync.sh new file mode 100755 index 0000000..632438e --- /dev/null +++ b/scripts/check-executables-allow-sync.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fails the push when root apm.yml's executables.allow key stops naming +# kyberforge's actual version. +# +# apm gates a package's hooks/ and bin/ on an EXACT dict lookup of +# "#" in executables.allow (apm_cli/security/executables.py, +# `allow_executables.get(package_key)`) — there is no wildcard and no +# version-less form. So bumping plugins/kyberforge/apm.yml's `version:` without +# bumping the key in root apm.yml does not error anywhere: the entry simply +# stops matching, kyberforge's SessionStart hook stops deploying, and the apm +# install goes quietly stale — the exact failure ADR-0019 records as live and +# mitigates only with a comment. Nothing else in the pre-push gate compares the +# two files, which is why this exists. +# +# Run from repo root or pass REPO_ROOT as arg. + +REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +# A nonexistent REPO_ROOT fails loudly rather than falling through to the +# "no kyberforge plugin here" no-op below: that no-op is for a repo that +# legitimately does not ship the plugin, not for a typo'd or stale path, and +# exit 0 would read as "checked, in sync" when nothing was compared at all. +if [[ ! -d "$REPO_ROOT" ]]; then + echo "FAIL: executables-allow sync check: REPO_ROOT '$REPO_ROOT' is not a directory." >&2 + echo " Fix: run this from the repo root, or pass a real repo path as the first argument." >&2 + exit 1 +fi +REPO_ROOT="$(cd "$REPO_ROOT" && pwd)" + +PLUGIN_DIR="$REPO_ROOT/plugins/kyberforge" +PLUGIN_MANIFEST="$PLUGIN_DIR/apm.yml" +ROOT_MANIFEST="$REPO_ROOT/apm.yml" + +# Only a repo with no kyberforge plugin at all is a legitimate no-op — there is +# no version to pin and no hook to deploy. +if [[ ! -d "$PLUGIN_DIR" ]]; then + exit 0 +fi + +if [[ ! -f "$PLUGIN_MANIFEST" ]]; then + echo "FAIL: plugins/kyberforge/ exists but has no apm.yml, so its version cannot be read." >&2 + echo " Why: this gate compares that version against root apm.yml's executables.allow key;" >&2 + echo " without the manifest it would exit 0 having compared nothing." >&2 + echo " Fix: restore plugins/kyberforge/apm.yml, or remove plugins/kyberforge/ entirely." >&2 + exit 1 +fi + +if [[ ! -f "$ROOT_MANIFEST" ]]; then + echo "FAIL: root apm.yml is missing, so kyberforge's executables allow key cannot be verified." >&2 + echo " Why: apm reads executables.allow from the consuming package's manifest; with no root" >&2 + echo " manifest nothing grants kyberforge's hooks/ and bin/ permission to deploy." >&2 + echo " Fix: restore apm.yml at the repo root." >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Manifest facts +# --------------------------------------------------------------------------- +# Both readers emit the same line protocol, so the rest of the script does not +# care which ran: +# +# v exactly once +# apresent|absent exactly once — is executables.allow a mapping? +# k zero or more +# +# python3 + PyYAML is preferred where importable, because it is a real parser. +# It is deliberately NOT a hard requirement: no other hook in this repo's +# pre-push gate needs PyYAML, and making a version-pin check the one thing that +# can block every push on a missing pip package is a worse failure than reading +# two known shapes by hand. The fallback below is not a YAML parser — it +# recognises exactly the two shapes these manifests use and nothing else. + +read_facts_python() { + python3 - "$ROOT_MANIFEST" "$PLUGIN_MANIFEST" << 'PY' +import sys +import yaml + + +def load(path): + with open(path) as fh: + data = yaml.safe_load(fh) + return data if isinstance(data, dict) else {} + + +root, plugin = load(sys.argv[1]), load(sys.argv[2]) + +version = plugin.get("version") +# A 2-component version parses as a YAML float and would render back as e.g. +# "1.5" from 1.50 — a mismatch invented by the reader rather than found in the +# files. Refuse instead of guessing; semver keys here are always strings. +if version is not None and not isinstance(version, str): + sys.stderr.write( + "kyberforge's version: is not a string (%r) — quote it so the " + "executables.allow key can be compared verbatim.\n" % (version,) + ) + raise SystemExit(2) +print("v\t%s" % ("" if version is None else version)) + +executables = root.get("executables") +allow = executables.get("allow") if isinstance(executables, dict) else None +if isinstance(allow, dict): + print("a\tpresent") + for key in allow: + print("k\t%s" % key) +else: + print("a\tabsent") +PY +} + +# Strips one layer of matching quotes plus surrounding whitespace from a scalar. +unquote() { + local value="$1" + read -r value <<< "$value" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s\n' "$value" +} + +read_facts_bash() { + local line version="" allow_state="absent" in_executables=0 in_allow=0 key + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" == version:* ]] || continue + version="$(unquote "${line#version:}")" + break + done < "$PLUGIN_MANIFEST" + + while IFS= read -r line || [[ -n "$line" ]]; do + # Blank and full-line comments carry no structure at any depth. + case "$line" in + '' | '#'*) continue ;; + esac + # A top-level key (column 0) closes whatever block was open. Checked before + # anything else so `marketplace:` after `executables:` cannot leak keys in. + if [[ "$line" != [[:space:]]* ]]; then + if [[ "$line" == executables:* ]]; then + in_executables=1 + else + in_executables=0 + fi + in_allow=0 + continue + fi + [[ $in_executables -eq 1 ]] || continue + # 2-space indent: a key directly under executables:. `allow:` opens the + # mapping this gate reads; any sibling key closes it. + if [[ "$line" =~ ^\ \ [^[:space:]#] ]]; then + if [[ "$line" =~ ^\ \ allow: ]]; then + in_allow=1 + allow_state="present" + else + in_allow=0 + fi + continue + fi + # 4-space indent while inside allow: — an allow key. Keys contain '#' by + # construction ("kyberforge#1.5.0"), so a trailing-comment strip would eat + # them; there is none, and inline comments are not used on these lines. + if [[ $in_allow -eq 1 && "$line" =~ ^\ \ \ \ ([^[:space:]#][^:]*): ]]; then + key="$(unquote "${BASH_REMATCH[1]}")" + printf 'k\t%s\n' "$key" + fi + done < "$ROOT_MANIFEST" + + printf 'v\t%s\n' "$version" + printf 'a\t%s\n' "$allow_state" +} + +if command -v python3 > /dev/null 2>&1 && python3 -c 'import yaml' > /dev/null 2>&1; then + READER="python3 + PyYAML" + if ! FACTS="$(read_facts_python)"; then + echo "FAIL: could not read apm.yml / plugins/kyberforge/apm.yml (see the parser error above)." >&2 + echo " Why: this gate compares kyberforge's version against root apm.yml's executables.allow" >&2 + echo " key; an unreadable manifest means the comparison did not happen." >&2 + echo " Fix: make both manifests valid YAML, then re-run." >&2 + exit 1 + fi +else + READER="shape-scan fallback (PyYAML unavailable)" + FACTS="$(read_facts_bash)" +fi + +VERSION="$(printf '%s\n' "$FACTS" | sed -n 's/^v\t//p')" +ALLOW_STATE="$(printf '%s\n' "$FACTS" | sed -n 's/^a\t//p')" +ALLOW_KEYS="$(printf '%s\n' "$FACTS" | sed -n 's/^k\t//p')" + +if [[ -z "$VERSION" ]]; then + echo "FAIL: plugins/kyberforge/apm.yml declares no version:, so no allow key can be checked against it." >&2 + echo " Why: apm's executables.allow lookup is keyed on '#' exactly; with no" >&2 + echo " version there is nothing for root apm.yml's key to stay in sync with." >&2 + echo " Fix: give plugins/kyberforge/apm.yml a top-level version: (read by $READER)." >&2 + exit 1 +fi + +EXPECTED_KEY="kyberforge#$VERSION" + +if [[ "$ALLOW_STATE" != "present" ]]; then + echo "FAIL: root apm.yml has no executables.allow mapping, but plugins/kyberforge is version $VERSION." >&2 + echo " Why: without an allow entry apm refuses to deploy kyberforge's hooks/ and bin/, so the" >&2 + echo " SessionStart hook that keeps this install level with the remote never runs and the" >&2 + echo " deployed skills go stale silently (ADR-0019)." >&2 + echo " Fix: add to root apm.yml:" >&2 + echo " executables:" >&2 + echo " allow:" >&2 + echo " $EXPECTED_KEY:" >&2 + echo " hooks: true" >&2 + echo " bin: true" >&2 + exit 1 +fi + +if printf '%s\n' "$ALLOW_KEYS" | grep -qxF "$EXPECTED_KEY"; then + exit 0 +fi + +STALE_KEYS="$(printf '%s\n' "$ALLOW_KEYS" | grep '^kyberforge#' || true)" + +echo "FAIL: root apm.yml's executables.allow has no '$EXPECTED_KEY' key, but that is kyberforge's version." >&2 +if [[ -n "$STALE_KEYS" ]]; then + echo " Found instead:" >&2 + printf '%s\n' "$STALE_KEYS" | sed 's/^/ /' >&2 +fi +echo " Why: apm matches this key by exact dict lookup — there is no wildcard and no version-less" >&2 +echo " form — so a key naming any other version silently stops granting kyberforge's hooks/" >&2 +echo " and bin/. The SessionStart hook then stops deploying and the apm install goes stale" >&2 +echo " with no error anywhere (ADR-0019, 'The allow key is version-pinned')." >&2 +echo " Fix: bump the key in root apm.yml to '$EXPECTED_KEY:' — the version bump in" >&2 +echo " plugins/kyberforge/apm.yml is not complete without it." >&2 +exit 1 diff --git a/tests/test-check-executables-allow-sync.sh b/tests/test-check-executables-allow-sync.sh new file mode 100644 index 0000000..6dd7c3a --- /dev/null +++ b/tests/test-check-executables-allow-sync.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# Tests for scripts/check-executables-allow-sync.sh — the pre-push gate that +# keeps root apm.yml's executables.allow key level with kyberforge's version. +# +# Fixtures are two-file skeletons (root apm.yml + plugins/kyberforge/apm.yml) +# rather than copies of the real repo: the gate reads exactly those two files, +# and a hand-built fixture is the only way to construct the drift it exists to +# catch without editing the real manifests. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/check-executables-allow-sync.sh" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +# Same `exit 77` (automake convention; run-tests.sh renders it as SKIPPED) guard +# the vale suites use. The script itself runs fine without python3 — it falls +# back to a shape scan — but this suite asserts BOTH readers agree, and the +# PyYAML path cannot be exercised at all on a machine without it. Reporting +# those cases as failures would say "a regression landed" when the truth is +# "this machine is missing a dev dependency". +command -v python3 > /dev/null 2>&1 || { echo "SKIP: python3 is required to exercise the PyYAML reader"; exit 77; } +python3 -c 'import yaml' > /dev/null 2>&1 || { echo "SKIP: PyYAML is required to exercise the PyYAML reader"; exit 77; } + +FIXTURES=() +cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; } +trap cleanup EXIT + +# make_fixture +# The executables block is passed verbatim (may be empty) so a fixture can omit +# it entirely, which is one of the failure modes under test. +make_fixture() { + local version_line="$1" executables_block="$2" dir + dir="$(mktemp -d)" + FIXTURES+=("$dir") + mkdir -p "$dir/plugins/kyberforge" + + { + echo "name: kyberforge" + echo "$version_line" + echo "description: fixture" + } > "$dir/plugins/kyberforge/apm.yml" + + { + echo "name: ai-development" + echo "version: 0.0.1" + echo "dependencies:" + echo " apm:" + echo " - name: kyberforge" + [[ -n "$executables_block" ]] && printf '%s\n' "$executables_block" + # A top-level key after the block: the fallback reader must stop collecting + # allow keys here rather than reading on into the next section. + echo "marketplace:" + echo " owner:" + echo " name: fixture" + } > "$dir/apm.yml" + + printf '%s\n' "$dir" +} + +MATCHING_BLOCK='executables: + allow: + kyberforge#1.5.0: + hooks: true + bin: true' + +STALE_BLOCK='executables: + allow: + kyberforge#1.4.0: + hooks: true + bin: true' + +OTHER_PACKAGE_BLOCK='executables: + allow: + git#1.0.0: + hooks: true' + +# run_gate — echoes combined output, sets GATE_RC. +GATE_RC=0 +run_gate() { + GATE_RC=0 + bash "$SCRIPT" "$1" > /dev/null 2>&1 || GATE_RC=$? +} + +# --------------------------------------------------------------------------- +echo "--- the real repo passes ---" +# --------------------------------------------------------------------------- + +# The gate's whole value is that it is green on a correct tree and red on drift; +# a version bump landing in only one of the two real manifests must show up here. +run_gate "$REPO_ROOT" +[[ $GATE_RC -eq 0 ]] && pass "current repo state passes" \ + || fail "current repo state should pass — the gate said: $(bash "$SCRIPT" "$REPO_ROOT" 2>&1 | head -3)" + +# --------------------------------------------------------------------------- +echo "" +echo "--- matching version ---" +# --------------------------------------------------------------------------- + +F="$(make_fixture "version: 1.5.0" "$MATCHING_BLOCK")" +run_gate "$F" +[[ $GATE_RC -eq 0 ]] && pass "exits 0 when the allow key names the plugin's version" \ + || fail "should pass when key and version agree, got rc=$GATE_RC" + +F="$(make_fixture 'version: "1.5.0"' "$MATCHING_BLOCK")" +run_gate "$F" +[[ $GATE_RC -eq 0 ]] && pass "exits 0 when the version is quoted" \ + || fail "a quoted version must compare the same as an unquoted one, got rc=$GATE_RC" + +# --------------------------------------------------------------------------- +echo "" +echo "--- mismatched version ---" +# --------------------------------------------------------------------------- + +F="$(make_fixture "version: 1.5.0" "$STALE_BLOCK")" +run_gate "$F" +[[ $GATE_RC -ne 0 ]] && pass "fails when the allow key names a different version" \ + || fail "a stale allow key must fail the push" +OUT="$(bash "$SCRIPT" "$F" 2>&1 || true)" +grep -q "kyberforge#1.5.0" <<< "$OUT" && pass "names the key that should be there" \ + || fail "the failure must state the expected key" +grep -q "kyberforge#1.4.0" <<< "$OUT" && pass "names the stale key it found instead" \ + || fail "the failure must quote back the stale key" +grep -q "Why:" <<< "$OUT" && grep -q "Fix:" <<< "$OUT" && pass "uses the FAIL/Why/Fix message block" \ + || fail "message must carry Why: and Fix: lines" + +# --------------------------------------------------------------------------- +echo "" +echo "--- missing executables.allow ---" +# --------------------------------------------------------------------------- + +F="$(make_fixture "version: 1.5.0" "")" +run_gate "$F" +[[ $GATE_RC -ne 0 ]] && pass "fails when there is no executables block at all" \ + || fail "a missing executables.allow must fail — apm deploys no hooks without it" +OUT="$(bash "$SCRIPT" "$F" 2>&1 || true)" +grep -q "executables:" <<< "$OUT" && grep -q "kyberforge#1.5.0" <<< "$OUT" \ + && pass "shows the block to add" || fail "the failure must show the block to add" + +# An `executables:` key that is not a mapping is the same hole as no key at all. +F="$(make_fixture "version: 1.5.0" "executables:")" +run_gate "$F" +[[ $GATE_RC -ne 0 ]] && pass "fails when executables: exists but allow: does not" \ + || fail "an empty executables: block grants nothing and must fail" + +# --------------------------------------------------------------------------- +echo "" +echo "--- allow present, kyberforge key missing ---" +# --------------------------------------------------------------------------- + +F="$(make_fixture "version: 1.5.0" "$OTHER_PACKAGE_BLOCK")" +run_gate "$F" +[[ $GATE_RC -ne 0 ]] && pass "fails when allow: exists but names no kyberforge key" \ + || fail "an allow block covering only other packages must still fail" + +# --------------------------------------------------------------------------- +echo "" +echo "--- degenerate inputs fail loudly rather than passing silently ---" +# --------------------------------------------------------------------------- + +run_gate "$REPO_ROOT/definitely-not-a-directory" +[[ $GATE_RC -ne 0 ]] && pass "fails on a nonexistent REPO_ROOT" \ + || fail "a bad path must not exit 0 — that reads as 'checked, in sync'" + +# No kyberforge plugin at all is the one legitimate no-op: nothing to pin. +NO_PLUGIN="$(mktemp -d)"; FIXTURES+=("$NO_PLUGIN") +echo "name: someone-else" > "$NO_PLUGIN/apm.yml" +run_gate "$NO_PLUGIN" +[[ $GATE_RC -eq 0 ]] && pass "no-ops in a repo with no kyberforge plugin" \ + || fail "a repo without plugins/kyberforge/ has nothing to check" + +# ...but a kyberforge directory with no manifest is drift, not a no-op. +mkdir -p "$NO_PLUGIN/plugins/kyberforge" +run_gate "$NO_PLUGIN" +[[ $GATE_RC -ne 0 ]] && pass "fails when plugins/kyberforge/ has no apm.yml" \ + || fail "a plugin dir with no manifest must not silently pass" + +F="$(make_fixture "description: no version here" "$MATCHING_BLOCK")" +run_gate "$F" +[[ $GATE_RC -ne 0 ]] && pass "fails when the plugin manifest declares no version" \ + || fail "no version means nothing to compare — must fail, not pass" + +# --------------------------------------------------------------------------- +echo "" +echo "--- the fallback reader agrees with the PyYAML reader ---" +# --------------------------------------------------------------------------- + +# PyYAML is deliberately not a hard dependency of this gate (no other pre-push +# hook needs it), so the shape-scan fallback carries the same verdicts. Masking +# is done with a python3 stub whose `import yaml` fails, which is the exact +# condition on a machine that has python3 without PyYAML. +NO_YAML_BIN="$(mktemp -d)"; FIXTURES+=("$NO_YAML_BIN") +printf '#!/usr/bin/env bash\nexit 1\n' > "$NO_YAML_BIN/python3" +chmod +x "$NO_YAML_BIN/python3" + +run_fallback() { + GATE_RC=0 + PATH="$NO_YAML_BIN:$PATH" bash "$SCRIPT" "$1" > /dev/null 2>&1 || GATE_RC=$? +} + +F="$(make_fixture "version: 1.5.0" "$MATCHING_BLOCK")" +run_fallback "$F" +[[ $GATE_RC -eq 0 ]] && pass "fallback passes a matching fixture" || fail "fallback should pass when in sync" + +F="$(make_fixture 'version: "1.5.0"' "$MATCHING_BLOCK")" +run_fallback "$F" +[[ $GATE_RC -eq 0 ]] && pass "fallback strips quotes from the version" || fail "fallback mishandled a quoted version" + +F="$(make_fixture "version: 1.5.0" "$STALE_BLOCK")" +run_fallback "$F" +[[ $GATE_RC -ne 0 ]] && pass "fallback fails a stale key" || fail "fallback missed a stale key" + +F="$(make_fixture "version: 1.5.0" "")" +run_fallback "$F" +[[ $GATE_RC -ne 0 ]] && pass "fallback fails a missing executables block" || fail "fallback missed a missing block" + +F="$(make_fixture "version: 1.5.0" "$OTHER_PACKAGE_BLOCK")" +run_fallback "$F" +[[ $GATE_RC -ne 0 ]] && pass "fallback fails when no kyberforge key is present" || fail "fallback missed an absent key" + +run_fallback "$REPO_ROOT" +[[ $GATE_RC -eq 0 ]] && pass "fallback passes the real repo" || fail "fallback disagrees with PyYAML on the real repo" + +# --------------------------------------------------------------------------- +echo "" +echo "--- wired into the pre-push gate ---" +# --------------------------------------------------------------------------- + +# A gate nobody runs is not a gate; this is the only assertion that the script +# is actually reachable from `git push`. +CONFIG="$REPO_ROOT/.pre-commit-config.yaml" +grep -q "id: check-executables-allow-sync" "$CONFIG" \ + && pass ".pre-commit-config.yaml declares the hook" || fail "hook is not declared in .pre-commit-config.yaml" +grep -q "scripts/check-executables-allow-sync.sh" "$CONFIG" \ + && pass ".pre-commit-config.yaml points at the script" || fail "hook does not reference the script path" + +# --------------------------------------------------------------------------- +echo "" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]]