ci/woodpecker/pr/pr-review Pipeline is running
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>
103 lines
4.3 KiB
Bash
Executable File
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
|