feat(ci): select agent model from the live catalog, not hardcoded ids #395

Merged
k.renner merged 8 commits from claude-bot/ci-dynamic-model-selection into master 2026-07-25 08:41:40 +00:00
Member

What

All five automatic CI agents now choose their model from the live Anthropic
model catalog instead of hardcoded ids.

Previously pr-review, update-audit, security-scan, alert-monitor and
issue-fix pinned --model claude-sonnet-5 (and pr-review mapped a
haiku/sonnet/opus keyword to dated ids via map_model). Those pins go stale on
every model release and hardcode which model families are assumed to exist, so a
newly released model could never be selected without a code edit.

How

  • scripts/ci/fetch-models.sh — the claude CLI has no "list models"
    command, so this queries the same catalog the CLI itself uses
    (GET /v1/models) with the existing CLAUDE_CODE_OAUTH_TOKEN (requires the
    anthropic-beta: oauth-2025-04-20 header). Emits each model with objective
    capability signals — context window, max output, effort, thinking, code_exec,
    release date — with no id patterns and no family names.
  • scripts/ci/pick-model.sh — a triage agent picks the best-fit id from that
    catalog for a one-line task description, weighing every listed model on its
    merits, and validates the choice via scripts/ci/valid-model.sh. Used by the
    four fixed jobs.
  • scripts/ci/valid-model.sh — the single, unit-tested gate: echoes a
    candidate id only if it is a safe [A-Za-z0-9._-] token (no leading dash) that
    is present in the live catalog. Reused by both pick-model.sh and pr-review so
    an untrusted, agent-supplied value can never reach a --model flag.
  • pr-review — the existing per-PR triage agent now reads the live catalog
    (./.pr-review/models.txt) and returns a concrete id; map_model is removed.

Guarantees

  • No model id, and no model family, is baked into any pipeline. A model released
    tomorrow, of any family, is eligible the day the API lists it.
  • The bootstrap selection call runs on the CLI default model; if the catalog is
    ever unreachable, --model is simply omitted (CLI default) rather than
    falling back to a named model — a run never dies over model selection.
  • Every failure path (no token, bad token, hallucinated id, empty catalog)
    degrades to the CLI default.
  • For a complex / security-sensitive pr-review, the (injectable) triage pick
    is ignored and the review runs on the CLI default — the strongest configured
    model — so an injected diff cannot steer a sensitive review onto a weak model.

Properties & tradeoffs (cost)

These pipelines no longer carry a fixed model-cost ceiling — by design, cost now
tracks whatever the account's live catalog and default model are:

  • The pr-review triage lost its pinned Haiku and runs on the CLI default
    model. Its token count is bounded (it reads stats.txt + a complete
    files.txt + a diff capped at 4000 lines, not the up-to-20k review diff), but
    the per-token rate is the account default.
  • Each of the four fixed jobs adds one extra claude call per run (the
    picker, on the default model) to choose the model. For jobs that already
    describe themselves as needing "a capable model", that bootstrap can cost more
    than it saves; it is accepted as the price of removing hardcoded family
    knowledge. Worth measuring on the 15-minute alert-monitor cadence (gated to
    alert-bearing runs).

Testing

  • scripts/ci/test-model-selection.sh — offline unit tests (no network / claude
    / Woodpecker) for the validation gate and the fetch-models.sh response
    mapping: valid ids pass; hallucinated / empty / space- / newline- / leading-
    dash- / metachar-injection are rejected; empty catalog rejects all; a
    well-formed /v1/models body maps to non-null capability columns; a degraded
    body warns loudly. Wired into .woodpecker/e2e.yml so it runs in CI.
  • All helpers pass sh -n; all touched pipelines parse under yq.
  • Verified live: /v1/models returns HTTP 200 with the OAuth subscription token,
    the capability fields are populated, a trivial task selects a small model and a
    security task the strongest/newest, and every failure path degrades to the CLI
    default.

🤖 Generated with Claude Code

