Files
homelab/scripts/ci/test-model-selection.sh
claude-botandClaude Opus 4.8 1992fabb12
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
fix(ci): make model-picker actually tool-less; gate tests on PRs
Addresses the 14aa6415 review.

- pick-model.sh: the picker relied on `--allowedTools ""` to be tool-less, but an
  empirical check showed an empty allowlist does NOT block tools (the Write tool
  still ran) -- it is treated as "unset" and the default toolset stays live. Add
  `--permission-mode plan`, which IS verified to block Write while still producing
  the selection JSON. --allowedTools "" + the Read deny-list remain as additional
  layers. Corrected the comments in pick-model.sh and the picker settings.json
  that claimed the empty allowlist made it tool-less.
- .woodpecker/ci-scripts-test.yml: new pull_request-triggered pipeline (homelab
  only) that runs scripts/ci/test-model-selection.sh, so a PR weakening the
  injection gate fails checks immediately instead of only on the next scheduled
  e2e cron (closes the coverage-timing gap the review flagged).
- Pin a recorded real /v1/models response (scripts/ci/testdata/v1-models-sample.json)
  and assert fetch-models.sh maps it correctly (opus effort=true, haiku
  effort=false), documenting the verified response shape.

Deferred with rationale: hard-failing a pipeline when the degraded-schema warning
fires -- the warning already reaches the job log (curl/jq stderr is no longer
suppressed), and hard-failing on a benign API schema addition would break all
model selection; graceful degradation to name+date is the deliberate choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 09:08:39 +02:00

93 lines
5.2 KiB
Bash
Executable File

#!/bin/sh
# Offline self-test for the model-selection validation gate (valid-model.sh).
# No network, no `claude`, no Woodpecker — just the string logic that decides
# whether an (untrusted) agent-supplied model id is allowed to reach a --model
# flag. This is the regression test for the flag-injection fix.
#
# Run: sh scripts/ci/test-model-selection.sh
set -eu
HERE="$(dirname "$0")"
CATALOG="$(printf '%s\n' \
'claude-opus-5 Claude Opus 5 released=2026-07-24' \
'claude-sonnet-5 Claude Sonnet 5 released=2026-06-29' \
'claude-haiku-4-5-20251001 Claude Haiku 4.5 released=2025-10-15')"
fails=0
check() { # description candidate expected-output
got="$(printf '%s\n' "$CATALOG" | sh "$HERE/valid-model.sh" "$2" || true)"
if [ "$got" = "$3" ]; then
printf 'ok %s\n' "$1"
else
printf 'FAIL %s: expected [%s] got [%s]\n' "$1" "$3" "$got"
fails=$((fails + 1))
fi
}
check "valid id passes" "claude-opus-5" "claude-opus-5"
check "valid dated id passes" "claude-haiku-4-5-20251001" "claude-haiku-4-5-20251001"
check "hallucinated id rejected" "claude-made-up-9" ""
check "empty rejected" "" ""
check "space injection rejected" "claude-opus-5 --dangerously-skip-permissions" ""
check "newline injection rejected" "$(printf 'claude-opus-5\n--dangerously-skip-permissions')" ""
check "leading-dash rejected" "--dangerously-skip-permissions" ""
check "path/quote chars rejected" 'claude-opus-5;rm -rf /' ""
# Empty catalog rejects everything (the fetch-failed / fallback case).
got="$(printf '' | sh "$HERE/valid-model.sh" "claude-opus-5" || true)"
if [ -z "$got" ]; then printf 'ok empty catalog rejects all\n'; else printf 'FAIL empty catalog: got [%s]\n' "$got"; fails=$((fails + 1)); fi
# Regression for the models.txt-writeback attack: pr-review must validate the
# triage's pick against the catalog snapshot captured BEFORE the (untrusted,
# unscoped-Write) triage agent runs — never a file the agent could overwrite.
# valid-model.sh takes the catalog on stdin precisely so the caller controls it;
# a token the attacker slipped into an on-disk file but that is NOT in the
# pristine snapshot passed here must be rejected.
pristine="$(printf '%s\n' 'claude-opus-5 Claude Opus 5' 'claude-sonnet-5 Claude Sonnet 5')"
got="$(printf '%s\n' "$pristine" | sh "$HERE/valid-model.sh" "claude-smuggled-weak" || true)"
if [ -z "$got" ]; then printf 'ok tampered id rejected by pristine-snapshot validation\n'; else printf 'FAIL writeback attack: got [%s]\n' "$got"; fails=$((fails + 1)); fi
# fetch-models.sh jq mapping — exercised over recorded /v1/models bodies via the
# FETCH_MODELS_BODY_FILE seam (no token, no network). Pins the response shape the
# selectors depend on, so an API change surfaces here instead of silently
# degrading both selectors to name+date.
FX="$(mktemp)"
trap 'rm -f "$FX"' EXIT
printf '%s' '{"data":[{"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5","created_at":"2026-07-24T00:00:00Z","max_input_tokens":1000000,"max_tokens":128000,"capabilities":{"effort":{"supported":true},"thinking":{"supported":true},"code_execution":{"supported":true}}}],"has_more":false}' > "$FX"
row="$(FETCH_MODELS_BODY_FILE="$FX" sh "$HERE/fetch-models.sh" --table 2>/dev/null || true)"
case "$row" in
*"claude-opus-5"*"released=2026-07-24"*"context=1000000"*"max_output=128000"*"effort=true"*"thinking=true"*"code_exec=true"*)
printf 'ok well-formed /v1/models body maps to non-null capability columns\n' ;;
*) printf 'FAIL well-formed mapping: got [%s]\n' "$row"; fails=$((fails + 1)) ;;
esac
printf '%s' '{"data":[{"type":"model","id":"claude-x","display_name":"Claude X","created_at":"2026-01-01T00:00:00Z"}],"has_more":false}' > "$FX"
warn="$(FETCH_MODELS_BODY_FILE="$FX" sh "$HERE/fetch-models.sh" --table 2>&1 >/dev/null || true)"
case "$warn" in
*"no model reports a context window"*) printf 'ok missing capability fields warn loudly (schema-change guard)\n' ;;
*) printf 'FAIL degraded-schema warning: got [%s]\n' "$warn"; fails=$((fails + 1)) ;;
esac
# Recorded REAL /v1/models response (the shape verified live, 2026-07-25) mapped
# through the same seam — pins the actual field layout the selectors depend on,
# so a change to what fetch-models.sh extracts is caught here.
rows="$(FETCH_MODELS_BODY_FILE="$HERE/testdata/v1-models-sample.json" sh "$HERE/fetch-models.sh" --table 2>/dev/null || true)"
opusline="$(printf '%s\n' "$rows" | grep 'claude-opus-5' || true)"
haikuline="$(printf '%s\n' "$rows" | grep 'claude-haiku-4-5' || true)"
o=FAIL; h=FAIL
case "$opusline" in *"context=1000000"*"effort=true"*"thinking=true"*) o=ok ;; esac
case "$haikuline" in *"context=200000"*"effort=false"*) h=ok ;; esac
if [ "$o" = ok ] && [ "$h" = ok ]; then
printf 'ok recorded real /v1/models fixture maps correctly (opus effort=true, haiku effort=false)\n'
else
printf 'FAIL recorded fixture: opus=[%s] haiku=[%s]\n' "$opusline" "$haikuline"; fails=$((fails + 1))
fi
if [ "$fails" -eq 0 ]; then
echo "All model-selection tests passed."
else
echo "$fails test(s) FAILED."
exit 1
fi