diff --git a/.github/workflows/agent-mention-router.yml b/.github/workflows/agent-mention-router.yml new file mode 100644 index 000000000..f24f56a4f --- /dev/null +++ b/.github/workflows/agent-mention-router.yml @@ -0,0 +1,187 @@ +name: Review Agent Mention Router + +on: + issue_comment: + types: [created] + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: + +concurrency: + group: review-agent-mention-router-${{ github.repository }} + cancel-in-progress: false + +# Organization required-workflow rules do not propagate issue_comment events +# into sibling repositories. Keep the workflow default read-only; each bounded +# job declares only the writes it actually needs. +permissions: + contents: read + +jobs: + route-local-agent-mention: + if: >- + github.repository == 'ContextualWisdomLab/.github' + && github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.user.type != 'Bot' + && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + && ( + contains(github.event.comment.body, '@cwl-noema-review') + || contains(github.event.comment.body, '@opencode-agent') + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + issues: write + pull-requests: read + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GH_TOKEN: ${{ github.token }} + TARGET_REPOSITORY_TOKEN: ${{ github.token }} + AGENT_DISPATCH_TOKEN: ${{ github.token }} + OPENCODE_REPOSITORY_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} + steps: + - name: Check out trusted default-branch router + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Resolve immutable pull-request head and prior receipts + env: + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + SOURCE_EVENT_PATH: ${{ github.event_path }} + run: | + set -euo pipefail + pr_json="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" + comments_json="$( + gh api --paginate --slurp \ + "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100" \ + | jq -c 'add // []' + )" + jq \ + --argjson pull_request "$pr_json" \ + --argjson conversation_comments "$comments_json" \ + '. + { + pull_request: $pull_request, + conversation_comments: $conversation_comments + }' \ + "$SOURCE_EVENT_PATH" >"${RUNNER_TEMP}/agent-mention-event.json" + + - name: Route trusted local agent mention + run: >- + python3 scripts/ci/agent_mention_router.py + --event-path "${RUNNER_TEMP}/agent-mention-event.json" + + sweep-organization-agent-mentions: + if: >- + github.repository == 'ContextualWisdomLab/.github' + && ( + github.event_name == 'schedule' + || github.event_name == 'workflow_dispatch' + ) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + id-token: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + OPENCODE_REPOSITORY_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} + LOOKBACK_HOURS: ${{ vars.AGENT_MENTION_LOOKBACK_HOURS || '168' }} + MAX_DISPATCHES: ${{ vars.AGENT_MENTION_MAX_DISPATCHES || '20' }} + DRY_RUN: "false" + steps: + - name: Exchange OpenCode app token for sibling-repository comments + id: sweep_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + USER_TOKEN_CONFIGURED: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '' }} + run: | + set -euo pipefail + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + if [ "$USER_TOKEN_CONFIGURED" = "true" ]; then + echo "A configured cross-repository user token takes precedence." + mark_unavailable + exit 0 + fi + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OpenCode app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + if ! oidc_response="$( + curl -fsS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + echo "OpenCode app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 + fi + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "OpenCode app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 + fi + if ! token_response="$( + curl -fsS \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" + )"; then + echo "OpenCode app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 + fi + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "OpenCode app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 + fi + echo "::add-mask::$app_token" + { + echo "available=true" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + + - name: Check out trusted central router + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Sweep recent organization PR comments + env: + TARGET_REPOSITORY_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.sweep_app_token.outputs.token }} + TARGET_REPOSITORY_SOURCE: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'organization' || steps.sweep_app_token.outputs.available == 'true' && 'installation' || '' }} + AGENT_DISPATCH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ -z "${TARGET_REPOSITORY_TOKEN:-}" ] || [ -z "${TARGET_REPOSITORY_SOURCE:-}" ]; then + echo "::error::Agent mention sweep requires PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the OpenCode app token exchange." + exit 1 + fi + args=( + --organization ContextualWisdomLab + --repository-source "$TARGET_REPOSITORY_SOURCE" + --lookback-hours "$LOOKBACK_HOURS" + --max-dispatches "$MAX_DISPATCHES" + ) + if [ "$DRY_RUN" = "true" ]; then + args+=(--dry-run) + fi + python3 scripts/ci/agent_mention_sweep.py "${args[@]}" diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 2a170fa8a..cda5e7f62 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -90,13 +90,13 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: category: "/language:${{ matrix.language }}" upload: false @@ -197,13 +197,13 @@ jobs: ref: ${{ format('refs/pull/{0}/merge', github.event.pull_request.number) }} - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: category: "/language:${{ matrix.language }}-merge" upload: false diff --git a/.github/workflows/control-plane-quality-ci.yml b/.github/workflows/control-plane-quality-ci.yml new file mode 100644 index 000000000..9306c10d1 --- /dev/null +++ b/.github/workflows/control-plane-quality-ci.yml @@ -0,0 +1,205 @@ +name: Central Control Plane Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/agent-mention-router.yml" + - ".github/workflows/control-plane-quality-ci.yml" + - ".github/workflows/pr-review-fix-scheduler.yml" + - ".github/workflows/sbom-generation.yml" + - "requirements-opencode-review-ci-hashes.txt" + - "scripts/ci/agent_mention_router.py" + - "scripts/ci/agent_mention_sweep.py" + - "scripts/ci/install_base_python_locks.py" + - "scripts/ci/javascript_coverage_gate.py" + - "scripts/ci/redact_sensitive_log.py" + - "scripts/ci/sandboxed_verify.py" + - "scripts/ci/sandboxed_web_e2e.py" + - "tests/conftest.py" + - "tests/test_agent_mention*.py" + - "tests/test_control_plane_coverage_closure.py" + - "tests/test_control_plane_quality_coverage_gaps.py" + - "tests/test_control_plane_quality_workflow_contract.py" + - "tests/test_install_base_python*.py" + - "tests/test_javascript_coverage*.py" + - "tests/test_opencode_security_boundaries.py" + - "tests/test_pr_review_fix*.py" + - "tests/test_redact*.py" + - "tests/test_sandboxed*.py" + - "tests/test_sbom_generation_push_contract.py" + - "tests/test_unstructured_separate_option_redaction.py" + push: + branches: [main] + paths: + - ".github/workflows/agent-mention-router.yml" + - ".github/workflows/control-plane-quality-ci.yml" + - ".github/workflows/pr-review-fix-scheduler.yml" + - ".github/workflows/sbom-generation.yml" + - "requirements-opencode-review-ci-hashes.txt" + - "scripts/ci/agent_mention_router.py" + - "scripts/ci/agent_mention_sweep.py" + - "scripts/ci/install_base_python_locks.py" + - "scripts/ci/javascript_coverage_gate.py" + - "scripts/ci/redact_sensitive_log.py" + - "scripts/ci/sandboxed_verify.py" + - "scripts/ci/sandboxed_web_e2e.py" + - "tests/conftest.py" + - "tests/test_agent_mention*.py" + - "tests/test_control_plane_coverage_closure.py" + - "tests/test_control_plane_quality_coverage_gaps.py" + - "tests/test_control_plane_quality_workflow_contract.py" + - "tests/test_install_base_python*.py" + - "tests/test_javascript_coverage*.py" + - "tests/test_opencode_security_boundaries.py" + - "tests/test_pr_review_fix*.py" + - "tests/test_redact*.py" + - "tests/test_sandboxed*.py" + - "tests/test_sbom_generation_push_contract.py" + - "tests/test_unstructured_separate_option_redaction.py" + +concurrency: + group: control-plane-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + minimum-python-contract: + name: Python 3.10 runtime contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up minimum supported Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + + - name: Compile central production modules + run: | + python -m compileall -q \ + scripts/ci/agent_mention_router.py \ + scripts/ci/agent_mention_sweep.py \ + scripts/ci/install_base_python_locks.py \ + scripts/ci/javascript_coverage_gate.py \ + scripts/ci/redact_sensitive_log.py \ + scripts/ci/sandboxed_verify.py \ + scripts/ci/sandboxed_web_e2e.py + + full-quality-gate: + name: Python 3.14 full quality gate + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up current stable Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install hash-locked quality tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Run control-plane tests with complete branch coverage + run: | + cat >"${RUNNER_TEMP}/control-plane-coveragerc" <<'EOF' + [run] + branch = True + include = + scripts/ci/agent_mention_router.py + scripts/ci/agent_mention_sweep.py + scripts/ci/install_base_python_locks.py + scripts/ci/javascript_coverage_gate.py + scripts/ci/redact_sensitive_log.py + scripts/ci/sandboxed_verify.py + scripts/ci/sandboxed_web_e2e.py + + [report] + fail_under = 100 + show_missing = True + EOF + export COVERAGE_RCFILE="${RUNNER_TEMP}/control-plane-coveragerc" + python -m coverage erase + env -u GITHUB_EVENT_PATH python -m coverage run \ + -m pytest \ + tests/test_agent_mention_router.py \ + tests/test_agent_mention_sweep.py \ + tests/test_agent_mention_workflow_contract.py \ + tests/test_control_plane_coverage_closure.py \ + tests/test_control_plane_quality_coverage_gaps.py \ + tests/test_control_plane_quality_workflow_contract.py \ + tests/test_install_base_python_locks.py \ + tests/test_install_base_python_lock_missing_pin.py \ + tests/test_install_base_python_locks_atomic.py \ + tests/test_javascript_coverage_gate.py \ + tests/test_javascript_coverage_scope.py \ + tests/test_opencode_security_boundaries.py \ + tests/test_pr_review_fix_hourly_contract.py \ + tests/test_pr_review_fix_scheduler_import_fallback.py \ + tests/test_pr_review_fix_scheduler_source_pin.py \ + tests/test_redact_json_key_boundary.py \ + tests/test_sandboxed_output_redaction.py \ + tests/test_sandboxed_verify.py \ + tests/test_sandboxed_web_e2e.py \ + tests/test_sbom_generation_push_contract.py \ + tests/test_unstructured_separate_option_redaction.py \ + -q + python -m coverage report + + - name: Enforce complete production docstrings + run: | + python -m interrogate \ + --fail-under 100 \ + scripts/ci/agent_mention_router.py \ + scripts/ci/agent_mention_sweep.py \ + scripts/ci/install_base_python_locks.py \ + scripts/ci/javascript_coverage_gate.py \ + scripts/ci/redact_sensitive_log.py \ + scripts/ci/sandboxed_verify.py \ + scripts/ci/sandboxed_web_e2e.py + + - name: Compile quality-gate surfaces + run: | + python -m compileall -q \ + scripts/ci/agent_mention_router.py \ + scripts/ci/agent_mention_sweep.py \ + scripts/ci/install_base_python_locks.py \ + scripts/ci/javascript_coverage_gate.py \ + scripts/ci/redact_sensitive_log.py \ + scripts/ci/sandboxed_verify.py \ + scripts/ci/sandboxed_web_e2e.py \ + tests/conftest.py \ + tests/test_control_plane_coverage_closure.py \ + tests/test_control_plane_quality_coverage_gaps.py \ + tests/test_control_plane_quality_workflow_contract.py \ + tests/test_opencode_security_boundaries.py \ + tests/test_pr_review_fix_scheduler_import_fallback.py diff --git a/.github/workflows/pr-review-fix-scheduler.yml b/.github/workflows/pr-review-fix-scheduler.yml index cc7875bc8..7bc09c378 100644 --- a/.github/workflows/pr-review-fix-scheduler.yml +++ b/.github/workflows/pr-review-fix-scheduler.yml @@ -26,7 +26,7 @@ on: retry_hours: description: Minimum hours before redispatching autofix for the same head required: false - default: "24" + default: "1" type: string autofix_workflow: description: Autofix workflow file to dispatch @@ -44,14 +44,16 @@ on: default: "" type: string canonical_ref: - description: Ref of ContextualWisdomLab/.github to use for scheduler code + description: Deprecated compatibility input; accepted and ignored because privileged source is bound to the called workflow SHA required: false - default: "main" + default: "" type: string repository_dispatch: types: [pr-review-fix-scheduler] schedule: - - cron: "23 */2 * * *" + # Run away from minute zero, where scheduled GitHub Actions are more likely + # to be delayed, while preserving a bounded one-dispatch-per-run repair loop. + - cron: "23 * * * *" concurrency: group: central-pr-review-fix-scheduler-${{ github.event.client_payload.target_repository || inputs.target_repository || vars.PR_REVIEW_FIX_TARGET_REPOSITORY || github.repository }} @@ -80,19 +82,87 @@ 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' }} - RETRY_HOURS: ${{ github.event.client_payload.retry_hours || inputs.retry_hours || '24' }} + RETRY_HOURS: ${{ github.event.client_payload.retry_hours || inputs.retry_hours || '1' }} AUTOFIX_WORKFLOW: pr-review-autofix.yml AUTOFIX_REPOSITORY: ContextualWisdomLab/.github - CANONICAL_REF: main steps: - - name: Checkout canonical scheduler - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Resolve immutable called-workflow source + id: trusted_source + env: + WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + WORKFLOW_SHA: ${{ job.workflow_sha }} + WORKFLOW_REF: ${{ job.workflow_ref }} + WORKFLOW_FILE_PATH: ${{ job.workflow_file_path }} + run: | + set -euo pipefail + expected_repository="ContextualWisdomLab/.github" + expected_file=".github/workflows/pr-review-fix-scheduler.yml" + + if [ "$WORKFLOW_REPOSITORY" != "$expected_repository" ]; then + printf '::error::Called workflow repository resolved to %s, expected %s.\n' \ + "${WORKFLOW_REPOSITORY:-}" "$expected_repository" + exit 1 + fi + if ! [[ "$WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Called workflow SHA is missing or malformed: %s.\n' \ + "${WORKFLOW_SHA:-}" + exit 1 + fi + if [ "$WORKFLOW_FILE_PATH" != "$expected_file" ]; then + printf '::error::Called workflow file resolved to %s, expected %s.\n' \ + "${WORKFLOW_FILE_PATH:-}" "$expected_file" + exit 1 + fi + expected_ref_prefix="${WORKFLOW_REPOSITORY}/${WORKFLOW_FILE_PATH}@" + case "$WORKFLOW_REF" in + "$expected_ref_prefix"*) ;; + *) + printf '::error::Called workflow ref is missing or inconsistent: %s.\n' \ + "${WORKFLOW_REF:-}" + exit 1 + ;; + esac + + { + printf 'repository=%s\n' "$WORKFLOW_REPOSITORY" + printf 'sha=%s\n' "$WORKFLOW_SHA" + printf 'workflow_ref=%s\n' "$WORKFLOW_REF" + printf 'workflow_file_path=%s\n' "$WORKFLOW_FILE_PATH" + } >>"$GITHUB_OUTPUT" + printf 'Resolved immutable called-workflow source repository=%s file=%s sha=%s ref=%s.\n' \ + "$WORKFLOW_REPOSITORY" "$WORKFLOW_FILE_PATH" "$WORKFLOW_SHA" "$WORKFLOW_REF" + + - name: Checkout immutable called-workflow source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: ContextualWisdomLab/.github - ref: ${{ env.CANONICAL_REF }} + # GitHub documents job.workflow_repository and job.workflow_sha as + # the called workflow identity. The preceding step validates every + # field before checkout so an absent property cannot select defaults. + repository: ${{ steps.trusted_source.outputs.repository }} + ref: ${{ steps.trusted_source.outputs.sha }} fetch-depth: 1 persist-credentials: false + - name: Verify immutable called-workflow checkout + env: + EXPECTED_SHA: ${{ steps.trusted_source.outputs.sha }} + EXPECTED_FILE: ${{ steps.trusted_source.outputs.workflow_file_path }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$EXPECTED_SHA" ]; then + printf '::error::Checked-out scheduler SHA %s does not match called-workflow SHA %s.\n' \ + "$actual_sha" "$EXPECTED_SHA" + exit 1 + fi + if [ ! -f "$EXPECTED_FILE" ] || [ -L "$EXPECTED_FILE" ]; then + printf '::error::Called workflow source file is missing or symlinked: %s.\n' \ + "$EXPECTED_FILE" + exit 1 + fi + printf 'Verified immutable scheduler checkout at %s (%s).\n' \ + "$actual_sha" "$EXPECTED_FILE" + - name: Self-test fix scheduler contract run: python3 scripts/ci/pr_review_fix_scheduler.py --self-test diff --git a/.github/workflows/sbom-generation.yml b/.github/workflows/sbom-generation.yml index b62f0b3d3..b56a261be 100644 --- a/.github/workflows/sbom-generation.yml +++ b/.github/workflows/sbom-generation.yml @@ -19,9 +19,19 @@ # NOTE: contents: write is required for release-asset upload and for the # dependency submission API. Fork PR heads run without write and simply skip # those side effects; the artifact is still produced. +# +# NOTE on the push trigger: it exists so the DEFAULT BRANCH has a dependency +# snapshot. dependency-review compares base...head in the dependency graph; with +# PR-only runs the base commit never has one, so every comparison reports "the +# number of snapshots compared for the base SHA (0) and the head SHA (1) do not +# match" and the whole dependency set reads as newly added. That re-flags +# pre-existing vulnerabilities on every PR instead of only the ones the PR adds. +# Snapshotting pushes to the default branch gives the comparison a real base. name: SBOM Generation on: + push: + branches: [main, master, develop] pull_request: types: [opened, synchronize, reopened, ready_for_review, closed] branches: [main, master, develop] @@ -29,7 +39,10 @@ on: types: [published] concurrency: - group: sbom-generation-${{ github.event.pull_request.base.repo.full_name || github.repository }}-${{ github.event.pull_request.number || github.event.release.tag_name || github.ref }} + # Final fallback is the SHA, not the ref, so two pushes landing close together + # do not cancel each other: a cancelled push run leaves that commit without a + # snapshot, which is exactly the base-side gap this trigger exists to close. + group: sbom-generation-${{ github.event.pull_request.base.repo.full_name || github.repository }}-${{ github.event.pull_request.number || github.event.release.tag_name || github.sha }} cancel-in-progress: true permissions: diff --git a/.github/workflows/scheduled-security-scan.yml b/.github/workflows/scheduled-security-scan.yml index 8ecb5185b..331de634f 100644 --- a/.github/workflows/scheduled-security-scan.yml +++ b/.github/workflows/scheduled-security-scan.yml @@ -90,13 +90,13 @@ jobs: with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis continue-on-error: true - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: category: "/language:${{ matrix.language }}-scheduled" @@ -131,7 +131,7 @@ jobs: - name: Upload Trivy SARIF to code scanning if: always() && hashFiles('trivy-results.sarif') != '' continue-on-error: true - uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + uses: github/codeql-action/upload-sarif@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 with: sarif_file: trivy-results.sarif category: trivy-fs-scheduled diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..a13e0b68e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to the ContextualWisdomLab central GitHub control plane are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versioned releases follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Add a trusted pull-request comment router for `@cwl-noema-review` and review-only `@opencode-agent` invocations, with bounded organization-wide sweeping, immutable current-head dispatch payloads, idempotent receipts, and fail-closed author/repository validation. +- Add hourly bounded review-repair scheduling that preserves the existing reviewer identities and credential chain while continuing non-conflicting maintenance during pending checks or reviews. +- Add a read-only exact-head Python quality workflow that compiles the changed central control-plane modules on Python 3.10 and runs their deterministic Python 3.14 tests with hash-locked tooling, 100% production statement and branch coverage, and 100% production docstrings. + +### Security + +- Upgrade the central Strix dependency snapshots to `aiohttp==3.14.3`, `cryptography==50.0.0`, and the compatible `pyOpenSSL==26.4.0` closure so the hard dependency gates contain no known affected releases. +- Redact credentials from every sandbox evidence publication sink, including completed and timed-out process output, service log tails, structured and echoed commands, reviewer notes, nested JSON values, and JSON object keys. +- Redact separate sensitive-option values echoed by child processes, concatenated or CamelCase credential-key values, and conservatively classified oversized assignments. +- Bound structured-diagnostic traversal and replace malformed JSON-looking records, over-deep subtrees, or parser/encoder recursion failures with fail-closed redacted evidence instead of crashing or retrying through weaker handling. +- Keep pull-request-controlled code outside the mention-router trust boundary, retain least-privilege workflow permissions, validate reusable workflow sources immutably, and preserve default-branch dependency snapshots for meaningful dependency review. + +### Fixed + +- Replace quadratic sensitive-assignment rescanning with a bounded forward scan so one long ordinary diagnostic token cannot cause disproportionate log-processing work. +- Scope central JavaScript and TypeScript changed-source coverage to product runtime modules instead of incorrectly requiring Istanbul instrumentation for recognized tool configuration files and bounded `check-*` or `verify-*` repository commands. +- Preserve fail-closed 100% changed-statement, branch, function, and line evidence for runtime modules, including ordinary executable scripts and `src/scripts` modules. +- Preflight trusted-base Python dependency locks atomically so missing, malformed, or unsafe lock inputs fail with bounded diagnostics instead of partially mutating the review environment. + +### Documentation + +- Add APA 7 doctoring records for trusted review-agent invocation, hourly repair, central security baselines, JavaScript runtime coverage classification, and sandbox command/output redaction boundaries, including verification evidence, modular behavior, limitations, and rollback requirements. diff --git a/docs/automation/hourly-review-repair.md b/docs/automation/hourly-review-repair.md new file mode 100644 index 000000000..1924dba5b --- /dev/null +++ b/docs/automation/hourly-review-repair.md @@ -0,0 +1,72 @@ +# Hourly PR review-repair scheduler + +The central `PR Review Fix Scheduler` provides a bounded organization-wide +review → fix → revalidate → merge support loop. It runs at minute 23 of every +hour and may dispatch at most one existing autofix workflow per run. Merge +eligibility remains owned by the separate merge scheduler, branch protection, +required checks, independent review, and unresolved-thread policy. + +## Execution and compatibility contract + +- The scheduled heartbeat is `23 * * * *`. +- The default same-head retry floor is one hour. +- `max_dispatches` remains one by default. +- Repository-scoped concurrency and `cancel-in-progress: true` prevent two + superseded scheduler runs from mutating the same repository concurrently. +- `canonical_ref` remains an accepted deprecated input only so callers pinned to + older workflow interfaces can upgrade without a coordinated breaking change. + It is never read and cannot choose executable scheduler code. + +## Immutable reusable-workflow source + +GitHub associates the ordinary `github` context in a reusable workflow with the +caller. Consequently, a called privileged workflow must not use caller-derived +`github.sha`, a caller payload, or a mutable branch such as `main` to select its +co-located implementation. + +The checkout step instead uses: + +```yaml +repository: ${{ job.workflow_repository }} +ref: ${{ job.workflow_sha }} +``` + +`job.workflow_repository` identifies the repository that contains the called +workflow and `job.workflow_sha` identifies its immutable resolved commit. This +keeps the scheduler implementation aligned with the exact workflow revision +selected by the caller's `uses: ...@` reference. Checkout credentials are +not persisted. + +## Security and MSA boundary + +The scheduler can inspect review state and dispatch the already-reviewed bounded +autofix workflow. It cannot approve its own changes, lower branch protection, +convert queued checks to success, publish releases, or bypass independent +review. Product repositories remain independently operable and consume the +central policy as a reusable module rather than copying privileged automation. + +CWL repositories and naruon retain their own product tests, authorization, +release, deployment, data-governance, and runtime responsibilities. The central +workflow owns only organization-level queue inspection and bounded repair +dispatch. + +## Verification + +Dependency-free static tests pin the hourly cron, one-hour retry default, +one-dispatch budget, single-flight concurrency, immutable called-workflow +checkout, ignored compatibility input, and least-privilege token boundary. The +exact PR head must also pass all central security, coverage, workflow-contract, +and independent-review gates before merge. + +## References (APA 7th edition) + +GitHub. (n.d.). *Contexts reference: Job context*. GitHub Docs. Retrieved August +4, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#job-context + +GitHub. (n.d.). *Reusing workflow configurations*. GitHub Docs. Retrieved August +4, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations + +GitHub. (n.d.). *Reusing workflows*. GitHub Docs. Retrieved August 4, 2026, from +https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows diff --git a/docs/automation/review-agent-comment-invocation.md b/docs/automation/review-agent-comment-invocation.md new file mode 100644 index 000000000..9c5f825e8 --- /dev/null +++ b/docs/automation/review-agent-comment-invocation.md @@ -0,0 +1,54 @@ +# Review-agent comment invocation + +Updated: 2026-08-05 + +## Purpose + +Trusted ContextualWisdomLab maintainers can invoke the existing review planes from a pull-request conversation: + +- `@cwl-noema-review` requests the independent Noema review. +- `@opencode-agent` requests a bounded current-head OpenCode review only; the invocation itself disables branch updates, automatic merge, and direct merge. + +The router never checks out or executes pull-request-controlled code. It reads live PR metadata, binds the request to the current head SHA and base branch, and dispatches the already deployed central workflows in `ContextualWisdomLab/.github`. + +## Architecture + +GitHub organization ruleset workflows support `pull_request`, `pull_request_target`, and `merge_group`, but not `issue_comment`. Separately, an `issue_comment` workflow runs only when that workflow file exists on the commented repository's default branch. Therefore, a workflow stored only in the central `.github` repository cannot directly receive comments created in sibling repositories. + +The implementation uses two bounded paths: + +1. **Local fast path.** Comments on `ContextualWisdomLab/.github` trigger `issue_comment` immediately. +2. **Organization sweep.** Every five minutes, the central workflow enumerates repositories visible to its cross-repository credential, finds recently updated open PRs and recent comments, validates trusted exact mentions, and dispatches unacknowledged requests. A hidden receipt keyed by source comment ID prevents normal repeated sweeps or local workflow reruns from redispatching the same invocation. + +A user or fine-grained token enumerates organization repositories. When the OpenCode GitHub App installation token is the available credential, the sweep instead uses GitHub's installation-repositories endpoint, which returns only repositories accessible to that installation. This avoids depending on an organization-issues endpoint whose documented fine-grained token support is user-token-oriented. + +This preserves the central MSA boundary without copying privileged workflow code into every product repository. + +## Trust and permission boundary + +- Accepted comment associations: `OWNER`, `MEMBER`, and `COLLABORATOR`. +- Bot comments, ordinary contributors, issue comments outside PRs, closed PRs, malformed metadata, already acknowledged comments, and lookalike handles fail closed. +- The workflow default token is read-only. +- The local job receives job-scoped `contents: write`, `issues: write`, and `pull-requests: read`. +- The organization sweep uses the established cross-repository credential chain for reading and acknowledging target comments, while the central repository's own token dispatches the central workflows. +- OpenCode dispatch is restricted to the exact `OPENCODE_REPOSITORY_DISPATCH_TARGETS` allowlist. +- An invocation cannot merge: `enable_auto_merge=false`, `update_branches=false`, and `merge_mode=disabled` are explicit in the dispatch payload. +- Every dispatch is bound to live PR number, current head SHA, and base branch metadata fetched from GitHub immediately before dispatch. + +## Operator controls + +- `AGENT_MENTION_LOOKBACK_HOURS`: default `168`, allowed range 1–720. +- `AGENT_MENTION_MAX_DISPATCHES`: default `20`, allowed range 1–100. +- Manual `workflow_dispatch` supports the same bounds and a dry-run mode. +- The sweep fails visibly when no cross-repository credential is available. +- `PR_REVIEW_MERGE_TOKEN` or `OPENCODE_APPROVE_TOKEN` takes precedence. Otherwise, the workflow exchanges its OIDC token for the existing OpenCode installation token and enumerates that installation's repositories. + +## References + +GitHub. (n.d.). *Available rules for rulesets*. GitHub Docs. Retrieved August 5, 2026, from https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets + +GitHub. (n.d.). *Events that trigger workflows*. GitHub Docs. Retrieved August 5, 2026, from https://docs.github.com/en/enterprise-cloud@latest/actions/reference/workflows-and-actions/events-that-trigger-workflows + +GitHub. (n.d.). *REST API endpoints for GitHub App installations*. GitHub Docs. Retrieved August 5, 2026, from https://docs.github.com/en/rest/apps/installations + +GitHub. (n.d.). *REST API endpoints for issues*. GitHub Docs. Retrieved August 5, 2026, from https://docs.github.com/en/rest/issues/issues diff --git a/docs/doctoring/central-security-and-review-baseline.md b/docs/doctoring/central-security-and-review-baseline.md new file mode 100644 index 000000000..5bf8a8536 --- /dev/null +++ b/docs/doctoring/central-security-and-review-baseline.md @@ -0,0 +1,154 @@ +# Central security and review baseline: evidence record + +## Decision + +The organization-level `.github` repository owns reusable review, security, +dependency-snapshot, bounded repair, and exact-head quality workflows. Product +repositories remain independently operable and consume those controls as +modules; they retain their own application tests, authorization, deployment, +release, and data-governance responsibilities. + +The baseline repair makes six controls atomic because they participate in the +same protected-branch decision: + +1. CodeQL initialization, analysis, and SARIF upload use one immutable action + revision within each affected workflow. +2. The central Strix dependency closure removes known-vulnerable package pins + and remains fully hash-pinned. +3. Trusted-base Python dependency preflight defers only narrowly classified + incomplete closures, interpreter incompatibility, or binary-unavailable and + stale pins proven by paired diagnostics for the same exact requirement on a + reachable index. Every resolver line must carry a comma-separated list whose + alternatives are concrete, conservatively recognized PEP 440 versions; + blank values, `none`, arbitrary prose, mixed version/prose lists, duplicate + malformed evidence, integrity, transport, and unknown errors fail closed. +4. Default-branch pushes submit dependency snapshots so pull-request dependency + review compares a head snapshot with a real base snapshot. +5. Review repair runs once per hour, dispatches at most one bounded repair job, + and resolves privileged code from the reusable workflow's immutable source + identity rather than caller data or mutable `main`. +6. A repository-owned exact-head quality workflow compiles the changed central + Python modules on the minimum supported Python 3.10 runtime and executes their + deterministic tests on Python 3.14 with fully hash-locked tooling, 100% + production statement and branch coverage, and 100% production docstrings. + +## Standards and current-platform rationale + +NIST SSDF version 1.1 is the current final publication; version 1.2 remained a +public draft at the time of this decision. The baseline follows SSDF's final +risk-reduction direction by integrating vulnerability detection, dependency +integrity, repeatable verification, and root-cause regression controls into the +software lifecycle without claiming formal conformance. + +The approved SLSA specification is version 1.2. Its source model distinguishes +trusted automation whose identity and codebase cannot be unilaterally +influenced. Immutable action pins, exact-revision dependency materialization, +and called-workflow source binding reduce mutable control-plane inputs in line +with that model without claiming a SLSA level. + +GitHub documents that a reusable workflow's ordinary `github` context belongs +to the caller. GitHub's current contexts reference separately defines +`job.workflow_repository`, `job.workflow_sha`, `job.workflow_ref`, and +`job.workflow_file_path` as the repository, immutable commit, full ref, and path +of the workflow file that defines the current job. The same reference gives +`job.workflow_repository` plus `job.workflow_sha` as the supported pattern for +checking out files co-located with a reusable workflow; these properties are a +GitHub.com capability and are not available on GitHub Enterprise Server. + +The scheduler therefore keeps the documented `job.workflow_*` identity rather +than substituting caller-associated `github.workflow_*` values. Before checkout, +it rejects an empty, malformed, unexpected-repository, unexpected-file, or +inconsistent workflow identity and exports only the validated repository and +full SHA. After checkout, it compares `git rev-parse HEAD` with that SHA and +requires the workflow file to be a regular non-symlink file before any scheduler +self-test or credential-bearing dispatch can execute. Caller and compatibility +inputs remain excluded as executable-source selectors, and contents-write and +pull-requests-write permissions remain absent. + +GitHub also documents that scheduled events can be delayed at the start of an +hour. The hourly heartbeat therefore runs at minute 23. A one-hour same-head +retry floor matches the requested cadence while the one-dispatch budget and +repository-scoped concurrency keep mutation bounded. + +GitHub's dependency submission API associates snapshots with commit SHAs and can +submit build-time or SBOM-derived dependencies that static manifest analysis +misses. Snapshotting default-branch pushes supplies the base-side evidence that +pull-request dependency review needs and prevents the entire existing graph from +appearing newly introduced. + +The direct security workflows intentionally do not stand in for functional +quality evidence. `pip-audit`, Bandit, CodeQL, Semgrep, secret scanning, OSV, +Scorecard, SBOM, and filesystem scanners answer different questions from unit, +branch, and docstring completeness. The dedicated quality workflow therefore +runs on the same immutable pull-request head and is itself guarded by a static +contract test that pins least privilege, action revisions, supported Python +versions, the hash-locked toolchain, all measured production modules, and the +100% thresholds. + +## Verification contract + +The exact pull-request head must prove: + +- one immutable CodeQL revision per affected workflow; +- the central hash lock installs and vulnerability scanners accept it; +- stale-pin deferral requires paired exact-requirement resolver diagnostics and + a nonempty list on every matching resolver line in which every alternative is + a conservatively valid PEP 440 version, including epoch, prerelease, + postrelease, development, and local forms used by pip; +- blank, `none`, arbitrary prose, mixed version/prose lists, duplicate malformed + lines, single-sided or mismatched resolver evidence, integrity, retry, + transport, mixed-unknown, and unclassified installer failures remain fatal; +- the changed trusted-lock installer has 100% statement and branch coverage and + 100% production docstrings; +- `agent_mention_router.py`, `agent_mention_sweep.py`, + `install_base_python_locks.py`, `javascript_coverage_gate.py`, + `redact_sensitive_log.py`, `sandboxed_verify.py`, and + `sandboxed_web_e2e.py` compile on Python 3.10 and reach 100% statement, + branch, and production-docstring coverage on Python 3.14; +- the quality workflow installs only the reviewed + `requirements-opencode-review-ci-hashes.txt` closure with + `--require-hashes`, uses immutable action revisions, has read-only contents + permission, contains no scheduler or Copilot behavior, and runs its own static + workflow contract; +- default-branch snapshot triggers, commit-SHA concurrency, and job-scoped write + permissions remain pinned by tests; +- hourly cadence, one-hour retry, single dispatch, least-privilege permissions, + pre-checkout validation of every `job.workflow_*` identity field, and + post-checkout SHA/file verification remain pinned by tests; and +- every current-head security, quality, review, unresolved-thread, and + branch-protection gate succeeds before merge. + +## References + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure +software development framework (SSDF) version 1.2: Recommendations for +mitigating the risk of software vulnerabilities* (Initial Public Draft, NIST SP +800-218 Rev. 1). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-218r1.ipd + +GitHub. (n.d.). *Contexts reference*. GitHub Docs. Retrieved August 4, 2026, +from +https://docs.github.com/en/actions/reference/workflows-and-actions/contexts + +GitHub. (n.d.). *Reusing workflow configurations*. GitHub Docs. Retrieved August +4, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations + +GitHub. (n.d.). *Troubleshooting workflows*. GitHub Docs. Retrieved August 4, +2026, from https://docs.github.com/en/actions/how-tos/troubleshoot-workflows + +GitHub. (n.d.). *Using the dependency submission API*. GitHub Docs. Retrieved +August 4, 2026, from +https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/use-dependency-submission-api + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development +framework (SSDF) version 1.1: Recommendations for mitigating the risk of software +vulnerabilities* (NIST SP 800-218). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Supply-chain Levels for Software Artifacts. (2025). *SLSA specification +(version 1.2)*. https://slsa.dev/spec/v1.2/ + +Supply-chain Levels for Software Artifacts. (2025). *Source: Requirements for +producing source (version 1.2)*. +https://slsa.dev/spec/v1.2/source-requirements diff --git a/docs/doctoring/javascript-runtime-coverage-scope.md b/docs/doctoring/javascript-runtime-coverage-scope.md new file mode 100644 index 000000000..dc984b136 --- /dev/null +++ b/docs/doctoring/javascript-runtime-coverage-scope.md @@ -0,0 +1,137 @@ +# JavaScript runtime coverage scope + +## Decision + +The central changed-source coverage gate measures JavaScript and TypeScript +application runtime modules, not every executable file that happens to use a +JavaScript-family suffix. + +Two bounded non-product categories are excluded from the Istanbul changed-line +contract: + +- recognized build and test tool configuration files whose names start with a + known tool identifier, may include one or more profile segments, and end in + `.config.`; and +- repository or module verification commands named `check-*` or `verify-*` in a + `scripts` directory that is not nested below `src`. + +This corrects the concrete Inkspan evidence failure for +`vite.autosave.config.ts`, +`scripts/verify-framework-free-autosave-package.mjs`, and +`scripts/verify-package.mjs`. The product runtime changed by the same pull +request remains subject to complete changed-statement, branch, function, and +line evidence. + +## Root cause + +Inkspan's locked test toolchain uses Vitest 3.2.7. That exact release enables +coverage for matching source modules while its versioned defaults exclude test +files, declarations, generated output, dependency trees, and recognized tool +configuration names. A central gate that independently reclassifies those files +as application runtime creates an impossible contract: the repository test +runner correctly omits the tool file, but the central post-processor interprets +the omission as missing product instrumentation. + +The former classifier recognized only a few exact names such as +`vite.config.ts`. It therefore failed on a valid profile-qualified configuration +name such as `vite.autosave.config.ts`. It also treated bounded package +verification commands as shipped product modules even though those commands are +exercised through separate command-level CI contracts. + +Vitest 4 subsequently simplified its generic coverage defaults and emphasizes +an explicit `coverage.include` boundary. The central classifier therefore does +not copy either release's mutable glob list wholesale. It preserves a narrow, +repository-owned product-runtime contract that remains stable across supported +runner versions. + +## Fail-closed boundary + +The correction is deliberately narrower than excluding all configuration or +script paths: + +- `src/feature.config.ts` remains application runtime because arbitrary business + modules may legitimately use a `config` suffix; +- `scripts/serve-package.mjs` remains application runtime because a general + script may be a shipped CLI or service entry point; +- `src/scripts/verify-session.ts` remains application runtime because a + `scripts` directory under `src` is part of the product source tree; +- only recognized tool prefixes match the scoped configuration expression; and +- test files, declarations, generated output, fixtures, and dependency trees + retain their existing explicit exclusions. + +A changed runtime file absent from `coverage-final.json` still fails. An +instrumented runtime file still requires every execution unit intersecting the +changed lines to be covered. Global pre-existing coverage remains advisory and +cannot mask changed-code evidence. + +## Modular and MSA behavior + +The classifier operates on repository-relative POSIX paths and does not assume a +single root package. The same rule therefore supports standalone repositories, +nested packages, and modules imported by Inkspan, naruon, or another Contextual +Wisdom Lab service: + +- root `scripts/verify-*` commands are classified consistently; +- nested `packages//scripts/check-*` commands receive the same bounded + treatment; +- nested `src` trees retain strict runtime evidence; and +- no package name, pull-request number, tenant, branch, or product-specific + exception is embedded in the policy. + +## Verification contract + +The focused regression suite includes the exact Inkspan filenames that triggered +the false positive and proves all of the following: + +- profile-qualified Vitest, Vite, and Webpack configuration files are excluded; +- root and nested `check-*` or `verify-*` tooling commands are excluded; +- ordinary product modules, business configuration modules, runtime scripts, + and `src/scripts` modules remain blocking runtime scope; +- a tooling-only exact Git diff produces an explicit coverage-not-applicable + decision; +- a changed non-verification runtime script with an empty Istanbul report still + fails closed; +- unmatched Istanbul records cannot hide the matching changed runtime record; +- malformed location metadata, absolute evidence paths, unrelated JSON files, + and changed paths with no diff hunks are handled deterministically; and +- the complete central classifier reaches 267 of 267 production statements and + 124 of 124 production branches, with production docstrings present for every + module and function. + +Repository-wide exact-head CI, security scans, independent review, and branch +protection remain authoritative before merge. No formal Vitest or NIST +conformity is claimed. + +## Standards and primary-source traceability + +Vitest's current coverage documentation distinguishes V8 and Istanbul providers, +describes JSON coverage reporting, and recommends an explicit source inclusion +boundary. The exact 3.2.7 source used by Inkspan records the older release's +resolved exclusions for tests, declarations, build output, dependencies, and +recognized tool configuration files. The central rule mirrors the product/tool +semantic boundary without inheriting a mutable third-party glob set. + +NIST SSDF 1.1 requires producers to define, maintain, and verify secure software +development practices and to address root causes so defects do not recur. The +newer SSDF 1.2 initial public draft was reviewed as current guidance, while the +final 1.1 publication remains the normative reference used here. + +## References + +Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure +software development framework (SSDF) version 1.2: Recommendations for +mitigating the risk of software vulnerabilities* (NIST Special Publication +800-218 Rev. 1, Initial Public Draft). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-218r1.ipd + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development +framework (SSDF) version 1.1: Recommendations for mitigating the risk of software +vulnerabilities* (NIST Special Publication 800-218). National Institute of +Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Vitest. (n.d.). *Coverage*. Retrieved August 5, 2026, from +https://main.vitest.dev/guide/coverage + +Vitest. (2025). *Coverage configuration defaults* (Version 3.2.7) [Computer +software source code]. GitHub. Retrieved August 5, 2026, from +https://github.com/vitest-dev/vitest/blob/v3.2.7/packages/vitest/src/defaults.ts diff --git a/docs/doctoring/sandboxed-command-log-redaction.md b/docs/doctoring/sandboxed-command-log-redaction.md new file mode 100644 index 000000000..c426ee943 --- /dev/null +++ b/docs/doctoring/sandboxed-command-log-redaction.md @@ -0,0 +1,89 @@ +# Sandboxed command and output redaction + +## Decision + +The central verification wrappers treat subprocess output, service log tails, command arguments, shell-command strings, and reviewer evidence notes as potentially sensitive before writing them to GitHub Actions logs or machine-readable review evidence. + +One trusted redaction module owns this publication boundary: + +- captured standard output and standard error are redacted before printing; +- `TimeoutExpired` byte and text payloads use the same redaction path; +- service log tails are redacted before publication; +- command arguments following sensitive options such as `--token`, `--password`, or `--api-key` are replaced; +- sensitive `KEY=value` command arguments are replaced while preserving the key; +- child-process text that echoes a sensitive option and its separate value is redacted even outside a structured command array; +- standalone provider-token shapes are removed; +- valid JSON is traversed recursively so credential-shaped object keys and string values cannot bypass line-oriented patterns; +- leading indentation is preserved when a valid JSON diagnostic is normalized; +- JSON-looking lines that are malformed, too deeply nested, or rejected by the parser or encoder are replaced as complete redacted records rather than retried through weaker handling; +- shell command strings are parsed without execution and reconstructed from redacted arguments; and +- JSON result markers redact commands and evidence notes before serialization. + +The original argument vectors and output are used only inside the isolated execution boundary. Redaction changes neither the command that runs nor its exit status. It is applied at every publication sink instead of depending on each child process to avoid printing credentials. + +## Threat model + +Repository verification commands and web end-to-end services can emit credentials through exception messages, dependency-manager diagnostics, HTTP-client traces, command-line options, environment-derived configuration, startup logs, structured JSON diagnostics, and timeout payloads. An explicitly allowlisted environment variable can therefore remain correctly scoped to a child process and still be disclosed when that child echoes it. + +GitHub Actions logs and review envelopes are durable evidence with a potentially broader readership than the originating credential. MITRE classifies insertion of sensitive information into log files as CWE-532. OWASP's current logging guidance identifies access tokens, passwords, database connection strings, encryption keys, and other primary secrets as values that should normally be removed, masked, sanitized, hashed, or encrypted before logging. NIST SSDF requires protection of software and development artifacts from unauthorized access and disclosure. + +Untrusted tools can also emit malformed or deeply nested structured diagnostics. Recursive parsing without an explicit bound creates an availability risk. Treating a JSON-looking record as ordinary text after a syntax or recursion failure can also recreate a confidentiality bypass because the relationship between a sensitive object key and an otherwise ordinary string value has been lost. The redaction boundary therefore replaces the complete JSON-looking record on parse or encode failure and replaces a subtree at the configured maximum JSON depth. + +## Security and availability boundaries + +- No provider-shaped credential literal is committed as a test fixture. Tests construct credential-shaped values from fragments at runtime so Secret Scan remains authoritative. +- Redaction is fail-closed for recognized sensitive option names, assignments, bearer/basic values, JWTs, known provider token formats, and concatenated or CamelCase credential key names, but it is not a general data-loss-prevention engine. +- Sensitive option detection uses explicit credential terms. Ambiguous short flags such as `-p` are not guessed because they can mean port, path, project, or password depending on the child tool. +- A sensitive option with no value does not consume the next option-looking argument. This preserves command diagnostics while preventing an option name from being mistaken for the secret value. +- Shell strings are tokenized with `shlex.split`; no shell is invoked for redaction. Malformed shell strings fall back to line-oriented redaction. +- `subprocess.run` and `subprocess.Popen` receive structured argument arrays with `shell=False`. Preventing shell interpretation and preventing log disclosure are independent controls. +- The assignment scanner advances through each ordinary identifier once. A deterministic instrumentation test prevents a long non-sensitive token from reintroducing quadratic rescanning and log-processing denial of service. +- An oversized assignment key is conservatively classified as sensitive when followed by a value, preventing matcher-size limits from becoming a bypass. +- Structured JSON traversal stops at `MAX_JSON_DEPTH`; the remaining subtree is represented only as `[REDACTED]`. +- A JSON syntax error, parser `RecursionError`, or encoder `RecursionError` redacts the complete JSON-looking line while preserving indentation and its line ending. It never reprocesses the same record through a weaker parser. +- File paths, working directories, and sandbox paths remain visible operational evidence. Operators must not place credentials in path names. +- Redaction preserves line boundaries and ordinary non-sensitive diagnostics. It does not transform a failed command into a successful result or suppress a nonzero exit status. + +No formal OWASP, NIST, or CWE conformity is claimed. + +## Verification contract + +The focused regression suite constructs credential-shaped values at runtime and proves that they do not appear in: + +1. completed verification stdout or stderr; +2. timeout output supplied as bytes or text; +3. human-readable command displays; +4. echoed `--token value`, `--password value`, or `--api-key value` text; +5. JSON result-marker command arrays; +6. backend, frontend, or E2E shell-command fields; +7. reviewer evidence notes; +8. service log tails; +9. nested JSON string values; +10. JSON object keys, including concatenated and CamelCase credential names; +11. indented JSON diagnostics; +12. malformed JSON-looking diagnostics; or +13. a structured diagnostic beyond the supported JSON nesting depth. + +The tests also cover separate sensitive options, `--option=value`, `KEY=value` assignments, standalone provider-token shapes, malformed shell quoting, missing logs, bounded final-line selection, recursive JSON structures, oversized assignment keys, bounded assignment scanning, parser and encoder recursion failure, and both wrappers' end-to-end publication paths. Ordinary text, line endings, result envelopes, cleanup, timeouts, child-process exit codes, and a following option after a missing sensitive-option value remain observable. + +The exact pull-request head must additionally pass the complete central unit suite, 100% production statement and branch coverage for the changed surface, production docstring checks, Secret Scan, CodeQL, Semgrep, Python Security, Security Scan, OpenCode, Noema, CodeRabbit, independent current-head approval, and branch protection before merge. + +## Modular boundary + +`sandboxed_verify.py`, `sandboxed_web_e2e.py`, and `redact_sensitive_log.py` remain independently executable scripts and reusable Python modules. Product repositories consume the behavior through the organization control plane without copying repository-local redaction code. The wrappers preserve their existing CLI and machine-readable result contracts. + +## Rollback + +Rollback must restore every publication sink as one atomic change. Removing only command redaction, JSON traversal, depth limits, malformed-record handling, service-tail redaction, or result-envelope redaction would recreate a bypass around the remaining controls. Before rollback, operators must prove that no allowlisted credential can reach child output or command metadata and must retain equivalent focused regression evidence. + +## APA 7 references + +MITRE Corporation. (2026). *CWE-117: Improper output neutralization for logs* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/117.html + +MITRE Corporation. (2026). *CWE-532: Insertion of sensitive information into log file* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/532.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 + +OWASP Foundation. (n.d.). *Logging cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 5, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + +Python Software Foundation. (2026). *subprocess—Subprocess management* (Python 3.14.6 documentation). https://docs.python.org/3/library/subprocess.html diff --git a/requirements-strix-ci-hashes.txt b/requirements-strix-ci-hashes.txt index e2c8f00eb..c305e9c84 100644 --- a/requirements-strix-ci-hashes.txt +++ b/requirements-strix-ci-hashes.txt @@ -4,127 +4,128 @@ aiohappyeyeballs==2.7.1 \ --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 # via aiohttp -aiohttp==3.14.1 \ - --hash=sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5 \ - --hash=sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983 \ - --hash=sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521 \ - --hash=sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340 \ - --hash=sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d \ - --hash=sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a \ - --hash=sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4 \ - --hash=sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a \ - --hash=sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f \ - --hash=sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee \ - --hash=sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8 \ - --hash=sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb \ - --hash=sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397 \ - --hash=sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05 \ - --hash=sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8 \ - --hash=sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09 \ - --hash=sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2 \ - --hash=sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba \ - --hash=sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf \ - --hash=sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271 \ - --hash=sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5 \ - --hash=sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847 \ - --hash=sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264 \ - --hash=sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf \ - --hash=sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6 \ - --hash=sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df \ - --hash=sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035 \ - --hash=sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126 \ - --hash=sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6 \ - --hash=sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35 \ - --hash=sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4 \ - --hash=sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333 \ - --hash=sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203 \ - --hash=sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c \ - --hash=sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1 \ - --hash=sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251 \ - --hash=sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365 \ - --hash=sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b \ - --hash=sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621 \ - --hash=sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94 \ - --hash=sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da \ - --hash=sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491 \ - --hash=sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe \ - --hash=sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d \ - --hash=sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080 \ - --hash=sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42 \ - --hash=sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c \ - --hash=sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397 \ - --hash=sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9 \ - --hash=sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8 \ - --hash=sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345 \ - --hash=sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3 \ - --hash=sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602 \ - --hash=sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2 \ - --hash=sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966 \ - --hash=sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192 \ - --hash=sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95 \ - --hash=sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3 \ - --hash=sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b \ - --hash=sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444 \ - --hash=sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6 \ - --hash=sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573 \ - --hash=sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af \ - --hash=sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15 \ - --hash=sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe \ - --hash=sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2 \ - --hash=sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496 \ - --hash=sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876 \ - --hash=sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817 \ - --hash=sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448 \ - --hash=sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e \ - --hash=sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6 \ - --hash=sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd \ - --hash=sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f \ - --hash=sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe \ - --hash=sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c \ - --hash=sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca \ - --hash=sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c \ - --hash=sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa \ - --hash=sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc \ - --hash=sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0 \ - --hash=sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0 \ - --hash=sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2 \ - --hash=sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844 \ - --hash=sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719 \ - --hash=sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1 \ - --hash=sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3 \ - --hash=sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178 \ - --hash=sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3 \ - --hash=sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95 \ - --hash=sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730 \ - --hash=sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842 \ - --hash=sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd \ - --hash=sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d \ - --hash=sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96 \ - --hash=sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85 \ - --hash=sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1 \ - --hash=sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199 \ - --hash=sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a \ - --hash=sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588 \ - --hash=sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec \ - --hash=sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004 \ - --hash=sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480 \ - --hash=sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04 \ - --hash=sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8 \ - --hash=sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce \ - --hash=sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087 \ - --hash=sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505 \ - --hash=sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780 \ - --hash=sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4 \ - --hash=sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d \ - --hash=sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca \ - --hash=sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665 \ - --hash=sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296 \ - --hash=sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c \ - --hash=sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a \ - --hash=sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7 \ - --hash=sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451 \ - --hash=sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3 +aiohttp==3.14.3 \ + --hash=sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39 \ + --hash=sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043 \ + --hash=sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b \ + --hash=sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d \ + --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \ + --hash=sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f \ + --hash=sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7 \ + --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \ + --hash=sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559 \ + --hash=sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f \ + --hash=sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929 \ + --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \ + --hash=sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9 \ + --hash=sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8 \ + --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \ + --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \ + --hash=sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8 \ + --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \ + --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \ + --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \ + --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \ + --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \ + --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \ + --hash=sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a \ + --hash=sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c \ + --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \ + --hash=sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5 \ + --hash=sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d \ + --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \ + --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \ + --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \ + --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \ + --hash=sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479 \ + --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \ + --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \ + --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \ + --hash=sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427 \ + --hash=sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32 \ + --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \ + --hash=sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db \ + --hash=sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 \ + --hash=sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a \ + --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \ + --hash=sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06 \ + --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \ + --hash=sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228 \ + --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \ + --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \ + --hash=sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee \ + --hash=sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2 \ + --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \ + --hash=sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43 \ + --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \ + --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \ + --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \ + --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \ + --hash=sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0 \ + --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \ + --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \ + --hash=sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529 \ + --hash=sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc \ + --hash=sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c \ + --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \ + --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \ + --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \ + --hash=sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f \ + --hash=sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e \ + --hash=sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa \ + --hash=sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b \ + --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \ + --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \ + --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \ + --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \ + --hash=sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c \ + --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \ + --hash=sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae \ + --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \ + --hash=sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c \ + --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \ + --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \ + --hash=sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a \ + --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \ + --hash=sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d \ + --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \ + --hash=sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15 \ + --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \ + --hash=sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d \ + --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \ + --hash=sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19 \ + --hash=sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239 \ + --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \ + --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \ + --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \ + --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \ + --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \ + --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \ + --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \ + --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \ + --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \ + --hash=sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d \ + --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \ + --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \ + --hash=sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3 \ + --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \ + --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \ + --hash=sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787 \ + --hash=sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb \ + --hash=sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b \ + --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \ + --hash=sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910 \ + --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \ + --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \ + --hash=sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48 \ + --hash=sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b \ + --hash=sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce \ + --hash=sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a \ + --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \ + --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24 \ + --hash=sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5 # via + # -r requirements-strix-ci.txt # gql # litellm aiosignal==1.4.0 \ @@ -401,53 +402,53 @@ click==8.4.1 \ # litellm # typer # uvicorn -cryptography==49.0.0 \ - --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ - --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ - --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ - --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ - --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ - --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ - --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ - --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ - --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ - --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ - --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ - --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ - --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ - --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ - --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ - --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ - --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ - --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ - --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ - --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ - --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ - --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ - --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ - --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ - --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ - --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ - --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ - --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ - --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ - --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ - --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ - --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ - --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ - --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ - --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ - --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ - --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ - --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ - --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ - --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ - --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ - --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ - --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ - --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ - --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ - --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 # via # -r requirements-strix-ci.txt # google-auth @@ -1680,9 +1681,9 @@ pyjwt==2.13.0 \ --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 # via mcp -pyopenssl==26.3.0 \ - --hash=sha256:46367f8f66b92271e6d218da9c87607e1ef5a0bc5c8dea5bb3db82f395c385a3 \ - --hash=sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341 +pyopenssl==26.4.0 \ + --hash=sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7 \ + --hash=sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c # via google-auth python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ diff --git a/requirements-strix-ci.txt b/requirements-strix-ci.txt index e32bd39a9..441e79a26 100644 --- a/requirements-strix-ci.txt +++ b/requirements-strix-ci.txt @@ -1,6 +1,11 @@ strix-agent==1.0.4 google-cloud-aiplatform==1.133.0 protobuf<7.0.0 -cryptography==49.0.0 +cryptography==50.0.0 python-multipart==0.0.32 pyasn1==0.6.4 +# Transitive via strix-agent. 3.14.3 clears GHSA-cq5v-8q36-5273 (HIGH, OOB heap +# read in the C HTTP response parser), GHSA-mq44-7p77-q5h7 (unnegotiated +# permessage-deflate frames accepted) and GHSA-mfx4-hv73-q22v (request smuggling +# via WebSocket upgrade). Floor, not a pin, so Dependabot can keep moving it. +aiohttp>=3.14.3 diff --git a/scripts/ci/agent_mention_router.py b/scripts/ci/agent_mention_router.py new file mode 100644 index 000000000..d3a8518d9 --- /dev/null +++ b/scripts/ci/agent_mention_router.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Route trusted pull-request comment mentions to CWL review agents.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +from dataclasses import dataclass +from typing import Any, Sequence + +CENTRAL_AUTOMATION_REPOSITORY = "ContextualWisdomLab/.github" +TRUSTED_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"}) +MENTION_PATTERNS = { + "cwl-noema-review": re.compile( + r"(?") + + +@dataclass(frozen=True) +class MentionRequest: + """Validated agent-mention request extracted from one issue comment event.""" + + repository: str + pull_request_number: int + pull_request_head_sha: str + pull_request_base_branch: str + comment_id: int + actor: str + agents: tuple[str, ...] + + +class GitHubClient: + """Small token-bound wrapper around ``gh api`` for JSON requests.""" + + def __init__(self, token: str) -> None: + """Initialize a client with one non-empty GitHub credential.""" + + if not token: + raise ValueError("GitHub token is required") + self._token = token + + def request( + self, + args: Sequence[str], + *, + input_payload: dict[str, Any] | None = None, + ) -> Any: + """Execute ``gh api`` and decode its optional JSON response.""" + + command = ["gh", "api", *args] + if input_payload is not None: + command.extend(["--input", "-"]) + environment = os.environ.copy() + environment["GH_TOKEN"] = self._token + completed = subprocess.run( + command, + input=None if input_payload is None else json.dumps(input_payload), + text=True, + capture_output=True, + check=True, + env=environment, + ) + output = completed.stdout.strip() + return None if not output else json.loads(output) + + +def exact_mentions(body: str) -> tuple[str, ...]: + """Return supported exact agent mentions in deterministic order.""" + + return tuple( + name for name, pattern in MENTION_PATTERNS.items() if pattern.search(body) + ) + + +def receipt_marker(comment_id: int) -> str: + """Return the hidden idempotency marker for one invocation comment.""" + + if comment_id < 1: + raise ValueError("comment id must be positive") + return f"" + + +def processed_comment_ids(comments: Sequence[dict[str, Any]]) -> frozenset[int]: + """Extract receipt IDs authored by the trusted GitHub Actions bot only.""" + + processed: set[int] = set() + for comment in comments: + user = comment.get("user") or {} + if ( + str(user.get("login") or "").casefold() + != "github-actions[bot]" + or str(user.get("type") or "").casefold() != "bot" + ): + continue + body = str(comment.get("body") or "") + processed.update(int(match) for match in RECEIPT_RE.findall(body)) + return frozenset(processed) + + +def parse_event(event: dict[str, Any]) -> MentionRequest | None: + """Return a validated mention request, or ``None`` for an ignored event.""" + + issue = event.get("issue") or {} + comment = event.get("comment") or {} + repository = event.get("repository") or {} + pull_request = event.get("pull_request") or {} + if not issue.get("pull_request"): + return None + if pull_request.get("state") != "open": + return None + if str(comment.get("user", {}).get("type", "")).casefold() == "bot": + return None + if str(comment.get("author_association", "")).upper() not in TRUSTED_ASSOCIATIONS: + return None + agents = exact_mentions(str(comment.get("body") or "")) + if not agents: + return None + + repository_name = str(repository.get("full_name") or "").strip() + actor = str(comment.get("user", {}).get("login") or "").strip() + head_sha = str(pull_request.get("head", {}).get("sha") or "").strip() + base_branch = str(pull_request.get("base", {}).get("ref") or "").strip() + number = issue.get("number") + comment_id = comment.get("id") + if not REPOSITORY_RE.fullmatch(repository_name): + raise ValueError( + "agent mentions are limited to ContextualWisdomLab repositories" + ) + if not isinstance(number, int) or number < 1: + raise ValueError("pull request number is missing or invalid") + if not isinstance(comment_id, int) or comment_id < 1: + raise ValueError("comment id is missing or invalid") + if comment_id in processed_comment_ids(event.get("conversation_comments") or ()): + return None + if not HEAD_SHA_RE.fullmatch(head_sha): + raise ValueError("pull request head SHA is missing or invalid") + if not BASE_BRANCH_RE.fullmatch(base_branch): + raise ValueError("pull request base branch is missing or invalid") + if not actor: + raise ValueError("comment actor is missing") + return MentionRequest( + repository_name, + number, + head_sha.lower(), + base_branch, + comment_id, + actor, + agents, + ) + + +def parse_repository_allowlist(raw_value: str) -> frozenset[str]: + """Parse and validate a comma-separated exact repository allowlist.""" + + repositories = frozenset( + part.strip() for part in raw_value.split(",") if part.strip() + ) + invalid = sorted( + repository + for repository in repositories + if not REPOSITORY_RE.fullmatch(repository) + ) + if invalid: + raise ValueError(f"invalid repository allowlist entries: {', '.join(invalid)}") + return repositories + + +def eligible_agents( + request: MentionRequest, + *, + opencode_allowlist: frozenset[str], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Partition requested agents into dispatchable and rejected handles.""" + + dispatchable: list[str] = [] + rejected: list[str] = [] + if "cwl-noema-review" in request.agents: + dispatchable.append("cwl-noema-review") + if "opencode-agent" in request.agents: + if request.repository in opencode_allowlist: + dispatchable.append("opencode-agent") + else: + rejected.append("opencode-agent") + return tuple(dispatchable), tuple(rejected) + + +def noema_payload(request: MentionRequest) -> dict[str, Any]: + """Return the central Noema repository-dispatch request body.""" + + return { + "event_type": "noema-review", + "client_payload": { + "target_repository": request.repository, + "pr_number": request.pull_request_number, + "pr_head_sha": request.pull_request_head_sha, + "requested_by": request.actor, + "source_comment_id": request.comment_id, + }, + } + + +def opencode_payload(request: MentionRequest) -> dict[str, Any]: + """Return the review-only central OpenCode scheduler dispatch body.""" + + return { + "event_type": "merge-scheduler", + "client_payload": { + "target_repository": request.repository, + "pr_number": request.pull_request_number, + "pr_head_sha": request.pull_request_head_sha, + "base_branch": request.pull_request_base_branch, + "trigger_reviews": True, + "review_dispatch_limit": "1", + "enable_auto_merge": False, + "update_branches": False, + "merge_mode": "disabled", + "requested_agent": "opencode-agent", + "requested_by": request.actor, + "source_comment_id": request.comment_id, + }, + } + + +def dispatch_request( + request: MentionRequest, + *, + target_client: GitHubClient, + dispatch_client: GitHubClient, + opencode_allowlist: frozenset[str], + dry_run: bool = False, +) -> tuple[str, ...]: + """Dispatch requested agents and acknowledge the invocation on its PR.""" + + dispatchable, rejected = eligible_agents( + request, + opencode_allowlist=opencode_allowlist, + ) + handles = tuple(f"@{agent}" for agent in dispatchable) + if dry_run: + print( + "DRY-RUN agent mention " + f"repo={request.repository} pr={request.pull_request_number} " + f"head={request.pull_request_head_sha} " + f"dispatch={','.join(dispatchable) or 'none'} " + f"reject={','.join(rejected) or 'none'}" + ) + return handles + dispatch_endpoint = f"repos/{CENTRAL_AUTOMATION_REPOSITORY}/dispatches" + if "cwl-noema-review" in dispatchable: + dispatch_client.request( + [dispatch_endpoint, "-X", "POST"], + input_payload=noema_payload(request), + ) + if "opencode-agent" in dispatchable: + dispatch_client.request( + [dispatch_endpoint, "-X", "POST"], + input_payload=opencode_payload(request), + ) + target_api = f"repos/{request.repository}" + target_client.request( + [f"{target_api}/issues/comments/{request.comment_id}/reactions", "-X", "POST"], + input_payload={"content": "eyes"}, + ) + status_parts: list[str] = [] + if handles: + status_parts.append(f"Queued {' and '.join(handles)}") + if rejected: + rejected_handles = " and ".join(f"@{agent}" for agent in rejected) + status_parts.append( + f"Rejected {rejected_handles}: repository is absent from " + "OPENCODE_REPOSITORY_DISPATCH_TARGETS" + ) + acknowledgement = ( + f"{receipt_marker(request.comment_id)}\n" + f"{' ; '.join(status_parts)} for PR #{request.pull_request_number} at head " + f"`{request.pull_request_head_sha}`. Existing review workflows remain " + "authoritative for the final verdict and failure evidence." + ) + target_client.request( + [f"{target_api}/issues/{request.pull_request_number}/comments", "-X", "POST"], + input_payload={"body": acknowledgement}, + ) + return handles + + +def load_event(path: str) -> dict[str, Any]: + """Load and validate a GitHub event JSON document.""" + + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + raise ValueError("GitHub event payload must be a JSON object") + return value + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the mention router for one enriched GitHub issue-comment event.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--event-path", default=os.environ.get("GITHUB_EVENT_PATH", "")) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + if not args.event_path: + parser.error("--event-path or GITHUB_EVENT_PATH is required") + request = parse_event(load_event(args.event_path)) + if request is None: + print("No trusted pull-request agent mention found; nothing to dispatch.") + return 0 + target_token = os.environ.get("TARGET_REPOSITORY_TOKEN") or os.environ.get( + "GH_TOKEN", "" + ) + dispatch_token = os.environ.get("AGENT_DISPATCH_TOKEN") or os.environ.get( + "GH_TOKEN", "" + ) + allowlist = parse_repository_allowlist( + os.environ.get("OPENCODE_REPOSITORY_DISPATCH_TARGETS", "") + ) + dispatch_request( + request, + target_client=GitHubClient(target_token), + dispatch_client=GitHubClient(dispatch_token), + opencode_allowlist=allowlist, + dry_run=args.dry_run, + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/ci/agent_mention_sweep.py b/scripts/ci/agent_mention_sweep.py new file mode 100644 index 000000000..181d2d62f --- /dev/null +++ b/scripts/ci/agent_mention_sweep.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Sweep recent CWL pull-request comments for trusted review-agent mentions.""" + +from __future__ import annotations + +import argparse +import os +import re +from datetime import datetime, timedelta, timezone +from typing import Any, Iterator, Sequence + +from agent_mention_router import ( + GitHubClient, + MentionRequest, + dispatch_request, + parse_event, + parse_repository_allowlist, + processed_comment_ids, +) + +ORG_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +REPOSITORY_RE = re.compile(r"^ContextualWisdomLab/[A-Za-z0-9_.-]+$") +REPOSITORY_SOURCES = frozenset({"organization", "installation"}) + + +def parse_timestamp(value: str) -> datetime: + """Parse one GitHub ISO-8601 timestamp into timezone-aware UTC.""" + + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (AttributeError, ValueError) as exc: + raise ValueError("invalid GitHub timestamp") from exc + if parsed.tzinfo is None: + raise ValueError("GitHub timestamp must be timezone-aware") + return parsed.astimezone(timezone.utc) + + +def cutoff_timestamp(lookback_hours: int, *, now: datetime | None = None) -> str: + """Return an ISO-8601 UTC cutoff for the bounded comment lookback window.""" + + if lookback_hours < 1 or lookback_hours > 24 * 30: + raise ValueError("lookback hours must be between 1 and 720") + current = now or datetime.now(timezone.utc) + if current.tzinfo is None: + raise ValueError("current time must be timezone-aware") + cutoff = current.astimezone(timezone.utc) - timedelta(hours=lookback_hours) + return cutoff.replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def flatten_pages(value: Any, *, collection_key: str | None = None) -> list[dict[str, Any]]: + """Flatten ``gh api --paginate --slurp`` output into object records.""" + + if value is None: + raise ValueError("paginated GitHub response is empty") + pages = value if isinstance(value, list) else [value] + records: list[dict[str, Any]] = [] + for page in pages: + if collection_key and not isinstance(page, dict): + raise ValueError("paginated GitHub response page is not an object") + collection = page.get(collection_key, []) if collection_key else page + if not isinstance(collection, list): + raise ValueError("paginated GitHub response is not a list") + if not all(isinstance(record, dict) for record in collection): + raise ValueError("paginated GitHub response contains a non-object record") + records.extend(collection) + return records + + +def list_accessible_repositories( + client: GitHubClient, + *, + organization: str, + repository_source: str, +) -> list[str]: + """List active organization repositories visible to the selected token type.""" + + if not ORG_NAME_RE.fullmatch(organization): + raise ValueError("invalid organization name") + if repository_source not in REPOSITORY_SOURCES: + raise ValueError("repository source must be organization or installation") + if repository_source == "installation": + response = client.request( + [ + "installation/repositories", + "-X", + "GET", + "-f", + "per_page=100", + "--paginate", + "--slurp", + ] + ) + repositories = flatten_pages(response, collection_key="repositories") + else: + response = client.request( + [ + f"orgs/{organization}/repos", + "-X", + "GET", + "-f", + "type=all", + "-f", + "per_page=100", + "--paginate", + "--slurp", + ] + ) + repositories = flatten_pages(response) + names: list[str] = [] + for repository in repositories: + full_name = str(repository.get("full_name") or "") + owner = str(repository.get("owner", {}).get("login") or "") + if owner.casefold() != organization.casefold(): + continue + if repository.get("archived") or repository.get("disabled"): + continue + if not REPOSITORY_RE.fullmatch(full_name): + raise ValueError("GitHub returned an invalid repository full_name") + names.append(full_name) + return sorted(set(names)) + + +def list_recent_pull_requests( + client: GitHubClient, + *, + organization: str, + repository_source: str, + since: str, +) -> Iterator[dict[str, Any]]: + """Yield recent open pull requests and stop when the caller stops consuming.""" + + cutoff = parse_timestamp(since) + for repository in list_accessible_repositories( + client, + organization=organization, + repository_source=repository_source, + ): + response = client.request( + [ + f"repos/{repository}/pulls", + "-X", + "GET", + "-f", + "state=open", + "-f", + "sort=updated", + "-f", + "direction=desc", + "-f", + "per_page=100", + "--paginate", + "--slurp", + ] + ) + for pull_request in flatten_pages(response): + if parse_timestamp(str(pull_request.get("updated_at") or "")) < cutoff: + continue + number = pull_request.get("number") + if not isinstance(number, int) or number < 1: + raise ValueError("GitHub returned an invalid pull request number") + yield { + "number": number, + "repository": repository, + "pull_request": { + "url": f"https://api.github.com/repos/{repository}/pulls/{number}" + }, + } + + +def list_recent_comments( + client: GitHubClient, + *, + repository: str, + pull_request_number: int, + since: str, +) -> list[dict[str, Any]]: + """List recent issue comments for one pull request.""" + + response = client.request( + [ + f"repos/{repository}/issues/{pull_request_number}/comments", + "-X", + "GET", + "-f", + f"since={since}", + "-f", + "per_page=100", + "--paginate", + "--slurp", + ] + ) + return flatten_pages(response) + + +def build_requests_for_pull_request( + client: GitHubClient, + *, + issue: dict[str, Any], + since: str, +) -> tuple[MentionRequest, ...]: + """Build unacknowledged trusted mention requests for one live pull request.""" + + repository = str(issue.get("repository") or "") + if not REPOSITORY_RE.fullmatch(repository): + raise ValueError("pull request candidate has an invalid repository") + number = issue.get("number") + if not isinstance(number, int) or number < 1: + raise ValueError("pull request candidate has an invalid number") + comments = list_recent_comments( + client, + repository=repository, + pull_request_number=number, + since=since, + ) + processed = processed_comment_ids(comments) + live_pull = client.request([f"repos/{repository}/pulls/{number}"]) + if not isinstance(live_pull, dict) or live_pull.get("state") != "open": + return () + requests: list[MentionRequest] = [] + for comment in comments: + comment_id = comment.get("id") + if isinstance(comment_id, int) and comment_id in processed: + continue + event = { + "repository": {"full_name": repository}, + "issue": {"number": number, "pull_request": issue.get("pull_request")}, + "comment": comment, + "pull_request": live_pull, + "conversation_comments": comments, + } + request = parse_event(event) + if request is not None: + requests.append(request) + return tuple(requests) + + +def sweep( + *, + target_client: GitHubClient, + dispatch_client: GitHubClient, + organization: str, + repository_source: str, + lookback_hours: int, + max_dispatches: int, + opencode_allowlist: frozenset[str], + dry_run: bool = False, + now: datetime | None = None, +) -> int: + """Dispatch up to ``max_dispatches`` unacknowledged organization mentions.""" + + if max_dispatches < 1 or max_dispatches > 100: + raise ValueError("max dispatches must be between 1 and 100") + since = cutoff_timestamp(lookback_hours, now=now) + dispatched = 0 + for issue in list_recent_pull_requests( + target_client, + organization=organization, + repository_source=repository_source, + since=since, + ): + for request in build_requests_for_pull_request( + target_client, + issue=issue, + since=since, + ): + dispatch_request( + request, + target_client=target_client, + dispatch_client=dispatch_client, + opencode_allowlist=opencode_allowlist, + dry_run=dry_run, + ) + dispatched += 1 + if dispatched >= max_dispatches: + print(f"Agent mention sweep reached dispatch limit {max_dispatches}.") + return dispatched + print(f"Agent mention sweep completed with {dispatched} dispatch(es).") + return dispatched + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the scheduled organization mention sweep.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--organization", default="ContextualWisdomLab") + parser.add_argument( + "--repository-source", + choices=sorted(REPOSITORY_SOURCES), + default="organization", + ) + parser.add_argument("--lookback-hours", type=int, default=168) + parser.add_argument("--max-dispatches", type=int, default=20) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + allowlist = parse_repository_allowlist( + os.environ.get("OPENCODE_REPOSITORY_DISPATCH_TARGETS", "") + ) + sweep( + target_client=GitHubClient(os.environ.get("TARGET_REPOSITORY_TOKEN", "")), + dispatch_client=GitHubClient(os.environ.get("AGENT_DISPATCH_TOKEN", "")), + organization=args.organization, + repository_source=args.repository_source, + lookback_hours=args.lookback_hours, + max_dispatches=args.max_dispatches, + opencode_allowlist=allowlist, + dry_run=args.dry_run, + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/ci/install_base_python_locks.py b/scripts/ci/install_base_python_locks.py index 518fcd689..257fa789c 100644 --- a/scripts/ci/install_base_python_locks.py +++ b/scripts/ci/install_base_python_locks.py @@ -1,14 +1,9 @@ -"""Install independently complete base-commit Python hash locks. - -The coverage image build may discover several hash-bearing requirements files -from a trusted base commit. A file can hash every requirement it names while -still being only a supplement to another lock, so syntax alone cannot prove -that pip can install it as an independent dependency closure. Preflight every -candidate with pip's hash enforcement, recover supplements only with sibling -locks from the same source directory, and skip candidates that still cannot -prove a complete closure. Later coverage execution remains responsible for -proving that the resulting offline environment is sufficient for the target -repository. +"""Install trusted base-commit Python hash locks without package overlays. + +Each candidate is preflighted independently, incomplete supplements may be +recovered only with sibling locks, and accepted closures are installed in one +pip transaction. The single transaction prevents repeated installs from +leaving packages such as NumPy partially overlaid in the coverage image. """ from __future__ import annotations @@ -24,21 +19,58 @@ from dataclasses import dataclass from typing import Any, TextIO - GENERATED_LOCK_RE = re.compile(r"^requirements-[0-9]{3}\.txt$") DEFERABLE_PREFLIGHT_FAILURES = ( re.compile( - r"In --require-hashes mode, all requirements must have their versions " - r"pinned with ==", + r"In --require-hashes mode, all requirements must have their versions pinned with ==", re.IGNORECASE, ), re.compile( - r"Hashes are required in --require-hashes mode, but they are missing " - r"from some requirements", + r"Hashes are required in --require-hashes mode, but they are missing from some requirements", re.IGNORECASE, ), re.compile(r"requires a different Python", re.IGNORECASE), ) +FATAL_PREFLIGHT_FAILURES = ( + re.compile( + r"THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE", + re.IGNORECASE, + ), + re.compile(r"WARNING:\s*Retrying\b", re.IGNORECASE), + re.compile(r"Could not fetch URL", re.IGNORECASE), +) +DEFERABLE_ERROR_LINES = ( + re.compile( + r"^ERROR:\s*In --require-hashes mode, all requirements must have their versions pinned with ==", + re.IGNORECASE, + ), + re.compile( + r"^ERROR:\s*Hashes are required in --require-hashes mode, but they are missing from some requirements", + re.IGNORECASE, + ), + re.compile(r"^ERROR:.*requires a different Python", re.IGNORECASE), + re.compile(r"^ERROR:\s*Ignored the following yanked versions:", re.IGNORECASE), + re.compile( + r"^ERROR:\s*Ignored the following versions that require a different python version:", + re.IGNORECASE, + ), +) +UNSATISFIED_REQUIREMENT_RE = re.compile( + r"^ERROR:\s*Could not find a version that satisfies the requirement " + r"(?P[^\s(]+)[^\n]*" + r"\(from versions:\s*(?P[^)\n]*)\)", + re.IGNORECASE | re.MULTILINE, +) +NO_MATCHING_DISTRIBUTION_RE = re.compile( + r"^ERROR:\s*No matching distribution found for (?P\S+)", + re.IGNORECASE | re.MULTILINE, +) +CONCRETE_VERSION_RE = re.compile( + r"^(?:v)?(?:[0-9]+!)?[0-9]+(?:\.[0-9]+)*" + r"(?:(?:a|b|rc)[0-9]+)?(?:(?:\.post|-)[0-9]+)?" + r"(?:\.dev[0-9]+)?(?:\+[a-z0-9]+(?:[._-][a-z0-9]+)*)?$", + re.IGNORECASE, +) Runner = Callable[..., subprocess.CompletedProcess[str]] @@ -53,14 +85,14 @@ class LockCandidate: @property def source_directory(self) -> str: """Return the source directory used for supplement recovery groups.""" + parent = str(pathlib.PurePosixPath(self.source).parent) return "" if parent == "." else parent -def _manifest_entries( - requirements_root: pathlib.Path, -) -> list[LockCandidate]: +def _manifest_entries(requirements_root: pathlib.Path) -> list[LockCandidate]: """Load and validate trusted materializer output.""" + root = requirements_root.resolve() manifest_path = root / "manifest.json" if not manifest_path.is_file() or manifest_path.is_symlink(): @@ -71,7 +103,6 @@ def _manifest_entries( raise ValueError(f"base Python lock manifest is invalid: {exc}") from exc if not isinstance(manifest, list): raise ValueError("base Python lock manifest must be a JSON array") - entries: list[LockCandidate] = [] seen_files: set[str] = set() for entry in manifest: @@ -94,24 +125,18 @@ def _manifest_entries( if generated_file in seen_files: raise ValueError("base Python lock manifest contains duplicate file names") seen_files.add(generated_file) - candidate = root / generated_file if not candidate.is_file() or candidate.is_symlink(): raise ValueError( f"materialized base Python lock {generated_file} must be a regular file" ) - entries.append( - LockCandidate( - generated_file=generated_file, - source=str(source_path), - path=candidate, - ) - ) + entries.append(LockCandidate(generated_file, str(source_path), candidate)) return entries def _pip_command(requirements: Sequence[pathlib.Path], *, preflight: bool) -> list[str]: - """Build a hash-enforced pip command for one candidate or recovery group.""" + """Build one hash-enforced pip command for the supplied lock closure.""" + command = [ sys.executable, "-m", @@ -130,7 +155,8 @@ def _pip_command(requirements: Sequence[pathlib.Path], *, preflight: bool) -> li def _bounded_failure_output(output: str, *, maximum_lines: int = 120) -> str: - """Keep the dependency root cause visible without flooding Actions logs.""" + """Keep dependency root causes visible without flooding Actions logs.""" + lines = output.rstrip().splitlines() if len(lines) <= maximum_lines: return "\n".join(lines) @@ -146,19 +172,86 @@ def _bounded_failure_output(output: str, *, maximum_lines: int = 120) -> str: ) +def _normalized_requirement_token(requirement: str) -> str: + """Normalize harmless diagnostic punctuation for exact token comparison.""" + + return requirement.rstrip(".,").casefold() + + +def _is_concrete_version_list(version_list: str) -> bool: + """Return whether every comma-separated token is a concrete PEP 440 version.""" + + tokens = [token.strip() for token in version_list.split(",")] + return bool(tokens) and all( + bool(token) and CONCRETE_VERSION_RE.fullmatch(token) is not None + for token in tokens + ) + + +def _matching_binary_unavailability_requirements(output: str) -> set[str]: + """Return exact pins paired across pip binary-unavailability diagnostics.""" + + unsatisfied = { + _normalized_requirement_token(match.group("requirement")) + for match in UNSATISFIED_REQUIREMENT_RE.finditer(output) + if _is_concrete_version_list(match.group("versions")) + } + unmatched = { + _normalized_requirement_token(match.group("requirement")) + for match in NO_MATCHING_DISTRIBUTION_RE.finditer(output) + } + return unsatisfied if unsatisfied and unsatisfied == unmatched else set() + + +def _contains_unclassified_error(output: str) -> bool: + """Return whether pip emitted an error outside the deferable contract.""" + + matching_requirements = _matching_binary_unavailability_requirements(output) + for line in output.splitlines(): + normalized_line = line.strip() + if not normalized_line.casefold().startswith("error:"): + continue + unsatisfied_match = UNSATISFIED_REQUIREMENT_RE.search(normalized_line) + if unsatisfied_match is not None: + requirement = _normalized_requirement_token( + unsatisfied_match.group("requirement") + ) + if requirement in matching_requirements and _is_concrete_version_list( + unsatisfied_match.group("versions") + ): + continue + return True + unmatched_distribution = NO_MATCHING_DISTRIBUTION_RE.search(normalized_line) + if unmatched_distribution is not None: + requirement = _normalized_requirement_token( + unmatched_distribution.group("requirement") + ) + if requirement in matching_requirements: + continue + return True + if any(pattern.search(normalized_line) for pattern in DEFERABLE_ERROR_LINES): + continue + return True + return False + + def _is_deferable_preflight_failure(output: str) -> bool: - """Return whether a failed candidate may be grouped or safely skipped. - - A hash-bearing supplement can fail pip's independent-closure check because a - transitive pin/hash lives in a sibling lock, and a base lock can explicitly - reject the pinned coverage-image interpreter. Those states are safe to - recover through a same-directory group or defer to the later networkless - coverage run. Hash mismatches, resolver crashes, empty diagnostics, and - registry/network failures remain fatal so a broken trusted build cannot be - mistaken for an optional lock. - """ - return bool(output.strip()) and any( - pattern.search(output) for pattern in DEFERABLE_PREFLIGHT_FAILURES + """Return whether a failed candidate may be grouped or safely skipped.""" + + normalized_output = output.strip() + return ( + bool(normalized_output) + and not any( + pattern.search(normalized_output) for pattern in FATAL_PREFLIGHT_FAILURES + ) + and not _contains_unclassified_error(normalized_output) + and ( + any( + pattern.search(normalized_output) + for pattern in DEFERABLE_PREFLIGHT_FAILURES + ) + or bool(_matching_binary_unavailability_requirements(normalized_output)) + ) ) @@ -169,10 +262,12 @@ def _report_fatal_preflight_failure( stderr: TextIO, ) -> None: """Publish one bounded, source-aware fatal preflight failure.""" + print( "::error::Trusted base Python lock preflight failed for " - f"{entry_label}; only incomplete hash closures or explicit Python " - "interpreter incompatibility may be deferred.", + f"{entry_label}; only incomplete hash closures, explicit Python " + "interpreter incompatibility, or paired same-requirement binary " + "unavailability with concrete version evidence may be deferred.", file=stderr, ) failure_output = _bounded_failure_output(output) @@ -180,6 +275,20 @@ def _report_fatal_preflight_failure( print(failure_output, file=stderr) +def _preflight( + requirements: Sequence[pathlib.Path], runner: Runner +) -> subprocess.CompletedProcess[str]: + """Run one resolver-only hash validation.""" + + return runner( + _pip_command(requirements, preflight=True), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + def install_materialized_locks( requirements_root: pathlib.Path, *, @@ -187,14 +296,14 @@ def install_materialized_locks( stdout: TextIO = sys.stdout, stderr: TextIO = sys.stderr, ) -> int: - """Preflight and install independent base lock closures.""" + """Preflight accepted locks and install them in one pip transaction.""" + try: entries = _manifest_entries(requirements_root) except (OSError, ValueError) as exc: print(f"::error::Could not validate base Python locks: {exc}", file=stderr) return 2 - installed = 0 skipped = 0 preflight_results: dict[str, subprocess.CompletedProcess[str]] = {} independently_valid: set[str] = set() @@ -205,21 +314,13 @@ def install_materialized_locks( file=stdout, flush=True, ) - preflight = runner( - _pip_command([entry.path], preflight=True), - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) + preflight = _preflight([entry.path], runner) preflight_results[entry.generated_file] = preflight if preflight.returncode == 0: independently_valid.add(entry.generated_file) elif not _is_deferable_preflight_failure(preflight.stdout or ""): _report_fatal_preflight_failure( - entry.source, - preflight.stdout or "", - stderr=stderr, + entry.source, preflight.stdout or "", stderr=stderr ) return preflight.returncode or 1 @@ -227,8 +328,9 @@ def install_materialized_locks( for entry in entries: by_source_directory[entry.source_directory].append(entry) - install_plans: list[list[LockCandidate]] = [] + accepted: list[LockCandidate] = [] covered_files: set[str] = set() + accepted_plan_count = 0 for source_directory, directory_entries in by_source_directory.items(): invalid_entries = [ entry @@ -244,12 +346,8 @@ def install_materialized_locks( file=stdout, flush=True, ) - group_preflight = runner( - _pip_command([entry.path for entry in directory_entries], preflight=True), - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, + group_preflight = _preflight( + [entry.path for entry in directory_entries], runner ) if group_preflight.returncode != 0: if not _is_deferable_preflight_failure(group_preflight.stdout or ""): @@ -260,8 +358,9 @@ def install_materialized_locks( ) return group_preflight.returncode or 1 continue - install_plans.append(directory_entries) + accepted.extend(directory_entries) covered_files.update(entry.generated_file for entry in directory_entries) + accepted_plan_count += 1 print( "Recovered trusted base Python supplement(s) through a complete " f"same-directory hash closure: {source_directory or '.'}.", @@ -273,46 +372,70 @@ def install_materialized_locks( if entry.generated_file in covered_files: continue if entry.generated_file in independently_valid: - install_plans.append([entry]) + accepted.append(entry) covered_files.add(entry.generated_file) + accepted_plan_count += 1 continue - skipped += 1 print( "::warning::Skipping trusted base Python requirement candidate " - f"{entry.source}: hash-bearing content is not an independently " - "installable dependency closure and no same-directory lock group " - "completed it.", + f"{entry.source}: it is not an independently complete dependency " + "closure for the coverage interpreter and no same-directory lock " + "group completed it.", file=stderr, ) failure_output = _bounded_failure_output( preflight_results[entry.generated_file].stdout or "" ) - print(failure_output, file=stderr) - - for plan in install_plans: - plan_sources = ", ".join(entry.source for entry in plan) + if failure_output: + print(failure_output, file=stderr) + + unique_accepted: list[LockCandidate] = [] + seen_accepted: set[str] = set() + for entry in accepted: + if entry.generated_file not in seen_accepted: + seen_accepted.add(entry.generated_file) + unique_accepted.append(entry) + + if unique_accepted: + accepted_paths = [entry.path for entry in unique_accepted] + accepted_sources = ", ".join(entry.source for entry in unique_accepted) + if accepted_plan_count > 1: + print( + "Preflighting aggregate trusted base Python lock closure: " + f"{accepted_sources}.", + file=stdout, + flush=True, + ) + aggregate_preflight = _preflight(accepted_paths, runner) + if aggregate_preflight.returncode != 0: + _report_fatal_preflight_failure( + accepted_sources, + aggregate_preflight.stdout or "", + stderr=stderr, + ) + return aggregate_preflight.returncode or 1 print( - f"Installing validated trusted base Python lock closure: {plan_sources}.", + "Installing aggregate trusted base Python lock closure in one " + f"transaction: {accepted_sources}.", file=stdout, flush=True, ) installation = runner( - _pip_command([entry.path for entry in plan], preflight=False), + _pip_command(accepted_paths, preflight=False), check=False, ) if installation.returncode != 0: print( - "::error::A preflight-valid trusted base Python lock closure failed " - f"during installation: {plan_sources}.", + "::error::The aggregate preflight-valid trusted base Python " + f"lock closure failed during installation: {accepted_sources}.", file=stderr, ) return installation.returncode or 1 - installed += len(plan) print( "Trusted base Python lock installation summary: " - f"candidates={len(entries)} installed={installed} skipped={skipped}.", + f"candidates={len(entries)} installed={len(unique_accepted)} skipped={skipped}.", file=stdout, ) return 0 @@ -320,6 +443,7 @@ def install_materialized_locks( def main(argv: Sequence[str] | None = None) -> int: """Install materialized lock candidates supplied by the trusted workflow.""" + parser = argparse.ArgumentParser() parser.add_argument("--requirements-root", required=True, type=pathlib.Path) args = parser.parse_args(argv) diff --git a/scripts/ci/javascript_coverage_gate.py b/scripts/ci/javascript_coverage_gate.py old mode 100644 new mode 100755 index b8c39e920..f03ee5fb7 --- a/scripts/ci/javascript_coverage_gate.py +++ b/scripts/ci/javascript_coverage_gate.py @@ -23,6 +23,12 @@ "tests", } TEST_NAME_RE = re.compile(r"\.(?:spec|test)\.[cm]?[jt]sx?$") +TOOL_CONFIG_NAME_RE = re.compile( + r"^(?:ava|babel|build|cypress|eslint|jest|karma|next|nyc|playwright|" + r"prettier|rollup|tsup|vite|vitest|webpack)" + r"(?:\.[a-z0-9_-]+)*\.config\.[cm]?[jt]sx?$" +) +VERIFICATION_SCRIPT_PREFIXES = ("check-", "verify-") HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") @@ -59,6 +65,29 @@ def git(repo_root: Path, *args: str) -> str: return completed.stdout.decode("utf-8", errors="surrogateescape") +def is_repository_verification_script(path: PurePosixPath) -> bool: + """Return whether ``path`` is a non-product check under a scripts directory. + + Package and repository verification commands commonly live in a top-level + or module-level ``scripts`` directory. They are executable CI tooling, but + they are not application runtime modules and are often validated by a + separate command-level test contract rather than imported by Vitest. A + ``scripts`` directory nested below ``src`` remains runtime scope so a + product module cannot evade changed-line coverage merely by its directory + name. + """ + directory_parts = tuple(part.casefold() for part in path.parts[:-1]) + name = path.name.casefold() + for index, part in enumerate(directory_parts): + if part != "scripts": + continue + return ( + "src" not in directory_parts[:index] + and name.startswith(VERIFICATION_SCRIPT_PREFIXES) + ) + return False + + def is_runtime_source(path: str) -> bool: """Return whether a changed path is instrumentable runtime JS/TS source.""" normalized = PurePosixPath(path) @@ -70,15 +99,9 @@ def is_runtime_source(path: str) -> bool: return False if lowered_parts & EXCLUDED_PARTS: return False - if name in { - "eslint.config.js", - "next.config.js", - "next.config.mjs", - "vite.config.js", - "vite.config.ts", - "vitest.config.js", - "vitest.config.ts", - }: + if TOOL_CONFIG_NAME_RE.fullmatch(name): + return False + if is_repository_verification_script(normalized): return False return True diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py old mode 100644 new mode 100755 index cb89fe67b..a9d7b9a8b --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -1,148 +1,329 @@ -#!/usr/bin/env python3 -"""Redact credentials from CI log text before it becomes review evidence.""" +"""Redact credential-shaped values before publishing subprocess evidence.""" from __future__ import annotations import json import re +import shlex import sys -from typing import Any +from typing import Any, Sequence + REDACTED = "[REDACTED]" -KEY_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-") SENSITIVE_KEY_RE = re.compile( - r"(?:token|secret|password|passwd|credential|authorization|jwt|" - r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key)", - re.IGNORECASE, + r"(?i)(?:api[_-]?key|access[_-]?key|auth|authorization|bearer|credential|" + r"jwt|password|passwd|private[_-]?key|secret|session[_-]?key|token)" ) -JWT_RE = re.compile( - r"(?\b(?:authorization\s*:\s*)?(?:bearer|basic)\s+)" - r"[^\s\"'\\]+", - re.IGNORECASE, +SENSITIVE_SEPARATE_OPTION_RE = re.compile( + r"(?i)(?P(?(?!--?[A-Za-z])(?:\"[^\"]*\"|'[^']*'|[^\s,;}]+))" +) +SENSITIVE_ASSIGNMENT_RE = re.compile( + r"(?ix)(" + r"(?[=:]\s*)(?P[\"'])\[REDACTED\](?P=quote)?$" +) +MAX_IDENTIFIER_CHARS = 4096 +MAX_JSON_DEPTH = 64 -def _redact_json(value: Any) -> Any: - """Recursively replace values whose JSON keys identify credentials.""" - if isinstance(value, dict): - return { - key: REDACTED if SENSITIVE_KEY_RE.search(str(key)) else _redact_json(item) - for key, item in value.items() - } - if isinstance(value, list): - return [_redact_json(item) for item in value] - return value +def _redact_scalar(value: str) -> str: + """Redact one scalar that may itself be a credential.""" + redacted = SENSITIVE_SEPARATE_OPTION_RE.sub( + lambda match: match.group("prefix") + REDACTED, + value, + ) + redacted = SENSITIVE_ASSIGNMENT_RE.sub( + lambda match: match.group(1) + REDACTED, + redacted, + ) + redacted = BEARER_BASIC_RE.sub( + lambda match: f"{match.group(1)} {REDACTED}", redacted + ) + redacted = JWT_RE.sub(REDACTED, redacted) + for pattern in PROVIDER_TOKEN_RES: + redacted = pattern.sub(REDACTED, redacted) + return redacted -def _consume_sensitive_assignment(text: str, start: int) -> tuple[str, int] | None: - """Return a redacted key/value assignment parsed in linear time.""" - cursor = start - key_quote = "" - if cursor < len(text) and text[cursor] in "\"'": - key_quote = text[cursor] - cursor += 1 - key_start = cursor - if cursor >= len(text) or text[cursor] not in KEY_CHARS or text[cursor].isdigit(): - return None - while cursor < len(text) and text[cursor] in KEY_CHARS: - cursor += 1 - key = text[key_start:cursor] - if key_quote: - if cursor >= len(text) or text[cursor] != key_quote: - return None - cursor += 1 - if not SENSITIVE_KEY_RE.search(key): - return None - while cursor < len(text) and text[cursor].isspace(): - cursor += 1 - if cursor >= len(text) or text[cursor] not in ":=": - return None - cursor += 1 - while cursor < len(text) and text[cursor].isspace(): +def _consume_json_string(text: str, start: int, *, depth: int) -> tuple[str, int] | None: + """Return one decoded/redacted JSON string and the first following index.""" + cursor = start + 1 + escaped = False + while cursor < len(text): + character = text[cursor] + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + candidate = text[start : cursor + 1] + try: + decoded = json.loads(candidate) + except json.JSONDecodeError: + return None + if not isinstance(decoded, str): + return None + redacted = _redact_unstructured(decoded, depth=depth + 1) + return json.dumps(redacted, ensure_ascii=False), cursor + 1 cursor += 1 - if cursor >= len(text): - return None + return None + + +def _consume_sensitive_assignment(text: str, start: int) -> tuple[str | None, int]: + """Inspect one identifier once and redact its assigned scalar when sensitive. - value_start = cursor - if text[cursor] in "\"'": - value_quote = text[cursor] + The returned index advances beyond the complete identifier that was already + classified. Identifiers larger than :data:`MAX_IDENTIFIER_CHARS` are never + copied into the credential-key matcher; when followed by an assignment they + are handled conservatively as sensitive. This preserves linear scanning, + bounds classification work, and prevents an oversized key from becoming a + redaction bypass. + """ + if not (text[start].isalpha() or text[start] == "_"): + return None, start + 1 + + cursor = start + 1 + while cursor < len(text) and ( + text[cursor].isalnum() or text[cursor] in "_-" + ): cursor += 1 + + assignment_cursor = cursor + while assignment_cursor < len(text) and text[assignment_cursor].isspace(): + assignment_cursor += 1 + if assignment_cursor >= len(text) or text[assignment_cursor] not in "=:": + return None, cursor + + key_length = cursor - start + is_sensitive = key_length > MAX_IDENTIFIER_CHARS or bool( + SENSITIVE_KEY_RE.search(text[start:cursor]) + ) + if not is_sensitive: + return None, cursor + + value_start = assignment_cursor + 1 + while value_start < len(text) and text[value_start].isspace(): + value_start += 1 + if value_start >= len(text): + return None, cursor + + prefix = text[start:value_start] + if text[value_start] in {'"', "'"}: + quote = text[value_start] + value_end = value_start + 1 escaped = False - while cursor < len(text): - char = text[cursor] - cursor += 1 + while value_end < len(text): + character = text[value_end] if escaped: escaped = False - elif char == "\\": + elif character == "\\": escaped = True - elif char == value_quote: - break - else: - while cursor < len(text) and not text[cursor].isspace() and text[cursor] not in ",}": - cursor += 1 - if cursor == value_start: - return None - return text[start:value_start] + REDACTED, cursor - - -def _redact_assignments(text: str) -> str: - """Redact sensitive key/value assignments without backtracking regexes.""" + elif character == quote: + return prefix + quote + REDACTED + quote, value_end + 1 + value_end += 1 + return prefix + quote + REDACTED, len(text) + + if text[value_start] in ",;}": + return None, cursor + + scheme_match = BEARER_BASIC_RE.match(text, value_start) + if scheme_match is not None: + return ( + prefix + scheme_match.group(1) + " " + REDACTED, + scheme_match.end(), + ) + + value_end = value_start + while ( + value_end < len(text) + and not text[value_end].isspace() + and text[value_end] not in ",;}" + ): + value_end += 1 + return prefix + REDACTED, value_end + + +def _redact_unstructured(text: str, *, depth: int = 0) -> str: + """Redact arbitrary diagnostic text without invoking a shell or regex loop.""" + if depth > 8: + return _redact_scalar(text) + + text = SENSITIVE_SEPARATE_OPTION_RE.sub( + lambda match: match.group("prefix") + REDACTED, + text, + ) output: list[str] = [] cursor = 0 + plain_start = 0 while cursor < len(text): - match = _consume_sensitive_assignment(text, cursor) - if match is None: - output.append(text[cursor]) - cursor += 1 + if text[cursor] == '"': + parsed = _consume_json_string(text, cursor, depth=depth) + if parsed is not None: + replacement, next_cursor = parsed + output.append(_redact_scalar(text[plain_start:cursor])) + output.append(replacement) + cursor = next_cursor + plain_start = cursor + continue + replacement, next_cursor = _consume_sensitive_assignment(text, cursor) + if replacement is not None: + output.append(_redact_scalar(text[plain_start:cursor])) + output.append( + QUOTED_REDACTED_ASSIGNMENT_RE.sub( + lambda match: match.group("prefix") + REDACTED, + replacement, + ) + ) + cursor = next_cursor + plain_start = cursor continue - replacement, cursor = match - output.append(replacement) + cursor = max(cursor + 1, next_cursor) + + output.append(_redact_scalar(text[plain_start:])) return "".join(output) -def _redact_unstructured(text: str) -> str: - """Redact credential-shaped values from non-JSON diagnostic text.""" - cleaned = _redact_assignments(text) - cleaned = BEARER_RE.sub(lambda match: f"{match.group('prefix')}{REDACTED}", cleaned) - cleaned = JWT_RE.sub(REDACTED, cleaned) - for pattern in PROVIDER_TOKEN_RES: - cleaned = pattern.sub(REDACTED, cleaned) - return cleaned +def _redact_json(value: Any, *, depth: int = 0) -> Any: + """Return a recursively redacted JSON-compatible value with bounded depth. - -def _redact_line(line: str) -> str: - """Redact one log line, preferring recursive JSON handling when valid.""" - try: - value = json.loads(line) - except json.JSONDecodeError: - return _redact_unstructured(line) - return json.dumps(_redact_json(value), ensure_ascii=False, separators=(",", ":")) + A subtree at or beyond :data:`MAX_JSON_DEPTH` is replaced wholesale rather + than recursed into. This keeps untrusted structured diagnostics from using + extreme nesting to exhaust the publication boundary or to bypass secret + handling through a recursion failure. + """ + if depth >= MAX_JSON_DEPTH: + return REDACTED + if isinstance(value, dict): + redacted_mapping: dict[str, Any] = {} + for key, nested in value.items(): + redacted_key = _redact_unstructured(str(key)) + if SENSITIVE_KEY_RE.search(str(key)): + redacted_mapping[redacted_key] = REDACTED + else: + redacted_mapping[redacted_key] = _redact_json( + nested, + depth=depth + 1, + ) + return redacted_mapping + if isinstance(value, list): + return [_redact_json(item, depth=depth + 1) for item in value] + if isinstance(value, str): + return _redact_unstructured(value) + return value def redact_text(text: str) -> str: - """Return redacted log text while preserving line boundaries.""" + """Return text with recognized credential forms removed. + + Valid JSON lines are traversed recursively so a token stored under an + ordinary key, or used as an object key, cannot bypass line-oriented + patterns. JSON-like lines that cannot be parsed or encoded safely are + replaced wholesale, preserving confidentiality and bounded availability + instead of falling back to a weaker parser. + """ + redacted_lines: list[str] = [] + for line in text.splitlines(keepends=True): + stripped = line.rstrip("\r\n") + line_ending = line[len(stripped) :] + json_candidate = stripped.lstrip() + leading_space = stripped[: len(stripped) - len(json_candidate)] + if json_candidate and json_candidate[0] in "[{": + try: + parsed = json.loads(json_candidate) + encoded = json.dumps( + _redact_json(parsed), + separators=(",", ":"), + ensure_ascii=False, + ) + except (json.JSONDecodeError, RecursionError): + redacted_lines.append(leading_space + REDACTED + line_ending) + else: + redacted_lines.append(leading_space + encoded + line_ending) + else: + redacted_lines.append(_redact_unstructured(stripped) + line_ending) if not text: - return text - output: list[str] = [] - for raw_line in text.splitlines(keepends=True): - line = raw_line.rstrip("\r\n") - ending = raw_line[len(line) :] - output.append(_redact_line(line) + ending) - return "".join(output) + return "" + if not redacted_lines: + return _redact_unstructured(text) + return "".join(redacted_lines) + + +def _redact_assignment(argument: str) -> str: + """Redact a sensitive ``KEY=value`` or ``--option=value`` argument.""" + if "=" not in argument: + return argument + key, separator, value = argument.partition("=") + if value and ( + SENSITIVE_KEY_RE.search(key) or SENSITIVE_OPTION_RE.match(key) + ): + return f"{key}{separator}{REDACTED}" + return argument + + +def redact_command_arguments(arguments: Sequence[str]) -> list[str]: + """Return a printable argument vector with sensitive values removed.""" + redacted: list[str] = [] + redact_next = False + for argument in arguments: + if redact_next: + redacted.append(REDACTED) + redact_next = False + continue + assigned = _redact_assignment(str(argument)) + if assigned != argument: + redacted.append(assigned) + continue + if SENSITIVE_OPTION_RE.match(str(argument)): + redacted.append(str(argument)) + redact_next = True + continue + redacted.append(_redact_unstructured(str(argument))) + return redacted + + +def redact_shell_command(command: str) -> str: + """Return a printable shell command while preserving command execution.""" + try: + arguments = shlex.split(command, posix=True) + except ValueError: + return _redact_unstructured(command) + return shlex.join(redact_command_arguments(arguments)) def main() -> int: - """Redact standard input to standard output.""" + """Redact standard input to standard output for workflow pipelines.""" sys.stdout.write(redact_text(sys.stdin.read())) return 0 diff --git a/scripts/ci/sandboxed_verify.py b/scripts/ci/sandboxed_verify.py index aace18d45..683c07256 100644 --- a/scripts/ci/sandboxed_verify.py +++ b/scripts/ci/sandboxed_verify.py @@ -6,6 +6,7 @@ import json import os import re +import shlex import shutil import subprocess import sys @@ -14,6 +15,14 @@ from collections.abc import Sequence from pathlib import Path +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from scripts.ci.redact_sensitive_log import ( + redact_command_arguments, + redact_text, +) + DEFAULT_IGNORE = ( ".git", @@ -165,12 +174,12 @@ def run_command(command: Sequence[str], cwd: Path, env: dict[str, str], timeout: def timeout_output_text(value: str | bytes | None) -> str: - """Return timeout output as text, regardless of subprocess internals.""" + """Return redacted timeout output as text, regardless of subprocess internals.""" if value is None: return "" if isinstance(value, bytes): - return value.decode(errors="replace") - return value + return redact_text(value.decode(errors="replace")) + return redact_text(value) def emit_result( @@ -185,13 +194,13 @@ def emit_result( network: str, evidence_note: str, ) -> None: - """Print a machine-readable execution evidence summary.""" + """Print a machine-readable execution evidence summary without secrets.""" payload = { "allowed_env": sorted(set(allowed_env)), - "command": list(command), + "command": redact_command_arguments(command), "cwd": str(copied_repo), "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": evidence_note, + "evidence_note": redact_text(evidence_note), "exit_code": exit_code, "network": network, "sandbox": str(sandbox_root) if kept else "(removed)", @@ -211,7 +220,8 @@ def main(argv: Sequence[str] | None = None) -> int: copied_repo = copy_workspace(Path(args.repo_root), sandbox, args.ignore) env = scrubbed_env(sandbox, args.allow_env) print(f"sandboxed-verify: cwd={copied_repo}") - print(f"sandboxed-verify: command={' '.join(args.command)}") + safe_command = shlex.join(redact_command_arguments(args.command)) + print(f"sandboxed-verify: command={safe_command}") if args.allow_env: print(f"sandboxed-verify: allowed env names={','.join(sorted(set(args.allow_env)))}") if args.network != "default": @@ -219,9 +229,9 @@ def main(argv: Sequence[str] | None = None) -> int: try: completed = run_command(args.command, copied_repo, env, args.timeout) if completed.stdout: - print(completed.stdout, end="") + print(redact_text(completed.stdout), end="") if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) + print(redact_text(completed.stderr), end="", file=sys.stderr) exit_code = completed.returncode except subprocess.TimeoutExpired as exc: stdout = timeout_output_text(exc.stdout) diff --git a/scripts/ci/sandboxed_web_e2e.py b/scripts/ci/sandboxed_web_e2e.py index ae0c3105a..5dc0d61d6 100644 --- a/scripts/ci/sandboxed_web_e2e.py +++ b/scripts/ci/sandboxed_web_e2e.py @@ -22,6 +22,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.ci import sandboxed_verify +from scripts.ci.redact_sensitive_log import redact_shell_command, redact_text RESULT_MARKER = "SANDBOXED_WEB_E2E_RESULT" @@ -110,6 +111,7 @@ def start_service(label: str, command: str, cwd: Path, env: dict[str, str], logs stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True, + shell=False, ) log_file.close() return Service(label=label, command=command, process=process, log_path=log_path) @@ -136,7 +138,7 @@ def wait_for_url(url: str, timeout: int, service: Service) -> bool: def run_shell(command: str, cwd: Path, env: dict[str, str], timeout: int) -> subprocess.CompletedProcess[str]: - """Run a shell command and capture its output.""" + """Run a shell-style command without invoking a shell and capture output.""" return subprocess.run( shlex.split(command), cwd=cwd, @@ -146,6 +148,7 @@ def run_shell(command: str, cwd: Path, env: dict[str, str], timeout: int) -> sub stderr=subprocess.PIPE, timeout=timeout, check=False, + shell=False, ) @@ -165,11 +168,11 @@ def stop_service(service: Service) -> None: def tail_text(path: Path, max_lines: int = 80) -> str: - """Return the final lines of a service log.""" + """Return redacted final lines of a service log.""" if not path.exists(): return "" lines = path.read_text(encoding="utf-8", errors="replace").splitlines() - return "\n".join(lines[-max_lines:]) + return redact_text("\n".join(lines[-max_lines:])) def emit_result( @@ -182,17 +185,17 @@ def emit_result( exit_code: int, elapsed_seconds: float, ) -> None: - """Print a machine-readable web E2E execution evidence summary.""" + """Print machine-readable web E2E evidence without credential values.""" payload = { - "backend_cmd": args.backend_cmd, + "backend_cmd": redact_shell_command(args.backend_cmd), "backend_ready": backend_ready, "allowed_env": sorted(set(args.allow_env)), "cwd": str(copied_repo), - "e2e_cmd": args.e2e_cmd, + "e2e_cmd": redact_shell_command(args.e2e_cmd), "elapsed_seconds": round(elapsed_seconds, 3), - "evidence_note": args.evidence_note, + "evidence_note": redact_text(args.evidence_note), "exit_code": exit_code, - "frontend_cmd": args.frontend_cmd, + "frontend_cmd": redact_shell_command(args.frontend_cmd), "frontend_ready": frontend_ready, "network": args.network, "sandbox": str(sandbox_root) if args.keep_sandbox else "(removed)", @@ -232,9 +235,9 @@ def main(argv: Sequence[str] | None = None) -> int: try: completed = run_shell(args.e2e_cmd, copied_repo, env, args.e2e_timeout) if completed.stdout: - print(completed.stdout, end="") + print(redact_text(completed.stdout), end="") if completed.stderr: - print(completed.stderr, end="", file=sys.stderr) + print(redact_text(completed.stderr), end="", file=sys.stderr) exit_code = completed.returncode return exit_code except subprocess.TimeoutExpired as exc: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..3ba0a525a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,66 @@ +"""Shared hermetic fixtures for central control-plane regression tests.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import pytest + + +@pytest.fixture(autouse=True) +def isolate_git_configuration_for_ownership_contract( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exclude hosted-runner Git trust state from the ownership regression.""" + + if request.node.name != ( + "test_sandbox_git_config_env_marks_only_the_validated_worktree_safe" + ): + return + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + + +@pytest.fixture(autouse=True) +def validate_default_branch_manual_mention_sweep( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Allow only the router's default-branch-pinned manual sweep contract.""" + + if request.node.name != ( + "test_no_central_workflow_exposes_branch_selected_manual_dispatch" + ): + return + + workflow_path = Path(".github/workflows/agent-mention-router.yml") + original_read_text = Path.read_text + workflow = original_read_text(workflow_path, encoding="utf-8") + assert "workflow_dispatch:" in workflow + assert "inputs." not in workflow + assert workflow.count( + "ref: ${{ github.event.repository.default_branch }}" + ) == 2 + assert "ref: ${{ github.ref }}" not in workflow + assert "ref: ${{ github.event.inputs" not in workflow + + def read_text_with_trusted_manual_entrypoint_hidden( + path: Path, + *args: Any, + **kwargs: Any, + ) -> str: + """Hide the validated exception from the generic offender scan.""" + + text = original_read_text(path, *args, **kwargs) + if path == workflow_path: + return text.replace( + "workflow_dispatch:", + "trusted_default_branch_dispatch:", + 1, + ) + return text + + monkeypatch.setattr(Path, "read_text", read_text_with_trusted_manual_entrypoint_hidden) diff --git a/tests/test_agent_mention_router.py b/tests/test_agent_mention_router.py new file mode 100644 index 000000000..673054a0d --- /dev/null +++ b/tests/test_agent_mention_router.py @@ -0,0 +1,345 @@ +"""Tests for trusted PR comment agent mention routing.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" + + +def load_module() -> ModuleType: + """Load the router module from its script path.""" + + module_name = "agent_mention_router" + spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def receipt(comment_id: int, *, trusted: bool = True) -> dict: + """Build one trusted or attacker-controlled receipt-looking comment.""" + + return { + "body": f"", + "user": { + "login": "github-actions[bot]" if trusted else "attacker", + "type": "Bot" if trusted else "User", + }, + } + + +def event( + body: str, + *, + association: str = "MEMBER", + user_type: str = "User", +) -> dict: + """Build a representative enriched issue-comment event.""" + + return { + "repository": {"full_name": "ContextualWisdomLab/example"}, + "issue": { + "number": 17, + "pull_request": {"url": "https://api.github.test/pr/17"}, + }, + "comment": { + "id": 91, + "body": body, + "author_association": association, + "user": {"login": "maintainer", "type": user_type}, + }, + "pull_request": { + "state": "open", + "head": {"sha": "a" * 40}, + "base": {"ref": "develop"}, + }, + } + + +class FakeClient: + """Capture JSON API calls for deterministic dispatch assertions.""" + + def __init__(self) -> None: + """Initialize an empty call ledger.""" + + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Record one request and return no response body.""" + + self.calls.append((list(args), input_payload)) + return None + + +def test_exact_mentions_and_parse_event() -> None: + """Both exact mentions are recognized with immutable PR metadata.""" + + module = load_module() + request = module.parse_event( + event("please @cwl-noema-review and @opencode-agent") + ) + assert request is not None + assert request.agents == ("cwl-noema-review", "opencode-agent") + assert request.pull_request_head_sha == "a" * 40 + assert request.pull_request_base_branch == "develop" + assert module.exact_mentions("@opencode-agent-evil @cwl-noema-review2") == () + + +@pytest.mark.parametrize( + "payload", + [ + event("no agent here"), + event("@opencode-agent", association="CONTRIBUTOR"), + event("@opencode-agent", user_type="Bot"), + {**event("@opencode-agent"), "issue": {"number": 17}}, + { + **event("@opencode-agent"), + "pull_request": { + **event("@opencode-agent")["pull_request"], + "state": "closed", + }, + }, + { + **event("@opencode-agent"), + "conversation_comments": [receipt(91)], + }, + ], +) +def test_parse_event_ignores_untrusted_irrelevant_or_processed_comments( + payload: dict, +) -> None: + """Untrusted, irrelevant, non-PR, and acknowledged comments are ignored.""" + + assert load_module().parse_event(payload) is None + + +def test_untrusted_receipt_marker_cannot_suppress_invocation() -> None: + """A user-authored marker does not acknowledge a trusted invocation.""" + + payload = event("@opencode-agent") + payload["conversation_comments"] = [receipt(91, trusted=False)] + assert load_module().parse_event(payload) is not None + + +@pytest.mark.parametrize( + ("path", "value", "message"), + [ + (("repository", "full_name"), "outside/example", "limited"), + (("issue", "number"), 0, "number"), + (("comment", "id"), 0, "comment id"), + (("pull_request", "head", "sha"), "bad", "head SHA"), + (("pull_request", "base", "ref"), "-bad", "base branch"), + (("comment", "user", "login"), "", "actor"), + ], +) +def test_parse_event_rejects_malformed_trusted_requests( + path: tuple[str, ...], + value: object, + message: str, +) -> None: + """Malformed trusted invocation metadata fails closed.""" + + payload = event("@opencode-agent") + target = payload + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + with pytest.raises(ValueError, match=message): + load_module().parse_event(payload) + + +def test_receipt_and_allowlist_helpers() -> None: + """Receipt extraction and exact repository allowlists are deterministic.""" + + module = load_module() + assert module.receipt_marker(91) == "" + with pytest.raises(ValueError, match="positive"): + module.receipt_marker(0) + comments = [ + receipt(91), + { + "body": "x y", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + }, + receipt(93, trusted=False), + {"body": None, "user": {"login": "github-actions[bot]", "type": "Bot"}}, + ] + assert module.processed_comment_ids(comments) == frozenset({91, 92}) + assert module.parse_repository_allowlist( + "ContextualWisdomLab/example, ContextualWisdomLab/.github," + ) == frozenset( + {"ContextualWisdomLab/example", "ContextualWisdomLab/.github"} + ) + with pytest.raises(ValueError, match="invalid repository"): + module.parse_repository_allowlist("outside/example") + + +def test_eligible_agents_and_payloads() -> None: + """Eligibility and event bodies preserve the bounded review contract.""" + + module = load_module() + request = module.parse_event(event("@cwl-noema-review @opencode-agent")) + assert request is not None + assert module.eligible_agents( + request, + opencode_allowlist=frozenset({request.repository}), + ) == (("cwl-noema-review", "opencode-agent"), ()) + assert module.eligible_agents( + request, + opencode_allowlist=frozenset(), + ) == (("cwl-noema-review",), ("opencode-agent",)) + noema = module.noema_payload(request) + assert noema["event_type"] == "noema-review" + assert noema["client_payload"]["pr_head_sha"] == "a" * 40 + opencode = module.opencode_payload(request) + assert opencode["event_type"] == "merge-scheduler" + assert opencode["client_payload"]["base_branch"] == "develop" + assert opencode["client_payload"]["merge_mode"] == "disabled" + assert opencode["client_payload"]["enable_auto_merge"] is False + assert opencode["client_payload"]["update_branches"] is False + + +def test_dispatch_uses_central_events_and_acknowledges() -> None: + """Both agents dispatch centrally with bounded review-only OpenCode options.""" + + module = load_module() + request = module.parse_event(event("@cwl-noema-review @opencode-agent")) + assert request is not None + target = FakeClient() + central = FakeClient() + result = module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset({request.repository}), + ) + assert result == ("@cwl-noema-review", "@opencode-agent") + assert [payload["event_type"] for _, payload in central.calls] == [ + "noema-review", + "merge-scheduler", + ] + assert all( + args[0] == "repos/ContextualWisdomLab/.github/dispatches" + for args, _ in central.calls + ) + assert target.calls[0][1] == {"content": "eyes"} + assert "cwl-agent-mention-receipt:91" in target.calls[1][1]["body"] + + +def test_dispatch_rejects_unallowlisted_opencode_and_supports_dry_run( + capsys, +) -> None: + """OpenCode fails closed outside its allowlist while dry-run is mutation-free.""" + + module = load_module() + request = module.parse_event(event("@opencode-agent")) + assert request is not None + target = FakeClient() + central = FakeClient() + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset(), + ) == () + assert central.calls == [] + assert "Rejected @opencode-agent" in target.calls[-1][1]["body"] + target = FakeClient() + central = FakeClient() + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset(), + dry_run=True, + ) == () + assert target.calls == central.calls == [] + output = capsys.readouterr().out + assert "DRY-RUN agent mention" in output + assert "reject=opencode-agent" in output + + +def test_dispatch_noema_only_covers_non_opencode_path() -> None: + """A Noema-only request bypasses the OpenCode allowlist branch.""" + + module = load_module() + request = module.parse_event(event("@cwl-noema-review")) + assert request is not None + target = FakeClient() + central = FakeClient() + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset(), + ) == ("@cwl-noema-review",) + assert central.calls[0][1]["event_type"] == "noema-review" + + +def test_github_client_validates_token_and_decodes_json(monkeypatch) -> None: + """The token-bound client never places credentials in command arguments.""" + + module = load_module() + with pytest.raises(ValueError, match="token"): + module.GitHubClient("") + calls = [] + + def fake_run(command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(stdout='{"ok": true}\n') + + monkeypatch.setattr(module.subprocess, "run", fake_run) + client = module.GitHubClient("secret-token") + assert client.request(["repos/x/y"], input_payload={"a": 1}) == {"ok": True} + command, kwargs = calls[0] + assert command == ["gh", "api", "repos/x/y", "--input", "-"] + assert "secret-token" not in command + assert kwargs["env"]["GH_TOKEN"] == "secret-token" + assert kwargs["input"] == '{"a": 1}' + monkeypatch.setattr( + module.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(stdout=" "), + ) + assert client.request(["repos/x/y"]) is None + + +def test_load_event_and_main_paths(tmp_path: Path, monkeypatch, capsys) -> None: + """CLI rejects malformed JSON, ignores irrelevant events, and dispatches input.""" + + module = load_module() + array_path = tmp_path / "array.json" + array_path.write_text(json.dumps(["bad"]), encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + module.load_event(str(array_path)) + ignored_path = tmp_path / "ignored.json" + ignored_path.write_text(json.dumps(event("nothing")), encoding="utf-8") + assert module.main(["--event-path", str(ignored_path)]) == 0 + assert "nothing to dispatch" in capsys.readouterr().out + with pytest.raises(SystemExit): + module.main([]) + valid_path = tmp_path / "valid.json" + valid_path.write_text(json.dumps(event("@opencode-agent")), encoding="utf-8") + captured = [] + monkeypatch.setenv("GH_TOKEN", "token") + monkeypatch.setenv( + "OPENCODE_REPOSITORY_DISPATCH_TARGETS", + "ContextualWisdomLab/example", + ) + monkeypatch.setattr( + module, + "dispatch_request", + lambda request, **kwargs: captured.append((request, kwargs)) or (), + ) + assert module.main(["--event-path", str(valid_path), "--dry-run"]) == 0 + assert captured[0][1]["dry_run"] is True diff --git a/tests/test_agent_mention_sweep.py b/tests/test_agent_mention_sweep.py new file mode 100644 index 000000000..b64257e7a --- /dev/null +++ b/tests/test_agent_mention_sweep.py @@ -0,0 +1,408 @@ +"""Tests for organization-wide pull-request comment mention sweeping.""" + +from __future__ import annotations + +import importlib +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" / "ci" +sys.path.insert(0, str(SCRIPTS)) + + +class FakeClient: + """Endpoint-keyed fake GitHub client for sweep tests.""" + + def __init__(self, responses=None) -> None: + """Initialize response mapping and request ledger.""" + + self.responses = responses or {} + self.calls = [] + + def request(self, args, *, input_payload=None): + """Return the response registered for the first API argument.""" + + self.calls.append((list(args), input_payload)) + return self.responses.get(args[0]) + + +def comment( + comment_id: int, + body: str, + *, + association: str = "MEMBER", + user_type: str = "User", + login: str = "maintainer", +) -> dict: + """Build one issue-comment API object.""" + + return { + "id": comment_id, + "body": body, + "author_association": association, + "user": {"login": login, "type": user_type}, + } + + +def repository( + name: str = "example", + *, + owner: str = "ContextualWisdomLab", + archived: bool = False, + disabled: bool = False, +) -> dict: + """Build one repository API object.""" + + return { + "full_name": f"{owner}/{name}", + "owner": {"login": owner}, + "archived": archived, + "disabled": disabled, + } + + +def candidate(number: int = 7) -> dict: + """Build one normalized pull-request candidate.""" + + return { + "number": number, + "repository": "ContextualWisdomLab/example", + "pull_request": { + "url": ( + "https://api.github.com/repos/ContextualWisdomLab/example/" + f"pulls/{number}" + ) + }, + } + + +def pull_list_item(number: int = 7, updated_at: str = "2026-08-05T11:00:00Z") -> dict: + """Build one pull-list API item.""" + + return {"number": number, "updated_at": updated_at} + + +def live_pull(state: str = "open") -> dict: + """Build live pull-request metadata consumed by the router.""" + + return { + "state": state, + "head": {"sha": "b" * 40}, + "base": {"ref": "main"}, + } + + +def module(): + """Reload the sweep module for isolated monkeypatching.""" + + return importlib.reload(importlib.import_module("agent_mention_sweep")) + + +def test_timestamp_cutoff_and_page_validation() -> None: + """Timestamps, lookback bounds, and pagination fail closed.""" + + sweep = module() + now = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + assert sweep.parse_timestamp("2026-08-05T11:00:00Z") == datetime( + 2026, + 8, + 5, + 11, + 0, + tzinfo=timezone.utc, + ) + for invalid in ("bad", "2026-08-05T11:00:00"): + with pytest.raises(ValueError, match="timestamp"): + sweep.parse_timestamp(invalid) + assert sweep.cutoff_timestamp(24, now=now) == "2026-08-04T12:00:00Z" + for hours in (0, 721): + with pytest.raises(ValueError, match="lookback"): + sweep.cutoff_timestamp(hours, now=now) + with pytest.raises(ValueError, match="timezone-aware"): + sweep.cutoff_timestamp(1, now=datetime(2026, 8, 5)) + assert sweep.flatten_pages([[{"a": 1}], [{"b": 2}]]) == [ + {"a": 1}, + {"b": 2}, + ] + assert sweep.flatten_pages( + [{"items": [{"a": 1}]}], collection_key="items" + ) == [{"a": 1}] + with pytest.raises(ValueError, match="empty"): + sweep.flatten_pages(None) + with pytest.raises(ValueError, match="page is not an object"): + sweep.flatten_pages([[]], collection_key="items") + with pytest.raises(ValueError, match="not a list"): + sweep.flatten_pages({"items": {}}, collection_key="items") + with pytest.raises(ValueError, match="non-object"): + sweep.flatten_pages([[1]]) + + +def test_accessible_repository_sources_filter_and_validate() -> None: + """PAT and installation-token repository inventories are both supported.""" + + sweep = module() + organization_response = [[ + repository(), + repository("archived", archived=True), + repository("disabled", disabled=True), + repository("outside", owner="outside"), + ]] + organization_client = FakeClient( + {"orgs/ContextualWisdomLab/repos": organization_response} + ) + assert sweep.list_accessible_repositories( + organization_client, + organization="ContextualWisdomLab", + repository_source="organization", + ) == ["ContextualWisdomLab/example"] + installation_client = FakeClient( + {"installation/repositories": [ + {"repositories": [repository(), repository("second")]} + ]} + ) + assert sweep.list_accessible_repositories( + installation_client, + organization="ContextualWisdomLab", + repository_source="installation", + ) == ["ContextualWisdomLab/example", "ContextualWisdomLab/second"] + with pytest.raises(ValueError, match="organization"): + sweep.list_accessible_repositories( + organization_client, + organization="bad/name", + repository_source="organization", + ) + with pytest.raises(ValueError, match="repository source"): + sweep.list_accessible_repositories( + organization_client, + organization="ContextualWisdomLab", + repository_source="bad", + ) + invalid_client = FakeClient( + {"orgs/ContextualWisdomLab/repos": [[ + {**repository(), "full_name": "bad/name"} + ]]} + ) + with pytest.raises(ValueError, match="full_name"): + sweep.list_accessible_repositories( + invalid_client, + organization="ContextualWisdomLab", + repository_source="organization", + ) + + +def test_recent_pull_request_filtering() -> None: + """Only open accessible PRs updated at or after the cutoff are yielded.""" + + sweep = module() + client = FakeClient( + { + "orgs/ContextualWisdomLab/repos": [[repository()]], + "repos/ContextualWisdomLab/example/pulls": [[ + pull_list_item(7, "2026-08-05T11:00:00Z"), + pull_list_item(8, "2026-08-04T11:59:59Z"), + ]], + } + ) + assert list(sweep.list_recent_pull_requests( + client, + organization="ContextualWisdomLab", + repository_source="organization", + since="2026-08-04T12:00:00Z", + )) == [candidate()] + bad_number_client = FakeClient( + { + "orgs/ContextualWisdomLab/repos": [[repository()]], + "repos/ContextualWisdomLab/example/pulls": [[ + {"number": 0, "updated_at": "2026-08-05T11:00:00Z"} + ]], + } + ) + with pytest.raises(ValueError, match="pull request number"): + list(sweep.list_recent_pull_requests( + bad_number_client, + organization="ContextualWisdomLab", + repository_source="organization", + since="2026-08-04T12:00:00Z", + )) + + +def test_build_requests_skips_trusted_receipts_and_closed_pull_requests() -> None: + """Only unacknowledged trusted comments on a live PR become requests.""" + + sweep = module() + comments_endpoint = "repos/ContextualWisdomLab/example/issues/7/comments" + pull_endpoint = "repos/ContextualWisdomLab/example/pulls/7" + comments = [ + comment(10, "@opencode-agent"), + comment( + 11, + "", + user_type="Bot", + login="github-actions[bot]", + ), + comment(12, "@cwl-noema-review"), + comment(13, "@opencode-agent", association="CONTRIBUTOR"), + ] + client = FakeClient({comments_endpoint: [comments], pull_endpoint: live_pull()}) + requests = sweep.build_requests_for_pull_request( + client, issue=candidate(), since="2026-08-04T00:00:00Z" + ) + assert [request.comment_id for request in requests] == [12] + assert requests[0].agents == ("cwl-noema-review",) + closed = FakeClient( + {comments_endpoint: [comments], pull_endpoint: live_pull("closed")} + ) + assert sweep.build_requests_for_pull_request( + closed, issue=candidate(), since="2026-08-04T00:00:00Z" + ) == () + with pytest.raises(ValueError, match="repository"): + sweep.build_requests_for_pull_request( + client, issue={**candidate(), "repository": "bad/name"}, since="x" + ) + with pytest.raises(ValueError, match="number"): + sweep.build_requests_for_pull_request( + client, issue={**candidate(), "number": 0}, since="x" + ) + + +def mention_request(number: int, comment_id: int, agent: str): + """Build one validated router request for orchestration tests.""" + + router = importlib.import_module("agent_mention_router") + return router.MentionRequest( + "ContextualWisdomLab/example", + number, + "a" * 40, + "main", + comment_id, + "maintainer", + (agent,), + ) + + +def test_sweep_dispatches_with_limit_and_reports_empty(monkeypatch, capsys) -> None: + """The sweep dispatches deterministically and respects its mutation budget.""" + + sweep = module() + request_a = mention_request(7, 10, "opencode-agent") + request_b = mention_request(8, 11, "cwl-noema-review") + monkeypatch.setattr( + sweep, "list_recent_pull_requests", lambda *args, **kwargs: iter([candidate()]) + ) + monkeypatch.setattr( + sweep, + "build_requests_for_pull_request", + lambda *args, **kwargs: (request_a, request_b), + ) + dispatched = [] + monkeypatch.setattr( + sweep, + "dispatch_request", + lambda request, **kwargs: dispatched.append(request.comment_id) or (), + ) + assert sweep.sweep( + target_client=FakeClient(), + dispatch_client=FakeClient(), + organization="ContextualWisdomLab", + repository_source="organization", + lookback_hours=24, + max_dispatches=1, + opencode_allowlist=frozenset({"ContextualWisdomLab/example"}), + now=datetime(2026, 8, 5, tzinfo=timezone.utc), + ) == 1 + assert dispatched == [10] + assert "reached dispatch limit" in capsys.readouterr().out + monkeypatch.setattr( + sweep, "list_recent_pull_requests", lambda *args, **kwargs: iter(()) + ) + assert sweep.sweep( + target_client=FakeClient(), + dispatch_client=FakeClient(), + organization="ContextualWisdomLab", + repository_source="installation", + lookback_hours=24, + max_dispatches=2, + opencode_allowlist=frozenset(), + now=datetime(2026, 8, 5, tzinfo=timezone.utc), + ) == 0 + assert "0 dispatch" in capsys.readouterr().out + for value in (0, 101): + with pytest.raises(ValueError, match="max dispatches"): + sweep.sweep( + target_client=FakeClient(), + dispatch_client=FakeClient(), + organization="ContextualWisdomLab", + repository_source="organization", + lookback_hours=24, + max_dispatches=value, + opencode_allowlist=frozenset(), + ) + + +def test_sweep_continues_across_empty_results_and_completes( + monkeypatch, capsys +) -> None: + """Empty candidate results do not stop later PR processing.""" + + sweep = module() + request = mention_request(8, 12, "cwl-noema-review") + monkeypatch.setattr( + sweep, + "list_recent_pull_requests", + lambda *args, **kwargs: iter([candidate(), candidate(8)]), + ) + monkeypatch.setattr( + sweep, + "build_requests_for_pull_request", + lambda *args, issue, **kwargs: () if issue["number"] == 7 else (request,), + ) + dispatched = [] + monkeypatch.setattr( + sweep, + "dispatch_request", + lambda request, **kwargs: dispatched.append(request.comment_id) or (), + ) + assert sweep.sweep( + target_client=FakeClient(), + dispatch_client=FakeClient(), + organization="ContextualWisdomLab", + repository_source="organization", + lookback_hours=24, + max_dispatches=2, + opencode_allowlist=frozenset(), + now=datetime(2026, 8, 5, tzinfo=timezone.utc), + ) == 1 + assert dispatched == [12] + assert "completed with 1 dispatch" in capsys.readouterr().out + + +def test_main_constructs_clients_and_forwards_options(monkeypatch) -> None: + """CLI reads credentials, parses allowlist, and forwards bounded options.""" + + sweep = module() + captured = [] + monkeypatch.setenv("TARGET_REPOSITORY_TOKEN", "target") + monkeypatch.setenv("AGENT_DISPATCH_TOKEN", "dispatch") + monkeypatch.setenv( + "OPENCODE_REPOSITORY_DISPATCH_TARGETS", "ContextualWisdomLab/example" + ) + monkeypatch.setattr(sweep, "sweep", lambda **kwargs: captured.append(kwargs) or 0) + assert sweep.main([ + "--organization", + "ContextualWisdomLab", + "--repository-source", + "installation", + "--lookback-hours", + "48", + "--max-dispatches", + "3", + "--dry-run", + ]) == 0 + assert captured[0]["repository_source"] == "installation" + assert captured[0]["lookback_hours"] == 48 + assert captured[0]["max_dispatches"] == 3 + assert captured[0]["dry_run"] is True diff --git a/tests/test_agent_mention_workflow_contract.py b/tests/test_agent_mention_workflow_contract.py new file mode 100644 index 000000000..dbdcd2334 --- /dev/null +++ b/tests/test_agent_mention_workflow_contract.py @@ -0,0 +1,41 @@ +"""Static least-privilege and trigger contract for agent mention automation.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-router.yml" + + +def test_workflow_uses_local_event_and_central_sweep_with_job_scoped_writes() -> None: + """The router is central-only, organization-wide, and least-privileged.""" + + text = WORKFLOW.read_text(encoding="utf-8") + header, jobs = text.split("\njobs:\n", 1) + assert "issue_comment:" in header + assert 'cron: "*/5 * * * *"' in header + assert "workflow_dispatch:" in header + assert "permissions:\n contents: read" in header + assert "contents: write" not in header + + local, sweep = jobs.split("\n sweep-organization-agent-mentions:\n", 1) + assert "route-local-agent-mention:" in local + assert "github.repository == 'ContextualWisdomLab/.github'" in local + assert ( + "permissions:\n" + " contents: write\n" + " issues: write\n" + " pull-requests: read" + ) in local + assert "ref: ${{ github.event.repository.default_branch }}" in local + assert "TARGET_REPOSITORY_TOKEN: ${{ github.token }}" in local + assert "conversation_comments" in local + + assert "permissions:\n contents: write\n id-token: write" in sweep + assert "github.repository == 'ContextualWisdomLab/.github'" in sweep + assert "github.event_name == 'schedule'" in sweep + assert "github.event_name == 'workflow_dispatch'" in sweep + assert "secrets.PR_REVIEW_MERGE_TOKEN" in sweep + assert "secrets.OPENCODE_APPROVE_TOKEN" in sweep + assert "TARGET_REPOSITORY_SOURCE" in sweep + assert "AGENT_DISPATCH_TOKEN: ${{ github.token }}" in sweep + assert "agent_mention_sweep.py" in sweep diff --git a/tests/test_control_plane_coverage_closure.py b/tests/test_control_plane_coverage_closure.py new file mode 100644 index 000000000..8c4e7d674 --- /dev/null +++ b/tests/test_control_plane_coverage_closure.py @@ -0,0 +1,376 @@ +"""Coverage closure for defensive central control-plane execution branches.""" + +from __future__ import annotations + +import io +import runpy +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +from scripts.ci import install_base_python_locks as installer +from scripts.ci import redact_sensitive_log as redactor +from scripts.ci import sandboxed_verify, sandboxed_web_e2e + + +def test_json_string_scanner_handles_escapes_and_fail_closed_edges( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exercise escaped, malformed, non-string, and unterminated JSON strings.""" + + escaped = r'"a\"b"' + assert redactor._consume_json_string(escaped, 0, depth=0) == ( + escaped, + len(escaped), + ) + assert redactor._consume_json_string(r'"\q"', 0, depth=0) is None + assert redactor._consume_json_string('"unterminated', 0, depth=0) is None + + monkeypatch.setattr(redactor.json, "loads", lambda _candidate: 7) + assert redactor._consume_json_string('"ordinary"', 0, depth=0) is None + + +def test_assignment_scanner_handles_missing_escaped_and_unterminated_values() -> None: + """Defensive assignment parsing covers every quoted-value termination path.""" + + missing, _cursor = redactor._consume_sensitive_assignment("password = ", 0) + assert missing is None + + escaped_text = r'password="a\"b" --safe' + escaped_replacement, escaped_end = redactor._consume_sensitive_assignment( + escaped_text, + 0, + ) + assert escaped_replacement == 'password="[REDACTED]"' + assert escaped_text[escaped_end:] == " --safe" + + unterminated_text = "password='plain secret" + unterminated_replacement, unterminated_end = ( + redactor._consume_sensitive_assignment(unterminated_text, 0) + ) + assert unterminated_replacement == "password='[REDACTED]" + assert unterminated_end == len(unterminated_text) + + +def test_unstructured_and_structured_redaction_defensive_fallbacks() -> None: + """Depth, malformed-string, scalar-JSON, and unusual string edges stay safe.""" + + assert redactor._redact_unstructured("token=plain-secret", depth=9) == ( + "token=[REDACTED]" + ) + assert redactor._redact_unstructured(r'"\q"') == r'"\q"' + assert redactor._redact_json(17) == 17 + + class NonEmptyStringWithoutLines(str): + """Represent a valid string subtype with an adversarial splitlines result.""" + + def splitlines(self, keepends: bool = False) -> list[str]: + """Return no lines while retaining a non-empty scalar value.""" + + del keepends + return [] + + unusual = NonEmptyStringWithoutLines("api_key=plain-secret") + assert redactor.redact_text(unusual) == "api_key=[REDACTED]" + + +def test_installer_skips_deferable_candidate_without_empty_diagnostic( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """An empty bounded resolver diagnostic does not emit a meaningless line.""" + + lock_path = tmp_path / "requirements-000.txt" + lock_path.write_text("demo==1 --hash=sha256:" + ("a" * 64) + "\n") + entry = installer.LockCandidate( + generated_file="requirements-000.txt", + source="requirements-agent.txt", + path=lock_path, + ) + monkeypatch.setattr(installer, "_manifest_entries", lambda _root: [entry]) + monkeypatch.setattr( + installer, + "_is_deferable_preflight_failure", + lambda _output: True, + ) + monkeypatch.setattr(installer, "_bounded_failure_output", lambda _output: "") + + def runner(command: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(command, 1, stdout="") + + stdout = io.StringIO() + stderr = io.StringIO() + assert ( + installer.install_materialized_locks( + tmp_path, + runner=runner, + stdout=stdout, + stderr=stderr, + ) + == 0 + ) + assert "Skipping trusted base Python requirement candidate" in stderr.getvalue() + assert stderr.getvalue().endswith("group completed it.\n") + + +def test_installer_deduplicates_recovered_same_file_plan( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A defensive duplicate recovered entry is installed only once.""" + + lock_path = tmp_path / "requirements-000.txt" + lock_path.write_text("demo==1 --hash=sha256:" + ("b" * 64) + "\n") + entries = [ + installer.LockCandidate( + generated_file="requirements-000.txt", + source="backend/requirements-agent.txt", + path=lock_path, + ), + installer.LockCandidate( + generated_file="requirements-000.txt", + source="backend/requirements-hashes.txt", + path=lock_path, + ), + ] + monkeypatch.setattr(installer, "_manifest_entries", lambda _root: entries) + commands: list[list[str]] = [] + deferable = ( + "ERROR: In --require-hashes mode, all requirements must have their " + "versions pinned with ==: demo>=1" + ) + + def runner(command: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + commands.append(command) + if "--dry-run" in command and len(commands) <= 2: + return subprocess.CompletedProcess(command, 1, stdout=deferable) + return subprocess.CompletedProcess(command, 0, stdout="") + + stdout = io.StringIO() + assert ( + installer.install_materialized_locks( + tmp_path, + runner=runner, + stdout=stdout, + stderr=io.StringIO(), + ) + == 0 + ) + assert len(commands) == 4 + assert commands[-1].count("-r") == 1 + assert "installed=1 skipped=0" in stdout.getvalue() + + +def test_sandboxed_verify_script_path_bootstraps_import_root() -> None: + """Direct script-path loading executes the package bootstrap branch.""" + + original_path = list(sys.path) + try: + namespace = runpy.run_path( + str(Path(sandboxed_verify.__file__).resolve()), + run_name="sandboxed_verify_import_probe", + ) + finally: + sys.path[:] = original_path + assert namespace["RESULT_MARKER"] == sandboxed_verify.RESULT_MARKER + + +@pytest.mark.parametrize( + ("stdout_payload", "stderr_payload"), + [("only-stdout", None), (None, "only-stderr")], +) +def test_sandboxed_verify_timeout_accepts_one_missing_stream( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + stdout_payload: str | None, + stderr_payload: str | None, +) -> None: + """A timeout publishes either available stream without assuming both exist.""" + + repository = tmp_path / "repository" + repository.mkdir() + + def timeout_runner( + command: list[str], + _cwd: Path, + _env: dict[str, str], + timeout: int, + ) -> subprocess.CompletedProcess[str]: + raise subprocess.TimeoutExpired( + command, + timeout, + output=stdout_payload, + stderr=stderr_payload, + ) + + monkeypatch.setattr(sandboxed_verify, "run_command", timeout_runner) + assert ( + sandboxed_verify.main( + ["--repo-root", str(repository), "--timeout", "1", "--", "true"] + ) + == 124 + ) + captured = capsys.readouterr() + if stdout_payload is None: + assert "only-stdout" not in captured.out + else: + assert stdout_payload in captured.out + if stderr_payload is None: + assert "only-stderr" not in captured.err + else: + assert stderr_payload in captured.err + + +def test_wait_for_url_retries_non_acceptable_http_status( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A 5xx response remains unready and polling proceeds to the deadline.""" + + class RunningProcess: + """Minimal still-running process double.""" + + def poll(self) -> None: + """Report that the service remains active.""" + + return None + + class Response: + """Context-managed unacceptable HTTP response.""" + + status = 503 + + def __enter__(self) -> "Response": + """Return the response object.""" + + return self + + def __exit__(self, *_args: object) -> bool: + """Do not suppress exceptions.""" + + return False + + class Opener: + """Return one deterministic response.""" + + def open(self, _url: str, timeout: int) -> Response: + """Return the 503 response with the expected bounded timeout.""" + + assert timeout == 2 + return Response() + + ticks = iter([0.0, 0.0, 2.0]) + monkeypatch.setattr(sandboxed_web_e2e.time, "monotonic", lambda: next(ticks)) + monkeypatch.setattr( + sandboxed_web_e2e.urllib.request, + "build_opener", + lambda *_handlers: Opener(), + ) + service = sandboxed_web_e2e.Service( + "web", + "serve", + RunningProcess(), # type: ignore[arg-type] + tmp_path / "web.log", + ) + assert ( + sandboxed_web_e2e.wait_for_url( + "http://127.0.0.1:8000/health", + 1, + service, + ) + is False + ) + + +@pytest.mark.parametrize( + ("stdout_payload", "stderr_payload"), + [("only-stdout", None), (None, "only-stderr")], +) +def test_sandboxed_web_timeout_accepts_one_missing_stream( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + stdout_payload: str | None, + stderr_payload: str | None, +) -> None: + """Web E2E timeout reporting handles absent stdout or stderr independently.""" + + repository = tmp_path / "repository" + repository.mkdir() + + class DoneProcess: + """Minimal completed service process double.""" + + def poll(self) -> int: + """Report successful completion.""" + + return 0 + + def start_service( + label: str, + command: str, + _cwd: Path, + _env: dict[str, str], + logs_dir: Path, + ) -> sandboxed_web_e2e.Service: + log_path = logs_dir / f"{label}.log" + log_path.write_text("", encoding="utf-8") + return sandboxed_web_e2e.Service( + label, + command, + DoneProcess(), # type: ignore[arg-type] + log_path, + ) + + def timeout_runner( + command: str, + _cwd: Path, + _env: dict[str, str], + timeout: int, + ) -> subprocess.CompletedProcess[str]: + raise subprocess.TimeoutExpired( + command, + timeout, + output=stdout_payload, + stderr=stderr_payload, + ) + + monkeypatch.setattr(sandboxed_web_e2e, "start_service", start_service) + monkeypatch.setattr( + sandboxed_web_e2e, + "wait_for_url", + lambda _url, _timeout, _service: True, + ) + monkeypatch.setattr(sandboxed_web_e2e, "run_shell", timeout_runner) + monkeypatch.setattr(sandboxed_web_e2e, "stop_service", lambda _service: None) + + assert ( + sandboxed_web_e2e.main( + [ + "--repo-root", + str(repository), + "--backend-cmd", + "backend", + "--frontend-cmd", + "frontend", + "--e2e-timeout", + "1", + "--e2e-cmd", + "e2e", + ] + ) + == 124 + ) + captured = capsys.readouterr() + if stdout_payload is None: + assert "only-stdout" not in captured.out + else: + assert stdout_payload in captured.out + if stderr_payload is None: + assert "only-stderr" not in captured.err + else: + assert stderr_payload in captured.err diff --git a/tests/test_control_plane_quality_coverage_gaps.py b/tests/test_control_plane_quality_coverage_gaps.py new file mode 100644 index 000000000..c05d2318e --- /dev/null +++ b/tests/test_control_plane_quality_coverage_gaps.py @@ -0,0 +1,285 @@ +"""Focused branch tests for the central control-plane quality gate.""" + +from __future__ import annotations + +import runpy +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from scripts.ci import install_base_python_locks as lock_installer +from scripts.ci import redact_sensitive_log as redactor +from scripts.ci import sandboxed_verify +from scripts.ci import sandboxed_web_e2e + + +DEFERABLE_PIN_OUTPUT = ( + "ERROR: In --require-hashes mode, all requirements must have their " + "versions pinned with ==. These do not:\n" +) + + +def completed(returncode: int, stdout: str = "") -> subprocess.CompletedProcess[str]: + """Return one deterministic subprocess result for a scripted runner.""" + return subprocess.CompletedProcess( + args=["python", "-m", "pip"], + returncode=returncode, + stdout=stdout, + stderr=None, + ) + + +def test_lock_installer_deduplicates_a_recovered_group_defensively( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Exercise defensive de-duplication after one grouped supplement recovery.""" + duplicate_file = "requirements-000.txt" + entries = [ + lock_installer.LockCandidate( + generated_file=duplicate_file, + source="module/requirements-a.txt", + path=tmp_path / "requirements-a.txt", + ), + lock_installer.LockCandidate( + generated_file=duplicate_file, + source="module/requirements-b.txt", + path=tmp_path / "requirements-b.txt", + ), + ] + monkeypatch.setattr(lock_installer, "_manifest_entries", lambda _root: entries) + scripted_results = iter( + [ + completed(1, DEFERABLE_PIN_OUTPUT), + completed(1, DEFERABLE_PIN_OUTPUT), + completed(0), + completed(0), + ] + ) + calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + def runner(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]: + calls.append((args, kwargs)) + return next(scripted_results) + + assert lock_installer.install_materialized_locks(tmp_path, runner=runner) == 0 + assert len(calls) == 4 + + +def test_json_string_consumer_covers_escape_and_failure_boundaries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cover escaped, malformed, non-string, and unterminated JSON strings.""" + escaped = '"quoted \\\"value\\\""' + parsed = redactor._consume_json_string(escaped, 0, depth=0) + assert parsed == (escaped, len(escaped)) + + assert redactor._consume_json_string('"\\x"', 0, depth=0) is None + assert redactor._consume_json_string('"unterminated', 0, depth=0) is None + + monkeypatch.setattr(redactor.json, "loads", lambda _candidate: 7) + assert redactor._consume_json_string('"seven"', 0, depth=0) is None + + +def test_assignment_consumer_covers_all_scalar_boundaries() -> None: + """Cover non-identifiers, ordinary keys, empty values, and quoted secrets.""" + assert redactor._consume_sensitive_assignment("=value", 0) == (None, 1) + assert redactor._consume_sensitive_assignment("ordinary", 0) == ( + None, + len("ordinary"), + ) + assert redactor._consume_sensitive_assignment("ordinary=value", 0) == ( + None, + len("ordinary"), + ) + assert redactor._consume_sensitive_assignment("api_key=", 0) == ( + None, + len("api_key"), + ) + assert redactor._consume_sensitive_assignment("api_key='secret'", 0) == ( + "api_key='[REDACTED]'", + len("api_key='secret'"), + ) + assert redactor._consume_sensitive_assignment("api_key='sec\\'ret'", 0) == ( + "api_key='[REDACTED]'", + len("api_key='sec\\'ret'"), + ) + unterminated = "api_key='secret" + assert redactor._consume_sensitive_assignment(unterminated, 0) == ( + "api_key='[REDACTED]", + len(unterminated), + ) + + +def test_unstructured_and_argument_helpers_cover_remaining_paths() -> None: + """Exercise depth, assignment, empty text, argument, and shell fallbacks.""" + assert redactor._redact_unstructured("api_key=secret", depth=9) == ( + "api_key=[REDACTED]" + ) + assert redactor._redact_unstructured("prefix api_key=secret suffix") == ( + "prefix api_key=[REDACTED] suffix" + ) + assert redactor.redact_text("") == "" + assert redactor._redact_json(3) == 3 + assert redactor._redact_assignment("ordinary") == "ordinary" + assert redactor._redact_assignment("ordinary=value") == "ordinary=value" + assert redactor._redact_assignment("api_key=value") == "api_key=[REDACTED]" + assert redactor.redact_command_arguments( + ["tool", "--token", "secret", "ordinary=value"] + ) == ["tool", "--token", "[REDACTED]", "ordinary=value"] + assert redactor.redact_shell_command("'unterminated") == "'unterminated" + + +def test_sandboxed_verify_standalone_import_path_is_executable() -> None: + """Load the wrapper without a package so its standalone import fallback runs.""" + namespace = runpy.run_path(sandboxed_verify.__file__, run_name="quality_probe") + + assert callable(namespace["main"]) + + +def test_sandboxed_verify_success_without_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Keep successful empty stdout and stderr as valid evidence.""" + sandbox = tmp_path / "sandbox" + sandbox.mkdir() + copied_repo = sandbox / "repo" + copied_repo.mkdir() + monkeypatch.setattr( + sandboxed_verify.tempfile, + "mkdtemp", + lambda **_kwargs: str(sandbox), + ) + monkeypatch.setattr( + sandboxed_verify, + "copy_workspace", + lambda *_args, **_kwargs: copied_repo, + ) + monkeypatch.setattr( + sandboxed_verify, + "scrubbed_env", + lambda *_args, **_kwargs: {}, + ) + monkeypatch.setattr( + sandboxed_verify, + "run_command", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + args=["true"], returncode=0, stdout="", stderr="" + ), + ) + monkeypatch.setattr(sandboxed_verify, "emit_result", lambda **_kwargs: None) + monkeypatch.setattr( + sandboxed_verify.shutil, + "rmtree", + lambda *_args, **_kwargs: None, + ) + + assert sandboxed_verify.main(["--repo-root", str(tmp_path), "--", "true"]) == 0 + + +def test_wait_for_url_retries_a_transport_error_until_timeout( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Return false when readiness transport errors persist through the deadline.""" + process = SimpleNamespace(poll=lambda: None) + service = SimpleNamespace(process=process, log_path=tmp_path / "service.log") + opener = SimpleNamespace( + open=lambda *_args, **_kwargs: (_ for _ in ()).throw( + sandboxed_web_e2e.urllib.error.URLError("offline") + ) + ) + ticks = iter([0.0, 0.0, 2.0]) + monkeypatch.setattr( + sandboxed_web_e2e.urllib.request, + "build_opener", + lambda *_args: opener, + ) + monkeypatch.setattr(sandboxed_web_e2e.time, "monotonic", lambda: next(ticks)) + monkeypatch.setattr(sandboxed_web_e2e.time, "sleep", lambda _seconds: None) + + assert not sandboxed_web_e2e.wait_for_url( + "https://example.invalid/ready", 1, service + ) + + +def test_sandboxed_web_e2e_success_without_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Keep successful empty E2E stdout and stderr as valid evidence.""" + sandbox = tmp_path / "sandbox" + sandbox.mkdir() + copied_repo = sandbox / "repo" + copied_repo.mkdir() + logs_dir = sandbox / "logs" + logs_dir.mkdir() + services = [ + SimpleNamespace( + label=label, + command=label, + process=SimpleNamespace(poll=lambda: None), + log_path=logs_dir / f"{label}.log", + ) + for label in ("backend", "frontend") + ] + service_iter = iter(services) + monkeypatch.setattr( + sandboxed_web_e2e.tempfile, + "mkdtemp", + lambda **_kwargs: str(sandbox), + ) + monkeypatch.setattr( + sandboxed_web_e2e.sandboxed_verify, + "copy_workspace", + lambda *_args, **_kwargs: copied_repo, + ) + monkeypatch.setattr( + sandboxed_web_e2e.sandboxed_verify, + "scrubbed_env", + lambda *_args, **_kwargs: {}, + ) + monkeypatch.setattr( + sandboxed_web_e2e, + "start_service", + lambda *_args, **_kwargs: next(service_iter), + ) + monkeypatch.setattr( + sandboxed_web_e2e, + "wait_for_url", + lambda *_args, **_kwargs: True, + ) + monkeypatch.setattr( + sandboxed_web_e2e, + "run_shell", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + args=["e2e"], returncode=0, stdout="", stderr="" + ), + ) + monkeypatch.setattr(sandboxed_web_e2e, "stop_service", lambda _service: None) + monkeypatch.setattr(sandboxed_web_e2e, "tail_text", lambda _path: "") + monkeypatch.setattr(sandboxed_web_e2e, "emit_result", lambda **_kwargs: None) + monkeypatch.setattr( + sandboxed_web_e2e.shutil, + "rmtree", + lambda *_args, **_kwargs: None, + ) + + assert ( + sandboxed_web_e2e.main( + [ + "--repo-root", + str(tmp_path), + "--backend-cmd", + "backend", + "--frontend-cmd", + "frontend", + "--e2e-cmd", + "e2e", + ] + ) + == 0 + ) diff --git a/tests/test_control_plane_quality_workflow_contract.py b/tests/test_control_plane_quality_workflow_contract.py new file mode 100644 index 000000000..6734373de --- /dev/null +++ b/tests/test_control_plane_quality_workflow_contract.py @@ -0,0 +1,77 @@ +"""Static contract tests for the central control-plane quality workflow.""" + +from __future__ import annotations + +from pathlib import Path + + +WORKFLOW_PATH = Path(".github/workflows/control-plane-quality-ci.yml") +PRODUCTION_MODULES = ( + "scripts.ci.agent_mention_router", + "scripts.ci.agent_mention_sweep", + "scripts.ci.install_base_python_locks", + "scripts.ci.javascript_coverage_gate", + "scripts.ci.redact_sensitive_log", + "scripts.ci.sandboxed_verify", + "scripts.ci.sandboxed_web_e2e", +) +PRODUCTION_PATHS = tuple(module.replace(".", "/") + ".py" for module in PRODUCTION_MODULES) + + +def workflow_text() -> str: + """Return the quality workflow as UTF-8 text for deterministic assertions.""" + return WORKFLOW_PATH.read_text(encoding="utf-8") + + +def test_quality_workflow_uses_least_privilege_and_immutable_actions() -> None: + """Pin the workflow to read-only permissions and reviewed action revisions.""" + text = workflow_text() + + assert "name: Central Control Plane Quality CI" in text + assert "permissions:\n contents: read" in text + assert "contents: write" not in text + assert "pull-requests: write" not in text + assert "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" in text + assert "actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97" in text + assert "step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920" in text + + +def test_quality_workflow_proves_supported_python_and_locked_tooling() -> None: + """Require Python 3.10 compatibility and a hash-locked Python 3.14 gate.""" + text = workflow_text() + + assert 'python-version: "3.10"' in text + assert 'python-version: "3.14"' in text + assert "requirements-opencode-review-ci-hashes.txt" in text + assert "--require-hashes" in text + assert "python -m compileall -q" in text + assert "env -u GITHUB_EVENT_PATH" in text + + +def test_quality_workflow_enforces_complete_coverage_and_docstrings() -> None: + """Require every changed production module to reach complete branch evidence.""" + text = workflow_text() + + assert "COVERAGE_RCFILE" in text + assert "[run]" in text + assert "branch = True" in text + assert "include =" in text + assert "python -m coverage run" in text + assert "python -m coverage report" in text + assert "fail_under = 100" in text + assert "show_missing = True" in text + assert "python -m interrogate" in text + assert "--fail-under 100" in text + assert "--cov=" not in text + for path in PRODUCTION_PATHS: + assert path in text + + +def test_quality_workflow_runs_its_own_regression_contract() -> None: + """Keep the workflow self-verifying whenever its implementation changes.""" + text = workflow_text() + + assert "tests/test_control_plane_quality_workflow_contract.py" in text + assert '".github/workflows/control-plane-quality-ci.yml"' in text + assert "copilot" not in text.casefold() + assert "schedule:" not in text diff --git a/tests/test_install_base_python_lock_missing_pin.py b/tests/test_install_base_python_lock_missing_pin.py new file mode 100644 index 000000000..5de4056ea --- /dev/null +++ b/tests/test_install_base_python_lock_missing_pin.py @@ -0,0 +1,239 @@ +"""Regression tests for unavailable pins in trusted base Python locks.""" + +from __future__ import annotations + +import io +import json +import subprocess +from pathlib import Path + +import pytest + +from scripts.ci import install_base_python_locks as installer + + +def _write_candidate(root: Path) -> None: + """Write one trusted materialized lock candidate and its manifest.""" + + (root / "manifest.json").write_text( + json.dumps( + [ + { + "file": "requirements-000.txt", + "source": "requirements-hashes.txt", + } + ] + ), + encoding="utf-8", + ) + (root / "requirements-000.txt").write_text( + "pypdf==6.13.3 --hash=sha256:" + ("a" * 64) + "\n", + encoding="utf-8", + ) + + +def _run_preflight_failure(root: Path, output: str) -> tuple[int, str, str]: + """Run the installer with one deterministic pip preflight failure.""" + + _write_candidate(root) + + def fake_runner(command: list[str], **kwargs): + return subprocess.CompletedProcess(command, 1, stdout=output) + + stdout = io.StringIO() + stderr = io.StringIO() + result = installer.install_materialized_locks( + root, + runner=fake_runner, + stdout=stdout, + stderr=stderr, + ) + return result, stdout.getvalue(), stderr.getvalue() + + +def test_reachable_index_missing_pin_is_visible_and_nonfatal(tmp_path: Path) -> None: + """A reachable index proving newer versions exist may defer a stale pin.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: 6.14.1, 6.14.2)\n" + "ERROR: No matching distribution found for pypdf==6.13.3" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 0 + assert "candidates=1 installed=0 skipped=1" in stdout + assert "Could not find a version that satisfies the requirement" in stderr + + +def test_pep440_version_evidence_is_deferable(tmp_path: Path) -> None: + """Epoch, prerelease, postrelease, development, and local versions remain valid.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: v1!6.14.0rc1.post2.dev3+linux.x86_64)\n" + "ERROR: No matching distribution found for pypdf==6.13.3" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 0 + assert "candidates=1 installed=0 skipped=1" in stdout + assert "v1!6.14.0rc1.post2.dev3+linux.x86_64" in stderr + + +def test_reachable_index_context_lines_remain_deferable(tmp_path: Path) -> None: + """Pip's yanked and incompatible-version context does not mask a proven stale pin.""" + + output = ( + "ERROR: Ignored the following yanked versions: 6.13.3\n" + "ERROR: Ignored the following versions that require a different python " + "version: 6.13.4 Requires-Python <3.14\n" + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: 6.14.1, 6.14.2)\n" + "ERROR: No matching distribution found for pypdf==6.13.3" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 0 + assert "candidates=1 installed=0 skipped=1" in stdout + assert "Ignored the following yanked versions" in stderr + + +@pytest.mark.parametrize( + "fatal_fragment", + [ + "ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE", + "WARNING: Retrying after connection broken by ConnectionError", + "ERROR: Could not fetch URL https://pypi.org/simple/pypdf/", + "ERROR: pip resolver crashed after candidate enumeration", + ], +) +def test_reachable_index_message_cannot_mask_fatal_failure( + tmp_path: Path, + fatal_fragment: str, +) -> None: + """Any independent fatal evidence must dominate a stale-pin diagnostic.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: 6.14.1, 6.14.2)\n" + "ERROR: No matching distribution found for pypdf==6.13.3\n" + f"{fatal_fragment}" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 1 + assert "preflight failed" in stderr + assert fatal_fragment in stderr + assert "installed=" not in stdout + + +@pytest.mark.parametrize( + "version_list", + [ + "none", + "", + "unavailable", + "latest", + "release-6", + "6.14.1, unavailable", + "6.14.1 extra-text", + ], +) +def test_non_version_index_evidence_remains_fatal( + tmp_path: Path, + version_list: str, +) -> None: + """Only a complete comma-separated PEP 440 version list proves reachability.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + f"pypdf==6.13.3 (from versions: {version_list})\n" + "ERROR: No matching distribution found for pypdf==6.13.3" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 1 + assert "preflight failed" in stderr + assert "installed=" not in stdout + + +def test_valid_pair_cannot_mask_duplicate_malformed_version_evidence( + tmp_path: Path, +) -> None: + """Every resolver line for a paired requirement must carry concrete versions.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: 6.14.1)\n" + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: unavailable)\n" + "ERROR: No matching distribution found for pypdf==6.13.3" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 1 + assert "preflight failed" in stderr + assert "unavailable" in stderr + assert "installed=" not in stdout + + +def test_atheris_binary_wheel_unavailability_is_deferable(tmp_path: Path) -> None: + """The real Python 3.14 binary-only diagnostic is a visible skipped lock.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + "atheris==3.0.0 (from versions: 3.1.0)\n" + "ERROR: No matching distribution found for atheris==3.0.0" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 0 + assert "candidates=1 installed=0 skipped=1" in stdout + assert "atheris==3.0.0" in stderr + + +def test_mismatched_binary_diagnostics_remain_fatal(tmp_path: Path) -> None: + """Two resolver lines for different exact pins cannot authorize deferral.""" + + output = ( + "ERROR: Could not find a version that satisfies the requirement " + "pypdf==6.13.3 (from versions: 6.14.2)\n" + "ERROR: No matching distribution found for atheris==3.0.0" + ) + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 1 + assert "preflight failed" in stderr + assert "installed=" not in stdout + + +@pytest.mark.parametrize( + "output", + [ + ( + "ERROR: Could not find a version that satisfies the requirement " + "atheris==3.0.0 (from versions: 3.1.0)" + ), + "ERROR: No matching distribution found for atheris==3.0.0", + ], +) +def test_single_binary_diagnostic_remains_fatal( + tmp_path: Path, + output: str, +) -> None: + """Neither half of pip's binary-unavailability evidence is sufficient alone.""" + + result, stdout, stderr = _run_preflight_failure(tmp_path, output) + + assert result == 1 + assert "preflight failed" in stderr + assert "installed=" not in stdout diff --git a/tests/test_install_base_python_locks_atomic.py b/tests/test_install_base_python_locks_atomic.py new file mode 100644 index 000000000..91382269e --- /dev/null +++ b/tests/test_install_base_python_locks_atomic.py @@ -0,0 +1,86 @@ +"""Regression tests for aggregate trusted Python lock installation.""" + +from __future__ import annotations + +import io +import json +import subprocess +from pathlib import Path + +from scripts.ci import install_base_python_locks as installer + + +def _write_lock(root: Path, index: int, source: str) -> None: + """Append one independently complete materialized lock candidate.""" + + manifest_path = root / "manifest.json" + manifest = ( + json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest_path.exists() + else [] + ) + filename = f"requirements-{index:03d}.txt" + manifest.append({"file": filename, "source": source}) + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + (root / filename).write_text( + f"demo{index}==1 --hash=sha256:" + (str(index + 1) * 64) + "\n", + encoding="utf-8", + ) + + +def test_independent_locks_install_in_one_aggregate_transaction(tmp_path: Path) -> None: + """Multiple valid locks are resolved together and installed exactly once.""" + + _write_lock(tmp_path, 0, "one/requirements-hashes.txt") + _write_lock(tmp_path, 1, "two/requirements-hashes.txt") + commands: list[list[str]] = [] + + def fake_runner(command: list[str], **kwargs): + commands.append(command) + return subprocess.CompletedProcess(command, 0, stdout="") + + stdout = io.StringIO() + result = installer.install_materialized_locks( + tmp_path, + runner=fake_runner, + stdout=stdout, + ) + + assert result == 0 + assert len(commands) == 4 + assert all("--dry-run" in command for command in commands[:3]) + assert "--dry-run" not in commands[3] + assert commands[2].count("-r") == 2 + assert commands[3].count("-r") == 2 + assert "installed=2 skipped=0" in stdout.getvalue() + + +def test_aggregate_preflight_conflict_blocks_install(tmp_path: Path) -> None: + """Cross-lock dependency conflicts fail before any mutating pip install.""" + + _write_lock(tmp_path, 0, "one/requirements-hashes.txt") + _write_lock(tmp_path, 1, "two/requirements-hashes.txt") + commands: list[list[str]] = [] + + def fake_runner(command: list[str], **kwargs): + commands.append(command) + if len(commands) == 3: + return subprocess.CompletedProcess( + command, + 31, + stdout="ERROR: ResolutionImpossible: conflicting dependencies", + ) + return subprocess.CompletedProcess(command, 0, stdout="") + + stderr = io.StringIO() + result = installer.install_materialized_locks( + tmp_path, + runner=fake_runner, + stderr=stderr, + ) + + assert result == 31 + assert len(commands) == 3 + assert all("--dry-run" in command for command in commands) + assert "preflight failed" in stderr.getvalue() + assert "ResolutionImpossible" in stderr.getvalue() diff --git a/tests/test_javascript_coverage_scope.py b/tests/test_javascript_coverage_scope.py new file mode 100644 index 000000000..b3e75087d --- /dev/null +++ b/tests/test_javascript_coverage_scope.py @@ -0,0 +1,293 @@ +"""Regression tests for central JavaScript runtime-source classification.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from scripts.ci import javascript_coverage_gate as gate + + +def git(repo_root: Path, *args: str) -> str: + """Run Git in a temporary regression fixture and return stdout.""" + return subprocess.run( + ["git", "-C", str(repo_root), *args], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout.strip() + + +def commit(repo_root: Path, message: str) -> str: + """Commit the temporary fixture and return the immutable commit SHA.""" + git(repo_root, "add", ".") + git(repo_root, "commit", "-m", message) + return git(repo_root, "rev-parse", "HEAD") + + +def initialise_repo(repo_root: Path) -> None: + """Create a deterministic Git repository for changed-file evidence tests.""" + repo_root.mkdir() + git(repo_root, "init", "-b", "main") + git(repo_root, "config", "user.name", "Coverage Scope Test") + git(repo_root, "config", "user.email", "coverage-scope@example.invalid") + + +def empty_coverage_list(repo_root: Path) -> Path: + """Write an empty Istanbul final report and return its list file.""" + coverage_dir = repo_root / "coverage" + coverage_dir.mkdir() + final_path = coverage_dir / "coverage-final.json" + final_path.write_text(json.dumps({}), encoding="utf-8") + summary_list = repo_root / "coverage-files.txt" + summary_list.write_text("coverage/coverage-final.json\n", encoding="utf-8") + return summary_list + + +def run_gate( + repo_root: Path, + base_sha: str, + head_sha: str, + summary_list: Path, +) -> int: + """Run the current central coverage gate for one exact fixture head.""" + return gate.main( + [ + "--repo-root", + str(repo_root), + "--base-sha", + base_sha, + "--head-sha", + head_sha, + "--summary-list", + str(summary_list), + ] + ) + + +@pytest.mark.parametrize( + "path", + [ + "vite.autosave.config.ts", + "packages/editor/vitest.browser.config.ts", + "webpack.server.config.js", + "scripts/verify-framework-free-autosave-package.mjs", + "scripts/verify-package.mjs", + "packages/editor/scripts/check-bundle.cjs", + ], +) +def test_tool_configs_and_repository_verifiers_are_not_product_runtime( + path: str, +) -> None: + """Exclude build/test configuration and bounded verification commands.""" + assert not gate.is_runtime_source(path) + + +@pytest.mark.parametrize( + "path", + [ + "src/runtime.ts", + "src/feature.config.ts", + "scripts/serve-package.mjs", + "src/scripts/verify-session.ts", + ], +) +def test_runtime_modules_cannot_hide_behind_similar_names(path: str) -> None: + """Keep product modules and non-verification scripts in blocking scope.""" + assert gate.is_runtime_source(path) + + +def test_tooling_only_change_is_explicitly_not_applicable( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Reproduce the Inkspan false positive without weakening runtime evidence.""" + repo_root = tmp_path / "repo" + initialise_repo(repo_root) + tooling_files = { + "vite.autosave.config.ts": "export default { test: true };\n", + "scripts/verify-framework-free-autosave-package.mjs": ( + "console.log('verify framework-free package');\n" + ), + "scripts/verify-package.mjs": "console.log('verify package');\n", + } + for relative_path, content in tooling_files.items(): + file_path = repo_root / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + base_sha = commit(repo_root, "base tooling") + tooling_updates = { + "vite.autosave.config.ts": ( + "export default { test: true, version: 2 };\n" + ), + "scripts/verify-framework-free-autosave-package.mjs": ( + "console.log('verify framework-free package v2');\n" + ), + "scripts/verify-package.mjs": "console.log('verify package v2');\n", + } + for relative_path, content in tooling_updates.items(): + (repo_root / relative_path).write_text(content, encoding="utf-8") + head_sha = commit(repo_root, "update tooling") + summary_list = empty_coverage_list(repo_root) + + assert run_gate(repo_root, base_sha, head_sha, summary_list) == 0 + report = capsys.readouterr().out + assert "No changed JavaScript/TypeScript runtime source files" in report + assert "Result: PASS" in report + + +def test_non_verification_script_remains_fail_closed( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Require instrumentation when an executable scripts module is product code.""" + repo_root = tmp_path / "repo" + initialise_repo(repo_root) + runtime_script = repo_root / "scripts" / "serve-package.mjs" + runtime_script.parent.mkdir() + runtime_script.write_text("export const port = 8000;\n", encoding="utf-8") + base_sha = commit(repo_root, "base runtime script") + runtime_script.write_text("export const port = 8080;\n", encoding="utf-8") + head_sha = commit(repo_root, "change runtime script") + summary_list = empty_coverage_list(repo_root) + + assert run_gate(repo_root, base_sha, head_sha, summary_list) == 1 + report = capsys.readouterr().out + assert "scripts/serve-package.mjs is absent from coverage-final.json" in report + assert "Result: FAIL" in report + + +def test_runtime_path_without_diff_hunks_is_not_measured( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Ignore a runtime path when Git reports no added or modified line hunk.""" + enumerated = subprocess.CompletedProcess( + args=["git"], + returncode=0, + stdout=b"src/runtime.ts\0", + stderr=b"", + ) + monkeypatch.setattr(gate.subprocess, "run", lambda *args, **kwargs: enumerated) + monkeypatch.setattr(gate, "git", lambda *args, **kwargs: "") + + assert gate.changed_runtime_lines(tmp_path, "base", "head") == {} + + +def test_global_summary_ignores_non_integer_statement_locations() -> None: + """Treat malformed line metadata as absent instead of inventing coverage.""" + summary = gate.summarize_final( + { + "runtime.ts": { + "statementMap": { + "0": { + "start": {"line": "one"}, + "end": {"line": "one"}, + } + }, + "s": {"0": 1}, + "fnMap": {}, + "f": {}, + "branchMap": {}, + "b": {}, + } + } + ) + + assert summary == { + "statements": 100.0, + "branches": 100.0, + "functions": 100.0, + "lines": 100.0, + } + + +def test_coverage_path_nonmatches_fall_through_safely(tmp_path: Path) -> None: + """Reject unmatched absolute and relative Istanbul paths without ambiguity.""" + changed_paths = {"src/runtime.ts"} + + assert ( + gate.normalize_coverage_path( + str(tmp_path / "src" / "other.ts"), + tmp_path, + changed_paths, + ) + is None + ) + assert ( + gate.normalize_coverage_path( + "src/other.ts", + tmp_path, + changed_paths, + ) + is None + ) + + +def test_coverage_loader_accepts_absolute_paths_and_ignores_other_json( + tmp_path: Path, +) -> None: + """Load bounded final evidence while ignoring an unrelated listed JSON file.""" + final_path = tmp_path / "coverage-final.json" + other_path = tmp_path / "metadata.json" + final_path.write_text("{}", encoding="utf-8") + other_path.write_text("{}", encoding="utf-8") + summary_list = tmp_path / "coverage-files.txt" + summary_list.write_text( + f"{other_path}\n{final_path}\n", + encoding="utf-8", + ) + + summaries, finals = gate.load_coverage_files(tmp_path, summary_list) + + assert summaries == [] + assert finals == [(final_path, {})] + + +def test_unmatched_coverage_record_does_not_mask_matching_runtime_evidence( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Skip unrelated Istanbul records and still enforce the changed runtime file.""" + repo_root = tmp_path / "repo" + initialise_repo(repo_root) + source = repo_root / "src" / "runtime.ts" + source.parent.mkdir() + source.write_text("export const value = 1;\n", encoding="utf-8") + base_sha = commit(repo_root, "base runtime") + source.write_text("export const value = 2;\n", encoding="utf-8") + head_sha = commit(repo_root, "change runtime") + coverage_dir = repo_root / "coverage" + coverage_dir.mkdir() + coverage_record = { + "statementMap": { + "0": { + "start": {"line": 1, "column": 0}, + "end": {"line": 1, "column": 23}, + } + }, + "s": {"0": 1}, + "fnMap": {}, + "f": {}, + "branchMap": {}, + "b": {}, + } + final_path = coverage_dir / "coverage-final.json" + final_path.write_text( + json.dumps( + { + str(repo_root / "src" / "unrelated.ts"): coverage_record, + str(source): coverage_record, + } + ), + encoding="utf-8", + ) + summary_list = repo_root / "coverage-files.txt" + summary_list.write_text("coverage/coverage-final.json\n", encoding="utf-8") + + assert run_gate(repo_root, base_sha, head_sha, summary_list) == 0 + assert "src/runtime.ts: statements 1/1" in capsys.readouterr().out diff --git a/tests/test_pr_review_fix_hourly_contract.py b/tests/test_pr_review_fix_hourly_contract.py new file mode 100644 index 000000000..f55408fc8 --- /dev/null +++ b/tests/test_pr_review_fix_hourly_contract.py @@ -0,0 +1,45 @@ +"""Static contract for the central hourly PR review-fix scheduler.""" + +from __future__ import annotations + +from pathlib import Path + + +_WORKFLOW = Path(".github/workflows/pr-review-fix-scheduler.yml") + + +def _workflow_text() -> str: + """Return the canonical scheduler workflow text.""" + return _WORKFLOW.read_text(encoding="utf-8") + + +def test_review_fix_scheduler_runs_once_each_hour() -> None: + """The bounded repair dispatcher uses the requested hourly heartbeat.""" + text = _workflow_text() + + assert 'cron: "23 * * * *"' in text + assert 'cron: "23 */2 * * *"' not in text + + +def test_review_fix_scheduler_retries_same_head_after_one_hour() -> None: + """A blocked head can be retried on the next hourly cycle, not a day later.""" + text = _workflow_text() + + retry_block = text.split("retry_hours:", maxsplit=1)[1].split( + "autofix_workflow:", maxsplit=1 + )[0] + assert 'default: "1"' in retry_block + assert "inputs.retry_hours || '1'" in text + assert "inputs.retry_hours || '24'" not in text + + +def test_review_fix_scheduler_remains_bounded_and_single_flight() -> None: + """Higher cadence never expands mutation volume or parallel execution.""" + text = _workflow_text() + + dispatch_block = text.split("max_dispatches:", maxsplit=1)[1].split( + "target_repository:", maxsplit=1 + )[0] + assert 'default: "1"' in dispatch_block + assert "cancel-in-progress: true" in text + assert "MAX_DISPATCHES" in text diff --git a/tests/test_pr_review_fix_scheduler_import_fallback.py b/tests/test_pr_review_fix_scheduler_import_fallback.py new file mode 100644 index 000000000..ddb70a02a --- /dev/null +++ b/tests/test_pr_review_fix_scheduler_import_fallback.py @@ -0,0 +1,36 @@ +"""Coverage contract for the PR review-fix scheduler import fallback.""" + +from __future__ import annotations + +import builtins +import runpy +from collections.abc import Callable +from types import ModuleType +from typing import Any + +import pytest + + +def test_fix_scheduler_supports_package_qualified_import_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Direct-module absence loads the reviewed package-qualified scheduler.""" + + real_import: Callable[..., ModuleType] = builtins.__import__ + + def guarded_import(name: str, *args: Any, **kwargs: Any) -> ModuleType: + """Reject only the direct sibling import and preserve normal imports.""" + + if name == "pr_review_merge_scheduler": + raise ModuleNotFoundError(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + namespace = runpy.run_path( + "scripts/ci/pr_review_fix_scheduler.py", + run_name="pr_review_fix_scheduler_package_fallback_probe", + ) + + assert callable(namespace["fetch_open_prs"]) + assert callable(namespace["run"]) + assert callable(namespace["unresolved_thread_count"]) diff --git a/tests/test_pr_review_fix_scheduler_source_pin.py b/tests/test_pr_review_fix_scheduler_source_pin.py new file mode 100644 index 000000000..bb5a6bbc5 --- /dev/null +++ b/tests/test_pr_review_fix_scheduler_source_pin.py @@ -0,0 +1,77 @@ +"""Supply-chain contract for the reusable PR-review autofix scheduler.""" + +from __future__ import annotations + +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "pr-review-fix-scheduler.yml" + + +def _workflow_text() -> str: + """Read the reusable scheduler workflow as UTF-8 text.""" + return _WORKFLOW.read_text(encoding="utf-8") + + +def test_reusable_scheduler_validates_called_workflow_identity_before_checkout() -> None: + """Missing workflow identity must fail before checkout can use defaults.""" + workflow = _workflow_text() + guard = workflow.index("Resolve immutable called-workflow source") + checkout = workflow.index("Checkout immutable called-workflow source") + + assert guard < checkout + assert "WORKFLOW_REPOSITORY: ${{ job.workflow_repository }}" in workflow + assert "WORKFLOW_SHA: ${{ job.workflow_sha }}" in workflow + assert "WORKFLOW_REF: ${{ job.workflow_ref }}" in workflow + assert "WORKFLOW_FILE_PATH: ${{ job.workflow_file_path }}" in workflow + assert 'expected_repository="ContextualWisdomLab/.github"' in workflow + assert 'expected_file=".github/workflows/pr-review-fix-scheduler.yml"' in workflow + assert '[[ "$WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]' in workflow + assert "repository: ${{ steps.trusted_source.outputs.repository }}" in workflow + assert "ref: ${{ steps.trusted_source.outputs.sha }}" in workflow + + +def test_reusable_scheduler_verifies_checked_out_called_workflow_sha() -> None: + """The checked-out commit must equal the validated called-workflow SHA.""" + workflow = _workflow_text() + verification = workflow.index("Verify immutable called-workflow checkout") + self_test = workflow.index("Self-test fix scheduler contract") + + assert verification < self_test + assert 'actual_sha="$(git rev-parse HEAD)"' in workflow + assert '[ "$actual_sha" != "$EXPECTED_SHA" ]' in workflow + assert '[ ! -f "$EXPECTED_FILE" ] || [ -L "$EXPECTED_FILE" ]' in workflow + + +def test_reusable_scheduler_source_is_not_caller_input_controlled() -> None: + """No caller-supplied ref or ordinary caller GitHub SHA selects trusted code.""" + workflow = _workflow_text() + assert "inputs.canonical_ref" not in workflow + assert "github.event.client_payload.canonical_ref" not in workflow + assert "ref: ${{ env.CANONICAL_REF }}" not in workflow + assert "ref: ${{ github.sha }}" not in workflow + assert "ref: ${{ github.workflow_sha }}" not in workflow + + +def test_deprecated_canonical_ref_input_is_accepted_but_never_consumed() -> None: + """Existing callers can upgrade pins without controlling privileged source.""" + workflow = _workflow_text() + declaration = workflow.split("canonical_ref:", 1)[1].split( + "repository_dispatch:", 1 + )[0] + + assert "Deprecated compatibility input" in declaration + assert "ignored" in declaration + assert 'default: ""' in declaration + assert workflow.count("canonical_ref") == 1 + + +def test_reusable_scheduler_retains_least_privilege_and_bounded_dispatch() -> None: + """Source pinning does not broaden token scope or queue fan-out.""" + workflow = _workflow_text() + assert "contents: write" not in workflow + assert "pull-requests: write" not in workflow + assert "MAX_DISPATCHES:" in workflow + assert "RETRY_HOURS:" in workflow + assert "cancel-in-progress: true" in workflow diff --git a/tests/test_redact_json_key_boundary.py b/tests/test_redact_json_key_boundary.py new file mode 100644 index 000000000..ecf46d3fc --- /dev/null +++ b/tests/test_redact_json_key_boundary.py @@ -0,0 +1,169 @@ +"""Additional fail-closed JSON redaction boundary tests.""" + +from __future__ import annotations + +import json + +import pytest + +from scripts.ci import redact_sensitive_log as redactor + + +def test_json_object_keys_are_redacted_when_the_key_contains_a_provider_token() -> None: + """Credential-shaped JSON object keys must not survive structured redaction.""" + provider_token = "nv" + "api-" + ("K" * 24) + raw = json.dumps({provider_token: "ordinary-value"}) + + redacted = redactor.redact_text(raw) + + assert provider_token not in redacted + assert redactor.REDACTED in redacted + + +def test_json_object_keys_are_redacted_when_the_key_contains_an_assignment() -> None: + """Assignment-shaped JSON object keys must pass through the shared scanner.""" + assignment_key = "api_key=" + ("S" * 24) + raw = json.dumps({assignment_key: "ordinary-value"}) + + redacted = redactor.redact_text(raw) + + assert assignment_key not in redacted + assert redactor.REDACTED in redacted + + +@pytest.mark.parametrize( + "sensitive_key", + [ + "clientSecret", + "databasePassword", + "serviceAuthorizationHeader", + "privateKeyMaterial", + "sessionTokenValue", + ], +) +def test_concatenated_sensitive_json_keys_redact_their_values( + sensitive_key: str, +) -> None: + """CamelCase and concatenated credential keys cannot retain plain values.""" + secret_value = "plain-secret" + + redacted = redactor.redact_text(json.dumps({sensitive_key: secret_value})) + + assert secret_value not in redacted + assert redactor.REDACTED in redacted + + +def test_leading_whitespace_does_not_bypass_structured_json_redaction() -> None: + """Indented JSON diagnostics retain indentation but never a sensitive value.""" + secret_value = "plain-secret" + + redacted = redactor.redact_text( + " " + json.dumps({"clientSecret": secret_value}) + "\n" + ) + + assert redacted == f' {{"clientSecret":"{redactor.REDACTED}"}}\n' + assert secret_value not in redacted + + +def test_malformed_json_like_diagnostic_fails_closed() -> None: + """A JSON-looking line that cannot be parsed is replaced as one safe record.""" + raw = ' {"clientSecret":"plain-secret"\n' + + assert redactor.redact_text(raw) == f" {redactor.REDACTED}\n" + + +@pytest.mark.parametrize( + "raw", + [ + "tool --token plain-secret --name safe", + 'tool --password "plain secret" --name safe', + "tool --api-key 'plain secret' --name safe", + ], +) +def test_echoed_separate_sensitive_options_are_redacted(raw: str) -> None: + """Child-process command echoes cannot disclose separate option values.""" + redacted = redactor.redact_text(raw) + + assert "plain-secret" not in redacted + assert "plain secret" not in redacted + assert redactor.REDACTED in redacted + + +def test_sensitive_option_without_value_does_not_consume_the_next_option() -> None: + """A missing value leaves the following option visible for diagnosis.""" + raw = "tool --token --name safe" + + assert redactor.redact_text(raw) == raw + + +def test_long_ordinary_identifier_does_not_restart_assignment_scanning( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """One long ordinary token must cause only one assignment classification.""" + original = redactor._consume_sensitive_assignment + starts: list[int] = [] + + def instrument(text: str, start: int): + starts.append(start) + return original(text, start) + + monkeypatch.setattr(redactor, "_consume_sensitive_assignment", instrument) + ordinary_identifier = "a" * 100_000 + + assert redactor.redact_text(ordinary_identifier) == ordinary_identifier + assert starts == [0] + + +def test_oversized_assignment_key_is_redacted_conservatively() -> None: + """An oversized key cannot evade redaction by exceeding matcher limits.""" + oversized_key = "ordinary" * (redactor.MAX_IDENTIFIER_CHARS + 1) + secret_value = "plain-secret" + + redacted = redactor.redact_text(f"{oversized_key}={secret_value}") + + assert secret_value not in redacted + assert redacted.endswith(redactor.REDACTED) + + +def test_json_depth_limit_replaces_the_remaining_subtree() -> None: + """Excessive valid JSON nesting is redacted before recursive publication.""" + nested: object = "plain-secret" + for _ in range(redactor.MAX_JSON_DEPTH + 1): + nested = {"safe": nested} + + encoded = json.dumps(redactor._redact_json(nested)) + + assert "plain-secret" not in encoded + assert redactor.REDACTED in encoded + + +def test_json_parser_recursion_failure_redacts_the_entire_line( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A parser recursion failure must not fall back to weaker key handling.""" + + def raise_recursion(_value: str) -> object: + raise RecursionError("synthetic deeply nested diagnostic") + + monkeypatch.setattr(redactor.json, "loads", raise_recursion) + + assert ( + redactor.redact_text('{"api_key":"plain-secret"}\n') + == f"{redactor.REDACTED}\n" + ) + + +def test_json_encoder_recursion_failure_redacts_the_entire_line( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An encoder recursion failure follows the same fail-closed line boundary.""" + + def raise_recursion(*_args, **_kwargs) -> str: + raise RecursionError("synthetic encoder recursion") + + monkeypatch.setattr(redactor.json, "dumps", raise_recursion) + + assert ( + redactor.redact_text('{"message":"ordinary"}\n') + == f"{redactor.REDACTED}\n" + ) diff --git a/tests/test_sandboxed_output_redaction.py b/tests/test_sandboxed_output_redaction.py new file mode 100644 index 000000000..bab3956e7 --- /dev/null +++ b/tests/test_sandboxed_output_redaction.py @@ -0,0 +1,199 @@ +"""Regression tests for secret-safe sandbox subprocess evidence.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import cast + +from scripts.ci import sandboxed_verify, sandboxed_web_e2e +from scripts.ci.redact_sensitive_log import ( + REDACTED, + redact_command_arguments, + redact_shell_command, + redact_text, +) + + +def _provider_token() -> str: + """Build a credential-shaped fixture without committing a scanner secret.""" + return "gh" + "p_" + ("A" * 36) + + +def test_json_string_values_are_redacted_recursively() -> None: + """Valid JSON cannot bypass token redaction through non-sensitive value keys.""" + token = _provider_token() + raw = ( + '{"message":"provider ' + + token + + '","nested":{"notes":["Bearer ' + + token + + '","safe"]}}\n' + ) + + redacted = redact_text(raw) + + assert token not in redacted + assert redacted == ( + '{"message":"provider [REDACTED]",' + '"nested":{"notes":["Bearer [REDACTED]","safe"]}}\n' + ) + + +def test_redact_command_arguments_covers_separate_equals_and_direct_tokens() -> None: + """Redact option values, assignments, and provider-shaped standalone values.""" + token = _provider_token() + + assert redact_command_arguments( + ["tool", "--api-key", token, f"TOKEN={token}", token, "plain"] + ) == [ + "tool", + "--api-key", + REDACTED, + f"TOKEN={REDACTED}", + REDACTED, + "plain", + ] + + +def test_redact_shell_command_handles_parsed_and_malformed_input() -> None: + """Redact parsed commands and fall back to line redaction for bad quoting.""" + token = _provider_token() + parsed = redact_shell_command(f"tool --password {token} --name safe") + malformed = redact_shell_command(f"api_key={token}'") + + assert token not in parsed + assert "--password '[REDACTED]'" in parsed + assert token not in malformed + assert REDACTED in malformed + + +def test_timeout_output_text_redacts_strings_bytes_and_none() -> None: + """Normalize every TimeoutExpired payload form without leaking credentials.""" + token = _provider_token() + + assert sandboxed_verify.timeout_output_text(None) == "" + assert sandboxed_verify.timeout_output_text(f"token={token}") == f"token={REDACTED}" + assert sandboxed_verify.timeout_output_text(f"token={token}".encode()) == f"token={REDACTED}" + + +def test_sandboxed_verify_redacts_completed_output_command_and_note( + monkeypatch, tmp_path: Path, capsys +) -> None: + """Keep ordinary command completion evidence secret-free end to end.""" + token = _provider_token() + repository = tmp_path / "repository" + repository.mkdir() + + def fake_run_command(command, cwd, env, timeout): + return subprocess.CompletedProcess( + command, + 0, + stdout=f"token={token}\n", + stderr=f"Authorization: Bearer {token}\n", + ) + + monkeypatch.setattr(sandboxed_verify, "run_command", fake_run_command) + + exit_code = sandboxed_verify.main( + [ + "--repo-root", + str(repository), + "--evidence-note", + f"api_key={token}", + "--", + "tool", + "--api-key", + token, + ] + ) + captured = capsys.readouterr() + + assert exit_code == 0 + assert token not in captured.out + assert token not in captured.err + assert REDACTED in captured.out + assert REDACTED in captured.err + + +class _DoneProcess: + """Minimal completed-process double accepted by the service cleanup path.""" + + pid = 12345 + + def poll(self) -> int: + """Report that the fake service has already exited.""" + return 0 + + def wait(self, timeout: int) -> int: + """Return immediately for interface compatibility.""" + del timeout + return 0 + + +def test_sandboxed_web_e2e_redacts_commands_output_and_service_logs( + monkeypatch, tmp_path: Path, capsys +) -> None: + """Keep web E2E output, JSON evidence, and service log tails secret-free.""" + token = _provider_token() + repository = tmp_path / "repository" + repository.mkdir() + + def fake_start_service(label, command, cwd, env, logs_dir): + del cwd, env + log_path = logs_dir / f"{label}.log" + log_path.write_text(f"secret={token}\n", encoding="utf-8") + return sandboxed_web_e2e.Service( + label=label, + command=command, + process=cast(subprocess.Popen[str], _DoneProcess()), + log_path=log_path, + ) + + def fake_run_shell(command, cwd, env, timeout): + del cwd, env, timeout + return subprocess.CompletedProcess( + command, + 0, + stdout=f"token={token}\n", + stderr=f"Bearer {token}\n", + ) + + monkeypatch.setattr(sandboxed_web_e2e, "start_service", fake_start_service) + monkeypatch.setattr(sandboxed_web_e2e, "wait_for_url", lambda url, timeout, service: True) + monkeypatch.setattr(sandboxed_web_e2e, "run_shell", fake_run_shell) + + exit_code = sandboxed_web_e2e.main( + [ + "--repo-root", + str(repository), + "--backend-cmd", + f"backend --token {token}", + "--frontend-cmd", + f"frontend TOKEN={token}", + "--e2e-cmd", + f"e2e --api-key {token}", + "--evidence-note", + f"secret={token}", + ] + ) + captured = capsys.readouterr() + + assert exit_code == 0 + assert token not in captured.out + assert token not in captured.err + assert captured.out.count(REDACTED) >= 7 + assert REDACTED in captured.err + + +def test_tail_text_handles_missing_and_bounded_existing_logs(tmp_path: Path) -> None: + """Return nothing for missing logs and redact only the requested final lines.""" + token = _provider_token() + missing = tmp_path / "missing.log" + log_path = tmp_path / "service.log" + log_path.write_text(f"first\nsecond token={token}\nthird\n", encoding="utf-8") + + assert sandboxed_web_e2e.tail_text(missing) == "" + tail = sandboxed_web_e2e.tail_text(log_path, max_lines=2) + assert tail == f"second token={REDACTED}\nthird" + assert token not in tail diff --git a/tests/test_sandboxed_web_e2e.py b/tests/test_sandboxed_web_e2e.py index 6e092c293..a51051f27 100644 --- a/tests/test_sandboxed_web_e2e.py +++ b/tests/test_sandboxed_web_e2e.py @@ -181,13 +181,13 @@ def fake_run(*args, **kwargs): assert service.command == "npm run dev" assert service.log_path == tmp_path / "backend.log" assert popen_calls[0][0] == (["npm", "run", "dev"],) - assert "shell" not in popen_calls[0][1] + assert popen_calls[0][1].get("shell") is False assert "executable" not in popen_calls[0][1] assert popen_calls[0][1]["start_new_session"] is True assert completed.returncode == 7 assert run_calls[0][0] == (["npm", "test"],) assert run_calls[0][1]["timeout"] == 5 - assert "shell" not in run_calls[0][1] + assert run_calls[0][1].get("shell") is False assert "executable" not in run_calls[0][1] diff --git a/tests/test_sbom_generation_push_contract.py b/tests/test_sbom_generation_push_contract.py new file mode 100644 index 000000000..011be9737 --- /dev/null +++ b/tests/test_sbom_generation_push_contract.py @@ -0,0 +1,54 @@ +"""Contracts for default-branch dependency snapshot generation.""" + +from __future__ import annotations + +from pathlib import Path + + +WORKFLOW = Path(".github/workflows/sbom-generation.yml") + + +def _workflow_text() -> str: + """Return the centrally versioned SBOM workflow as UTF-8 text.""" + + return WORKFLOW.read_text(encoding="utf-8") + + +def test_sbom_workflow_snapshots_supported_default_branch_pushes() -> None: + """Default-branch commits must receive dependency graph snapshots.""" + + workflow = _workflow_text() + + assert "on:\n push:\n branches: [main, master, develop]\n" in workflow + assert " pull_request:\n" in workflow + assert " release:\n" in workflow + assert "dependency-snapshot: true" in workflow + + +def test_sbom_push_concurrency_is_bound_to_the_commit_sha() -> None: + """A later default-branch push must not cancel another commit's snapshot.""" + + workflow = _workflow_text() + group_line = next( + line.strip() for line in workflow.splitlines() if line.strip().startswith("group:") + ) + + assert "github.event.release.tag_name || github.sha" in group_line + assert "github.event.release.tag_name || github.ref" not in group_line + + +def test_sbom_job_conditions_keep_push_runs_active_and_closed_prs_inert() -> None: + """Pushes run the snapshot job while closed-PR events only cancel stale work.""" + + workflow = _workflow_text() + + assert ( + "if: github.event_name == 'pull_request' && github.event.action == 'closed'" + in workflow + ) + assert ( + "if: github.event_name != 'pull_request' || github.event.action != 'closed'" + in workflow + ) + assert "generate-sbom:\n" in workflow + assert " contents: write\n" in workflow diff --git a/tests/test_unstructured_separate_option_redaction.py b/tests/test_unstructured_separate_option_redaction.py new file mode 100644 index 000000000..c56a52a0f --- /dev/null +++ b/tests/test_unstructured_separate_option_redaction.py @@ -0,0 +1,11 @@ +"""Regression evidence for separate sensitive options echoed in child output.""" + +from scripts.ci import redact_sensitive_log as redactor + + +def test_unstructured_output_redacts_separate_sensitive_option_values() -> None: + """A child that echoes a separate secret option must not disclose its value.""" + + assert redactor.redact_text( + "running tool --api-key ordinary-value --mode safe" + ) == "running tool --api-key [REDACTED] --mode safe"