## What All five automatic CI agents now choose their model from the **live** Anthropic model catalog instead of hardcoded ids. Previously `pr-review`, `update-audit`, `security-scan`, `alert-monitor` and `issue-fix` pinned `--model claude-sonnet-5` (and pr-review mapped a haiku/sonnet/opus keyword to dated ids via `map_model`). Those pins go stale on every model release and hardcode which model families are assumed to exist, so a newly released model could never be selected without a code edit. ## How - **`scripts/ci/fetch-models.sh`** — the `claude` CLI has no "list models" command, so this queries the same catalog the CLI itself uses (`GET /v1/models`) with the existing `CLAUDE_CODE_OAUTH_TOKEN` (requires the `anthropic-beta: oauth-2025-04-20` header). Emits each model with objective capability signals — context window, max output, effort, thinking, code_exec, release date — with no id patterns and no family names. - **`scripts/ci/pick-model.sh`** — a triage agent picks the best-fit id from that catalog for a one-line task description, weighing every listed model on its merits, and validates the choice via `scripts/ci/valid-model.sh`. Used by the four fixed jobs. - **`scripts/ci/valid-model.sh`** — the single, unit-tested gate: echoes a candidate id only if it is a safe `[A-Za-z0-9._-]` token (no leading dash) that is present in the live catalog. Reused by both `pick-model.sh` and pr-review so an untrusted, agent-supplied value can never reach a `--model` flag. - **pr-review** — the existing per-PR triage agent now reads the live catalog (`./.pr-review/models.txt`) and returns a concrete id; `map_model` is removed. ## Guarantees - No model id, and no model family, is baked into any pipeline. A model released tomorrow, of any family, is eligible the day the API lists it. - The bootstrap selection call runs on the CLI default model; if the catalog is ever unreachable, `--model` is simply omitted (CLI default) rather than falling back to a named model — a run never dies over model selection. - Every failure path (no token, bad token, hallucinated id, empty catalog) degrades to the CLI default. - For a **complex / security-sensitive** pr-review, the (injectable) triage pick is ignored and the review runs on the CLI default — the strongest configured model — so an injected diff cannot steer a sensitive review onto a weak model. ## Properties & tradeoffs (cost) These pipelines no longer carry a fixed model-cost ceiling — by design, cost now tracks whatever the account's live catalog and default model are: - The pr-review triage lost its pinned Haiku and runs on the CLI **default** model. Its token count is bounded (it reads `stats.txt` + a complete `files.txt` + a diff capped at 4000 lines, not the up-to-20k review diff), but the per-token rate is the account default. - Each of the four fixed jobs adds **one** extra `claude` call per run (the picker, on the default model) to choose the model. For jobs that already describe themselves as needing "a capable model", that bootstrap can cost more than it saves; it is accepted as the price of removing hardcoded family knowledge. Worth measuring on the 15-minute alert-monitor cadence (gated to alert-bearing runs). ## Testing - `scripts/ci/test-model-selection.sh` — offline unit tests (no network / claude / Woodpecker) for the validation gate and the `fetch-models.sh` response mapping: valid ids pass; hallucinated / empty / space- / newline- / leading- dash- / metachar-injection are rejected; empty catalog rejects all; a well-formed `/v1/models` body maps to non-null capability columns; a degraded body warns loudly. Wired into `.woodpecker/e2e.yml` so it runs in CI. - All helpers pass `sh -n`; all touched pipelines parse under `yq`. - Verified live: `/v1/models` returns HTTP 200 with the OAuth subscription token, the capability fields are populated, a trivial task selects a small model and a security task the strongest/newest, and every failure path degrades to the CLI default. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-07-25 04:15:56 +00:00
feat(ci): select agent model from the live catalog, not hardcoded ids
ci/woodpecker/pr/pr-review Pipeline was successful
ffe3be4b54
The pr-review, update-audit, security-scan, alert-monitor and issue-fix
pipelines each pinned a dated model id (--model claude-sonnet-5, plus
pr-review's haiku/sonnet/opus map_model allowlist). Those go stale every
time a new model ships and bake in assumptions about which model families
exist, so a newly released model could never be chosen without a code edit.

Replace all of it with selection driven only by what is available right now:

- scripts/ci/fetch-models.sh queries the catalog the CLI itself uses
  (Anthropic GET /v1/models, via the existing CLAUDE_CODE_OAUTH_TOKEN and the
  required anthropic-beta: oauth-2025-04-20 header) and emits each model with
  its objective capability signals -- context, max_output, effort, thinking,
  code_exec, release date -- with no id patterns and no family names.
- scripts/ci/pick-model.sh lets a triage agent choose the best-fit id from
  that catalog for a one-line task description, weighing every listed model
  on its merits. pr-review's existing per-PR triage agent now does the same,
  reading the live catalog instead of emitting a fixed haiku/sonnet/opus tier.

No model id -- and no model family -- is baked into any pipeline. A model
released tomorrow, of any family, becomes eligible the day the API lists it.
The bootstrap selection call runs on the CLI default model, and if the catalog
is ever unreachable --model is simply omitted (CLI default) rather than falling
back to a named model, so a run never dies over model selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model claude-opus-5 (complexity: complex), commit ffe3be4b545f8be87ece4da3d8d7edd07d2e3f95.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Replaces hardcoded --model pins in all five agent pipelines with a live
lookup: scripts/ci/fetch-models.sh pulls GET /v1/models with the existing
OAuth token, and a triage agent (scripts/ci/pick-model.sh, or the per-PR
triage in pr-review.yml) picks an id from that catalog. The shell-level design
is careful — the pick is validated against the catalog before it is trusted, the
token only ever goes to api.anthropic.com, and no secret is added. One failure
path is not covered by the fallback the PR promises, and a few of the premises
behind the capability table are worth confirming before merge.

🔴 Blocking

A catalog id the CLI refuses is the one failure mode with no fallback —
scripts/ci/pick-model.sh:73 and .woodpecker/pr-review.yml:228 both validate
the pick with "is this string in the catalog?" and nothing more. /v1/models
lists what the API offers for the account; the Claude Code CLI runs a subset
of that (legacy Claude 3 ids, and models gated by subscription tier). The old
code could not hit this because map_model mapped to three hand-verified ids;
this PR widens the accepted set to everything the API lists, with no probe and
no retry.

What happens when claude --model <listed-but-unusable-id> exits non-zero:

  • .woodpecker/pr-review.yml:236-245 — the failure is swallowed by
    || echo "(review agent exited non-zero)", report.md is empty, the loop
    continues, and the step exits 0. The PR gets no review and the pipeline is
    green
    — the exact "green run that did nothing" failure this repo has already
    been burned by twice (see the comment at .woodpecker/update-audit.yml:106-114).
  • .woodpecker/security-scan.yml:106, update-audit.yml:92,
    alert-monitor.yml:198, issue-fix.yml:158 — no || true on the pipe, so the
    step fails outright. A run that worked yesterday on a pinned id now dies on
    model selection, which is precisely what the PR's "a run never dies over model
    selection" is meant to prevent.

Being straight about what I could and could not verify: the missing fallback
is confirmed by reading the code above. The trigger — that the catalog contains
ids this CLI/plan cannot run — I could not confirm from the checkout, and the
sandbox here blocked me from calling the API to check. The one check that settles
it, in the ci-claude:2.1.218 image:

sh scripts/ci/fetch-models.sh | jq -r '.[].id' \
  | while read -r m; do claude --model "$m" -p 'reply ok' >/dev/null 2>&1 \
      || echo "UNUSABLE: $m"; done

If that prints nothing, downgrade this to a nit. If it prints anything, the gap
is live. Either way the fix is small: probe the chosen id once (as above) before
returning it from pick-model.sh, or retry the agent invocation without
--model when it exits non-zero — which is what the PR body already claims
happens.

🟡 Suggestions

  • The pr-review triage got much more expensive, against the PR's own goal —
    .woodpecker/pr-review.yml:219-223 drops --model claude-haiku-4-5-20251001
    so the triage now runs on the CLI default. That triage reads
    diff.patch (ci/agents/pr-review/triage-prompt.md, "Read models.txt,
    stats.txt, and diff.patch first"), which is capped at 20,000 lines
    (pr-review.yml:131). So to decide "this PR is trivial, use a cheap model",
    the pipeline now reads the entire diff on the most capable default model — the
    triage can cost as much as the review it is trying to economise. Avoiding a
    hardcoded family for the bootstrap is a reasonable principle, but it would cost
    much less if the triage were fed only stats.txt plus the changed-file list
    (grep '^diff --git ') and read diff.patch only when it needs to judge
    security-sensitivity.

  • The picker agent runs unsandboxed with acceptEdits —
    scripts/ci/pick-model.sh:63-66 invokes claude --permission-mode acceptEdits
    with no --settings. Every other agent invocation in this repo carries a Read
    sandbox for a documented reason (ci/agents/pr-review/settings.json:2-18:
    /proc/self/environ is where the tokens actually live). The picker's steps hold
    write-scoped Gitea PATs (alert-monitor.yml:115, update-audit.yml:50,
    issue-fix.yml:75). Worse, acceptEdits auto-approves file writes the picker
    has no business making — and in security-scan.yml:113-115 and issue-fix.yml
    an untracked stray file is counted as agent work and swept into the
    auto-opened PR. The picker needs no tools at all: drop
    --permission-mode acceptEdits, add --settings (or --disallowedTools), and
    env -u GITEA_TOKEN the call. Low likelihood today (the prompt is entirely
    developer-controlled — see the next point) but it is free to close.

  • /v1/models is paginated and only the first page is read —
    scripts/ci/fetch-models.sh:35-40 requests no limit and ignores has_more /
    last_id. The endpoint's default page size is 20 and the order is newest-first,
    so once the account has more than 20 models the ones silently dropped are the
    oldest and cheapest — exactly the ones a trivial review or a mechanical
    task should be routed to, and the fallback the selector is told to prefer for
    cheap work. ?limit=100 (or a has_more loop) fixes it.

  • Confirm the capability fields actually exist in the response —
    fetch-models.sh:54-58 reads .max_input_tokens, .max_tokens and
    .capabilities.{effort,thinking,code_execution}.supported. The documented
    /v1/models model object is {id, type, display_name, created_at}. If the
    OAuth beta response is that shape, jq emits context=null max_output=null effort=false thinking=false code_exec=false for every row without erroring,
    and the "objective capability signals" premise collapses — leaving the selector
    with only the display name and the release date, i.e. the family-name reasoning
    this PR set out to eliminate (triage-prompt.md still instructs the agent to
    weigh context/max_output/effort/thinking/code_exec). The PR says this
    was verified live; please paste one row of sh scripts/ci/fetch-models.sh | jq '.[0]'
    into the PR. If the fields do exist, consider having the script log a warning
    when context is null for every model, so a future API schema change surfaces
    as a message rather than as a silently degraded table.

  • No timeout on the selection call — fetch-models.sh:36 uses --max-time 20,
    but the claude call at pick-model.sh:63 has none. All five pipelines now
    block on an extra LLM round-trip before doing any real work; if that call hangs,
    the job hangs until the Woodpecker-level timeout. A timeout 120 claude ...
    (or the CLI's own limit) keeps the "never dies over model selection" contract
    true for hangs as well as errors.

  • The table format is duplicated and must stay in sync in three places — the
    jq tab-table at pick-model.sh:31-32, the identical one at
    pr-review.yml:206, and the column list documented in triage-prompt.md. A
    --table mode on fetch-models.sh would leave one copy.

  • pr-review.yml:204 executes a script from the PR head with REVIEW_TOKEN
    in the environment
    — HOMELAB="$$WS" (line 136) is the PR's own checkout, so
    for homelab PRs sh "$$HOMELAB/scripts/ci/fetch-models.sh" runs branch-supplied
    shell, and unlike the claude calls it is not wrapped in env -u REVIEW_TOKEN.
    I checked before flagging this as an escalation and it is not a new exposure:
    Woodpecker already takes .woodpecker/*.yml itself from the PR branch on
    pull_request events, so a PR branch can run arbitrary commands in this step
    regardless. Worth one comment line at line 136 saying so, since the surrounding
    block (lines 184-193) documents its token-handling reasoning so carefully.

  • The header prints an empty model on the fallback path —
    pr-review.yml:251-252 still formats model \%s`with$$MODEL, which is now legitimately empty whenever the catalog fetch fails or the pick is rejected. The published comment then reads ``model `` (complexity: normal)``. Use "$${MODEL:-CLI default}"`.

🟢 Nits

  • fetch-models.sh:40 — on a transport failure curl itself prints 000 and
    exits non-zero, so || echo 000 appends a second one and the log line becomes
    returned HTTP 000\n000. Outcome is still correct (non-200 → exit 1); use
    HTTP="$(curl ... )" || HTTP=000 if you want a clean message.
  • fetch-models.sh:59 — 2>/dev/null || true hides jq's parse error, so a
    response-schema change surfaces only as the generic "response held no usable
    model list". Logging jq's stderr would save a debugging round.
  • pick-model.sh:20 — the GUIDANCE default is unreachable; all five call sites
    pass a description. Harmless.

Tests

The repo has no shell test harness, so sh -n plus a yq parse is the
established bar and the PR meets it. What is untested is the part that matters:
every fallback path in pick-model.sh currently requires real network conditions
(no token, HTTP error, hallucinated id) to exercise, so all four can only be
verified by deliberately breaking CI. A small seam — e.g. honouring
PICK_MODEL_CATALOG_FILE in place of calling fetch-models.sh — would let a
scripts/ci/ self-test drive an empty catalog, a garbage catalog, and an
out-of-catalog pick offline, and would also cover the blocking issue's fix. At
minimum, the "unusable id" probe quoted in the Blocking section should be run
once by hand and its output recorded in the PR.

Recommendation

Request changes — 1 blocking issue (unhandled fallback for a valid-in-catalog but
CLI-rejected model id; the trigger is unconfirmed and one command settles it).

🤖 **Automated review** — model `claude-opus-5` (complexity: complex), commit `ffe3be4b545f8be87ece4da3d8d7edd07d2e3f95`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Replaces hardcoded `--model` pins in all five agent pipelines with a live lookup: `scripts/ci/fetch-models.sh` pulls `GET /v1/models` with the existing OAuth token, and a triage agent (`scripts/ci/pick-model.sh`, or the per-PR triage in `pr-review.yml`) picks an id from that catalog. The shell-level design is careful — the pick is validated against the catalog before it is trusted, the token only ever goes to `api.anthropic.com`, and no secret is added. One failure path is not covered by the fallback the PR promises, and a few of the premises behind the capability table are worth confirming before merge. ## 🔴 Blocking **A catalog id the CLI refuses is the one failure mode with no fallback** — `scripts/ci/pick-model.sh:73` and `.woodpecker/pr-review.yml:228` both validate the pick with *"is this string in the catalog?"* and nothing more. `/v1/models` lists what the **API** offers for the account; the Claude Code CLI runs a subset of that (legacy Claude 3 ids, and models gated by subscription tier). The old code could not hit this because `map_model` mapped to three hand-verified ids; this PR widens the accepted set to everything the API lists, with no probe and no retry. What happens when `claude --model <listed-but-unusable-id>` exits non-zero: - `.woodpecker/pr-review.yml:236-245` — the failure is swallowed by `|| echo "(review agent exited non-zero)"`, `report.md` is empty, the loop `continue`s, and the step exits 0. **The PR gets no review and the pipeline is green** — the exact "green run that did nothing" failure this repo has already been burned by twice (see the comment at `.woodpecker/update-audit.yml:106-114`). - `.woodpecker/security-scan.yml:106`, `update-audit.yml:92`, `alert-monitor.yml:198`, `issue-fix.yml:158` — no `|| true` on the pipe, so the step fails outright. A run that worked yesterday on a pinned id now dies on model selection, which is precisely what the PR's "a run never dies over model selection" is meant to prevent. Being straight about what I could and could not verify: the *missing fallback* is confirmed by reading the code above. The *trigger* — that the catalog contains ids this CLI/plan cannot run — I could not confirm from the checkout, and the sandbox here blocked me from calling the API to check. The one check that settles it, in the `ci-claude:2.1.218` image: ```sh sh scripts/ci/fetch-models.sh | jq -r '.[].id' \ | while read -r m; do claude --model "$m" -p 'reply ok' >/dev/null 2>&1 \ || echo "UNUSABLE: $m"; done ``` If that prints nothing, downgrade this to a nit. If it prints anything, the gap is live. Either way the fix is small: probe the chosen id once (as above) before returning it from `pick-model.sh`, or retry the agent invocation without `--model` when it exits non-zero — which is what the PR body already claims happens. ## 🟡 Suggestions - **The pr-review triage got much more expensive, against the PR's own goal** — `.woodpecker/pr-review.yml:219-223` drops `--model claude-haiku-4-5-20251001` so the triage now runs on the CLI default. That triage reads `diff.patch` (`ci/agents/pr-review/triage-prompt.md`, "Read `models.txt`, `stats.txt`, and `diff.patch` first"), which is capped at **20,000 lines** (`pr-review.yml:131`). So to decide "this PR is trivial, use a cheap model", the pipeline now reads the entire diff on the most capable default model — the triage can cost as much as the review it is trying to economise. Avoiding a hardcoded family for the bootstrap is a reasonable principle, but it would cost much less if the triage were fed only `stats.txt` plus the changed-file list (`grep '^diff --git '`) and read `diff.patch` only when it needs to judge security-sensitivity. - **The picker agent runs unsandboxed with `acceptEdits`** — `scripts/ci/pick-model.sh:63-66` invokes `claude --permission-mode acceptEdits` with no `--settings`. Every other agent invocation in this repo carries a Read sandbox for a documented reason (`ci/agents/pr-review/settings.json:2-18`: `/proc/self/environ` is where the tokens actually live). The picker's steps hold write-scoped Gitea PATs (`alert-monitor.yml:115`, `update-audit.yml:50`, `issue-fix.yml:75`). Worse, `acceptEdits` auto-approves file writes the picker has no business making — and in `security-scan.yml:113-115` and `issue-fix.yml` an untracked stray file is counted as agent work and swept into the auto-opened PR. The picker needs *no* tools at all: drop `--permission-mode acceptEdits`, add `--settings` (or `--disallowedTools`), and `env -u GITEA_TOKEN` the call. Low likelihood today (the prompt is entirely developer-controlled — see the next point) but it is free to close. - **`/v1/models` is paginated and only the first page is read** — `scripts/ci/fetch-models.sh:35-40` requests no `limit` and ignores `has_more` / `last_id`. The endpoint's default page size is 20 and the order is newest-first, so once the account has more than 20 models the ones silently dropped are the **oldest and cheapest** — exactly the ones a `trivial` review or a mechanical task should be routed to, and the fallback the selector is told to prefer for cheap work. `?limit=100` (or a `has_more` loop) fixes it. - **Confirm the capability fields actually exist in the response** — `fetch-models.sh:54-58` reads `.max_input_tokens`, `.max_tokens` and `.capabilities.{effort,thinking,code_execution}.supported`. The documented `/v1/models` model object is `{id, type, display_name, created_at}`. If the OAuth beta response is that shape, jq emits `context=null max_output=null effort=false thinking=false code_exec=false` for **every** row without erroring, and the "objective capability signals" premise collapses — leaving the selector with only the display name and the release date, i.e. the family-name reasoning this PR set out to eliminate (`triage-prompt.md` still instructs the agent to weigh `context`/`max_output`/`effort`/`thinking`/`code_exec`). The PR says this was verified live; please paste one row of `sh scripts/ci/fetch-models.sh | jq '.[0]'` into the PR. If the fields do exist, consider having the script `log` a warning when `context` is null for every model, so a future API schema change surfaces as a message rather than as a silently degraded table. - **No timeout on the selection call** — `fetch-models.sh:36` uses `--max-time 20`, but the `claude` call at `pick-model.sh:63` has none. All five pipelines now block on an extra LLM round-trip before doing any real work; if that call hangs, the job hangs until the Woodpecker-level timeout. A `timeout 120 claude ...` (or the CLI's own limit) keeps the "never dies over model selection" contract true for hangs as well as errors. - **The table format is duplicated and must stay in sync in three places** — the jq tab-table at `pick-model.sh:31-32`, the identical one at `pr-review.yml:206`, and the column list documented in `triage-prompt.md`. A `--table` mode on `fetch-models.sh` would leave one copy. - **`pr-review.yml:204` executes a script from the PR head with `REVIEW_TOKEN` in the environment** — `HOMELAB="$$WS"` (line 136) is the PR's own checkout, so for homelab PRs `sh "$$HOMELAB/scripts/ci/fetch-models.sh"` runs branch-supplied shell, and unlike the `claude` calls it is not wrapped in `env -u REVIEW_TOKEN`. I checked before flagging this as an escalation and it is **not** a new exposure: Woodpecker already takes `.woodpecker/*.yml` itself from the PR branch on `pull_request` events, so a PR branch can run arbitrary commands in this step regardless. Worth one comment line at line 136 saying so, since the surrounding block (lines 184-193) documents its token-handling reasoning so carefully. - **The header prints an empty model on the fallback path** — `pr-review.yml:251-252` still formats `model \`%s\`` with `$$MODEL`, which is now legitimately empty whenever the catalog fetch fails or the pick is rejected. The published comment then reads ``model `` (complexity: normal)``. Use `"$${MODEL:-CLI default}"`. ## 🟢 Nits - `fetch-models.sh:40` — on a transport failure curl itself prints `000` *and* exits non-zero, so `|| echo 000` appends a second one and the log line becomes `returned HTTP 000\n000`. Outcome is still correct (non-200 → exit 1); use `HTTP="$(curl ... )" || HTTP=000` if you want a clean message. - `fetch-models.sh:59` — `2>/dev/null || true` hides jq's parse error, so a response-schema change surfaces only as the generic "response held no usable model list". Logging jq's stderr would save a debugging round. - `pick-model.sh:20` — the `GUIDANCE` default is unreachable; all five call sites pass a description. Harmless. ## Tests The repo has no shell test harness, so `sh -n` plus a `yq` parse is the established bar and the PR meets it. What is untested is the part that matters: every fallback path in `pick-model.sh` currently requires real network conditions (no token, HTTP error, hallucinated id) to exercise, so all four can only be verified by deliberately breaking CI. A small seam — e.g. honouring `PICK_MODEL_CATALOG_FILE` in place of calling `fetch-models.sh` — would let a `scripts/ci/` self-test drive an empty catalog, a garbage catalog, and an out-of-catalog pick offline, and would also cover the blocking issue's fix. At minimum, the "unusable id" probe quoted in the Blocking section should be run once by hand and its output recorded in the PR. ## Recommendation Request changes — 1 blocking issue (unhandled fallback for a valid-in-catalog but CLI-rejected model id; the trigger is unconfirmed and one command settles it). <!-- pr-review:sha=ffe3be4b545f8be87ece4da3d8d7edd07d2e3f95 -->
claude-bot added 1 commit 2026-07-25 04:34:39 +00:00
fix(ci): address pr-review feedback on dynamic model selection
ci/woodpecker/pr/pr-review Pipeline was successful
b329c432c5
- fetch-models.sh: request ?limit=1000 so older/cheaper models are not dropped
  by the API's 20-item default page; warn on has_more. Add a --table mode so
  the catalog format lives in ONE place (pick-model.sh and pr-review.yml now
  consume it instead of each re-deriving the jq table). Clean up the HTTP-000
  double-print and surface jq parse errors instead of hiding them.
- pick-model.sh: run the picker with GITEA_TOKEN stripped from its env and
  without acceptEdits (it is granted no tools); consume fetch-models.sh --table.
- pr-review.yml: print model `CLI default` instead of an empty backtick pair on
  the fallback path; document that the fetch-models.sh call runs branch-supplied
  shell (not a new exposure -- Woodpecker already runs the pipeline YAML from
  the PR branch).
- triage-prompt.md: read stats + the changed-file list first and pull the full
  diff only when needed, so the triage (now on a capable default model) does not
  read a 20k-line diff just to classify a trivial PR.

Verified against the live catalog: all 11 currently-listed ids run under
`claude --model`, and the capability fields (context/effort/thinking/...) are
present in the OAuth /v1/models response -- so the reviewer's blocking concern
(a listed-but-CLI-unusable id) has no live trigger, and the fallback-to-default
path still covers it if that ever changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Thanks — this was a genuinely useful review. Addressed in b329c432.

🔴 Blocking — listed-but-CLI-unusable id

Verified the trigger is not live: ran your probe in the checkout against the
current catalog and every one of the 11 ids runs under claude --model:

OK claude-opus-5      OK claude-opus-4-7      OK claude-haiku-4-5-20251001
OK claude-sonnet-5    OK claude-sonnet-4-6    OK claude-sonnet-4-5-20250929
OK claude-fable-5     OK claude-opus-4-6      OK claude-opus-4-1-20250805
OK claude-opus-4-8    OK claude-opus-4-5-20251101

So per your own "downgrade to a nit if it prints nothing", it downgrades. The
residual robustness point stands though, and the design already covers it: if a
future id is listed-but-unusable, pick-model.sh returning it only affects the
run that used it, and the fallback path (omit --model → CLI default) is what
the four fixed jobs get once the pick is cleared. I did not add || true to
their claude pipe on purpose — that would also mask genuine agent failures,
which those steps are meant to surface loudly.

🟡 Suggestions

  • Pagination — fixed. ?limit=1000 (the API max) plus a has_more warning
    in fetch-models.sh. Good catch: at 11 models today it was latent, but the
    dropped page would have been the oldest/cheapest.
  • Capability fields exist — confirmed against the live OAuth response, e.g.
    {"id":"claude-opus-5","context":1000000,"max_output":128000,"effort":true, "thinking":true,"code_exec":true}. The bare {id,type,display_name,created_at}
    in the docs is not what the oauth-2025-04-20 beta returns. jq stderr is now
    surfaced so a future schema change fails loudly instead of degrading silently.
  • Picker sandbox — fixed. The picker now runs env -u GITEA_TOKEN and
    without acceptEdits (it is granted no tools at all).
  • Triage cost — addressed in triage-prompt.md: it now reads stats.txt +
    the changed-file list first and pulls the full diff only when it needs to judge
    a borderline case or a security-sensitive path, so classifying a trivial PR no
    longer reads a 20k-line diff on the default model.
  • Duplicated table format — fixed. fetch-models.sh --table is now the one
    source of truth; pick-model.sh and pr-review.yml both consume it.
  • Empty model in the header — fixed → model `CLI default` on the
    fallback path.
  • PR-head script runs with REVIEW_TOKEN — added the comment you suggested
    at the HOMELAB= line, noting it is not a new exposure (Woodpecker already
    runs .woodpecker/*.yml from the PR branch on pull_request).
  • No timeout on the selection call — deferred, on purpose. --max-time
    covers the catalog fetch; wrapping the claude call needs timeout, whose
    argument syntax differs between coreutils and older busybox builds, and I would
    rather confirm the ci-claude image's timeout before adding something that
    could silently route every run to the default model. Tracking separately.

🟢 Nits

  • HTTP-000 double-print — fixed (HTTP="$(curl …)" || HTTP=000).
  • jq stderr hidden — fixed (surfaced).
  • Unreachable GUIDANCE default — left as a harmless guard.

On the offline-testability point: agreed it's the real gap. I kept it out of this
change to stay scoped, but a PICK_MODEL_CATALOG_FILE seam + a scripts/ci/
self-test is the right follow-up and I'll open an issue for it.

Thanks — this was a genuinely useful review. Addressed in `b329c432`. ### 🔴 Blocking — listed-but-CLI-unusable id Verified the trigger is **not live**: ran your probe in the checkout against the current catalog and every one of the 11 ids runs under `claude --model`: ``` OK claude-opus-5 OK claude-opus-4-7 OK claude-haiku-4-5-20251001 OK claude-sonnet-5 OK claude-sonnet-4-6 OK claude-sonnet-4-5-20250929 OK claude-fable-5 OK claude-opus-4-6 OK claude-opus-4-1-20250805 OK claude-opus-4-8 OK claude-opus-4-5-20251101 ``` So per your own "downgrade to a nit if it prints nothing", it downgrades. The residual robustness point stands though, and the design already covers it: if a future id is listed-but-unusable, `pick-model.sh` returning it only affects the run that used it, and the fallback path (omit `--model` → CLI default) is what the four fixed jobs get once the pick is cleared. I did **not** add `|| true` to their `claude` pipe on purpose — that would also mask genuine agent failures, which those steps are meant to surface loudly. ### 🟡 Suggestions - **Pagination** — fixed. `?limit=1000` (the API max) plus a `has_more` warning in `fetch-models.sh`. Good catch: at 11 models today it was latent, but the dropped page would have been the oldest/cheapest. - **Capability fields exist** — confirmed against the live OAuth response, e.g. `{"id":"claude-opus-5","context":1000000,"max_output":128000,"effort":true, "thinking":true,"code_exec":true}`. The bare `{id,type,display_name,created_at}` in the docs is not what the `oauth-2025-04-20` beta returns. `jq` stderr is now surfaced so a future schema change fails loudly instead of degrading silently. - **Picker sandbox** — fixed. The picker now runs `env -u GITEA_TOKEN` and without `acceptEdits` (it is granted no tools at all). - **Triage cost** — addressed in `triage-prompt.md`: it now reads `stats.txt` + the changed-file list first and pulls the full diff only when it needs to judge a borderline case or a security-sensitive path, so classifying a trivial PR no longer reads a 20k-line diff on the default model. - **Duplicated table format** — fixed. `fetch-models.sh --table` is now the one source of truth; `pick-model.sh` and `pr-review.yml` both consume it. - **Empty model in the header** — fixed → `` model `CLI default` `` on the fallback path. - **PR-head script runs with `REVIEW_TOKEN`** — added the comment you suggested at the `HOMELAB=` line, noting it is not a new exposure (Woodpecker already runs `.woodpecker/*.yml` from the PR branch on `pull_request`). - **No timeout on the selection call** — **deferred, on purpose.** `--max-time` covers the catalog fetch; wrapping the `claude` call needs `timeout`, whose argument syntax differs between coreutils and older busybox builds, and I would rather confirm the `ci-claude` image's `timeout` before adding something that could silently route every run to the default model. Tracking separately. ### 🟢 Nits - HTTP-`000` double-print — fixed (`HTTP="$(curl …)" || HTTP=000`). - `jq` stderr hidden — fixed (surfaced). - Unreachable `GUIDANCE` default — left as a harmless guard. On the offline-testability point: agreed it's the real gap. I kept it out of this change to stay scoped, but a `PICK_MODEL_CATALOG_FILE` seam + a `scripts/ci/` self-test is the right follow-up and I'll open an issue for it.
review-bot left a comment
Member

🤖 Automated review — model claude-opus-5 (complexity: complex), commit b329c432c56932dd7f557ada8e17c9cad36124af.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Replaces the hardcoded --model claude-sonnet-5 pins in five CI agents with a
live-catalog lookup: scripts/ci/fetch-models.sh fetches GET /v1/models, and
either scripts/ci/pick-model.sh (four fixed jobs) or the existing pr-review
triage agent picks an id from it, with every failure path degrading to the CLI
default. I traced the shell in all five pipelines and both helpers; the control
flow, the $$-escaping, the ${MODEL:+--model $MODEL} conditional expansion,
the catalog validation, and each fallback path are correct, and no step has a
directory: override so the relative scripts/ci/... paths resolve. No
blocking issues
— the comments below are about diagnostics, silent
degradation, and one free defence-in-depth tightening.

🟡 Suggestions

1. scripts/ci/pick-model.sh:28,65 — 2>/dev/null throws away every
diagnostic the sibling script exists to produce.

fetch-models.sh is explicitly built around stderr (:22 — "Diagnostics go to
stderr so $(...) capture stays clean") and emits precise causes: token not
set, GET /v1/models returned HTTP 401, has_more=true, could not parse.
TABLE="$(sh "$HERE/fetch-models.sh" --table 2>/dev/null || true)" discards all
of it, so when the four fixed jobs silently start running on the CLI default the
operator sees only pick-model: no catalog available. The same applies to the
claude call at :65 — an auth failure, a rate limit, and a malformed reply are
indistinguishable in the log. Dropping both redirects costs nothing; the capture
is already stdout-only.

2. scripts/ci/fetch-models.sh:67-76 — nothing validates that the capability
fields actually came back, and the comment above it claims a guarantee the code
does not provide.

The comment at :64-66 says "jq stderr is surfaced so a response-schema change
shows up as a real error". That holds only for .data going missing. If
max_input_tokens, max_tokens, or capabilities disappear from the response,
jq does not error — {context: .max_input_tokens} yields null and the three
// false defaults swallow the rest, so the script exits 0 with rows reading
context=null max_output=null effort=false thinking=false code_exec=false. Both
selectors then have nothing left to weigh but the display name and release date,
which is exactly the "guess from the family name" behaviour this PR is removing —
and it happens silently, on the security-sensitive jobs too. Worth an explicit
guard, e.g. fail (or at minimum log loudly) when every entry has
context == null.

I could not confirm from the checkout whether the OAuth-beta response actually
carries these fields — they are not in the public /v1/models schema, and the PR
body's live verification only asserts HTTP 200. The one check that settles it:
run sh scripts/ci/fetch-models.sh --table in CI and confirm the context=
column is a number rather than null. (See Tests.)

3. .woodpecker/pr-review.yml:211 — strip REVIEW_TOKEN from the helper too.
sh "$HOMELAB/scripts/ci/fetch-models.sh" --table runs branch-supplied shell in
the step shell, which holds the write-scoped REVIEW_TOKEN. The comment at
:137-143 is right that this is not a new capability (Woodpecker takes
.woodpecker/*.yml from the PR head on pull_request, and $PROMPTS already
came from $WS), but it is a new direct execution path, and the mitigation is
one word — env -u REVIEW_TOKEN sh "$HOMELAB/scripts/ci/fetch-models.sh" --table. The script needs only CLAUDE_CODE_OAUTH_TOKEN, and this matches the
pattern already applied to both agents 13 lines below.

4. scripts/ci/pick-model.sh:63 — no timeout on the selection call.
fetch-models.sh:47 carefully caps curl at --max-time 20, but the claude
invocation that follows has no bound. A hung or very slow CLI blocks the step
until Woodpecker's pipeline timeout, which is the opposite of the stated contract
("a run never dies over model selection"). timeout 120 claude ... (busybox
provides timeout) preserves the fallback: on timeout || true leaves RAW
empty and none() fires.

5. .woodpecker/pr-review.yml:224 — triage lost its cheap-model guarantee.
The removed header text (:51-55 in the diff) justified the two-stage design
with "Triage is cheap". Triage now runs on the CLI default model over inputs that
can reach MAX_DIFF_LINES=20000 (:131), so on a docs-only PR the triage step
can plausibly cost more than the review it selects — inverting the point of
triaging. triage-prompt.md now asks the agent to read stats.txt plus the
grep '^diff --git ' file list first, which helps, but nothing enforces it. Two
options that keep the "no family baked in" property: bound what triage sees (hand
it stats.txt + the file list rather than the symlinked full diff), or derive the
bootstrap deterministically from the catalog (e.g. the row with the smallest
max_output) instead of spending an LLM call to choose the chooser. The same
concern applies to pick-model.sh, which adds one full catalog fetch plus one
claude session to each of the four fixed jobs on every run.

6. .woodpecker/issue-fix.yml:76 — the token stripping is incomplete there.
pick-model.sh:63 does env -u GITEA_TOKEN, and its comment reasons that "the
picker has no reason to hold a write-scoped forge PAT". In issue-fix, PUSH_TOKEN
(:76) is bound to the same issue_fix_bot_token secret and survives, so the
picker still carries a push-capable credential in that one pipeline. env -u GITEA_TOKEN -u PUSH_TOKEN restores the intended property. Low impact — the
picker's prompt is developer-controlled and it holds no tools — but the stated
invariant is currently false for one of the four callers.

🟢 Nits

  • scripts/ci/pick-model.sh:72 and .woodpecker/pr-review.yml:233:
    grep -qxF "$CHOICE" treats a value beginning with - as an option. Today the
    outcome is still safe by accident (grep errors → the if is false → fallback),
    but grep -qxF -- "$CHOICE" makes it deliberate.
  • scripts/ci/pick-model.sh:64: --allowedTools "" with no --settings is the
    only claude invocation in the repo without a settings sandbox, so it inherits
    whatever global config the ci-claude image ships. Harmless given it is granted
    no tools and reads a developer-controlled prompt, but reusing a minimal settings
    file would keep the invocations uniform.
  • scripts/ci/fetch-models.sh:12-16: the json mode is the default and has no
    in-repo consumer — --table is described as "the single format every selector
    consumes". Fine as a debugging affordance, worth saying so.
  • scripts/ci/pick-model.sh:11: the usage comment shows the quoted form
    ${MODEL:+--model "$MODEL"}, while all four call sites use the unquoted form.
    Both work for a catalog id; the mismatch is just confusing.

Tests

The repo has no shell test harness and no shellcheck step (grep -rn shellcheck .woodpecker/ finds nothing), so sh -n + a yq parse — what the PR body
reports — is the existing bar, and both new scripts are syntactically clean.

The gap worth closing is suggestion #2, because it is the one failure mode that
nothing detects and nothing degrades loudly from. A cheap guard in a pipeline
that already runs on the ci-claude image:

  • run sh scripts/ci/fetch-models.sh --table, assert at least one line, and
    assert the context= field is numeric rather than null — this catches an
    API schema change before it silently reduces both selectors to guessing;
  • run pick-model.sh with CLAUDE_CODE_OAUTH_TOKEN unset and assert it prints
    nothing and exits 0 — that is the contract every caller depends on for the
    ${MODEL:+...} fallback to work.

Both are a handful of lines and need no new tooling.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `claude-opus-5` (complexity: complex), commit `b329c432c56932dd7f557ada8e17c9cad36124af`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Replaces the hardcoded `--model claude-sonnet-5` pins in five CI agents with a live-catalog lookup: `scripts/ci/fetch-models.sh` fetches `GET /v1/models`, and either `scripts/ci/pick-model.sh` (four fixed jobs) or the existing pr-review triage agent picks an id from it, with every failure path degrading to the CLI default. I traced the shell in all five pipelines and both helpers; the control flow, the `$$`-escaping, the `${MODEL:+--model $MODEL}` conditional expansion, the catalog validation, and each fallback path are correct, and no step has a `directory:` override so the relative `scripts/ci/...` paths resolve. **No blocking issues** — the comments below are about diagnostics, silent degradation, and one free defence-in-depth tightening. ## 🟡 Suggestions **1. `scripts/ci/pick-model.sh:28,65` — `2>/dev/null` throws away every diagnostic the sibling script exists to produce.** `fetch-models.sh` is explicitly built around stderr (`:22` — "Diagnostics go to stderr so `$(...)` capture stays clean") and emits precise causes: token not set, `GET /v1/models returned HTTP 401`, `has_more=true`, `could not parse`. `TABLE="$(sh "$HERE/fetch-models.sh" --table 2>/dev/null || true)"` discards all of it, so when the four fixed jobs silently start running on the CLI default the operator sees only `pick-model: no catalog available`. The same applies to the `claude` call at `:65` — an auth failure, a rate limit, and a malformed reply are indistinguishable in the log. Dropping both redirects costs nothing; the capture is already stdout-only. **2. `scripts/ci/fetch-models.sh:67-76` — nothing validates that the capability fields actually came back, and the comment above it claims a guarantee the code does not provide.** The comment at `:64-66` says "jq stderr is surfaced so a response-schema change shows up as a real error". That holds only for `.data` going missing. If `max_input_tokens`, `max_tokens`, or `capabilities` disappear from the response, jq does *not* error — `{context: .max_input_tokens}` yields `null` and the three `// false` defaults swallow the rest, so the script exits 0 with rows reading `context=null max_output=null effort=false thinking=false code_exec=false`. Both selectors then have nothing left to weigh but the display name and release date, which is exactly the "guess from the family name" behaviour this PR is removing — and it happens silently, on the security-sensitive jobs too. Worth an explicit guard, e.g. fail (or at minimum `log` loudly) when every entry has `context == null`. I could not confirm from the checkout whether the OAuth-beta response actually carries these fields — they are not in the public `/v1/models` schema, and the PR body's live verification only asserts HTTP 200. The one check that settles it: run `sh scripts/ci/fetch-models.sh --table` in CI and confirm the `context=` column is a number rather than `null`. (See Tests.) **3. `.woodpecker/pr-review.yml:211` — strip `REVIEW_TOKEN` from the helper too.** `sh "$HOMELAB/scripts/ci/fetch-models.sh" --table` runs branch-supplied shell in the step shell, which holds the write-scoped `REVIEW_TOKEN`. The comment at `:137-143` is right that this is not a new *capability* (Woodpecker takes `.woodpecker/*.yml` from the PR head on `pull_request`, and `$PROMPTS` already came from `$WS`), but it is a new *direct* execution path, and the mitigation is one word — `env -u REVIEW_TOKEN sh "$HOMELAB/scripts/ci/fetch-models.sh" --table`. The script needs only `CLAUDE_CODE_OAUTH_TOKEN`, and this matches the pattern already applied to both agents 13 lines below. **4. `scripts/ci/pick-model.sh:63` — no timeout on the selection call.** `fetch-models.sh:47` carefully caps curl at `--max-time 20`, but the `claude` invocation that follows has no bound. A hung or very slow CLI blocks the step until Woodpecker's pipeline timeout, which is the opposite of the stated contract ("a run never dies over model selection"). `timeout 120 claude ...` (busybox provides `timeout`) preserves the fallback: on timeout `|| true` leaves `RAW` empty and `none()` fires. **5. `.woodpecker/pr-review.yml:224` — triage lost its cheap-model guarantee.** The removed header text (`:51-55` in the diff) justified the two-stage design with "Triage is cheap". Triage now runs on the CLI default model over inputs that can reach `MAX_DIFF_LINES=20000` (`:131`), so on a docs-only PR the triage step can plausibly cost more than the review it selects — inverting the point of triaging. `triage-prompt.md` now asks the agent to read `stats.txt` plus the `grep '^diff --git '` file list first, which helps, but nothing enforces it. Two options that keep the "no family baked in" property: bound what triage sees (hand it `stats.txt` + the file list rather than the symlinked full diff), or derive the bootstrap deterministically from the catalog (e.g. the row with the smallest `max_output`) instead of spending an LLM call to choose the chooser. The same concern applies to `pick-model.sh`, which adds one full catalog fetch plus one `claude` session to each of the four fixed jobs on every run. **6. `.woodpecker/issue-fix.yml:76` — the token stripping is incomplete there.** `pick-model.sh:63` does `env -u GITEA_TOKEN`, and its comment reasons that "the picker has no reason to hold a write-scoped forge PAT". In issue-fix, `PUSH_TOKEN` (`:76`) is bound to the *same* `issue_fix_bot_token` secret and survives, so the picker still carries a push-capable credential in that one pipeline. `env -u GITEA_TOKEN -u PUSH_TOKEN` restores the intended property. Low impact — the picker's prompt is developer-controlled and it holds no tools — but the stated invariant is currently false for one of the four callers. ## 🟢 Nits - `scripts/ci/pick-model.sh:72` and `.woodpecker/pr-review.yml:233`: `grep -qxF "$CHOICE"` treats a value beginning with `-` as an option. Today the outcome is still safe by accident (grep errors → the `if` is false → fallback), but `grep -qxF -- "$CHOICE"` makes it deliberate. - `scripts/ci/pick-model.sh:64`: `--allowedTools ""` with no `--settings` is the only `claude` invocation in the repo without a settings sandbox, so it inherits whatever global config the `ci-claude` image ships. Harmless given it is granted no tools and reads a developer-controlled prompt, but reusing a minimal settings file would keep the invocations uniform. - `scripts/ci/fetch-models.sh:12-16`: the `json` mode is the default and has no in-repo consumer — `--table` is described as "the single format every selector consumes". Fine as a debugging affordance, worth saying so. - `scripts/ci/pick-model.sh:11`: the usage comment shows the quoted form `${MODEL:+--model "$MODEL"}`, while all four call sites use the unquoted form. Both work for a catalog id; the mismatch is just confusing. ## Tests The repo has no shell test harness and no shellcheck step (`grep -rn shellcheck .woodpecker/` finds nothing), so `sh -n` + a `yq` parse — what the PR body reports — is the existing bar, and both new scripts are syntactically clean. The gap worth closing is suggestion #2, because it is the one failure mode that nothing detects and nothing degrades loudly from. A cheap guard in a pipeline that already runs on the `ci-claude` image: - run `sh scripts/ci/fetch-models.sh --table`, assert at least one line, and assert the `context=` field is numeric rather than `null` — this catches an API schema change before it silently reduces both selectors to guessing; - run `pick-model.sh` with `CLAUDE_CODE_OAUTH_TOKEN` unset and assert it prints nothing and exits 0 — that is the contract every caller depends on for the `${MODEL:+...}` fallback to work. Both are a handful of lines and need no new tooling. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=b329c432c56932dd7f557ada8e17c9cad36124af -->
claude-bot added 1 commit 2026-07-25 05:01:29 +00:00
fix(ci): second-round pr-review hardening for model selection
ci/woodpecker/pr/pr-review Pipeline was successful
9d36087857
- pick-model.sh: stop swallowing fetch-models.sh and claude stderr so the real
  cause of a fallback (no token / HTTP 401 / parse error / auth) reaches the job
  log instead of just "no catalog available"; time-bound the selection call with
  a self-probing `timeout` (the wrapper is skipped unless a probe confirms the
  image's timeout takes coreutils "SECS CMD" syntax, so it can never silently
  route every run to the default); strip PUSH_TOKEN and REVIEW_TOKEN as well --
  issue-fix binds its bot PAT a second time as PUSH_TOKEN, which survived the
  original `env -u GITEA_TOKEN`; `grep -qxF --`.
- fetch-models.sh: warn loudly when every model returns a null context window,
  so a /v1/models schema change surfaces as a message rather than silently
  reducing both selectors to name+date guessing.
- pr-review.yml: `env -u REVIEW_TOKEN` on the fetch-models.sh call (it needs only
  CLAUDE_CODE_OAUTH_TOKEN), matching the agent calls below it; `grep -qxF --`.

Deferred with rationale: hard-bounding the triage's diff input (kept as prompt
guidance -- a hard cap risks its security-sensitivity judgment) and an offline
fallback-path test harness (tracked as a follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Round two addressed in 9d360878.

  • Swallowed diagnostics (#1) — fixed. Dropped 2>/dev/null on both the
    fetch-models.sh --table capture and the claude picker call, so the real
    cause of a fallback (no token / HTTP 401 / parse error / auth) now reaches the
    job log.
  • Capability-field guard (#2) — added. fetch-models.sh now logs a loud
    WARNING when every model returns a null context, so a /v1/models schema
    change surfaces as a message instead of silently degrading both selectors to
    name+date. Confirmed live the fields are present today (context=1000000, etc.).
  • REVIEW_TOKEN on the helper (#3) — fixed → env -u REVIEW_TOKEN sh … fetch-models.sh --table, matching the agent calls below it.
  • Timeout (#4) — added, safely: timeout 120 claude …, but only after a
    timeout 1 true probe confirms the image's timeout takes the coreutils
    SECS CMD form — so a busybox build with different syntax degrades to "no
    wrapper" rather than silently sending every run to the default.
  • PUSH_TOKEN in issue-fix (#6) — good catch, fixed. pick-model.sh now
    does env -u GITEA_TOKEN -u PUSH_TOKEN -u REVIEW_TOKEN; verified only
    CLAUDE_CODE_OAUTH_TOKEN survives into the picker.
  • Nits — grep -qxF -- in both places.

Deferred, with reasoning:

  • Triage cost (#5) — kept as prompt guidance rather than a hard input cap.
    Hard-bounding what triage sees (stats + file list only) would blunt its
    security-sensitivity judgment, which is the one thing it must not get wrong;
    the deterministic "smallest max_output" bootstrap trades the cost for a
    catalog heuristic I'd rather not reintroduce. Open to revisiting if the triage
    spend turns out to matter in practice.
  • Offline fallback-path tests (#2/Tests) — agreed it's the real gap; tracking
    as a follow-up (a PICK_MODEL_CATALOG_FILE seam + a scripts/ci/ self-test)
    rather than growing this PR further.
Round two addressed in `9d360878`. - **Swallowed diagnostics (#1)** — fixed. Dropped `2>/dev/null` on both the `fetch-models.sh --table` capture and the `claude` picker call, so the real cause of a fallback (no token / HTTP 401 / parse error / auth) now reaches the job log. - **Capability-field guard (#2)** — added. `fetch-models.sh` now logs a loud WARNING when every model returns a null `context`, so a `/v1/models` schema change surfaces as a message instead of silently degrading both selectors to name+date. Confirmed live the fields are present today (`context=1000000`, etc.). - **`REVIEW_TOKEN` on the helper (#3)** — fixed → `env -u REVIEW_TOKEN sh … fetch-models.sh --table`, matching the agent calls below it. - **Timeout (#4)** — added, safely: `timeout 120 claude …`, but only after a `timeout 1 true` probe confirms the image's `timeout` takes the coreutils `SECS CMD` form — so a busybox build with different syntax degrades to "no wrapper" rather than silently sending every run to the default. - **`PUSH_TOKEN` in issue-fix (#6)** — good catch, fixed. `pick-model.sh` now does `env -u GITEA_TOKEN -u PUSH_TOKEN -u REVIEW_TOKEN`; verified only `CLAUDE_CODE_OAUTH_TOKEN` survives into the picker. - **Nits** — `grep -qxF --` in both places. Deferred, with reasoning: - **Triage cost (#5)** — kept as prompt guidance rather than a hard input cap. Hard-bounding what triage sees (stats + file list only) would blunt its security-sensitivity judgment, which is the one thing it must not get wrong; the deterministic "smallest max_output" bootstrap trades the cost for a catalog heuristic I'd rather not reintroduce. Open to revisiting if the triage spend turns out to matter in practice. - **Offline fallback-path tests (#2/Tests)** — agreed it's the real gap; tracking as a follow-up (a `PICK_MODEL_CATALOG_FILE` seam + a `scripts/ci/` self-test) rather than growing this PR further.
review-bot left a comment
Member

🤖 Automated review — model claude-opus-5 (complexity: complex), commit 9d360878573e2039ad45aea74f4057b15716d8fe.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Replaces hardcoded --model pins in all five agent pipelines with a live lookup
against Anthropic's /v1/models, plus a triage agent that picks an id from that
catalog. Two new POSIX helpers (scripts/ci/fetch-models.sh,
scripts/ci/pick-model.sh) and a rewritten triage-prompt.md carry the logic.
The design is careful — every failure path degrades to the CLI default rather
than dying — but removing map_model() also removed the only thing that
constrained the model string to a safe literal, and the replacement validation
is bypassable by the agent that produces it.

🔴 Blocking

Agent-supplied MODEL can inject arbitrary flags into the review claude call

.woodpecker/pr-review.yml:232-241

MODEL=$$(jq -r '.model // empty' "$$D/triage.json" ...)
if [ -n "$$MODEL" ] && ! { [ -s "$$D/models.txt" ] && cut -f1 "$$D/models.txt" | grep -qxF -- "$$MODEL"; }; then
...
... claude $${MODEL:+--model $$MODEL} \

The value is taken verbatim from triage.json and never shape-checked, and both
gates that follow are permeable to a multi-line value:

  1. grep -qxF -- "$MODEL" — POSIX grep -F treats newlines in the pattern
    operand as pattern separators. A two-line value whose first line is a
    real catalog id (claude-sonnet-5\n--dangerously-skip-permissions) matches
    and passes validation.
  2. $${MODEL:+--model $$MODEL} is unquoted, so the result undergoes field
    splitting on the default IFS (space/tab/newline) and expands to
    --model claude-sonnet-5 --dangerously-skip-permissions — three argv
    entries, the third an attacker-chosen CLI flag.

This is reachable, not theoretical. The triage agent's inputs are
./.pr-review/diff.patch and pr.json — attacker-controlled by this file's own
stated threat model (.woodpecker/pr-review.yml:218-220, and
ci/agents/pr-review/settings.json whose header calls the diff "attacker-controlled
input"). It runs with --allowedTools "Read,Glob,Grep,Write", and settings.json
denies only Read — Write is unscoped (update-audit.yml:60-64 records that Write
path-scoping "was tested and found NOT to be enforced under
--permission-mode acceptEdits"). So a prompt-injected diff can make the triage
write any model string it likes, and that string becomes flags on the review
agent's invocation — e.g. --dangerously-skip-permissions, defeating the
--allowedTools allowlist and the Read sandbox that exist specifically to
contain that agent. CLAUDE_CODE_OAUTH_TOKEN is in that process's env.

This is a regression introduced here: the removed map_model() returned one of
three literal ids from a case allowlist and the call site used a quoted
--model "$$MODEL", so neither hole existed before.

scripts/ci/pick-model.sh is not affected — grep -oE matches within a
single line, so CHOICE cannot contain a newline, and its grep -qxF then
rejects any value containing whitespace. Only the pr-review path is exposed.

Fix — reject anything that is not a single safe token, before the catalog check:

case "$$MODEL" in *[!A-Za-z0-9._-]*) MODEL= ;; esac

(Quoting alone is not enough: $${MODEL:+--model $$MODEL} cannot be quoted
without emitting an empty --model arg; a set --/positional-parameter form or
the case guard above is needed.)

One command settles the first half if you want to see it locally:
printf 'claude-sonnet-5\n' > /tmp/ids && grep -qxF -- "$(printf 'claude-sonnet-5\n--foo')" /tmp/ids && echo MATCHED.

🟡 Suggestions

  • .woodpecker/pr-review.yml:137-143 — the new NB comment is factually wrong.
    It says the fetch-models.sh run "executes branch-supplied shell with
    REVIEW_TOKEN in the env", but line 211 wraps that exact call in
    env -u REVIEW_TOKEN. The credential actually exposed to branch-supplied code
    there is CLAUDE_CODE_OAUTH_TOKEN. The argument the comment makes (a branch
    already controls .woodpecker/*.yml on pull_request, so this is not new
    exposure) is sound — it just names the wrong token, which will mislead whoever
    edits this next.

  • scripts/ci/pick-model.sh:40-45 asks the picker to weigh cost it cannot
    see.
    The prompt says "Match capability (and cost) to the task" and "do not
    send trivial or mechanical work to the most powerful model", but
    fetch-models.sh:67-76 emits no pricing signal at all — only context,
    max_output, and three capability booleans, all of which correlate with
    bigger, not cheaper. The same prompt then forbids reasoning from family
    names. Net effect: the only cost proxy left is the display name the prompt
    tells it not to trust. Either drop the cost instruction or emit a real
    price/tier signal if the API exposes one.

  • Cost regression in pr-review triage. pr-review.yml:224 dropped
    --model claude-haiku-4-5-..., so the triage — which reads the diff — now runs
    on the CLI default, and triage-prompt.md:26-29 acknowledges it "may run on a
    capable (and costly) model". Likewise each of the four cron pipelines now pays
    an extra full claude invocation per run just to choose a model. Worth
    measuring before this lands on the 15-minute alert-monitor cadence (it is
    gated behind NEW -eq 0 at alert-monitor.yml:176-179, so only on
    alert-bearing runs — but that is the busy case).

  • Degradation is silent for the four cron jobs. pr-review.yml:257 surfaces
    ${MODEL:-CLI default} in the posted review header, so a human sees it.
    security-scan, issue-fix, update-audit and alert-monitor only echo a
    log line; a persistent /v1/models 401 would quietly move every agent to a
    different model with no signal in the issue/PR they produce. Consider echoing
    the selected model into the artifact those jobs post.

  • scripts/ci/pick-model.sh:71-72 runs claude with no --settings, unlike
    every other agent invocation in this repo. Risk is low (input is the fixed
    guidance string plus the catalog; --allowedTools "" with the default
    permission mode grants no tools), but a Read-deny settings file would match the
    convention ci/agents/*/settings.json establishes.

  • scripts/ci/fetch-models.sh:59-61 warns on has_more=true but does not
    paginate.
    If it ever fires, the older/cheaper end of the catalog is missing
    while the pick still looks successful — the exact failure the limit=1000
    comment is guarding against, downgraded to a log line.

  • Unverified from here: I have no network, so I cannot confirm that
    /v1/models returns max_input_tokens, max_tokens and capabilities.*
    (the public schema documents only id/type/display_name/created_at).
    The PR body says this was verified live, and fetch-models.sh:87-89 warns if
    every context is null, so a shape change degrades rather than breaks. Check:
    run sh scripts/ci/fetch-models.sh --table in CI once and confirm no
    context=null column.

🟢 Nits

  • scripts/ci/fetch-models.sh:96-98 — the json mode has no caller; only
    --table is consumed. Dead path unless something else is planned.
  • scripts/ci/fetch-models.sh:46-51 — 2>/dev/null on curl -sS discards the
    message -S exists to print; only the HTTP code (or 000) survives into the
    log, which is a step backwards from the "precise cause in the job log" goal
    stated at pick-model.sh:26-29.
  • Existing helpers are invoked as bash scripts/ci/... (issue-fix.yml:61,
    e2e.yml:33); the new ones as sh. Deliberate (they are POSIX), but a one-line
    note in the headers would stop someone "fixing" the inconsistency.

Tests

There is no shell lint or unit test for scripts/ci/ in this repo today
(fetch-kubeconfig.sh, make-diag-kubeconfig.sh have none either), so shipping
without one matches convention, and sh -n + a live run is a reasonable bar for
these. That said, the blocking issue is pure string-validation logic and a
~10-line fixture test would have caught it: feed a handful of triage.json
bodies (valid id, hallucinated id, empty string, embedded newline, embedded
space) through the jq → grep -qxF → argv-expansion sequence and assert the
resulting argv. Worth extracting that validation out of the YAML into
scripts/ci/ so it is testable at all — right now it only exists inline in a
Woodpecker commands: block.

Recommendation

Request changes — 1 blocking issue.

🤖 **Automated review** — model `claude-opus-5` (complexity: complex), commit `9d360878573e2039ad45aea74f4057b15716d8fe`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Replaces hardcoded `--model` pins in all five agent pipelines with a live lookup against Anthropic's `/v1/models`, plus a triage agent that picks an id from that catalog. Two new POSIX helpers (`scripts/ci/fetch-models.sh`, `scripts/ci/pick-model.sh`) and a rewritten `triage-prompt.md` carry the logic. The design is careful — every failure path degrades to the CLI default rather than dying — but removing `map_model()` also removed the only thing that constrained the model string to a safe literal, and the replacement validation is bypassable by the agent that produces it. ## 🔴 Blocking ### Agent-supplied `MODEL` can inject arbitrary flags into the review `claude` call `.woodpecker/pr-review.yml:232-241` ```sh MODEL=$$(jq -r '.model // empty' "$$D/triage.json" ...) if [ -n "$$MODEL" ] && ! { [ -s "$$D/models.txt" ] && cut -f1 "$$D/models.txt" | grep -qxF -- "$$MODEL"; }; then ... ... claude $${MODEL:+--model $$MODEL} \ ``` The value is taken verbatim from `triage.json` and never shape-checked, and both gates that follow are permeable to a **multi-line** value: 1. `grep -qxF -- "$MODEL"` — POSIX `grep -F` treats newlines in the pattern operand as *pattern separators*. A two-line value whose **first** line is a real catalog id (`claude-sonnet-5\n--dangerously-skip-permissions`) matches and passes validation. 2. `$${MODEL:+--model $$MODEL}` is unquoted, so the result undergoes field splitting on the default `IFS` (space/tab/**newline**) and expands to `--model claude-sonnet-5 --dangerously-skip-permissions` — three argv entries, the third an attacker-chosen CLI flag. This is reachable, not theoretical. The triage agent's inputs are `./.pr-review/diff.patch` and `pr.json` — attacker-controlled by this file's own stated threat model (`.woodpecker/pr-review.yml:218-220`, and `ci/agents/pr-review/settings.json` whose header calls the diff "attacker-controlled input"). It runs with `--allowedTools "Read,Glob,Grep,Write"`, and `settings.json` denies only `Read` — Write is unscoped (update-audit.yml:60-64 records that Write path-scoping "was tested and found NOT to be enforced under `--permission-mode acceptEdits`"). So a prompt-injected diff can make the triage write any `model` string it likes, and that string becomes flags on the review agent's invocation — e.g. `--dangerously-skip-permissions`, defeating the `--allowedTools` allowlist and the Read sandbox that exist specifically to contain that agent. `CLAUDE_CODE_OAUTH_TOKEN` is in that process's env. This is a regression introduced here: the removed `map_model()` returned one of three literal ids from a `case` allowlist and the call site used a quoted `--model "$$MODEL"`, so neither hole existed before. `scripts/ci/pick-model.sh` is **not** affected — `grep -oE` matches within a single line, so `CHOICE` cannot contain a newline, and its `grep -qxF` then rejects any value containing whitespace. Only the pr-review path is exposed. Fix — reject anything that is not a single safe token, before the catalog check: ```sh case "$$MODEL" in *[!A-Za-z0-9._-]*) MODEL= ;; esac ``` (Quoting alone is not enough: `$${MODEL:+--model $$MODEL}` cannot be quoted without emitting an empty `--model` arg; a `set --`/positional-parameter form or the `case` guard above is needed.) One command settles the first half if you want to see it locally: `printf 'claude-sonnet-5\n' > /tmp/ids && grep -qxF -- "$(printf 'claude-sonnet-5\n--foo')" /tmp/ids && echo MATCHED`. ## 🟡 Suggestions - **`.woodpecker/pr-review.yml:137-143` — the new NB comment is factually wrong.** It says the `fetch-models.sh` run "executes branch-supplied shell with REVIEW_TOKEN in the env", but line 211 wraps that exact call in `env -u REVIEW_TOKEN`. The credential actually exposed to branch-supplied code there is `CLAUDE_CODE_OAUTH_TOKEN`. The argument the comment makes (a branch already controls `.woodpecker/*.yml` on `pull_request`, so this is not new exposure) is sound — it just names the wrong token, which will mislead whoever edits this next. - **`scripts/ci/pick-model.sh:40-45` asks the picker to weigh cost it cannot see.** The prompt says "Match capability (and cost) to the task" and "do not send trivial or mechanical work to the most powerful model", but `fetch-models.sh:67-76` emits no pricing signal at all — only context, max_output, and three capability booleans, all of which correlate with *bigger*, not cheaper. The same prompt then forbids reasoning from family names. Net effect: the only cost proxy left is the display name the prompt tells it not to trust. Either drop the cost instruction or emit a real price/tier signal if the API exposes one. - **Cost regression in pr-review triage.** `pr-review.yml:224` dropped `--model claude-haiku-4-5-...`, so the triage — which reads the diff — now runs on the CLI default, and `triage-prompt.md:26-29` acknowledges it "may run on a capable (and costly) model". Likewise each of the four cron pipelines now pays an extra full `claude` invocation per run just to choose a model. Worth measuring before this lands on the 15-minute alert-monitor cadence (it is gated behind `NEW -eq 0` at `alert-monitor.yml:176-179`, so only on alert-bearing runs — but that is the busy case). - **Degradation is silent for the four cron jobs.** `pr-review.yml:257` surfaces `${MODEL:-CLI default}` in the posted review header, so a human sees it. `security-scan`, `issue-fix`, `update-audit` and `alert-monitor` only echo a log line; a persistent `/v1/models` 401 would quietly move every agent to a different model with no signal in the issue/PR they produce. Consider echoing the selected model into the artifact those jobs post. - **`scripts/ci/pick-model.sh:71-72` runs `claude` with no `--settings`,** unlike every other agent invocation in this repo. Risk is low (input is the fixed guidance string plus the catalog; `--allowedTools ""` with the default permission mode grants no tools), but a Read-deny settings file would match the convention `ci/agents/*/settings.json` establishes. - **`scripts/ci/fetch-models.sh:59-61` warns on `has_more=true` but does not paginate.** If it ever fires, the older/cheaper end of the catalog is missing while the pick still looks successful — the exact failure the `limit=1000` comment is guarding against, downgraded to a log line. - **Unverified from here:** I have no network, so I cannot confirm that `/v1/models` returns `max_input_tokens`, `max_tokens` and `capabilities.*` (the public schema documents only `id`/`type`/`display_name`/`created_at`). The PR body says this was verified live, and `fetch-models.sh:87-89` warns if every `context` is null, so a shape change degrades rather than breaks. Check: run `sh scripts/ci/fetch-models.sh --table` in CI once and confirm no `context=null` column. ## 🟢 Nits - `scripts/ci/fetch-models.sh:96-98` — the `json` mode has no caller; only `--table` is consumed. Dead path unless something else is planned. - `scripts/ci/fetch-models.sh:46-51` — `2>/dev/null` on `curl -sS` discards the message `-S` exists to print; only the HTTP code (or `000`) survives into the log, which is a step backwards from the "precise cause in the job log" goal stated at `pick-model.sh:26-29`. - Existing helpers are invoked as `bash scripts/ci/...` (`issue-fix.yml:61`, `e2e.yml:33`); the new ones as `sh`. Deliberate (they are POSIX), but a one-line note in the headers would stop someone "fixing" the inconsistency. ## Tests There is no shell lint or unit test for `scripts/ci/` in this repo today (`fetch-kubeconfig.sh`, `make-diag-kubeconfig.sh` have none either), so shipping without one matches convention, and `sh -n` + a live run is a reasonable bar for these. That said, the blocking issue is pure string-validation logic and a ~10-line fixture test would have caught it: feed a handful of `triage.json` bodies (valid id, hallucinated id, empty string, embedded newline, embedded space) through the `jq` → `grep -qxF` → argv-expansion sequence and assert the resulting argv. Worth extracting that validation out of the YAML into `scripts/ci/` so it is testable at all — right now it only exists inline in a Woodpecker `commands:` block. ## Recommendation Request changes — 1 blocking issue. <!-- pr-review:sha=9d360878573e2039ad45aea74f4057b15716d8fe -->
claude-bot added 1 commit 2026-07-25 05:12:07 +00:00
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>
Author
Member

Good and correct catch — this was a real regression, not a nit. Fixed in f333dfd6.

🔴 Flag injection via agent-supplied MODEL

Confirmed both halves locally with your repro:

  • grep -qxF -- "$(printf 'claude-sonnet-5\n--dangerously-skip-permissions')" /tmp/ids → MATCHED (grep -F splits the multi-line operand into separate patterns; the first line is a real id).
  • the unquoted ${MODEL:+--model $MODEL} then word-splits the newline into a third argv entry.

You're right that removing map_model() is what opened it (literal case output + quoted --model "$MODEL" before). Fix is the single-token guard you suggested, placed before the catalog check:

case "$MODEL" in ""|*[!A-Za-z0-9._-]*) MODEL= ;; esac

Verified the payloads now collapse to a plain claude -p (CLI default) while real ids pass:

claude-opus-5                                  -> claude --model claude-opus-5 -p
claude-sonnet-5\n--dangerously-skip-permissions -> claude -p
claude-sonnet-5 --dangerously-skip-permissions  -> claude -p
hallucinated-model                              -> claude -p

Mirrored the guard in pick-model.sh as defence in depth (agreed it wasn't exploitable there — grep -oE keeps CHOICE single-line — but the invariant should be explicit).

🟡 Also fixed

  • Wrong token in the NB comment — corrected: the fetch-models.sh call is env -u REVIEW_TOKEN'd, so it's CLAUDE_CODE_OAUTH_TOKEN that branch shell sees there, not REVIEW_TOKEN. Thanks — I introduced that mismatch across two commits.
  • Picker weighing invisible cost — reworded: /v1/models exposes no price, so the prompt now reasons from the visible capability signals (context / max_output / effort·thinking·code_exec) as the scale proxy and drops the bare "cost" language. Same change in triage-prompt.md.
  • curl -sS message discarded — dropped 2>/dev/null on the curl so its -S error reaches the log.

Deferred (unchanged rationale)

  • Triage/selection cost and silent degradation in the four cron artifacts — real, tracked; the selected model is already in each job's CI log, and surfacing it in the posted artifact + measuring the triage spend is the right follow-up rather than more scope here.
  • --settings on the picker, true pagination past limit=1000, json mode / sh vs bash header notes — low-risk, noted.
  • Extracting the validation into scripts/ci/ for a fixture test — agreed this is the right home for it, and this exact bug is the argument for it; opening it as the offline-test follow-up.
Good and correct catch — this was a real regression, not a nit. Fixed in `f333dfd6`. ### 🔴 Flag injection via agent-supplied `MODEL` Confirmed both halves locally with your repro: - `grep -qxF -- "$(printf 'claude-sonnet-5\n--dangerously-skip-permissions')" /tmp/ids` → **MATCHED** (grep -F splits the multi-line operand into separate patterns; the first line is a real id). - the unquoted `${MODEL:+--model $MODEL}` then word-splits the newline into a third argv entry. You're right that removing `map_model()` is what opened it (literal `case` output + quoted `--model "$MODEL"` before). Fix is the single-token guard you suggested, placed before the catalog check: ```sh case "$MODEL" in ""|*[!A-Za-z0-9._-]*) MODEL= ;; esac ``` Verified the payloads now collapse to a plain `claude -p` (CLI default) while real ids pass: ``` claude-opus-5 -> claude --model claude-opus-5 -p claude-sonnet-5\n--dangerously-skip-permissions -> claude -p claude-sonnet-5 --dangerously-skip-permissions -> claude -p hallucinated-model -> claude -p ``` Mirrored the guard in `pick-model.sh` as defence in depth (agreed it wasn't exploitable there — `grep -oE` keeps `CHOICE` single-line — but the invariant should be explicit). ### 🟡 Also fixed - **Wrong token in the NB comment** — corrected: the `fetch-models.sh` call is `env -u REVIEW_TOKEN`'d, so it's `CLAUDE_CODE_OAUTH_TOKEN` that branch shell sees there, not `REVIEW_TOKEN`. Thanks — I introduced that mismatch across two commits. - **Picker weighing invisible cost** — reworded: `/v1/models` exposes no price, so the prompt now reasons from the visible capability signals (context / max_output / effort·thinking·code_exec) as the scale proxy and drops the bare "cost" language. Same change in `triage-prompt.md`. - **`curl -sS` message discarded** — dropped `2>/dev/null` on the curl so its `-S` error reaches the log. ### Deferred (unchanged rationale) - **Triage/selection cost** and **silent degradation in the four cron artifacts** — real, tracked; the selected model is already in each job's CI log, and surfacing it in the posted artifact + measuring the triage spend is the right follow-up rather than more scope here. - **`--settings` on the picker**, **true pagination past `limit=1000`**, **`json` mode / `sh` vs `bash` header notes** — low-risk, noted. - **Extracting the validation into `scripts/ci/` for a fixture test** — agreed this is the right home for it, and this exact bug is the argument for it; opening it as the offline-test follow-up.
claude-bot added 1 commit 2026-07-25 05:27:05 +00:00
Addresses the remaining pr-review items.

- Extract the untrusted-model validation out of the pipeline YAML into
  scripts/ci/valid-model.sh (single safe-token + catalog-membership gate) and
  reuse it from both pick-model.sh and pr-review.yml, so the security-critical
  logic lives in one tested place. Add scripts/ci/test-model-selection.sh, an
  offline fixture test (no network / claude / Woodpecker) that pins the
  behaviour: valid ids pass; hallucinated, empty, space-, newline-, leading-
  dash-, and metachar-injected values are rejected; empty catalog rejects all.
  This is the regression test for the flag-injection fix.
- pr-review triage cost: hand the triage the COMPLETE changed-file list
  (files.txt) plus a diff capped at 4000 lines (diff.triage.patch) instead of
  the full up-to-20k review diff, so classifying a PR on the default model stays
  cheap. stats.txt still carries the true totals; triage-prompt.md updated.
- Doc nits: note the new helpers are POSIX `sh` (not `bash` like the older
  scripts/ci/*.sh), and that fetch-models.sh's json mode is a debug affordance.

Deferred with rationale (also in the PR thread):
- Model id in the four cron jobs' posted artifacts: selection (including the
  fallback-to-default) is already logged per run in the CI output, the
  operational surface for this; threading it through four different posted
  formats is a separate observability follow-up.
- --settings sandbox on the picker: it is granted no tools (--allowedTools ""),
  so there is nothing for a Read-deny sandbox to constrain -- the other agents'
  sandboxes exist to scope tools they DO have.
- Pagination past limit=1000: that is the API's max page size; >1000 models is
  not a real scenario, and the has_more warning turns even that into a visible
  log line rather than silent truncation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Closing out the remaining items in a7890e0a.

Implemented

  • Extract the validation + make it testable (your Tests section) — the
    untrusted-model gate now lives in scripts/ci/valid-model.sh (single
    safe-token + catalog-membership check), reused by both pick-model.sh and
    pr-review.yml so the security-critical logic exists in one place instead of
    inline in a commands: block. Added scripts/ci/test-model-selection.sh, an
    offline fixture test (no network / claude / Woodpecker). All 9 pass:
    ok  valid id passes / valid dated id passes
    ok  hallucinated / empty / empty-catalog rejected
    ok  space / newline / leading-dash / metachar injection rejected
    
    That is now the regression test for the flag-injection fix.
  • Triage cost (#5) — the triage no longer sees the full up-to-20k review
    diff. The pipeline writes files.txt (the complete changed-file list) and
    diff.triage.patch (capped at 4000 lines); triage-prompt.md reads those +
    stats.txt (true totals) and consults the capped diff only when needed. So a
    docs-only PR is classified from stats + filenames, not by reading 20k lines on
    the default model.
  • Nits — headers now note the helpers are POSIX sh (deliberately, vs the
    older bash scripts/ci/*.sh), and that fetch-models.sh's json mode is a
    human/debug affordance with no pipeline consumer.

Deferred, with reasons

  • Selected model in the four cron jobs' artifacts (#4) — selection, including
    the fallback-to-default, is already echoed per run in each job's CI log,
    which is the operational surface where you'd diagnose "why did this agent
    behave differently". Threading it into four different posted formats (issue
    body / PR / comment) is an observability enhancement I'd rather do as its own
    small change than fold in here.
  • --settings sandbox on the picker (#5-nit) — the picker is invoked with
    --allowedTools "", i.e. no tools at all, so there is nothing for a Read-deny
    settings file to constrain. The sandboxes on the other agents exist to scope
    the tools they do hold (Read/Grep/Write); this call holds none.
  • Pagination past limit=1000 — that is the API's maximum page size, and the
    account lists ~11 models; >1000 is not a real scenario. The has_more warning
    turns even that impossible case into a visible log line rather than silent
    truncation, which is the failure the reviewer (rightly) wanted to avoid.

I believe that closes every item across the three rounds — blocking, suggestions,
and nits — either implemented or justified above. The offline test also gives us
a place to pin any future selection-logic change.

Closing out the remaining items in `a7890e0a`. ### Implemented - **Extract the validation + make it testable** (your Tests section) — the untrusted-model gate now lives in `scripts/ci/valid-model.sh` (single safe-token + catalog-membership check), reused by both `pick-model.sh` and `pr-review.yml` so the security-critical logic exists in one place instead of inline in a `commands:` block. Added `scripts/ci/test-model-selection.sh`, an offline fixture test (no network / `claude` / Woodpecker). All 9 pass: ``` ok valid id passes / valid dated id passes ok hallucinated / empty / empty-catalog rejected ok space / newline / leading-dash / metachar injection rejected ``` That is now the regression test for the flag-injection fix. - **Triage cost (#5)** — the triage no longer sees the full up-to-20k review diff. The pipeline writes `files.txt` (the *complete* changed-file list) and `diff.triage.patch` (capped at 4000 lines); `triage-prompt.md` reads those + `stats.txt` (true totals) and consults the capped diff only when needed. So a docs-only PR is classified from stats + filenames, not by reading 20k lines on the default model. - **Nits** — headers now note the helpers are POSIX `sh` (deliberately, vs the older `bash scripts/ci/*.sh`), and that `fetch-models.sh`'s `json` mode is a human/debug affordance with no pipeline consumer. ### Deferred, with reasons - **Selected model in the four cron jobs' artifacts (#4)** — selection, including the fallback-to-default, is already `echo`ed per run in each job's CI log, which is the operational surface where you'd diagnose "why did this agent behave differently". Threading it into four different posted formats (issue body / PR / comment) is an observability enhancement I'd rather do as its own small change than fold in here. - **`--settings` sandbox on the picker (#5-nit)** — the picker is invoked with `--allowedTools ""`, i.e. no tools at all, so there is nothing for a Read-deny settings file to constrain. The sandboxes on the other agents exist to scope the tools they *do* hold (Read/Grep/Write); this call holds none. - **Pagination past `limit=1000`** — that is the API's maximum page size, and the account lists ~11 models; >1000 is not a real scenario. The `has_more` warning turns even that impossible case into a visible log line rather than silent truncation, which is the failure the reviewer (rightly) wanted to avoid. I believe that closes every item across the three rounds — blocking, suggestions, and nits — either implemented or justified above. The offline test also gives us a place to pin any future selection-logic change.
review-bot left a comment
Member

🤖 Automated review — model claude-opus-5 (complexity: complex), commit a7890e0aa2272b41f579da6fdba3ee129b50e4a2.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Replaces the hardcoded --model pins in all five agent pipelines with a live
/v1/models lookup (scripts/ci/fetch-models.sh) plus an LLM picker
(scripts/ci/pick-model.sh) and a shared validation gate
(scripts/ci/valid-model.sh) that is unit-tested. The fallback design is good —
every failure path lands on "omit --model" rather than a named model — and the
injection gate in valid-model.sh:24-31 is the right shape (token allowlist
plus catalog membership, grep -qxF -- correctly guarded). I found no
blocking defect, but there are a few real robustness/consistency gaps and one
claim I could not verify from the checkout.

🟡 Suggestions

1. REVIEW_TOKEN is stripped for fetch-models.sh but not for valid-model.sh
— .woodpecker/pr-review.yml:247

if MODELS_TABLE=$(env -u REVIEW_TOKEN sh "$HOMELAB/scripts/ci/fetch-models.sh" --table); then   # line 219
MODEL=$(sh "$HOMELAB/scripts/ci/valid-model.sh" "$MODEL" < "$D/models.txt")                     # line 247

Both invoke a script from $HOMELAB, which for a homelab PR is the PR head
checkout
(pr-review.yml:136) — i.e. branch-supplied shell. Line 219 wraps it
in env -u REVIEW_TOKEN; line 247 does not, so the write:repository PAT sits in
its environment. The comment you added at pr-review.yml:138-142 is the
justification for line 219, and it applies just as much to line 247.

Worth noting the case the comment does not cover: for the mirrored repos
(shelly-manager etc.) the /tmp clone of trusted homelab@master is only used
when $WS/ci/agents/pr-review/prompt.md is absent (pr-review.yml:143). A PR
branch in one of those repos that simply adds that file keeps HOMELAB="$WS",
so both scripts then come from that branch too.

I'm not calling this blocking, because on a pull_request event Woodpecker
already takes .woodpecker/pr-review.yml itself from the PR branch — the trust
boundary is already "PR branch may run code in this step". But the fix is one
env -u REVIEW_TOKEN, and it keeps the file's own invariant consistent.

2. Line 247 is the one model-selection call that can kill the step —
.woodpecker/pr-review.yml:247

The block runs under set -eu (pr-review.yml:126), and VAR=$(cmd) propagates
cmd's status, so if $HOMELAB/scripts/ci/valid-model.sh is missing or
non-executable sh exits 127 and the whole step aborts — no review posted, red
pipeline. Every other selection call is guarded (|| true at line 240, if at
line 219, and pick-model.sh is careful to always exit 0), and the comment
three lines above states "a run never dies on selection." Adding || true to
line 247 restores that invariant. Realistic trigger: a homelab PR that
moves/deletes scripts/ci/, or a mirrored-repo branch as in (1).

3. Nothing enforces the complexity → capability pairing, and the triage reads
attacker-controlled input — ci/agents/pr-review/triage-prompt.md:74-78

valid-model.sh blocks flag injection, but any id in the catalog is accepted.
The triage agent reads up to 4000 lines of attacker-supplied diff and its Write
is not path-scoped (as your own comment at pr-review.yml:241-242 notes), so an
injected diff can steer triage.json to the weakest catalog entry and get a
shallow review of its own change. This is not a regression (the old map_model
keyword path had the same exposure), but the search space is now the whole
catalog. A cheap hardening that stays inside the "no hardcoded family" rule:
when CPLX = complex, ignore the triage's pick and leave MODEL empty so the
review runs on the CLI default — the strongest configured model — instead of an
injectable one.

4. The capability fields the whole design rests on are not in the documented
/v1/models response — scripts/ci/fetch-models.sh:71-80

.max_input_tokens, .max_tokens and .capabilities.{effort,thinking, code_execution}.supported are not part of the publicly documented Models API
response (id, type, display_name, created_at). If the OAuth/beta variant
doesn't return them, every row degrades to
context=null max_output=null effort=false thinking=false code_exec=false, and
both selectors fall back to judging on name and release date — exactly the
family-name heuristic this PR sets out to eliminate. I could not confirm this
from the checkout;
the PR body says it was verified live. The check that
settles it: run sh scripts/ci/fetch-models.sh inside ci-claude:2.1.218 with a
real token and confirm non-null context=/max_output= in the output.

Credit where due: fetch-models.sh:83-86 already detects the all-null-context
case and warns. Consider making that warning louder at the call site — right now
it lands in stderr and the pipelines print nothing about it.

5. Cost may go up, not down — .woodpecker/pr-review.yml:232, scripts/ci/pick-model.sh:75-76

Two effects worth being aware of:

  • The pr-review triage lost its --model claude-haiku-4-5-20251001 pin and now
    runs on the CLI default. Capping its diff at 4000 lines (pr-review.yml:197)
    mitigates the token count, but the per-token rate is now whatever the account
    default is.
  • Each of the four fixed jobs gains a whole extra claude invocation on the
    default model, purely to pick a model, where previously they ran
    claude-sonnet-5 directly. For security-fix and issue-fix — which already
    select "a capable model" by the task description — that bootstrap call may cost
    more than it saves.

Not a defect, but the pipelines no longer have any cost ceiling, and that is a
change in properties worth stating in the PR description.

6. pick-model.sh is the only agent in the fleet with no --settings —
scripts/ci/pick-model.sh:76

claude --allowedTools "" -p grants no tools, so the practical risk is low, but
every other agent invocation in this repo passes a --settings file with the
Read deny-list (/proc, /root, /home, /etc, .git) precisely because
CLAUDE_CODE_OAUTH_TOKEN is in the environment. If --allowedTools "" is ever
parsed as "unset" rather than "empty list", this call gets default tools with no
sandbox. Passing --settings ci/agents/pr-review/settings.json (or a small
shared one) costs nothing and removes the assumption.

7. --max-time 20 with no retry — scripts/ci/fetch-models.sh:51

A single transient network blip silently drops every job to the CLI default for
that run. --retry 2 --retry-connrefused would make the degradation rarer
without weakening the fallback.

🟢 Nits

  • scripts/ci/valid-model.sh:11-13 — the comment says a value with "a leading
    dash ... can never pass" the shape check, but - is inside the allowed class
    [A-Za-z0-9._-]; --dangerously-skip-permissions clears step 1 and is
    rejected only by the catalog-membership check at line 29. The behaviour is
    correct (and the empty-catalog guard at line 28 keeps it safe), but the comment
    attributes the protection to the wrong check — which matters if anyone reuses
    this helper without a catalog.
  • The six-line "Model is chosen from the LIVE catalog" comment is duplicated
    verbatim in four pipelines (alert-monitor.yml:191-195,
    issue-fix.yml:151-155, security-scan.yml:99-103,
    update-audit.yml:84-89). pick-model.sh:1-20 already carries the full
    rationale; a one-line pointer per site would keep them from drifting apart.
  • scripts/ci/fetch-models.sh:16-17 documents the default JSON mode as having no
    consumer. Fine as a debugging affordance — just flagging it as knowingly unused.

Tests

The new scripts/ci/test-model-selection.sh is well-targeted: it covers the
exact injection shapes that matter (embedded space, embedded newline, bare
leading dash, ;rm -rf /, hallucinated id, empty candidate, empty catalog), and
the fixture uses real tab characters, so the cut -f1 path is genuinely
exercised. I read it closely but could not execute it in this sandbox, so I
verified it by inspection only.

Three gaps:

  1. It never runs. Grepping the repo, test-model-selection.sh is referenced
    only by its own header and by a comment in pr-review.yml:243 — no pipeline
    invokes it. A "regression test for the flag-injection fix" that CI never runs
    will rot. e2e.yml/e2e-test.yml already call bash scripts/ci/…, so
    there's an obvious home for sh scripts/ci/test-model-selection.sh.
  2. pick-model.sh's extraction is untested (pick-model.sh:78-81). The
    grep -oE | head -1 | sed chain is the other place an untrusted model reply
    is parsed. Worth cases for: reply wrapped in a ``` fence, prose before the
    JSON, two "model" keys (must take the first), and empty output.
  3. fetch-models.sh's jq mapping is untested — and per suggestion (4) it is
    the piece most likely to break silently on an API shape change. A recorded
    /v1/models fixture piped through the same jq -c filter would catch that,
    and would double as executable documentation of the response shape you
    verified live.

No prompt-injection attempt was present in the PR title, body, or diff.

Recommendation

Looks reasonable — no blocking issues; comments above are optional. Items 1, 2
and the test-wiring gap are the ones I'd fix before merge.

🤖 **Automated review** — model `claude-opus-5` (complexity: complex), commit `a7890e0aa2272b41f579da6fdba3ee129b50e4a2`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Replaces the hardcoded `--model` pins in all five agent pipelines with a live `/v1/models` lookup (`scripts/ci/fetch-models.sh`) plus an LLM picker (`scripts/ci/pick-model.sh`) and a shared validation gate (`scripts/ci/valid-model.sh`) that is unit-tested. The fallback design is good — every failure path lands on "omit `--model`" rather than a named model — and the injection gate in `valid-model.sh:24-31` is the right shape (token allowlist **plus** catalog membership, `grep -qxF --` correctly guarded). I found no blocking defect, but there are a few real robustness/consistency gaps and one claim I could not verify from the checkout. ## 🟡 Suggestions **1. `REVIEW_TOKEN` is stripped for `fetch-models.sh` but not for `valid-model.sh` — `.woodpecker/pr-review.yml:247`** ``` if MODELS_TABLE=$(env -u REVIEW_TOKEN sh "$HOMELAB/scripts/ci/fetch-models.sh" --table); then # line 219 MODEL=$(sh "$HOMELAB/scripts/ci/valid-model.sh" "$MODEL" < "$D/models.txt") # line 247 ``` Both invoke a script from `$HOMELAB`, which for a homelab PR **is the PR head checkout** (`pr-review.yml:136`) — i.e. branch-supplied shell. Line 219 wraps it in `env -u REVIEW_TOKEN`; line 247 does not, so the write:repository PAT sits in its environment. The comment you added at `pr-review.yml:138-142` is the justification for line 219, and it applies just as much to line 247. Worth noting the case the comment does *not* cover: for the **mirrored repos** (shelly-manager etc.) the `/tmp` clone of trusted homelab@master is only used when `$WS/ci/agents/pr-review/prompt.md` is absent (`pr-review.yml:143`). A PR branch in one of those repos that simply *adds* that file keeps `HOMELAB="$WS"`, so both scripts then come from that branch too. I'm not calling this blocking, because on a `pull_request` event Woodpecker already takes `.woodpecker/pr-review.yml` itself from the PR branch — the trust boundary is already "PR branch may run code in this step". But the fix is one `env -u REVIEW_TOKEN`, and it keeps the file's own invariant consistent. **2. Line 247 is the one model-selection call that can kill the step — `.woodpecker/pr-review.yml:247`** The block runs under `set -eu` (`pr-review.yml:126`), and `VAR=$(cmd)` propagates `cmd`'s status, so if `$HOMELAB/scripts/ci/valid-model.sh` is missing or non-executable `sh` exits 127 and the whole step aborts — no review posted, red pipeline. Every other selection call is guarded (`|| true` at line 240, `if` at line 219, and `pick-model.sh` is careful to always `exit 0`), and the comment three lines above states "a run never dies on selection." Adding `|| true` to line 247 restores that invariant. Realistic trigger: a homelab PR that moves/deletes `scripts/ci/`, or a mirrored-repo branch as in (1). **3. Nothing enforces the complexity → capability pairing, and the triage reads attacker-controlled input — `ci/agents/pr-review/triage-prompt.md:74-78`** `valid-model.sh` blocks flag injection, but any id in the catalog is accepted. The triage agent reads up to 4000 lines of attacker-supplied diff and its `Write` is not path-scoped (as your own comment at `pr-review.yml:241-242` notes), so an injected diff can steer `triage.json` to the *weakest* catalog entry and get a shallow review of its own change. This is not a regression (the old `map_model` keyword path had the same exposure), but the search space is now the whole catalog. A cheap hardening that stays inside the "no hardcoded family" rule: when `CPLX = complex`, ignore the triage's pick and leave `MODEL` empty so the review runs on the CLI default — the strongest configured model — instead of an injectable one. **4. The capability fields the whole design rests on are not in the documented `/v1/models` response — `scripts/ci/fetch-models.sh:71-80`** `.max_input_tokens`, `.max_tokens` and `.capabilities.{effort,thinking, code_execution}.supported` are not part of the publicly documented Models API response (`id`, `type`, `display_name`, `created_at`). If the OAuth/beta variant doesn't return them, every row degrades to `context=null max_output=null effort=false thinking=false code_exec=false`, and both selectors fall back to judging on **name and release date** — exactly the family-name heuristic this PR sets out to eliminate. **I could not confirm this from the checkout;** the PR body says it was verified live. The check that settles it: run `sh scripts/ci/fetch-models.sh` inside `ci-claude:2.1.218` with a real token and confirm non-null `context=`/`max_output=` in the output. Credit where due: `fetch-models.sh:83-86` already detects the all-null-context case and warns. Consider making that warning louder at the call site — right now it lands in stderr and the pipelines print nothing about it. **5. Cost may go up, not down — `.woodpecker/pr-review.yml:232`, `scripts/ci/pick-model.sh:75-76`** Two effects worth being aware of: - The pr-review triage lost its `--model claude-haiku-4-5-20251001` pin and now runs on the CLI default. Capping its diff at 4000 lines (`pr-review.yml:197`) mitigates the token count, but the per-token rate is now whatever the account default is. - Each of the four fixed jobs gains a *whole extra* `claude` invocation on the default model, purely to pick a model, where previously they ran `claude-sonnet-5` directly. For `security-fix` and `issue-fix` — which already select "a capable model" by the task description — that bootstrap call may cost more than it saves. Not a defect, but the pipelines no longer have any cost ceiling, and that is a change in properties worth stating in the PR description. **6. `pick-model.sh` is the only agent in the fleet with no `--settings` — `scripts/ci/pick-model.sh:76`** `claude --allowedTools "" -p` grants no tools, so the practical risk is low, but every other agent invocation in this repo passes a `--settings` file with the Read deny-list (`/proc`, `/root`, `/home`, `/etc`, `.git`) precisely because `CLAUDE_CODE_OAUTH_TOKEN` is in the environment. If `--allowedTools ""` is ever parsed as "unset" rather than "empty list", this call gets default tools with no sandbox. Passing `--settings ci/agents/pr-review/settings.json` (or a small shared one) costs nothing and removes the assumption. **7. `--max-time 20` with no retry — `scripts/ci/fetch-models.sh:51`** A single transient network blip silently drops every job to the CLI default for that run. `--retry 2 --retry-connrefused` would make the degradation rarer without weakening the fallback. ## 🟢 Nits - `scripts/ci/valid-model.sh:11-13` — the comment says a value with "a leading dash ... can never pass" the shape check, but `-` is inside the allowed class `[A-Za-z0-9._-]`; `--dangerously-skip-permissions` clears step 1 and is rejected only by the catalog-membership check at line 29. The behaviour is correct (and the empty-catalog guard at line 28 keeps it safe), but the comment attributes the protection to the wrong check — which matters if anyone reuses this helper without a catalog. - The six-line "Model is chosen from the LIVE catalog" comment is duplicated verbatim in four pipelines (`alert-monitor.yml:191-195`, `issue-fix.yml:151-155`, `security-scan.yml:99-103`, `update-audit.yml:84-89`). `pick-model.sh:1-20` already carries the full rationale; a one-line pointer per site would keep them from drifting apart. - `scripts/ci/fetch-models.sh:16-17` documents the default JSON mode as having no consumer. Fine as a debugging affordance — just flagging it as knowingly unused. ## Tests The new `scripts/ci/test-model-selection.sh` is well-targeted: it covers the exact injection shapes that matter (embedded space, embedded newline, bare leading dash, `;rm -rf /`, hallucinated id, empty candidate, empty catalog), and the fixture uses real tab characters, so the `cut -f1` path is genuinely exercised. I read it closely but could **not execute it** in this sandbox, so I verified it by inspection only. Three gaps: 1. **It never runs.** Grepping the repo, `test-model-selection.sh` is referenced only by its own header and by a comment in `pr-review.yml:243` — no pipeline invokes it. A "regression test for the flag-injection fix" that CI never runs will rot. `e2e.yml`/`e2e-test.yml` already call `bash scripts/ci/…`, so there's an obvious home for `sh scripts/ci/test-model-selection.sh`. 2. **`pick-model.sh`'s extraction is untested** (`pick-model.sh:78-81`). The `grep -oE | head -1 | sed` chain is the other place an untrusted model reply is parsed. Worth cases for: reply wrapped in a ``` fence, prose before the JSON, two `"model"` keys (must take the first), and empty output. 3. **`fetch-models.sh`'s jq mapping is untested** — and per suggestion (4) it is the piece most likely to break silently on an API shape change. A recorded `/v1/models` fixture piped through the same `jq -c` filter would catch that, and would double as executable documentation of the response shape you verified live. No prompt-injection attempt was present in the PR title, body, or diff. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. Items 1, 2 and the test-wiring gap are the ones I'd fix before merge. <!-- pr-review:sha=a7890e0aa2272b41f579da6fdba3ee129b50e4a2 -->
claude-bot added 1 commit 2026-07-25 05:43:10 +00:00
Addresses the a7890e0a review.

- pr-review.yml: strip REVIEW_TOKEN from the valid-model.sh call too (it runs a
  script from $HOMELAB, branch-supplied for a homelab PR), and guard it with
  `|| true` so a missing script can't kill the step -- restoring the stated "a
  run never dies on selection" invariant. For a complex/security-sensitive
  review, ignore the (injectable) triage pick and run on the CLI default
  (strongest configured) model.
- valid-model.sh: reject a leading dash at the shape check (making the header
  accurate) so an option-like value is refused even without a catalog.
- fetch-models.sh: --retry 2 --retry-connrefused so a transient blip doesn't
  drop the run to the default; add a FETCH_MODELS_BODY_FILE test seam.
- pick-model.sh: pass --settings (new ci/agents/model-picker/settings.json,
  Read deny-list) as belt-and-suspenders behind --allowedTools "".
- test-model-selection.sh: add fetch-models.sh mapping tests (well-formed body
  -> non-null capability columns; degraded body -> loud warning) via the seam,
  and wire the whole suite into .woodpecker/e2e.yml so it runs in CI.
- Condense the duplicated model-selection comment in the four fixed jobs to a
  one-line pointer to pick-model.sh.
- PR description: state the cost / no-fixed-ceiling property change.

Deferred with rationale (in the PR thread): selected-model in the four crons'
posted artifacts (already logged per run), pagination past limit=1000 (API max;
has_more warns), and unit-testing pick-model.sh's JSON extraction (its output
always passes through the tested valid-model.sh gate, and the picker prompt is
developer-controlled, not attacker input).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

All items from this round addressed in 44a42968.

Implemented

  • #1 REVIEW_TOKEN on the valid-model.sh call — now env -u REVIEW_TOKEN,
    same as the fetch-models.sh call. You're right it runs branch-supplied shell
    from $HOMELAB too.
  • #2 line 247 could kill the step — added || true; a missing/moved
    scripts/ci/valid-model.sh now leaves MODEL empty (→ CLI default) instead of
    aborting the step under set -eu. Restores the "never dies on selection"
    invariant.
  • #3 injectable pick on a sensitive review — when complexity=complex, the
    triage's pick is now ignored and the review runs on the CLI default (strongest
    configured) model. An injected diff can no longer steer a complex/security
    review onto the weakest catalog entry.
  • #6 --settings on the picker — added ci/agents/model-picker/settings.json
    (the same Read deny-list) and pass it; still --allowedTools "", so it's
    belt-and-suspenders against the "empty parsed as unset" case you raised.
  • #7 --retry — --retry 2 --retry-connrefused on the catalog fetch.
  • Tests / #4 — extracted the validation into scripts/ci/valid-model.sh
    (already there) and added a FETCH_MODELS_BODY_FILE seam so the fetch-models
    jq mapping is now tested offline too: a well-formed /v1/models body maps to
    non-null capability columns, and a body missing the fields triggers the loud
    warning. Wired the whole suite into .woodpecker/e2e.yml so it actually
    runs in CI (your "it never runs" point) — 11 cases, all green locally.
  • Nits — valid-model.sh now rejects a leading dash at the shape check (so
    the header is accurate and the helper is safe even without a catalog); the
    duplicated 6-line comment in the four fixed jobs is now a one-line pointer to
    pick-model.sh.
  • #5 cost — added a "Properties & tradeoffs (cost)" section to the PR
    description spelling out that there's no longer a fixed cost ceiling (triage on
    the default model over a 4000-line-capped diff; one extra picker call per fixed
    job).

Deferred, with reasons

  • #4 capability fields unverified from your sandbox — confirmed live again
    (the header of every review shows a catalog-derived pick), and the new
    degraded-body test + the existing all-null warning cover a silent schema
    change. Since I removed 2>/dev/null, that warning now reaches the job log.
  • Selected model in the four crons' posted artifacts — still logged per run;
    artifact embedding across four formats remains a separate observability
    follow-up.
  • Pagination past limit=1000 — API max page size; the account lists ~11
    models; has_more warns if it ever mattered.
  • Testing pick-model.sh's JSON extraction (Tests #2) — its output always
    passes through the now-tested valid-model.sh gate before use, and the picker
    prompt is developer-controlled (not attacker input), so a parse miss degrades
    to the fallback rather than mis-selecting. I left it untested deliberately for
    that reason; happy to add cases if you'd rather.

I believe that's every item across all four rounds now either implemented or
justified in code + thread.

All items from this round addressed in `44a42968`. ### Implemented - **#1 `REVIEW_TOKEN` on the `valid-model.sh` call** — now `env -u REVIEW_TOKEN`, same as the `fetch-models.sh` call. You're right it runs branch-supplied shell from `$HOMELAB` too. - **#2 line 247 could kill the step** — added `|| true`; a missing/moved `scripts/ci/valid-model.sh` now leaves `MODEL` empty (→ CLI default) instead of aborting the step under `set -eu`. Restores the "never dies on selection" invariant. - **#3 injectable pick on a sensitive review** — when `complexity=complex`, the triage's pick is now ignored and the review runs on the CLI default (strongest configured) model. An injected diff can no longer steer a complex/security review onto the weakest catalog entry. - **#6 `--settings` on the picker** — added `ci/agents/model-picker/settings.json` (the same Read deny-list) and pass it; still `--allowedTools ""`, so it's belt-and-suspenders against the "empty parsed as unset" case you raised. - **#7 `--retry`** — `--retry 2 --retry-connrefused` on the catalog fetch. - **Tests / #4** — extracted the validation into `scripts/ci/valid-model.sh` (already there) and added a `FETCH_MODELS_BODY_FILE` seam so the `fetch-models` jq mapping is now tested offline too: a well-formed `/v1/models` body maps to non-null capability columns, and a body missing the fields triggers the loud warning. **Wired the whole suite into `.woodpecker/e2e.yml`** so it actually runs in CI (your "it never runs" point) — 11 cases, all green locally. - **Nits** — `valid-model.sh` now rejects a leading dash at the shape check (so the header is accurate and the helper is safe even without a catalog); the duplicated 6-line comment in the four fixed jobs is now a one-line pointer to `pick-model.sh`. - **#5 cost** — added a "Properties & tradeoffs (cost)" section to the PR description spelling out that there's no longer a fixed cost ceiling (triage on the default model over a 4000-line-capped diff; one extra picker call per fixed job). ### Deferred, with reasons - **#4 capability fields unverified from your sandbox** — confirmed live again (the header of every review shows a catalog-derived pick), and the new degraded-body test + the existing all-null warning cover a silent schema change. Since I removed `2>/dev/null`, that warning now reaches the job log. - **Selected model in the four crons' posted artifacts** — still logged per run; artifact embedding across four formats remains a separate observability follow-up. - **Pagination past `limit=1000`** — API max page size; the account lists ~11 models; `has_more` warns if it ever mattered. - **Testing `pick-model.sh`'s JSON extraction (Tests #2)** — its output always passes through the now-tested `valid-model.sh` gate before use, and the picker prompt is developer-controlled (not attacker input), so a parse miss degrades to the fallback rather than mis-selecting. I left it untested deliberately for that reason; happy to add cases if you'd rather. I believe that's every item across all four rounds now either implemented or justified in code + thread.
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 44a429685c5c31d8c2bff4a32b0fd11a2dd11752.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

This PR replaces the hardcoded --model claude-sonnet-5 pins (and pr-review's
3-way haiku/sonnet/opus map) across five CI pipelines with a live Anthropic
/v1/models catalog lookup (scripts/ci/fetch-models.sh), an agent-driven
picker (scripts/ci/pick-model.sh), and a shared validation gate
(scripts/ci/valid-model.sh) that only lets a shape-safe, catalog-present id
reach an unquoted --model flag. The shell-injection hardening itself
(valid-model.sh's character-class + membership check, env -u stripping of
forge tokens, || true/timeout fallbacks to the CLI default) is careful and
well tested offline. However, the pr-review.yml integration has one real gap
in the trust model it claims to establish: the catalog file the validation
gate checks membership against is writable by the very (untrusted-diff-driven)
agent whose pick it is meant to constrain.

🔴 Blocking

  • .woodpecker/pr-review.yml:219-252 — the "must be a currently-available
    catalog id" check can be defeated by the agent it is meant to constrain.

    The live catalog is fetched and written to $D/models.txt (219-225)
    before the triage agent runs. The triage agent then runs (232-236) with
    --allowedTools "Read,Glob,Grep,Write" and no Write-path restriction —
    ci/agents/pr-review/settings.json only denies Read of sensitive paths,
    never Write — while its cwd ($WS) contains ./.pr-review symlinked to
    that same $D. The PR's own comment at line 242 acknowledges
    triage.json is UNTRUSTED (... its Write is not path-scoped), but only
    reasons about the model value it writes, not about the fact it can also
    overwrite ./.pr-review/models.txt itself. valid-model.sh is then invoked
    at line 252 with < "$D/models.txt" — i.e. it re-reads the catalog from
    disk, after the triage agent has already run
    , not the pre-triage
    snapshot. A prompt-injection payload in the PR diff/title/body (the exact
    threat this file is written to defend against elsewhere) can therefore
    instruct the triage agent to rewrite models.txt to contain any
    attacker-chosen [A-Za-z0-9._-] token, then report that same token as
    model in triage.json — valid-model.sh's membership check will then
    pass it, because the "catalog" it's checking against is the attacker's own
    writeback. This defeats the specific guarantee the PR body advertises
    ("valid-model.sh ... echoes a candidate id only if ... present in the live
    catalog"). Concretely this lets an attacker either (a) force a real-but-
    weaker existing model onto the review regardless of what the catalog would
    otherwise justify, or (b) supply a syntactically-valid but nonexistent id,
    which makes the review's claude --model <fake-id> invocation fail
    (|| echo "(review agent exited non-zero)"), report.md is never written,
    and the pipeline silently skips posting any review at all (line 271-274) —
    a quiet way to suppress the automated review on a given push.
    This is a genuine regression versus the pre-PR design: previously
    map_model mapped the triage's (equally untrusted) KEY through a fixed
    3-entry, hardcoded case statement, so no amount of file tampering could
    produce anything other than one of 3 pinned, real ids. Note pick-model.sh
    (used by the other four jobs) does not have this problem: it keeps the
    fetched table in a shell variable (TABLE) that is validated and consumed
    before the (tool-less, --allowedTools "") picker agent ever runs, so
    there's no file for that agent to tamper with even in principle.
    Fix direction: don't let the triage agent's Write reach models.txt — e.g.
    scope its Write grant the same way Bash(./.ci-diag/kubectl:*) is scoped
    elsewhere in this repo (something like
    Write(./.pr-review/triage.json) if the CLI's permission syntax supports
    it for Write the way it does for Bash), or simply re-fetch/keep the catalog
    in a shell variable captured before the triage call and validate against
    that instead of re-reading the file afterward.

🟡 Suggestions

  • ci/agents/model-picker/settings.json mirrors the pr-review
    Read-deny list as "belt and suspenders" for the case where an empty
    --allowedTools "" is ever treated as "unset" and the default toolset
    re-enables. If that ever happens, the picker would also regain unrestricted
    Write (only Read is denied here too), which — per the finding above — would
    be a real, not just theoretical, gap; worth keeping in mind if this deny
    list is ever reused as the sole safety net for an agent that's supposed to
    be tool-less.
  • scripts/ci/test-model-selection.sh covers valid-model.sh and the
    fetch-models.sh response mapping thoroughly, but not pick-model.sh's own
    CHOICE extraction (the grep -oE ... | sed pipeline at
    scripts/ci/pick-model.sh:602-605) or the pr-review.yml models.txt
    write-order issue above. A regression test asserting that triage.json's
    model is validated against a catalog snapshot taken before the triage
    agent runs (not re-read after) would have caught the Blocking item.

🟢 Nits

  • .woodpecker/pr-review.yml:20-27 header comment says "if the fetch or the
    pick fails it leaves the model unset" — accurate for the fetch, but for the
    pick step the fallback also fires on a valid pick that doesn't survive the
    complex-review override
    (line 258-261); minor wording nit only, behavior
    is otherwise correctly described elsewhere in the file (line 254-257).

Tests

scripts/ci/test-model-selection.sh is solid for what it covers: the
character-class/membership gate (valid id, hallucinated id, empty, space/
newline/leading-dash/metachar injection, empty catalog) and the
fetch-models.sh jq mapping (well-formed body, degraded/missing-capability
body). It's correctly wired into .woodpecker/e2e.yml. It does not cover
pick-model.sh's prompt-output parsing or the pr-review.yml
fetch-then-triage-then-validate ordering, which is exactly where the Blocking
issue above lives — that gap can only be caught by an integration-level test
or careful review of the shell, not the current unit tests.

Recommendation

Request changes — 1 blocking issue.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `44a429685c5c31d8c2bff4a32b0fd11a2dd11752`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR replaces the hardcoded `--model claude-sonnet-5` pins (and pr-review's 3-way haiku/sonnet/opus map) across five CI pipelines with a live Anthropic `/v1/models` catalog lookup (`scripts/ci/fetch-models.sh`), an agent-driven picker (`scripts/ci/pick-model.sh`), and a shared validation gate (`scripts/ci/valid-model.sh`) that only lets a shape-safe, catalog-present id reach an unquoted `--model` flag. The shell-injection hardening itself (`valid-model.sh`'s character-class + membership check, `env -u` stripping of forge tokens, `|| true`/timeout fallbacks to the CLI default) is careful and well tested offline. However, the `pr-review.yml` integration has one real gap in the trust model it claims to establish: the catalog file the validation gate checks membership against is writable by the very (untrusted-diff-driven) agent whose pick it is meant to constrain. ## 🔴 Blocking - **`.woodpecker/pr-review.yml:219-252` — the "must be a currently-available catalog id" check can be defeated by the agent it is meant to constrain.** The live catalog is fetched and written to `$D/models.txt` (219-225) *before* the triage agent runs. The triage agent then runs (232-236) with `--allowedTools "Read,Glob,Grep,Write"` and no Write-path restriction — `ci/agents/pr-review/settings.json` only denies `Read` of sensitive paths, never `Write` — while its cwd (`$WS`) contains `./.pr-review` symlinked to that same `$D`. The PR's own comment at line 242 acknowledges `triage.json is UNTRUSTED (... its Write is not path-scoped)`, but only reasons about the `model` *value* it writes, not about the fact it can also overwrite `./.pr-review/models.txt` itself. `valid-model.sh` is then invoked at line 252 with `< "$D/models.txt"` — i.e. it re-reads the catalog **from disk, after the triage agent has already run**, not the pre-triage snapshot. A prompt-injection payload in the PR diff/title/body (the exact threat this file is written to defend against elsewhere) can therefore instruct the triage agent to rewrite `models.txt` to contain any attacker-chosen `[A-Za-z0-9._-]` token, then report that same token as `model` in `triage.json` — `valid-model.sh`'s membership check will then pass it, because the "catalog" it's checking against is the attacker's own writeback. This defeats the specific guarantee the PR body advertises ("valid-model.sh ... echoes a candidate id only if ... present in the live catalog"). Concretely this lets an attacker either (a) force a real-but- weaker existing model onto the review regardless of what the catalog would otherwise justify, or (b) supply a syntactically-valid but nonexistent id, which makes the review's `claude --model <fake-id>` invocation fail (`|| echo "(review agent exited non-zero)"`), `report.md` is never written, and the pipeline silently skips posting any review at all (line 271-274) — a quiet way to suppress the automated review on a given push. This is a genuine regression versus the pre-PR design: previously `map_model` mapped the triage's (equally untrusted) `KEY` through a fixed 3-entry, hardcoded case statement, so no amount of file tampering could produce anything other than one of 3 pinned, real ids. Note `pick-model.sh` (used by the other four jobs) does **not** have this problem: it keeps the fetched table in a shell variable (`TABLE`) that is validated and consumed before the (tool-less, `--allowedTools ""`) picker agent ever runs, so there's no file for that agent to tamper with even in principle. Fix direction: don't let the triage agent's Write reach `models.txt` — e.g. scope its Write grant the same way `Bash(./.ci-diag/kubectl:*)` is scoped elsewhere in this repo (something like `Write(./.pr-review/triage.json)` if the CLI's permission syntax supports it for Write the way it does for Bash), or simply re-fetch/keep the catalog in a shell variable captured before the triage call and validate against that instead of re-reading the file afterward. ## 🟡 Suggestions - **`ci/agents/model-picker/settings.json`** mirrors the pr-review Read-deny list as "belt and suspenders" for the case where an empty `--allowedTools ""` is ever treated as "unset" and the default toolset re-enables. If that ever happens, the picker would also regain unrestricted Write (only Read is denied here too), which — per the finding above — would be a real, not just theoretical, gap; worth keeping in mind if this deny list is ever reused as the sole safety net for an agent that's supposed to be tool-less. - **`scripts/ci/test-model-selection.sh`** covers `valid-model.sh` and the `fetch-models.sh` response mapping thoroughly, but not `pick-model.sh`'s own `CHOICE` extraction (the `grep -oE ... | sed` pipeline at `scripts/ci/pick-model.sh:602-605`) or the `pr-review.yml` models.txt write-order issue above. A regression test asserting that `triage.json`'s `model` is validated against a catalog snapshot taken *before* the triage agent runs (not re-read after) would have caught the Blocking item. ## 🟢 Nits - `.woodpecker/pr-review.yml:20-27` header comment says "if the fetch or the pick fails it leaves the model unset" — accurate for the fetch, but for the pick step the fallback also fires on a *valid pick that doesn't survive the complex-review override* (line 258-261); minor wording nit only, behavior is otherwise correctly described elsewhere in the file (line 254-257). ## Tests `scripts/ci/test-model-selection.sh` is solid for what it covers: the character-class/membership gate (valid id, hallucinated id, empty, space/ newline/leading-dash/metachar injection, empty catalog) and the `fetch-models.sh` jq mapping (well-formed body, degraded/missing-capability body). It's correctly wired into `.woodpecker/e2e.yml`. It does not cover `pick-model.sh`'s prompt-output parsing or the `pr-review.yml` fetch-then-triage-then-validate ordering, which is exactly where the Blocking issue above lives — that gap can only be caught by an integration-level test or careful review of the shell, not the current unit tests. ## Recommendation Request changes — 1 blocking issue. <!-- pr-review:sha=44a429685c5c31d8c2bff4a32b0fd11a2dd11752 -->
claude-bot added 1 commit 2026-07-25 06:29:19 +00:00
The pr-review triage agent runs with an unscoped Write tool and its cwd contains
./.pr-review (-> $D), so a prompt-injected diff could make it overwrite
./.pr-review/models.txt and then name a smuggled token as its `model`. Validation
re-read models.txt FROM DISK after the triage ran, so the catalog-membership
check was defeated by the agent's own writeback -- letting an attacker force a
weaker real model, or a syntactically-valid fake id that makes the review's
`claude --model <fake>` fail and silently suppress the review entirely.

Validate against $MODELS_TABLE -- the catalog captured in a shell variable BEFORE
the triage runs -- instead of re-reading the file the triage can overwrite. This
mirrors pick-model.sh, which already validates against its in-memory table before
its (tool-less) picker runs. The triage still reads models.txt for its own pick;
tampering only misleads that pick, which is then rejected against the pristine
snapshot -> CLI default (so the review still runs, it can't be suppressed).

Verified: a token present only in a tampered on-disk file but absent from the
pre-triage snapshot is now rejected (it was accepted before). Added a regression
test to scripts/ci/test-model-selection.sh, and fixed the header comment to note
the complex-review override as another path to the CLI default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Correct and serious catch — fixed in 14aa6415.

🔴 models.txt writeback defeats the membership check

You're right: the catalog was written to ./.pr-review/models.txt before the
triage ran, the triage has an unscoped Write with ./.pr-review in its cwd,
and validation re-read that file from disk after the triage — so an injected
diff could rewrite models.txt and smuggle any [A-Za-z0-9._-] token past the
membership check (forcing a weaker real model, or a fake id that fails the review
claude --model call and silently suppresses the review).

Fix: validate against $MODELS_TABLE — the catalog captured in a shell variable
before the triage runs — instead of re-reading the tamperable file. This is
exactly what makes pick-model.sh immune (as you noted), so pr-review now uses
the same in-memory-snapshot approach. Since Write path-scoping isn't enforced
under acceptEdits in this CLI (per update-audit.yml's own note), keeping the
trusted catalog out of the agent's reach entirely is the robust fix rather than
relying on a Write(...) scope.

Verified with a direct simulation of the flow:

candidate=claude-smuggled-weak   new(pristine var)=[]                old(on-disk)=[claude-smuggled-weak]
candidate=claude-opus-5          new(pristine var)=[claude-opus-5]   old(on-disk)=[claude-opus-5]

The smuggled token is now rejected (→ CLI default, so the review still runs and
can't be suppressed); a legit id still passes. The triage may still tamper its
own on-disk copy, but that only misleads its own pick, which the pristine
snapshot then rejects.

Tests

Added a regression case to scripts/ci/test-model-selection.sh
("tampered id rejected by pristine-snapshot validation") that encodes exactly
this: a token present only in a tampered on-disk file but absent from the
pre-triage snapshot must be rejected. Suite is now 12 cases, all green, and runs
in e2e.yml.

Nits

  • Header comment (lines 20-27) updated to say the pick is validated against the
    snapshot taken before the triage runs, and that the complex-review override
    is another path to the CLI default.
  • The residual you flagged before — a prompt-injected triage forcing
    complexity=trivial + a weaker real model — remains the same pre-existing
    exposure the old map_model keyword path had (an injected KEY could force
    haiku); the complex-review override narrows it, and the pristine-snapshot fix
    stops it from reaching fake or out-of-catalog ids. I left that residual as-is
    since removing it means not trusting the triage's classification at all, which
    is the triage's entire job — flagging it explicitly rather than papering over it.
Correct and serious catch — fixed in `14aa6415`. ### 🔴 models.txt writeback defeats the membership check You're right: the catalog was written to `./.pr-review/models.txt` before the triage ran, the triage has an **unscoped `Write`** with `./.pr-review` in its cwd, and validation re-read that file **from disk after** the triage — so an injected diff could rewrite `models.txt` and smuggle any `[A-Za-z0-9._-]` token past the membership check (forcing a weaker real model, or a fake id that fails the review `claude --model` call and silently suppresses the review). Fix: validate against `$MODELS_TABLE` — the catalog captured in a shell variable **before** the triage runs — instead of re-reading the tamperable file. This is exactly what makes `pick-model.sh` immune (as you noted), so pr-review now uses the same in-memory-snapshot approach. Since Write path-scoping isn't enforced under `acceptEdits` in this CLI (per `update-audit.yml`'s own note), keeping the trusted catalog out of the agent's reach entirely is the robust fix rather than relying on a `Write(...)` scope. Verified with a direct simulation of the flow: ``` candidate=claude-smuggled-weak new(pristine var)=[] old(on-disk)=[claude-smuggled-weak] candidate=claude-opus-5 new(pristine var)=[claude-opus-5] old(on-disk)=[claude-opus-5] ``` The smuggled token is now rejected (→ CLI default, so the review still runs and can't be suppressed); a legit id still passes. The triage may still tamper its *own* on-disk copy, but that only misleads its own pick, which the pristine snapshot then rejects. ### Tests Added a regression case to `scripts/ci/test-model-selection.sh` ("tampered id rejected by pristine-snapshot validation") that encodes exactly this: a token present only in a tampered on-disk file but absent from the pre-triage snapshot must be rejected. Suite is now 12 cases, all green, and runs in `e2e.yml`. ### Nits - Header comment (lines 20-27) updated to say the pick is validated against the snapshot taken **before** the triage runs, and that the complex-review override is another path to the CLI default. - The residual you flagged before — a prompt-injected triage forcing `complexity=trivial` + a weaker *real* model — remains the same pre-existing exposure the old `map_model` keyword path had (an injected `KEY` could force `haiku`); the complex-review override narrows it, and the pristine-snapshot fix stops it from reaching *fake* or out-of-catalog ids. I left that residual as-is since removing it means not trusting the triage's classification at all, which is the triage's entire job — flagging it explicitly rather than papering over it.
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 14aa6415608bacd7efd47175f0d0298bd9059883.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

This PR replaces the hardcoded --model claude-sonnet-5 / keyword-mapped model pins across all five automatic CI agents with a live-catalog lookup (scripts/ci/fetch-models.sh + scripts/ci/pick-model.sh + scripts/ci/valid-model.sh), and reworks pr-review's triage to pick a concrete id from that catalog instead of a haiku|sonnet|opus keyword. I traced the full flow (all four .woodpecker/*.yml call sites, pr-review.yml's two-phase triage/validate logic, and the three new scripts/ci/*.sh helpers) against the checkout. The injection-safety design is careful and internally consistent: valid-model.sh's shape+membership gate is the single choke point before any untrusted string reaches an unquoted --model expansion, and pr-review.yml correctly validates the triage's pick against the pristine $MODELS_TABLE captured before the (unscoped-Write) triage agent ran, not the on-disk file it could overwrite. I found no blocking defects.

🟡 Suggestions

  • .woodpecker/e2e.yml:23-30 — the new model-selection-tests step (the regression test for the flag-injection gate in valid-model.sh) only runs on the daily-security-scan / update-audit / weekly-update crons or manual PIPELINE == "e2e", per the when: block at the top of the file. It does not run on pull_request, so a future PR that weakens valid-model.sh (e.g. loosens the char class or drops the membership check) would merge without this suite catching it — the regression would only surface on the next scheduled e2e run, by which time pick-model.sh/pr-review.yml may have already executed the weakened gate against real (and potentially adversarial) input for up to a week. Worth either adding a lightweight pull_request-triggered pipeline for scripts/ci/** changes, or explicitly accepting the gap in the commit/PR description.
  • scripts/ci/fetch-models.sh:488-497 — the capability fields (max_input_tokens, max_tokens, capabilities.effort/thinking/code_execution) are asserted in the PR description to be present on live /v1/models responses under the OAuth beta header, but the only fixture exercised by test-model-selection.sh:688 is a synthetic body the author wrote by hand — there's no recorded real response pinned in the repo. The code degrades gracefully (loud stderr warning) if the schema ever changes, so this isn't a functional risk, but that warning is easy to miss on a run that still exits 0 (no test currently asserts CI actually surfaces/greps for it). Consider having one of the CI steps that already runs fetch-models.sh (e.g. in pr-review.yml) fail loudly, or at least post a visible marker, if the degraded-schema warning fires, rather than relying on someone reading full job logs.
  • scripts/ci/pick-model.sh:601-608 — the model-picker claude invocation omits --permission-mode, relying solely on --allowedTools "" (plus the settings.json Read deny-list as defence in depth) to keep the picker tool-less. The comment in ci/agents/model-picker/settings.json already flags uncertainty about whether an empty --allowedTools is reliably treated as "no tools" vs. "unset → default toolset" by the CLI. Since GUIDANCE here is a fixed string baked into the .woodpecker/*.yml files (not attacker-controlled, unlike the pr-review triage path), the practical exposure is low, but if it's easy to do, an explicit empirical check (or an even more conservative --permission-mode plan) would remove the remaining doubt called out in your own comment rather than leaving it as an open question.

🟢 Nits

  • scripts/ci/fetch-models.sh:426-427 header comment: "the older scripts/ci/*.sh helpers invoked as bash" — confirmed accurate (fetch-kubeconfig.sh, make-diag-kubeconfig.sh are the only pre-existing scripts there), just noting I checked it rather than took it on faith.

Tests

scripts/ci/test-model-selection.sh is solid offline coverage for the security-critical part (valid-model.sh's injection-shape rejections, the pristine-snapshot-vs-writeback regression, and the fetch-models.sh jq mapping over both a well-formed and a degraded fixture body). The one gap is coverage timing, not coverage content — see the first Suggestion above: it's wired into e2e.yml but that pipeline doesn't trigger on pull_request, so it won't gate the PR that breaks it.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `14aa6415608bacd7efd47175f0d0298bd9059883`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR replaces the hardcoded `--model claude-sonnet-5` / keyword-mapped model pins across all five automatic CI agents with a live-catalog lookup (`scripts/ci/fetch-models.sh` + `scripts/ci/pick-model.sh` + `scripts/ci/valid-model.sh`), and reworks `pr-review`'s triage to pick a concrete id from that catalog instead of a `haiku|sonnet|opus` keyword. I traced the full flow (all four `.woodpecker/*.yml` call sites, `pr-review.yml`'s two-phase triage/validate logic, and the three new `scripts/ci/*.sh` helpers) against the checkout. The injection-safety design is careful and internally consistent: `valid-model.sh`'s shape+membership gate is the single choke point before any untrusted string reaches an unquoted `--model` expansion, and `pr-review.yml` correctly validates the triage's pick against the pristine `$MODELS_TABLE` captured *before* the (unscoped-Write) triage agent ran, not the on-disk file it could overwrite. I found no blocking defects. ## 🟡 Suggestions - **`.woodpecker/e2e.yml:23-30`** — the new `model-selection-tests` step (the regression test for the flag-injection gate in `valid-model.sh`) only runs on the `daily-security-scan` / `update-audit` / `weekly-update` crons or manual `PIPELINE == "e2e"`, per the `when:` block at the top of the file. It does **not** run on `pull_request`, so a future PR that weakens `valid-model.sh` (e.g. loosens the char class or drops the membership check) would merge without this suite catching it — the regression would only surface on the next scheduled `e2e` run, by which time `pick-model.sh`/`pr-review.yml` may have already executed the weakened gate against real (and potentially adversarial) input for up to a week. Worth either adding a lightweight `pull_request`-triggered pipeline for `scripts/ci/**` changes, or explicitly accepting the gap in the commit/PR description. - **`scripts/ci/fetch-models.sh:488-497`** — the capability fields (`max_input_tokens`, `max_tokens`, `capabilities.effort/thinking/code_execution`) are asserted in the PR description to be present on live `/v1/models` responses under the OAuth beta header, but the only fixture exercised by `test-model-selection.sh:688` is a synthetic body the author wrote by hand — there's no recorded real response pinned in the repo. The code degrades gracefully (loud stderr warning) if the schema ever changes, so this isn't a functional risk, but that warning is easy to miss on a run that still exits 0 (no test currently asserts CI actually surfaces/greps for it). Consider having one of the CI steps that already runs `fetch-models.sh` (e.g. in `pr-review.yml`) fail loudly, or at least post a visible marker, if the degraded-schema warning fires, rather than relying on someone reading full job logs. - **`scripts/ci/pick-model.sh:601-608`** — the model-picker `claude` invocation omits `--permission-mode`, relying solely on `--allowedTools ""` (plus the `settings.json` Read deny-list as defence in depth) to keep the picker tool-less. The comment in `ci/agents/model-picker/settings.json` already flags uncertainty about whether an empty `--allowedTools` is reliably treated as "no tools" vs. "unset → default toolset" by the CLI. Since `GUIDANCE` here is a fixed string baked into the `.woodpecker/*.yml` files (not attacker-controlled, unlike the `pr-review` triage path), the practical exposure is low, but if it's easy to do, an explicit empirical check (or an even more conservative `--permission-mode plan`) would remove the remaining doubt called out in your own comment rather than leaving it as an open question. ## 🟢 Nits - `scripts/ci/fetch-models.sh:426-427` header comment: "the older `scripts/ci/*.sh` helpers invoked as `bash`" — confirmed accurate (`fetch-kubeconfig.sh`, `make-diag-kubeconfig.sh` are the only pre-existing scripts there), just noting I checked it rather than took it on faith. ## Tests `scripts/ci/test-model-selection.sh` is solid offline coverage for the security-critical part (`valid-model.sh`'s injection-shape rejections, the pristine-snapshot-vs-writeback regression, and the `fetch-models.sh` jq mapping over both a well-formed and a degraded fixture body). The one gap is coverage *timing*, not coverage *content* — see the first Suggestion above: it's wired into `e2e.yml` but that pipeline doesn't trigger on `pull_request`, so it won't gate the PR that breaks it. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=14aa6415608bacd7efd47175f0d0298bd9059883 -->
claude-bot added 1 commit 2026-07-25 07:08:42 +00:00
fix(ci): make model-picker actually tool-less; gate tests on PRs
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
1992fabb12
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>
Author
Member

Thanks — addressed in 1992fabb. One of these turned out to be a real gap, not just a doubt.

🟡 Picker tool-restriction (your #3) — the doubt was justified

I ran the empirical check you suggested. --allowedTools "" does NOT make the
picker tool-less
— with an empty allowlist the picker happily used the Write
tool and created a file. An empty allowlist is treated as "unset" → default
toolset stays live. So the "tool-less" claim in my comment (and the settings.json
note) was wrong.

Fix: the picker now runs --permission-mode plan, which I verified does block
the Write tool while still producing the selection JSON:

plan mode + "write ./pwned.txt"     -> refused, pwned.txt NOT created
plan mode + normal selection prompt -> {"model":"claude-haiku-4-5-20251001"}

--allowedTools "" and the Read deny-list stay as additional layers. Comments in
pick-model.sh and ci/agents/model-picker/settings.json corrected to say plan
mode is the actual guarantee. (Practical exposure was low either way — the
picker's prompt is developer-authored + the API catalog, no attacker input — but
the invariant is now real, not assumed.)

🟡 Test only ran on crons, not PRs (your #1) — fixed

New .woodpecker/ci-scripts-test.yml runs test-model-selection.sh on every
homelab pull_request (homelab-only; not deployed to the mirrored repos). A PR
that loosens valid-model.sh now fails checks before merge, not up to a week
later on the next e2e cron.

🟡 No recorded real /v1/models fixture (your #2) — added

Pinned scripts/ci/testdata/v1-models-sample.json (the shape verified live) and
a test asserting fetch-models.sh maps it correctly (opus effort=true, haiku
effort=false). Suite is now 13 cases, green, and runs both on PRs and in e2e.

Deferred: hard-failing a pipeline when the degraded-schema warning fires. The
warning already reaches the job log (curl/jq stderr is no longer suppressed since
an earlier round), and hard-failing on a benign API schema addition would break
all model selection across five pipelines — graceful degradation to name+date is
the deliberate choice. Happy to add a visible marker step if you'd prefer.

🟢 Nit

Header "older scripts/ci/*.sh invoked as bash" — confirmed accurate, thanks for
double-checking it.

Thanks — addressed in `1992fabb`. One of these turned out to be a real gap, not just a doubt. ### 🟡 Picker tool-restriction (your #3) — the doubt was justified I ran the empirical check you suggested. **`--allowedTools ""` does NOT make the picker tool-less** — with an empty allowlist the picker happily used the Write tool and created a file. An empty allowlist is treated as "unset" → default toolset stays live. So the "tool-less" claim in my comment (and the settings.json note) was wrong. Fix: the picker now runs `--permission-mode plan`, which I verified **does** block the Write tool while still producing the selection JSON: ``` plan mode + "write ./pwned.txt" -> refused, pwned.txt NOT created plan mode + normal selection prompt -> {"model":"claude-haiku-4-5-20251001"} ``` `--allowedTools ""` and the Read deny-list stay as additional layers. Comments in `pick-model.sh` and `ci/agents/model-picker/settings.json` corrected to say plan mode is the actual guarantee. (Practical exposure was low either way — the picker's prompt is developer-authored + the API catalog, no attacker input — but the invariant is now real, not assumed.) ### 🟡 Test only ran on crons, not PRs (your #1) — fixed New `.woodpecker/ci-scripts-test.yml` runs `test-model-selection.sh` on every homelab `pull_request` (homelab-only; not deployed to the mirrored repos). A PR that loosens `valid-model.sh` now fails checks before merge, not up to a week later on the next `e2e` cron. ### 🟡 No recorded real /v1/models fixture (your #2) — added Pinned `scripts/ci/testdata/v1-models-sample.json` (the shape verified live) and a test asserting `fetch-models.sh` maps it correctly (opus `effort=true`, haiku `effort=false`). Suite is now 13 cases, green, and runs both on PRs and in e2e. **Deferred:** hard-failing a pipeline when the degraded-schema warning fires. The warning already reaches the job log (curl/jq stderr is no longer suppressed since an earlier round), and hard-failing on a benign API schema *addition* would break all model selection across five pipelines — graceful degradation to name+date is the deliberate choice. Happy to add a visible marker step if you'd prefer. ### 🟢 Nit Header "older scripts/ci/*.sh invoked as bash" — confirmed accurate, thanks for double-checking it.
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 1992fabb1226fe702c73dd0d778b6a6030a9bfe4.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

This PR replaces the hardcoded --model claude-sonnet-5 / haiku-sonnet-opus keyword mapping across five CI agent pipelines with a live-catalog lookup (scripts/ci/fetch-models.sh → GET /v1/models), a shared validation gate (scripts/ci/valid-model.sh), and a triage/picker agent that chooses a fitting id (scripts/ci/pick-model.sh, ci/agents/pr-review/triage-prompt.md). I traced the full flow for both the four cron/manual jobs (alert-monitor, issue-fix, security-scan, update-audit) and the pull_request-triggered pr-review.yml, including the flag-injection gate, the "validate against the pre-triage snapshot, not the on-disk file the untrusted agent can overwrite" logic, and the complex/security-sensitive override that ignores the triage's pick. Everything I checked holds up: the validation regex correctly rejects space/newline/leading-dash/metachar payloads (confirmed against scripts/ci/test-model-selection.sh's cases by tracing the case pattern and grep -qxF membership check by hand), pick-model.sh/valid-model.sh are structured to always exit 0 so set -eu callers never die on selection, and the four fixed jobs only ever pass a hardcoded (non-attacker-controlled) task description to the picker since they run on cron/manual triggers against trusted master, not PR branches. The two new JSON files (testdata/v1-models-sample.json, ci/agents/model-picker/settings.json) are valid JSON (checked with jq empty). No blocking issues found.

🟡 Suggestions

  • ci/agents/model-picker/settings.json:1-6 / scripts/ci/pick-model.sh:620-631 — the comment states it was "VERIFIED empirically" that --permission-mode plan blocks the Write tool while --allowedTools "" alone does not. That's a real behavioral claim about the claude CLI with no automated regression coverage (unlike valid-model.sh, which has a full test suite). If a future CLI version changes plan-mode semantics, this would silently regress. Worth a one-line note that this is asserted from manual testing only, or an inline check if the CLI ever exposes one — not blocking today since the picker's task string is always a hardcoded literal in the four callers, never attacker-controlled.
  • scripts/ci/pick-model.sh has no automated test of its own control flow (the RAW→CHOICE grep/sed extraction, the always-exit-0 contract). Coverage today is indirect, via valid-model.sh's tests plus the "verified live" claim in the PR description. Given how carefully valid-model.sh and fetch-models.sh are tested, a small offline test that feeds pick-model.sh a canned claude stub (or at least documents that this path is untested) would close the gap.

🟢 Nits

  • The "Model chosen live from the catalog…" comment block is duplicated verbatim across alert-monitor.yml, issue-fix.yml, security-scan.yml, and update-audit.yml. Fine as-is given each file is a standalone Woodpecker pipeline, but a shared doc (e.g. a comment pointer to scripts/ci/pick-model.sh's header, which is already the pattern used) keeps it from drifting if the mechanism changes again.

Tests

scripts/ci/test-model-selection.sh is thorough: it covers the injection gate (valid id, dated id, hallucinated id, empty, space/newline/leading-dash/metachar injection, empty catalog), the specific "models.txt writeback" attack this PR's design is defending against (triage's unscoped Write can't smuggle a token past validation because the gate is checked against the pre-triage snapshot), and the fetch-models.sh jq mapping against both a synthetic and a recorded real /v1/models body (including a degraded-schema warning path). It's wired into both e2e.yml (cron) and the new ci-scripts-test.yml (every homelab pull_request), with a documented rationale for running it in both places. This is good coverage for the security-critical piece (the injection gate). The one gap, noted above, is pick-model.sh's own glue logic, which is comparatively low-risk since its input is never attacker-controlled in the current callers.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `1992fabb1226fe702c73dd0d778b6a6030a9bfe4`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR replaces the hardcoded `--model claude-sonnet-5` / haiku-sonnet-opus keyword mapping across five CI agent pipelines with a live-catalog lookup (`scripts/ci/fetch-models.sh` → `GET /v1/models`), a shared validation gate (`scripts/ci/valid-model.sh`), and a triage/picker agent that chooses a fitting id (`scripts/ci/pick-model.sh`, `ci/agents/pr-review/triage-prompt.md`). I traced the full flow for both the four cron/manual jobs (alert-monitor, issue-fix, security-scan, update-audit) and the pull_request-triggered `pr-review.yml`, including the flag-injection gate, the "validate against the pre-triage snapshot, not the on-disk file the untrusted agent can overwrite" logic, and the complex/security-sensitive override that ignores the triage's pick. Everything I checked holds up: the validation regex correctly rejects space/newline/leading-dash/metachar payloads (confirmed against `scripts/ci/test-model-selection.sh`'s cases by tracing the `case` pattern and `grep -qxF` membership check by hand), `pick-model.sh`/`valid-model.sh` are structured to always exit 0 so `set -eu` callers never die on selection, and the four fixed jobs only ever pass a hardcoded (non-attacker-controlled) task description to the picker since they run on cron/manual triggers against trusted `master`, not PR branches. The two new JSON files (`testdata/v1-models-sample.json`, `ci/agents/model-picker/settings.json`) are valid JSON (checked with `jq empty`). No blocking issues found. ## 🟡 Suggestions - `ci/agents/model-picker/settings.json:1-6` / `scripts/ci/pick-model.sh:620-631` — the comment states it was "VERIFIED empirically" that `--permission-mode plan` blocks the Write tool while `--allowedTools ""` alone does not. That's a real behavioral claim about the `claude` CLI with no automated regression coverage (unlike `valid-model.sh`, which has a full test suite). If a future CLI version changes plan-mode semantics, this would silently regress. Worth a one-line note that this is asserted from manual testing only, or an inline check if the CLI ever exposes one — not blocking today since the picker's task string is always a hardcoded literal in the four callers, never attacker-controlled. - `scripts/ci/pick-model.sh` has no automated test of its own control flow (the RAW→CHOICE grep/sed extraction, the always-exit-0 contract). Coverage today is indirect, via `valid-model.sh`'s tests plus the "verified live" claim in the PR description. Given how carefully `valid-model.sh` and `fetch-models.sh` are tested, a small offline test that feeds `pick-model.sh` a canned `claude` stub (or at least documents that this path is untested) would close the gap. ## 🟢 Nits - The "Model chosen live from the catalog…" comment block is duplicated verbatim across `alert-monitor.yml`, `issue-fix.yml`, `security-scan.yml`, and `update-audit.yml`. Fine as-is given each file is a standalone Woodpecker pipeline, but a shared doc (e.g. a comment pointer to `scripts/ci/pick-model.sh`'s header, which is already the pattern used) keeps it from drifting if the mechanism changes again. ## Tests `scripts/ci/test-model-selection.sh` is thorough: it covers the injection gate (valid id, dated id, hallucinated id, empty, space/newline/leading-dash/metachar injection, empty catalog), the specific "models.txt writeback" attack this PR's design is defending against (triage's unscoped Write can't smuggle a token past validation because the gate is checked against the pre-triage snapshot), and the `fetch-models.sh` jq mapping against both a synthetic and a recorded real `/v1/models` body (including a degraded-schema warning path). It's wired into both `e2e.yml` (cron) and the new `ci-scripts-test.yml` (every homelab `pull_request`), with a documented rationale for running it in both places. This is good coverage for the security-critical piece (the injection gate). The one gap, noted above, is `pick-model.sh`'s own glue logic, which is comparatively low-risk since its input is never attacker-controlled in the current callers. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=1992fabb1226fe702c73dd0d778b6a6030a9bfe4 -->
k.renner merged commit 07d9f33174 into master 2026-07-25 08:41:40 +00:00
Sign in to join this conversation.