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")