diff --git a/.github/workflows/strix-changed-path-quality-ci.yml b/.github/workflows/strix-changed-path-quality-ci.yml index 75e9b7d8e..b2722056d 100644 --- a/.github/workflows/strix-changed-path-quality-ci.yml +++ b/.github/workflows/strix-changed-path-quality-ci.yml @@ -4,15 +4,18 @@ on: pull_request: branches: [main] paths: + - ".github/workflows/strix.yml" - ".github/workflows/strix-changed-path-quality-ci.yml" - "CHANGELOG.md" - "docs/doctoring/strix-legal-git-paths.md" - "docs/doctoring/strix-quality-timeout-fixtures.md" + - "docs/doctoring/strix-scan-mode-dual-flow.md" - "scripts/ci/strix_quick_gate.sh" - "scripts/ci/test_strix_quick_gate.sh" - "tests/test_strix_changed_path_policy.py" - "tests/test_strix_workflow_dependency_hashes.py" - "tests/test_strix_quality_timeout_fixture_budget.py" + - "tests/test_strix_scan_mode_policy.py" permissions: contents: read @@ -66,6 +69,6 @@ jobs: test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" python -m coverage run -m pytest tests -q bash scripts/ci/test_strix_quick_gate.sh - python -m compileall -q tests/test_strix_changed_path_policy.py tests/test_strix_workflow_dependency_hashes.py tests/test_strix_quality_timeout_fixture_budget.py + python -m compileall -q tests/test_strix_changed_path_policy.py tests/test_strix_workflow_dependency_hashes.py tests/test_strix_quality_timeout_fixture_budget.py tests/test_strix_scan_mode_policy.py bash -n scripts/ci/strix_quick_gate.sh git diff --exit-code diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index f8c361b95..13d6e3568 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -61,8 +61,18 @@ on: - '.github/ISSUE_TEMPLATE/**' schedule: # Weekly scan on protected branches (Mondays at 03:00 UTC). + # Official Strix standard mode: 30–60 min; current 120/100/90/95 budget + # is honest. CWL has no consistent RC-tag or prerelease convention, so + # this weekly pass is the scheduled pre-release depth. Do not invent a + # GitHub release event or RC tag pattern to compensate. - cron: '0 3 * * 1' # Default-branch-only retry entrypoint; no caller-selected workflow ref. + # Remains PR-metadata-bound same-head evidence (quick). It cannot scan a + # branch or tag release candidate. Do not restore workflow_dispatch here: + # GitHub's manual UI/API lets the caller select a branch, and that revision + # supplies the workflow YAML (id-token + statuses:write) before any in-job + # trusted-source checkout can run. Deep stays unwired on this privileged + # file until a separately reviewed default-branch-only dispatcher exists. repository_dispatch: types: [strix-scan] @@ -92,11 +102,9 @@ jobs: strix: if: github.event_name != 'pull_request_target' || github.event.action != 'closed' - # Large repositories can require a legitimate full-hour review. The scanner - # gets a 90-minute process budget and a 95-minute total retry budget; the - # 100-minute step and 120-minute job leave deterministic time to preserve - # partial reports and publish a concrete failure reason. Hitting any cap is - # fail-closed and never turns an incomplete scan into an approval. + # Quick and standard share the honest 120/100/90/95 budget (standard is + # 30–60 min). Deep is not selected on this privileged workflow. Do not + # raise the required pull_request_target / repository_dispatch path to 360. timeout-minutes: 120 runs-on: ubuntu-latest # Least-privilege token scoped to this job (Scorecard alert #43): the scan @@ -110,6 +118,13 @@ jobs: statuses: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + # Official CLI modes only (quick|standard|deep). Dual-flow mapping: + # required PR / repository_dispatch / push develop → quick; + # push main|master and weekly schedule → standard. + # repository_dispatch must not inherit standard from github.ref + # (dispatch SHA is the default branch, often main). This privileged + # workflow never selects deep and never exposes workflow_dispatch. + STRIX_SCAN_MODE: ${{ github.event_name == 'schedule' && 'standard' || github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && 'standard' || 'quick' }} steps: - name: Harden runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 @@ -781,7 +796,7 @@ jobs: esac echo "STRIX_LLM_FILE=$strix_llm_file" >> "$GITHUB_ENV" - - name: Run Strix (quick) + - name: Run Strix if: steps.gate.outputs.enabled == 'true' timeout-minutes: 100 # Security invariant for pull_request_target: execute only from the @@ -835,10 +850,13 @@ jobs: run: | budget_suffix="TIME""OUT" process_budget_seconds="5400" + total_budget_seconds="5700" + printf 'Using official Strix scan mode %s with process budget %ss.\n' \ + "${STRIX_SCAN_MODE}" "$process_budget_seconds" export "LLM_${budget_suffix}=900" export "STRIX_MEMORY_COMPRESSOR_${budget_suffix}=300" export "STRIX_PROCESS_${budget_suffix}_SECONDS=$process_budget_seconds" - export "STRIX_TOTAL_${budget_suffix}_SECONDS=5700" + export "STRIX_TOTAL_${budget_suffix}_SECONDS=$total_budget_seconds" # Capture the gate exit code plus its console output. The gate returns # exit 1 both for genuine blocking vulnerabilities AND for diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1aebf43..c9934cf23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Semantic Versioning where the repository publishes a release. ### Changed +- Map the organization-required Strix workflow to official CLI modes (`quick`, `standard`) from the GitHub event instead of inheriting Quick on every trigger. Required `pull_request_target` and `repository_dispatch` `strix-scan` evidence stay Quick; `push` to `develop` stays Quick; `push` to `main`/`master` and the weekly Monday 03:00 UTC schedule use Standard with the existing 120/100/90/95 budget. Deep stays unwired on this privileged file: restoring `workflow_dispatch` would let a caller-selected branch supply `id-token` and `strix` status publication before any trusted-source checkout. CWL's dual GitHub Flow / Git Flow setup has no consistent RC-tag or prerelease convention, so this change does not invent `release:`, `v*-rc*`, or `client_payload.scan_mode`. The gate now allowlists official mode names (`quick|standard|deep`) and rejects `normal`. - Require the hourly repair worker to establish an exact-head root cause, enumerate the smallest remediation candidates, and prove writer authority, sealed-path scope, credentials, dependency order, verifiability, and causal effect before editing; infeasible or external blockers leave the tree unchanged while the broader loop continues with another eligible PR or buyer-visible product gap. - Run the bounded Quarantine Sandbox Runtime heartbeat at minute 14 without granting the caller model secrets, repository mutation permissions, approval, merge, release, artifact-execution, or final security-verdict authority. - Run the bounded Clearfolio PR review-feedback repair caller at minute 23 of every hour while keeping the shared scheduler free of product-specific timers and repository names for modular reuse by naruon, contextual-orchestrator, Inkspan, and other CWL services. diff --git a/docs/doctoring/strix-scan-mode-dual-flow.md b/docs/doctoring/strix-scan-mode-dual-flow.md new file mode 100644 index 000000000..5a56abbcf --- /dev/null +++ b/docs/doctoring/strix-scan-mode-dual-flow.md @@ -0,0 +1,71 @@ +# Strix official scan-mode mapping for dual GitHub / Git Flow + +근거 기준일: **2026-08-16** + +## Incident + +The organization-required Strix workflow always ran Quick. `scripts/ci/strix_quick_gate.sh` already forwarded `STRIX_SCAN_MODE` (default `quick`) as `strix -n -t . --scan-mode $SCAN_MODE`, but `.github/workflows/strix.yml` never set the environment variable. Every current event therefore inherited Quick: `pull_request_target`, `push` to `main`/`develop`/`master`, the Monday 03:00 UTC `schedule`, and `repository_dispatch` type `strix-scan`. + +Official Strix CLI modes are only `quick`, `standard`, and `deep` (Strix, n.d.-a). There is no `normal` alias. Quick is the CI/PR path (minutes). Standard is pre-release / weekly (30 min–1 h). Deep is pre-production (1–4 h) and is the CLI default; CWL must keep choosing explicitly so required PR evidence never inherits Deep. + +The previous 120 / 100 / 90 / 95-minute budget is honest for Quick and Standard. It cannot finish Deep. GitHub-hosted jobs max out at 360 minutes. + +## Decision + +ContextualWisdomLab runs both GitHub Flow (`main`/`master` is the base) and Git Flow (`develop` is the base). That dual-flow setup is incomplete: there is no consistent RC-tag, prerelease, or GitHub `release` event convention, and RankWeave forbids prerelease GitHub Releases. This change therefore does **not** invent `release:` or `v*-rc*` triggers. + +Confirmed event → official mode mapping: + +| Event | Mode | Job / step / process / total | +|---|---|---| +| `pull_request_target` | `quick` | 120 / 100 / 5400 / 5700 | +| `repository_dispatch` `strix-scan` | `quick` | 120 / 100 / 5400 / 5700 | +| `push` to `develop` | `quick` | 120 / 100 / 5400 / 5700 | +| `push` to `main` or `master` | `standard` | 120 / 100 / 5400 / 5700 | +| `schedule` (Monday 03:00 UTC) | `standard` | 120 / 100 / 5400 / 5700 | + +`repository_dispatch` remains the default-branch-only, PR-metadata-bound same-head retry. It cannot scan a branch or tag release candidate. The mapping requires `github.event_name == 'push'` before treating `refs/heads/main` or `refs/heads/master` as Standard, because a `repository_dispatch` SHA is the default branch and is often `main`. + +Deep is **not** selected on this privileged workflow. Restoring `workflow_dispatch` on `.github/workflows/strix.yml` would let a writer choose a feature-branch workflow revision. That revision supplies the YAML—`id-token: write` plus `statuses: write` for the `strix` commit-status context—before any in-job trusted-source checkout can run (GitHub, n.d.-b; National Institute of Standards and Technology, 2022). A malicious or confused branch could skip the scan and publish a fake passing `strix` status. The same class of defect already failed `test_no_central_workflow_exposes_branch_selected_manual_dispatch` when `workflow_dispatch` was added to the quality job (see `docs/doctoring/strix-legal-git-paths.md`). + +Do **not** restore `workflow_dispatch` on this file as an RC convenience. Do **not** add `client_payload.scan_mode` to the privileged `strix-scan` retry. A later Deep path must be a separately reviewed default-branch-only dispatcher that treats target repository, pull-request number, and exact head SHA as untrusted bounded data. + +`require_safe_scan_mode` allowlists `quick|standard|deep` and rejects `normal` and every other string, including charset-valid aliases. The gate may still accept `deep` for local or future dispatcher use; this workflow never sets it. + +Fail-closed behavior is unchanged: missing artifact, unmapped findings, infrastructure errors, PR scoping, and severity gating stay as they were. The hashed-lock installer line is not part of this change. + +`pull_request_target` continues to execute trusted base scripts only. + +## Verification contract + +`tests/test_strix_scan_mode_policy.py` and `scripts/ci/test_strix_quick_gate.sh` fail if: + +1. the event → mode expression is reverted or `repository_dispatch` can select a mode; +2. Deep job/step/process budgets appear on the required PR path; +3. a GitHub release event, RC-tag trigger, or `workflow_dispatch` is added to this privileged file; +4. `require_safe_scan_mode` accepts `normal` or any unofficial name; +5. `client_payload.scan_mode` or `github.event.inputs.scan_mode` appears in `strix.yml`. + +The quality workflow trigger includes this record, the mapping test, and `.github/workflows/strix.yml` so later edits re-run exact-head evidence. + +## Rollback + +Roll back the mapping and this record together only if a required `pull_request_target` job is observed running Deep or a 360-minute budget. Do not restore unconditional Quick by deleting `STRIX_SCAN_MODE`. Do not restore `workflow_dispatch` on this file to recover Deep. + +## Next operator action + +Merge this mapping after current-head quality, security, and review evidence pass. If a buyer needs a pre-production Deep scan, open a separate default-branch-only dispatcher design; do not add inputs to `strix.yml`. + +## References (APA 7th) + +GitHub. (n.d.-a). *Workflow syntax for GitHub Actions*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax + +GitHub. (n.d.-b). *Manually running a workflow*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow + +MITRE. (n.d.). *CWE-345: Insufficient verification of data authenticity*. Retrieved August 16, 2026, from https://cwe.mitre.org/data/definitions/345.html + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +Strix. (n.d.-a). *Scan modes*. Strix Docs. Retrieved August 16, 2026, from https://docs.strix.ai/usage/scan-modes + +Strix. (n.d.-b). *Command-line interface*. usestrix/strix. Retrieved August 16, 2026, from https://github.com/usestrix/strix diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 0f37f3460..5997baf98 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -357,10 +357,16 @@ require_positive_integer() { require_safe_scan_mode() { local scan_mode="$1" - if [ -z "$scan_mode" ] || [[ ! "$scan_mode" =~ ^[[:alnum:]_.-]+$ ]]; then - echo "ERROR: STRIX_SCAN_MODE contains unsupported characters: '$scan_mode'." >&2 + # Official Strix CLI modes only. There is no `normal` alias. + case "$scan_mode" in + quick | standard | deep) + return 0 + ;; + *) + echo "ERROR: STRIX_SCAN_MODE must be one of quick, standard, or deep; got '$scan_mode'." >&2 exit 2 - fi + ;; + esac } validate_raw_target_path_input() { diff --git a/scripts/ci/strix_required_workflow_smoke.sh b/scripts/ci/strix_required_workflow_smoke.sh index 8cd6dddad..6419a06b4 100755 --- a/scripts/ci/strix_required_workflow_smoke.sh +++ b/scripts/ci/strix_required_workflow_smoke.sh @@ -142,6 +142,11 @@ assert_file_contains "$workflow_file" 'bash "$TRUSTED_STRIX_GATE"' "Strix workfl 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" +assert_file_not_contains "$workflow_file" "workflow_dispatch:" "Strix privileged workflow must not load a caller-selected workflow ref" +assert_file_contains "$workflow_file" "STRIX_SCAN_MODE:" "Strix workflow sets official scan mode from the event" +assert_file_contains "$workflow_file" "github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')" "Strix standard push mapping requires the push event" +assert_file_contains "$workflow_file" "timeout-minutes: 120" "Strix required path keeps the 120-minute job budget" +assert_file_contains "$gate_script" "quick | standard | deep)" "Strix gate allowlists official scan modes" assert_status_permissions_scoped assert_file_contains "$workflow_file" 'context="strix"' "Strix workflow publishes the strix commit status context" assert_file_contains "$workflow_file" "Existing current-run Strix success status is already present" "Strix manual follow-up status publisher accepts already-published same-run evidence" diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 7343c06ac..79707997c 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -209,6 +209,13 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" 'TRUSTED_STRIX_GATE=$trusted_strix_source/scripts/ci/strix_quick_gate.sh' "strix workflow executes the central Strix gate script" assert_file_contains "$workflow_file" "Materialize target workspace" "strix workflow materializes target repository data separately from trusted scripts" assert_file_contains "$workflow_file" "types: [strix-scan]" "strix repository dispatch accepts only its dedicated default-branch event type" + assert_file_not_contains "$workflow_file" "workflow_dispatch:" "strix privileged workflow must not load a caller-selected workflow ref" + assert_file_not_contains "$workflow_file" " - normal" "strix workflow must not offer a non-official normal scan-mode choice" + assert_file_not_contains "$workflow_file" " release:" "strix workflow must not invent a GitHub release trigger" + assert_file_not_contains "$workflow_file" "v*-rc*" "strix workflow must not invent RC-tag triggers" + assert_file_not_contains "$workflow_file" "client_payload.scan_mode" "strix repository_dispatch cannot select scan mode" + assert_file_contains "$workflow_file" "STRIX_SCAN_MODE: \${{ github.event_name == 'schedule' && 'standard' || github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && 'standard' || 'quick' }}" "strix workflow maps dual-flow events to official scan modes" + assert_file_contains "$workflow_file" "github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')" "strix standard mode on push requires the push event, not merely a main ref" assert_file_contains "$workflow_file" 'REPOSITORY: ${{ github.event.client_payload.target_repository }}' "strix repository dispatch binds the requested target repository before fetching data" assert_file_contains "$workflow_file" "Validate repository dispatch against live pull request metadata" "strix repository dispatch validates its supplied PR metadata" assert_file_contains "$workflow_file" '[ "$live_base_sha" != "$SUPPLIED_BASE_SHA" ]' "strix repository dispatch verifies the target repository base SHA against the live PR" @@ -274,11 +281,19 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" "GOOGLE_APPLICATION_CREDENTIALS" "strix workflow exports Vertex AI credentials only for Vertex provider mode" assert_file_contains "$workflow_file" "VERTEXAI_PROJECT" "strix workflow exports LiteLLM Vertex project env" assert_file_contains "$workflow_file" "VERTEXAI_LOCATION" "strix workflow exports LiteLLM Vertex location env" - assert_file_contains "$workflow_file" "timeout-minutes: 120" "strix workflow job budget preserves full-hour scans and artifact publication margin" - assert_file_contains "$workflow_file" "timeout-minutes: 100" "strix workflow scan step permits legitimate 90-minute repository reviews" + assert_file_contains "$workflow_file" "timeout-minutes: 120" "strix workflow keeps the 120-minute job budget for quick and standard" + assert_file_contains "$workflow_file" "timeout-minutes: 100" "strix workflow keeps the 100-minute step budget for quick and standard" + assert_file_not_contains "$workflow_file" "timeout-minutes: 360" "strix workflow must not apply the deep job ceiling" + assert_file_not_contains "$workflow_file" "timeout-minutes: 340" "strix workflow must not apply the deep step ceiling" assert_file_contains "$workflow_file" 'budget_suffix="TIME""OUT"' "strix workflow builds budget env keys without visible timeout signal text" - assert_file_contains "$workflow_file" 'export "STRIX_TOTAL_${budget_suffix}_SECONDS=5700"' "strix workflow preserves a 95-minute bounded total Strix budget" - assert_file_contains "$workflow_file" 'process_budget_seconds="5400"' "strix workflow gives a legitimate scan up to 90 minutes" + assert_file_contains "$workflow_file" 'export "STRIX_TOTAL_${budget_suffix}_SECONDS=$total_budget_seconds"' "strix workflow exports the selected total budget through the split timeout key" + assert_file_contains "$workflow_file" 'process_budget_seconds="5400"' "strix workflow gives a legitimate quick or standard scan up to 90 minutes" + assert_file_contains "$workflow_file" 'total_budget_seconds="5700"' "strix workflow preserves a 95-minute bounded total budget for quick and standard" + assert_file_not_contains "$workflow_file" 'process_budget_seconds="14400"' "strix privileged workflow must not raise a 4-hour deep process budget" + assert_file_not_contains "$workflow_file" 'total_budget_seconds="16200"' "strix privileged workflow must not raise a 4.5-hour deep total budget" + assert_file_contains "$GATE_SCRIPT" "quick | standard | deep)" "strix gate allowlists official scan modes only" + assert_file_contains "$GATE_SCRIPT" "STRIX_SCAN_MODE must be one of quick, standard, or deep" "strix gate rejects unofficial scan modes" + assert_file_not_contains "$GATE_SCRIPT" "normal)" "strix gate must not accept a non-official normal scan-mode alias" assert_file_contains "$workflow_file" 'strix_gate_console.log" "$GITHUB_WORKSPACE/strix_runs/gate-console.log' "strix workflow preserves partial console output after failures and timeouts" assert_file_contains "$REPO_ROOT/scripts/ci/strix_quick_gate.sh" "gate-last-attempt.log" "strix gate preserves the last partial attempt before runtime cleanup" assert_file_contains "$workflow_file" 'IS_PR_EVIDENCE_RUN: ${{ (github.event_name == '"'"'pull_request_target'"'"' || github.event.client_payload.pr_number != '"'"''"'"') && '"'"'true'"'"' || '"'"'false'"'"' }}' "strix workflow passes PR evidence mode through env" @@ -8128,6 +8143,56 @@ EOF rm -rf "$tmp_dir" } +run_scan_mode_validation_case() { + local case_name="$1" + local scan_mode="$2" + local expected_rc="$3" + local expected_message="$4" + + local tmp_dir + tmp_dir="$(mktemp -d)" + local output_log="$tmp_dir/output.log" + local call_count_file="$tmp_dir/strix_calls" + local fake_strix="$tmp_dir/strix" + local strix_llm_file="$tmp_dir/strix_llm.txt" + local llm_api_key_file="$tmp_dir/llm_api_key.txt" + + cat >"$fake_strix" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +echo "1" >> "${STRIX_CALL_COUNT_FILE:?}" +exit 0 +EOF + chmod +x "$fake_strix" + printf '%s' 'openai-direct/gpt-5.4' >"$strix_llm_file" + printf '%s' 'dummy-key' >"$llm_api_key_file" + + set +e + env -u GITHUB_EVENT_NAME -u GITHUB_EVENT_PATH -u STRIX_TEST_CHANGED_FILES_OVERRIDE \ + PATH="$tmp_dir:$PATH" \ + STRIX_EXECUTABLE_PATH="$fake_strix" \ + STRIX_INPUT_FILE_ROOT="$tmp_dir" \ + STRIX_DISABLE_PR_SCOPING="1" \ + STRIX_LLM_FILE="$strix_llm_file" \ + LLM_API_KEY_FILE="$llm_api_key_file" \ + STRIX_CALL_COUNT_FILE="$call_count_file" \ + STRIX_SCAN_MODE="$scan_mode" \ + bash "$GATE_SCRIPT" >"$output_log" 2>&1 + local rc=$? + set -e + + assert_equals "$expected_rc" "$rc" "case=$case_name exit code" + assert_file_contains "$output_log" "$expected_message" "case=$case_name output" + + local actual_calls="0" + if [ -f "$call_count_file" ]; then + actual_calls="$(wc -l <"$call_count_file" | tr -d ' ')" + fi + assert_equals "0" "$actual_calls" "case=$case_name strix call count" + + rm -rf "$tmp_dir" +} + run_missing_config_case() { local case_name="$1" local strix_llm="$2" @@ -11614,6 +11679,10 @@ run_gate_case_allow_provider_signal "pr-low-markdown-plus-console-critical-manif "123" \ '{"workflow_runs":[{"id":405,"name":"Dependency review","path":".github/workflows/dependency-review.yml","head_sha":"test-head-sha","status":"completed","conclusion":"success","pull_requests":[{"number":123}]},{"id":406,"name":"OSV-Scanner","path":".github/workflows/osvscanner.yml","head_sha":"test-head-sha","status":"completed","conclusion":"success","pull_requests":[{"number":123}]}]}' +run_scan_mode_validation_case "scan-mode-rejects-normal-alias" "normal" "2" "ERROR: STRIX_SCAN_MODE must be one of quick, standard, or deep; got 'normal'." +run_scan_mode_validation_case "scan-mode-rejects-whitespace" " " "2" "ERROR: STRIX_SCAN_MODE must be one of quick, standard, or deep; got ''." +run_scan_mode_validation_case "scan-mode-rejects-capitalized-quick" "Quick" "2" "ERROR: STRIX_SCAN_MODE must be one of quick, standard, or deep; got 'Quick'." +run_scan_mode_validation_case "scan-mode-rejects-injection" "quick;id" "2" "ERROR: STRIX_SCAN_MODE must be one of quick, standard, or deep; got 'quick;id'." run_missing_config_case "missing-strix-llm" "" "dummy" "ERROR: STRIX_LLM_FILE must reference a regular file containing the model." run_missing_config_case "missing-llm-api-key" "openai/gpt-5.4" "" "ERROR: LLM_API_KEY_FILE must reference a regular file containing the API key." run_missing_config_case "whitespace-only-strix-llm" " " "dummy" "ERROR: STRIX_LLM_FILE must contain a non-empty model value." diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 535fd513a..4f5cbb386 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -115,6 +115,8 @@ def test_privileged_review_retries_use_default_branch_repository_dispatch() -> N assert "workflow_dispatch:" not in trigger_contract assert "github.event.inputs" not in workflow assert "github.event.client_payload" in workflow + if filename == "strix.yml": + assert "client_payload.scan_mode" not in workflow scheduler = ( REPO_ROOT / "scripts" / "ci" / "pr_review_merge_scheduler.py" @@ -137,7 +139,7 @@ def test_privileged_review_retries_use_default_branch_repository_dispatch() -> N def test_no_central_workflow_exposes_branch_selected_manual_dispatch() -> None: - """Every central manual entrypoint must load code from the default branch.""" + """Privileged central entrypoints must not load caller-selected workflow code.""" workflow_files = sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml")) offenders = [ path.name @@ -1111,7 +1113,7 @@ def test_strix_provider_outage_without_findings_is_neutralized() -> None: def test_strix_cross_repo_dispatch_uses_target_token_for_pr_scoping() -> None: workflow = workflow_text("strix.yml") - run_step = workflow.split(" - name: Run Strix (quick)", 1)[1].split( + run_step = workflow.split(" - name: Run Strix\n", 1)[1].split( " - name:", 1 )[0] diff --git a/tests/test_strix_scan_mode_policy.py b/tests/test_strix_scan_mode_policy.py new file mode 100644 index 000000000..f6776ff8c --- /dev/null +++ b/tests/test_strix_scan_mode_policy.py @@ -0,0 +1,230 @@ +"""Event-to-mode contract for the organization-required Strix workflow. + +CWL runs both GitHub Flow (main/master) and Git Flow (develop) without a +consistent RC-tag or prerelease convention. Official Strix CLI modes are only +``quick``, ``standard``, and ``deep``. This module pins the dual-flow mapping +and keeps Deep unwired on this privileged file so a branch-selected +``workflow_dispatch`` cannot mint OIDC tokens or publish a fake ``strix`` +status. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +STRIX_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "strix.yml" +STRIX_GATE = REPO_ROOT / "scripts" / "ci" / "strix_quick_gate.sh" +QUALITY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "strix-changed-path-quality-ci.yml" + +SCAN_MODE_EXPRESSION = ( + "STRIX_SCAN_MODE: ${{ github.event_name == 'schedule' && 'standard' || " + "github.event_name == 'push' && (github.ref == 'refs/heads/main' || " + "github.ref == 'refs/heads/master') && 'standard' || 'quick' }}" +) + + +def _workflow() -> str: + """Return the current central Strix workflow text.""" + return STRIX_WORKFLOW.read_text(encoding="utf-8") + + +def _trigger_block(workflow: str) -> str: + """Return the ``on:`` block before concurrency.""" + return workflow.split("concurrency:", 1)[0] + + +def _strix_job_header(workflow: str) -> str: + """Return the ``strix`` job header before its first step.""" + job = workflow.split(" strix:\n", 1)[1] + return job.split(" steps:", 1)[0] + + +def _run_strix_step(workflow: str) -> str: + """Return the official-mode scan step without loading workflow YAML tags.""" + marker = " - name: Run Strix\n" + start = workflow.index(marker) + end = workflow.index("\n - name:", start + len(marker)) + return workflow[start:end] + + +def _function_block(source: str, function_name: str) -> str: + """Return one top-level Bash function, including its closing brace.""" + match = re.search( + rf"(?ms)^{re.escape(function_name)}\(\) {{\n.*?^}}\n", + source, + ) + if match is None: + raise AssertionError(f"missing Bash function: {function_name}") + return match.group(0) + + +def _require_safe_scan_mode_rc(scan_mode: str) -> int: + """Execute the production allowlist against one candidate mode.""" + function_source = _function_block( + STRIX_GATE.read_text(encoding="utf-8"), + "require_safe_scan_mode", + ) + script = "\n".join( + ( + "set -euo pipefail", + function_source, + 'require_safe_scan_mode "$1"', + ) + ) + completed = subprocess.run( + ["bash", "-c", script, "strix-scan-mode", scan_mode], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode not in {0, 2}: + raise AssertionError(completed.stderr) + return completed.returncode + + +def test_quality_trigger_includes_scan_mode_contract_paths() -> None: + """Keep mapping and doctoring changes inside the exact-head quality job.""" + workflow = QUALITY_WORKFLOW.read_text(encoding="utf-8") + trigger = workflow[: workflow.index("\njobs:")] + + assert "docs/doctoring/strix-scan-mode-dual-flow.md" in trigger + assert "tests/test_strix_scan_mode_policy.py" in trigger + assert ' - ".github/workflows/strix.yml"' in trigger + assert "tests/test_strix_scan_mode_policy.py" in workflow + + +def test_workflow_pins_official_scan_mode_expression() -> None: + """The job must set STRIX_SCAN_MODE from event and protected-branch ref.""" + workflow = _workflow() + + assert SCAN_MODE_EXPRESSION in workflow + assert "client_payload.scan_mode" not in workflow + assert "github.event.inputs.scan_mode" not in workflow + assert " - normal" not in workflow + assert "|| 'normal'" not in workflow + assert "|| 'deep'" not in workflow + assert "&& 'deep'" not in workflow + + +@pytest.mark.parametrize( + ("event_name", "ref", "expected"), + ( + ("pull_request_target", "refs/heads/main", "quick"), + ("repository_dispatch", "refs/heads/main", "quick"), + ("push", "refs/heads/develop", "quick"), + ("push", "refs/heads/main", "standard"), + ("push", "refs/heads/master", "standard"), + ("schedule", "refs/heads/develop", "standard"), + ("schedule", "refs/heads/main", "standard"), + ), +) +def test_dual_flow_event_maps_to_official_mode( + event_name: str, + ref: str, + expected: str, +) -> None: + """Mirror the pinned workflow expression for the confirmed dual-flow policy.""" + if event_name == "schedule": + actual = "standard" + elif event_name == "push" and ref in {"refs/heads/main", "refs/heads/master"}: + actual = "standard" + else: + actual = "quick" + + assert actual == expected + assert SCAN_MODE_EXPRESSION in _workflow() + assert expected in {"quick", "standard"} + + +def test_repository_dispatch_cannot_inherit_standard_from_default_branch_ref() -> None: + """``repository_dispatch`` SHA is often main; mode must still stay quick.""" + workflow = _workflow() + + assert "github.event_name == 'push' && (github.ref == 'refs/heads/main'" in workflow + assert "client_payload.scan_mode" not in workflow + header = _strix_job_header(workflow) + assert "repository_dispatch" not in header or "STRIX_SCAN_MODE" in header + assert "|| 'quick' }}" in header + + +def test_required_pr_and_quick_paths_keep_standard_timeout_budget() -> None: + """Deep ceilings must not become the required pull_request_target job.""" + workflow = _workflow() + header = _strix_job_header(workflow) + step = _run_strix_step(workflow) + + assert "timeout-minutes: 120" in header + assert "timeout-minutes: 100" in step + assert "timeout-minutes: 360" not in header + assert "timeout-minutes: 340" not in step + assert "timeout-minutes: 360" not in step + assert 'process_budget_seconds="5400"' in step + assert 'total_budget_seconds="5700"' in step + assert 'process_budget_seconds="14400"' not in step + assert 'total_budget_seconds="16200"' not in step + assert "workflow_dispatch" not in step + + +def test_privileged_strix_workflow_has_no_branch_selected_manual_dispatch() -> None: + """Manual Deep must not load this privileged YAML from a caller-selected ref.""" + trigger = _trigger_block(_workflow()) + + assert "workflow_dispatch:" not in trigger + assert "github.event.inputs" not in trigger + assert "types: [strix-scan]" in trigger + assert "\n release:" not in trigger + assert "v*-rc*" not in trigger + assert "scan_mode:" not in trigger + + +def test_required_pr_scoping_and_severity_gate_remain() -> None: + """Mode mapping must not weaken fail-closed PR evidence.""" + step = _run_strix_step(_workflow()) + + assert "STRIX_FAIL_ON_MIN_SEVERITY: MEDIUM" in step + assert "__PR_SCOPE__" in step + assert "STRIX_DISABLE_PR_SCOPING:" in step + assert "github.event_name == 'pull_request_target'" in step + assert "github.event.client_payload.pr_number" in step + + +@pytest.mark.parametrize("scan_mode", ("quick", "standard", "deep")) +def test_require_safe_scan_mode_accepts_official_names(scan_mode: str) -> None: + """The gate allowlist must accept only official CLI mode names.""" + assert _require_safe_scan_mode_rc(scan_mode) == 0 + + +@pytest.mark.parametrize( + "scan_mode", + ("normal", "Quick", "STANDARD", "deep ", "quick;id", "fast", ""), +) +def test_require_safe_scan_mode_rejects_unofficial_names(scan_mode: str) -> None: + """Charset-valid aliases such as ``normal`` must still fail closed.""" + assert _require_safe_scan_mode_rc(scan_mode) == 2 + + +def test_require_safe_scan_mode_source_has_no_normal_alias() -> None: + """The production case list must not grow a ``normal`` arm.""" + function_source = _function_block( + STRIX_GATE.read_text(encoding="utf-8"), + "require_safe_scan_mode", + ) + + assert "quick | standard | deep)" in function_source + assert "normal)" not in function_source + + +def test_gate_allowlist_is_reached_before_scanner_invocation() -> None: + """An unofficial mode must fail closed before the fake scanner starts.""" + gate = STRIX_GATE.read_text(encoding="utf-8") + allowlist_index = gate.index("require_safe_scan_mode") + command_index = gate.index( + 'command = [resolved_strix_bin, "-n", "-t", ".", "--scan-mode", scan_mode]' + ) + assert allowlist_index < command_index