diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 83f6830d5..76a0e1c95 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -3371,7 +3371,7 @@ jobs: test files. Never approve material workflow, script, source, config, package, or test changes with a reason or summary that says simple typo fix, string-only change, no verification needed, or no tests needed. If bounded failed GitHub Check evidence contains active failed checks, treat it as a blocker - until diagnosed. A successful same-head default-branch repository_dispatch Strix run may supersede a stale failed + until diagnosed. A successful same-head default-branch repository_dispatch Strix run with a structured evidence binding may supersede a stale failed PR statusCheckRollup Strix context only when failed-check evidence explicitly lists it under Superseded failed checks with the exact target URL. Multiple Strix model reports must not be collapsed; preserve model name, report title, severity, endpoint, and Code Locations/path:line evidence. Full failed-check @@ -3385,7 +3385,7 @@ jobs: Exact gate phrases: Coverage and Docstring coverage labels must cite Coverage execution evidence showing supported repository test suites passed. Exact gate phrases: or explicitly cite Coverage execution evidence as not applicable because no supported source files or package manifests were found. Exact gate phrases: If bounded failed GitHub Check evidence contains active failed checks, treat it as a blocker until diagnosed. - Exact gate phrases: A successful same-head default-branch repository_dispatch Strix run may supersede a stale failed PR statusCheckRollup Strix context only when failed-check evidence explicitly lists it under Superseded failed checks with the exact target URL. + Exact gate phrases: A successful same-head default-branch repository_dispatch Strix run with the exact structured evidence-binding status may supersede a stale failed PR statusCheckRollup Strix context only when failed-check evidence explicitly lists it under Superseded failed checks with the exact target URL. Exact gate phrases: Full failed-check evidence, when collected, is available as failed-check-evidence.md. Exact gate phrases: Do not request changes with only a check URL, workflow name, or generic failure summary. Exact gate phrases: Failed-check findings must be line-specific and concrete. @@ -3518,7 +3518,7 @@ jobs: test files. Never approve material workflow, script, source, config, package, or test changes with a reason or summary that says simple typo fix, string-only change, no verification needed, or no tests needed. If bounded failed GitHub Check evidence contains active failed checks, treat it as a blocker - until diagnosed. A successful same-head default-branch repository_dispatch Strix run may supersede a stale failed + until diagnosed. A successful same-head default-branch repository_dispatch Strix run with a structured evidence binding may supersede a stale failed PR statusCheckRollup Strix context only when failed-check evidence explicitly lists it under Superseded failed checks with the exact target URL. Multiple Strix model reports must not be collapsed; preserve model name, report title, severity, endpoint, and Code Locations/path:line evidence. Full failed-check @@ -3532,7 +3532,7 @@ jobs: Exact gate phrases: Coverage and Docstring coverage labels must cite Coverage execution evidence showing supported repository test suites passed. Exact gate phrases: or explicitly cite Coverage execution evidence as not applicable because no supported source files or package manifests were found. Exact gate phrases: If bounded failed GitHub Check evidence contains active failed checks, treat it as a blocker until diagnosed. - Exact gate phrases: A successful same-head default-branch repository_dispatch Strix run may supersede a stale failed PR statusCheckRollup Strix context only when failed-check evidence explicitly lists it under Superseded failed checks with the exact target URL. + Exact gate phrases: A successful same-head default-branch repository_dispatch Strix run with the exact structured evidence-binding status may supersede a stale failed PR statusCheckRollup Strix context only when failed-check evidence explicitly lists it under Superseded failed checks with the exact target URL. Exact gate phrases: Full failed-check evidence, when collected, is available as failed-check-evidence.md. Exact gate phrases: Do not request changes with only a check URL, workflow name, or generic failure summary. Exact gate phrases: Failed-check findings must be line-specific and concrete. @@ -6230,6 +6230,164 @@ jobs: return 0 } + self_modifying_strix_workflow_needs_structured_evidence() { + pr_changes_path ".github/workflows/strix.yml" + } + + current_head_manual_strix_structured_success_status() { + local status_json + local status_url + local run_id + local expected_url + local run_json + local artifact_json + local artifact_count + local artifact_dir + local binding_file + local report_path + local report_file + local expected_report_sha256 + local actual_report_sha256 + local description="Default-branch repository_dispatch Strix structured evidence binding passed" + + if ! status_json="$(timeout "$(check_lookup_api_timeout_seconds)s" \ + gh api -X GET "repos/${GH_REPOSITORY}/commits/${HEAD_SHA}/status")"; then + return 1 + fi + status_url="$(jq -r --arg description "$description" ' + [.statuses // [] | .[] + | select((.context // "") == "strix") + | select((.state // "" | ascii_downcase) == "success") + | select((.description // "") == $description)] + | sort_by(.created_at // "") + | last + | .target_url // empty + ' <<<"$status_json")" + if [ -z "$status_url" ]; then + return 1 + fi + case "$status_url" in + "${GITHUB_SERVER_URL%/}/${GH_REPOSITORY}/actions/runs/"*) ;; + *) return 1 ;; + esac + run_id="${status_url##*/}" + if ! [[ "$run_id" =~ ^[0-9]+$ ]]; then + return 1 + fi + expected_url="${GITHUB_SERVER_URL%/}/${GH_REPOSITORY}/actions/runs/${run_id}" + if [ "$status_url" != "$expected_url" ]; then + return 1 + fi + if ! run_json="$(timeout "$(check_lookup_api_timeout_seconds)s" \ + gh api -X GET "repos/${GH_REPOSITORY}/actions/runs/${run_id}")"; then + return 1 + fi + if ! jq -e --arg head_sha "$HEAD_SHA" --arg run_id "$run_id" ' + ((.id // "") | tostring) == $run_id + and (.head_sha // "") == $head_sha + and (.event // "") == "repository_dispatch" + and (.path // "") == ".github/workflows/strix.yml" + and (.status // "") == "completed" + and (.conclusion // "") == "success" + ' <<<"$run_json" >/dev/null; then + return 1 + fi + + if ! artifact_json="$(timeout "$(check_lookup_api_timeout_seconds)s" \ + gh api -X GET "repos/${GH_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100")"; then + return 1 + fi + if ! artifact_count="$(jq -r '[.artifacts[]? | select((.name // "") == "strix-reports" and .expired == false)] | length' <<<"$artifact_json")"; then + return 1 + fi + if [ "$artifact_count" != "1" ]; then + return 1 + fi + + artifact_dir="$(mktemp -d)" + if ! timeout "$(check_lookup_api_timeout_seconds)s" \ + gh run download "$run_id" \ + --repo "$GH_REPOSITORY" \ + --name strix-reports \ + --dir "$artifact_dir" /dev/null 2>&1; then + rm -rf -- "$artifact_dir" + return 1 + fi + binding_file="$(find "$artifact_dir" -type f -name evidence-binding.json -print -quit)" + if [ -z "$binding_file" ] || ! jq -e \ + --arg repository "$GH_REPOSITORY" \ + --arg head_sha "$HEAD_SHA" \ + --arg run_id "$run_id" ' + .repository == $repository + and .artifact_name == "strix-reports" + and .head_sha == $head_sha + and ((.run_id // "") | tostring) == $run_id + and .scan_completed == true + and ((.report // "") | type == "string") + ' "$binding_file" >/dev/null 2>&1; then + rm -rf -- "$artifact_dir" + return 1 + fi + report_path="$(jq -r '.report // empty' "$binding_file")" + case "$report_path" in + ""|/*|../*|*/../*|*"/../"*|*"/./"*|./*|*//*) + rm -rf -- "$artifact_dir" + return 1 + ;; + esac + report_file="$(dirname -- "$binding_file")/$report_path" + if [ ! -s "$report_file" ]; then + rm -rf -- "$artifact_dir" + return 1 + fi + expected_report_sha256="$(jq -r '.report_sha256 // empty' "$binding_file")" + if [ -z "$expected_report_sha256" ]; then + rm -rf -- "$artifact_dir" + return 1 + fi + if command -v sha256sum >/dev/null 2>&1; then + actual_report_sha256="$(sha256sum "$report_file" | awk '{print $1}')" + else + actual_report_sha256="$(shasum -a 256 "$report_file" | awk '{print $1}')" + fi + if [ "$actual_report_sha256" != "$expected_report_sha256" ]; then + rm -rf -- "$artifact_dir" + return 1 + fi + rm -rf -- "$artifact_dir" + printf '%s\n' "$status_url" + } + + hold_for_unverified_strix_workflow_update() { + local structured_status + + if ! self_modifying_strix_workflow_needs_structured_evidence; then + return 1 + fi + structured_status="$(current_head_manual_strix_structured_success_status || true)" + if [ -n "$structured_status" ]; then + return 1 + fi + body="$(printf '%s\n' \ + "## Pull request overview" \ + "" \ + "OpenCode did not approve because this PR changes the trusted Strix workflow, but no structured same-head default-branch evidence binding is available." \ + "" \ + "## Approval hold" \ + "" \ + "### The active pull_request_target workflow is base-branch code" \ + "- Problem: pull_request_target evaluates the required workflow from the trusted base branch; PR-head workflow materialization is data-only self-test input and cannot prove the new wrapper ran." \ + "- Root cause: A workflow-changing PR can otherwise receive a false-green result from the previous base workflow before its new provenance validator is active." \ + "- Fix: merge only after independent review and protected checks, then rerun same-head repository_dispatch Strix evidence and require the structured evidence-binding status." \ + "- Regression test: Keep the Strix status description and this approval hold tied to structured evidence binding, not to a generic success context." \ + "" \ + "- Result: WAITING_FOR_POST_MERGE_STRIX_EVIDENCE" \ + "- Head SHA: \`${HEAD_SHA}\`" \ + "- Required evidence: \`Default-branch repository_dispatch Strix structured evidence binding passed\`" + )" + hold_approval_without_review "WAITING_FOR_POST_MERGE_STRIX_EVIDENCE" "$body" + } + build_pending_check_body() { local pending_checks_file="$1" local body_file="$2" @@ -6558,13 +6716,6 @@ jobs: } current_head_manual_strix_success_status() { - local status_target - local manual_run_line - local manual_run_status - local manual_run_conclusion - local manual_run_url - - status_target="$( timeout "$(check_lookup_api_timeout_seconds)s" \ gh api -X GET "repos/${GH_REPOSITORY}/commits/${HEAD_SHA}/status" \ --jq ' @@ -6573,74 +6724,10 @@ jobs: | sort_by(.created_at // "") | last // empty | select((.state // "" | ascii_downcase) == "success") - | select((.description // "") | contains("Default-branch repository_dispatch Strix evidence passed")) + | select((.description // "") | contains("Default-branch repository_dispatch Strix structured evidence binding passed")) | select((.target_url // "") | test("/actions/runs/[0-9]+")) | .target_url ' - )" - if [ -n "$status_target" ]; then - printf '%s\n' "$status_target" - return 0 - fi - - manual_run_line="$(latest_current_head_manual_strix_run || true)" - IFS="$(printf '\t')" read -r manual_run_status manual_run_conclusion manual_run_url <<<"$manual_run_line" || true - if [ "$manual_run_status" = "completed" ] && - [ "$manual_run_conclusion" = "success" ] && - [ -n "$manual_run_url" ]; then - printf '%s\n' "$manual_run_url" - fi - } - - current_head_successful_strix_check_run() { - local owner="${GH_REPOSITORY%%/*}" - local name="${GH_REPOSITORY#*/}" - - timeout "$(check_lookup_api_timeout_seconds)s" gh api graphql \ - -f owner="$owner" \ - -f name="$name" \ - -F number="$PR_NUMBER" \ - -f query=' - query($owner:String!,$name:String!,$number:Int!) { - repository(owner:$owner,name:$name) { - pullRequest(number:$number) { - statusCheckRollup { - contexts(first: 100) { - nodes { - __typename - ... on CheckRun { - name - status - conclusion - completedAt - detailsUrl - checkSuite { - workflowRun { - workflow { - name - } - } - } - } - } - } - } - } - } - } - ' \ - --jq ' - (.data.repository.pullRequest.statusCheckRollup.contexts.nodes // []) - | map( - select(.__typename == "CheckRun") - | select((.status // "") == "COMPLETED") - | select((.conclusion // "" | ascii_upcase) == "SUCCESS") - | select((.name // "" | ascii_downcase) == "strix") - | select((.checkSuite.workflowRun.workflow.name // "") == "Strix Security Scan" or (.checkSuite.workflowRun.workflow.name // "") == "Strix") - ) - | sort_by(.completedAt // "") - | last.detailsUrl // empty - ' } latest_current_head_manual_strix_run() { @@ -6692,25 +6779,9 @@ jobs: local output_file="$2" local manual_strix_success_target local manual_strix_success_run_id - local manual_strix_run_info - local manual_strix_status - local manual_strix_conclusion - local manual_strix_url local failed_strix_run_id manual_strix_success_target="$(current_head_manual_strix_success_status || true)" - if [ -z "$manual_strix_success_target" ]; then - manual_strix_success_target="$(current_head_successful_strix_check_run || true)" - fi - if [ -z "$manual_strix_success_target" ]; then - manual_strix_run_info="$(latest_current_head_manual_strix_run || true)" - IFS=$'\t' read -r manual_strix_status manual_strix_conclusion manual_strix_url <<<"$manual_strix_run_info" || true - if [ "$manual_strix_status" = "completed" ] && - [ "$manual_strix_conclusion" = "success" ] && - [ -n "$manual_strix_url" ]; then - manual_strix_success_target="$manual_strix_url" - fi - fi if [ -n "$manual_strix_success_target" ]; then manual_strix_success_run_id="$(printf '%s' "$manual_strix_success_target" | sed -n 's#.*/actions/runs/\([0-9][0-9]*\).*#\1#p')" while IFS= read -r rollup_line; do @@ -7658,6 +7729,9 @@ jobs: stop_failed_check_fallback_unavailable fi fi + if hold_for_unverified_strix_workflow_update; then + : + fi if ! require_r_cmd_check_for_deferred_coverage; then body="$(printf '%s\n' \ "## Pull request overview" \ diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 03ec23257..292cd39a3 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -413,6 +413,13 @@ jobs: echo "::error::PR head ref did not resolve to expected commit $PR_HEAD_SHA after retries." >&2 exit 1 + # pull_request_target evaluates this workflow from the trusted base + # branch. Materializing a PR-head workflow above is data-only self-test + # input; it does not replace the active wrapper for this run. + # Consequently a workflow-changing PR is not cleanly evidenced until a + # default-branch repository_dispatch run executes this wrapper after the + # change is merged. + - name: Self-test Strix required workflow contract timeout-minutes: 2 working-directory: trusted-strix-source @@ -834,53 +841,36 @@ jobs: export "STRIX_MEMORY_COMPRESSOR_${budget_suffix}=300" export "STRIX_PROCESS_${budget_suffix}_SECONDS=$process_budget_seconds" export "STRIX_TOTAL_${budget_suffix}_SECONDS=5700" - - # Capture the gate exit code plus its console output. The gate returns - # exit 1 both for genuine blocking vulnerabilities AND for - # LLM-backend-unavailable outcomes (GitHub Models "Too many requests" - # rate limits, OpenAI quota starvation, 413 tokens_limit_reached - # token-cap, connection/warm-up failures) that could not complete a scan. A backend outage is CI - # infrastructure noise, not a security finding, so it must not fail - # the required check and block merges. + printf '%s\n' "${PR_HEAD_SHA:-$GITHUB_SHA}" > "$RUNNER_TEMP/strix_scan_head_sha" + + # Capture the gate exit code plus its console output. A non-zero gate + # result means the scan did not produce complete, trusted evidence; + # provider outages are therefore failures, not clean security scans. + # Fallback and retry policy belongs in the trusted gate itself. This + # wrapper must never convert an incomplete scan into success. + # The provider classifier retains the literal Nvidia_nimException + # marker for the trusted pre-merge smoke contract. strix_run_log="$RUNNER_TEMP/strix_gate_console.log" + export STRIX_GATE_MARKER_PREFIX="CWL_STRIX_GATE_MARKER_${GITHUB_RUN_ID}:" strix_rc=0 set +e bash "$TRUSTED_STRIX_GATE" 2>&1 | tee "$strix_run_log" strix_rc="${PIPESTATUS[0]}" set -e - if [ "$strix_rc" -eq 0 ]; then - exit 0 - fi - - # Preserve configuration failures (exit 2) and any unexpected exit - # code as hard failures — only the scan-failure code (1) can be an - # infrastructure/backend-unavailability outcome. - if [ "$strix_rc" -ne 1 ]; then + if [ "$strix_rc" -ne 0 ]; then + echo "::error title=Strix evidence incomplete::The trusted Strix gate did not produce a clean scan result (exit ${strix_rc}); provider failures and missing reports remain fail-closed. See the strix-reports artifact and the run log." exit "$strix_rc" fi - - # Recognized signals that the LLM backend was unavailable / starved. - backend_unavailable_signal='RateLimitError|Too many requests\. For more on scraping GitHub|exceeded your current quota|insufficient_quota|billing details|"status"[[:space:]]*:[[:space:]]*"RESOURCE_EXHAUSTED"|tokens_limit_reached|Request body too large|Max size:[[:space:]]*[0-9]+[[:space:]]+tokens|Error code:[[:space:]]*413|LLM CONNECTION FAILED|Could not establish connection to the language model|LLM warm-up failed|Configured model and fallback models were unavailable|Configured Vertex model and fallback models were unavailable|emitted provider infrastructure or failure-signal output|before provider infrastructure failure|litellm(\.exceptions)?\.NotFoundError[^[:cntrl:]]*Nvidia_nimException[^[:cntrl:]]*Error code:[[:space:]]*404' - # Any evidence that a vulnerability was actually reported. Its presence - # forces a hard failure so real findings are NEVER downgraded. Keep the - # severity branch anchored away from identifiers so environment lines - # such as STRIX_FAIL_ON_MIN_SEVERITY do not look like findings. - reported_vulnerability_signal='Vulnerabilities[[:space:]]+[1-9]|(^|[^A-Za-z0-9_])severity[[:space:]]*:' - - # Neutral skip only when ALL hold: a backend-unavailability signal is - # present and no vulnerability was reported anywhere. This preserves - # real security gating while keeping uncontrollable provider outages - # from blocking current-head merge progress. - if grep -Eiq "$backend_unavailable_signal" "$strix_run_log" \ - && ! grep -Eiq "$reported_vulnerability_signal" "$strix_run_log"; then - echo "::warning title=Strix backend unavailable::Strix could not complete because its LLM backend was unavailable (rate limit / token cap / connection or warm-up failure) before producing a vulnerability report. Treating as a neutral skip so an infrastructure outage does not block merges; genuine findings still fail the check. See the strix-reports artifact and the run log." - exit 0 + # CWE-754: a zero exit is not complete evidence if the gate itself + # printed fail-closed, incomplete-evidence, or neutral-skip text (IEEE, + # 2008). + if grep -F -- "$STRIX_GATE_MARKER_PREFIX" "$strix_run_log" | + grep -Eiq 'failing closed|fail-closed|fail closed|incomplete evidence|incomplete-evidence|neutral[[:space:]]+skip'; then + echo "::error title=Strix evidence incomplete::The trusted Strix gate printed a fail-closed, incomplete-evidence, or neutral-skip marker but exited 0; refusing to convert that into a successful required check. See the strix-reports artifact and the run log." + exit 1 fi - echo "Strix reported security findings or failed for a non-backend reason; failing the required check (gate exit ${strix_rc})." >&2 - exit "$strix_rc" - - name: Collect Strix reports for artifact upload if: ${{ always() && steps.gate.outputs.enabled == 'true' }} env: @@ -899,6 +889,10 @@ jobs: cp "$RUNNER_TEMP/strix_gate_console.log" "$GITHUB_WORKSPACE/strix_runs/gate-console.log" copied_reports=1 fi + if [ -s "$RUNNER_TEMP/strix_scan_head_sha" ]; then + cp "$RUNNER_TEMP/strix_scan_head_sha" "$GITHUB_WORKSPACE/strix_runs/scan-head-sha.txt" + copied_reports=1 + fi if [ -n "$(find "$GITHUB_WORKSPACE/strix_runs" -mindepth 1 -print -quit)" ]; then copied_reports=1 fi @@ -911,6 +905,133 @@ jobs: } > "$GITHUB_WORKSPACE/strix_runs/scan-summary.txt" fi + - name: Redact Strix evidence before artifact publication + if: ${{ always() && steps.gate.outputs.enabled == 'true' }} + run: | + set -euo pipefail + redactor="$TRUSTED_STRIX_SOURCE/scripts/ci/redact_sensitive_log.py" + if [ ! -f "$redactor" ]; then + echo "::error::Trusted Strix evidence redactor is missing." + exit 1 + fi + while IFS= read -r -d '' evidence_file; do + redacted_file="${evidence_file}.redacted" + python3 "$redactor" <"$evidence_file" >"$redacted_file" + mv -- "$redacted_file" "$evidence_file" + done < <(find "$GITHUB_WORKSPACE/strix_runs" -type f -print0) + + - name: Validate Strix report provenance + if: ${{ always() && steps.gate.outputs.enabled == 'true' }} + env: + TARGET_REPOSITORY: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository || github.repository }} + PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} + run: | + set -euo pipefail + evidence_head_sha="${PR_HEAD_SHA:-$GITHUB_SHA}" + if ! [[ "$evidence_head_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Strix evidence head SHA must be a 40-character git SHA." + exit 1 + fi + + scan_stage_head_sha="" + if [ -s "$GITHUB_WORKSPACE/strix_runs/scan-head-sha.txt" ]; then + scan_stage_head_sha="$(tr -d '[:space:]' < "$GITHUB_WORKSPACE/strix_runs/scan-head-sha.txt")" + fi + if ! [[ "$scan_stage_head_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Strix evidence must contain the exact head SHA recorded at scan start." + exit 1 + fi + if [ "${scan_stage_head_sha,,}" != "${evidence_head_sha,,}" ]; then + echo "::error::Strix scan-start head SHA does not match the evidence head." + exit 1 + fi + + successful_run_file="" + report_file="" + while IFS= read -r -d '' candidate_run; do + if ! jq -e '(.status == "completed") and (.scan_results.scan_completed == true) and (.scan_results.success == true)' "$candidate_run" >/dev/null 2>&1; then + continue + fi + candidate_metadata_count="$(jq -r ' + [ + .head_sha, + .commit_sha, + ((.scan_results // {}).head_sha), + ((.scan_results // {}).commit_sha) + ] + | map(select(. != null)) + | length + ' "$candidate_run")" + if [ "$candidate_metadata_count" -eq 0 ]; then + continue + fi + candidate_metadata_matches=1 + candidate_head_sha="" + while IFS= read -r candidate_metadata_value; do + if [ -z "$candidate_head_sha" ]; then + candidate_head_sha="$candidate_metadata_value" + fi + if ! [[ "$candidate_metadata_value" =~ ^[0-9a-fA-F]{40}$ ]] || + [ "${candidate_metadata_value,,}" != "${evidence_head_sha,,}" ]; then + candidate_metadata_matches=0 + break + fi + done < <(jq -r ' + [ + .head_sha, + .commit_sha, + ((.scan_results // {}).head_sha), + ((.scan_results // {}).commit_sha) + ] + | map(select(. != null)) + | .[] + | if type == "string" then . else "__invalid_metadata_type__" end + ' "$candidate_run") + if [ "$candidate_metadata_matches" -ne 1 ]; then + continue + fi + candidate_report="$(dirname -- "$candidate_run")/penetration_test_report.md" + if [ -s "$candidate_report" ]; then + successful_run_file="$candidate_run" + report_file="$candidate_report" + break + fi + done < <(find "$GITHUB_WORKSPACE/strix_runs" -type f -name run.json -print0) + + if [ -z "$successful_run_file" ] || [ -z "$report_file" ]; then + echo "::error::Strix evidence must contain a completed successful run.json and a non-empty penetration_test_report.md." + exit 1 + fi + + gate_console="$GITHUB_WORKSPACE/strix_runs/gate-console.log" + marker_prefix="CWL_STRIX_GATE_MARKER_${GITHUB_RUN_ID}:" + if [ -f "$gate_console" ] && + grep -F -- "$marker_prefix" "$gate_console" | + grep -Eiq 'failing closed|fail-closed|fail closed|incomplete evidence|incomplete-evidence|neutral[[:space:]]+skip'; then + echo "::error::Strix evidence contains a fail-closed/provider-infrastructure marker; it cannot be published as a successful scan." + exit 1 + fi + + if ! [[ "${GITHUB_RUN_ID:-}" =~ ^[0-9]+$ ]]; then + echo "::error::GitHub Actions run ID is missing or malformed." + exit 1 + fi + # The provider's run.json may contain an internal run identifier. + # Only the outer GitHub Actions run ID can bind the uploaded artifact + # to the status URL consumed by the protected review gate. + run_id="$GITHUB_RUN_ID" + report_sha256="$(sha256sum "$report_file" | awk '{print $1}')" + jq -n \ + --arg repository "$TARGET_REPOSITORY" \ + --arg artifact_name "strix-reports" \ + --arg head_sha "$evidence_head_sha" \ + --arg run_id "$run_id" \ + --arg run_json "${successful_run_file#"$GITHUB_WORKSPACE/strix_runs/"}" \ + --arg report "${report_file#"$GITHUB_WORKSPACE/strix_runs/"}" \ + --arg report_sha256 "$report_sha256" \ + '{repository:$repository, artifact_name:$artifact_name, head_sha:$head_sha, run_id:$run_id, run_json:$run_json, report:$report, report_sha256:$report_sha256, scan_completed:true}' \ + > "$GITHUB_WORKSPACE/strix_runs/evidence-binding.json" + - name: Upload Strix reports artifact if: ${{ always() && steps.gate.outputs.enabled == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -940,7 +1061,7 @@ jobs: case "$STRIX_RESULT" in success) state="success" - description="Default-branch repository_dispatch Strix evidence passed" + description="Default-branch repository_dispatch Strix structured evidence binding passed" ;; failure|cancelled|skipped) state="failure" @@ -1090,7 +1211,7 @@ jobs: case "$STRIX_RESULT" in success) state="success" - description="Default-branch repository_dispatch Strix evidence passed" + description="Default-branch repository_dispatch Strix structured evidence binding passed" ;; failure|cancelled|skipped) state="failure" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..04f93b079 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,100 @@ +# Architecture — ContextualWisdomLab `.github` + +This repository is the organization control plane. It is not naruon and it +does not own product data. Sibling products remain standalone modules; this +repo publishes org profile assets, reusable required workflows, and the +review/merge schedulers those products consume. + +## System context + +```mermaid +flowchart LR + Buyer["Commercial buyer / reviewer"] + Agents["Agents on AGENTS.md"] + Project["GitHub Project #1"] + Hub["This repo: org .github"] + Products["Owned products
naruon · orchestrator · engines"] + Runner["Required workflows in each repo context"] + + Buyer --> Hub + Agents --> Project + Agents --> Hub + Project --> Hub + Hub --> Runner + Runner --> Products + Products -->|"standalone or as module"| Buyer +``` + +## Strix incomplete-evidence gate + +```mermaid +flowchart TD + Gate["Trusted strix_quick_gate.sh"] + Rc{"exit 0 and no run-scoped gate marker?"} + Pass["Required check succeeds"] + Fail["Fail closed: incomplete evidence"] + + Gate --> Rc + Rc -->|"yes"| Pass + Rc -->|"no"| Fail +``` + +CWE-754: a zero exit plus a run-scoped `CWL_STRIX_GATE_MARKER_:` +line containing `failing closed`, incomplete evidence, or neutral skip is +unusual and must not become a green security check. The wrapper ignores the +same words in untrusted scanner/model/source text. + +`pull_request_target` evaluates required workflow YAML from the trusted +base/default branch. A PR-head workflow may be materialized for data-only +self-test, but it is not the active wrapper. Workflow-changing PRs therefore +need a post-merge default-branch `repository_dispatch` Strix run with an +`evidence-binding.json` binding the exact PR-head SHA, scan-start SHA, +metadata-bearing `run.json`, workflow run ID, artifact, report path, and report +SHA-256. A metadata-less `run.json` is excluded rather than substituted with +the scan-start SHA; the generic `strix` success context is insufficient. + +## Control-plane data flow + +```mermaid +sequenceDiagram + participant PR as Pull request + participant RW as Required workflows + participant OC as OpenCode reviewer + participant SV as sandboxed_verify / web E2E + participant MS as Merge scheduler + + PR->>RW: pull_request_target on trusted base + RW->>OC: bounded evidence + NVIDIA NIM / OpenCode + OC->>SV: PoC command in isolated copy + SV-->>OC: redacted stdout/stderr + command metadata + OC-->>PR: APPROVE or request changes + MS->>PR: dispatch default-branch Strix for exact PR head + PR-->>MS: structured same-head evidence binding + MS->>PR: merge only on protected review + current checks + clean binding + resolved threads +``` + +## Trust boundaries + +- Required review workflows execute **base-branch** scripts. +- Reviewer agents stay `edit: deny`. +- Evidence artifacts apply the allowlisted minimum-disclosure scrubber to + credentials, email addresses, phone numbers, IPv4 addresses, and absolute + runner paths before upload. Repository-relative source locations remain so + findings stay actionable; private reasons are not propagated to reviewer + context. Artifact access is limited to the repository's existing Actions + artifact readers, for the security-review purpose, with the existing + five-day retention and GitHub audit trail. +- LLM and scheduled agents bind `NVIDIA_NIM_API_KEY`. They never use + `COPILOT_GITHUB_TOKEN`. +- Rust remains the psychometric arithmetic owner. + +## Quality gates + +`scripts/ci/` ships with 100% statement/branch coverage and 100% +docstrings. + +## Related durable documents + +- [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) +- [`docs/doctoring/strix-provider-evidence-fail-closed.md`](docs/doctoring/strix-provider-evidence-fail-closed.md) +- [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..b7e4b4a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Refused a completed successful Strix `run.json` that carries no `head_sha` or `commit_sha` metadata. Provenance no longer substitutes the scan-start SHA for that missing binding, so an unbound report cannot publish as current-head evidence (CWE-754). +- Kept the required Strix check fail-closed when the trusted gate prints fail-closed or incomplete-evidence text even if the process exits 0, so a provider outage cannot become a green security check (CWE-754). The wrapper now matches hyphenated and spaced spellings of those markers, not only `failing closed` and `incomplete evidence`. Scan-start `scan-head-sha.txt` must also match the evidence head SHA before provenance can publish. + - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/CLAUDE.md b/CLAUDE.md index 1c7bdb2f6..21fe57c68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,9 @@ Details: `README.md` and `PR_GOVERNANCE_AUDIT.md`. dependency sets (see below). - `fuzz/` + `.clusterfuzzlite/` — Atheris fuzz targets for the review-output normalizer and the ClusterFuzzLite discovery marker. +- `ARCHITECTURE.md` — control-plane mermaid (system context, Strix + fail-closed gate, review sequence, trust boundaries). Reconstruct from + the repo, not private agent memory. - `docs/` — master context, Project protocol, `org-required-workflow-rollout.md`, `scorecard-governance.md`, SBOM inventory. - `.jules/` — recorded performance (`bolt.md`) and security (`sentinel.md`) learnings from past work diff --git a/docs/doctoring/aggregate-review-decision-merge-gate.md b/docs/doctoring/aggregate-review-decision-merge-gate.md new file mode 100644 index 000000000..64cbd0f9d --- /dev/null +++ b/docs/doctoring/aggregate-review-decision-merge-gate.md @@ -0,0 +1,40 @@ +# Aggregate review approval is a merge gate + +## Incident + +The scheduler treated a current-head OpenCode `APPROVED` review as sufficient +to enable or retain native auto-merge. That was unsafe when GitHub's aggregate +`reviewDecision` remained `REVIEW_REQUIRED` (or another non-approval state), +which is the state that represents missing code-owner, independent, or other +branch-protection review policy. + +The defect was observed while auditing the exact current heads of +`ContextualWisdomLab/.github#965`, +`ContextualWisdomLab/contextual-orchestrator#109`, and +`ContextualWisdomLab/fast-mlsirm#816`: the scheduler could re-enable +auto-merge after seeing the automated current-head review even though GitHub +reported `REVIEW_REQUIRED`. + +## Decision + +The scheduler now requires both signals before it can merge or enable +auto-merge: + +1. OpenCode approved the exact current head. +2. GitHub's aggregate `reviewDecision` is exactly `APPROVED`. + +Missing, empty, `REVIEW_REQUIRED`, and `CHANGES_REQUESTED` aggregate states +fail closed. An existing auto-merge request is disabled; otherwise the PR is +blocked. The REST fallback continues to use `REVIEW_REQUIRED` because it does +not expose the GraphQL aggregate decision, so REST-only data cannot create +merge authority. + +This is a merge-policy gate, not a replacement for terminal current-head +checks, structured Strix evidence, resolved review threads, independent +approval, or protected-branch enforcement. + +## Verification + +`tests/test_pr_review_merge_scheduler.py` covers every non-approval aggregate +state and asserts that neither merge nor auto-merge is invoked. The focused +scheduler suite passes with 114 tests. diff --git a/docs/doctoring/strix-nvidia-nim-not-found-fallback.md b/docs/doctoring/strix-nvidia-nim-not-found-fallback.md index 70299ebdf..16ffe655e 100644 --- a/docs/doctoring/strix-nvidia-nim-not-found-fallback.md +++ b/docs/doctoring/strix-nvidia-nim-not-found-fallback.md @@ -30,10 +30,10 @@ combining with an unrelated application `404` to spoof infrastructure fallback. Provider-side failure also remains a fail-closed incomplete scan until a distinct fallback produces complete evidence. -The outer workflow may classify exhausted provider infrastructure as neutral only -when the run log contains no vulnerability signal. Any reported severity or -non-zero vulnerability count remains blocking. Scanner reports and attempt logs -remain available as artifacts. +Exhausted provider infrastructure is incomplete evidence and remains +fail-closed even when the run log contains no vulnerability signal. A provider +outage cannot be classified as a clean or neutral security result. Scanner +reports and attempt logs remain available as artifacts. ## Verification contract diff --git a/docs/doctoring/strix-provider-evidence-fail-closed.md b/docs/doctoring/strix-provider-evidence-fail-closed.md new file mode 100644 index 000000000..baec57b52 --- /dev/null +++ b/docs/doctoring/strix-provider-evidence-fail-closed.md @@ -0,0 +1,255 @@ +# Strix provider failures are incomplete evidence + +## Incident + +The trusted Strix workflow previously converted a non-zero gate result into a +successful required check when the console contained a provider-unavailable +marker and no parsed vulnerability line. That made a rate limit, provider +retirement response, or missing report indistinguishable from a completed +zero-finding scan. + +The failure was observed on the same-head scan for +`ContextualWisdomLab/fast-mlsirm#816` at +`e2480e76dfa2139ab23f8372013681dd2cead46a`: the report artifact said zero +vulnerabilities, while the gate logs recorded NVIDIA NIM `429`, GitHub Models +`410`, and an explicit incomplete-evidence/fail-closed result. The required +check nevertheless reported success because the workflow wrapper neutralized +the non-zero gate exit. + +## Decision + +The trusted gate remains responsible for bounded retry and fallback. The +workflow wrapper now propagates every non-zero gate result. Provider outages, +timeouts, missing reports, and malformed evidence therefore remain failed +security checks until a clean, current-head scan is available. A successful +check is reserved for a trusted gate exit of zero that did not also print +fail-closed, fail closed, failing closed, incomplete-evidence, or +incomplete evidence text. + +CWE-754 (MITRE, 2026) and IEEE 1028 (IEEE, 2008): a zero process exit is +an unusual condition when the same log says the scan is failing closed. +The wrapper must not treat that as a completed security review. + +This preserves the security boundary: infrastructure failure may delay a merge, +but it cannot create an unaudited approval signal. + +## Active required-workflow boundary (2026-08-13) + +The exact-head `ContextualWisdomLab/.github#965` run at commit +`5489c5106123f150a3bd77cfb3759de7de4219b1` exposed a second false-green path. +Run `31681226640`, job `94386887113`, reported `success`, but its downloaded +`strix-reports` artifact contained NVIDIA NIM `429`, GitHub Models `410`, +`No Strix vulnerability report artifact was produced`, and no +`evidence-binding.json`. The job step list also lacked the PR-head +`Validate Strix report provenance` step. + +The cause is GitHub execution semantics: `pull_request_target` runs the +workflow YAML from the trusted base/default branch. Its PR-head materialization +is data-only input for the trusted smoke test; it does not execute the PR-head +workflow wrapper. Therefore a workflow-changing PR cannot use its own +pull-request run as proof that the new wrapper is active. + +The remediation is now explicit. The status publisher uses the distinct +description `Default-branch repository_dispatch Strix structured evidence +binding passed`, and the OpenCode approval path holds a workflow-changing PR +until that exact same-head status exists. After the workflow PR is merged by the +normal protected-branch process, a new default-branch `repository_dispatch` +run must produce a matching `evidence-binding.json` before the result is called +clean. The observed run above is inconclusive and must not be used as approval +evidence. + +The same boundary was reproduced on the current exact head of PR #965. Run +`31696985802` (job `94436969831`) reported `success` for head +`b8695c534cf15a2227d92f942dcce3c653276393`, but the downloaded +`strix-reports` artifact had no `evidence-binding.json`, no provenance-validation +step, one `completed` `run.json` without head/commit metadata, and three failed +`run.json` files. Its gate log also contained NVIDIA NIM `429`, GitHub Models +`410` retirement-brownout, `failing closed`, and `No Strix vulnerability +report artifact was produced` markers. Because this was again the trusted +base workflow selected by `pull_request_target`, the green job is +inconclusive base-workflow evidence, not proof that the PR-head provenance +change ran. It must not be used to clear the required security check; only a +post-merge/default-branch `repository_dispatch` run with a matching structured +binding and clean provider evidence can establish completion. + +The provenance step also fails closed when `scan-head-sha.txt` exists but +does not match the evidence head SHA. A scan started on a different commit +cannot be published as current-head evidence. + +A completed successful `run.json` with no `head_sha` or `commit_sha` (including +nested `scan_results` fields) is also incomplete evidence. The wrapper +previously substituted the scan-start SHA for that missing binding. That let a +copied or metadata-less report publish as current-head evidence. Provenance now +skips those candidates. Only a `run.json` that itself carries a matching head +SHA can pair with `penetration_test_report.md`. + +The failed-check evidence collector follows the same rule. A generic successful +check-run or workflow-run is not sufficient to supersede a stale Strix failure; +the collector accepts only a downloaded `strix-reports` artifact whose binding +matches the current head and run ID, whose report exists, and whose SHA-256 +digest matches the binding. If that artifact cannot be downloaded or verified, +the failed check remains active. + +The same fail-closed rule applies to status supersession. A previous +`current_head_manual_strix_success_status` implementation fell back to any +same-head `repository_dispatch` run whose API result said `completed/success`. +That run result is not proof that the structured artifact was bound to the +head, run ID, and report digest, so it could recreate a false-green path. +The fallback was removed; only the explicit structured status description can +supersede a stale Strix context. The contract tests reject reintroduction of +the unbound fallback. + +The latest reproduction is run `31702234021` (job `94453926612`) for head +`4d7267b3bf5a90a1fd5a64368bb5c9af33f12234`. GitHub again reported the Strix job +as `success`, but the artifact contained only failed `run.json` files, no +`evidence-binding.json`, and provider failures including NVIDIA NIM `429`, a +GitHub Models `410` retirement brownout, and a context-window overflow. The +executed step list had no provenance-validation step because the +`pull_request_target` run used the trusted base workflow; that base workflow +printed `Treating as a neutral skip` after the fallback attempts were +exhausted. This is not clean security evidence and cannot clear the required +check. + +The wrapper now treats any `neutral skip` marker in the captured gate log as +incomplete evidence even when the gate exits zero. The regression contract pins +that marker check. This protects future default-branch runs, while the PR that +introduces the fix still requires a post-merge default-branch +`repository_dispatch` run with a matching `evidence-binding.json`; a green +`pull_request_target` result before that run remains base-workflow evidence only. + +After this fix was pushed, central run `31708982141` for the exact head +`e1cfbed814431533ffbe03ba0f33aca671c160da` was cancelled at +`2026-08-13T14:16:42Z` before Strix could produce a report. The same +`pull_request_target` event cancelled the linked required jobs, while the +contextual-orchestrator and fast-mlsirm exact-head jobs remained queued and the +three repository runner APIs reported `0 total / 0 online / 0 busy`. This is +CI-capacity evidence, not a code or security conclusion; no cancelled run may +supersede the required checks or structured-evidence gate. + +## Model tool-contract failures (2026-08-14) + +Contextual-orchestrator PR #109 exact head +`27aa4ad3dcfbd94ec85fbce40a77955361b877c4` produced a failed Strix run +`31775265809`/job `94689345852` after 884 seconds. NVIDIA NIM Nemotron returned +an agent tool request that the installed Strix agent could not execute: +`agents.exceptions.ModelBehaviorError: Tool execute not found in agent strix`, +with the trusted traceback in `strix/core/execution.py`. No vulnerability +report was produced and publication was skipped. + +The trusted gate must preserve this as provider/model execution failure and +incomplete evidence. Central PR #965 adds a bounded classifier requiring both +the exact agent exception and the Strix execution traceback, routes only to a +distinct fallback model, and deliberately does not retry the same model. The +classifier rejects target-source text that merely copies the error wording. +This is not a LibreSSL/TLS diagnosis, a target vulnerability, or a clean scan. + +Central run `31776384905` later produced a zero-finding report, but artifact +`9210207198` still lacked `evidence-binding.json` because the +`pull_request_target` execution used the protected base workflow. That result +is provider/content evidence only. A protected-main integration followed by a +default-branch run must still bind repository, full head, run/job, report path, +and digest before any security result can satisfy a merge gate. + +## Dependabot alert reconciliation (2026-08-14) + +The repository default branch still reports open alerts #5--#9 for `aiohttp` +and `cryptography`, although the manifests already carry the first patched +versions: `aiohttp==3.14.3` and `cryptography==50.0.0` in both Strix +requirements files. The current required Python supply-chain check passes. + +Keep the exact pins and hash lock, do not dismiss or suppress these alerts, and +re-fetch the alert manifest and `first_patched_version` after dependency +refreshes until GitHub recomputes the stale alert state. If a refreshed alert +still overlaps an installed version, regenerate the lock and hashes from the +project tooling and rerun the security workflow; never weaken the gate to make +the warning disappear. + +## Current-head review remediation (2026-08-14) + +The exact-head CodeRabbit review of central PR #965 identified four boundary +issues that remain part of the acceptance contract: + +1. A structured `strix` commit status is usable only when its description is + an exact match, its URL is exactly the configured repository's Actions run + URL, and the referenced run API object is the same successful + `repository_dispatch` execution of `.github/workflows/strix.yml` with the + current head SHA. A description substring, external Actions URL, different + workflow, or different head is rejected. +2. The gate emits a run-scoped marker prefix before fail-closed or incomplete + evidence messages. The wrapper matches only that prefix, so untrusted model, + scanner, or target-source text cannot manufacture a marker or cause a + false-negative guard. +3. The retained `strix_runs/` tree is scrubbed by the trusted redactor before + provenance binding and artifact upload. Its minimum-disclosure allowlist + removes credential shapes, email addresses, phone numbers, IPv4 addresses, + and absolute runner paths while preserving repository-relative findings and + exact report digests. +4. Each OpenCode model attempt is launched in a dedicated POSIX session and + process group. Cleanup therefore cannot skip a child because it inherited + the review shell's process group; the failed-check artifact download also + receives `/dev/null` on stdin and its cleanup function returns explicitly. + +The corresponding regressions cover the exact URL/run/head/workflow contract, +run-scoped marker detection, evidence redaction, requirements include paths, +and process-group cleanup. These fixes do not create approval authority: +independent review, terminal current-head checks, structured same-head Strix +evidence, resolved threads, and protected merge remain separate gates. + +## Structured-status hold must validate the artifact (2026-08-14) + +The post-merge hold consumer had a narrower boundary than the failed-check +collector: it verified the `strix` status description, Actions URL, and +`repository_dispatch` run metadata, but it could have released +`WAITING_FOR_POST_MERGE_STRIX_EVIDENCE` without downloading the run's +`strix-reports` artifact. A successful status and run object alone do not prove +that `evidence-binding.json`, the report path, or the report digest exists. + +The consumer now downloads the named `strix-reports` artifact and requires the +same current head SHA, run ID, completed scan marker, safe report-relative path, +nonempty report, and SHA-256 digest match used by failed-check supersession. +Missing, mismatched, malformed, or digest-invalid artifacts leave the hold in +place. The contract test covers missing binding, wrong head, wrong run ID, +missing report, and wrong digest cases. + +## Artifact identity and outer-run binding (2026-08-14) + +The first version of this consumer checked only the artifact name and copied +the provider's `run.json` identifier into `evidence-binding.json`. A provider +run identifier is not the GitHub Actions run identifier in the status URL, and +name-only download is ambiguous if a run exposes duplicate, expired, or stale +artifacts. The workflow now records the target repository, the exact +`strix-reports` artifact name, and the outer `$GITHUB_RUN_ID`; consumers first +require exactly one non-expired artifact with that name, then require all three +binding fields before accepting the report. The provider's internal identifier +remains non-authoritative. Regression coverage rejects missing, duplicate, and +expired artifact listings as well as repository/name mismatches. + +## Current exact-head provider/content evidence (2026-08-14) + +Central PR #965 exact head +`3a2be84e983f44f4ad584a650f9721223621b52b` produced Strix run +`31777570466`/job `94696182267` with a successful zero-finding report and +artifact `9210803173`. The changed-file materializer retained seven CI/workflow +files, and the report assessed the scanning infrastructure rather than an +application target. The artifact contained no `evidence-binding.json` and the +raw `run.json` had no repository, head, or digest metadata. This is bounded +provider/content and scope evidence only, not proof of a clean PR-head security +scan or merge eligibility. Protected-main integration and a matching structured +binding remain required. + +The linked fast-mlsirm PR #816 exact head +`03004b8ca54a6f821109afbc02bca5e7e3f94391` produced Strix run +`31777428325`/job `94695759332` with a successful zero-finding report and +artifact `9210847280`. Its raw `run.json` likewise had null repository/head/ +digest fields and the artifact had no `evidence-binding.json`. Preserve this +as provider/content evidence only; do not promote it to a clean security gate +until the hardened workflow is on protected `main` and a post-integration run +verifies the exact repository, full head, run/job, report path, and digest. + +## References + +MITRE. (2026). *CWE-754: Improper check for unusual or exceptional +conditions*. https://cwe.mitre.org/data/definitions/754.html + +IEEE. (2008). *IEEE standard for software reviews and audits* (IEEE Std +1028-2008). https://doi.org/10.1109/IEEESTD.2008.4601584 diff --git a/docs/doctoring/trusted-uv-lock-materialization.md b/docs/doctoring/trusted-uv-lock-materialization.md index 8f78759ca..ff72a4ec4 100644 --- a/docs/doctoring/trusted-uv-lock-materialization.md +++ b/docs/doctoring/trusted-uv-lock-materialization.md @@ -169,6 +169,16 @@ This prerequisite repair is intentionally test-only for production behavior. It changes neither the trusted uv download boundary nor the dependency closure accepted by the coverage sandbox. +## Local-host platform contract (2026-08-14) + +The pinned archive is intentionally Linux x86_64-only. macOS is a supported +development host but is not a supported runtime for this exporter, so installer +regression tests must explicitly pin the mocked platform to Linux x86_64 when +they exercise download, executable verification, or cleanup behavior. The +unsupported-runner test remains separate and proves that Darwin and non-x86_64 +inputs fail before any download. This keeps local full-suite runs truthful +without widening the trusted binary supply-chain boundary. + ## References Astral Software, Inc. (n.d.). *Exporting a lockfile*. uv documentation. Retrieved diff --git a/scripts/ci/collect_failed_check_evidence.sh b/scripts/ci/collect_failed_check_evidence.sh index 51e5f1e5b..934085fca 100755 --- a/scripts/ci/collect_failed_check_evidence.sh +++ b/scripts/ci/collect_failed_check_evidence.sh @@ -376,6 +376,8 @@ workflow_run_contexts="$(mktemp)" active_failed_contexts="$(mktemp)" manual_success_contexts="$(mktemp)" manual_success_check_runs="$(mktemp)" +manual_success_check_run_candidates="$(mktemp)" +manual_success_run_candidates="$(mktemp)" superseded_failed_contexts="$(mktemp)" tmp_files=( "$failed_contexts" @@ -383,6 +385,8 @@ tmp_files=( "$active_failed_contexts" "$manual_success_contexts" "$manual_success_check_runs" + "$manual_success_check_run_candidates" + "$manual_success_run_candidates" "$superseded_failed_contexts" ) cleanup() { @@ -411,6 +415,85 @@ target_workflow_available() { return 1 } +manual_strix_run_has_structured_binding() { + local run_id="$1" + local artifact_dir + local artifact_json + local artifact_count + local binding_file + local report_path + local report_file + local expected_report_sha256 + local actual_report_sha256 + + if [ -z "$run_id" ]; then + return 1 + fi + artifact_dir="$(mktemp -d)" + if ! artifact_json="$(gh api -X GET "repos/${GH_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100")"; then + rm -rf -- "$artifact_dir" + return 1 + fi + if ! artifact_count="$(jq -r '[.artifacts[]? | select((.name // "") == "strix-reports" and .expired == false)] | length' <<<"$artifact_json")"; then + rm -rf -- "$artifact_dir" + return 1 + fi + if [ "$artifact_count" != "1" ]; then + rm -rf -- "$artifact_dir" + return 1 + fi + if ! gh run download "$run_id" \ + --repo "$GH_REPOSITORY" \ + --name strix-reports \ + --dir "$artifact_dir" /dev/null 2>&1; then + rm -rf -- "$artifact_dir" + return 1 + fi + + binding_file="$(find "$artifact_dir" -type f -name evidence-binding.json -print -quit)" + if [ -z "$binding_file" ] || ! jq -e --arg repository "$GH_REPOSITORY" --arg head_sha "$HEAD_SHA" --arg run_id "$run_id" ' + .repository == $repository + and .artifact_name == "strix-reports" + and .head_sha == $head_sha + and ((.run_id // "") | tostring) == $run_id + and .scan_completed == true + and ((.report // "") | type == "string") + ' "$binding_file" >/dev/null 2>&1; then + rm -rf -- "$artifact_dir" + return 1 + fi + + report_path="$(jq -r '.report // empty' "$binding_file")" + case "$report_path" in + ""|/*|../*|*/../*) + rm -rf -- "$artifact_dir" + return 1 + ;; + esac + report_file="$(dirname -- "$binding_file")/$report_path" + if [ ! -s "$report_file" ]; then + rm -rf -- "$artifact_dir" + return 1 + fi + expected_report_sha256="$(jq -r '.report_sha256 // empty' "$binding_file")" + if [ -z "$expected_report_sha256" ]; then + rm -rf -- "$artifact_dir" + return 1 + fi + if command -v sha256sum >/dev/null 2>&1; then + actual_report_sha256="$(sha256sum "$report_file" | awk '{print $1}')" + else + actual_report_sha256="$(shasum -a 256 "$report_file" | awk '{print $1}')" + fi + if [ "$actual_report_sha256" != "$expected_report_sha256" ]; then + rm -rf -- "$artifact_dir" + return 1 + fi + + rm -rf -- "$artifact_dir" + return 0 +} + manual_success_for_label() { local label="$1" local failed_run_id="${2:-}" @@ -598,12 +681,20 @@ gh api graphql \ | [ "strix", (.detailsUrl // ""), - "Current-head successful Strix check run superseded stale failed Strix evidence." + "Current-head successful Strix check run superseded stale failed Strix evidence.", + ((.checkSuite.workflowRun.databaseId // "") | tostring) ] ) | .[] | @tsv - ' >"$manual_success_check_runs" + ' >"$manual_success_check_run_candidates" + +while IFS=$'\t' read -r success_context success_url success_description success_run_id; do + if [ -z "$success_run_id" ] || ! manual_strix_run_has_structured_binding "$success_run_id"; then + continue + fi + printf '%s\t%s\t%s\n' "$success_context" "$success_url" "$success_description" >>"$manual_success_check_runs" +done <"$manual_success_check_run_candidates" if target_workflow_available "strix.yml"; then env HEAD_SHA="$HEAD_SHA" gh run list \ @@ -620,12 +711,22 @@ if target_workflow_available "strix.yml"; then | select((.status // "") == "completed") | select((.conclusion // "" | ascii_downcase) == "success") | [ - "strix", - (.url // ""), - "Default-branch repository_dispatch Strix evidence passed" + (.databaseId // "" | tostring), + (.url // "") ] | @tsv - ' >>"$manual_success_check_runs" || true + ' >"$manual_success_run_candidates" || true + + while IFS=$'\t' read -r success_run_id success_url; do + if [ -z "$success_run_id" ] || ! manual_strix_run_has_structured_binding "$success_run_id"; then + continue + fi + printf '%s\t%s\t%s\n' \ + "strix" \ + "$success_url" \ + "Default-branch repository_dispatch Strix structured evidence binding passed" \ + >>"$manual_success_check_runs" + done <"$manual_success_run_candidates" fi env HEAD_SHA="$HEAD_SHA" gh run list \ @@ -674,7 +775,7 @@ if ! gh api -X GET "repos/${GH_REPOSITORY}/commits/${HEAD_SHA}/status" \ | map(last) | map( select((.state // "" | ascii_downcase) == "success") - | select((.description // "") | contains("Default-branch repository_dispatch Strix evidence passed")) + | select((.description // "") | contains("Default-branch repository_dispatch Strix structured evidence binding passed")) | select((.target_url // "") | test("/actions/runs/[0-9]+")) | [ (.__context_key // ""), diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 75e18c860..d542c1a63 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -1583,6 +1583,20 @@ def head_already_restamped_for_last_push_approval(pr: dict[str, Any]) -> bool: return latest_commit_headline(pr) == LAST_PUSH_APPROVAL_RESTAMP_MESSAGE +def aggregate_review_approved(pr: dict[str, Any]) -> bool: + """Return whether GitHub's aggregate review decision permits merging.""" + return str(pr.get("reviewDecision") or "").upper() == "APPROVED" + + +def aggregate_review_gate_reason(pr: dict[str, Any]) -> str: + """Explain why an OpenCode approval cannot substitute for aggregate review approval.""" + review_decision = str(pr.get("reviewDecision") or "").upper() or "MISSING" + return ( + "current-head OpenCode approval exists, but GitHub reviewDecision is " + f"{review_decision}; require aggregate APPROVED before merge or re-enabling auto-merge" + ) + + def should_restamp_for_last_push_approval( repo: str, pr: dict[str, Any], @@ -1598,7 +1612,7 @@ def should_restamp_for_last_push_approval( return False if not same_repository_head(repo, pr): return False - if str(pr.get("reviewDecision") or "").upper() != "APPROVED": + if not aggregate_review_approved(pr): return False if strix_evidence_state(pr) != "complete": return False @@ -2400,13 +2414,25 @@ def request_branch_update(freshness_reason: str, *, suffix: str = "") -> Decisio return decide("block", "current-head OpenCode review requested changes") current_head_approved = has_current_head_approval(pr) + auto_merge_enabled = bool(pr.get("autoMergeRequest")) + if current_head_approved and not aggregate_review_approved(pr): + review_gate_reason = aggregate_review_gate_reason(pr) + if auto_merge_enabled: + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=review_gate_reason, + ) + ) + return decide("block", review_gate_reason) if current_head_approved: stale_review_cleanup_count = dismiss_stale_opencode_change_requests( repo, pr, dry_run=dry_run, ) - auto_merge_enabled = bool(pr.get("autoMergeRequest")) if merge_state in {"DIRTY", "CONFLICTING"}: conflict_reason = merge_conflict_guidance(pr, merge_state) if current_head_approved: @@ -3189,7 +3215,7 @@ def self_test() -> None: "isCrossRepository": False, "maintainerCanModify": False, "headRepository": {"nameWithOwner": "owner/repo"}, - "reviewDecision": "REVIEW_REQUIRED", + "reviewDecision": "APPROVED", "commits": { "nodes": [ { diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 16e89f264..2690302eb 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Redact credentials from CI log text before it becomes review evidence.""" from __future__ import annotations @@ -30,6 +29,18 @@ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"), re.compile(r"\bAKIA[0-9A-Z]{16}\b"), ) +EMAIL_RE = re.compile( + r"(? Any: @@ -114,13 +125,21 @@ def _redact_assignments(text: str) -> str: def _redact_unstructured(text: str) -> str: - """Redact credential-shaped values from non-JSON diagnostic text.""" + """Redact credential-shaped and allowlisted operational identifiers.""" cleaned = _redact_assignments(text) cleaned = BEARER_RE.sub(lambda match: f"{match.group('prefix')}{REDACTED}", cleaned) cleaned = JWT_RE.sub(REDACTED, cleaned) for pattern in PROVIDER_TOKEN_RES: cleaned = pattern.sub(REDACTED, cleaned) - return cleaned + return _redact_operational_identifiers(cleaned) + + +def _redact_operational_identifiers(text: str) -> str: + """Apply the minimum-disclosure allowlist to common operational PII.""" + cleaned = EMAIL_RE.sub("[REDACTED_EMAIL]", text) + cleaned = PHONE_RE.sub("[REDACTED_PHONE]", cleaned) + cleaned = IPV4_RE.sub("[REDACTED_IP]", cleaned) + return RUNNER_PATH_RE.sub("[REDACTED_PATH]", cleaned) def _redact_line(line: str) -> str: @@ -129,7 +148,9 @@ def _redact_line(line: str) -> str: value = json.loads(line) except json.JSONDecodeError: return _redact_unstructured(line) - return json.dumps(_redact_json(value), ensure_ascii=False, separators=(",", ":")) + return _redact_operational_identifiers( + json.dumps(_redact_json(value), ensure_ascii=False, separators=(",", ":")) + ) def redact_text(text: str) -> str: diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 986982e9a..8a3b4242f 100644 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -3,6 +3,57 @@ set -euo pipefail : "${GITHUB_OUTPUT:=/dev/null}" +signal_process_tree() { + local signum="$1" + local pid="$2" + local child pgid shell_pgid + for child in $(pgrep -P "$pid" 2>/dev/null || true); do + signal_process_tree "$signum" "$child" + done + pgid="$(process_group_id_for_pid "$pid")" + shell_pgid="$(ps -o pgid= -p "$$" 2>/dev/null | tr -d ' ')" + if [ -n "$pgid" ] && [ "$pgid" != "$shell_pgid" ]; then + signal_process_group "$signum" "$pgid" + else + kill "-$signum" "$pid" 2>/dev/null || true + fi +} + +process_group_id_for_pid() { + ps -o pgid= -p "$1" 2>/dev/null | tr -d ' ' +} + +signal_process_group() { + local signum="$1" + local pgid="$2" + local shell_pgid + shell_pgid="$(process_group_id_for_pid "$$")" + if [[ "$pgid" =~ ^[0-9]+$ ]] && [ "$pgid" != "$shell_pgid" ]; then + kill "-$signum" -- "-$pgid" 2>/dev/null || true + fi +} + +capture_process_group_ids() { + local pid="$1" + local child pgid + pgid="$(process_group_id_for_pid "$pid")" + if [ -n "$pgid" ]; then + printf '%s\n' "$pgid" + fi + for child in $(pgrep -P "$pid" 2>/dev/null || true); do + capture_process_group_ids "$child" + done +} + +signal_captured_process_groups() { + local signum="$1" + local captured_groups="$2" + local pgid + while IFS= read -r pgid; do + [ -n "$pgid" ] || continue + signal_process_group "$signum" "$pgid" + done <<<"$captured_groups" +} record_review_status() { printf 'review_status=%s\n' "$1" >>"$GITHUB_OUTPUT" } @@ -446,6 +497,56 @@ cap_model_run_timeout() { fi } +run_opencode_in_process_group() { + local run_timeout_seconds="$1" + local prompt_file="$2" + local agent="$3" + local model_candidate="$4" + local title="$5" + + # Python is already required by the review runner. os.setsid() is the + # portable macOS/Linux primitive available here; unlike timeout alone it + # gives every attempt a process group that cannot be the parent shell's. + python3 - "$run_timeout_seconds" "$prompt_file" "$agent" "$model_candidate" "$title" <<'PY' +from pathlib import Path +import os +import sys + +run_timeout_seconds, prompt_file, agent, model_candidate, title = sys.argv[1:] +os.setsid() +for name in ( + "GH_TOKEN", + "GITHUB_TOKEN", + "OPENCODE_APP_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_URL", +): + os.environ.pop(name, None) +prompt = Path(prompt_file).read_text(encoding="utf-8") +os.execvpe( + "timeout", + [ + "timeout", + "--kill-after=30s", + f"{run_timeout_seconds}s", + "opencode", + "run", + prompt, + "--pure", + "--agent", + agent, + "--model", + model_candidate, + "--format", + "json", + "--title", + title, + ], + os.environ, +) +PY +} + run_one_model_attempt() { local model_candidate="$1" local attempt="$2" @@ -456,7 +557,7 @@ run_one_model_attempt() { local opencode_json_file="$7" local opencode_export_file="$8" local run_timeout_seconds export_timeout_seconds opencode_status session_id opencode_stderr_file - local opencode_pid fatal_poll_seconds + local opencode_pid fatal_poll_seconds opencode_process_groups run_timeout_seconds="${OPENCODE_RUN_TIMEOUT_SECONDS:-3600}" export_timeout_seconds="${OPENCODE_EXPORT_TIMEOUT_SECONDS:-120}" @@ -465,15 +566,12 @@ run_one_model_attempt() { rm -f "$opencode_json_file" "$opencode_stderr_file" "$opencode_export_file" "$candidate_output_file" set +e - timeout --kill-after=30s "${run_timeout_seconds}s" \ - env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN \ - -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ - opencode run "$(cat "$prompt_file")" \ - --pure \ - --agent "$agent" \ - --model "$model_candidate" \ - --format json \ - --title "PR #${PR_NUMBER} OpenCode bounded review ${model_candidate} attempt ${attempt}/${attempts}" \ + run_opencode_in_process_group \ + "$run_timeout_seconds" \ + "$prompt_file" \ + "$agent" \ + "$model_candidate" \ + "PR #${PR_NUMBER} OpenCode bounded review ${model_candidate} attempt ${attempt}/${attempts}" \ >"$opencode_json_file" 2>"$opencode_stderr_file" & opencode_pid=$! # Some providers (github-models ContextOverflowError) log a fatal error and @@ -482,14 +580,19 @@ run_one_model_attempt() { # through to the next candidate within seconds instead of minutes. while kill -0 "$opencode_pid" 2>/dev/null; do if has_fatal_provider_error_event "$opencode_json_file"; then + opencode_process_groups="$(capture_process_group_ids "$opencode_pid")" printf 'OpenCode %s attempt %s/%s logged a fatal provider error while still running; killing the hung process instead of waiting out the %ss run timeout.\n' \ "$model_candidate" "$attempt" "$attempts" "$run_timeout_seconds" - kill "$opencode_pid" 2>/dev/null + signal_process_tree TERM "$opencode_pid" for _ in $(seq 1 30); do kill -0 "$opencode_pid" 2>/dev/null || break sleep 1 done - kill -9 "$opencode_pid" 2>/dev/null + if [ -n "$opencode_process_groups" ]; then + signal_captured_process_groups KILL "$opencode_process_groups" + else + signal_process_tree KILL "$opencode_pid" + fi break fi sleep "$fatal_poll_seconds" diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 0f37f3460..1b4d86b31 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -46,6 +46,7 @@ STRIX_TRANSIENT_RETRY_PER_MODEL="${STRIX_TRANSIENT_RETRY_PER_MODEL:-0}" STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS="${STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS:-3}" STRIX_FAIL_ON_MIN_SEVERITY="${STRIX_FAIL_ON_MIN_SEVERITY:-MEDIUM}" STRIX_FAIL_ON_PROVIDER_SIGNAL="${STRIX_FAIL_ON_PROVIDER_SIGNAL:-0}" +STRIX_GATE_MARKER_PREFIX="${STRIX_GATE_MARKER_PREFIX:-CWL_STRIX_GATE_MARKER:}" RUN_START_EPOCH=0 TOTAL_TIMEOUT_EXCEEDED=0 ATTEMPT_LOG_SEQUENCE=0 @@ -69,6 +70,10 @@ PULL_REQUEST_SCOPE_DIRS=() LAST_PULL_REQUEST_SCOPE_DIR="" TARGET_PATH_IS_INTERNAL_PR_SCOPE=0 +emit_strix_gate_marker() { + printf '%s %s\n' "$STRIX_GATE_MARKER_PREFIX" "$*" | tee -a "$STRIX_LOG" >&2 +} + resolve_trusted_input_file() { local label="$1" local input_file="$2" @@ -483,7 +488,7 @@ is_valid_git_commit_sha() { invalid_pull_request_sha() { local label="$1" - echo "ERROR: pull request $label commit SHA is invalid; failing closed." >&2 + emit_strix_gate_marker "ERROR: pull request $label commit SHA is invalid; failing closed." return 2 } @@ -550,11 +555,11 @@ changed_file_exists_for_scan() { return 1 ;; 3) - echo "ERROR: pull request changed file is not a regular PR-head file; failing closed: $relative_path" >&2 + emit_strix_gate_marker "ERROR: pull request changed file is not a regular PR-head file; failing closed: $relative_path" return 2 ;; *) - echo "ERROR: pull request changed file could not be read from PR head; failing closed: $relative_path" >&2 + emit_strix_gate_marker "ERROR: pull request changed file could not be read from PR head; failing closed: $relative_path" return 2 ;; esac @@ -579,7 +584,7 @@ changed_file_exists_for_scan() { return 1 ;; 3) - echo "ERROR: pull request changed file is not a regular PR-head file; failing closed: $relative_path" >&2 + emit_strix_gate_marker "ERROR: pull request changed file is not a regular PR-head file; failing closed: $relative_path" return 2 ;; *) @@ -1011,7 +1016,7 @@ PY fi if [ -z "$base_sha" ] || [ -z "$head_sha" ]; then if pull_request_head_blob_required; then - echo "ERROR: pull request base/head metadata is unavailable; failing closed." >&2 + emit_strix_gate_marker "ERROR: pull request base/head metadata is unavailable; failing closed." return 2 fi return 1 @@ -1032,14 +1037,14 @@ PY fi if ! git rev-parse --verify --quiet "$base_sha^{commit}" >/dev/null; then if pull_request_head_blob_required; then - echo "ERROR: pull request base commit could not be read; failing closed: $base_sha" >&2 + emit_strix_gate_marker "ERROR: pull request base commit could not be read; failing closed: $base_sha" return 2 fi return 1 fi if ! git rev-parse --verify --quiet "$head_sha^{commit}" >/dev/null; then if pull_request_head_blob_required; then - echo "ERROR: pull request head commit could not be read; failing closed: $head_sha" >&2 + emit_strix_gate_marker "ERROR: pull request head commit could not be read; failing closed: $head_sha" return 2 fi return 1 @@ -1056,14 +1061,14 @@ PY if changed_files_output="$(git -c core.quotepath=false diff --name-only "$base_sha" "$head_sha" -- 2>/dev/null)"; then echo "Using explicit base/head diff for workflow_dispatch PR-scope Strix evidence." >&2 else - echo "ERROR: pull request changed file list could not be read; failing closed." >&2 + emit_strix_gate_marker "ERROR: pull request changed file list could not be read; failing closed." return 2 fi elif changed_files_output="$(git -c core.quotepath=false diff --name-only "$base_sha..$head_sha" -- 2>/dev/null)"; then echo "INFO: Unable to compute PR merge base; falling back to direct base/head diff for changed file enumeration." >&2 else if pull_request_head_blob_required; then - echo "ERROR: pull request changed file list could not be read; failing closed." >&2 + emit_strix_gate_marker "ERROR: pull request changed file list could not be read; failing closed." return 2 fi return 1 @@ -1134,11 +1139,11 @@ is_scannable_changed_file() { return 1 ;; 3) - echo "ERROR: pull request changed file is not a regular PR-head file; failing closed: $normalized_changed_file" >&2 + emit_strix_gate_marker "ERROR: pull request changed file is not a regular PR-head file; failing closed: $normalized_changed_file" return 2 ;; *) - echo "ERROR: pull request changed file could not be read from PR head; failing closed: $normalized_changed_file" >&2 + emit_strix_gate_marker "ERROR: pull request changed file could not be read from PR head; failing closed: $normalized_changed_file" return 2 ;; esac @@ -1337,7 +1342,7 @@ PY return 0 fi if pull_request_head_blob_required || [ "$copy_rc" -eq 2 ]; then - echo "ERROR: pull request changed file could not be read from PR head; failing closed: $changed_file" >&2 + emit_strix_gate_marker "ERROR: pull request changed file could not be read from PR head; failing closed: $changed_file" return 2 fi local src_path="$REPO_ROOT/$relative_path" @@ -1386,7 +1391,7 @@ PY return 0 fi if pull_request_head_blob_required || [ "$copy_rc" -eq 2 ]; then - echo "ERROR: pull request changed context file could not be read from PR head; failing closed: $context_file" >&2 + emit_strix_gate_marker "ERROR: pull request changed context file could not be read from PR head; failing closed: $context_file" return 2 fi ;; @@ -1482,17 +1487,17 @@ build_pull_request_head_tree_scope_dir() { local head_sha head_sha="$(trim_whitespace "${PR_HEAD_SHA:-}")" if [ -z "$head_sha" ] || ! is_valid_git_commit_sha "$head_sha"; then - echo "ERROR: pull request head commit SHA is invalid; failing closed." >&2 + emit_strix_gate_marker "ERROR: pull request head commit SHA is invalid; failing closed." return 2 fi if ! git rev-parse --verify --quiet "$head_sha^{commit}" >/dev/null; then - echo "ERROR: pull request head commit could not be read; failing closed: $head_sha" >&2 + emit_strix_gate_marker "ERROR: pull request head commit could not be read; failing closed: $head_sha" return 2 fi local tree_output if ! tree_output="$(git -c core.quotepath=false ls-tree -r --full-tree "$head_sha")"; then - echo "ERROR: pull request head tree could not be read; failing closed." >&2 + emit_strix_gate_marker "ERROR: pull request head tree could not be read; failing closed." return 2 fi @@ -1512,14 +1517,14 @@ build_pull_request_head_tree_scope_dir() { continue fi if [ "$object_type" != "blob" ]; then - echo "ERROR: pull request head tree entry is not a blob; failing closed: $relative_path" >&2 + emit_strix_gate_marker "ERROR: pull request head tree entry is not a blob; failing closed: $relative_path" return 2 fi case "$mode" in 100644 | 100755) ;; *) - echo "ERROR: pull request head tree entry has unsupported mode $mode; failing closed: $relative_path" >&2 + emit_strix_gate_marker "ERROR: pull request head tree entry has unsupported mode $mode; failing closed: $relative_path" return 2 ;; esac @@ -1542,7 +1547,7 @@ PY tmp_dst="$(mktemp "$(dirname -- "$dst_path")/.pr-head.XXXXXX")" || return 2 if ! git cat-file blob "$object_hash" >"$tmp_dst"; then rm -f -- "$tmp_dst" - echo "ERROR: pull request head blob could not be copied; failing closed: $relative_path" >&2 + emit_strix_gate_marker "ERROR: pull request head blob could not be copied; failing closed: $relative_path" return 2 fi if ! mv -- "$tmp_dst" "$dst_path"; then @@ -1556,7 +1561,7 @@ PY done <<<"$tree_output" if [ "$copied_file_count" -eq 0 ]; then - echo "ERROR: pull request head tree contains no regular files to scan; failing closed." >&2 + emit_strix_gate_marker "ERROR: pull request head tree contains no regular files to scan; failing closed." return 2 fi @@ -1975,7 +1980,7 @@ evaluate_pull_request_findings() { fi if ! load_pull_request_changed_files; then PR_FINDINGS_DECISION="block_unmapped" - echo "Unable to map Strix findings to changed files; failing closed for pull request." >&2 + emit_strix_gate_marker "Unable to map Strix findings to changed files; failing closed for pull request." return 1 fi @@ -2009,7 +2014,7 @@ evaluate_pull_request_findings() { rank="$(extract_max_severity_rank "$vuln_file")" if [ "$rank" -lt 0 ]; then PR_FINDINGS_DECISION="block_unmapped" - echo "Unrecognized Strix severity marker; failing closed for pull request." >&2 + emit_strix_gate_marker "Unrecognized Strix severity marker; failing closed for pull request." return 1 fi if [ "$rank" -lt "$threshold_rank" ]; then @@ -2019,7 +2024,7 @@ evaluate_pull_request_findings() { mapfile -t vulnerability_locations < <(extract_vulnerability_locations "$vuln_file") if [ "${#vulnerability_locations[@]}" -eq 0 ]; then PR_FINDINGS_DECISION="block_unmapped" - echo "Unable to map Strix findings to changed files; failing closed for pull request." >&2 + emit_strix_gate_marker "Unable to map Strix findings to changed files; failing closed for pull request." return 1 fi if all_vulnerability_locations_are_dependency_manifests "${vulnerability_locations[@]}"; then @@ -2071,7 +2076,7 @@ evaluate_pull_request_findings() { mapfile -t vulnerability_locations < <(extract_vulnerability_locations "$STRIX_LOG") if [ "${#vulnerability_locations[@]}" -eq 0 ]; then PR_FINDINGS_DECISION="block_unmapped" - echo "Unable to map Strix findings to changed files; failing closed for pull request." >&2 + emit_strix_gate_marker "Unable to map Strix findings to changed files; failing closed for pull request." return 1 fi if all_vulnerability_locations_are_dependency_manifests "${vulnerability_locations[@]}"; then @@ -2116,7 +2121,7 @@ evaluate_pull_request_findings() { if [ "$found_changed_manifest_only_threshold_finding" -eq 1 ]; then PR_FINDINGS_DECISION="block_manifest_finding" - echo "Strix changed-manifest threshold finding requires package and CVE remediation; pull-request-controlled SCA workflow results cannot override model evidence, so the scan is failing closed." >&2 + emit_strix_gate_marker "Strix changed-manifest threshold finding requires package and CVE remediation; pull-request-controlled SCA workflow results cannot override model evidence, so the scan is failing closed." return 1 fi @@ -2163,7 +2168,7 @@ fail_unmapped_threshold_report() { return 1 fi PR_FINDINGS_DECISION="block_unmapped" - echo "Unable to map Strix findings to changed files; failing closed for pull request." >&2 + emit_strix_gate_marker "Unable to map Strix findings to changed files; failing closed for pull request." echo "Strix quick scan failed with a non-recoverable error." >&2 return 0 } @@ -2587,13 +2592,13 @@ PY local report_failure_signal=0 if has_strix_report_failure_signal "$ACTIVE_REPORTS_DIR" "${resolved_target_path%/}/strix_runs"; then report_failure_signal=1 - echo "Strix report artifacts emitted warning/fatal/denied/timeout output; failing closed." | tee -a "$STRIX_LOG" >&2 + emit_strix_gate_marker "Strix report artifacts emitted warning/fatal/denied/timeout output; failing closed." fi if [ "$report_failure_signal" -eq 1 ] || has_detected_infrastructure_error; then INFRA_ERROR_DETECTED=1 if [ "$rc" -eq 0 ] && provider_signal_fail_closed_enabled; then - echo "Strix run emitted provider infrastructure or failure-signal output; failing closed." >&2 + emit_strix_gate_marker "Strix run emitted provider infrastructure or failure-signal output; failing closed." return 1 fi fi @@ -2601,7 +2606,7 @@ PY if [ "$rc" -eq 0 ]; then if has_blocking_vulnerability_reports; then if ! evaluate_pull_request_findings || [ "$PR_FINDINGS_DECISION" != "allow_baseline" ]; then - echo "Strix exited successfully but emitted a vulnerability at or above '$STRIX_FAIL_ON_MIN_SEVERITY'; failing closed." >&2 + emit_strix_gate_marker "Strix exited successfully but emitted a vulnerability at or above '$STRIX_FAIL_ON_MIN_SEVERITY'; failing closed." return 1 fi fi @@ -2924,6 +2929,19 @@ is_midstream_fallback_error() { return 1 } +is_strix_model_tool_contract_error() { + # Strix can fail before producing a report when a provider/model response + # requests a tool that the installed agent does not expose. Require both + # the exact agent exception and a Strix execution traceback so target-source + # text cannot manufacture a fallback signal. + if grep -Fq 'agents.exceptions.ModelBehaviorError: Tool execute not found in agent strix' "$STRIX_LOG" && + grep -Fq 'strix/core/execution.py' "$STRIX_LOG"; then + return 0 + fi + + return 1 +} + # Narrower variant: LLM providers only, excluding HTTP transport libraries # (httpx, httpcore, requests). Used for generic transport failures where # library names alone are insufficient to prove the timeout/connection error @@ -2964,6 +2982,10 @@ has_detected_infrastructure_error() { return 0 fi + if is_strix_model_tool_contract_error; then + return 0 + fi + if is_llm_api_connection_error; then return 0 fi @@ -3077,7 +3099,7 @@ has_only_below_threshold_vulnerabilities() { done if [ "$found_any_vuln_file" -eq 0 ]; then - echo "No Strix vulnerability report artifact was produced; log-only severity markers are incomplete evidence, so the scan is failing closed." >&2 + emit_strix_gate_marker "No Strix vulnerability report artifact was produced; log-only severity markers are incomplete evidence, so the scan is failing closed." return 1 fi @@ -3094,7 +3116,7 @@ has_only_below_threshold_vulnerabilities() { # failure — or even success — but the partial report's low-severity # findings must not be treated as a clean scan result. if [ "$INFRA_ERROR_DETECTED" -eq 1 ]; then - echo "Below-threshold findings detected, but infrastructure errors occurred during this pipeline run; refusing bypass due to potentially incomplete scan." >&2 + emit_strix_gate_marker "Below-threshold findings detected, but infrastructure errors occurred during this pipeline run; refusing bypass due to potentially incomplete scan." return 1 fi @@ -3150,7 +3172,7 @@ fail_reported_vulnerabilities_before_fallback_success() { esac if has_blocking_vulnerability_reports; then - echo "Strix model reported threshold vulnerabilities before fallback success; failing closed so every model-reported vulnerability is reviewed." >&2 + emit_strix_gate_marker "Strix model reported threshold vulnerabilities before fallback success; failing closed so every model-reported vulnerability is reviewed." echo "Strix quick scan failed with a non-recoverable error." >&2 return 0 fi @@ -3221,7 +3243,7 @@ should_fail_pull_request_infra_zero_findings() { return 1 fi - echo "Strix reported zero vulnerabilities before provider infrastructure failure; failing closed because provider infrastructure failures are not clean scan evidence." >&2 + emit_strix_gate_marker "Strix reported zero vulnerabilities before provider infrastructure failure; failing closed because provider infrastructure failures are not clean scan evidence." return 0 } @@ -3857,6 +3879,10 @@ is_model_retryable_error() { return 0 fi + if is_strix_model_tool_contract_error; then + return 0 + fi + if [ "$PR_FINDINGS_DECISION" = "retry_model_inconsistency" ]; then return 0 fi @@ -3897,12 +3923,12 @@ run_current_target_scan() { if is_model_retryable_error "$PRIMARY_MODEL" && has_distinct_fallback_model_for_model "$PRIMARY_MODEL"; then strict_primary_provider_fallback=1 else - echo "Strix scan failed after provider infrastructure or failure-signal output; failing closed." >&2 + emit_strix_gate_marker "Strix scan failed after provider infrastructure or failure-signal output; failing closed." return 1 fi fi - if has_only_below_threshold_vulnerabilities; then + if [ "$strict_primary_provider_fallback" -eq 0 ] && has_only_below_threshold_vulnerabilities; then return 0 fi @@ -3979,7 +4005,7 @@ run_current_target_scan() { strict_fallback_provider_signal=1 fi - if has_only_below_threshold_vulnerabilities; then + if [ "$strict_fallback_provider_signal" -eq 0 ] && has_only_below_threshold_vulnerabilities; then return 0 fi diff --git a/scripts/ci/strix_required_workflow_smoke.sh b/scripts/ci/strix_required_workflow_smoke.sh index 8cd6dddad..8ec480ab7 100755 --- a/scripts/ci/strix_required_workflow_smoke.sh +++ b/scripts/ci/strix_required_workflow_smoke.sh @@ -139,6 +139,9 @@ assert_file_contains "$workflow_file" "requirements-strix-ci-hashes.txt" "Strix assert_file_contains "$workflow_file" "Materialize target workspace" "Strix workflow separates target workspace from trusted source" assert_file_contains "$workflow_file" 'STRIX_REPO_ROOT:' "Strix workflow passes target root explicitly" assert_file_contains "$workflow_file" 'bash "$TRUSTED_STRIX_GATE"' "Strix workflow executes central Strix gate" +assert_file_contains "$workflow_file" "Validate Strix report provenance" "Strix workflow validates structured report provenance before upload" +assert_file_contains "$workflow_file" "evidence-binding.json" "Strix workflow binds uploaded evidence to the scanned head" +assert_file_contains "$workflow_file" "Default-branch repository_dispatch Strix structured evidence binding passed" "Strix workflow publishes only structured same-head evidence success" assert_file_contains "$workflow_file" "Self-test Strix required workflow contract" "Strix workflow uses bounded required-path smoke test" assert_file_contains "$workflow_file" 'bash "$TRUSTED_STRIX_REQUIRED_SMOKE"' "Strix workflow executes bounded smoke test" assert_file_contains "$workflow_file" "timeout-minutes: 2" "Strix required-path smoke test has a short timeout" @@ -159,6 +162,7 @@ assert_file_contains "$workflow_file" "nvidia_nim/nvidia/nemotron-3-super-120b-a assert_file_contains "$workflow_file" "nvidia_nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 github_models/openai/o3 github_models/openai/gpt-5-chat" "Strix tries another NVIDIA hosted model before GitHub Models" assert_file_contains "$workflow_file" "Nvidia_nimException" "Strix workflow recognizes provider-scoped NVIDIA NIM failures" assert_file_contains "$gate_script" "is_nvidia_nim_not_found_error" "Strix gate classifies NVIDIA NIM model-catalog 404s" +assert_file_contains "$gate_script" "is_strix_model_tool_contract_error" "Strix gate classifies unsupported provider tool contracts" if [ "$failures" -ne 0 ]; then echo "Strix required workflow smoke test failed with $failures failure(s)." >&2 diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 7343c06ac..39df78332 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -226,6 +226,22 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" "bash \"\$TRUSTED_STRIX_GATE\"" "strix workflow executes trusted temp gate script" assert_file_contains "$workflow_file" "Collect Strix reports for artifact upload" "strix workflow preserves reports from trusted workspace" assert_file_contains "$workflow_file" "scan-summary.txt" "strix workflow creates a fallback artifact when Strix emits no report files" + assert_file_contains "$workflow_file" "Validate Strix report provenance" "strix workflow validates structured report provenance before publishing evidence" + assert_file_contains "$workflow_file" "scan_results.scan_completed == true" "strix workflow requires a completed Strix scan result" + assert_file_contains "$workflow_file" "strix_scan_head_sha" "strix workflow records the head SHA at scan start" + assert_file_contains "$workflow_file" "scan-head-sha.txt" "strix workflow preserves the scan-stage head SHA artifact" + assert_file_contains "$workflow_file" "Strix scan-start head SHA does not match the evidence head." "strix workflow binds the scan-start SHA to the evidence head" + assert_file_contains "$workflow_file" "candidate_head_sha" "strix workflow binds each candidate report to a head SHA" + assert_file_contains "$workflow_file" "candidate_metadata_count" "strix workflow inspects every candidate head metadata field" + assert_file_contains "$workflow_file" "candidate_metadata_matches" "strix workflow rejects conflicting candidate head metadata" + assert_file_contains "$workflow_file" "scan_stage_head_sha" "strix workflow still records the scan-stage head SHA for mismatch checks" + assert_file_not_contains "$workflow_file" 'candidate_head_sha="$scan_stage_head_sha"' "strix provenance does not treat a run.json with no head metadata as current-head evidence" + assert_file_contains "$workflow_file" "__invalid_metadata_type__" "strix workflow rejects non-string candidate head metadata" + assert_file_contains "$workflow_file" "((.scan_results // {}).commit_sha)" "strix workflow checks alternate structured commit metadata" + assert_file_contains "$workflow_file" "evidence-binding.json" "strix workflow binds the report artifact to the scanned head SHA" + assert_file_contains "$workflow_file" "fail-closed/provider-infrastructure marker" "strix workflow rejects provider-failure evidence even when a report exists" + assert_file_contains "$GATE_SCRIPT" 'strict_primary_provider_fallback" -eq 0 ] && has_only_below_threshold_vulnerabilities' "strix gate cannot bypass strict primary provider fallback with below-threshold findings" + assert_file_contains "$GATE_SCRIPT" 'strict_fallback_provider_signal" -eq 0 ] && has_only_below_threshold_vulnerabilities' "strix gate cannot bypass strict fallback provider signal with below-threshold findings" local checkout_count checkout_count="$(grep -Fc "uses: actions/checkout@" "$workflow_file")" assert_equals "1" "$checkout_count" "strix workflow uses actions/checkout exactly once for the central trusted source" @@ -708,8 +724,10 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "Never return raw tool-call markup" "opencode review prompt forbids raw tool-call transcripts as final review output" assert_file_contains "$workflow_file" "Do not spend the session listing every changed path before reviewing" "opencode review prompt prevents fallback sessions from exhausting steps on file listing" assert_file_contains "$workflow_file" "Always return a final control block instead of a progress summary" "opencode review prompt requires a gate conclusion instead of a progress summary" - assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'timeout --kill-after=30s "${run_timeout_seconds}s"' "opencode review model pool has a kill-after bounded timeout" - assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'env -u GH_TOKEN -u GITHUB_TOKEN -u OPENCODE_APP_TOKEN' "opencode review model pool scrubs GitHub credentials before model execution" + assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "run_opencode_in_process_group" "opencode review model pool starts each attempt in a dedicated process group" + assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'os.setsid()' "opencode review model pool isolates the attempt session from the parent shell" + assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" '"--kill-after=30s"' "opencode review model pool has a kill-after bounded timeout" + assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" '"GH_TOKEN"' "opencode review model pool scrubs GitHub credentials before model execution" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "assert_reasoning_effort_for_candidate" "opencode review validates high reasoning effort before running capable model candidates" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "assert_opencode_reasoning_effort.py" "opencode review reuses the central reasoning effort guard" assert_file_contains "$REPO_ROOT/scripts/ci/assert_opencode_reasoning_effort.py" "options.reasoningEffort=high" "opencode review requires high reasoning effort in opencode.jsonc for capable models" @@ -725,7 +743,14 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "not a generic model-exhaustion message" "opencode review tells models to return concrete missing-evidence findings instead of progress-only output" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "tokens_limit_reached" "opencode review detects provider context-window overflow" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "skipping remaining attempts for this model" "opencode review skips same-model retries after context-window overflow" - assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" "exceeded your current quota" "strix wrapper neutralizes quota-only provider failures without vulnerability reports" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'if [ "$strix_rc" -ne 0 ]; then' "strix wrapper fails when the trusted gate does not produce clean evidence" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'exit "$strix_rc"' "strix wrapper propagates nonzero trusted-gate results" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'CWL_STRIX_GATE_MARKER_${GITHUB_RUN_ID}:' "strix wrapper scopes fail-closed marker detection to gate-generated run markers" + assert_file_contains "$REPO_ROOT/scripts/ci/strix_quick_gate.sh" "emit_strix_gate_marker" "strix gate prefixes fail-closed evidence markers before log publication" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" "Redact Strix evidence before artifact publication" "strix workflow redacts all retained evidence before upload" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" "redact_sensitive_log.py" "strix artifact redaction uses the tested trusted scrubber" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" "neutral[[:space:]]+skip" "strix wrapper rejects provider neutral-skip output even when the gate exits zero" + assert_file_not_contains "$REPO_ROOT/.github/workflows/strix.yml" "Treating as a neutral skip" "strix wrapper must not convert provider outages into successful security evidence" assert_file_contains "$REPO_ROOT/scripts/ci/strix_quick_gate.sh" "billing details" "strix quick gate classifies provider quota starvation as infrastructure" assert_file_contains "$workflow_file" 'timeout-minutes: 325' "opencode review target contains evidence, the bounded long-review pool, publication, Noema handoff, and cleanup overhead" assert_file_contains "$workflow_file" 'timeout-minutes: 12' "opencode evidence preparation fails closed before it ties up the review queue" @@ -1106,10 +1131,10 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_not_contains "$workflow_file" '(.name // "") == "scan-pr-queue" and ((.workflow // "") == "PR Review Merge Scheduler" or (.workflow // "") == "Required PR Review Merge Scheduler")' "opencode scheduler cancellation classification does not depend on optional workflow metadata" assert_file_contains "$workflow_file" 'grep -Fq -- "Strix Security Scan/strix:" "$rollup_file"' "opencode approval avoids duplicate supplemental Strix workflow-run blockers when statusCheckRollup already has the Strix check" assert_file_contains "$workflow_file" 'current_head_manual_strix_success_status()' "opencode approval can identify same-head manual Strix success status evidence" - assert_file_contains "$workflow_file" 'manual_run_line="$(latest_current_head_manual_strix_run || true)"' "opencode approval falls back to same-head manual Strix check-run success when commit status publication is unavailable" + assert_file_not_contains "$workflow_file" 'manual_run_line="$(latest_current_head_manual_strix_run || true)"' "opencode approval must not treat an unbound manual Strix run as successful evidence" assert_file_contains "$workflow_file" 'filter_superseded_strix_failures()' "opencode approval filters only explicitly superseded stale Strix failures" assert_file_contains "$workflow_file" '"- Strix Security Scan/"*|"- strix:"*' "opencode approval filters stale Strix workflow helper checks after newer manual evidence" - assert_file_contains "$workflow_file" 'Default-branch repository_dispatch Strix evidence passed' "opencode approval requires an explicit manual Strix evidence status description" + assert_file_contains "$workflow_file" 'Default-branch repository_dispatch Strix structured evidence binding passed' "opencode approval requires an explicit structured manual Strix evidence status description" assert_file_contains "$workflow_file" 'last // empty' "opencode approval checks the latest strix status before accepting manual success evidence" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'publish-manual-pr-evidence-status:' "strix workflow publishes same-head manual PR evidence as a commit status" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'statuses: write' "strix scan job can publish same-repo manual status evidence" @@ -1299,13 +1324,20 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "Create one OpenCode finding per Strix model vulnerability report" "failed-check evidence contract requires one finding per Strix model report" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "model name, title, severity, endpoint, and Code Locations/path:line evidence" "failed-check evidence collector names required Strix report fields" assert_file_contains "$workflow_file" "If bounded failed GitHub Check evidence contains active failed checks, treat it as a blocker until diagnosed." "opencode review prompt forces active failed-check diagnosis" - assert_file_contains "$workflow_file" "A successful same-head default-branch repository_dispatch Strix run may supersede a stale failed PR statusCheckRollup Strix context only when failed-check evidence explicitly lists it under Superseded failed checks with the exact target URL" "opencode review prompt allows only explicit same-head manual Strix evidence to supersede stale rollup failures" - assert_file_contains "$workflow_file" "current_head_successful_strix_check_run" "opencode approval gate treats same-head successful Strix check runs as stale Strix failure superseders" + assert_file_contains "$workflow_file" "A successful same-head default-branch repository_dispatch Strix run with the exact structured evidence-binding status may supersede a stale failed PR statusCheckRollup Strix context only when failed-check evidence explicitly lists it under Superseded failed checks with the exact target URL" "opencode review prompt allows only exact structured same-head Strix evidence to supersede stale rollup failures" + assert_file_contains "$workflow_file" "current_head_manual_strix_structured_success_status" "opencode approval gate treats only structured same-head Strix status as stale Strix failure superseder" + assert_file_not_contains "$workflow_file" "current_head_successful_strix_check_run" "opencode approval must not supersede failures from an unbound generic successful check run" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "Superseded failed checks" "failed-check evidence lists stale failed contexts superseded by current-head manual Strix evidence" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "manual_success_contexts" "failed-check evidence compares explicit manual success statuses before active failures" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "manual_success_check_runs" "failed-check evidence compares successful same-head Strix check runs before active failures" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "--workflow strix.yml" "failed-check evidence looks up same-head manual Strix success runs when status publication is unavailable" - assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '"Default-branch repository_dispatch Strix evidence passed"' "failed-check evidence records manual Strix success without requiring a commit status" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '"Default-branch repository_dispatch Strix structured evidence binding passed"' "failed-check evidence records structured manual Strix success without requiring a commit status" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "manual_strix_run_has_structured_binding" "failed-check evidence verifies a structured Strix artifact before superseding failures" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'gh run download "$run_id"' "failed-check evidence downloads the exact Strix artifact before accepting run-only success" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '.head_sha == $head_sha' "failed-check evidence binds downloaded Strix artifacts to the current head" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" '((.run_id // "") | tostring) == $run_id' "failed-check evidence binds the downloaded artifact to its workflow run" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'evidence-binding.json' "failed-check evidence requires the structured Strix evidence binding" + assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" 'actual_report_sha256' "failed-check evidence verifies the structured report digest" assert_file_contains "$REPO_ROOT/scripts/ci/collect_failed_check_evidence.sh" "No active failed GitHub Checks remained after superseded checks were classified" "failed-check evidence reports no active failures after stale contexts are superseded" assert_file_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" "Strix vulnerability report window([[:space:]]|$)" "failed-check fallback detects numbered Strix vulnerability report windows with a POSIX ERE boundary" assert_file_not_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" "Strix vulnerability report window\\\\b" "failed-check fallback must not rely on non-portable grep -E word boundaries" diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 8a383f0c2..7ad8991a8 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -644,6 +644,8 @@ def test_install_trusted_uv_verifies_version_and_caches_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The installer writes one executable, verifies its version, and caches it.""" + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -690,6 +692,8 @@ def test_install_trusted_uv_rejects_version_process_failures( failure: OSError | subprocess.TimeoutExpired, ) -> None: """A missing or hung downloaded executable is removed and rejected.""" + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -721,6 +725,8 @@ def test_install_trusted_uv_rejects_wrong_version_or_exit_status( completed: subprocess.CompletedProcess[bytes], ) -> None: """Unexpected version output or a nonzero status cannot satisfy the pin.""" + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" monkeypatch.setattr( materializer.tempfile, diff --git a/tests/test_opencode_model_pool_runner.py b/tests/test_opencode_model_pool_runner.py index 08d17f000..33ea789b6 100644 --- a/tests/test_opencode_model_pool_runner.py +++ b/tests/test_opencode_model_pool_runner.py @@ -157,14 +157,40 @@ def run_failed_model( fake_opencode.write_text( "#!/usr/bin/env bash\n" 'if [ "${1:-}" = run ]; then\n' - ' [ -z "${FAKE_OPENCODE_PROMPT_CAPTURE:-}" ] || printf \'%s\\n\' "$2" > "$FAKE_OPENCODE_PROMPT_CAPTURE"\n' + ' prompt="${2:-}"\n' + ' model=""\n' + ' while [ "$#" -gt 0 ]; do\n' + ' if [ "${1:-}" = "--model" ] && [ "$#" -ge 2 ]; then\n' + ' model="$2"\n' + ' shift 2\n' + ' else\n' + ' shift\n' + ' fi\n' + ' done\n' + ' [ -z "${FAKE_OPENCODE_MODEL_LOG:-}" ] || printf \'%s\\n\' "$model" >> "$FAKE_OPENCODE_MODEL_LOG"\n' + ' if [ "$model" = "${FAKE_OPENCODE_NEXT_MODEL:-}" ] && [ -f "${FAKE_OPENCODE_CHILD_PID_FILE:-}" ]; then\n' + ' child_pid="$(tr -d \'[:space:]\' < "$FAKE_OPENCODE_CHILD_PID_FILE")"\n' + ' if [ -n "$child_pid" ] && kill -0 "$child_pid" 2>/dev/null; then\n' + ' : > "${FAKE_OPENCODE_OVERLAP_FILE:?}"\n' + ' fi\n' + ' fi\n' + ' if [ "$model" = "${FAKE_OPENCODE_FATAL_MODEL:-}" ]; then\n' + ' (trap "" TERM; sleep "${FAKE_OPENCODE_CHILD_SLEEP_SECONDS:-120}") &\n' + ' child_pid=$!\n' + ' [ -z "${FAKE_OPENCODE_CHILD_PID_FILE:-}" ] || printf \'%s\' "$child_pid" > "$FAKE_OPENCODE_CHILD_PID_FILE"\n' + ' fi\n' + ' [ -z "${FAKE_OPENCODE_PROMPT_CAPTURE:-}" ] || printf \'%s\\n\' "$prompt" > "$FAKE_OPENCODE_PROMPT_CAPTURE"\n' ' [ -z "${FAKE_OPENCODE_JSON:-}" ] || printf \'%s\\n\' "$FAKE_OPENCODE_JSON"\n' ' [ -z "${FAKE_OPENCODE_STDERR:-}" ] || printf \'%s\\n\' "$FAKE_OPENCODE_STDERR" >&2\n' - ' sleep "${FAKE_OPENCODE_HANG_SECONDS:-0}"\n' + ' if [ -n "${FAKE_OPENCODE_FATAL_MODEL:-}" ] && [ "$model" != "$FAKE_OPENCODE_FATAL_MODEL" ]; then\n' + ' sleep "${FAKE_OPENCODE_NON_FATAL_HANG_SECONDS:-0}"\n' + ' else\n' + ' sleep "${FAKE_OPENCODE_HANG_SECONDS:-0}"\n' + ' fi\n' ' exit "${FAKE_OPENCODE_RUN_EXIT:-1}"\n' "fi\n" 'if [ "${1:-}" = export ]; then\n' - ' [ -z "${FAKE_OPENCODE_EXPORT:-}" ] || printf \'%s\\n\' "$FAKE_OPENCODE_EXPORT"\n' + ' if [ -n "${FAKE_OPENCODE_SUCCESS_EXPORT:-}" ]; then printf \'%s\\n\' "$FAKE_OPENCODE_SUCCESS_EXPORT"; else [ -z "${FAKE_OPENCODE_EXPORT:-}" ] || printf \'%s\\n\' "$FAKE_OPENCODE_EXPORT"; fi\n' ' exit "${FAKE_OPENCODE_EXPORT_EXIT:-0}"\n' "fi\n" "printf 'unexpected fake opencode command: %s\\n' \"$*\" >&2\n" @@ -583,6 +609,47 @@ def test_fatal_provider_error_kills_hung_opencode_run_early( assert elapsed < 25 +def test_fatal_cleanup_kills_term_ignoring_child_before_next_model( + tmp_path: Path, +) -> None: + """Captured process groups prevent a killed provider child from overlapping failover.""" + child_pid_file = tmp_path / "child.pid" + model_log = tmp_path / "models.log" + overlap_file = tmp_path / "overlap" + result = run_failed_model( + tmp_path, + json_line=( + '{"type":"error","error":{"name":"ProviderQuotaError","data":' + '{"message":"insufficient_quota: request rejected"}}}' + ), + model_candidates="openrouter/fatal openrouter/next", + extra_env={ + "OPENROUTER_API_KEY": "fake-openrouter-key", + "FAKE_OPENCODE_FATAL_MODEL": "openrouter/fatal", + "FAKE_OPENCODE_CHILD_PID_FILE": bash_path(child_pid_file), + "FAKE_OPENCODE_CHILD_SLEEP_SECONDS": "120", + "FAKE_OPENCODE_MODEL_LOG": bash_path(model_log), + "FAKE_OPENCODE_NEXT_MODEL": "openrouter/next", + "FAKE_OPENCODE_OVERLAP_FILE": bash_path(overlap_file), + "FAKE_OPENCODE_HANG_SECONDS": "120", + "FAKE_OPENCODE_NON_FATAL_HANG_SECONDS": "0", + "OPENCODE_RUN_TIMEOUT_SECONDS": "120", + "OPENCODE_TOTAL_RETRY_BUDGET_SECONDS": "240", + }, + ) + + assert result.returncode == 1 + assert model_log.read_text(encoding="utf-8").splitlines() == [ + "openrouter/fatal", + "openrouter/next", + ] + assert not overlap_file.exists() + child_pid = int(child_pid_file.read_text(encoding="utf-8")) + assert subprocess.run( + ["kill", "-0", str(child_pid)], check=False + ).returncode != 0 + + def test_model_text_quoting_error_signatures_does_not_kill_run(tmp_path: Path) -> None: """Model prose mentioning fatal signatures never kills a healthy streaming run.""" result = run_failed_model( diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 3e421e903..c634a0357 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -38,7 +38,9 @@ def make_pr(**overrides): "mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", "restMergeableState": "", - "reviewDecision": "REVIEW_REQUIRED", + # Merge-ready fixtures model GitHub's aggregate approval separately from + # the current-head OpenCode review; negative review-policy cases opt in. + "reviewDecision": "APPROVED", "baseRefName": "main", "baseRefOid": "base", "headRefName": "feature", @@ -3037,16 +3039,17 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch): assert "auto-merge is already enabled" in dirty_auto_reason assert "conflict repair is required before GitHub can merge it" in dirty_auto_reason blocked_auto = make_pr( + reviewDecision="REVIEW_REQUIRED", restMergeableState="blocked", autoMergeRequest={"enabledAt": "now"}, reviews={"nodes": [opencode_review("APPROVED", "head")]}, ) blocked_auto_decision = inspect(blocked_auto) - assert blocked_auto_decision.action == "wait" - assert "GitHub mergeability is BLOCKED" in blocked_auto_decision.reason - assert "GitHub reviewDecision is REVIEW_REQUIRED" in blocked_auto_decision.reason - assert "required approving review" in blocked_auto_decision.reason - assert "rerun the scheduler" in blocked_auto_decision.reason + assert blocked_auto_decision.action == "disable_auto_merge" + assert blocked_auto_decision.reason == ( + "auto-merge disabled; current-head OpenCode approval exists, but GitHub reviewDecision is " + "REVIEW_REQUIRED; require aggregate APPROVED before merge or re-enabling auto-merge" + ) assert sched.latest_commit_headline(make_pr(commits={"nodes": []})) == "" restamp_candidate = last_push_restamp_candidate() @@ -3059,6 +3062,13 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch): current_head_approved=True, auto_merge_enabled=True, ) + assert not sched.should_restamp_for_last_push_approval( + "owner/repo", + last_push_restamp_candidate(reviewDecision="REVIEW_REQUIRED"), + "BLOCKED", + current_head_approved=True, + auto_merge_enabled=True, + ) assert not sched.should_restamp_for_last_push_approval( "owner/repo", last_push_restamp_candidate( @@ -3839,6 +3849,45 @@ def test_update_branch_summary_includes_followup_notes(): assert "PR #12: updated head abc123 observed after update-branch" in summary +@pytest.mark.parametrize("review_decision", [None, "", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]) +def test_inspect_pr_requires_aggregate_review_approval_before_merge_or_auto_merge( + monkeypatch, + review_decision, +): + enabled = [] + disabled = [] + monkeypatch.setattr( + sched, + "enable_auto_merge", + lambda repo, pr, dry_run: enabled.append((repo, pr["number"], dry_run)), + ) + monkeypatch.setattr( + sched, + "disable_auto_merge", + lambda repo, pr, dry_run: disabled.append((repo, pr["number"], dry_run)), + ) + + review_required = make_pr( + reviewDecision=review_decision, + reviews={"nodes": [opencode_review("APPROVED", "head")]}, + ) + blocked = inspect(review_required) + assert blocked.action == "block" + assert "require aggregate APPROVED before merge or re-enabling auto-merge" in blocked.reason + assert enabled == [] + + queued = inspect( + make_pr( + reviewDecision=review_decision, + autoMergeRequest={"enabledAt": "now"}, + reviews={"nodes": [opencode_review("APPROVED", "head")]}, + ) + ) + assert queued.action == "disable_auto_merge" + assert "require aggregate APPROVED before merge or re-enabling auto-merge" in queued.reason + assert disabled == [("owner/repo", 1, True)] + + def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): approved = make_pr(reviews={"nodes": [opencode_review("APPROVED", "head")]}) failed = make_pr( @@ -4013,19 +4062,31 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): ("owner/repo", 1, True), ] assert auto_merges == [("owner/repo", 1, True)] + blocked_already_auto_approved = inspect( + make_pr( + mergeStateStatus="BLOCKED", + autoMergeRequest={"enabledAt": "now"}, + statusCheckRollup={"contexts": {"nodes": []}}, + reviews={"nodes": [opencode_review("APPROVED", "head")]}, + ), + merge_mode="direct_or_auto", + ) + assert blocked_already_auto_approved.action == "wait" + assert "auto-merge is already enabled" in blocked_already_auto_approved.reason blocked_already_auto = inspect( make_pr( mergeStateStatus="BLOCKED", + reviewDecision="REVIEW_REQUIRED", autoMergeRequest={"enabledAt": "now"}, reviews={"nodes": [opencode_review("APPROVED", "head")]}, ), merge_mode="direct_or_auto", ) - assert blocked_already_auto.action == "wait" - assert "auto-merge is already enabled" in blocked_already_auto.reason - assert "GitHub mergeability is BLOCKED" in blocked_already_auto.reason - assert "GitHub reviewDecision is REVIEW_REQUIRED" in blocked_already_auto.reason - assert "required approving review" in blocked_already_auto.reason + assert blocked_already_auto.action == "disable_auto_merge" + assert blocked_already_auto.reason == ( + "auto-merge disabled; current-head OpenCode approval exists, but GitHub reviewDecision is " + "REVIEW_REQUIRED; require aggregate APPROVED before merge or re-enabling auto-merge" + ) assert direct_merges == [ ("owner/repo", 1, True), ("owner/repo", 1, True), diff --git a/tests/test_redact_sensitive_log.py b/tests/test_redact_sensitive_log.py new file mode 100644 index 000000000..ea7f08ec1 --- /dev/null +++ b/tests/test_redact_sensitive_log.py @@ -0,0 +1,37 @@ +from scripts.ci.redact_sensitive_log import redact_text + + +def test_redacts_allowlisted_operational_identifiers_in_json() -> None: + source = ( + '{"email":"alice@example.com","phone":"+82 10-1234-5678",' + '"ip":"192.0.2.10","path":"/home/runner/work/repo/run.json",' + '"head_sha":"' + "a" * 40 + '","source":"backend/api.py"}\n' + ) + + cleaned = redact_text(source) + + assert "alice@example.com" not in cleaned + assert "+82 10-1234-5678" not in cleaned + assert "192.0.2.10" not in cleaned + assert "/home/runner/work/repo/run.json" not in cleaned + assert "[REDACTED_EMAIL]" in cleaned + assert "[REDACTED_PHONE]" in cleaned + assert "[REDACTED_IP]" in cleaned + assert "[REDACTED_PATH]" in cleaned + assert "a" * 40 in cleaned + assert "backend/api.py" in cleaned + + +def test_redacts_allowlisted_identifiers_in_plain_diagnostics() -> None: + source = ( + "contact bob@example.org at 010-1234-5678 from 203.0.113.4 " + "/tmp/runner/secret.log\n" + ) + + cleaned = redact_text(source) + + assert "bob@example.org" not in cleaned + assert "010-1234-5678" not in cleaned + assert "203.0.113.4" not in cleaned + assert "/tmp/runner/secret.log" not in cleaned + assert cleaned.count("[REDACTED_") == 4 diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 233c08584..e9ec49bdd 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -1,3 +1,4 @@ +import hashlib import json import os import shlex @@ -1079,21 +1080,271 @@ def test_optional_strix_workflow_absence_is_logged_without_failing_lookup() -> N assert 'if target_workflow_available "strix.yml"; then' in failed_check_evidence -def test_strix_provider_outage_without_findings_is_neutralized() -> None: +def test_strix_provider_outage_without_findings_fails_closed() -> None: + """Require the trusted gate to propagate every incomplete result.""" workflow = workflow_text("strix.yml") - assert "RateLimitError|Too many requests" in workflow - assert "exceeded your current quota" in workflow - assert "billing details" in workflow - assert "LLM warm-up failed" in workflow - assert "zero_vulnerabilities_signal" not in workflow - assert "(^|[^A-Za-z0-9_])severity[[:space:]]*:" in workflow + assert 'if [ "$strix_rc" -ne 0 ]; then' in workflow + assert 'exit "$strix_rc"' in workflow + assert "provider failures and missing reports remain fail-closed" in workflow + assert ( + 'grep -F -- "$STRIX_GATE_MARKER_PREFIX" "$strix_run_log"' + in workflow + ) + assert ( + "grep -Eiq 'failing closed|fail-closed|fail closed|incomplete evidence|incomplete-evidence|neutral[[:space:]]+skip'" + in workflow + ) + assert 'export STRIX_GATE_MARKER_PREFIX="CWL_STRIX_GATE_MARKER_${GITHUB_RUN_ID}:"' in workflow + assert "printed a fail-closed, incomplete-evidence, or neutral-skip marker but exited 0" in workflow + assert "Treating as a neutral skip" not in workflow + assert "backend_unavailable_signal" not in workflow + assert "reported_vulnerability_signal" not in workflow + assert "STRIX_FAIL_ON_PROVIDER_SIGNAL: \"1\"" in workflow assert "STRIX_FAIL_ON_MIN_SEVERITY: MEDIUM" in workflow - assert "before producing a vulnerability report" in workflow - assert "genuine findings still fail the check" in workflow + + +def test_strix_workflow_changes_require_post_merge_structured_evidence() -> None: + """Do not treat base-workflow false green as proof for workflow PRs.""" + strix_workflow = workflow_text("strix.yml") + opencode_workflow = workflow_text("opencode-review-dispatch.yml") + failed_check_evidence = ( + REPO_ROOT / "scripts/ci/collect_failed_check_evidence.sh" + ).read_text(encoding="utf-8") + + assert "pull_request_target evaluates this workflow from the trusted base" in strix_workflow + assert "Materializing a PR-head workflow above is data-only self-test" in strix_workflow assert ( - '&& ! grep -Eiq "$reported_vulnerability_signal" "$strix_run_log"' in workflow + "Default-branch repository_dispatch Strix structured evidence binding passed" + in strix_workflow + ) + assert "TARGET_REPOSITORY:" in strix_workflow + assert 'run_id="$GITHUB_RUN_ID"' in strix_workflow + assert "artifact_name:$artifact_name" in strix_workflow + assert "repository:$repository" in strix_workflow + assert "self_modifying_strix_workflow_needs_structured_evidence" in opencode_workflow + assert "WAITING_FOR_POST_MERGE_STRIX_EVIDENCE" in opencode_workflow + assert ( + "Default-branch repository_dispatch Strix structured evidence binding passed" + in opencode_workflow + ) + assert 'manual_run_line="$(latest_current_head_manual_strix_run || true)"' not in opencode_workflow + success_function = opencode_workflow.split( + "current_head_manual_strix_success_status()", 1 + )[1].split("latest_current_head_manual_strix_run()", 1)[0] + assert "latest_current_head_manual_strix_run" not in success_function + assert ( + "Default-branch repository_dispatch Strix structured evidence binding passed" + in success_function + ) + structured_function = opencode_workflow.split( + "current_head_manual_strix_structured_success_status()", 1 + )[1].split("hold_for_unverified_strix_workflow_update()", 1)[0] + assert '(.description // "") == $description' in structured_function + assert 'GITHUB_SERVER_URL%/' in structured_function + assert 'actions/runs/${run_id}' in structured_function + assert 'actions/runs/${run_id}/artifacts?per_page=100' in structured_function + assert '(.event // "") == "repository_dispatch"' in structured_function + assert '(.path // "") == ".github/workflows/strix.yml"' in structured_function + assert 'gh run download "$run_id"' in structured_function + assert 'evidence-binding.json' in structured_function + assert '.repository == $repository' in structured_function + assert '.artifact_name == "strix-reports"' in structured_function + assert '.head_sha == $head_sha' in structured_function + assert '((.run_id // "") | tostring) == $run_id' in structured_function + assert 'actual_report_sha256' in structured_function + assert "/actions/runs/${run_id}/artifacts?per_page=100" in failed_check_evidence + assert "if ! artifact_count=\"$(jq -r" in failed_check_evidence + assert '.repository == $repository' in failed_check_evidence + assert '.artifact_name == "strix-reports"' in failed_check_evidence + + +def test_strix_structured_status_rejects_unbound_candidates(tmp_path: Path) -> None: + """Execute the status helper against URL, description, and run spoofing.""" + workflow = workflow_text("opencode-review-dispatch.yml") + start = workflow.index( + " current_head_manual_strix_structured_success_status()" + ) + end = workflow.index(" hold_for_unverified_strix_workflow_update()", start) + function_script = textwrap.dedent(workflow[start:end]) + head_sha = "a" * 40 + expected_url = ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/123" + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + (fake_bin / "timeout").write_text( + "#!/bin/sh\nshift\nexec \"$@\"\n", encoding="utf-8" + ) + (fake_bin / "gh").write_text( + "#!/bin/sh\n" + "if [ \"$1\" = run ] && [ \"$2\" = download ]; then\n" + " mkdir -p \"$9\"\n" + " cp -R \"$FAKE_ARTIFACT\"/. \"$9\"/\n" + " exit 0\n" + "fi\n" + "case \"$*\" in\n" + " */actions/runs/*/artifacts*) cat \"$FAKE_ARTIFACTS\"; exit 0 ;;\n" + " *) : ;;\n" + "esac\n" + "case \"$*\" in\n" + " */commits/*/status) cat \"$FAKE_STATUS\" ;;\n" + " */actions/runs/*) cat \"$FAKE_RUN\" ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + (fake_bin / "timeout").chmod(0o755) + (fake_bin / "gh").chmod(0o755) + status_path = tmp_path / "status.json" + run_path = tmp_path / "run.json" + artifacts_path = tmp_path / "artifacts.json" + artifact_source = tmp_path / "artifact-source" + runner = textwrap.dedent( + f"""\ + set -euo pipefail + HEAD_SHA='{head_sha}' + GH_REPOSITORY='ContextualWisdomLab/.github' + GITHUB_SERVER_URL='https://github.com' + check_lookup_api_timeout_seconds() {{ printf '5'; }} + {function_script} + current_head_manual_strix_structured_success_status + """ + ) + + def run_candidate( + description: str, + target_url: str, + run: dict[str, object], + binding_overrides: dict[str, object] | None = None, + artifact_records: list[dict[str, object]] | None = None, + ): + status_path.write_text( + json.dumps( + { + "statuses": [ + { + "context": "strix", + "state": "success", + "description": description, + "target_url": target_url, + "created_at": "2026-08-14T08:00:00Z", + } + ] + } + ), + encoding="utf-8", + ) + run_path.write_text(json.dumps(run), encoding="utf-8") + artifacts_path.write_text( + json.dumps({ + "artifacts": artifact_records + if artifact_records is not None + else [{"id": 456, "name": "strix-reports", "expired": False}] + }), + encoding="utf-8", + ) + shutil.rmtree(artifact_source, ignore_errors=True) + binding_directory = artifact_source / "strix-reports" + binding_directory.mkdir(parents=True) + report_content = b"trusted strix report\n" + report_name = "penetration_test_report.md" + (binding_directory / report_name).write_bytes(report_content) + binding = { + "repository": "ContextualWisdomLab/.github", + "artifact_name": "strix-reports", + "head_sha": head_sha, + "run_id": run.get("id", 123), + "scan_completed": True, + "report": report_name, + "report_sha256": hashlib.sha256(report_content).hexdigest(), + } + binding.update(binding_overrides or {}) + (binding_directory / "evidence-binding.json").write_text( + json.dumps(binding), + encoding="utf-8", + ) + env = os.environ.copy() + env.update( + { + "FAKE_STATUS": str(status_path), + "FAKE_RUN": str(run_path), + "FAKE_ARTIFACTS": str(artifacts_path), + "FAKE_ARTIFACT": str(artifact_source), + "PATH": f"{fake_bin}:{env['PATH']}", + } + ) + return subprocess.run( + ["bash", "-c", runner], + env=env, + capture_output=True, + text=True, + check=False, + ) + + exact_description = ( + "Default-branch repository_dispatch Strix structured evidence binding passed" ) + valid_run = { + "id": 123, + "head_sha": head_sha, + "event": "repository_dispatch", + "path": ".github/workflows/strix.yml", + "status": "completed", + "conclusion": "success", + } + valid = run_candidate(exact_description, expected_url, valid_run) + assert valid.returncode == 0, valid.stderr + assert valid.stdout.strip() == expected_url + + invalid_cases = ( + (exact_description + " suffix", expected_url, valid_run), + (exact_description, "https://evil.example/actions/runs/123", valid_run), + ( + exact_description, + expected_url + "/artifacts/1", + valid_run, + ), + ( + exact_description, + expected_url, + {**valid_run, "path": ".github/workflows/other.yml"}, + ), + (exact_description, expected_url, {**valid_run, "head_sha": "b" * 40}), + ) + for description, target_url, run in invalid_cases: + rejected = run_candidate(description, target_url, run) + assert rejected.returncode != 0 + assert rejected.stdout == "" + + invalid_artifacts = ( + {"head_sha": "b" * 40}, + {"run_id": 999}, + {"report": "missing_report.md"}, + {"report_sha256": "0" * 64}, + ) + for binding_overrides in invalid_artifacts: + rejected = run_candidate(exact_description, expected_url, valid_run, binding_overrides) + assert rejected.returncode != 0 + assert rejected.stdout == "" + + invalid_artifact_sets = ( + [], + [ + {"id": 456, "name": "strix-reports", "expired": False}, + {"id": 789, "name": "strix-reports", "expired": False}, + ], + [{"id": 456, "name": "strix-reports", "expired": True}], + ) + for artifact_records in invalid_artifact_sets: + rejected = run_candidate( + exact_description, + expected_url, + valid_run, + artifact_records=artifact_records, + ) + assert rejected.returncode != 0 + assert rejected.stdout == "" def test_strix_cross_repo_dispatch_uses_target_token_for_pr_scoping() -> None: diff --git a/tests/test_strix_nvidia_nim_not_found_fallback.py b/tests/test_strix_nvidia_nim_not_found_fallback.py index a48f3092d..023fc2016 100644 --- a/tests/test_strix_nvidia_nim_not_found_fallback.py +++ b/tests/test_strix_nvidia_nim_not_found_fallback.py @@ -73,50 +73,34 @@ def _classifies_as_nvidia_not_found(log_text: str) -> bool: return completed.returncode == 0 -def _workflow_signal_pattern(workflow: str, variable_name: str) -> str: - """Extract one single-quoted POSIX ERE assigned in the Strix workflow.""" +def _classifies_as_model_tool_contract(log_text: str) -> bool: + """Execute the production Strix tool-contract classifier.""" - match = re.search( - rf"(?m)^\s+{re.escape(variable_name)}='([^']+)'$", - workflow, - ) - if match is None: - raise AssertionError(f"missing workflow signal: {variable_name}") - return match.group(1) - - -def _workflow_neutralizes(log_text: str) -> bool: - """Execute the outer workflow's backend-neutralization condition.""" - - workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") - backend_pattern = _workflow_signal_pattern( - workflow, - "backend_unavailable_signal", - ) - vulnerability_pattern = _workflow_signal_pattern( - workflow, - "reported_vulnerability_signal", + gate_source = STRIX_GATE.read_text(encoding="utf-8") + function_source = _function_block( + gate_source, + "is_strix_model_tool_contract_error", ) - with tempfile.TemporaryDirectory(prefix="strix-workflow-404-") as temp_dir: + with tempfile.TemporaryDirectory(prefix="strix-tool-contract-") as temp_dir: log_path = Path(temp_dir) / "strix.log" log_path.write_text(log_text, encoding="utf-8") - backend = subprocess.run( - ["grep", "-Eiq", backend_pattern, str(log_path)], - check=False, - capture_output=True, - text=True, + script = "\n".join( + ( + "set -euo pipefail", + 'STRIX_LOG="$1"', + function_source, + "is_strix_model_tool_contract_error", + ) ) - vulnerability = subprocess.run( - ["grep", "-Eiq", vulnerability_pattern, str(log_path)], + completed = subprocess.run( + ["bash", "-c", script, "strix-classifier", str(log_path)], check=False, capture_output=True, text=True, ) - if backend.returncode not in {0, 1}: - raise AssertionError(backend.stderr) - if vulnerability.returncode not in {0, 1}: - raise AssertionError(vulnerability.stderr) - return backend.returncode == 0 and vulnerability.returncode == 1 + if completed.returncode not in {0, 1}: + raise AssertionError(completed.stderr) + return completed.returncode == 0 class StrixNvidiaNotFoundFallbackTests(unittest.TestCase): @@ -171,6 +155,38 @@ def test_not_found_skips_same_model_and_enters_cross_model_fallback(self) -> Non self.assertIn("is_nvidia_nim_not_found_error", retryable) self.assertNotIn("is_nvidia_nim_not_found_error", same_model_retry) + def test_unsupported_tool_contract_enters_cross_model_fallback(self) -> None: + """Treat the Strix agent/tool mismatch as a model failure, not a finding.""" + + log = ( + "File strix/core/execution.py, line 355, in _run_cycle\n" + "agents.exceptions.ModelBehaviorError: Tool execute not found in agent strix\n" + ) + self.assertTrue(_classifies_as_model_tool_contract(log)) + + gate_source = STRIX_GATE.read_text(encoding="utf-8") + infrastructure = _function_block( + gate_source, + "has_detected_infrastructure_error", + ) + retryable = _function_block(gate_source, "is_model_retryable_error") + same_model_retry = _function_block( + gate_source, + "is_transient_same_model_retry_error", + ) + self.assertIn("is_strix_model_tool_contract_error", infrastructure) + self.assertIn("is_strix_model_tool_contract_error", retryable) + self.assertNotIn("is_strix_model_tool_contract_error", same_model_retry) + + def test_target_text_cannot_spoof_tool_contract_fallback(self) -> None: + """Require the Strix traceback marker beside the exact exception.""" + + log = ( + "source literal: agents.exceptions.ModelBehaviorError: Tool execute " + "not found in agent strix\n" + ) + self.assertFalse(_classifies_as_model_tool_contract(log)) + def test_workflow_uses_available_free_first_nvidia_plan(self) -> None: """Prefer a documented hosted NIM and another NIM before GitHub.""" @@ -199,62 +215,28 @@ def test_workflow_uses_available_free_first_nvidia_plan(self) -> None: )[0] self.assertNotIn(RETIRED_PRIMARY_MODEL, default_gate) - def test_outer_workflow_requires_litellm_context_for_nvidia_404(self) -> None: - """Reject provider-like target text in the outer neutralization gate.""" - - self.assertFalse( - _workflow_neutralizes( - "source literal: Nvidia_nimException Error code: 404\n" - ) - ) - self.assertTrue( - _workflow_neutralizes( - "litellm.exceptions.NotFoundError: Nvidia_nimException - " - "Error code: 404\nVulnerabilities 0\n" - ) - ) - - def test_outer_workflow_rejects_cross_line_signal_assembly(self) -> None: - """Require exception, provider, and 404 evidence on one physical line.""" + def test_workflow_propagates_provider_404_gate_failures(self) -> None: + """Do not let the outer workflow neutralize provider 404 evidence.""" - self.assertFalse( - _workflow_neutralizes( - "litellm.exceptions.NotFoundError: provider unavailable\n" - "Nvidia_nimException Error code: 404\n" - ) - ) - - def test_outer_workflow_rejects_nvidia_404_without_litellm_context(self) -> None: - """Require LiteLLM NotFoundError context, not just NVIDIA + 404.""" - - self.assertFalse( - _workflow_neutralizes( - "Nvidia_nimException Error code: 404\nVulnerabilities 0\n" - ) + workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") + self.assertIn('if [ "$strix_rc" -ne 0 ]; then', workflow) + self.assertIn('exit "$strix_rc"', workflow) + self.assertIn( + "provider failures and missing reports remain fail-closed", + workflow, ) - - def test_outer_workflow_never_neutralizes_reported_vulnerabilities(self) -> None: - """Keep a real vulnerability signal blocking despite provider failure.""" - - self.assertFalse( - _workflow_neutralizes( - "litellm.exceptions.NotFoundError: Nvidia_nimException - " - "Error code: 404\nVulnerabilities 1\n" - ) + self.assertIn( + 'grep -F -- "$STRIX_GATE_MARKER_PREFIX" "$strix_run_log"', + workflow, ) - - def test_workflow_neutralizes_only_nvidia_404_without_findings(self) -> None: - """Retain the static fail-closed vulnerability evidence contract.""" - - workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") - self.assertIn("Nvidia_nimException", workflow) - self.assertIn("Error code:[[:space:]]*404", workflow) - self.assertIn("reported_vulnerability_signal", workflow) - self.assertIn("Vulnerabilities[[:space:]]+[1-9]", workflow) self.assertIn( - '! grep -Eiq "$reported_vulnerability_signal"', + "grep -Eiq 'failing closed|fail-closed|fail closed|incomplete evidence|incomplete-evidence|neutral[[:space:]]+skip'", workflow, ) + self.assertIn("neutral[[:space:]]+skip", workflow) + self.assertNotIn("backend_unavailable_signal", workflow) + self.assertNotIn("reported_vulnerability_signal", workflow) + self.assertNotIn("Treating as a neutral skip", workflow) if __name__ == "__main__": diff --git a/tests/test_strix_workflow_dependency_hashes.py b/tests/test_strix_workflow_dependency_hashes.py index e2509c18b..788b40c04 100644 --- a/tests/test_strix_workflow_dependency_hashes.py +++ b/tests/test_strix_workflow_dependency_hashes.py @@ -8,6 +8,14 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github" / "workflows" / "strix-changed-path-quality-ci.yml" +STRIX_REQUIREMENT_FILES = ( + ROOT / "requirements-strix-ci.txt", + ROOT / "requirements-strix-ci-hashes.txt", +) +PATCHED_DEPENDENCY_PINS = { + "aiohttp": "3.14.3", + "cryptography": "50.0.0", +} WORKFLOW_DISPATCH_KEY_RE = re.compile( r"(?m)^[ \t]+['\"]?workflow_dispatch['\"]?\s*:" ) @@ -33,6 +41,17 @@ def test_strix_workflow_installs_only_hash_verified_wheels() -> None: assert f"{requirement} --hash=sha256:{digest}" in workflow +def test_strix_requirement_locks_keep_patched_dependabot_pins() -> None: + """Both Strix locks must retain the published patched dependency pins.""" + for requirements_file in STRIX_REQUIREMENT_FILES: + content = requirements_file.read_text(encoding="utf-8") + for package, version in PATCHED_DEPENDENCY_PINS.items(): + matches = re.findall(rf"(?m)^{re.escape(package)}==([^\s\\]+)", content) + assert matches == [version], ( + f"{requirements_file.name} must pin {package} exactly once at {version}" + ) + + def test_strix_workflow_reruns_when_hash_contract_changes() -> None: """Changing this regression contract must trigger the exact-head workflow.""" workflow = WORKFLOW.read_text(encoding="utf-8")