· about 40 minutes · runs on ubuntu-26.04-claude · unrated
An unrated lab. It runs on your machine with no account and no network, and everything about it — the faults, the checks, the hints and the reference solution — is in the repository. An attempt on it is recorded on your profile and never moves a rating: only rated labs do. Rated and unrated labs.
The site team added a GitHub Actions workflow that asks Claude Code to label new issues. The
month's usage report came in at many times the estimate. Nobody can say how many runs there were
or how long each took, and one run in the Actions history was cancelled by GitHub after six hours.
The workflow is ~/site/.github/workflows/claude-triage.yml; the script it runs is
~/site/ci/triage.sh.
There is no GitHub here. The workflow file is graded as written, and the script is run for real:
claude on this machine is the real Claude Code 2.1.270, talking to a scripted model instead of
Anthropic. To try the script the way the workflow runs it:
cd ~/site && GITHUB_EVENT_PATH=ci/sample-event.json sh ci/triage.sh
The scripted model this time is one that never finishes: it keeps asking to read files.
This lab runs in a container, as learner with sudo. What is expected, and graded:
run: script.This lab runs in a container — Docker or Podman, no VM — so there is no boot to survive: the checks run once, against the state you left.
| Check | Objective |
|---|---|
| 01_only_new_issues_start_the_job | Trigger a CI agent job only on the event it is for |
| 02_a_looping_model_is_stopped | Cap a headless run's turns, and give it the smallest model and no tools it does not need |
| 03_haiku_with_no_tools | Cap a headless run's turns, and give it the smallest model and no tools it does not need |
| 04_least_privilege_and_a_timeout | Give a workflow the least permissions, a timeout, and no secret or untrusted text in its shell |
| 05_nothing_secret_or_untrusted_in_run_scripts | Give a workflow the least permissions, a timeout, and no secret or untrusted text in its shell |
Where the lab's hints send you, level by level, as you ask for them (h, then l opens a journal section in the TUI).
4 questions on the same topic, in the lab's Theory tab. They never affect the lab's grade. Three of them, to answer here:
A script runs `claude -p "Label this issue" --output-format json` with no --max-turns, and the model keeps asking to read another file. When does the run stop?
Only when the model stops asking, or something outside kills it: -p has no turn limit by default
--max-turns and --max-budget-usd are opt-in; without them a headless run continues for as long as the model keeps calling tools. In the lab image an endless scripted model drove 1,186 turns in 25 seconds. In CI the only other bound is the job's timeout-minutes, which defaults to 360. With --max-turns the run ends with subtype error_max_turns and a non-zero exit.
https://code.claude.com/docs/en/cli-reference · https://code.claude.com/docs/en/headless
A triage script calls `claude -p ... --tools "" --json-schema "$schema" --output-format json`. Where is the label, and which tool is the model offered?
In structured_output; the model is offered only StructuredOutput
With --json-schema, Claude Code gives the model a StructuredOutput tool whose input is validated against the schema, and puts the validated object in the result's structured_output. --tools "" removes the built-in tools but not that one. The result field then holds the JSON as a string, so a script that reads .result gets '{"label":"bug"}', not bug. Verified with 2.1.270.
https://code.claude.com/docs/en/headless
A workflow step runs `echo "Triaging: ${{ github.event.issue.title }}"`. Why is this a vulnerability?
The expression is substituted into the script before bash runs, so a title like $(curl …) becomes code
Actions evaluates ${{ }} expressions first and writes the result into the script file; bash then runs whatever text is there. An issue title is written by anyone who can open an issue. Pass it through env: (`TITLE: ${{ github.event.issue.title }}`) and use "$TITLE" in the script, where it is data. Quoting inside the script does not help, because the substitution happens before quoting means anything.
https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions