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..552658f3c 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -61,10 +61,37 @@ 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. repository_dispatch: types: [strix-scan] + # Manual official-mode selector for an incomplete RC. Deep is allowed only + # on this path. The sole input is scan_mode; callers cannot choose a + # target repository or substitute privileged retry metadata. Selecting a + # workflow revision still requires write access, matching GitHub's + # workflow_dispatch model. Privileged same-head retries stay on + # repository_dispatch above. + workflow_dispatch: + inputs: + scan_mode: + description: >- + Official Strix CLI mode (quick, standard, or deep). Deep is + manual-only and raises this job to the GitHub-hosted 360-minute + ceiling. CWL does not invent RC tags or GitHub release events; + use this input to scan an incomplete release candidate by hand. + required: true + type: choice + default: standard + options: + - quick + - standard + - deep concurrency: # Include the event name so default-branch repository_dispatch evidence cannot cancel @@ -92,12 +119,13 @@ 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. - timeout-minutes: 120 + # Quick and standard share the honest 120/100/90/95 budget (standard is + # 30–60 min). Deep is manual workflow_dispatch only and uses the + # GitHub-hosted 360-minute job ceiling, leaving ~20 minutes after the + # 340-minute step for artifact and status publication. Required + # pull_request_target / repository_dispatch evidence stays on the 120 + # budget; do not raise that path to 360. + timeout-minutes: ${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.scan_mode == 'deep' && '360' || '120') }} runs-on: ubuntu-latest # Least-privilege token scoped to this job (Scorecard alert #43): the scan # exchanges an OIDC token (id-token) and publishes same-repo status evidence @@ -110,6 +138,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; + # workflow_dispatch → the scan_mode input (default standard). + # repository_dispatch must not inherit standard from github.ref + # (dispatch SHA is the default branch, often main). + STRIX_SCAN_MODE: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.scan_mode || 'standard') || 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,9 +816,9 @@ 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 + timeout-minutes: ${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.scan_mode == 'deep' && '340' || '100') }} # Security invariant for pull_request_target: execute only from the # trusted base checkout. The gate copies PR-head blobs into an isolated # temporary scope with execute bits stripped, then scans that scope as @@ -834,11 +869,22 @@ jobs: IS_PR_EVIDENCE_RUN: ${{ (github.event_name == 'pull_request_target' || github.event.client_payload.pr_number != '') && 'true' || 'false' }} run: | budget_suffix="TIME""OUT" - process_budget_seconds="5400" + # Deep process/total budgets are workflow_dispatch-only. A required + # PR or repository_dispatch run must keep the 90/95-minute cap even + # if STRIX_SCAN_MODE were ever mis-set to deep. + if [ "${STRIX_SCAN_MODE}" = "deep" ] && [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + process_budget_seconds="14400" + total_budget_seconds="16200" + else + process_budget_seconds="5400" + total_budget_seconds="5700" + fi + 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 7bf8ad766..cba03e20a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Semantic Versioning where the repository publishes a release. ### Changed +- Map the organization-required Strix workflow to official CLI modes (`quick`, `standard`, `deep`) 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. `workflow_dispatch` is restored with a `scan_mode` choice (default Standard) so an incomplete release candidate can be scanned by hand. Deep is manual-only and is the only path that raises the job to 360/340/4h/4.5h. CWL's dual GitHub Flow / Git Flow setup has no consistent RC-tag or prerelease convention, so this change does not invent `release:` or `v*-rc*` triggers. The gate now allowlists official mode names 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 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. - Run the bounded DiskSage repair heartbeat at minute 37 of every hour, dispatch no more than one exact-head repair, and wait two hours before redispatching an unchanged head so legitimate OpenCode or NVIDIA NIM latency does not create duplicate writers. @@ -36,6 +37,8 @@ Semantic Versioning where the repository publishes a release. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). - Compared the trusted `uv` executable's post-install `--version` output against the real GitHub Releases build's full string, `uv 0.12.1 (x86_64-unknown-linux-gnu)`, instead of the bare `uv 0.12.1` the prior check required; the genuine release binary always prints the target triple, so every installation was failing the pin check immediately after the archive download itself was fixed (ContextualWisdomLab/.github#1109). - Excluded relative `-r` and `--requirement` referrers from generated flat base-lock publication while retaining bounded include syntax diagnostics and discovering independently complete direct `.txt` children of `requirements` directories. +- Bound the Strix dual-flow mapping cases to the pinned GitHub expression and recorded that restored `workflow_dispatch` still lets a repository writer choose the workflow revision (`gh workflow run --ref`); write access is the only control, and `target_repository` / `pr_number` inputs stay forbidden. +- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - 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. 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..d3798e5f8 --- /dev/null +++ b/docs/doctoring/strix-scan-mode-dual-flow.md @@ -0,0 +1,66 @@ +# 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` (usestrix/strix `--scan-mode` choices). 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). The CLI default is deep; CWL must keep choosing explicitly. + +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 | +| `workflow_dispatch` `scan_mode` | chosen (`quick` / `standard` / `deep`, default `standard`) | Deep only: 360 / 340 / 14400 / 16200 | + +`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`. + +`workflow_dispatch` is restored with a single `scan_mode` choice so an incomplete release candidate can be scanned by hand. Deep is allowed only on that manual path. The required PR job stays on the 120-minute budget. Deep uses the GitHub-hosted 360-minute ceiling and leaves about 20 minutes after the 340-minute step for artifact and status publication. + +## Residual: workflow_dispatch revision selection + +GitHub's manual-run UI and `gh workflow run --ref` let a repository writer choose which revision supplies the workflow definition *before* the trusted-source checkout (GitHub, n.d., *Manually running a workflow*). After this YAML is on the default branch, that selected revision runs with scanner secrets and `id-token: write`. A job-level `if:` on `main` does not stop a malicious selected revision. Write access is the only control. + +This residual is accepted because CWL has no RC-tag convention and Deep must remain available by hand on this file. Do not add `target_repository` or `pr_number` inputs to "fix" it; those would widen the privileged surface. Privileged same-head retries stay on default-branch `repository_dispatch` type `strix-scan`. + +`require_safe_scan_mode` now allowlists `quick|standard|deep` and rejects `normal` and every other string, including charset-valid aliases. + +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 apply to the required PR path; +3. a GitHub release event or RC-tag trigger is added; +4. `require_safe_scan_mode` accepts `normal` or any unofficial name; +5. `workflow_dispatch` grows repository, pull-request, or privileged-retry inputs. + +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 invent RC tags to replace the manual `workflow_dispatch` path. + +## References (APA 7th) + +GitHub. (n.d.). *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.). *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 + +Strix. (n.d.). *Command-line interface* (`--scan-mode` `{quick,standard,deep}`). 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..f4b24bc32 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_contains "$workflow_file" "workflow_dispatch:" "Strix workflow restores manual official-mode selection" +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" "fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.scan_mode == 'deep' && '360' || '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..bf1a2ce90 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -209,6 +209,20 @@ 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_contains "$workflow_file" "workflow_dispatch:" "strix workflow restores manual official-mode selection for incomplete release candidates" + assert_file_contains "$REPO_ROOT/docs/doctoring/strix-scan-mode-dual-flow.md" "Write access is the only control" "strix doctoring records the workflow_dispatch revision-selection residual" + assert_file_contains "$REPO_ROOT/docs/doctoring/strix-scan-mode-dual-flow.md" "gh workflow run --ref" "strix doctoring names the GitHub manual-run revision selector" + assert_file_contains "$REPO_ROOT/docs/doctoring/strix-scan-mode-dual-flow.md" "Do not add \`target_repository\` or \`pr_number\` inputs" "strix doctoring forbids widening workflow_dispatch into a privileged retry" + assert_file_contains "$workflow_file" "default: standard" "strix manual dispatch defaults to official standard mode" + assert_file_contains "$workflow_file" " - quick" "strix manual dispatch offers official quick mode" + assert_file_contains "$workflow_file" " - standard" "strix manual dispatch offers official standard mode" + assert_file_contains "$workflow_file" " - deep" "strix manual dispatch offers official deep mode" + 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 == 'workflow_dispatch' && (github.event.inputs.scan_mode || 'standard') || 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 +288,20 @@ 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: \${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.scan_mode == 'deep' && '360' || '120') }}" "strix workflow keeps the 120-minute job budget except for manual deep" + assert_file_contains "$workflow_file" "timeout-minutes: \${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.scan_mode == 'deep' && '340' || '100') }}" "strix workflow keeps the 100-minute step budget except for manual deep" + assert_file_not_contains "$workflow_file" "timeout-minutes: 360" "strix workflow must not apply the deep job ceiling unconditionally" + assert_file_not_contains "$workflow_file" "timeout-minutes: 340" "strix workflow must not apply the deep step ceiling unconditionally" 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_contains "$workflow_file" 'process_budget_seconds="14400"' "strix workflow gives a manual deep scan a 4-hour process budget" + assert_file_contains "$workflow_file" 'total_budget_seconds="16200"' "strix workflow gives a manual deep scan a 4.5-hour total budget" + assert_file_contains "$workflow_file" '[ "${STRIX_SCAN_MODE}" = "deep" ] && [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]' "strix workflow raises process budgets only for manual deep" + 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 +8151,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 +11687,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 233c08584..65c071c7f 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -112,8 +112,13 @@ def test_privileged_review_retries_use_default_branch_repository_dispatch() -> N assert "repository_dispatch:" in trigger_contract assert f"types: [{event_type}]" in trigger_contract - assert "workflow_dispatch:" not in trigger_contract - assert "github.event.inputs" not in workflow + if filename == "strix.yml": + assert "workflow_dispatch:" in trigger_contract + assert "github.event.inputs.scan_mode" in workflow + assert "client_payload.scan_mode" not in workflow + else: + assert "workflow_dispatch:" not in trigger_contract + assert "github.event.inputs" not in workflow assert "github.event.client_payload" in workflow scheduler = ( @@ -137,14 +142,20 @@ 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. + + Strix is the documented exception: ``workflow_dispatch`` exists only so an + operator can choose official ``quick|standard|deep`` for an incomplete + release candidate. CWL has no RC-tag convention. The privileged same-head + retry remains default-branch ``repository_dispatch`` type ``strix-scan``. + """ workflow_files = sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml")) offenders = [ path.name for path in workflow_files if "workflow_dispatch:" in path.read_text(encoding="utf-8") ] - assert offenders == [] + assert offenders == ["strix.yml"] def test_required_pull_request_workflows_cancel_superseded_runs() -> None: @@ -1098,7 +1109,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..baf514df2 --- /dev/null +++ b/tests/test_strix_scan_mode_policy.py @@ -0,0 +1,416 @@ +"""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 the Deep-only timeout raise so a revert or a 360-minute required-PR job +fails closed in CI. +""" + +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" +DOCTORING = REPO_ROOT / "docs" / "doctoring" / "strix-scan-mode-dual-flow.md" +SCAN_MODE_EXPRESSION_PREFIX = "STRIX_SCAN_MODE: ${{ " +SCAN_MODE_EXPRESSION_SUFFIX = " }}" + +SCAN_MODE_EXPRESSION = ( + "STRIX_SCAN_MODE: ${{ github.event_name == 'workflow_dispatch' && " + "(github.event.inputs.scan_mode || 'standard') || " + "github.event_name == 'schedule' && 'standard' || " + "github.event_name == 'push' && (github.ref == 'refs/heads/main' || " + "github.ref == 'refs/heads/master') && 'standard' || 'quick' }}" +) +JOB_TIMEOUT_EXPRESSION = ( + "timeout-minutes: ${{ fromJSON(github.event_name == 'workflow_dispatch' && " + "github.event.inputs.scan_mode == 'deep' && '360' || '120') }}" +) +STEP_TIMEOUT_EXPRESSION = ( + "timeout-minutes: ${{ fromJSON(github.event_name == 'workflow_dispatch' && " + "github.event.inputs.scan_mode == 'deep' && '340' || '100') }}" +) + + +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 _pinned_scan_mode_expression() -> str: + """Return the inner GitHub expression pinned by ``SCAN_MODE_EXPRESSION``.""" + if not SCAN_MODE_EXPRESSION.startswith(SCAN_MODE_EXPRESSION_PREFIX): + raise AssertionError("scan-mode pin lost its GitHub expression prefix") + if not SCAN_MODE_EXPRESSION.endswith(SCAN_MODE_EXPRESSION_SUFFIX): + raise AssertionError("scan-mode pin lost its GitHub expression suffix") + return SCAN_MODE_EXPRESSION[ + len(SCAN_MODE_EXPRESSION_PREFIX) : -len(SCAN_MODE_EXPRESSION_SUFFIX) + ] + + +def _github_expression_tokens(expression: str) -> list[str]: + """Tokenize a GitHub Actions ``&&`` / ``||`` / ``==`` string expression.""" + tokens: list[str] = [] + index = 0 + length = len(expression) + while index < length: + current = expression[index] + if current.isspace(): + index += 1 + continue + if expression.startswith("&&", index) or expression.startswith("||", index): + tokens.append(expression[index : index + 2]) + index += 2 + continue + if expression.startswith("==", index): + tokens.append("==") + index += 2 + continue + if current in "()": + tokens.append(current) + index += 1 + continue + if current == "'": + end = expression.find("'", index + 1) + if end < 0: + raise AssertionError("unterminated GitHub expression string") + tokens.append(expression[index : end + 1]) + index = end + 1 + continue + if current == "." or current.isalpha() or current == "_": + end = index + 1 + while end < length and ( + expression[end].isalnum() or expression[end] in "._" + ): + end += 1 + tokens.append(expression[index:end]) + index = end + continue + raise AssertionError(f"unsupported GitHub expression token at {index}") + return tokens + + +def _github_value_is_truthy(value: str) -> bool: + """Return whether a GitHub Actions operand continues ``&&`` / ``||``.""" + return value not in {"", "false", "0", "null"} + + +def _eval_github_or_and(expression: str, bindings: dict[str, str]) -> str: + """Evaluate the pinned scan-mode expression with GitHub ``&&`` / ``||`` rules.""" + tokens = _github_expression_tokens(expression) + index = 0 + + def peek() -> str | None: + if index >= len(tokens): + return None + return tokens[index] + + def consume(expected: str | None = None) -> str: + nonlocal index + if index >= len(tokens): + raise AssertionError("unexpected end of GitHub expression") + token = tokens[index] + if expected is not None and token != expected: + raise AssertionError(f"expected {expected!r}, found {token!r}") + index += 1 + return token + + def parse_primary() -> str: + token = peek() + if token == "(": + consume("(") + value = parse_or() + consume(")") + return value + if token is None: + raise AssertionError("missing GitHub expression operand") + consume() + if token.startswith("'") and token.endswith("'"): + return token[1:-1] + if token not in bindings: + raise AssertionError(f"unbound GitHub expression identifier: {token}") + return bindings[token] + + def parse_comparison() -> str: + left = parse_primary() + if peek() != "==": + return left + consume("==") + right = parse_primary() + return "true" if left == right else "false" + + def parse_and() -> str: + value = parse_comparison() + while peek() == "&&": + consume("&&") + if not _github_value_is_truthy(value): + parse_comparison() + continue + value = parse_comparison() + return value + + def parse_or() -> str: + value = parse_and() + while peek() == "||": + consume("||") + if _github_value_is_truthy(value): + parse_and() + continue + value = parse_and() + return value + + result = parse_or() + if index != len(tokens): + raise AssertionError(f"trailing GitHub expression tokens: {tokens[index:]}") + return result + + +def _eval_pinned_scan_mode(event_name: str, ref: str, scan_mode_input: str) -> str: + """Evaluate the exact pinned workflow expression against one event.""" + return _eval_github_or_and( + _pinned_scan_mode_expression(), + { + "github.event_name": event_name, + "github.event.inputs.scan_mode": scan_mode_input, + "github.ref": ref, + }, + ) + + +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_pinned_scan_mode_expression_evaluator_uses_github_or_and_rules() -> None: + """A remade Python if/elif must not be able to hide pin drift.""" + expression = _pinned_scan_mode_expression() + + assert "github.event_name == 'workflow_dispatch'" in expression + assert "github.event_name == 'push'" in expression + assert ( + _eval_github_or_and( + expression, + { + "github.event_name": "repository_dispatch", + "github.event.inputs.scan_mode": "", + "github.ref": "refs/heads/main", + }, + ) + == "quick" + ) + + +def test_workflow_pins_official_scan_mode_expression() -> None: + """The job must set STRIX_SCAN_MODE from event, ref, and manual input.""" + workflow = _workflow() + + assert SCAN_MODE_EXPRESSION in workflow + assert "client_payload.scan_mode" not in workflow + assert " - normal" not in workflow + assert "|| 'normal'" not in workflow + + +@pytest.mark.parametrize( + ("event_name", "ref", "scan_mode_input", "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"), + ("workflow_dispatch", "refs/heads/release-candidate", "", "standard"), + ("workflow_dispatch", "refs/heads/main", "quick", "quick"), + ("workflow_dispatch", "refs/heads/main", "standard", "standard"), + ("workflow_dispatch", "refs/heads/main", "deep", "deep"), + ), +) +def test_dual_flow_event_maps_to_official_mode( + event_name: str, + ref: str, + scan_mode_input: str, + expected: str, +) -> None: + """Evaluate the pinned workflow expression, not a parallel Python remake.""" + assert SCAN_MODE_EXPRESSION in _workflow() + assert _eval_pinned_scan_mode(event_name, ref, scan_mode_input) == expected + if expected == "deep": + assert event_name == "workflow_dispatch" + assert scan_mode_input == "deep" + + +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 JOB_TIMEOUT_EXPRESSION in header + assert STEP_TIMEOUT_EXPRESSION 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 ( + '[ "${STRIX_SCAN_MODE}" = "deep" ] && ' + '[ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]' + ) in step + assert 'process_budget_seconds="14400"' in step + assert 'total_budget_seconds="16200"' in step + + +def test_doctoring_records_workflow_dispatch_revision_residual() -> None: + """Write access remains the only control after restoring manual dispatch.""" + doctoring = DOCTORING.read_text(encoding="utf-8") + + assert "gh workflow run --ref" in doctoring + assert "Write access is the only control" in doctoring + assert "job-level `if:`" in doctoring + assert "Do not add `target_repository` or `pr_number` inputs" in doctoring + + +def test_workflow_dispatch_is_scan_mode_only_and_keeps_dispatch_retry() -> None: + """Manual deep/standard is not a privileged same-head retry replacement.""" + trigger = _trigger_block(_workflow()) + + assert "workflow_dispatch:" in trigger + assert "scan_mode:" in trigger + assert "default: standard" in trigger + assert " - quick" in trigger + assert " - standard" in trigger + assert " - deep" in trigger + assert "types: [strix-scan]" in trigger + assert "\n release:" not in trigger + assert "v*-rc*" not in trigger + assert "target_repository:" not in trigger.split("workflow_dispatch:", 1)[1] + assert "pr_number:" not in trigger.split("workflow_dispatch:", 1)[1] + + +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