#!/usr/bin/env bash # Runs one behavior-eval case, then its deterministic (D) checks and grader (G) expectations. # Usage: run-behavior.sh # Env: OUT_DIR scratch directory for fixtures, transcripts, and reports # AI_PLUGINS_CLONE local clone of bitwarden/ai-plugins, for cases that review a real pull request # MODEL optional, defaults to opus # RESCORE set to re-run checks and grading against an existing run without re-running the review set -euo pipefail EVALS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGINS_DIR="$(cd "$EVALS_DIR/../../../.." && pwd)" EVAL_SET="$EVALS_DIR/behavior-eval.json" id="$1" run="$2" : "${OUT_DIR:?set OUT_DIR}" [[ "$OUT_DIR" == /* ]] || { echo "OUT_DIR must be an absolute path" >&2; exit 1; } model="${MODEL:-opus}" [[ "$id" =~ ^[0-9]+$ ]] || { echo "case id must be numeric" >&2; exit 1; } [[ "$run" =~ ^[0-9]+$ ]] || { echo "run number must be numeric" >&2; exit 1; } case_json="$(jq -ce --argjson id "$id" '.evals[] | select(.id == $id)' "$EVAL_SET")" run_dir="$OUT_DIR/behavior/$id/run-$run" T="$run_dir/report" RUN="$run_dir/run.jsonl" FIXTURE="$run_dir/fixture" mkdir -p "$T" # A case with `skip` is recorded like an errored run, so aggregates leave it out instead of counting a fail. skip_reason="$(jq -r '.skip // empty' <<<"$case_json")" if [[ -n "$skip_reason" ]]; then jq -n --argjson id "$id" --argjson r "$run" --arg s "$skip_reason" \ '{eval_id: $id, run_number: $r, passed: null, skipped: $s, expectations: []}' | tee "$run_dir/result.json" exit 0 fi if [[ -z "${RESCORE:-}" ]]; then [[ ! -e "$FIXTURE" ]] || { echo "$run_dir already holds a run; delete it to re-run, or set RESCORE=1 to re-score it" >&2; exit 1; } "$EVALS_DIR/fixtures/setup.sh" "$(jq -r .fixture <<<"$case_json")" "$FIXTURE" >/dev/null prompt="$(jq -r .prompt <<<"$case_json")" prompt="${prompt//\{T\}/$T}" allowed=( Read Grep Glob Skill Agent "Edit(/$T/**)" "Bash(gh pr view:*)" "Bash(gh pr diff:*)" "Bash(git diff:*)" "Bash(git status:*)" "Bash(git log:*)" "Bash(git show:*)" "Bash(git rev-parse:*)" "Bash(git merge-base:*)" "Bash(git fetch origin:*)" "Bash(git ls-files:*)" "Bash(ls:*)" "Bash(cat:*)" "Bash(head:*)" "Bash(wc:*)" ) # Options that write files (--output) or run commands (--upload-pack, which git also accepts abbreviated # down to --upl) under an allowed git prefix. denied=( "Bash(git * --output*)" "Bash(git *--output=*)" "Bash(git *--upl*)" ) # Without this note, one denied compound command led a run to stop using Bash altogether. harness_note="Tool permissions in this session are an allow-list, enforced per command. Allowed Bash commands: gh pr view, gh pr diff, and read-only git subcommands (diff, status, log, show, rev-parse, merge-base, ls-files, and fetch from origin), plus ls, cat, head, and wc. Run each command on its own, not chained with && or ;. A denied command does not mean Bash is unavailable." # A fixture that ships fixtures/gh-stub serves its pull request from there; only this run's PATH changes. run_path="$PATH" [[ -x "$FIXTURE/gh-stub/gh" ]] && run_path="$FIXTURE/gh-stub:$PATH" (cd "$FIXTURE/work" && PATH="$run_path" env -u CLAUDECODE claude -p "$prompt" \ --model "$model" \ --setting-sources project,local \ --permission-mode dontAsk \ --allowedTools "${allowed[@]}" \ --disallowedTools "${denied[@]}" \ --append-system-prompt "$harness_note" \ --no-session-persistence \ --output-format stream-json --verbose \ --plugin-dir "$PLUGINS_DIR/bitwarden-code-review" \ --plugin-dir "$PLUGINS_DIR/bitwarden-security-engineer" \ >"$RUN" 2>"$run_dir/stderr.log") || echo "claude exited $? for case $id run $run" >&2 fi # A run that never reached the model would pass the "no agents, no report" checks vacuously. run_error="$(jq -rs 'map(select(.type == "result")) | if length == 0 then "no result event" elif .[-1].is_error then (.[-1].result // "error") else empty end' "$RUN" 2>/dev/null || echo "unreadable transcript")" if [[ -n "$run_error" ]]; then jq -n --argjson id "$id" --argjson r "$run" --arg e "$run_error" \ '{eval_id: $id, run_number: $r, passed: null, error: $e, expectations: []}' | tee "$run_dir/result.json" exit 0 fi # Helpers available to each case's D check. report() { shopt -s nullglob; local f=("$T"/*.md); (( ${#f[@]} == 1 )) && cat "${f[0]}"; } tool_inputs() { jq -c --arg n "$1" 'select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and (.name == $n or ($n == "Agent" and .name == "Task"))) | .input' "$RUN"; } agent_calls() { tool_inputs Agent | wc -l | tr -d ' '; } bash_commands() { tool_inputs Bash | jq -r .command; } # cites FILE LINE: a validated finding's location in FILE covers LINE. cites() { report | awk '/^## Findings/{on=1} /^## Reviewed and Dismissed/{on=0} on' \ | grep -oE "\`$1:[0-9]+(-[0-9]+)?\`" | tr -d '`' | while IFS= read -r c; do local r="${c##*:}"; (( ${r%-*} <= $2 && ${r#*-} >= $2 )) && echo hit done | grep -q hit } arg() { jq -r --arg k "$2" '.[$k] // empty' <<<"$1"; } # Runs one D check. Patterns are extended regular expressions passed to grep as data, never evaluated. run_check() { local c="$1" flags="-Eq" sel sub [[ "$(arg "$c" ignore_case)" == true ]] && flags="-Eqi" case "$(arg "$c" kind)" in report_matches) report | grep $flags -- "$(arg "$c" pattern)" ;; report_title_matches) report | head -1 | grep $flags -- "$(arg "$c" pattern)" ;; cites) cites "$(arg "$c" file)" "$(arg "$c" line)" ;; no_agent_calls) [[ "$(agent_calls)" -eq 0 ]] ;; report_dir_empty) [[ -z "$(ls -A "$T")" ]] ;; bash_command_matches) bash_commands | grep $flags -- "$(arg "$c" pattern)" ;; no_bash_command_matches) ! bash_commands | grep $flags -- "$(arg "$c" pattern)" ;; every_bash_command_matches) sel="$(bash_commands | grep -E -- "$(arg "$c" select)")" [[ -n "$sel" ]] && ! grep -Ev -- "$(arg "$c" pattern)" <<<"$sel" | grep -q . ;; agents_ran_without_line) [[ "$(agent_calls)" -gt 0 ]] && ! tool_inputs Agent | jq -r .prompt | grep $flags -- "$(arg "$c" pattern)" ;; agent_prompts_have_line) tool_inputs Agent | jq -r .prompt | grep $flags -- "$(arg "$c" pattern)" ;; stub_called) while IFS= read -r sub; do grep -Eq -- "^$sub( |$)" "$FIXTURE/gh-stub/calls.log" || return 1 done < <(jq -r '.subcommands[]' <<<"$c") ;; any_of) while IFS= read -r sub; do run_check "$sub" && return 0; done < <(jq -c '.checks[]' <<<"$c") return 1 ;; *) echo "unknown check kind: $(arg "$c" kind)" >&2; return 1 ;; esac } checks='[]' while IFS= read -r check; do desc="$(jq -r .text <<<"$check")" if (set +e +o pipefail; run_check "$check") >/dev/null 2>&1; then passed=true; else passed=false; fi checks="$(jq -c --arg t "$desc" --argjson p "$passed" '. + [{text: $t, type: "D", passed: $p}]' <<<"$checks")" done < <(jq -c '.checks[]?' <<<"$case_json") if jq -e '.expectations | length > 0' <<<"$case_json" >/dev/null; then schema='{"type":"object","additionalProperties":false,"required":["expectations"],"properties":{"expectations":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["text","passed","evidence"],"properties":{"text":{"type":"string"},"passed":{"type":"boolean"},"evidence":{"type":"string"}}}}}}' grader_prompt="$(cat < $(report || echo "(no report was written)") EOF )" graded="$(cd "$FIXTURE/work" && env -u CLAUDECODE claude -p "$grader_prompt" --model "$model" \ --setting-sources local --permission-mode dontAsk --allowedTools Read Grep Glob \ --no-session-persistence --output-format json --json-schema "$schema" 2>>"$run_dir/stderr.log" \ | jq -c '[.structured_output.expectations[] | . + {type: "G"}]')" || graded='[{"text":"grader","type":"G","passed":false,"evidence":"grader returned no structured output"}]' checks="$(jq -c --argjson g "$graded" '. + $g' <<<"$checks")" fi jq -n --argjson id "$id" --argjson r "$run" --argjson c "$checks" \ '{eval_id: $id, run_number: $r, passed: ($c | all(.passed)), expectations: $c}' | tee "$run_dir/result.json"