diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dc150637..9b8b11d2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,10 @@ updates: directory: / schedule: interval: weekly + # Let ecosystem fixes settle before routine version PRs. Dependabot + # security updates are explicitly not delayed by this setting. + cooldown: + default-days: 7 groups: ccusage-runtime: patterns: diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 224e1f98..2163a2b1 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -18,9 +18,7 @@ on: - "apps/desktop/src-tauri/tauri.conf.json" workflow_dispatch: -permissions: - contents: write - actions: write +permissions: {} concurrency: group: auto-release-${{ github.ref }} @@ -28,11 +26,16 @@ concurrency: jobs: publish: + name: Publish release and dispatch build runs-on: ubuntu-latest + permissions: + contents: write # Create the versioned GitHub release. + actions: write # Dispatch the separate signed-build workflow. steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 + persist-credentials: false - name: Read version from tauri.conf.json id: ver @@ -50,12 +53,13 @@ jobs: id: check env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.ver.outputs.tag }} run: | set -euo pipefail - if gh release view "${{ steps.ver.outputs.tag }}" \ + if gh release view "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" - echo "Release ${{ steps.ver.outputs.tag }} already exists — skipping." + echo "Release $RELEASE_TAG already exists — skipping." else echo "exists=false" >> "$GITHUB_OUTPUT" fi @@ -64,21 +68,23 @@ jobs: if: steps.check.outputs.exists != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.ver.outputs.tag }} run: | set -euo pipefail - gh release create "${{ steps.ver.outputs.tag }}" \ + gh release create "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --target "$GITHUB_SHA" \ - --title "CodeVetter ${{ steps.ver.outputs.tag }}" \ + --title "CodeVetter $RELEASE_TAG" \ --generate-notes - name: Dispatch release build workflow if: steps.check.outputs.exists != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.ver.outputs.tag }} run: | set -euo pipefail gh workflow run release.yml \ --repo "$GITHUB_REPOSITORY" \ --ref main \ - -f tag="${{ steps.ver.outputs.tag }}" + -f tag="$RELEASE_TAG" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea6d5de5..3410211a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,19 +4,72 @@ on: branches: [main] pull_request: workflow_dispatch: + inputs: + native_qualification: + description: "Run isolated native macOS qualification" + required: true + default: false + type: boolean + native_interaction: + description: "Include XCUITest on the isolated hosted desktop" + required: true + default: false + type: boolean + native_production_qualification: + description: "Run protected signing, notarization, and migration qualification" + required: true + default: false + type: boolean +permissions: {} + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + native-qualification: + name: Native macOS qualification + if: >- + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.native_qualification) + uses: ./.github/workflows/native-qualification.yml + with: + run_interaction: ${{ github.event_name == 'pull_request' || inputs.native_interaction }} + permissions: + contents: read + + native-production-qualification: + name: Native macOS production-candidate qualification + if: github.event_name == 'workflow_dispatch' && inputs.native_production_qualification + uses: ./.github/workflows/native-production-qualification.yml + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }} + SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }} + permissions: + contents: read + lint-and-typecheck: + name: Lint, test, and build runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 2 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 + persist-credentials: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '22' cache: 'pnpm' - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - name: Install Tauri Linux dependencies run: | sudo apt-get update @@ -25,7 +78,7 @@ jobs: libayatana-appindicator3-dev \ librsvg2-dev \ libxdo-dev - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: apps/desktop/src-tauri - name: Install Dependencies @@ -35,7 +88,9 @@ jobs: run: pnpm run lint - name: Fetch code-health base if: github.event_name != 'workflow_dispatch' - run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} + env: + CODE_HEALTH_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} + run: git fetch --depth=1 origin "$CODE_HEALTH_BASE" - name: Code health env: CODE_HEALTH_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }} @@ -83,7 +138,8 @@ jobs: run: | pnpm run test:ccusage-sidecar pnpm run prepare:ccusage-sidecar - src-tauri/binaries/ccusage-$(rustc -vV | sed -n 's/^host: //p') --version + CCUSAGE_TARGET="$(rustc -vV | sed -n 's/^host: //p')" + "src-tauri/binaries/ccusage-$CCUSAGE_TARGET" --version - name: Qualify CLI artifact working-directory: apps/desktop run: | diff --git a/.github/workflows/deploy-landing.yml b/.github/workflows/deploy-landing.yml index 629d8339..787eff44 100644 --- a/.github/workflows/deploy-landing.yml +++ b/.github/workflows/deploy-landing.yml @@ -7,9 +7,8 @@ name: Deploy Landing Page # apps/landing-page-astro/. on: workflow_dispatch: -permissions: - contents: read - deployments: write + +permissions: {} concurrency: group: deploy-landing-${{ github.ref }} @@ -17,16 +16,22 @@ concurrency: jobs: deploy: + name: Build and deploy landing page runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read + deployments: write # Record the Cloudflare Pages deployment. steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" - cache: pnpm + package-manager-cache: false - name: Install dependencies run: pnpm install --frozen-lockfile @@ -58,7 +63,7 @@ jobs: - name: Deploy to Cloudflare Pages if: steps.cloudflare.outputs.deploy_enabled == 'true' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 21d75599..a59fc946 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,12 +5,19 @@ on: [push, pull_request] permissions: contents: read +concurrency: + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: validate: + name: Validate documentation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: "22" - name: Validate docs (links, frontmatter, structure) diff --git a/.github/workflows/native-production-qualification.yml b/.github/workflows/native-production-qualification.yml new file mode 100644 index 00000000..32ff52c4 --- /dev/null +++ b/.github/workflows/native-production-qualification.yml @@ -0,0 +1,274 @@ +name: Native macOS production-candidate qualification + +on: + workflow_call: + secrets: + APPLE_CERTIFICATE: + required: true + APPLE_CERTIFICATE_PASSWORD: + required: true + APPLE_SIGNING_IDENTITY: + required: true + APPLE_ID: + required: true + APPLE_PASSWORD: + required: true + APPLE_TEAM_ID: + required: true + SPARKLE_EDDSA_PRIVATE_KEY: + required: true + SPARKLE_EDDSA_PUBLIC_KEY: + required: true + workflow_dispatch: + +permissions: {} + +concurrency: + group: native-production-qualification-${{ github.ref }} + cancel-in-progress: false + +jobs: + qualify: + name: Sign, notarize, migrate, and qualify native candidate + runs-on: xcode-27 + timeout-minutes: 180 + permissions: + contents: read + env: + CODEVETTER_NATIVE_CHANNEL: production + CODEVETTER_NATIVE_BUNDLE_IDENTIFIER: com.codevetter.desktop + CODEVETTER_NATIVE_SPARKLE_FEED_URL: https://github.com/Codevetter/codevetter/releases/latest/download/appcast.xml + CODEVETTER_NATIVE_SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }} + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 2 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + package-manager-cache: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + no-cache: true + + - name: Install locked dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Require protected production inputs + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }} + SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }} + shell: bash + run: | + set -euo pipefail + missing=() + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID SPARKLE_EDDSA_PRIVATE_KEY SPARKLE_EDDSA_PUBLIC_KEY; do + if [[ -z "${!name:-}" ]]; then missing+=("$name"); fi + done + if (( ${#missing[@]} > 0 )); then + echo "Missing protected inputs: ${missing[*]}" >&2 + exit 1 + fi + + - name: Import Developer ID certificate into ephemeral keychain + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + shell: bash + run: | + set -euo pipefail + KEYCHAIN_PATH="$RUNNER_TEMP/codevetter-signing.keychain-db" + CERTIFICATE_PATH="$RUNNER_TEMP/codevetter-developer-id.p12" + KEYCHAIN_PASSWORD="$(openssl rand -hex 32)" + printf '%s' "$APPLE_CERTIFICATE" | /usr/bin/base64 --decode > "$CERTIFICATE_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + rm -f "$CERTIFICATE_PATH" + echo "CODEVETTER_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Build production-identity Release app through XcodeBuildMCP + run: pnpm native:build:release + + - name: Build and Developer ID-sign exact native package + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + shell: bash + run: | + set -euo pipefail + pnpm native:package:qualify -- \ + --channel production \ + --identity "$APPLE_SIGNING_IDENTITY" \ + --out-root artifacts/native-production-ci + QUALIFICATION="$(find artifacts/native-production-ci -name qualification.json -print -quit)" + test -n "$QUALIFICATION" + PACKAGE_DIR="$(dirname "$QUALIFICATION")" + echo "NATIVE_QUALIFICATION=$QUALIFICATION" >> "$GITHUB_ENV" + echo "NATIVE_PACKAGE_DIR=$PACKAGE_DIR" >> "$GITHUB_ENV" + + - name: Submit signed app to Apple and staple ticket + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + shell: bash + run: | + set -euo pipefail + DEVELOPER_DIR="$(xcode-select -p)" + NOTARYTOOL="$DEVELOPER_DIR/usr/bin/notarytool" + STAPLER="$DEVELOPER_DIR/usr/bin/stapler" + ZIP_PATH="$(find "$NATIVE_PACKAGE_DIR" -maxdepth 1 -name '*.zip' -print -quit)" + "$NOTARYTOOL" submit "$ZIP_PATH" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --output-format json > artifacts/native-production-ci/notary-initial.json + jq -e '.status == "Accepted"' artifacts/native-production-ci/notary-initial.json >/dev/null + "$STAPLER" staple "$NATIVE_PACKAGE_DIR/CodeVetter.app" + "$STAPLER" validate "$NATIVE_PACKAGE_DIR/CodeVetter.app" + + - name: Rebuild archives around the stapled application + run: pnpm native:package:finalize -- --qualification "$NATIVE_QUALIFICATION" + + - name: Bind final archive to Apple notarization + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + shell: bash + run: | + set -euo pipefail + NOTARYTOOL="$(xcode-select -p)/usr/bin/notarytool" + ZIP_PATH="$(find "$NATIVE_PACKAGE_DIR" -maxdepth 1 -name '*.zip' -print -quit)" + "$NOTARYTOOL" submit "$ZIP_PATH" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --output-format json > artifacts/native-production-ci/notary-final.json + jq -e '.status == "Accepted"' artifacts/native-production-ci/notary-final.json >/dev/null + pnpm native:notarization:prove -- \ + --app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \ + --archive "$ZIP_PATH" \ + --qualification "$NATIVE_QUALIFICATION" \ + --submission artifacts/native-production-ci/notary-final.json \ + --out artifacts/native-production-ci/notarization-proof.json + + - name: Generate and cryptographically inspect Sparkle appcast + env: + SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }} + shell: bash + run: | + set -euo pipefail + APPCAST_DIR="artifacts/native-production-ci/appcast" + mkdir -p "$APPCAST_DIR" + ZIP_PATH="$(find "$NATIVE_PACKAGE_DIR" -maxdepth 1 -name '*.zip' -print -quit)" + cp "$ZIP_PATH" "$APPCAST_DIR/" + GENERATE_APPCAST="$(find artifacts/native-build/DerivedData/SourcePackages/artifacts -path '*/Sparkle/bin/generate_appcast' -type f -print -quit)" + test -x "$GENERATE_APPCAST" + printf '%s' "$SPARKLE_EDDSA_PRIVATE_KEY" | "$GENERATE_APPCAST" \ + --ed-key-file - \ + --download-url-prefix "https://github.com/Codevetter/codevetter/releases/latest/download/" \ + "$APPCAST_DIR" + test -f "$APPCAST_DIR/appcast.xml" + pnpm native:appcast:inspect -- \ + --app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \ + --appcast "$APPCAST_DIR/appcast.xml" \ + --qualification "$NATIVE_QUALIFICATION" \ + --out artifacts/native-production-ci/appcast-proof.json + + - name: Download retained Tauri release for isolated migration proof + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + INCUMBENT_ROOT="$RUNNER_TEMP/codevetter-incumbent" + mkdir -p "$INCUMBENT_ROOT" + gh release download v1.11.0 \ + --repo "$GITHUB_REPOSITORY" \ + --pattern 'CodeVetter_aarch64.app.tar.gz' \ + --dir "$INCUMBENT_ROOT" + tar -xzf "$INCUMBENT_ROOT/CodeVetter_aarch64.app.tar.gz" -C "$INCUMBENT_ROOT" + INCUMBENT_APP="$(find "$INCUMBENT_ROOT" -maxdepth 2 -name CodeVetter.app -type d -print -quit)" + test -n "$INCUMBENT_APP" + echo "NATIVE_INCUMBENT_APP=$INCUMBENT_APP" >> "$GITHUB_ENV" + + - name: Qualify isolated upgrade, relaunch, custom rubric, data, and rollback + run: | + pnpm native:installed-upgrade:qualify -- \ + --incumbent-app "$NATIVE_INCUMBENT_APP" \ + --native-app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \ + --qualification "$NATIVE_QUALIFICATION" \ + --run-root "$RUNNER_TEMP/codevetter-native-upgrade" \ + --out artifacts/native-production-ci/installed-upgrade-proof.json \ + --foreground \ + --hosted-ephemeral + + - name: Require every production-readiness check + shell: bash + run: | + set -euo pipefail + pnpm native:release:inspect -- \ + --app "$NATIVE_PACKAGE_DIR/CodeVetter.app" \ + --qualification "$NATIVE_QUALIFICATION" \ + --appcast-proof artifacts/native-production-ci/appcast-proof.json \ + --notarization-proof artifacts/native-production-ci/notarization-proof.json \ + --installed-proof artifacts/native-production-ci/installed-upgrade-proof.json \ + --out artifacts/native-production-ci/release-readiness.json + jq -e '.shipping_ready == true and (.checks | all(.passed == true))' \ + artifacts/native-production-ci/release-readiness.json >/dev/null + + - name: Test all production qualification contracts + run: | + pnpm test:native-runner + pnpm test:native-package + pnpm test:native-package-finalize + pnpm test:native-appcast + pnpm test:native-notarization + pnpm test:native-data-continuity + pnpm test:native-installed-upgrade + pnpm test:native-release + + - name: Remove ephemeral credential material + if: always() + shell: bash + run: | + if [[ -n "${CODEVETTER_SIGNING_KEYCHAIN:-}" ]]; then + security delete-keychain "$CODEVETTER_SIGNING_KEYCHAIN" || true + fi + rm -f "$RUNNER_TEMP/codevetter-developer-id.p12" + + - name: Upload protected production-candidate evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: native-production-qualification-${{ github.run_id }} + path: | + artifacts/native-production-ci + artifacts/native-build/DerivedData/Build/Products/Release/CodeVetter.app.dSYM + if-no-files-found: warn + retention-days: 7 + compression-level: 0 diff --git a/.github/workflows/native-qualification.yml b/.github/workflows/native-qualification.yml new file mode 100644 index 00000000..f5d436df --- /dev/null +++ b/.github/workflows/native-qualification.yml @@ -0,0 +1,126 @@ +name: Native macOS qualification + +on: + workflow_call: + inputs: + run_interaction: + description: "Run XCUITest on the isolated hosted desktop" + required: true + type: boolean + workflow_dispatch: + inputs: + run_interaction: + description: "Run XCUITest on the isolated hosted desktop" + required: true + default: false + type: boolean + +permissions: {} + +concurrency: + group: native-qualification-${{ github.ref }} + cancel-in-progress: false + +jobs: + qualify: + name: Build and qualify native preview + runs-on: xcode-27 + timeout-minutes: 120 + permissions: + contents: read + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 2 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 22 + package-manager-cache: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + no-cache: true + + - name: Install locked dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Prove desktop-control guard + run: pnpm test:native-runner + + - name: Prepare deterministic owner-review outputs + shell: bash + run: | + node scripts/render-native-owner-review.mjs env \ + --out-root artifacts/native-owner-review-ci >> "$GITHUB_ENV" + + - name: Run background-safe native qualification + run: pnpm test:native:background + + - name: Finalize deterministic owner-review packet + run: | + node scripts/render-native-owner-review.mjs finalize \ + --out-root artifacts/native-owner-review-ci + pnpm test:native-review-render + + - name: Run interaction qualification on isolated desktop + if: inputs.run_interaction + run: pnpm test:native:ui -- --foreground --desktop-idle + + - name: Build coverage-free Release application + run: pnpm native:build:release + + - name: Build and qualify local preview package + run: pnpm native:package:qualify -- --out-root artifacts/native-package-ci + + - name: Inspect exact preview release boundaries + id: readiness + shell: bash + run: | + set -euo pipefail + QUALIFICATION="$(find artifacts/native-package-ci -name qualification.json -print -quit)" + test -n "$QUALIFICATION" + PACKAGE_DIR="$(dirname "$QUALIFICATION")" + node scripts/inspect-native-release-readiness.mjs \ + --app "$PACKAGE_DIR/CodeVetter.app" \ + --qualification "$QUALIFICATION" \ + --out artifacts/native-package-ci/release-readiness.json + echo "qualification=$QUALIFICATION" >> "$GITHUB_OUTPUT" + + - name: Test package and readiness inspectors + run: | + pnpm test:native-package + pnpm test:native-release + + - name: Preserve native tool logs + if: always() + shell: bash + run: | + mkdir -p artifacts/native-tool-logs + find "$HOME/Library/Developer/XcodeBuildMCP" \ + -path '*/logs/*' -type f -name '*.log' \ + -exec cp {} artifacts/native-tool-logs/ \; + + - name: Upload unsigned qualification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: native-qualification-${{ github.run_id }} + path: | + artifacts/native-package-ci + artifacts/native-owner-review-ci + artifacts/native-tool-logs + artifacts/native-build/DerivedData/Build/Products/Release/CodeVetter.app.dSYM + if-no-files-found: warn + retention-days: 7 + compression-level: 0 diff --git a/.github/workflows/osv-offline.yml b/.github/workflows/osv-offline.yml new file mode 100644 index 00000000..26e263d0 --- /dev/null +++ b/.github/workflows/osv-offline.yml @@ -0,0 +1,84 @@ +name: OSV Offline Scan + +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: osv-offline-${{ github.ref }} + cancel-in-progress: false + +jobs: + refresh-databases: + name: Refresh OSV databases + runs-on: ubuntu-latest + steps: + - name: Download ecosystem databases + env: + OSV_CACHE_ROOT: ${{ runner.temp }}/osv-db/osv-scalibr + run: | + mkdir -p "$OSV_CACHE_ROOT/npm" "$OSV_CACHE_ROOT/crates.io" "$OSV_CACHE_ROOT/Go" + curl --fail --silent --show-error --location \ + https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip \ + --output "$OSV_CACHE_ROOT/npm/all.zip" + curl --fail --silent --show-error --location \ + https://osv-vulnerabilities.storage.googleapis.com/crates.io/all.zip \ + --output "$OSV_CACHE_ROOT/crates.io/all.zip" + curl --fail --silent --show-error --location \ + https://osv-vulnerabilities.storage.googleapis.com/Go/all.zip \ + --output "$OSV_CACHE_ROOT/Go/all.zip" + cd "$OSV_CACHE_ROOT" + sha256sum npm/all.zip crates.io/all.zip Go/all.zip > SHA256SUMS + - name: Upload database snapshot + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: osv-databases-${{ github.run_id }} + path: ${{ runner.temp }}/osv-db + retention-days: 7 + + offline-scan: + name: Scan with network-disabled mode + needs: refresh-databases + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download database snapshot + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: osv-databases-${{ github.run_id }} + path: ${{ runner.temp }}/osv-db + - name: Install checksum-pinned OSV-Scanner + env: + OSV_BINARY: osv-scanner_linux_amd64 + OSV_SHA256: f9f25499a2c8cc367b3af45df2ea7eeca7fbccceab9c35079968f4b3652194be + OSV_URL: https://github.com/google/osv-scanner/releases/download/v2.5.1/osv-scanner_linux_amd64 + run: | + curl --fail --silent --show-error --location "$OSV_URL" --output "$RUNNER_TEMP/$OSV_BINARY" + echo "$OSV_SHA256 $RUNNER_TEMP/$OSV_BINARY" | sha256sum --check --strict + chmod 0755 "$RUNNER_TEMP/$OSV_BINARY" + mkdir -p "$RUNNER_TEMP/osv-bin" + mv "$RUNNER_TEMP/$OSV_BINARY" "$RUNNER_TEMP/osv-bin/osv-scanner" + echo "$RUNNER_TEMP/osv-bin" >> "$GITHUB_PATH" + - name: Run offline scan + id: scan + continue-on-error: true + env: + XDG_CACHE_HOME: ${{ runner.temp }}/osv-db + run: node scripts/run-osv-offline.mjs + - name: Upload scan evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: osv-offline-evidence-${{ github.run_id }} + path: artifacts/tooling/osv + if-no-files-found: error + retention-days: 30 + - name: Enforce scan result + if: always() && steps.scan.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 235c30d4..fd596879 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,47 +10,51 @@ on: required: true type: string -permissions: - contents: write +permissions: {} + +concurrency: + group: release-${{ github.event.release.tag_name || inputs.tag || github.ref }} + cancel-in-progress: false jobs: build: + name: Build, sign, and upload desktop artifacts strategy: matrix: platform: [macos-latest] runs-on: ${{ matrix.platform }} + permissions: + contents: write # Upload signed artifacts and updater metadata. steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: # On workflow_dispatch we want the commit the tag points at, not # the head of main — checkout the tag explicitly. ref: ${{ github.event.release.tag_name || inputs.tag }} + persist-credentials: false - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 # Don't specify version here — it reads "packageManager" from # package.json (pnpm@10.33.2). Specifying both causes # "Multiple versions of pnpm specified" error. - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 - cache: pnpm + package-manager-cache: false - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: targets: aarch64-apple-darwin - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Cache Rust - uses: Swatinem/rust-cache@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - workspaces: apps/desktop/src-tauri + no-cache: true - name: Install dependencies run: pnpm install --ignore-scripts @@ -102,7 +106,7 @@ jobs: - name: Build Tauri app id: tauri - uses: tauri-apps/tauri-action@v0 + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_ENV_TARGET_TRIPLE: aarch64-apple-darwin diff --git a/.github/workflows/repository-security.yml b/.github/workflows/repository-security.yml new file mode 100644 index 00000000..5a40510a --- /dev/null +++ b/.github/workflows/repository-security.yml @@ -0,0 +1,184 @@ +name: Repository Security + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: {} + +concurrency: + group: repository-security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + name: GitHub Actions semantics + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install pinned workflow analyzers + env: + ACTIONLINT_ARCHIVE: actionlint_1.7.12_linux_amd64.tar.gz + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + ACTIONLINT_URL: https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz + SHELLCHECK_ARCHIVE: shellcheck-v0.11.0.linux.x86_64.tar.gz + SHELLCHECK_SHA256: b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6 + SHELLCHECK_URL: https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.gz + run: | + mkdir -p "$RUNNER_TEMP/workflow-tools" + curl --fail --silent --show-error --location "$ACTIONLINT_URL" --output "$RUNNER_TEMP/$ACTIONLINT_ARCHIVE" + echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/$ACTIONLINT_ARCHIVE" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/$ACTIONLINT_ARCHIVE" -C "$RUNNER_TEMP/workflow-tools" actionlint + curl --fail --silent --show-error --location "$SHELLCHECK_URL" --output "$RUNNER_TEMP/$SHELLCHECK_ARCHIVE" + echo "$SHELLCHECK_SHA256 $RUNNER_TEMP/$SHELLCHECK_ARCHIVE" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/$SHELLCHECK_ARCHIVE" -C "$RUNNER_TEMP" + mv "$RUNNER_TEMP/shellcheck-v0.11.0/shellcheck" "$RUNNER_TEMP/workflow-tools/shellcheck" + echo "$RUNNER_TEMP/workflow-tools" >> "$GITHUB_PATH" + - name: Validate workflow syntax and shell fragments + run: | + actionlint -version + shellcheck --version + actionlint -color + + biome-sarif: + name: Biome SARIF + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # Publish the generated Biome report. + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Generate Biome SARIF + env: + BIOME_SARIF_PATH: artifacts/tooling/biome.sarif + run: pnpm run quality:sarif + - name: Upload Biome SARIF + if: always() + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + with: + sarif_file: artifacts/tooling/biome.sarif + category: biome + + cargo-deny: + name: Rust dependency policy + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # Publish license and source-policy findings. + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install pinned cargo-deny binary + env: + CARGO_DENY_ARCHIVE: cargo-deny-0.20.2-x86_64-unknown-linux-musl.tar.gz + CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f + CARGO_DENY_URL: https://github.com/EmbarkStudios/cargo-deny/releases/download/0.20.2/cargo-deny-0.20.2-x86_64-unknown-linux-musl.tar.gz + run: | + curl --fail --silent --show-error --location "$CARGO_DENY_URL" --output "$RUNNER_TEMP/$CARGO_DENY_ARCHIVE" + echo "$CARGO_DENY_SHA256 $RUNNER_TEMP/$CARGO_DENY_ARCHIVE" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/$CARGO_DENY_ARCHIVE" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/cargo-deny-0.20.2-x86_64-unknown-linux-musl" >> "$GITHUB_PATH" + - name: Seed locked Rust dependencies + run: cargo fetch --locked --manifest-path apps/desktop/src-tauri/Cargo.toml + - name: Check licenses, sources, and wildcard requirements + id: policy + continue-on-error: true + run: | + mkdir -p artifacts/tooling + set +e + cargo-deny --format sarif --manifest-path apps/desktop/src-tauri/Cargo.toml --config apps/desktop/src-tauri/deny.toml --frozen check licenses sources > artifacts/tooling/cargo-deny.sarif + policy_status=$? + cargo-deny --manifest-path apps/desktop/src-tauri/Cargo.toml --config apps/desktop/src-tauri/deny.toml --frozen check --hide-inclusion-graph bans + bans_status=$? + set -e + if [ ! -s artifacts/tooling/cargo-deny.sarif ]; then + printf '%s\n' '{"version":"2.1.0","runs":[{"tool":{"driver":{"name":"cargo-deny","version":"0.20.2","rules":[]}},"results":[]}]}' > artifacts/tooling/cargo-deny.sarif + fi + if jq empty artifacts/tooling/cargo-deny.sarif; then + echo "sarif_valid=true" >> "$GITHUB_OUTPUT" + fi + if [ "$policy_status" -ne 0 ] || [ "$bans_status" -ne 0 ]; then + exit 1 + fi + - name: Upload cargo-deny SARIF + if: always() && steps.policy.outputs.sarif_valid == 'true' + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + with: + sarif_file: artifacts/tooling/cargo-deny.sarif + category: cargo-deny + - name: Enforce cargo-deny result + if: always() && steps.policy.outcome == 'failure' + run: exit 1 + + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # Publish the redacted Gitleaks report. + steps: + - name: Checkout complete history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install pinned Gitleaks binary + env: + GITLEAKS_ARCHIVE: gitleaks_8.30.1_linux_x64.tar.gz + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + GITLEAKS_URL: https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz + run: | + curl --fail --silent --show-error --location "$GITLEAKS_URL" --output "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" + echo "$GITLEAKS_SHA256 $RUNNER_TEMP/$GITLEAKS_ARCHIVE" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/gitleaks-bin" + tar -xzf "$RUNNER_TEMP/$GITLEAKS_ARCHIVE" -C "$RUNNER_TEMP/gitleaks-bin" gitleaks + echo "$RUNNER_TEMP/gitleaks-bin" >> "$GITHUB_PATH" + - name: Scan repository history + id: scan + continue-on-error: true + run: >- + gitleaks git --no-banner --redact=100 --report-format sarif + --report-path "$RUNNER_TEMP/gitleaks.sarif" . + - name: Upload Gitleaks SARIF + if: always() + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + with: + sarif_file: ${{ runner.temp }}/gitleaks.sarif + category: gitleaks + - name: Enforce Gitleaks result + if: always() && steps.scan.outcome == 'failure' + run: exit 1 + + zizmor: + name: zizmor + runs-on: ubuntu-latest + permissions: + security-events: write # zizmor-action uploads its SARIF report. + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Audit GitHub Actions + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index c0f7cecb..cb36ed8e 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -4,24 +4,35 @@ on: - cron: '0 9 * * 1' workflow_dispatch: +permissions: {} + +concurrency: + group: weekly-quality-${{ github.ref }} + cancel-in-progress: true + jobs: quality: + name: Run weekly quality canary runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - name: Record source revision id: rev run: | - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + { + echo "sha=$(git rev-parse HEAD)" + echo "short=$(git rev-parse --short HEAD)" + echo "ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '22' @@ -72,24 +83,29 @@ jobs: - name: Emit canary evidence if: always() + env: + JOB_STATUS: ${{ job.status }} + SOURCE_REVISION: ${{ steps.rev.outputs.sha }} + SOURCE_REVISION_SHORT: ${{ steps.rev.outputs.short }} + STARTED_AT: ${{ steps.rev.outputs.ts }} run: | set -euo pipefail mkdir -p canary-out # The conclusion is only known after the quality step; read it # from the job status env that GitHub sets for `if: always()` steps. # We treat any non-success quality step as a failure. - CONCLUSION="${{ job.status }}" + CONCLUSION="$JOB_STATUS" # `job.status` is the *job* status at the point this step starts; # because this step runs with `if: always()`, the prior step's # failure has already propagated to the job status. cat > canary-out/canary-evidence.json <> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY" - echo "|---|---|" >> "$GITHUB_STEP_SUMMARY" - echo "| Revision | \`${{ steps.rev.outputs.short }}\` |" >> "$GITHUB_STEP_SUMMARY" - echo "| Started | ${{ steps.rev.outputs.ts }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Conclusion | ${CONCLUSION} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Timeout | 20 minutes |" >> "$GITHUB_STEP_SUMMARY" - echo "| Run | [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "If this run failed, the previous failed run's conclusion + URL are the unresolved failure evidence. Foundry reads this artifact to compute freshness against the 8-day window." >> "$GITHUB_STEP_SUMMARY" + { + echo "### Weekly canary evidence" + echo "" + echo "| Field | Value |" + echo "|---|---|" + echo "| Revision | \`$SOURCE_REVISION_SHORT\` |" + echo "| Started | $STARTED_AT |" + echo "| Conclusion | ${CONCLUSION} |" + echo "| Timeout | 20 minutes |" + echo "| Run | [$GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) |" + echo "" + echo "If this run failed, the previous failed run's conclusion + URL are the unresolved failure evidence. Foundry reads this artifact to compute freshness against the 8-day window." + } >> "$GITHUB_STEP_SUMMARY" cat canary-out/canary-evidence.json - name: Upload canary evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: canary-evidence path: canary-out/canary-evidence.json diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..f46c0171 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,7 @@ -npx lint-staged +pnpm exec lint-staged + +if command -v gitleaks >/dev/null 2>&1; then + pnpm run quality:secrets:staged +else + echo "gitleaks is not installed; repository security CI will enforce the history scan" >&2 +fi diff --git a/.husky/pre-push b/.husky/pre-push index 60232096..f7bb930f 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,14 +1,22 @@ -# Abort push if lint fails or a known secret pattern leaks into tracked files. +# Abort push if lint fails or Gitleaks finds a secret in repository history. +# Keep the narrow regex fallback for contributors who do not have Gitleaks. set -e if [ -f package.json ] && grep -q '"lint"' package.json; then - npm run lint || { echo "lint failed — fix before pushing" >&2; exit 1; } + pnpm run lint || { echo "lint failed — fix before pushing" >&2; exit 1; } fi +if command -v gitleaks >/dev/null 2>&1; then + pnpm run quality:secrets + exit 0 +fi + +echo "gitleaks is not installed; using the limited tracked-file fallback" >&2 + SECRETS=$(git ls-files -z 2>/dev/null \ | xargs -0 grep -lE \ - 'sk-(proj-|ant-)?[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|AIzaSy[A-Za-z0-9_-]{33}|xoxb-[A-Za-z0-9-]+|-----BEGIN (RSA |EC )?PRIVATE KEY-----' 2>/dev/null \ - | grep -vE '(\.example$|\.sample$|/tests?/|/__tests__/|/fixtures?/|/mocks?/|/vendor/|/\.tmp-|^benchmark/|^apps/landing-page-astro/public/benchmark/|src/commands/secret_policy\.rs$)' \ + 'sk-(proj-|ant-)?[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|AIzaSy[A-Za-z0-9_-]{33}|xoxb-[A-Za-z0-9-]+|pk_[A-Za-z0-9]{32,}|-----BEGIN (RSA |EC )?PRIVATE KEY-----' 2>/dev/null \ + | grep -vE '(\.example$|\.sample$|/tests?/|/__tests__/|/fixtures?/|/mocks?/|/vendor/|/\.tmp-|^benchmarks/public-catch-rate/|^apps/landing-page-astro/public/benchmark/|src/commands/secret_policy\.rs$|^foundry\.json$)' \ || true) if [ -n "$SECRETS" ]; then diff --git a/apps/desktop/artifacts/design/product-surfaces-scope-1440.png b/apps/desktop/artifacts/design/product-surfaces-scope-1440.png deleted file mode 100644 index d42a3dd2..00000000 Binary files a/apps/desktop/artifacts/design/product-surfaces-scope-1440.png and /dev/null differ diff --git a/apps/desktop/artifacts/design/product-surfaces-scope-390.png b/apps/desktop/artifacts/design/product-surfaces-scope-390.png deleted file mode 100644 index 4fe578eb..00000000 Binary files a/apps/desktop/artifacts/design/product-surfaces-scope-390.png and /dev/null differ diff --git a/apps/desktop/artifacts/design/product-surfaces-scope-768.png b/apps/desktop/artifacts/design/product-surfaces-scope-768.png deleted file mode 100644 index 5fd672d1..00000000 Binary files a/apps/desktop/artifacts/design/product-surfaces-scope-768.png and /dev/null differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b04aa7de..3dbf837f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,11 +22,11 @@ "qualify:agent-island:release": "node scripts/verify-agent-island-release.mjs", "tauri:dev": "pnpm prepare:mcp-sidecar && pnpm prepare:ccusage-sidecar && pnpm prepare:agent-island && tauri dev", "tauri:build": "tauri build", - "test": "npx playwright test", + "test": "playwright test", "test:unit": "node --import tsx --test \"src/**/*.test.ts\" && node --import tsx --test tests/qualification/warm-verification-live.test.ts", - "test:coverage": "c8 node --import tsx --test \"src/**/*.test.ts\" && node --import tsx --test tests/qualification/warm-verification-live.test.ts", - "test:e2e": "npx playwright test", - "test:e2e:ui": "npx playwright test --ui", + "test:coverage": "c8 --reporter=text --reporter=lcov --reporter=cobertura node --import tsx --test \"src/**/*.test.ts\" && node --import tsx --test tests/qualification/warm-verification-live.test.ts", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "test:review-proof": "node --import tsx --test src/lib/review-proof.test.ts", "test:agent-fix-packet": "node --import tsx --test src/lib/agent-fix-packet.test.ts", "test:synthetic-qa": "node --import tsx --test src/lib/synthetic-qa/apply-evidence.test.ts src/lib/synthetic-qa/fixture-runner.test.ts", @@ -38,7 +38,8 @@ "verifyd": "node --import tsx src/lib/warm-verification/daemon-entry.ts", "test:verify": "node --import tsx --test \"src/lib/warm-verification/*.test.ts\" && node --import tsx --test tests/qualification/warm-verification-live.test.ts", "lint": "biome check .", - "bench:bundle": "node scripts/bundle-budget.mjs", + "bench:bundle": "node scripts/bundle-budget.mjs && size-limit", + "bench:bundle:upstream": "size-limit", "bench:history-ui": "node --import tsx scripts/history-workbench-benchmark.ts", "bench:mcp": "node scripts/mcp-benchmark.mjs", "bench:mcp:smoke": "node scripts/mcp-benchmark.mjs --smoke", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 859fc455..a291e93c 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -7,7 +7,12 @@ export default defineConfig({ timeout: 30_000, retries: 0, workers: 1, - reporter: [['list'], ['html', { open: 'never' }]], + reporter: [ + ['list'], + ['html', { open: 'never' }], + ['json', { outputFile: 'test-results/playwright.json' }], + ['junit', { outputFile: 'test-results/junit.xml', includeProjectInTestName: true }], + ], use: { baseURL: 'http://localhost:1420', viewport: { width: 1280, height: 800 }, diff --git a/apps/desktop/scripts/mcp-benchmark.mjs b/apps/desktop/scripts/mcp-benchmark.mjs index f5316376..cfc6a13f 100644 --- a/apps/desktop/scripts/mcp-benchmark.mjs +++ b/apps/desktop/scripts/mcp-benchmark.mjs @@ -7,7 +7,7 @@ import { createInterface } from 'node:readline'; const PROTOCOL_VERSION = '2025-11-25'; const MAX_STRUCTURED_RESPONSE_BYTES = 256 * 1_024; -const EXPECTED_TOOL_COUNT = 24; +const EXPECTED_TOOL_COUNT = 28; const EXPECTED_RELEASE_COUNT = 64; const EXPECTED_GRAPH_NODE_COUNT = 512; const EXPECTED_GRAPH_EDGE_COUNT = 1_024; @@ -18,12 +18,14 @@ const options = parseOptions(process.argv.slice(2)); const desktopRoot = resolve(import.meta.dirname, '..'); const tauriRoot = join(desktopRoot, 'src-tauri'); const protectedRepo = resolve(desktopRoot, '../..'); -const sidecar = join( - tauriRoot, - 'target', - 'release', - process.platform === 'win32' ? 'codevetter-mcp.exe' : 'codevetter-mcp' -); +const sidecar = process.env.CV_MCP_SIDECAR_PATH + ? resolve(process.env.CV_MCP_SIDECAR_PATH) + : join( + tauriRoot, + 'target', + 'release', + process.platform === 'win32' ? 'codevetter-mcp.exe' : 'codevetter-mcp' + ); const fixtureDir = mkdtempSync(join(tmpdir(), 'codevetter-mcp-bench-')); const database = join(fixtureDir, 'codevetter.db'); const activeSessions = new Set(); diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index a605276f..dbb15484 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1278,11 +1278,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 22ba15c3..37867467 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -2,6 +2,7 @@ name = "codevetter-desktop" version = "0.1.0" edition = "2021" +publish = false description = "CodeVetter Desktop — code review + agent management" default-run = "codevetter-desktop" @@ -87,6 +88,12 @@ opt-level = 0 [profile.test] debug = 1 +[profile.release] +# Shipping companions favor whole-program runtime and footprint over compile +# throughput. Keep panic unwinding for diagnosability and behavior parity. +lto = "fat" +codegen-units = 1 + [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] diff --git a/apps/desktop/src-tauri/deny.toml b/apps/desktop/src-tauri/deny.toml new file mode 100644 index 00000000..b8813bbe --- /dev/null +++ b/apps/desktop/src-tauri/deny.toml @@ -0,0 +1,40 @@ +[graph] +targets = ["aarch64-apple-darwin"] +all-features = false +no-default-features = false + +[licenses] +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +confidence-threshold = 0.8 + +[licenses.private] +ignore = true + +[bans] +multiple-versions = "warn" +wildcards = "deny" +highlight = "simplest-path" +workspace-default-features = "allow" +external-default-features = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/apps/desktop/src-tauri/tests/fixtures/surface-parity/evidence-scope-v1.json b/apps/desktop/src-tauri/tests/fixtures/surface-parity/evidence-scope-v1.json index fda89e48..43aa0d42 100644 --- a/apps/desktop/src-tauri/tests/fixtures/surface-parity/evidence-scope-v1.json +++ b/apps/desktop/src-tauri/tests/fixtures/surface-parity/evidence-scope-v1.json @@ -1 +1,66 @@ -{"schema_version":"codevetter.surface-parity-fixture/v1","authority":{"rust":"authoritative_resolver","cli":"supervised_projection","native":"supervised_projection","mcp":"read_only_projection","mcp_may_execute":false},"request":{"consumer":"performance","kind":"flow","value":"coupon total"},"repository":{"files":{"vitest.config.ts":"export default {};\n","src/cart/coupon.ts":"export const couponTotal = (value: number) => value;\n","src/cart/coupon.test.ts":"import { couponTotal } from './coupon';\ntest('coupon total', () => couponTotal(2));\n"}},"expected":{"schema_version":1,"status":"ready","candidate_count":1,"first_candidate":{"id":"scope-336fa25dbb5e59fc","adapter":"vitest","target":"src/cart/coupon.test.ts","confidence_milli":950,"testing_supported":true,"performance_supported":true},"limitation_contains":"Human-language scope is a deterministic local search"},"canonical_receipt":{"schema_version":1,"plan_id":"scope:surface-parity-v1","repository_revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","dirty":false,"kind":"flow","original_input":"coupon total","consumer":"performance","status":"ready","candidates":[{"id":"scope-336fa25dbb5e59fc","adapter":"vitest","target":"src/cart/coupon.test.ts","name":null,"reason":"Matched the described flow through local path/content evidence (score 35)","source_paths":["src/cart/coupon.test.ts","src/cart/coupon.ts"],"confidence_milli":950,"testing_supported":true,"performance_supported":true}],"uncovered_paths":[],"limitations":["Human-language scope is a deterministic local search, not model interpretation."]}} +{ + "schema_version": "codevetter.surface-parity-fixture/v1", + "authority": { + "rust": "authoritative_resolver", + "cli": "supervised_projection", + "native": "supervised_projection", + "mcp": "read_only_projection", + "mcp_may_execute": false + }, + "request": { + "consumer": "performance", + "kind": "flow", + "value": "coupon total" + }, + "repository": { + "files": { + "vitest.config.ts": "export default {};\n", + "src/cart/coupon.ts": "export const couponTotal = (value: number) => value;\n", + "src/cart/coupon.test.ts": "import { couponTotal } from './coupon';\ntest('coupon total', () => couponTotal(2));\n" + } + }, + "expected": { + "schema_version": 1, + "status": "ready", + "candidate_count": 1, + "first_candidate": { + "id": "scope-336fa25dbb5e59fc", + "adapter": "vitest", + "target": "src/cart/coupon.test.ts", + "confidence_milli": 950, + "testing_supported": true, + "performance_supported": true + }, + "limitation_contains": "Human-language scope is a deterministic local search" + }, + "canonical_receipt": { + "schema_version": 1, + "plan_id": "scope:surface-parity-v1", + "repository_revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "dirty": false, + "kind": "flow", + "original_input": "coupon total", + "consumer": "performance", + "status": "ready", + "candidates": [ + { + "id": "scope-336fa25dbb5e59fc", + "adapter": "vitest", + "target": "src/cart/coupon.test.ts", + "name": null, + "reason": "Matched the described flow through local path/content evidence (score 35)", + "source_paths": [ + "src/cart/coupon.test.ts", + "src/cart/coupon.ts" + ], + "confidence_milli": 950, + "testing_supported": true, + "performance_supported": true + } + ], + "uncovered_paths": [], + "limitations": [ + "Human-language scope is a deterministic local search, not model interpretation." + ] + } +} diff --git a/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json b/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json index 8129f489..d738885b 100644 --- a/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json +++ b/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json @@ -1 +1,113 @@ -{"schema_version":"codevetter.local-check-surface-parity-fixture/v1","authority":{"rust":"authoritative_service","cli":"supervised_execution","native":"supervised_execution","mcp":"read_only_projection","mcp_may_execute":false},"request":{"schema_version":"codevetter.verification-command/v1","request_id":"surface-parity-local-check","operation":"execute","repo_path":"/fixture/repo","change":"main...HEAD","task":"Preserve checkout totals"},"expected":{"receipt_schema":"codevetter.local-check/v1","request_id":"surface-parity-local-check","run_id":"local-check-surface-parity","verdict":"no_confidence","exit_code":2,"performance_status":"no_confidence","limitation":"The performance collector is unavailable in this fixture.","mcp_projection_schema":"codevetter.verification-receipt-projection/v1"},"canonical_receipt":{"schema_version":"codevetter.local-check/v1","request_id":"surface-parity-local-check","run_id":"local-check-surface-parity","ran_at":"2026-09-01T00:00:00Z","repo_path":"/fixture/repo","task":"Preserve checkout totals","source":{"kind":"range","input":"main...HEAD","base_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","commits":["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],"changed_paths":["src/cart.ts"]},"stages":{"review":{"status":"completed","duration_ms":18,"target":null,"evidence":{"summary":"The bounded review completed without a qualified finding.","findings":[],"cross_review":{"schema_version":"codevetter.cross-review/v1","strategy":"claude_then_codex_independent","status":"completed","counts":{"corroborated":0,"claude_only":0,"codex_only":0,"conflicting":0},"passes":[{"reviewer":"claude","status":"completed"},{"reviewer":"codex","status":"completed"}],"proof_boundary":"Reviewer agreement is review coverage, never executable proof."}},"limitations":[]},"correctness":{"status":"passed","duration_ms":12,"target":{"adapter":"vitest","target":"src/cart.test.ts","name":null,"source":"selected:fixture"},"evidence":{"verdict":"passed"},"limitations":[]},"performance":{"status":"no_confidence","duration_ms":0,"target":null,"evidence":{},"limitations":["The performance collector is unavailable in this fixture."]},"optimization":{"status":"no_confidence","duration_ms":0,"target":null,"evidence":{},"limitations":["No paired optimization claim can be made without performance evidence."]}},"verdict":"no_confidence","limitations":["The performance collector is unavailable in this fixture.","No paired optimization claim can be made without performance evidence."]}} +{ + "schema_version": "codevetter.local-check-surface-parity-fixture/v1", + "authority": { + "rust": "authoritative_service", + "cli": "supervised_execution", + "native": "supervised_execution", + "mcp": "read_only_projection", + "mcp_may_execute": false + }, + "request": { + "schema_version": "codevetter.verification-command/v1", + "request_id": "surface-parity-local-check", + "operation": "execute", + "repo_path": "/fixture/repo", + "change": "main...HEAD", + "task": "Preserve checkout totals" + }, + "expected": { + "receipt_schema": "codevetter.local-check/v1", + "request_id": "surface-parity-local-check", + "run_id": "local-check-surface-parity", + "verdict": "no_confidence", + "exit_code": 2, + "performance_status": "no_confidence", + "limitation": "The performance collector is unavailable in this fixture.", + "mcp_projection_schema": "codevetter.verification-receipt-projection/v1" + }, + "canonical_receipt": { + "schema_version": "codevetter.local-check/v1", + "request_id": "surface-parity-local-check", + "run_id": "local-check-surface-parity", + "ran_at": "2026-09-01T00:00:00Z", + "repo_path": "/fixture/repo", + "task": "Preserve checkout totals", + "source": { + "kind": "range", + "input": "main...HEAD", + "base_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "commits": [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "changed_paths": [ + "src/cart.ts" + ] + }, + "stages": { + "review": { + "status": "completed", + "duration_ms": 18, + "target": null, + "evidence": { + "summary": "The bounded review completed without a qualified finding.", + "findings": [], + "cross_review": { + "schema_version": "codevetter.cross-review/v1", + "strategy": "claude_then_codex_independent", + "status": "completed", + "counts": { + "corroborated": 0, + "claude_only": 0, + "codex_only": 0, + "conflicting": 0 + }, + "passes": [ + { "reviewer": "claude", "status": "completed" }, + { "reviewer": "codex", "status": "completed" } + ], + "proof_boundary": "Reviewer agreement is review coverage, never executable proof." + } + }, + "limitations": [] + }, + "correctness": { + "status": "passed", + "duration_ms": 12, + "target": { + "adapter": "vitest", + "target": "src/cart.test.ts", + "name": null, + "source": "selected:fixture" + }, + "evidence": { + "verdict": "passed" + }, + "limitations": [] + }, + "performance": { + "status": "no_confidence", + "duration_ms": 0, + "target": null, + "evidence": {}, + "limitations": [ + "The performance collector is unavailable in this fixture." + ] + }, + "optimization": { + "status": "no_confidence", + "duration_ms": 0, + "target": null, + "evidence": {}, + "limitations": [ + "No paired optimization claim can be made without performance evidence." + ] + } + }, + "verdict": "no_confidence", + "limitations": [ + "The performance collector is unavailable in this fixture.", + "No paired optimization claim can be made without performance evidence." + ] + } +} diff --git a/apps/desktop/src-tauri/tests/mcp_stdio.rs b/apps/desktop/src-tauri/tests/mcp_stdio.rs index 998bf3e4..2d938eff 100644 --- a/apps/desktop/src-tauri/tests/mcp_stdio.rs +++ b/apps/desktop/src-tauri/tests/mcp_stdio.rs @@ -27,9 +27,21 @@ fn stdio_boundary_is_json_only_scoped_and_paginated() { })); let tool_definitions = tools["result"]["tools"].as_array().expect("tools"); assert_eq!(tool_definitions.len(), 28); + assert!(tool_definitions.iter().any(|tool| { + tool["name"] == "capability_catalog" && tool["inputSchema"]["additionalProperties"] == false + })); + assert!(tool_definitions.iter().any(|tool| { + tool["name"] == "resolve_evidence_scope" + && tool["inputSchema"]["additionalProperties"] == false + })); assert!(tool_definitions.iter().any(|tool| { tool["name"] == "prepare_review" && tool["inputSchema"]["additionalProperties"] == false })); + assert!(tool_definitions.iter().any(|tool| { + tool["name"] == "verification_get_receipt" + && tool["inputSchema"]["additionalProperties"] == false + && tool["annotations"]["readOnlyHint"] == true + })); assert!(tool_definitions.iter().any(|tool| { tool["name"] == "history_list_landmarks" && tool["inputSchema"]["additionalProperties"] == false diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 57d9df5e..5bddcdb1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -11,6 +11,7 @@ import Sidebar from '@/components/sidebar'; import UpdateChecker from '@/components/update-checker'; import { trackAppLaunch } from '@/lib/analytics'; import { ProjectWorkspaceProvider } from '@/lib/project-workspace'; +import { migrateLegacyRubricConfig } from '@/lib/rubric-migration'; import { getPreference, isTauriAvailable } from '@/lib/tauri-ipc'; import { useWindowVisibilityClass } from '@/lib/use-visibility'; @@ -154,6 +155,12 @@ export default function App() { trackAppLaunch(); }, []); + useEffect(() => { + // A failed attempt leaves the sanitized WebView copy intact. Opening the + // incumbent Rubrics surface retries and exposes its existing sync warning. + void migrateLegacyRubricConfig().catch(() => undefined); + }, []); + return ( } /> diff --git a/apps/desktop/src/lib/performance-workbench.ts b/apps/desktop/src/lib/performance-workbench.ts index 795e5766..2bd86ddb 100644 --- a/apps/desktop/src/lib/performance-workbench.ts +++ b/apps/desktop/src/lib/performance-workbench.ts @@ -41,6 +41,14 @@ export interface PerformanceRunReceipt { owned_process_reaped: boolean; temporary_profiles_retained: boolean; }; + resources?: { + sampler: string | null; + sample_interval_ms: number; + samples: number; + peak_rss_bytes: number | null; + peak_processes: number | null; + limitations: string[]; + }; } export type PerformanceBridgeFixtureKind = diff --git a/apps/desktop/src/lib/review-service.test.ts b/apps/desktop/src/lib/review-service.test.ts index 19c1677d..647c3888 100644 --- a/apps/desktop/src/lib/review-service.test.ts +++ b/apps/desktop/src/lib/review-service.test.ts @@ -7,7 +7,6 @@ import { getActiveStandardsPack, getStandardsPacks, loadReviewConfig, - PROVIDER_PRESETS, type ReviewConfig, saveReviewConfig, } from './review-service'; @@ -29,10 +28,8 @@ class MemoryStorage { } const validConfig: ReviewConfig = { - gatewayBaseUrl: 'https://gateway.example/v1', - gatewayApiKey: 'sk-test', - gatewayModel: 'auto', - reviewTone: 'direct', + activeStandardsPack: 'product-safety', + customRules: ['Check authorization'], }; beforeEach(() => { @@ -45,20 +42,48 @@ describe('loadReviewConfig', () => { assert.equal(loadReviewConfig(), null); }); - it('returns null when required credentials are missing', () => { - saveReviewConfig({ ...validConfig, gatewayApiKey: '' }); - assert.equal(loadReviewConfig(), null); - }); - it('returns null on malformed JSON', () => { localStorage.setItem('codevetter_review_config', '{not json'); assert.equal(loadReviewConfig(), null); + assert.equal(localStorage.getItem('codevetter_review_config'), null); }); it('round-trips a valid config', () => { saveReviewConfig(validConfig); assert.deepEqual(loadReviewConfig(), validConfig); }); + + it('migrates legacy provider config without retaining the credential', () => { + localStorage.setItem( + 'codevetter_review_config', + JSON.stringify({ + ...validConfig, + gatewayApiKey: 'sk-legacy-secret', + gatewayBaseUrl: 'https://api.example.test/v1', + gatewayModel: 'legacy-model', + reviewTone: 'direct', + }) + ); + + assert.deepEqual(loadReviewConfig(), validConfig); + const stored = localStorage.getItem('codevetter_review_config') ?? ''; + assert.equal(stored.includes('sk-legacy-secret'), false); + assert.equal(stored.includes('gatewayApiKey'), false); + assert.equal(stored.includes('gatewayBaseUrl'), false); + assert.equal(stored.includes('gatewayModel'), false); + }); + + it('persists only allowlisted review-standard fields', () => { + saveReviewConfig({ + ...validConfig, + gatewayApiKey: 'sk-should-not-persist', + } as ReviewConfig & { gatewayApiKey: string }); + + const stored = localStorage.getItem('codevetter_review_config'); + assert.ok(stored); + assert.deepEqual(JSON.parse(stored), validConfig); + assert.equal(stored.includes('sk-should-not-persist'), false); + }); }); describe('getStandardsPacks', () => { @@ -122,14 +147,3 @@ describe('buildActiveStandardsContext', () => { assert.equal((context.match(/Custom rule:/g) ?? []).length, 1); }); }); - -describe('PROVIDER_PRESETS', () => { - it('exposes a base url and model for each known provider', () => { - for (const key of ['anthropic', 'openai', 'openrouter']) { - const preset = PROVIDER_PRESETS[key]; - assert.ok(preset, `missing preset for ${key}`); - assert.match(preset.baseUrl, /^https:\/\//); - assert.ok(preset.model.length > 0); - } - }); -}); diff --git a/apps/desktop/src/lib/review-service.ts b/apps/desktop/src/lib/review-service.ts index d5b3839a..1905812c 100644 --- a/apps/desktop/src/lib/review-service.ts +++ b/apps/desktop/src/lib/review-service.ts @@ -1,13 +1,6 @@ -/** - * Review config persistence and provider presets. - * Used by the Settings page to configure AI provider credentials. - */ +/** Review-standards persistence. Provider credentials are never stored here. */ export interface ReviewConfig { - gatewayBaseUrl: string; - gatewayApiKey: string; - gatewayModel: string; - reviewTone: string; customRules?: string[]; activeStandardsPack?: string; standardsPacks?: StandardsPack[]; @@ -55,20 +48,57 @@ export const DEFAULT_STANDARDS_PACKS: StandardsPack[] = [ }, ]; +function isStandardsPack(value: unknown): value is StandardsPack { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const candidate = value as Partial; + return ( + typeof candidate.id === 'string' && + typeof candidate.name === 'string' && + typeof candidate.focus === 'string' && + Array.isArray(candidate.checks) && + candidate.checks.every((check) => typeof check === 'string') + ); +} + +function sanitizeReviewConfig(value: unknown): ReviewConfig | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const candidate = value as Partial; + const config: ReviewConfig = {}; + + if (Array.isArray(candidate.customRules)) { + config.customRules = candidate.customRules.filter( + (rule): rule is string => typeof rule === 'string' + ); + } + if (typeof candidate.activeStandardsPack === 'string') { + config.activeStandardsPack = candidate.activeStandardsPack; + } + if (Array.isArray(candidate.standardsPacks)) { + config.standardsPacks = candidate.standardsPacks.filter(isStandardsPack); + } + return config; +} + export function loadReviewConfig(): ReviewConfig | null { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; - const config = JSON.parse(raw) as ReviewConfig; - if (!config.gatewayApiKey || !config.gatewayBaseUrl) return null; + const config = sanitizeReviewConfig(JSON.parse(raw)); + if (!config) { + localStorage.removeItem(STORAGE_KEY); + return null; + } + const sanitized = JSON.stringify(config); + if (sanitized !== raw) localStorage.setItem(STORAGE_KEY, sanitized); return config; } catch { + localStorage.removeItem(STORAGE_KEY); return null; } } export function saveReviewConfig(config: ReviewConfig): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitizeReviewConfig(config) ?? {})); } export function getStandardsPacks(config: ReviewConfig | null): StandardsPack[] { @@ -123,18 +153,3 @@ export function getActiveStandardsPackId(): string | null { if (!config?.activeStandardsPack) return null; return getActiveStandardsPack(config).id; } - -export const PROVIDER_PRESETS: Record = { - anthropic: { - baseUrl: 'https://api.anthropic.com/v1', - model: 'claude-sonnet-4-20250514', - }, - openai: { - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4o', - }, - openrouter: { - baseUrl: 'https://openrouter.ai/api/v1', - model: 'anthropic/claude-sonnet-4-20250514', - }, -}; diff --git a/apps/desktop/src/lib/rubric-migration.test.ts b/apps/desktop/src/lib/rubric-migration.test.ts new file mode 100644 index 00000000..39c98732 --- /dev/null +++ b/apps/desktop/src/lib/rubric-migration.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ReviewConfig } from '@/lib/review-service'; +import type { RubricSettingsReceipt } from '@/lib/tauri-ipc'; +import { migrateLegacyRubricConfig } from './rubric-migration'; + +const legacyConfig: ReviewConfig = { + activeStandardsPack: 'team-safety', + customRules: ['Preserve the audit trail.'], + standardsPacks: [ + { + id: 'team-safety', + name: 'Team Safety', + focus: 'Team-specific regressions', + checks: ['Check the audit trail.'], + }, + ], +}; + +function receipt(migrated: boolean): RubricSettingsReceipt { + return { + schema_version: 'codevetter.rubric-settings/v1', + generated_at: '2026-09-02T00:00:00Z', + operation: 'read', + active_pack_id: 'team-safety', + custom_rules: legacyConfig.customRules ?? [], + packs: [], + saved_pack_id: null, + migrated_legacy_config: migrated, + }; +} + +test('browser-only startup does not inspect or migrate WebView state', async () => { + let loaded = false; + let invoked = false; + const status = await migrateLegacyRubricConfig({ + isTauriAvailable: () => false, + loadReviewConfig: () => { + loaded = true; + return legacyConfig; + }, + getRubricSettings: async () => { + invoked = true; + return receipt(true); + }, + }); + assert.equal(status, 'not_tauri'); + assert.equal(loaded, false); + assert.equal(invoked, false); +}); + +test('Tauri startup sends the exact sanitized legacy config to Rust once', async () => { + let received: ReviewConfig | null = null; + const status = await migrateLegacyRubricConfig({ + isTauriAvailable: () => true, + loadReviewConfig: () => legacyConfig, + getRubricSettings: async (config) => { + received = config; + return receipt(true); + }, + }); + assert.equal(status, 'migrated'); + assert.deepEqual(received, legacyConfig); +}); + +test('startup distinguishes absent legacy state from an existing canonical preference', async () => { + const noLegacy = await migrateLegacyRubricConfig({ + isTauriAvailable: () => true, + loadReviewConfig: () => null, + getRubricSettings: async () => assert.fail('Rust should not be called without legacy state'), + }); + assert.equal(noLegacy, 'no_legacy_config'); + + const canonical = await migrateLegacyRubricConfig({ + isTauriAvailable: () => true, + loadReviewConfig: () => legacyConfig, + getRubricSettings: async () => receipt(false), + }); + assert.equal(canonical, 'already_canonical'); +}); + +test('migration errors remain observable to the startup caller', async () => { + await assert.rejects( + migrateLegacyRubricConfig({ + isTauriAvailable: () => true, + loadReviewConfig: () => legacyConfig, + getRubricSettings: async () => { + throw new Error('canonical store unavailable'); + }, + }), + /canonical store unavailable/ + ); +}); diff --git a/apps/desktop/src/lib/rubric-migration.ts b/apps/desktop/src/lib/rubric-migration.ts new file mode 100644 index 00000000..d611fd87 --- /dev/null +++ b/apps/desktop/src/lib/rubric-migration.ts @@ -0,0 +1,30 @@ +import { loadReviewConfig, type ReviewConfig } from '@/lib/review-service'; +import { getRubricSettings, isTauriAvailable, type RubricSettingsReceipt } from '@/lib/tauri-ipc'; + +export type LegacyRubricMigrationStatus = + | 'not_tauri' + | 'no_legacy_config' + | 'migrated' + | 'already_canonical'; + +interface LegacyRubricMigrationDependencies { + isTauriAvailable: () => boolean; + loadReviewConfig: () => ReviewConfig | null; + getRubricSettings: (legacyConfig: ReviewConfig) => Promise; +} + +const defaultDependencies: LegacyRubricMigrationDependencies = { + isTauriAvailable, + loadReviewConfig, + getRubricSettings, +}; + +export async function migrateLegacyRubricConfig( + dependencies: LegacyRubricMigrationDependencies = defaultDependencies +): Promise { + if (!dependencies.isTauriAvailable()) return 'not_tauri'; + const legacyConfig = dependencies.loadReviewConfig(); + if (!legacyConfig) return 'no_legacy_config'; + const receipt = await dependencies.getRubricSettings(legacyConfig); + return receipt.migrated_legacy_config ? 'migrated' : 'already_canonical'; +} diff --git a/apps/desktop/src/lib/tauri-ipc.ts b/apps/desktop/src/lib/tauri-ipc.ts index 2fd44ba1..b35978c2 100644 --- a/apps/desktop/src/lib/tauri-ipc.ts +++ b/apps/desktop/src/lib/tauri-ipc.ts @@ -7,6 +7,7 @@ import { } from '@tauri-apps/plugin-notification'; import { buildActiveStandardsContext, getActiveStandardsPackId } from '@/lib/review-service'; +import type { ReviewConfig, StandardsPack } from '@/lib/review-service'; import type { EvidenceScopeInput, EvidenceScopePlan } from '@/lib/evidence-scope'; import type { DaemonHealth, VerifyResult } from '@/lib/warm-verification/contracts'; import type { @@ -962,6 +963,43 @@ export async function getStandardsPackUsage(): Promise return resp.usage; } +export type RubricSettingsOperation = 'read' | 'select' | 'upsert'; + +export interface RubricPackReceipt extends StandardsPack { + built_in: boolean; + active: boolean; + review_count: number; + total_findings: number; + prompt_preview: string; +} + +export interface RubricSettingsReceipt { + schema_version: 'codevetter.rubric-settings/v1'; + generated_at: string; + operation: RubricSettingsOperation; + active_pack_id: string | null; + custom_rules: string[]; + packs: RubricPackReceipt[]; + saved_pack_id: string | null; + migrated_legacy_config: boolean; +} + +export async function getRubricSettings( + legacyConfig?: ReviewConfig | null +): Promise { + return safeInvoke('get_rubric_settings', { + legacyConfig: legacyConfig ?? null, + }); +} + +export async function setActiveRubricPack(packId: string): Promise { + return safeInvoke('set_active_rubric_pack', { packId }); +} + +export async function saveRubricPack(pack: StandardsPack): Promise { + return safeInvoke('save_rubric_pack', { pack }); +} + // ─── CLI Review ────────────────────────────────────────────────────────────── export interface CliReviewFinding { @@ -1766,7 +1804,11 @@ export async function runCliReview( qaRuns?: ReviewQaRunEvidence[]; } ): Promise { - const standardsContext = buildActiveStandardsContext(); + const canonicalRubrics = await getRubricSettings().catch(() => null); + const canonicalPack = + canonicalRubrics?.packs.find((pack) => pack.id === canonicalRubrics.active_pack_id) ?? + canonicalRubrics?.packs[0]; + const standardsContext = canonicalPack?.prompt_preview ?? buildActiveStandardsContext(); const projectWithStandards = projectDescription.trim() ? `${projectDescription}\n\n${standardsContext}` : standardsContext; @@ -1778,7 +1820,7 @@ export async function runCliReview( changeDescription, agent: agent ?? null, qaRuns: options?.qaRuns ?? null, - standardsPack: getActiveStandardsPackId(), + standardsPack: canonicalRubrics?.active_pack_id ?? getActiveStandardsPackId(), }); } diff --git a/apps/desktop/src/lib/warm-verification/differential-cli.test.ts b/apps/desktop/src/lib/warm-verification/differential-cli.test.ts index 0c426ee8..3b1c0ce6 100644 --- a/apps/desktop/src/lib/warm-verification/differential-cli.test.ts +++ b/apps/desktop/src/lib/warm-verification/differential-cli.test.ts @@ -80,6 +80,13 @@ describe('differential CLI contract', () => { assert.equal(differentialExitCode('cleanup', cleanup(true)), 0); assert.equal(differentialExitCode('cleanup', cleanup(false)), 3); assert.equal(differentialExitCode('run', prepared('ready')), 3); + assert.equal( + differentialExitCode('cleanup', { + type: 'error', + error: { code: 'cleanup_failed', message: 'Cleanup failed.', retryable: false }, + }), + 3 + ); }); }); diff --git a/apps/desktop/src/lib/warm-verification/differential-cli.ts b/apps/desktop/src/lib/warm-verification/differential-cli.ts index 61edc3ee..312b7518 100644 --- a/apps/desktop/src/lib/warm-verification/differential-cli.ts +++ b/apps/desktop/src/lib/warm-verification/differential-cli.ts @@ -142,22 +142,21 @@ export function differentialExitCode( command: Command, response: DifferentialDaemonResponse ): 0 | 2 | 3 { - if (command === 'prepare' && response.type === 'differential_prepared') { - return response.summary.status === 'ready' ? 0 : 3; + switch (response.type) { + case 'differential_prepared': + return command === 'prepare' && response.summary.status === 'ready' ? 0 : 3; + case 'differential_result': + if (command !== 'run' || response.summary.status !== 'complete') return 3; + return response.summary.classification === 'regressed' ? 2 : 0; + case 'differential_status': + if (command === 'cancel') return response.summary.state === 'not_found' ? 3 : 0; + if (command !== 'status' || response.summary.state !== 'completed') return 3; + return response.summary.classification === 'regressed' ? 2 : 0; + case 'differential_cleanup': + return command === 'cleanup' && response.summary.complete ? 0 : 3; + case 'error': + return 3; } - if (command === 'run' && response.type === 'differential_result') { - if (response.summary.status !== 'complete') return 3; - return response.summary.classification === 'regressed' ? 2 : 0; - } - if ((command === 'status' || command === 'cancel') && response.type === 'differential_status') { - if (command === 'cancel') return response.summary.state === 'not_found' ? 3 : 0; - if (response.summary.state !== 'completed') return 3; - return response.summary.classification === 'regressed' ? 2 : 0; - } - if (command === 'cleanup' && response.type === 'differential_cleanup') { - return response.summary.complete ? 0 : 3; - } - return 3; } function daemonRequest(options: DifferentialCliOptions): DifferentialDaemonRequest { @@ -178,7 +177,9 @@ function print(options: DifferentialCliOptions, response: DifferentialDaemonResp process.stdout.write(`${JSON.stringify(response)}\n`); return; } - if (response.type === 'differential_prepared') { + if (response.type === 'error') { + process.stderr.write(`${response.error.code}: ${response.error.message}\n`); + } else if (response.type === 'differential_prepared') { const summary = response.summary; process.stdout.write( `${summary.status} · ${summary.scenario_count} scenario(s) · cache=${summary.source_cache_hits}/2+${Number(summary.dependency_cache_hit)}\n` diff --git a/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.test.ts b/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.test.ts index 04d2dc15..c2195462 100644 --- a/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.test.ts +++ b/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.test.ts @@ -143,6 +143,15 @@ describe('differential daemon wire contracts', () => { error_codes: [], }, }, + { + type: 'error', + error: { + code: 'differential_unavailable', + message: 'The comparison service is unavailable.', + remediation: 'Restart the owned verifier.', + retryable: true, + }, + }, ]; responses.forEach((value) => assert.equal(validateDifferentialDaemonResponseEnvelope(response(value)).ok, true) diff --git a/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.ts b/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.ts index c13159c9..6a7bc81d 100644 --- a/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.ts +++ b/apps/desktop/src/lib/warm-verification/differential-daemon-contracts.ts @@ -7,6 +7,7 @@ import { VERIFY_CONTRACT_LIMITS, type ContractIssue, type ContractValidation, + type DaemonError, } from './contracts'; import { DIFFERENTIAL_CLASSIFICATIONS, @@ -122,7 +123,8 @@ export type DifferentialDaemonResponse = | { type: 'differential_prepared'; summary: DifferentialPreparedSummary } | { type: 'differential_result'; summary: DifferentialRunSummary } | { type: 'differential_status'; summary: DifferentialStatusSummary } - | { type: 'differential_cleanup'; summary: DifferentialCleanupSummary }; + | { type: 'differential_cleanup'; summary: DifferentialCleanupSummary } + | { type: 'error'; error: DaemonError }; export interface DifferentialDaemonResponseEnvelope { protocol_version: 1; request_id: string; @@ -287,6 +289,25 @@ function validateRequest(value: unknown, issues: ContractIssue[]) { function validateResponse(value: unknown, issues: ContractIssue[]) { const response = object(value, '$.response', issues); if (!response) return; + if (response.type === 'error') { + exactKeys(response, '$.response', ['type', 'error'], issues); + const error = object(response.error, '$.response.error', issues); + if (!error) return; + exactKeys( + error, + '$.response.error', + error.remediation === undefined + ? ['code', 'message', 'retryable'] + : ['code', 'message', 'remediation', 'retryable'], + issues + ); + stringField(error, 'code', '$.response.error', issues, { pattern: ID }); + stringField(error, 'message', '$.response.error', issues); + if (error.remediation !== undefined) + stringField(error, 'remediation', '$.response.error', issues); + boolean(error, 'retryable', '$.response.error', issues); + return; + } exactKeys(response, '$.response', ['type', 'summary'], issues); const rules: Record = { differential_prepared: prepared, diff --git a/apps/desktop/src/pages/Rubrics.tsx b/apps/desktop/src/pages/Rubrics.tsx index 41eaada9..3e2bdc72 100644 --- a/apps/desktop/src/pages/Rubrics.tsx +++ b/apps/desktop/src/pages/Rubrics.tsx @@ -27,10 +27,6 @@ import { getStandardsPackUsage, isTauriAvailable } from '@/lib/tauri-ipc'; function fallbackConfig(): ReviewConfig { return { - gatewayBaseUrl: '', - gatewayApiKey: '', - gatewayModel: 'auto', - reviewTone: 'direct', activeStandardsPack: DEFAULT_STANDARDS_PACKS[0].id, standardsPacks: [], }; diff --git a/apps/desktop/src/pages/Settings.tsx b/apps/desktop/src/pages/Settings.tsx index 0d0eca8d..8557710d 100644 --- a/apps/desktop/src/pages/Settings.tsx +++ b/apps/desktop/src/pages/Settings.tsx @@ -6,12 +6,6 @@ import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; -import { - loadReviewConfig, - PROVIDER_PRESETS, - type ReviewConfig, - saveReviewConfig, -} from '@/lib/review-service'; import type { GitHubAuthStatus, LinearUser, @@ -590,48 +584,6 @@ export default function Settings() { const [claudeCodePath, setClaudeCodePath] = usePref('claude_cli_path', ''); const [codexPath, setCodexPath] = usePref('codex_cli_path', ''); - // AI Provider - const [aiProvider, setAiProvider] = useState('anthropic'); - const [aiBaseUrl, setAiBaseUrl] = useState(''); - const [aiApiKey, setAiApiKey] = useState(''); - const [aiModel, setAiModel] = useState(''); - const [aiConfigSaved, setAiConfigSaved] = useState(false); - - useEffect(() => { - const existing = loadReviewConfig(); - if (existing) { - setAiBaseUrl(existing.gatewayBaseUrl); - setAiApiKey(existing.gatewayApiKey); - setAiModel(existing.gatewayModel); - // Detect provider from URL - if (existing.gatewayBaseUrl.includes('anthropic')) setAiProvider('anthropic'); - else if (existing.gatewayBaseUrl.includes('openai.com')) setAiProvider('openai'); - else if (existing.gatewayBaseUrl.includes('openrouter')) setAiProvider('openrouter'); - else setAiProvider('custom'); - } - }, []); - - function handleProviderChange(provider: string) { - setAiProvider(provider); - setAiConfigSaved(false); - if (provider !== 'custom' && PROVIDER_PRESETS[provider]) { - setAiBaseUrl(PROVIDER_PRESETS[provider].baseUrl); - setAiModel(PROVIDER_PRESETS[provider].model); - } - } - - function handleSaveAiConfig() { - const config: ReviewConfig = { - gatewayBaseUrl: aiBaseUrl, - gatewayApiKey: aiApiKey, - gatewayModel: aiModel, - reviewTone: defaultTone, - }; - saveReviewConfig(config); - setAiConfigSaved(true); - setTimeout(() => setAiConfigSaved(false), 2000); - } - // Notifications const [notifyReviewDone, toggleNotifyReviewDone] = useBoolPref('notify_review_done', true); const [notifyAgentError, toggleNotifyAgentError] = useBoolPref('notify_agent_error', true); @@ -1500,82 +1452,6 @@ export default function Settings() {

- -

- AI Provider -

-
- - - - - { - setAiApiKey(v); - setAiConfigSaved(false); - }} - /> - - {aiProvider === 'custom' && ( - <> - - { - setAiBaseUrl(v); - setAiConfigSaved(false); - }} - /> - - )} - - - - { - setAiModel(v); - setAiConfigSaved(false); - }} - /> - -
- - {!aiApiKey && ( - API key required to run reviews - )} -
-
); diff --git a/scripts/inspect-native-appcast.test.mjs b/scripts/inspect-native-appcast.test.mjs index 363e5f05..4b5dbaa2 100644 --- a/scripts/inspect-native-appcast.test.mjs +++ b/scripts/inspect-native-appcast.test.mjs @@ -55,10 +55,7 @@ test('appcast attributes are decoded exactly once', () => { const input = fixture(); const receipt = evaluateNativeAppcast({ ...input, - xml: input.xml.replace( - `/${input.archiveName}\"`, - `/${input.archiveName}?label=a&quot;b\"` - ), + xml: input.xml.replace(`/${input.archiveName}"`, `/${input.archiveName}?label=a&quot;b"`), }); assert.equal(receipt.qualified, true);