diff --git a/.github/workflows/github-hourly-review-repair.yml b/.github/workflows/github-hourly-review-repair.yml new file mode 100644 index 000000000..97665aa5f --- /dev/null +++ b/.github/workflows/github-hourly-review-repair.yml @@ -0,0 +1,27 @@ +name: Central GitHub Hourly Review Repair + +on: + schedule: + # Keep the control-plane queue moving without colliding with minute-zero jobs. + - cron: "21 * * * *" + +concurrency: + group: github-hourly-review-repair + cancel-in-progress: false + +permissions: + contents: read + +jobs: + dispatch-review-repair: + uses: ./.github/workflows/pr-review-fix-scheduler.yml + with: + target_repository: ContextualWisdomLab/.github + base_branch: main + max_prs: "50" + max_dispatches: "1" + resolve_unreviewed_conflicts: true + retry_hours: "1" + secrets: + PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} + OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }} diff --git a/.github/workflows/hourly-nvidia-nim-review-repair.yml b/.github/workflows/hourly-nvidia-nim-review-repair.yml index 0cb5e33dc..db7224f5a 100644 --- a/.github/workflows/hourly-nvidia-nim-review-repair.yml +++ b/.github/workflows/hourly-nvidia-nim-review-repair.yml @@ -10,16 +10,20 @@ on: - .github/workflows/clearfolio-hourly-review-repair.yml - .github/workflows/disksage-hourly-review-repair.yml - .github/workflows/fast-mlsirm-hourly-review-repair.yml + - .github/workflows/github-hourly-review-repair.yml - .github/workflows/governance-risk-compliance-hourly-review-repair.yml - .github/workflows/hourly-nvidia-nim-review-repair.yml + - .github/workflows/nonnest2-hourly-review-repair.yml - .github/workflows/originweave-hourly-review-repair.yml - scripts/ci/pr_review_conflict_scope.py - scripts/ci/pr_review_autofix_context.py - tests/test_bandscope_hourly_review_caller.py - tests/test_disksage_hourly_review_caller.py - tests/test_fast_mlsirm_hourly_review_caller.py + - tests/test_github_hourly_conflict_repair.py - tests/test_governance_risk_compliance_hourly_review_caller.py - tests/test_hourly_scheduler_runtime_budget.py + - tests/test_nonnest2_hourly_review_caller.py - tests/test_originweave_hourly_review_caller.py - tests/test_hourly_autofix_context_quality_gate.py - tests/test_pr_review_conflict_scope.py @@ -39,8 +43,10 @@ on: - docs/doctoring/conflict-control-evidence-isolation.md - docs/doctoring/disksage-hourly-review-caller.md - docs/doctoring/fast-mlsirm-hourly-review-caller.md + - docs/doctoring/github-hourly-conflict-repair.md - docs/doctoring/governance-risk-compliance-hourly-review-caller.md - docs/doctoring/hourly-nvidia-nim-autofix.md + - docs/doctoring/nonnest2-hourly-review-caller.md - docs/doctoring/originweave-hourly-review-caller.md push: paths: @@ -51,16 +57,20 @@ on: - .github/workflows/clearfolio-hourly-review-repair.yml - .github/workflows/disksage-hourly-review-repair.yml - .github/workflows/fast-mlsirm-hourly-review-repair.yml + - .github/workflows/github-hourly-review-repair.yml - .github/workflows/governance-risk-compliance-hourly-review-repair.yml - .github/workflows/hourly-nvidia-nim-review-repair.yml + - .github/workflows/nonnest2-hourly-review-repair.yml - .github/workflows/originweave-hourly-review-repair.yml - scripts/ci/pr_review_conflict_scope.py - scripts/ci/pr_review_autofix_context.py - tests/test_bandscope_hourly_review_caller.py - tests/test_disksage_hourly_review_caller.py - tests/test_fast_mlsirm_hourly_review_caller.py + - tests/test_github_hourly_conflict_repair.py - tests/test_governance_risk_compliance_hourly_review_caller.py - tests/test_hourly_scheduler_runtime_budget.py + - tests/test_nonnest2_hourly_review_caller.py - tests/test_originweave_hourly_review_caller.py - tests/test_hourly_autofix_context_quality_gate.py - tests/test_pr_review_conflict_scope.py @@ -80,8 +90,10 @@ on: - docs/doctoring/conflict-control-evidence-isolation.md - docs/doctoring/disksage-hourly-review-caller.md - docs/doctoring/fast-mlsirm-hourly-review-caller.md + - docs/doctoring/github-hourly-conflict-repair.md - docs/doctoring/governance-risk-compliance-hourly-review-caller.md - docs/doctoring/hourly-nvidia-nim-autofix.md + - docs/doctoring/nonnest2-hourly-review-caller.md - docs/doctoring/originweave-hourly-review-caller.md permissions: @@ -133,8 +145,10 @@ jobs: tests/test_bandscope_hourly_review_caller.py \ tests/test_disksage_hourly_review_caller.py \ tests/test_fast_mlsirm_hourly_review_caller.py \ + tests/test_github_hourly_conflict_repair.py \ tests/test_governance_risk_compliance_hourly_review_caller.py \ tests/test_hourly_scheduler_runtime_budget.py \ + tests/test_nonnest2_hourly_review_caller.py \ tests/test_originweave_hourly_review_caller.py \ tests/test_pr_review_conflict_scope_control_files.py \ tests/test_hourly_autofix_context_quality_gate.py \ diff --git a/.github/workflows/nonnest2-hourly-review-repair.yml b/.github/workflows/nonnest2-hourly-review-repair.yml new file mode 100644 index 000000000..d43290fa0 --- /dev/null +++ b/.github/workflows/nonnest2-hourly-review-repair.yml @@ -0,0 +1,37 @@ +name: nonnest2 Hourly Review Repair + +on: + schedule: + # Minute 16 avoids pg-llm-batch (1), aFIPC (2), kaefa (3), LineageWeave (4), + # codec-carver (5), life-os (6), Wardnet (7), mightyETL (8), + # psychometrics-commons (9), OriginWeave (10), naruon (11), + # DiagramWeave (12), pg-erd-cloud (13), mhtml-etl-gateway (14), + # html4tree (15), orchestrator (17), noema (19), Clearfolio (23), + # Keyverse (29), Scopeweave (31), DiskSage (37), Appguardrail (41), + # newsdom-api (43), Inkspan (47), fast-mlsirm (49), BandScope (53), + # and semantic-data-portal (59). + - cron: "16 * * * *" + +concurrency: + group: nonnest2-hourly-review-repair + # A later heartbeat must not cancel an in-flight Vuong or fit RCA. + cancel-in-progress: false + +permissions: + contents: read + +jobs: + dispatch-review-repair: + permissions: + contents: read + id-token: write + uses: ./.github/workflows/pr-review-fix-scheduler.yml + with: + target_repository: ContextualWisdomLab/nonnest2 + base_branch: master + max_prs: "50" + max_dispatches: "1" + retry_hours: "2" + secrets: + PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} + OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }} diff --git a/.github/workflows/pr-review-fix-scheduler.yml b/.github/workflows/pr-review-fix-scheduler.yml index 7eb0251d5..a3fdaa1aa 100644 --- a/.github/workflows/pr-review-fix-scheduler.yml +++ b/.github/workflows/pr-review-fix-scheduler.yml @@ -23,6 +23,11 @@ on: required: false default: "" type: string + resolve_unreviewed_conflicts: + description: Dispatch bounded conflict repair before the original head is reviewed + required: false + default: true + type: boolean retry_hours: description: Minimum hours before redispatching autofix for the same head required: false @@ -83,6 +88,7 @@ jobs: DRY_RUN: ${{ github.event.client_payload.dry_run == true || github.event.client_payload.dry_run == 'true' || inputs.dry_run == true }} MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || '50' }} MAX_DISPATCHES: ${{ github.event.client_payload.max_dispatches || inputs.max_dispatches || '1' }} + RESOLVE_UNREVIEWED_CONFLICTS: ${{ github.event.client_payload.resolve_unreviewed_conflicts == true || github.event.client_payload.resolve_unreviewed_conflicts == 'true' || inputs.resolve_unreviewed_conflicts == true }} RETRY_HOURS: ${{ github.event.client_payload.retry_hours || inputs.retry_hours || '1' }} AUTOFIX_WORKFLOW: pr-review-autofix.yml AUTOFIX_REPOSITORY: ContextualWisdomLab/.github @@ -104,20 +110,26 @@ jobs: "${TARGET_REPOSITORY:-}" exit 1 fi - if [ -z "$ALLOWED_TARGET_REPOSITORIES" ]; then - echo "::error::Scheduler target repository allowlist is not configured." - exit 1 - fi target_allowed=false - IFS=',' read -r -a allowed_targets <<<"$ALLOWED_TARGET_REPOSITORIES" - for candidate in "${allowed_targets[@]}"; do - candidate="${candidate//[[:space:]]/}" - if [ -n "$candidate" ] && [ "$candidate" = "$TARGET_REPOSITORY" ]; then - target_allowed=true - break + if [ -n "${GITHUB_REPOSITORY:-}" ] && + [ "$TARGET_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then + echo "Self-targeted scheduler invocation uses the protected caller repository." + target_allowed=true + else + if [ -z "$ALLOWED_TARGET_REPOSITORIES" ]; then + echo "::error::Scheduler target repository allowlist is not configured." + exit 1 fi - done + IFS=',' read -r -a allowed_targets <<<"$ALLOWED_TARGET_REPOSITORIES" + for candidate in "${allowed_targets[@]}"; do + candidate="${candidate//[[:space:]]/}" + if [ -n "$candidate" ] && [ "$candidate" = "$TARGET_REPOSITORY" ]; then + target_allowed=true + break + fi + done + fi if [ "$target_allowed" != "true" ]; then printf '::error::Scheduler target repository is not allowlisted: %s.\n' \ "$TARGET_REPOSITORY" @@ -127,7 +139,7 @@ jobs: # A reusable workflow receives its caller's original event payload, # so the hourly callers arrive as `schedule`, not `workflow_call`. # Only the direct repository_dispatch surface needs sender binding; - # every invocation still passes the target allowlist above. + # cross-repository invocations still pass the configured allowlist. if [ "$EVENT_NAME" = "repository_dispatch" ]; then if [ -z "$ALLOWED_DISPATCH_ACTOR" ] || [ "$DISPATCH_ACTOR" != "$ALLOWED_DISPATCH_ACTOR" ] || @@ -308,6 +320,9 @@ jobs: --autofix-workflow "$AUTOFIX_WORKFLOW" --autofix-repository "$AUTOFIX_REPOSITORY" ) + if [ "$RESOLVE_UNREVIEWED_CONFLICTS" = "true" ]; then + args+=(--resolve-unreviewed-conflicts) + fi if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run) fi diff --git a/.github/workflows/python-security.yml b/.github/workflows/python-security.yml index 9d2c2e965..ca57f9db5 100644 --- a/.github/workflows/python-security.yml +++ b/.github/workflows/python-security.yml @@ -236,9 +236,37 @@ jobs: # Audit every discovered requirements file. while IFS= read -r req; do - echo "::group::pip-audit -r ${req}" - pip-audit --strict --desc=on -r "${req}" || status=1 - echo "::endgroup::" + # A matching requirements--ci-overrides.txt (a `uv pip compile --override` + # input, e.g. requirements-strix-ci-overrides.txt) means the *-hashes.txt this + # override applies to pins a version whose declared metadata range intentionally + # conflicts with another pin in the same file (verified safe at override time, not a + # resolution mistake). pip's own dependency resolver -- which pip-audit's default + # `-r` mode still calls even for fully hash-pinned files -- fails on that same + # declared-range conflict regardless of --require-hashes, and plain --no-deps does + # not suppress it (confirmed: --no-deps only skips fetching undeclared transitive + # packages, pip's resolver still cross-checks the packages that *are* listed + # together). --disable-pip bypasses pip's resolver entirely and audits the exact + # pins directly, but it requires every requirement to be an exact version (raises on + # any bare range) -- true for the compiled *-hashes.txt, not necessarily true for the + # hand-maintained raw input (e.g. requirements-strix-ci.txt intentionally leaves + # protobuf as a range). So: hashed output files with an override get + # --disable-pip --no-deps; their raw, non-hash input counterpart is skipped here + # (it is never itself a `pip install --require-hashes` target -- only its compiled + # *-hashes.txt is installed -- and that compiled file is the one audited with full + # transitive coverage). + base="${req%.txt}" + unhashed_base="${base%-hashes}" + if [ "$base" != "$unhashed_base" ] && [ -f "${unhashed_base}-overrides.txt" ]; then + echo "::group::pip-audit -r ${req} (--disable-pip --no-deps: overridden lock)" + pip-audit --strict --desc=on --no-deps --disable-pip -r "${req}" || status=1 + echo "::endgroup::" + elif [ "$base" = "$unhashed_base" ] && [ -f "${unhashed_base}-overrides.txt" ]; then + echo "::notice::Skipping pip-audit for ${req}: it is the raw input to an overridden lock (${unhashed_base}-hashes.txt), never itself a pip install --require-hashes target, and its compiled hashes file is audited separately with full resolution." + else + echo "::group::pip-audit -r ${req}" + pip-audit --strict --desc=on -r "${req}" || status=1 + echo "::endgroup::" + fi done < <(find . -type f -name 'requirements*.txt' -not -path './.git/*') # Audit the project itself when a PEP 621 / lock manifest exists. diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 03ec23257..ef1452ae0 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -185,6 +185,8 @@ jobs: test -f "$trusted_strix_source/scripts/ci/strix_quick_gate.sh" test -f "$trusted_strix_source/scripts/ci/test_strix_quick_gate.sh" test -f "$trusted_strix_source/scripts/ci/strix_required_workflow_smoke.sh" + test -f "$trusted_strix_source/scripts/ci/strix_report_semantics.py" + test -f "$trusted_strix_source/scripts/ci/validate_strix_runtime_compatibility.py" { echo "TRUSTED_STRIX_SOURCE=$trusted_strix_source" echo "TRUSTED_STRIX_GATE=$trusted_strix_source/scripts/ci/strix_quick_gate.sh" @@ -543,7 +545,13 @@ jobs: # private install umask before creating the credential-bearing Strix # entry point; the runtime gate still rejects any later relaxation. umask 022 - python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes -r requirements-strix-ci-hashes.txt + # --no-deps: strix-agent declares cryptography<49, conflicting with this repo's + # cryptography==50.0.0 pin (CVE-2026-39892 fix, see requirements-strix-ci-overrides.txt). + # --require-hashes already pins every package (including transitive deps) to an exact, + # hash-verified version, so skipping pip's redundant declared-range resolution here is + # safe -- verified locally with --dry-run against this exact file before pushing. + python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --no-deps -r requirements-strix-ci-hashes.txt + python3 -I scripts/ci/validate_strix_runtime_compatibility.py requirements-strix-ci-hashes.txt strix_executable="$(command -v strix || true)" if [ -z "$strix_executable" ] || [[ "$strix_executable" != /* ]] \ || [ ! -f "$strix_executable" ] || [ -L "$strix_executable" ] \ @@ -835,50 +843,21 @@ jobs: 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. + # Preserve the gate exit code and console output. An incomplete backend + # run is not security evidence, so every non-zero gate result remains + # fail-closed while the following always() steps preserve diagnostics. strix_run_log="$RUNNER_TEMP/strix_gate_console.log" 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 - exit "$strix_rc" + if [ "$strix_rc" -ne 0 ]; then + # Provider-specific catalog failures such as Nvidia_nimException + # remain classified inside the trusted gate; this wrapper never + # converts an incomplete scan into success. + echo "Strix did not produce complete passing security evidence; failing the required check (gate exit ${strix_rc})." >&2 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 - 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 diff --git a/AGENTS.md b/AGENTS.md index 0fda888c7..bd6a96a11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,3 +6,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). +nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 01f634ec5..3e2e70b58 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -32,6 +32,13 @@ flowchart LR only established scheduler credentials, and grants job-scoped `id-token: write`. The reusable engine stays product-neutral. +## nonnest2 hourly caller + +`nonnest2-hourly-review-repair.yml` is a thin, read-only caller at minute +16. It names `ContextualWisdomLab/nonnest2` and protected `master`, maps +only established scheduler credentials, and grants job-scoped +`id-token: write`. The reusable engine stays product-neutral. + ## Hourly NVIDIA NIM repair gate ```mermaid diff --git a/CLAUDE.md b/CLAUDE.md index 65ee3ce81..d73a5c169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,9 @@ Details: `README.md` and `PR_GOVERNANCE_AUDIT.md`. configuration (GitHub Models provider, CodeGraph/DeepWiki/Context7/web-search MCP). All reviewer agents have `"edit": "deny"`: they are reviewers, never implementers. Keep it that way. - `requirements-{bandit,pip-audit,strix,opencode-review}-ci.txt` + `*-hashes.txt` — pinned CI - dependency sets (see below). + dependency sets (see below). `requirements-strix-ci-overrides.txt` documents one deliberate + `uv pip compile --override` (strix-agent's declared `cryptography<49` vs. this repo's + `cryptography==50.0.0` security pin; see #952) — re-verify it whenever strix-agent bumps again. - `fuzz/` + `.clusterfuzzlite/` — Atheris fuzz targets for the review-output normalizer and the ClusterFuzzLite discovery marker. - `docs/` — master context, Project protocol, `org-required-workflow-rollout.md`, @@ -96,7 +98,7 @@ e.g.: ```bash uv pip compile --generate-hashes --python-version 3.12 --python-platform x86_64-manylinux_2_28 requirements-bandit-ci.txt -o requirements-bandit-ci-hashes.txt uv pip compile --generate-hashes --python-version 3.12 --python-platform x86_64-manylinux_2_28 requirements-pip-audit-ci.txt -o requirements-pip-audit-ci-hashes.txt -uv pip compile --generate-hashes --python-version 3.13 --python-platform x86_64-manylinux_2_28 --output-file requirements-strix-ci-hashes.txt requirements-strix-ci.txt +uv pip compile --generate-hashes --python-version 3.13 --python-platform x86_64-manylinux_2_28 --override requirements-strix-ci-overrides.txt --output-file requirements-strix-ci-hashes.txt requirements-strix-ci.txt ./scripts/ci/compile_opencode_review_lock.sh ``` diff --git a/docs/doctoring/github-hourly-conflict-repair.md b/docs/doctoring/github-hourly-conflict-repair.md new file mode 100644 index 000000000..2a3fc2a68 --- /dev/null +++ b/docs/doctoring/github-hourly-conflict-repair.md @@ -0,0 +1,119 @@ +# Central `.github` hourly OpenCode conflict repair + +## Decision + +The central repository scans its own open `main` pull requests once per hour and +dispatches the existing trusted OpenCode conflict worker for a same-repository +head reported by GitHub as `DIRTY` or `CONFLICTING`. + +A review is **not** a prerequisite for this bounded repair. Resolving the +conflict creates a new merge commit and therefore a new pull-request head; any +review of the old head cannot establish approval of the resulting combined +source. The repaired head must complete fresh review and required checks before +it can merge. + +Direct Python-library callers retain the historical approval prerequisite. The +trusted reusable workflow opts into unreviewed conflict repair explicitly with +`--resolve-unreviewed-conflicts`, making the privilege visible and testable. + +## Execution path + +```text +hourly protected-default-branch caller +→ exact open PR inventory +→ same-repository, non-draft, configured-base filter +→ GitHub DIRTY / CONFLICTING signal +→ head-scoped retry marker +→ repository_dispatch(pr-review-autofix, repair_mode=conflict) +→ exact live base/head revalidation +→ git merge --no-commit --no-ff +→ sealed NUL-delimited conflicted-path allowlist +→ whole-worktree snapshot outside the repository +→ OpenCode edits conflicted paths only +→ scope verification, conflict-marker rejection, syntax checks +→ live-head race check +→ merge commit push +→ fresh required reviews and checks +``` + +## Preserved security and governance boundaries + +- Draft pull requests remain ineligible. +- Fork and external-head pull requests remain read-only. +- The configured base branch must match. +- The worker refetches and validates the exact live base and head before writing. +- OpenCode receives no GitHub token, OIDC request token, shell permission, + external-directory permission, web access, task delegation, or arbitrary + JavaScript execution permission. +- The model may modify only paths Git reported as unmerged. +- Tracked, untracked, ignored, deleted, retargeted, and symbolic-link state is + included in the scope evidence. +- Unresolved conflict markers fail closed. +- A concurrent head movement prevents the push. +- Conflict repair never approves, merges, or releases the pull request; it only + produces a reviewable combined head. +- One repair is dispatched per scheduler pass, with a one-hour exact-head retry + interval and non-cancelling worker concurrency. +- `COPILOT_GITHUB_TOKEN` is not used. + +## Why approval-before-repair was removed from the scheduled path + +The previous selector required a current-head approval before conflict repair. +That created a circular dependency for PRs such as `.github#1098`: reviewers +could not assess a valid merge preview while the conflict prevented the safe +combined head from existing, and the conflict worker could not run until a +review approved the pre-resolution head. + +The correct evidence order is: + +```text +conflict detected +→ bounded mechanical/semantic repair +→ new exact head +→ review and checks on that exact head +→ guarded merge decision +``` + +This changes eligibility only. It does not weaken the worker's write boundary or +the repository's review, required-check, branch-protection, and merge gates. + +## Regression evidence + +`tests/test_github_hourly_conflict_repair.py` fixes the following contracts: + +1. An unreviewed `DIRTY` PR becomes eligible only when the trusted policy flag is + explicit. +2. Direct library use remains backward-compatible by default. +3. The CLI exposes the policy flag. +4. The reusable workflow enables the policy for hourly callers by default. +5. `.github` has its own hourly caller at minute 21. +6. A same-repository protected caller does not require a cross-repository target + allowlist entry, while cross-repository targets still do. +7. The focused NVIDIA NIM review-repair gate tracks the caller, regression test, + and this doctoring record. + +The pre-existing conflict-scope, control-file isolation, trusted Git executable, +ignored-path, symlink-target, exact-head, writer-security, and NVIDIA NIM +contract suites remain authoritative for the worker boundary. + +## Operator next action + +After this change reaches `main`, inspect the next `Central GitHub Hourly Review +Repair` run. A qualifying conflict should receive the head-scoped scheduler +marker, followed by a `PR Review Autofix` conflict-mode run. Confirm that the +new head has a merge commit whose parents are the previous PR head and the live +protected base, then require normal current-head reviews and checks before +merging. + +## References — APA 7th + +GitHub. (n.d.). *About protected branches*. GitHub Docs. +https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches + +GitHub. (n.d.). *Resolving a merge conflict using the command line*. GitHub Docs. +https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/resolving-a-merge-conflict-using-the-command-line + +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 diff --git a/docs/doctoring/nonnest2-hourly-review-caller.md b/docs/doctoring/nonnest2-hourly-review-caller.md new file mode 100644 index 000000000..eba36c787 --- /dev/null +++ b/docs/doctoring/nonnest2-hourly-review-caller.md @@ -0,0 +1,140 @@ +# nonnest2 hourly review-repair caller + +검토 기준일: **2026-08-17** + +## Decision + +ContextualWisdomLab operates one protected hourly caller for +`ContextualWisdomLab/nonnest2` (R package that compares non-nested model +fit and distinguishability via Vuong tests). The caller runs at minute +16, delegates to the product-neutral central review-fix scheduler, +inspects at most 50 open pull requests targeting protected `master`, and +dispatches at most one bounded repair per heartbeat. + +A paying buyer of psychometric model comparison would feel live nonnest2 +pull requests stalling while hourly NVIDIA NIM repair scanned only +Clearfolio, DiskSage, and fast-mlsirm. Live heads such as +ContextualWisdomLab/nonnest2#89 (exported-function input validation), +ContextualWisdomLab/nonnest2#86 (main-function input validation), +ContextualWisdomLab/nonnest2#84 (call-stack leak on unvalidated errors), +and ContextualWisdomLab/nonnest2#90 (vapply matrix-row bound) target +`master` and never enter those other callers. + +The caller does not implement review or mutation logic itself. nonnest2 +remains standalone; fast-mlsirm and kaefa consume Vuong comparisons +without owning the R runtime. Privileged automation stays in +`ContextualWisdomLab/.github`. + +## Root-cause analysis and remediation feasibility + +The reusable worker performs exact-head root-cause analysis and tests +remediation feasibility before it edits. The reusable worker must: + +1. Refetch the exact live head, base, reviews, checks, changed paths, and + writer state. +2. Establish the causal chain rather than repeat the terminal symptom. +3. Enumerate materially distinct minimal remedies. +4. Reject remedies that lack writer authority, cross sealed paths, require + unavailable credentials or protected-setting changes, violate stack + order, cannot be verified, or do not alter the diagnosed cause. +5. Dispatch at most one feasible repair. Otherwise leave the tree + unchanged. + +A queued or pending check remains a merge blocker but is not itself a +code finding. The independent non-author approval remains an external +authorization gate and is never synthesized by the repair worker. The +worker cannot approve, merge, release, resolve review findings by +inference, change protection, or manufacture passing checks. + +## Cadence and concurrency + +The caller uses a single concurrency group and `cancel-in-progress: false`. +This preserves an in-flight bounded RCA instead of discarding Vuong +evidence when the next hourly heartbeat arrives. The reusable scheduler +cancels only its own superseded short queue scan. + +The caller sets a **two-hour same-head retry floor**. Central OpenCode and +NVIDIA NIM work, plus validation or log-likelihood analysis, can +legitimately approach two hours. An hourly redispatch of the same +unchanged head would create duplicate writer pressure rather than faster +remediation. + +GitHub scheduled workflows can be delayed under load and execute only +from the default branch. The cron expression is a heartbeat, not a +real-time SLA. + +## Credential and model boundary + +The caller keeps workflow `GITHUB_TOKEN` at `contents: read` and grants +the reusable job `id-token: write` so the central scheduler can mint the +OpenCode GitHub App token from GitHub OIDC when the mapped PAT is absent +(GitHub, n.d.-c). It maps only `PR_REVIEW_MERGE_TOKEN` and +`OPENCODE_APPROVE_TOKEN`. It never uses `secrets: inherit`, receives +`NVIDIA_NIM_API_KEY`, or introduces `COPILOT_GITHUB_TOKEN`. CWE-250 +forbids executing the caller with write or model privileges it does not +need (MITRE, 2026). + +Model execution remains inside the central worker. The model credential +is the GitHub Secret `NVIDIA_NIM_API_KEY`; the caller does not receive or +forward it. + +Before protected-master activation, the repository variable +`OPENCODE_REPOSITORY_DISPATCH_TARGETS` must contain the exact +`ContextualWisdomLab/nonnest2` target. Missing or mismatched +configuration fails before mutation credential materialization. + +## Security, standalone operation, and modularity + +The caller adds no nonnest2 runtime dependency, database object, network +endpoint, tenant authority, or product credential. nonnest2 continues to +run as a standalone R package. fast-mlsirm, kaefa, and other CWL +services may consume its tests, but they cannot weaken its exact-head, +approval, or security gates. + +## Verification and rollback + +Machine-checkable contracts require the exact target/base, minute 16 +cadence, non-cancelling single-flight group, one dispatch, two-hour +retry floor, explicit secret mapping, read-only contents plus job-scoped +`id-token: write`, focused path-filter coverage, and absence of model or +Copilot credentials. Independent `pull_request`, `push`, and `compileall` +path blocks must each name the caller, doctoring, or contract they own. + +After source integration, closure requires a scheduled or manual +protected-master consumer run proving the exact nonnest2 repository and +`master` base. Source checks alone are not protected-master operational acceptance. +Merge still requires zero unresolved valid findings and a +qualifying independent non-author approval. + +Rollback removes the nonnest2 caller, its focused test, doctoring, and +central path-filter/documentation entries. It must not remove scheduler +dispatch validation or affect independent product callers. + +## APA 7th references + +GitHub, Inc. (n.d.-a). *Events that trigger workflows*. GitHub Docs. +Retrieved August 17, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule + +GitHub, Inc. (n.d.-b). *Reuse workflows*. GitHub Docs. Retrieved August +17, 2026, from +https://docs.github.com/en/actions/how-tos/sharing-automations/reuse-workflows + +GitHub, Inc. (n.d.-c). *Automatic token authentication*. GitHub Docs. +Retrieved August 17, 2026, from +https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#permissions-for-the-github_token + +MITRE. (2026). *CWE-250: Execution with unnecessary privileges*. +https://cwe.mitre.org/data/definitions/250.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 + +NVIDIA. (n.d.). *NVIDIA NIM for large language models documentation*. +Retrieved August 17, 2026, from +https://docs.nvidia.com/nim/large-language-models/latest/ + +OpenCode. (n.d.). *OpenCode documentation*. Retrieved August 17, 2026, +from https://opencode.ai/docs/ diff --git a/requirements-strix-ci-hashes.txt b/requirements-strix-ci-hashes.txt index c305e9c84..01f00ab9e 100644 --- a/requirements-strix-ci-hashes.txt +++ b/requirements-strix-ci-hashes.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --generate-hashes --python-version 3.13 --python-platform x86_64-manylinux_2_28 --output-file requirements-strix-ci-hashes.txt requirements-strix-ci.txt +# uv pip compile --generate-hashes --python-version 3.13 --python-platform x86_64-manylinux_2_28 --override requirements-strix-ci-overrides.txt --output-file requirements-strix-ci-hashes.txt requirements-strix-ci.txt aiohappyeyeballs==2.7.1 \ --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 @@ -393,7 +393,9 @@ charset-normalizer==3.4.7 \ --hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \ --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \ --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 - # via requests + # via + # reportlab + # requests click==8.4.1 \ --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \ --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96 @@ -450,10 +452,12 @@ cryptography==50.0.0 \ --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 # via + # --override requirements-strix-ci-overrides.txt # -r requirements-strix-ci.txt # google-auth # pyjwt # pyopenssl + # strix-agent cvss==3.6 \ --hash=sha256:e342c6ad9c7eb69d2aebbbc2768a03cabd57eb947c806e145de5b936219833ea \ --hash=sha256:f21d18224efcd3c01b44ff1b37dec2e3208d29a6d0ce6c87a599c73c21ee1a99 @@ -1061,10 +1065,6 @@ jsonschema-specifications==2025.9.1 \ --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d # via jsonschema -linkify-it-py==2.1.0 \ - --hash=sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e \ - --hash=sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b - # via markdown-it-py litellm==1.94.1 \ --hash=sha256:001be1cde7950f2ae484e450ab2f8e93ab8791e5e8d4da560d21f2fb456b0b47 \ --hash=sha256:07c1771315d7d26e242ef90b9336bcbc49a52158ff72ee640b4f8160cc963147 \ @@ -1082,14 +1082,13 @@ litellm==1.94.1 \ --hash=sha256:e9b6d92e305d96bdadb8a5ccd343b1ac188de142fbd6c91f72c75416b8c25c48 \ --hash=sha256:e9effe4c1e9206740b4bb4c98142ea1f71bae57e49df007cd25ef24b0ce4563f \ --hash=sha256:ffa9a6cd9b6205d60b02ffc0b7f077a03693d835b06d2a34bfeaabb4f073c08a - # via openai-agents + # via + # openai-agents + # strix-agent markdown-it-py==4.2.0 \ --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a - # via - # mdit-py-plugins - # rich - # textual + # via rich markupsafe==3.0.3 \ --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ @@ -1185,10 +1184,6 @@ mcp==1.28.1 \ --hash=sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df \ --hash=sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683 # via openai-agents -mdit-py-plugins==0.6.1 \ - --hash=sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d \ - --hash=sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0 - # via textual mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba @@ -1343,15 +1338,16 @@ multidict==6.7.1 \ # via # aiohttp # yarl -openai==2.43.0 \ - --hash=sha256:65a670b54fadf2268c9e1330133373c963eb779ee969e5cbad419ec2c21dce97 \ - --hash=sha256:e74d238200a26868977002190fb6631613480a93dfe0c9c982e77021ed60a017 +openai==2.54.0 \ + --hash=sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b \ + --hash=sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa # via # litellm # openai-agents -openai-agents==0.14.6 \ - --hash=sha256:e9d16b835f73be4c5e3798694f90d7a62efcade931e59416bc7462c850e15705 \ - --hash=sha256:fdd3fb459892c8af5d0b522908b544e96f6217c7254ba55e966424493b43c1ed + # strix-agent +openai-agents==0.19.4 \ + --hash=sha256:12e0372fae9698fe6f78e05aaeb4ccdb229602f7ef99b8195a7d68dc82869f51 \ + --hash=sha256:fe21778ee1e8216c9cdb775fa86d11b08be68c0184e14023993088d3f812c0be # via strix-agent packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ @@ -1360,10 +1356,95 @@ packaging==26.2 \ # google-cloud-aiplatform # google-cloud-bigquery # huggingface-hub -platformdirs==4.10.0 \ - --hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \ - --hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a - # via textual +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 + # via reportlab propcache==0.5.2 \ --hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \ --hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \ @@ -1674,9 +1755,7 @@ pydantic-settings==2.14.2 \ pygments==2.20.0 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 - # via - # rich - # textual + # via rich pyjwt==2.13.0 \ --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 @@ -1685,6 +1764,10 @@ pyopenssl==26.4.0 \ --hash=sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7 \ --hash=sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c # via google-auth +pypdf==6.16.1 \ + --hash=sha256:63fec31c4092ae50b6729beedcb469055b60d20c834bde1c402df241f371f644 \ + --hash=sha256:c4d1b43ddae921387321cf63936cd16a7743b91d2da92f165c149a195c972ba9 + # via strix-agent python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 @@ -1775,7 +1858,9 @@ pyyaml==6.0.3 \ --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 - # via huggingface-hub + # via + # huggingface-hub + # strix-agent referencing==0.37.0 \ --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 @@ -1898,6 +1983,10 @@ regex==2026.7.19 \ --hash=sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1 \ --hash=sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2 # via tiktoken +reportlab==5.0.0 \ + --hash=sha256:9d5a3affa84919e1111ede580031266a570e93b1ce388219621347965ff1d93c \ + --hash=sha256:e4494a0c6623ae213bb856fba523171b2b54a7bf629fda02d5e525a7b899a784 + # via strix-agent requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed @@ -1916,7 +2005,6 @@ rich==15.0.0 \ --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 # via # strix-agent - # textual # typer rpds-py==2026.5.1 \ --hash=sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead \ @@ -2076,18 +2164,18 @@ starlette==1.3.1 \ # via # mcp # sse-starlette -strix-agent==1.0.4 \ - --hash=sha256:6c9d1bd2e3bfca64b1c4c7c24f70c287ea50b1d616d7a391a1e9819b01b9cc60 \ - --hash=sha256:a52b67ec91c114b42409a710065676370bb39fd4894dc79dafa58f7f8efa1a23 +strix-agent==1.5.3 \ + --hash=sha256:1a6207b493162049e9d651306798533fd4ece4dc2d2956f722ad1966ddc66647 \ + --hash=sha256:675c6f357f1cbddd1786299f42c9fc03743f7b597ba6416e695848f1eb4be280 \ + --hash=sha256:a5babe4e6d42cb24a10d4508bcd3c477bd369ff7194c95a7c58de6d6e4c3be18 \ + --hash=sha256:ba0b6b13f13f41e45f3eb4dba515641d1bc71363ca6e758d0cd05c20ff56b6ea \ + --hash=sha256:da35ae6e9a6ae0bf5cc662012608cf0aa671479129ba94052a3f893bff74c43f \ + --hash=sha256:e89cc335b379f42b1a1b53ebbb414d6ffceccea202a2bcdfc2e9df83a55a5a7d # via -r requirements-strix-ci.txt tenacity==9.1.4 \ --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \ --hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a # via google-genai -textual==8.2.7 \ - --hash=sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73 \ - --hash=sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105 - # via strix-agent tiktoken==0.13.0 \ --hash=sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4 \ --hash=sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58 \ @@ -2176,10 +2264,6 @@ typer==0.25.1 \ --hash=sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89 \ --hash=sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc # via huggingface-hub -types-requests==2.33.0.20260518 \ - --hash=sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0 \ - --hash=sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e - # via openai-agents typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 @@ -2193,7 +2277,6 @@ typing-extensions==4.15.0 \ # openai-agents # pydantic # pydantic-core - # textual # typing-inspection typing-inspection==0.4.2 \ --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ @@ -2202,17 +2285,12 @@ typing-inspection==0.4.2 \ # mcp # pydantic # pydantic-settings -uc-micro-py==2.0.0 \ - --hash=sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c \ - --hash=sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811 - # via linkify-it-py urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 # via # docker # requests - # types-requests uvicorn==0.49.0 \ --hash=sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f \ --hash=sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3 diff --git a/requirements-strix-ci-overrides.txt b/requirements-strix-ci-overrides.txt new file mode 100644 index 000000000..a38f75f1f --- /dev/null +++ b/requirements-strix-ci-overrides.txt @@ -0,0 +1,15 @@ +# uv pip compile --override for requirements-strix-ci.txt (see #952). +# +# strix-agent (every release from 1.4.0 through the current 1.5.3) declares +# cryptography<49,>=48.0.1, which conflicts with this repo's cryptography==50.0.0 +# pin (commit 7616fd80, CVE-2026-39892 fix). strix-agent's own code never imports +# `cryptography` directly (verified: no import in the installed package source); +# the real consumers pulling it in transitively are pyjwt and google-auth, both +# using only long-stable hazmat.primitives.asymmetric / serialization APIs for JWT +# signing. Verified locally: strix-agent==1.5.3 imports cleanly alongside +# cryptography==50.0.0, and a pyjwt RS256 sign/verify roundtrip against that +# cryptography version succeeds. strix-agent's <49 upper bound reads as an +# unreviewed "latest tested at release time" pin, not a real API incompatibility. +# +# Re-verify this override whenever strix-agent is bumped again. +cryptography==50.0.0 diff --git a/requirements-strix-ci.txt b/requirements-strix-ci.txt index 98e5c33e2..23d1c6568 100644 --- a/requirements-strix-ci.txt +++ b/requirements-strix-ci.txt @@ -1,4 +1,4 @@ -strix-agent==1.0.4 +strix-agent==1.5.3 aiohttp==3.14.3 google-cloud-aiplatform==1.133.0 protobuf<7.0.0 diff --git a/scripts/ci/pr_review_fix_scheduler.py b/scripts/ci/pr_review_fix_scheduler.py index 0a4263e19..2c9745d09 100755 --- a/scripts/ci/pr_review_fix_scheduler.py +++ b/scripts/ci/pr_review_fix_scheduler.py @@ -174,20 +174,28 @@ def needs_rca_repair(pr: dict[str, Any]) -> tuple[bool, tuple[str, ...]]: CONFLICT_MERGE_STATES = frozenset({"DIRTY", "CONFLICTING"}) -def needs_conflict_resolution(pr: dict[str, Any]) -> tuple[bool, tuple[str, ...]]: - """Return whether an approved PR has a conflict safe to auto-resolve. - - Only a current-head-approved PR that GitHub reports as ``DIRTY`` or - ``CONFLICTING`` qualifies. The worker merges the base into the head and the - resulting head must be reviewed and checked again before merge. +def needs_conflict_resolution( + pr: dict[str, Any], + *, + allow_unreviewed: bool = False, +) -> tuple[bool, tuple[str, ...]]: + """Return whether a GitHub-reported conflict is safe to auto-resolve. + + Direct library callers retain the historical current-head approval + prerequisite unless ``allow_unreviewed`` is explicit. Trusted scheduled + callers enable it because conflict repair creates a new head and therefore + requires fresh reviews and checks regardless of the previous review state. """ merge_state = str(pr.get("mergeStateStatus") or "").upper() if merge_state not in CONFLICT_MERGE_STATES: return False, () - if not has_current_head_approval(pr): + approved = has_current_head_approval(pr) + if not approved and not allow_unreviewed: return False, () + review_state = "current-head approved" if approved else "unreviewed" return True, ( - f"current-head approved PR is {merge_state.lower()}; auto-resolving the merge conflict", + f"{review_state} PR is {merge_state.lower()}; auto-resolving the merge " + "conflict and requiring fresh review and checks on the resulting head", ) @@ -234,7 +242,7 @@ def dispatch_autofix( ``repair_mode=rca`` tells the trusted context collector to gather failed check evidence and widen the sealed edit scope only to current PR files. - ``resolve_conflict`` retains the separate approved-conflict path. + ``resolve_conflict`` retains the separately bounded conflict path. """ dispatch_repo = workflow_repository or repo if workflow != DEFAULT_AUTOFIX_WORKFLOW: @@ -303,7 +311,12 @@ def inspect_pr( repair_mode = "rca" reasons = rca_reasons else: - needs_resolve, resolve_reasons = needs_conflict_resolution(pr) + needs_resolve, resolve_reasons = needs_conflict_resolution( + pr, + allow_unreviewed=bool( + getattr(args, "resolve_unreviewed_conflicts", False) + ), + ) if not needs_resolve: return "skip", ( "no current-head autofixable review, failed-check RCA, or approved merge conflict", @@ -356,7 +369,12 @@ def process_queue(args: argparse.Namespace) -> int: continue needs_fix, _ = needs_autofix(pr) needs_rca, _ = needs_rca_repair(pr) - needs_resolve, _ = needs_conflict_resolution(pr) + needs_resolve, _ = needs_conflict_resolution( + pr, + allow_unreviewed=bool( + getattr(args, "resolve_unreviewed_conflicts", False) + ), + ) if needs_fix or needs_rca or needs_resolve: prs_needing_comments.append(pr) @@ -503,6 +521,12 @@ def self_test() -> int: {**approved_dirty_pr, "mergeStateStatus": "CLEAN"} ) == (False, ()) assert needs_conflict_resolution(dirty_pr) == (False, ()) + resolves, resolve_reasons = needs_conflict_resolution( + dirty_pr, + allow_unreviewed=True, + ) + assert resolves + assert "fresh review and checks" in resolve_reasons[0] model_exhausted_pr = { **pr, "reviews": { @@ -552,6 +576,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--max-prs", type=int, default=50) parser.add_argument("--max-dispatches", type=int, default=1) parser.add_argument("--retry-hours", type=int, default=24) + parser.add_argument("--resolve-unreviewed-conflicts", action="store_true") parser.add_argument("--autofix-workflow", default="pr-review-autofix.yml") parser.add_argument( "--autofix-repository", diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 0f37f3460..678d45aec 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -2144,6 +2144,9 @@ has_unmapped_threshold_report() { if [ ! -f "$vuln_file" ] || [ -L "$vuln_file" ]; then continue fi + if vulnerability_file_is_retryable_model_inconsistency "$vuln_file"; then + continue + fi rank="$(extract_max_severity_rank "$vuln_file")" if [ "$rank" -lt "$threshold_rank" ]; then continue @@ -2583,6 +2586,24 @@ PY fi preserve_attempt_log "$model" "$rc" + local authoritative_reports_dir="${resolved_target_path%/}/strix_runs" + if [ -L "$authoritative_reports_dir" ] || { [ -e "$authoritative_reports_dir" ] && [ ! -d "$authoritative_reports_dir" ]; }; then + echo "Strix authoritative report output is not a regular directory; failing closed." | tee -a "$STRIX_LOG" >&2 + INFRA_ERROR_DETECTED=1 + return 1 + fi + if [ -d "$authoritative_reports_dir" ]; then + local imported_report_count + if ! imported_report_count="$(python3 -I "$SCRIPT_DIR/strix_report_semantics.py" import-current-attempt "$authoritative_reports_dir" "$ACTIVE_REPORTS_DIR" "$start_epoch" "${PR_BASE_SHA:-}" "${PR_HEAD_SHA:-}")"; then + echo "Strix authoritative report import failed; failing closed." | tee -a "$STRIX_LOG" >&2 + INFRA_ERROR_DETECTED=1 + return 1 + fi + if [ "$imported_report_count" -gt 0 ]; then + printf "Imported %s current-attempt Strix report file(s) from the authoritative target output path.\n" "$imported_report_count" >&2 + fi + fi + sanitize_known_strix_report_warnings "$ACTIVE_REPORTS_DIR" "${resolved_target_path%/}/strix_runs" local report_failure_signal=0 if has_strix_report_failure_signal "$ACTIVE_REPORTS_DIR" "${resolved_target_path%/}/strix_runs"; then @@ -3070,6 +3091,9 @@ has_only_below_threshold_vulnerabilities() { if [ ! -f "$vuln_file" ] || [ -L "$vuln_file" ]; then continue fi + if vulnerability_file_is_retryable_model_inconsistency "$vuln_file"; then + continue + fi found_any_vuln_file=1 update_max_severity_from_stream "$vuln_file" @@ -3178,6 +3202,9 @@ has_any_reported_severity_markers() { if [ ! -f "$vuln_file" ] || [ -L "$vuln_file" ]; then continue fi + if vulnerability_file_is_retryable_model_inconsistency "$vuln_file"; then + continue + fi if grep -Eiq 'severity[[:space:]]*:' "$vuln_file"; then return 0 fi @@ -3775,8 +3802,17 @@ vulnerability_file_reports_generic_github_actions_workflow_insecurity() { return 1 } +vulnerability_file_is_self_negating_no_finding() { + local vuln_file="$1" + python3 -I "$SCRIPT_DIR/strix_report_semantics.py" is-self-negating "$vuln_file" +} + vulnerability_file_is_retryable_model_inconsistency() { local vuln_file="$1" + if vulnerability_file_is_self_negating_no_finding "$vuln_file"; then + echo "Detected a self-negating Strix no-finding record with contradictory severity metadata; excluding it from vulnerability severity decisions." >&2 + return 0 + fi if ! vulnerability_file_is_below_threshold "$vuln_file"; then return 1 fi diff --git a/scripts/ci/strix_report_semantics.py b/scripts/ci/strix_report_semantics.py new file mode 100644 index 000000000..c6ad37aa6 --- /dev/null +++ b/scripts/ci/strix_report_semantics.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Safely import Strix reports and classify contradictory no-finding records.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import sys +from pathlib import Path + +MAX_REPORT_FILE_BYTES = 8 * 1024 * 1024 +MAX_ATTEMPT_BYTES = 64 * 1024 * 1024 +GIT_SHA = re.compile(r"^[0-9a-fA-F]{40}$") +NO_FINDING_PATTERNS = ( + re.compile( + r"^#{1,6}\s+no\b.{0,100}\bvulnerabilit(?:y|ies)\b.{0,40}" + r"\b(?:found|discovered|identified)\b", + re.IGNORECASE | re.MULTILINE, + ), + re.compile( + r"\bno\s+(?:security\s+)?vulnerabilit(?:y|ies)\s+(?:were\s+)?" + r"(?:found|discovered|identified)\b", + re.IGNORECASE, + ), + re.compile( + r"\bn/?a\s*[-:–—]\s*no\s+vulnerabilit(?:y|ies)\s+" + r"(?:found|discovered|identified)\b", + re.IGNORECASE, + ), + re.compile(r"\bno\s+immediate\s+remediation\s+is\s+required\b", re.IGNORECASE), + re.compile(r"\bno\s+exploitable\s+(?:security\s+)?issues?\b", re.IGNORECASE), +) +POSITIVE_FINDING_PATTERNS = ( + re.compile(r"\bproof\s+of\s+concept\b", re.IGNORECASE), + re.compile(r"\breproduction\s+steps?\b", re.IGNORECASE), + re.compile(r"\b(?:successfully\s+)?exploited\b", re.IGNORECASE), + re.compile(r"\bconfirmed\s+(?:authentication\s+)?bypass\b", re.IGNORECASE), + re.compile(r"\bexploit(?:ation)?\s+path\b", re.IGNORECASE), + re.compile( + r"\b(?:attacker|unauthenticated\s+user)\s+" + r"(?:can|could|is\s+able\s+to)\b", + re.IGNORECASE, + ), + re.compile( + r"\ballows?\s+(?:an?\s+)?(?:attacker|unauthenticated\s+user)\b", + re.IGNORECASE, + ), +) + + +def _regular_file(path: Path) -> Path: + """Resolve a bounded regular report file without accepting symlinks.""" + if path.is_symlink() or not path.is_file(): + raise ValueError(f"report must be a regular non-symlink file: {path}") + if path.stat().st_size > MAX_REPORT_FILE_BYTES: + raise ValueError(f"report exceeds size limit: {path}") + return path.resolve(strict=True) + + +def _independent_no_finding_spans(text: str) -> list[tuple[int, int]]: + """Return non-overlapping evidence spans so one sentence counts only once.""" + spans = sorted( + (match.start(), match.end()) + for pattern in NO_FINDING_PATTERNS + for match in pattern.finditer(text) + ) + independent: list[tuple[int, int]] = [] + for start, end in spans: + if independent and start < independent[-1][1]: + previous_start, previous_end = independent[-1] + independent[-1] = (previous_start, max(previous_end, end)) + else: + independent.append((start, end)) + return independent + + +def is_self_negating_report(path: Path) -> bool: + """Return true only for strongly contradictory no-finding pseudo-records.""" + resolved = _regular_file(path) + text = resolved.read_text(encoding="utf-8", errors="replace") + no_finding_evidence = _independent_no_finding_spans(text) + positive_evidence = any(pattern.search(text) for pattern in POSITIVE_FINDING_PATTERNS) + return len(no_finding_evidence) >= 2 and not positive_evidence + + +def _assert_tree_is_regular(root: Path) -> list[Path]: + """Return files below root while rejecting links and special filesystem nodes.""" + if root.is_symlink() or not root.is_dir(): + raise ValueError(f"report root must be a real directory: {root}") + files: list[Path] = [] + for current, directory_names, file_names in os.walk(root, followlinks=False): + current_path = Path(current) + for name in directory_names: + candidate = current_path / name + if candidate.is_symlink() or not candidate.is_dir(): + raise ValueError(f"unsafe report directory: {candidate}") + for name in file_names: + candidate = current_path / name + if candidate.is_symlink() or not candidate.is_file(): + raise ValueError(f"unsafe report file: {candidate}") + if candidate.stat().st_size > MAX_REPORT_FILE_BYTES: + raise ValueError(f"report file exceeds size limit: {candidate}") + files.append(candidate) + return files + + +def _validate_scope_sha(value: str, label: str) -> str | None: + """Validate an optional exact PR scope commit SHA.""" + normalized = value.strip() + if not normalized: + return None + if not GIT_SHA.fullmatch(normalized): + raise ValueError(f"{label} must be an exact 40-character git SHA") + return normalized.lower() + + +def _write_evidence_receipt( + destination: Path, + selected: list[Path], + source: Path, + started_at_epoch: int, + base_sha: str, + head_sha: str, +) -> None: + """Bind imported report hashes to the exact PR base/head evidence scope.""" + normalized_base = _validate_scope_sha(base_sha, "base_sha") + normalized_head = _validate_scope_sha(head_sha, "head_sha") + if bool(normalized_base) != bool(normalized_head): + raise ValueError("base_sha and head_sha must be supplied together") + file_records = [ + { + "path": source_file.relative_to(source).as_posix(), + "sha256": hashlib.sha256(source_file.read_bytes()).hexdigest(), + "size_bytes": source_file.stat().st_size, + } + for source_file in sorted(selected) + ] + receipt = { + "schema_version": 1, + "evidence_kind": "current_attempt_strix_report_import", + "started_at_epoch": started_at_epoch, + "pr_base_sha": normalized_base, + "pr_head_sha": normalized_head, + "files": file_records, + } + encoded = json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode() + receipt_digest = hashlib.sha256(encoded).hexdigest() + receipt_directory = destination / "gate-evidence" + if receipt_directory.is_symlink(): + raise ValueError("gate evidence directory must not be a symlink") + receipt_directory.mkdir(parents=True, exist_ok=True) + receipt_path = receipt_directory / f"{started_at_epoch}-{receipt_digest[:16]}.json" + if receipt_path.exists(): + if receipt_path.is_symlink() or receipt_path.read_bytes() != encoded: + raise ValueError("conflicting Strix gate evidence receipt") + return + receipt_path.write_bytes(encoded) + receipt_path.chmod(0o600) + + +def import_current_attempt_reports( + source_root: Path, + destination_root: Path, + started_at_epoch: int, + base_sha: str = "", + head_sha: str = "", +) -> int: + """Copy current-attempt regular files and bind them to the PR evidence scope.""" + if not source_root.exists(): + return 0 + source = source_root.resolve(strict=True) + destination_root.mkdir(parents=True, exist_ok=True) + destination = destination_root.resolve(strict=True) + if source == destination: + return 0 + files = _assert_tree_is_regular(source) + minimum_mtime = max(0, started_at_epoch - 2) + selected = [path for path in files if int(path.stat().st_mtime) >= minimum_mtime] + total_bytes = sum(path.stat().st_size for path in selected) + if total_bytes > MAX_ATTEMPT_BYTES: + raise ValueError("current Strix attempt reports exceed aggregate size limit") + copied = 0 + for source_file in selected: + relative = source_file.relative_to(source) + destination_file = destination / relative + destination_file.parent.mkdir(parents=True, exist_ok=True) + if destination_file.exists(): + if destination_file.is_symlink() or not destination_file.is_file(): + raise ValueError(f"unsafe destination report path: {destination_file}") + if destination_file.read_bytes() != source_file.read_bytes(): + raise ValueError(f"conflicting report copies: {relative}") + continue + shutil.copyfile(source_file, destination_file, follow_symlinks=False) + destination_file.chmod(0o600) + copied += 1 + if selected: + _write_evidence_receipt( + destination, + selected, + source, + started_at_epoch, + base_sha, + head_sha, + ) + return copied + + +def main(argv: list[str]) -> int: + """Expose narrow shell-safe commands for the Strix gate.""" + try: + if len(argv) == 3 and argv[1] == "is-self-negating": + return 0 if is_self_negating_report(Path(argv[2])) else 1 + if len(argv) == 7 and argv[1] == "import-current-attempt": + copied = import_current_attempt_reports( + Path(argv[2]), + Path(argv[3]), + int(argv[4]), + argv[5], + argv[6], + ) + print(copied) + return 0 + except (OSError, ValueError, UnicodeError) as exc: + print(f"Strix report semantics validation failed: {exc}", file=sys.stderr) + return 2 + print( + f"usage: {argv[0]} is-self-negating | " + "import-current-attempt " + " ", + file=sys.stderr, + ) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/ci/strix_required_workflow_smoke.sh b/scripts/ci/strix_required_workflow_smoke.sh index 8cd6dddad..00421dbb1 100755 --- a/scripts/ci/strix_required_workflow_smoke.sh +++ b/scripts/ci/strix_required_workflow_smoke.sh @@ -136,9 +136,14 @@ assert_file_contains "$workflow_file" 'repository: ${{ steps.trusted_source.outp assert_file_contains "$workflow_file" 'ref: ${{ steps.trusted_source.outputs.ref }}' "Strix workflow checks out resolved central ref" assert_file_contains "$workflow_file" "Materialize central Strix dependency lock from PR head" "Strix workflow validates same-repo central lock-file PRs against the PR head lock" assert_file_contains "$workflow_file" "requirements-strix-ci-hashes.txt" "Strix workflow can materialize the central Strix hashed requirements lock" +assert_file_contains "$workflow_file" "--require-hashes --no-deps -r requirements-strix-ci-hashes.txt" "Strix workflow installs the complete hashed lock without dependency re-resolution" +assert_file_contains "$workflow_file" "validate_strix_runtime_compatibility.py" "Strix workflow verifies exact pins and executable cryptographic consumers" 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_not_contains "$workflow_file" "Treating as a neutral skip" "Strix required wrapper never converts incomplete scans to success" +assert_file_contains "$gate_script" "import-current-attempt" "Strix gate imports authoritative current-attempt reports before judgment" +assert_file_contains "$gate_script" "vulnerability_file_is_self_negating_no_finding" "Strix gate recognizes contradictory no-finding pseudo-records" 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" diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 7343c06ac..0e9ab5925 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -203,6 +203,8 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" 'repository: ${{ steps.trusted_source.outputs.repository }}' "strix workflow checks out central Strix scripts instead of target-repo copies" assert_file_contains "$workflow_file" 'ref: ${{ steps.trusted_source.outputs.ref }}' "strix workflow checks out the exact trusted Strix source ref" assert_file_contains "$workflow_file" "Materialize central Strix dependency lock from PR head" "strix workflow validates central same-repo lock-file PRs against the PR head lock" + assert_file_contains "$workflow_file" "--require-hashes --no-deps -r requirements-strix-ci-hashes.txt" "strix workflow installs the complete hashed lock without dependency re-resolution" + assert_file_contains "$workflow_file" "validate_strix_runtime_compatibility.py" "strix workflow executes exact-pin and crypto consumer smoke checks" assert_file_contains "$workflow_file" "github.event.pull_request.head.repo.full_name == 'ContextualWisdomLab/.github'" "strix workflow limits central lock materialization to same-repository PR heads" assert_file_contains "$workflow_file" 'git -C "$TRUSTED_WORKSPACE" show "$PR_HEAD_SHA:requirements-strix-ci-hashes.txt"' "strix workflow copies only the hashed requirements lock from the PR head" assert_file_contains "$workflow_file" 'TRUSTED_STRIX_SOURCE=$trusted_strix_source' "strix workflow exports the central Strix source path" @@ -225,6 +227,9 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_not_contains "$workflow_file" "bash \"\$TRUSTED_STRIX_GATE_TEST\"" "strix required path does not execute the full long-form gate harness" 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_not_contains "$workflow_file" "Treating as a neutral skip" "strix workflow keeps incomplete provider scans fail-closed" + assert_file_contains "$GATE_SCRIPT" "import-current-attempt" "strix gate imports authoritative current-attempt reports" + assert_file_contains "$GATE_SCRIPT" "vulnerability_file_is_self_negating_no_finding" "strix gate semantically excludes contradictory no-finding records" assert_file_contains "$workflow_file" "scan-summary.txt" "strix workflow creates a fallback artifact when Strix emits no report files" local checkout_count checkout_count="$(grep -Fc "uses: actions/checkout@" "$workflow_file")" @@ -725,7 +730,9 @@ 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_not_contains "$REPO_ROOT/.github/workflows/strix.yml" "backend_unavailable_signal=" "strix wrapper does not neutralize incomplete provider scans" + assert_file_not_contains "$REPO_ROOT/.github/workflows/strix.yml" "Treating as a neutral skip" "strix wrapper keeps provider outages fail-closed" + assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" "Strix did not produce complete passing security evidence" "strix wrapper preserves incomplete-scan failures" 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" diff --git a/scripts/ci/validate_strix_runtime_compatibility.py b/scripts/ci/validate_strix_runtime_compatibility.py new file mode 100644 index 000000000..fece6cd16 --- /dev/null +++ b/scripts/ci/validate_strix_runtime_compatibility.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Validate the exact Strix lock and exercised cryptographic consumers.""" + +from __future__ import annotations + +import re +import sys +from importlib import metadata +from pathlib import Path + +REQUIRED_DISTRIBUTIONS = ("strix-agent", "cryptography") +EXACT_PIN = re.compile(r"^(?P[A-Za-z0-9_.-]+)==(?P[^\s\\]+)") + + +def required_runtime_pins(lock_path: Path) -> dict[str, str]: + """Return the exact Strix and cryptography pins from a compiled lock.""" + pins: dict[str, str] = {} + for raw_line in lock_path.read_text(encoding="utf-8").splitlines(): + match = EXACT_PIN.match(raw_line.strip()) + if match and match.group("name").lower() in REQUIRED_DISTRIBUTIONS: + pins[match.group("name").lower()] = match.group("version") + missing = set(REQUIRED_DISTRIBUTIONS) - pins.keys() + if missing: + raise ValueError(f"compiled lock is missing exact pins: {sorted(missing)}") + return pins + + +def verify_installed_versions(expected: dict[str, str]) -> None: + """Fail when the installed environment differs from the compiled lock.""" + for distribution, expected_version in expected.items(): + actual_version = metadata.version(distribution) + if actual_version != expected_version: + raise RuntimeError( + f"installed {distribution}=={actual_version}; expected {expected_version}" + ) + + +def exercise_cryptographic_consumers() -> None: + """Exercise the PyJWT and pyOpenSSL APIs used through Strix dependencies.""" + import jwt + from OpenSSL import crypto + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + public_pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + encoded = jwt.encode({"sub": "strix-runtime-smoke"}, private_pem, algorithm="RS256") + decoded = jwt.decode(encoded, public_pem, algorithms=["RS256"]) + if decoded.get("sub") != "strix-runtime-smoke": + raise RuntimeError("PyJWT RS256 round trip returned unexpected claims") + + openssl_key = crypto.PKey() + openssl_key.generate_key(crypto.TYPE_RSA, 2048) + certificate = crypto.X509() + certificate.set_version(2) + certificate.set_serial_number(1) + certificate.get_subject().CN = "strix-runtime-smoke" + certificate.gmtime_adj_notBefore(0) + certificate.gmtime_adj_notAfter(60) + certificate.set_issuer(certificate.get_subject()) + certificate.set_pubkey(openssl_key) + certificate.sign(openssl_key, "sha256") + pem = crypto.dump_certificate(crypto.FILETYPE_PEM, certificate) + if b"BEGIN CERTIFICATE" not in pem: + raise RuntimeError("pyOpenSSL certificate round trip failed") + + +def main(argv: list[str]) -> int: + """Run exact-version and executable compatibility checks.""" + if len(argv) != 2: + print(f"usage: {argv[0]} ", file=sys.stderr) + return 2 + try: + expected = required_runtime_pins(Path(argv[1])) + verify_installed_versions(expected) + exercise_cryptographic_consumers() + except (OSError, ValueError, RuntimeError, metadata.PackageNotFoundError, ImportError) as exc: + print(f"Strix runtime compatibility validation failed: {exc}", file=sys.stderr) + return 1 + print( + "Validated exact Strix/cryptography pins plus PyJWT RS256 and pyOpenSSL crypto APIs." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/test_github_hourly_conflict_repair.py b/tests/test_github_hourly_conflict_repair.py new file mode 100644 index 000000000..e905bbce8 --- /dev/null +++ b/tests/test_github_hourly_conflict_repair.py @@ -0,0 +1,133 @@ +"""Regression contracts for unattended OpenCode merge-conflict repair.""" + +from pathlib import Path +from typing import Any + +import pytest + +from scripts.ci import pr_review_fix_scheduler as scheduler + + +_CALLER = Path(".github/workflows/github-hourly-review-repair.yml") +_REUSABLE_SCHEDULER = Path(".github/workflows/pr-review-fix-scheduler.yml") + + +def _unreviewed_conflict() -> dict[str, object]: + """Return a same-repository PR whose current head has no review yet.""" + return { + "number": 1098, + "isDraft": False, + "baseRefName": "main", + "baseRefOid": "b" * 40, + "headRefName": "feature/conflict", + "headRefOid": "a" * 40, + "headRepository": {"nameWithOwner": "ContextualWisdomLab/.github"}, + "mergeStateStatus": "DIRTY", + "reviews": {"nodes": []}, + "reviewThreads": {"nodes": []}, + } + + +def test_explicit_policy_dispatches_unreviewed_conflict() -> None: + """Conflict repair must not wait for an approval invalidated by its own commit.""" + needs_repair, reasons = scheduler.needs_conflict_resolution( + _unreviewed_conflict(), + allow_unreviewed=True, + ) + + assert needs_repair + assert "fresh review and checks" in reasons[0] + + +def test_scheduler_dispatches_conflict_mode_for_unreviewed_head( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The trusted queue must reach the existing bounded conflict worker.""" + arguments = scheduler.parse_args( + [ + "--repo", + "ContextualWisdomLab/.github", + "--base-branch", + "main", + "--resolve-unreviewed-conflicts", + "--dry-run", + ] + ) + captured: dict[str, Any] = {} + + def capture_dispatch(_repo: str, _pr: dict[str, Any], **kwargs: Any) -> None: + """Capture dispatch arguments without invoking GitHub.""" + captured.update(kwargs) + + monkeypatch.setattr(scheduler, "dispatch_autofix", capture_dispatch) + monkeypatch.setattr( + scheduler, + "create_fix_marker", + lambda *_args, **_kwargs: None, + ) + + action, reasons = scheduler.inspect_pr( + "ContextualWisdomLab/.github", + _unreviewed_conflict(), + arguments, + comments=[], + ) + + assert action == "dispatch" + assert "fresh review and checks" in reasons[0] + assert captured["resolve_conflict"] is True + + +def test_default_library_policy_remains_backward_compatible() -> None: + """Direct library callers retain the prior approval requirement unless opted in.""" + assert scheduler.needs_conflict_resolution(_unreviewed_conflict()) == (False, ()) + + +def test_cli_exposes_unreviewed_conflict_policy() -> None: + """The trusted workflow can opt into unreviewed conflict repair explicitly.""" + arguments = scheduler.parse_args( + [ + "--repo", + "ContextualWisdomLab/.github", + "--base-branch", + "main", + "--resolve-unreviewed-conflicts", + ] + ) + + assert arguments.resolve_unreviewed_conflicts is True + + +def test_reusable_scheduler_enables_policy_for_hourly_callers() -> None: + """Central callers receive conflict repair by default without duplicating logic.""" + workflow = _REUSABLE_SCHEDULER.read_text(encoding="utf-8") + + assert "resolve_unreviewed_conflicts:" in workflow + policy_block = workflow.split("resolve_unreviewed_conflicts:", maxsplit=1)[1].split( + "retry_hours:", maxsplit=1 + )[0] + assert "default: true" in policy_block + assert "--resolve-unreviewed-conflicts" in workflow + + +def test_central_repository_has_hourly_self_caller() -> None: + """The central repository itself is scanned instead of relying on product callers.""" + workflow = _CALLER.read_text(encoding="utf-8") + + assert 'cron: "21 * * * *"' in workflow + assert "uses: ./.github/workflows/pr-review-fix-scheduler.yml" in workflow + assert "target_repository: ContextualWisdomLab/.github" in workflow + assert "base_branch: main" in workflow + assert "resolve_unreviewed_conflicts: true" in workflow + assert 'max_dispatches: "1"' in workflow + assert 'retry_hours: "1"' in workflow + assert "COPILOT_GITHUB_TOKEN" not in workflow + + +def test_scheduled_self_target_does_not_require_cross_repository_allowlist() -> None: + """A protected same-repository schedule is valid even without cross-repo config.""" + workflow = _REUSABLE_SCHEDULER.read_text(encoding="utf-8") + + assert 'if [ -n "${GITHUB_REPOSITORY:-}" ] &&' in workflow + assert '[ "$TARGET_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then' in workflow + assert "Self-targeted scheduler invocation uses the protected caller repository." in workflow diff --git a/tests/test_nonnest2_hourly_review_caller.py b/tests/test_nonnest2_hourly_review_caller.py new file mode 100644 index 000000000..0830c0870 --- /dev/null +++ b/tests/test_nonnest2_hourly_review_caller.py @@ -0,0 +1,166 @@ +"""Contract tests for nonnest2's bounded hourly review-repair caller.""" + +from pathlib import Path + + +CALLER = Path(".github/workflows/nonnest2-hourly-review-repair.yml") +DOCTORING = Path("docs/doctoring/nonnest2-hourly-review-caller.md") +QUALITY_WORKFLOW = Path(".github/workflows/hourly-nvidia-nim-review-repair.yml") +SCHEDULER = Path(".github/workflows/pr-review-fix-scheduler.yml") + + +def _read(path: Path) -> str: + """Return one repository contract file as UTF-8 text.""" + return path.read_text(encoding="utf-8") + + +def _yaml_path_entries(block: str) -> set[str]: + """Return dashed YAML path entries from one trigger or compileall block.""" + entries: set[str] = set() + for raw_line in block.splitlines(): + stripped = raw_line.strip() + if stripped.startswith("- "): + entries.add(stripped[2:].strip()) + elif stripped.startswith("tests/") or stripped.startswith("scripts/"): + entries.add(stripped.rstrip(" \\")) + return entries + + +def _trigger_path_block(quality: str, trigger: str) -> str: + """Return the dashed path list under one named workflow trigger.""" + marker = f" {trigger}:\n paths:\n" + start = quality.index(marker) + len(marker) + lines: list[str] = [] + for line in quality[start:].splitlines(): + if line.startswith(" - "): + lines.append(line) + continue + if line.strip() == "": + continue + break + return "\n".join(lines) + + +def _compileall_block(quality: str) -> str: + """Return the compileall argument list from the focused quality job.""" + marker = "python -m compileall -q \\" + start = quality.index(marker) + remainder = quality[start:] + end = remainder.find("\n git ") + return remainder if end < 0 else remainder[:end] + + +def test_nonnest2_caller_is_hourly_bounded_and_non_cancelling() -> None: + """nonnest2 receives one realistic Vuong-test repair without cancellation.""" + caller = _read(CALLER) + + assert 'cron: "16 * * * *"' in caller + assert "group: nonnest2-hourly-review-repair" in caller + assert "cancel-in-progress: false" in caller + assert "uses: ./.github/workflows/pr-review-fix-scheduler.yml" in caller + assert "target_repository: ContextualWisdomLab/nonnest2" in caller + assert "base_branch: master" in caller + assert 'max_prs: "50"' in caller + assert 'max_dispatches: "1"' in caller + assert 'retry_hours: "2"' in caller + + +def test_nonnest2_caller_preserves_oidc_and_explicit_secret_scope() -> None: + """The queue scanner maps established credentials without model secrets.""" + caller = _read(CALLER) + workflow_scope, jobs_scope = caller.split("\njobs:\n", maxsplit=1) + + assert "\npermissions:\n contents: read\n" in workflow_scope + assert ( + "\n permissions:\n contents: read\n id-token: write\n" + in jobs_scope + ) + assert "PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }}" in caller + assert "OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }}" in caller + assert "secrets: inherit" not in caller + assert "NVIDIA_NIM_API_KEY" not in caller + assert "COPILOT_GITHUB_TOKEN" not in caller + for forbidden in ( + "actions: write", + "contents: write", + "issues: write", + "pull-requests: write", + "statuses: write", + ): + assert forbidden not in caller + + +def test_nonnest2_target_is_not_hard_coded_in_shared_scheduler() -> None: + """Product identity remains in the thin caller rather than the engine.""" + assert "ContextualWisdomLab/nonnest2" not in _read(SCHEDULER) + + +def test_nonnest2_doctoring_records_vuong_activation_and_credentials() -> None: + """Operators retain target-allowlist, Vuong tests, and approval prerequisites.""" + doctoring = _read(DOCTORING) + + for phrase in ( + "ContextualWisdomLab/nonnest2", + "OPENCODE_REPOSITORY_DISPATCH_TARGETS", + "independent non-author approval", + "NVIDIA_NIM_API_KEY", + "COPILOT_GITHUB_TOKEN", + "id-token: write", + "two-hour same-head retry floor", + "root-cause analysis", + "remediation feasibility", + "protected-master operational acceptance", + "APA 7th references", + "ContextualWisdomLab/nonnest2#89", + "ContextualWisdomLab/nonnest2#86", + "ContextualWisdomLab/nonnest2#84", + "ContextualWisdomLab/nonnest2#90", + ): + assert phrase in doctoring + + +def test_path_block_helpers_keep_trigger_and_compileall_sets_disjoint() -> None: + """A path listed only under push or compileall must not satisfy pull_request.""" + quality = ( + "on:\n" + " pull_request:\n" + " paths:\n" + " - .github/workflows/nonnest2-hourly-review-repair.yml\n" + " push:\n" + " paths:\n" + " - docs/doctoring/nonnest2-hourly-review-caller.md\n" + " python -m compileall -q \\\n" + " tests/test_nonnest2_hourly_review_caller.py\n" + " git diff --check\n" + ) + + pull_request_paths = _yaml_path_entries(_trigger_path_block(quality, "pull_request")) + push_paths = _yaml_path_entries(_trigger_path_block(quality, "push")) + compileall_paths = _yaml_path_entries(_compileall_block(quality)) + + assert pull_request_paths == {".github/workflows/nonnest2-hourly-review-repair.yml"} + assert push_paths == {"docs/doctoring/nonnest2-hourly-review-caller.md"} + assert compileall_paths == {"tests/test_nonnest2_hourly_review_caller.py"} + assert "docs/doctoring/nonnest2-hourly-review-caller.md" not in pull_request_paths + assert ".github/workflows/nonnest2-hourly-review-repair.yml" not in compileall_paths + + +def test_focused_quality_workflow_tracks_nonnest2_contracts() -> None: + """Caller, test, and doctoring edits always rerun the focused gate.""" + quality = _read(QUALITY_WORKFLOW) + pull_request_paths = _yaml_path_entries(_trigger_path_block(quality, "pull_request")) + push_paths = _yaml_path_entries(_trigger_path_block(quality, "push")) + compileall_paths = _yaml_path_entries(_compileall_block(quality)) + caller = ".github/workflows/nonnest2-hourly-review-repair.yml" + doctoring = "docs/doctoring/nonnest2-hourly-review-caller.md" + contract = "tests/test_nonnest2_hourly_review_caller.py" + + assert caller in pull_request_paths + assert doctoring in pull_request_paths + assert contract in pull_request_paths + assert caller in push_paths + assert doctoring in push_paths + assert contract in push_paths + assert contract in compileall_paths + assert caller not in compileall_paths + assert doctoring not in compileall_paths diff --git a/tests/test_repository_branch_coverage_review_schedulers.py b/tests/test_repository_branch_coverage_review_schedulers.py index 8ee58db12..d50f94f05 100644 --- a/tests/test_repository_branch_coverage_review_schedulers.py +++ b/tests/test_repository_branch_coverage_review_schedulers.py @@ -138,7 +138,9 @@ def test_fix_scheduler_queue_includes_eligible_pr_without_fix_need( monkeypatch.setattr(fix_scheduler, "same_repository_head", lambda *_args: True) monkeypatch.setattr(fix_scheduler, "needs_autofix", lambda _pr: (False, ())) monkeypatch.setattr( - fix_scheduler, "needs_conflict_resolution", lambda _pr: (False, ()) + fix_scheduler, + "needs_conflict_resolution", + lambda _pr, **_kwargs: (False, ()), ) monkeypatch.setattr( fix_scheduler, "inspect_pr", lambda *_args, **_kwargs: ("skip", ("clean",)) diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 233c08584..d36fc9a3c 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -1079,21 +1079,16 @@ 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_remains_fail_closed() -> None: 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 "backend_unavailable_signal=" not in workflow + assert "reported_vulnerability_signal=" not in workflow + assert "Treating as a neutral skip" not in workflow + assert "Nvidia_nimException" 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 - assert ( - '&& ! grep -Eiq "$reported_vulnerability_signal" "$strix_run_log"' in workflow - ) + assert "Strix did not produce complete passing security evidence" in workflow + assert 'exit "$strix_rc"' in workflow def test_strix_cross_repo_dispatch_uses_target_token_for_pr_scoping() -> None: diff --git a/tests/test_strix_952_runtime_contract.py b/tests/test_strix_952_runtime_contract.py new file mode 100644 index 000000000..dc330be5c --- /dev/null +++ b/tests/test_strix_952_runtime_contract.py @@ -0,0 +1,154 @@ +"""Regression contracts for issue 952's trusted Strix runtime boundary.""" + +from __future__ import annotations + +import importlib.util +import os +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "strix.yml" +GATE = ROOT / "scripts" / "ci" / "strix_quick_gate.sh" +SEMANTICS = ROOT / "scripts" / "ci" / "strix_report_semantics.py" +VALIDATOR = ROOT / "scripts" / "ci" / "validate_strix_runtime_compatibility.py" + + +def load_module(path: Path, name: str): + """Load a repository script as a module without changing sys.path.""" + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise AssertionError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class WorkflowContractTests(unittest.TestCase): + """Protect the base-executed pull_request_target installation path.""" + + def test_complete_hashed_lock_is_installed_without_reresolution(self) -> None: + """The base workflow must consume the closed lock without pip resolution.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn( + "--require-hashes --no-deps -r requirements-strix-ci-hashes.txt", + workflow, + ) + self.assertIn("validate_strix_runtime_compatibility.py", workflow) + + def test_incomplete_backend_scan_cannot_become_success(self) -> None: + """No provider outage may turn an incomplete required scan into success.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertNotIn("Treating as a neutral skip", workflow) + self.assertNotIn("backend_unavailable_signal=", workflow) + self.assertIn('exit "$strix_rc"', workflow) + + def test_gate_imports_authoritative_target_reports_before_judgment(self) -> None: + """The gate must evaluate reports written under the exact scan target.""" + gate = GATE.read_text(encoding="utf-8") + self.assertIn("import-current-attempt", gate) + self.assertIn('"${resolved_target_path%/}/strix_runs"', gate) + self.assertIn("vulnerability_file_is_self_negating_no_finding", gate) + + +class ReportSemanticsTests(unittest.TestCase): + """Verify narrow contradictory no-finding classification and safe import.""" + + def setUp(self) -> None: + """Load the standalone semantics module for each isolated test.""" + self.module = load_module(SEMANTICS, "strix_report_semantics") + + def test_scopeweave_self_negating_high_record_is_not_a_finding(self) -> None: + """HIGH metadata cannot override multiple explicit no-finding claims.""" + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "finding.md" + report.write_text( + "# No JWT Authentication Vulnerabilities Found\n\n" + "Severity: HIGH\nCVSS: 7.3\n\n" + "N/A - No vulnerabilities discovered.\n" + "No immediate remediation is required.\n", + encoding="utf-8", + ) + self.assertTrue(self.module.is_self_negating_report(report)) + + def test_real_finding_with_negative_control_language_is_preserved(self) -> None: + """Positive exploit and source evidence defeat no-finding classification.""" + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "finding.md" + report.write_text( + "# JWT Authentication Bypass\n\nSeverity: HIGH\n" + "The negative control found no issue, but the affected endpoint " + "accepts an unsigned token.\nTarget: backend/auth.py:42\n" + "Proof of concept: send alg=none.\n", + encoding="utf-8", + ) + self.assertFalse(self.module.is_self_negating_report(report)) + + def test_single_no_remediation_phrase_is_not_enough(self) -> None: + """A real informational finding cannot be suppressed by one mild phrase.""" + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "finding.md" + report.write_text( + "# Weak hardening recommendation\nSeverity: LOW\n" + "No immediate remediation is required, but the affected component " + "should be hardened.\nLocation: backend/config.py:10\n", + encoding="utf-8", + ) + self.assertFalse(self.module.is_self_negating_report(report)) + + def test_current_attempt_import_rejects_symlinked_output(self) -> None: + """Untrusted report trees cannot escape through a symlink.""" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + destination = root / "destination" + outside = root / "outside.md" + source.mkdir() + destination.mkdir() + outside.write_text("Severity: HIGH", encoding="utf-8") + vulnerabilities = source / "run" / "vulnerabilities" + vulnerabilities.mkdir(parents=True) + (vulnerabilities / "escape.md").symlink_to(outside) + with self.assertRaises(ValueError): + self.module.import_current_attempt_reports(source, destination, 0) + + def test_current_attempt_import_ignores_stale_files(self) -> None: + """Only files created or changed during this attempt enter evaluation.""" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + destination = root / "destination" + vulnerabilities = source / "run" / "vulnerabilities" + vulnerabilities.mkdir(parents=True) + report = vulnerabilities / "stale.md" + report.write_text("Severity: HIGH", encoding="utf-8") + os.utime(report, (1, 1)) + copied = self.module.import_current_attempt_reports( + source, destination, started_at_epoch=10 + ) + self.assertEqual(copied, 0) + self.assertFalse((destination / "run" / "vulnerabilities" / "stale.md").exists()) + + +class RuntimeCompatibilityTests(unittest.TestCase): + """Verify exact-pin parsing independently of installed third-party modules.""" + + def test_lock_parser_reads_exact_strix_and_cryptography_pins(self) -> None: + """The runtime smoke must attest the versions present in the hash lock.""" + module = load_module(VALIDATOR, "validate_strix_runtime_compatibility") + with tempfile.TemporaryDirectory() as directory: + lock = Path(directory) / "requirements.txt" + lock.write_text( + "strix-agent==1.5.3 --hash=sha256:abc\n" + "cryptography==50.0.0 --hash=sha256:def\n", + encoding="utf-8", + ) + self.assertEqual( + module.required_runtime_pins(lock), + {"strix-agent": "1.5.3", "cryptography": "50.0.0"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strix_nvidia_nim_not_found_fallback.py b/tests/test_strix_nvidia_nim_not_found_fallback.py index a48f3092d..d6c443ed6 100644 --- a/tests/test_strix_nvidia_nim_not_found_fallback.py +++ b/tests/test_strix_nvidia_nim_not_found_fallback.py @@ -73,52 +73,6 @@ 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.""" - - 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", - ) - with tempfile.TemporaryDirectory(prefix="strix-workflow-404-") 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, - ) - vulnerability = subprocess.run( - ["grep", "-Eiq", vulnerability_pattern, 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 - - class StrixNvidiaNotFoundFallbackTests(unittest.TestCase): """Protect provider-scoped 404 fallback without weakening security gates.""" @@ -199,63 +153,18 @@ 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.""" - - 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" - ) - ) - - 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" - ) - ) - - def test_workflow_neutralizes_only_nvidia_404_without_findings(self) -> None: - """Retain the static fail-closed vulnerability evidence contract.""" + def test_outer_workflow_keeps_provider_failures_fail_closed(self) -> None: + """Provider fallback belongs inside the gate; its required wrapper preserves rc.""" 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.assertNotIn("backend_unavailable_signal=", workflow) + self.assertNotIn("reported_vulnerability_signal=", workflow) + self.assertNotIn("Treating as a neutral skip", workflow) + self.assertIn('exit "$strix_rc"', workflow) self.assertIn( - '! grep -Eiq "$reported_vulnerability_signal"', + "Strix did not produce complete passing security evidence", workflow, ) - if __name__ == "__main__": unittest.main()