Files
boshu2__agentops/scripts/check-skill-python-ratchet.sh
Bo aceeb6f10f feat(gates): enforce ADR-0016 shipped-Python rule; fix RPI/Validate digest disagreement (#995)
Two fixes from the 2026-07-25 skill-overhaul progress review, both independent
of the unlanded tranche fleet.

1. skill.python-ratchet — the gate ADR-0016 said it already had. Section 3
   fixed "Python never ships in skills" on 2026-07-18 and called its own
   violation "a gate failure, not a style nit", while zero checks enforced it.
   Now a blocking shrink-only ratchet over skills/*/scripts/**/*.py: the 24
   current files are pinned, new ones hard-fail, a promoted file must be
   pruned, and the growth guard rejects a change that allowlists itself. The
   surviving count prints on every run. ADR-0016 records the enforcing check by
   name and the skills/*/tests/** carve-out with its rationale, so the
   exemption cannot decay into the same inert prose.

2. The RPI/Validate acceptance-digest disagreement. run_once.py digested a
   canonical-JSON re-serialization of the parsed intent mapping, validate.py
   digested the raw intent bytes, and run_once.py hard-compared the two, so the
   composed contract could not succeed. Both suites were green because RPI's
   test mocked Validate with RPI's own digest function. RPI stops being a
   second digest authority: it carries the digest Plan declares over the bytes
   Plan snapshotted and cross-checks Validate's independently re-derived value.

3. The cathedral-cut probe, the same blind spot found again. It set
   intent_bytes = canonical_bytes(resolved_intent) — precisely the one input
   where the two digests coincide — so like the unit test it was built around
   the coincidence and could not observe the bug.

Test shape is liveness-first: every gate negative is a seeded witness the gate
must be shown to FAIL on (12/12 bats), and the digest fix landed against a RED
witness that reproduces the real production error.
2026-07-25 18:37:41 -04:00

202 lines
9.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# check-skill-python-ratchet.sh — ADR-0016 shipped-Python ratchet.
#
# WHY: ADR-0016 section 3 fixes the division rule without hedging — "Python
# never ships in skills" (line 97), and "Shipping Python inside a skill is a
# gate failure, not a style nit" (line 123). It called itself a gate failure
# while NO gate existed. A sweep of scripts/ and cli/internal/gates found zero
# enforcement, which is the same disease the repo has already diagnosed
# elsewhere: a rule that exists only as text changes no behavior. This is the
# gate. Interpreter dependencies on the user execution path are what the ADR is
# protecting against — a single static binary IS the distribution story, and
# "deterministic verification" degrades into "deterministic, if your
# interpreter, venv, and PATH cooperate" once Python ships to strangers'
# machines.
#
# WHAT (zero-churn ratchet — never rewrites the existing tree):
# For each ADDED or MODIFIED `skills/*/scripts/**/*.py` in the changed scope
# that is NOT on the grandfather snapshot, FAIL. Grandfathered files are
# exempt UNTIL they are promoted into `ao` (or otherwise removed) — at which
# point their line must be PRUNED from the snapshot (the allowlist only
# shrinks). The surviving grandfathered count is printed on every run so the
# number is visible and can only ratchet down.
#
# SCOPE — the user EXECUTION path only, and deliberately so:
# * `skills/*/scripts/**/*.py` is governed. This is what a skill actually
# invokes on a user's machine, so it is what carries the interpreter
# dependency the ADR forbids.
# * `skills/*/tests/**` is EXEMPT AS A CLASS. Test code never executes on a
# user's machine, so the interpreter-state argument does not reach it. That
# is a recorded amendment to ADR-0016 with its rationale
# (docs/adr/ADR-0016-state-tiers.md, "Amendment 2026-07-25 (tests)"), NOT an
# unstated exception — an unrecorded carve-out would be the same inert-prose
# defect this gate exists to fix.
# * `skills-codex/**` is EXEMPT AS A CLASS: it is a generated projection of
# `skills/**` (regenerated by scripts/regen-all.sh). Governing a projection
# would fail the same violation twice and could not be repaired
# independently of its source. Projections are never authoritative — that is
# ADR-0016's own title.
#
# REPAIR: shared mechanism goes into the `ao` binary as one owner; skill-specific
# parsing and contract logic becomes an `ao` subcommand the skill invokes through
# `sh` glue. ADR-0016's promotion bar and the scratch-tier prototype path exist
# precisely so this rule has no cost. If a file genuinely cannot be promoted,
# that case is made per file as an ADR amendment — not by widening the
# allowlist, which the growth guard rejects.
#
# Usage:
# bash scripts/check-skill-python-ratchet.sh # scope: auto
# bash scripts/check-skill-python-ratchet.sh --scope head # push scope
# bash scripts/check-skill-python-ratchet.sh --scope worktree
#
# Exit codes:
# 0 - pass / not applicable
# 1 - new Python on a skill's execution path, OR a grandfathered entry that no
# longer exists (snapshot must shrink), OR the grandfather snapshot GAINED
# entries vs. its base-ref version (allowlist additions are rejected)
# 2 - usage error / unreadable snapshot / unresolvable base ref (fail-closed)
# shellcheck disable=SC1007
. "$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/preamble.sh"
cd "$REPO_ROOT" || exit 1
# Shared shrink-only ratchet mechanics (changed-scope collection, base-ref
# snapshot, growth guard, intersection authority). Parse mode `raw` matches the
# snapshot format: one path per line, `#` comments and blanks stripped.
. "$REPO_ROOT/scripts/lib/ratchet.sh"
GRANDFATHER="scripts/.skill-python-grandfather"
SCOPE="auto"
while [[ $# -gt 0 ]]; do
case "$1" in
--scope)
shift
[[ $# -gt 0 ]] || { echo "--scope requires a value" >&2; exit 2; }
SCOPE="$1"
;;
--scope=*) SCOPE="${1#--scope=}" ;;
-h|--help) sed -n '2,56p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
shift
done
case "$SCOPE" in
head|staged|worktree|upstream|auto) ;;
*) echo "Invalid --scope: $SCOPE (want head|staged|worktree|upstream|auto)" >&2; exit 2 ;;
esac
# governed PATH → 0 if the path is Python on a skill's user execution path.
# `skills/<slug>/scripts/**/*.py` at any nesting depth (reverse-engineer keeps
# scripts/binary/*.py), never skills-codex/** and never skills/*/tests/**.
governed() {
# ANCHORED REGEX, not a `==` glob: inside `[[ ]]` a `*` matches `/` too, so
# `skills/*/scripts/*.py` would also match `skills/x/tests/scripts/y.py` and
# silently govern the exempt tests class. `[^/]+` pins the slug to exactly one
# segment; `.+` after `scripts/` keeps arbitrary nesting governed
# (reverse-engineer ships scripts/binary/*.py) — depth is not an escape hatch.
[[ "$1" =~ ^skills/[^/]+/scripts/.+\.py$ ]]
}
# still_exists ENTRY → 0 if the grandfathered path is still in the tree. Used as
# the stale predicate: a promoted/removed file must be pruned from the snapshot.
still_exists() {
# Invoked by NAME through ratchet_stale_entries_by, so shellcheck cannot see
# the call site and reports the body as unreachable (SC2317, info-level).
# shellcheck disable=SC2317
[[ -f "$1" ]]
}
check_grandfather_shrink_only() {
local added rc_=0
added="$(ratchet_assert_shrink_only "$GRANDFATHER" raw)" || rc_=$?
[[ "$rc_" -eq 2 ]] && exit 2
if [[ "$rc_" -ne 0 ]]; then
echo "FAIL: $GRANDFATHER gained new entries — the grandfather list only SHRINKS." >&2
echo " New Python cannot be allowlisted (ADR-0016 section 3). Route the logic" >&2
echo " into an \`ao\` subcommand and invoke it through \`sh\` glue. Added entries:" >&2
while IFS= read -r line; do
echo " $line" >&2
done <<< "$added"
return 1
fi
return 0
}
tmp_changed="$(mktemp -d "${TMPDIR:-/tmp}/skill-python-ratchet.XXXXXX")"
trap 'rm -rf "$tmp_changed"' EXIT
ratchet_changed_files_status "$SCOPE" > "$tmp_changed/raw" 2>/dev/null || true
shipped=0 # new Python on a skill execution path
stale=0 # grandfathered entries that no longer exist (must prune)
grow=0 # snapshot gained entries (allowlist only shrinks)
ratchet_load_base "$GRANDFATHER" "$(ratchet_base_ref "$SCOPE")" || exit 2
if ! check_grandfather_shrink_only; then
grow=1
fi
while IFS=$'\t' read -r status path rest; do
[[ -n "${status:-}" && -n "${path:-}" ]] || continue
# Renames arrive as "R<score>\told\tnew"; git puts the NEW path in `rest`.
if [[ "$status" == R* && -n "${rest:-}" ]]; then
path="$rest"
status="A"
fi
# Only ADDED or MODIFIED files are governed. Deletions (D) are never a
# violation — removing shipped Python is the direction this gate wants.
case "$status" in
A|M) ;;
*) continue ;;
esac
governed "$path" || continue
# Staged-then-deleted edge: skip anything not present in the working tree.
[[ -f "$path" ]] || continue
ratchet_rc=0
ratchet_is_pinned "$path" "$GRANDFATHER" raw || ratchet_rc=$?
[[ "$ratchet_rc" -eq 2 ]] && exit 2
[[ "$ratchet_rc" -eq 0 ]] && continue
echo "FAIL: $path ships Python on a skill's user execution path (ADR-0016 section 3)." >&2
echo " \"Python never ships in skills\" — interpreter dependencies break" >&2
echo " determinism on user machines. Route this logic into the \`ao\` binary and" >&2
echo " invoke it from the skill through \`sh\` glue; prototype in the scratch tier" >&2
echo " first if it is not yet proven. Adding the path to $GRANDFATHER is" >&2
echo " NOT a repair — the growth guard rejects it." >&2
shipped=$((shipped + 1))
done < "$tmp_changed/raw"
# Shrink direction: a pinned entry whose file is gone was promoted (or deleted)
# and must leave the snapshot, so the count can only ratchet down.
stale_rc=0
stale_list="$(ratchet_stale_entries_by still_exists "$GRANDFATHER" raw)" || stale_rc=$?
[[ "$stale_rc" -eq 2 ]] && exit 2
if [[ -n "$stale_list" ]]; then
echo "FAIL: $GRANDFATHER pins path(s) that no longer exist — prune them so the" >&2
echo " allowlist ratchets down:" >&2
while IFS= read -r line; do
[[ -n "$line" ]] || continue
echo " $line" >&2
stale=$((stale + 1))
done <<< "$stale_list"
fi
# Visible count: the whole point of a ratchet is that the number is public and
# monotone. Print it on pass and fail alike (audit P1.1).
pinned_count="$(ratchet_load_pinned "$GRANDFATHER" raw | grep -c . || true)"
if [[ "$shipped" -gt 0 || "$stale" -gt 0 || "$grow" -gt 0 ]]; then
echo "" >&2
echo "skill-python-ratchet: $shipped new shipped-Python file(s); $stale stale grandfather entry/entries; snapshot-grew=$grow." >&2
echo "skill-python-ratchet: $pinned_count grandfathered file(s) remain on the execution path." >&2
exit 1
fi
echo "PASS: skill-python ratchet — no new Python on any skill's execution path."
echo " $pinned_count grandfathered file(s) remain (ADR-0016 section 3; shrink-only)."
exit 0