Files
homelab/scripts/ci/fetch-models.sh
T
claude-botandClaude Opus 4.8 f333dfd606
ci/woodpecker/pr/pr-review Pipeline is running
fix(ci): block flag injection via agent-supplied model id (pr-review)
The pr-review triage agent reads the attacker-controlled PR diff and writes
triage.json; its Write tool is not path-scoped, so a prompt-injected diff can
make it emit any `model` string. The catalog validation used
`grep -qxF -- "$MODEL"`, and grep -F treats a MULTI-LINE operand as
newline-separated patterns: a value like
"claude-sonnet-5\n--dangerously-skip-permissions" matches on its first line,
passes validation, and -- via the unquoted `${MODEL:+--model $MODEL}` expansion
-- word-splits into an extra argv entry, injecting a CLI flag into the review
agent's own `claude` invocation (defeating its --allowedTools allowlist and Read
sandbox, with CLAUDE_CODE_OAUTH_TOKEN in that process's env).

Removing map_model() (which returned one of three literal ids) is what opened
this; restore the invariant with a single-token guard before the expansion:
`case "$MODEL" in ""|*[!A-Za-z0-9._-]*) MODEL= ;; esac`. Verified it turns the
newline/space/hallucinated payloads into a plain `claude -p` (CLI default) while
still passing real ids. Mirrored the guard in pick-model.sh as defence in depth
(its grep -oE already keeps CHOICE single-line, so it was not exploitable).

Also from the review:
- pr-review.yml: correct the HOMELAB comment -- the fetch-models.sh call is
  env -u REVIEW_TOKEN'd, so CLAUDE_CODE_OAUTH_TOKEN (not REVIEW_TOKEN) is what
  branch-supplied shell there can see.
- pick-model.sh / triage-prompt.md: stop asking the picker to weigh "cost" the
  catalog does not expose; reason from the visible capability signals (context /
  max_output / effort|thinking|code_exec) as the scale proxy instead.
- fetch-models.sh: drop 2>/dev/null on curl so its -S error reaches the log.

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

103 lines
4.3 KiB
Bash
Executable File

#!/bin/sh
# Fetch the list of Claude models currently available to this account.
#
# The `claude` CLI has no "list models" subcommand, so the authoritative
# source is the same catalog the CLI itself uses: the Anthropic Models API
# (GET /v1/models), authenticated with the very CLAUDE_CODE_OAUTH_TOKEN the CI
# agents already run under. This lets the pipelines pick a model from what is
# live *today*, judged on the API's own capability metadata — no hardcoded
# model ids and no baked-in assumptions about model families. A model released
# tomorrow shows up here and becomes selectable the same day.
#
# Usage:
# fetch-models.sh -> compact JSON array (newest first)
# fetch-models.sh --table -> the same data as a tab-separated table, one
# model per line: the single format every
# selector consumes (id is column 1).
#
# JSON fields / table columns (objective signals a selector can weigh):
# id, name, released=YYYY-MM-DD, context=N, max_output=N,
# effort=bool, thinking=bool, code_exec=bool
#
# Diagnostics go to stderr so `$(...)` capture stays clean.
# Exit status: 0 with non-empty output on success; non-zero on any failure
# (missing token, HTTP error, empty/garbage body) — the caller decides how
# to fall back.
set -eu
MODE="${1:-json}" # json (default) | --table
log() { echo "fetch-models: $*" >&2; }
TOKEN="${CLAUDE_CODE_OAUTH_TOKEN:-}"
if [ -z "$TOKEN" ]; then
log "CLAUDE_CODE_OAUTH_TOKEN is not set"
exit 1
fi
BODY="$(mktemp)"
trap 'rm -f "$BODY"' EXIT
# limit=1000 (the API max): the default page size is 20 and the order is
# newest-first, so without this the OLDEST/cheapest models — the ones trivial
# work should route to — would be silently dropped once the account lists >20.
# oauth-2025-04-20 is the beta header the subscription OAuth token is minted
# for; without it the API rejects a Bearer (as opposed to x-api-key) caller.
HTTP="$(curl -sS -o "$BODY" -w '%{http_code}' \
--max-time 20 \
'https://api.anthropic.com/v1/models?limit=1000' \
-H "Authorization: Bearer $TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: oauth-2025-04-20")" || HTTP=000
if [ "$HTTP" != "200" ]; then
log "GET /v1/models returned HTTP $HTTP"
exit 1
fi
# A truncated first page would mean the pipelines silently ignore older models;
# surface it rather than fail (limit=1000 makes it practically impossible).
if [ "$(jq -r '.has_more' "$BODY" 2>/dev/null)" = "true" ]; then
log "WARNING: /v1/models reported has_more=true — catalog is larger than one page"
fi
# Keep only objective signals a selector can reason from; preserve API order
# (newest first). No id patterns, no family names — just the facts the API
# reports about each model. jq stderr is surfaced so a response-schema change
# shows up as a real error, not just the generic "no usable model list".
OUT="$(jq -c '[.data[] | {
id,
name: .display_name,
released: (.created_at[0:10]),
context: .max_input_tokens,
max_output: .max_tokens,
effort: (.capabilities.effort.supported // false),
thinking: (.capabilities.thinking.supported // false),
code_exec: (.capabilities.code_execution.supported // false)
}]' "$BODY")" || { log "could not parse /v1/models response"; exit 1; }
if [ -z "$OUT" ] || [ "$OUT" = "[]" ]; then
log "response held no usable model list"
exit 1
fi
# jq does not error when a capability field simply goes missing — .max_input_tokens
# becomes null and the `// false` defaults swallow the rest. If EVERY row lost its
# context window the API shape has changed and both selectors are left with only
# name + date to reason from. Degrade rather than fail, but say so loudly.
if [ "$(printf '%s' "$OUT" | jq -r 'all(.[]; .context == null)')" = "true" ]; then
log "WARNING: no model reports a context window — /v1/models may have changed shape; capability signals are unavailable this run"
fi
case "$MODE" in
--table)
printf '%s' "$OUT" | jq -r '.[]
| "\(.id)\t\(.name)\treleased=\(.released)\tcontext=\(.context)\tmax_output=\(.max_output)\teffort=\(.effort)\tthinking=\(.thinking)\tcode_exec=\(.code_exec)"'
;;
json)
printf '%s\n' "$OUT"
;;
*)
log "unknown mode '$MODE' (use --table or nothing)"; exit 2
;;
esac