From 5369349e9e9394d7265dfecf3e46e49cf4672960 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:01:03 +0200 Subject: [PATCH] chore(ci): unblock the CVE gate, finish Sonnet 5, parallelize, harden the supply chain (#1223) Unblocks docker-image-scan (red 5 runs straight on GHSA-f88m-g3jw-g9cj: next's nested sharp@0.34.5, deduped via an override). Finishes the #1218 Sonnet 5 rollout: compliance-pr and compliance-swarm were falling through to compliancemaxx's sonnet-4-6 default; swedish-compliance-review.mjs budgeted max_tokens as if thinking were off (it is adaptive-by-default on Sonnet 5) and never checked stop_reason; pr-agent's token budgets were sized for 4.6's tokenizer and its hidden default OpenAI fallback list is now emptied explicitly. Core build 7m43s -> 2m51s measured (parallel checks/build/test, unit suite sharded 4 ways). Docker publish moves off QEMU to native ARM runners with a digest-merge job, so tags apply only on success and latest never moves on failure. 40 actions pinned to immutable SHAs; adds zizmor (0 high after fixing persist-credentials on 7 checkouts and permissions on test-pg-real) and CodeQL (0 findings on first run). Full details in the PR body. --- .github/actions/setup-core/action.yml | 43 ++ .github/workflows/ci-cache.yml | 54 ++ .github/workflows/codeql.yml | 71 +++ .github/workflows/compliance-pr.yml | 11 +- .github/workflows/compliance-swarm.yml | 12 +- .github/workflows/core-build.yml | 107 +++- .github/workflows/docker-image-scan.yml | 12 +- .github/workflows/docker-publish.yml | 222 ++++++-- .github/workflows/pr-agent.yml | 20 +- .github/workflows/swedish-compliance-diff.yml | 4 +- .../workflows/swedish-compliance-review.yml | 6 +- .github/workflows/test-pg-real.yml | 17 +- .github/workflows/zizmor.yml | 81 +++ .github/zizmor.yml | 30 + DECISIONS.md | 7 + package-lock.json | 514 ------------------ package.json | 3 +- scripts/swedish-compliance-review.mjs | 28 +- 18 files changed, 650 insertions(+), 592 deletions(-) create mode 100644 .github/actions/setup-core/action.yml create mode 100644 .github/workflows/ci-cache.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/zizmor.yml create mode 100644 .github/zizmor.yml diff --git a/.github/actions/setup-core/action.yml b/.github/actions/setup-core/action.yml new file mode 100644 index 00000000..849434c1 --- /dev/null +++ b/.github/actions/setup-core/action.yml @@ -0,0 +1,43 @@ +name: Set up core toolchain +description: >- + Node, dependencies, and a zero-extension registry: the prologue every + core-build job needs before it can lint, build, or test. The caller must check + the repository out first, because a local composite action cannot be resolved + until the workspace containing it exists. + +runs: + using: composite + steps: + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 20 + + # Restore-only, deliberately never `save`. A cache written from a + # pull_request run is scoped to that PR's ref, so no other run can ever read + # it: PR-side saves are pure quota burn. Fourteen dead 284 MB copies (4 GB, + # 40% of the repo quota) accumulated in a single day the last time a naive + # `cache: npm` was left on, which is why test-pg-real.yml still runs + # uncached. The entries read here are written on main by ci-cache.yml, and + # default-branch entries are restorable from every PR. + - name: Restore npm cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} + restore-keys: | + npm-${{ runner.os }}-node20- + + - name: Install dependencies + shell: bash + run: npm ci + + # CI builds core with zero extensions enabled: that property is the entire + # reason this workflow exists, so every job that compiles or runs code needs + # the registry regenerated from an empty config first. + - name: Reset extensions config + shell: bash + run: echo '{"extensions":[]}' > extensions.config.json + + - name: Generate extension registry + shell: bash + run: npm run setup:extensions diff --git a/.github/workflows/ci-cache.yml b/.github/workflows/ci-cache.yml new file mode 100644 index 00000000..a4720dfc --- /dev/null +++ b/.github/workflows/ci-cache.yml @@ -0,0 +1,54 @@ +name: CI cache warm + +# The save half of the npm cache that .github/actions/setup-core restores. +# +# It has to live in its own main-branch workflow. GitHub scopes a cache entry to +# the ref that wrote it, with one exception: entries written on the default +# branch are readable from every branch and PR. core-build.yml runs on +# pull_request only, so anything it saved would be readable by exactly one PR +# and dead the moment that PR merged. That is not hypothetical here: a naive +# `cache: npm` on the pg-real workflow once put fourteen 284 MB copies (4 GB, +# 40% of the repo quota) on disk in a day, none of them ever restorable. +# +# So: main saves, PRs restore-only. Keyed on the lockfile hash, so this runs +# only when dependencies actually move and each entry is read many times before +# it is replaced. + +on: + push: + branches: [main] + paths: + - package-lock.json + - package.json + workflow_dispatch: {} + +concurrency: + group: ci-cache + cancel-in-progress: true + +permissions: + contents: read + +jobs: + warm: + name: Warm npm cache + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 20 + + # Populates ~/.npm. `npm ci` deletes node_modules first, so this measures + # a cold install the same way a PR job will. + - run: npm ci + + # A no-op when the key already exists, which is the intent: one entry per + # lockfile state, not one per push. + - name: Save npm cache + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..57d25d9a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,71 @@ +name: CodeQL + +# Semantic code scanning. The repo already had `security-events: write` and +# uploaded Trivy SARIF, but Trivy only reports known CVEs in dependencies and +# base images: nothing analysed the application's own code. For a multi-tenant +# accounting SaaS holding personnummer, bank data and money, that was the gap. +# +# Two languages: +# javascript-typescript - the app itself (injection, path traversal, unsafe +# deserialization, missing authorization checks, hardcoded credentials). +# actions - GitHub's own workflow analysis. It overlaps zizmor.yml without +# replacing it: CodeQL follows dataflow into composite actions, zizmor knows +# Actions-specific misconfigurations CodeQL has no notion of. Two cheap +# scanners with different blind spots beat one. +# +# The default query suite is used deliberately. `security-extended` finds more +# but roughly doubles the runtime, and this already runs on every PR; revisit +# once the default suite's findings are triaged. + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + # Weekly. CodeQL ships new queries continuously, so an unchanged repo can + # still acquire findings. + - cron: '19 3 * * 1' + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + # Required to upload the analysis results. + security-events: write + # Required by the `actions` language pack to read workflow metadata. + actions: read + strategy: + # A failure in one language should not hide the other's results. + fail-fast: false + matrix: + language: [javascript-typescript, actions] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + languages: ${{ matrix.language }} + + # No build step. javascript-typescript and actions are both interpreted + # languages to CodeQL, extracted straight from source, so `npm ci` and + # `next build` would add minutes and change nothing about the database. + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/compliance-pr.yml b/.github/workflows/compliance-pr.yml index 4af6ffda..1085b4c7 100644 --- a/.github/workflows/compliance-pr.yml +++ b/.github/workflows/compliance-pr.yml @@ -23,11 +23,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - - uses: erp-mafia/compliancemaxx@v2 + - uses: erp-mafia/compliancemaxx@248cebcf90867fa813a8c0a2bc66cca70a56db3a # v2 with: base: ${{ github.event.pull_request.base.sha }} fail-on-findings: false # advisory while bedding in @@ -35,3 +35,10 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_REGION: eu-north-1 + # Pin the review model explicitly. compliancemaxx falls back to + # `eu.anthropic.claude-sonnet-4-6` when this is unset + # (packages/cli/src/llm/bedrock.ts), so leaving it out silently kept + # this workflow a generation behind the rest of the repo after the + # Sonnet 5 migration (#1218). Keep in sync with + # swedish-compliance-review.yml and lib/agent/composer/client.ts. + COMPLIANCE_BEDROCK_MODEL: eu.anthropic.claude-sonnet-5 diff --git a/.github/workflows/compliance-swarm.yml b/.github/workflows/compliance-swarm.yml index fdb39b81..c879de0a 100644 --- a/.github/workflows/compliance-swarm.yml +++ b/.github/workflows/compliance-swarm.yml @@ -40,11 +40,11 @@ jobs: timeout-minutes: 60 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - - uses: erp-mafia/compliancemaxx@v2 + - uses: erp-mafia/compliancemaxx@248cebcf90867fa813a8c0a2bc66cca70a56db3a # v2 with: mode: audit # v2 name; was `swarm` in v1 llm-provider: bedrock @@ -53,5 +53,9 @@ jobs: AWS_REGION: eu-north-1 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - # Override default model if desired: - # COMPLIANCE_BEDROCK_MODEL: 'eu.anthropic.claude-sonnet-5' + # Pin the audit model explicitly rather than leaving this commented + # out. compliancemaxx defaults to `eu.anthropic.claude-sonnet-4-6` + # when it is unset (packages/cli/src/llm/bedrock.ts), which quietly + # kept the nightly audit a generation behind after #1218. Keep in + # sync with compliance-pr.yml and lib/agent/composer/client.ts. + COMPLIANCE_BEDROCK_MODEL: eu.anthropic.claude-sonnet-5 diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml index 4951a62a..7e5199fa 100644 --- a/.github/workflows/core-build.yml +++ b/.github/workflows/core-build.yml @@ -1,44 +1,63 @@ name: Core Build (no extensions) +# Split out of a single serial job that took 7m43s wall-clock: npm ci 24s, lint +# ratchet 1m29s, build 2m00s, unit tests 3m31s, ratchets ~10s. None of those +# stages needed the previous one's output, so they were serial only by +# accident. Running them as independent jobs (and sharding the 897-file unit +# suite four ways) puts the critical path on `build` at roughly 2m30s. +# +# The prologue each job needs is in .github/actions/setup-core. + on: [pull_request] +# Six concurrent jobs per push makes stale runs far more expensive than they +# were when this was one job, and a superseded push has nothing worth finishing. +concurrency: + group: core-build-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: - core-only: + checks: + name: Checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - node-version: 20 - - run: npm ci + # Nothing here pushes, so leaving the token in .git/config only widens + # what a compromised dependency in the build could reach. + persist-credentials: false + - uses: ./.github/actions/setup-core + - name: Verify skill bodies are in sync with the seed migration # Fails if a .claude/skills/**/SKILL.md changed without regenerating the # seed migration (npm run skills:generate). Keeps prod skill content from # silently drifting out of sync. No DB needed: reads files + manifest. run: npm run skills:check + - name: Verify taxonomy registry is in sync with the element lists # Fails if dev_docs/bokslut/taxonomi/** changed without regenerating # lib/bokslut/ixbrl/taxonomy/generated/ (npm run taxonomy:generate). # The iXBRL generator emits facts strictly from the generated registry, # so drift here means filings tagged against a stale concept set. run: npm run taxonomy:check - - name: Reset extensions config - run: echo '{"extensions":[]}' > extensions.config.json - - run: npm run setup:extensions + - name: Lint ratchet (no new ESLint errors) # `npm run lint` was never wired into CI, so ~60 legacy errors # accumulated. This ratchet (sibling of check:guards) fails only when # a PR ADDS an error beyond scripts/checks/eslint-baseline.json; the # baseline ratchets down as legacy errors get fixed. run: npm run check:lint - - run: npm run build - - run: npm test + - name: Antipattern ratchet (no new MFA-bypassing routes / naive öre-rounding) # Fails only if a PR ADDS a route that hand-rolls supabase.auth.getUser() # instead of the MFA-enforcing guard, or a new Math.round(x*100)/100. # Baseline lives in scripts/checks/antipatterns-baseline.json and ratchets # down as the A1 (route auth) and D1 (rounding) migrations land. run: npm run check:guards + - name: Check no core imports from extensions run: | VIOLATIONS=$(grep -r "from '@/extensions/" lib/ app/api/ components/ --include="*.ts" --include="*.tsx" \ @@ -51,3 +70,71 @@ jobs: echo "$VIOLATIONS" exit 1 fi + + build: + name: Build (zero extensions) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # Nothing here pushes, so leaving the token in .git/config only widens + # what a compromised dependency in the build could reach. + persist-credentials: false + - uses: ./.github/actions/setup-core + - run: npm run build + + test: + # 897 unit test files (11,341 tests) take 3m31s in one process. Measured + # locally, four shards split them 225/224/224/224 with the slowest at 64s, + # which puts this comfortably under the build job so it stops being the + # critical path. Vitest hard-errors when the shard count exceeds the + # resolved file count, which is nowhere near a concern at this size. + name: Unit tests (${{ matrix.shard }}/4) + runs-on: ubuntu-latest + strategy: + # One shard failing should not hide failures in the other three: a red PR + # is more useful when it lists every broken test, not just the first. + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # Nothing here pushes, so leaving the token in .git/config only widens + # what a compromised dependency in the build could reach. + persist-credentials: false + - uses: ./.github/actions/setup-core + # Via env rather than interpolated straight into the shell body: the value + # is a static matrix integer and harmless, but keeping every `run:` free of + # ${{ }} is the rule that makes the template-injection audit meaningful. + - run: npm test -- --shard="$SHARD/4" + env: + SHARD: ${{ matrix.shard }} + + core-build: + # One stable check name covering all six jobs above, so the PR checks list + # (and any future required-status-check rule) has a single thing to read + # rather than a shard-numbered matrix. `needs` alone would not be enough: + # a needed job that is skipped or cancelled does not fail its dependents, + # so the results are asserted explicitly. + name: Core Build + if: always() + needs: [checks, build, test] + runs-on: ubuntu-latest + steps: + - name: Assert every core job succeeded + env: + CHECKS: ${{ needs.checks.result }} + BUILD: ${{ needs.build.result }} + TEST: ${{ needs.test.result }} + run: | + set -euo pipefail + echo "checks=$CHECKS build=$BUILD test=$TEST" + failed=0 + for r in "$CHECKS" "$BUILD" "$TEST"; do + [ "$r" = "success" ] || failed=1 + done + if [ "$failed" -ne 0 ]; then + echo "::error::One or more core-build jobs did not succeed" + exit 1 + fi diff --git a/.github/workflows/docker-image-scan.yml b/.github/workflows/docker-image-scan.yml index 5f262670..50cbe772 100644 --- a/.github/workflows/docker-image-scan.yml +++ b/.github/workflows/docker-image-scan.yml @@ -61,14 +61,14 @@ jobs: steps: - name: Log in to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Scan published image with Trivy - uses: aquasecurity/trivy-action@v0.36.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest # Block on fixable CRITICAL/HIGH: the same policy the build pipeline @@ -92,7 +92,7 @@ jobs: # scan step above failed the run. Same category as docker-publish.yml so # the two analyses share one alert set instead of duplicating. if: always() - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: sarif_file: trivy-results.sarif category: trivy @@ -109,12 +109,12 @@ jobs: security-events: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - name: Scan npm lockfile with Trivy - uses: aquasecurity/trivy-action@v0.36.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: # Filesystem scan: picks up package-lock.json and reports known CVEs # in the resolved dependency tree, including transitive pins. Same @@ -136,7 +136,7 @@ jobs: # step failed the run. Distinct category from the image scan so # dependency alerts and image alerts stay separately traceable. if: always() - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: sarif_file: trivy-sca.sarif category: trivy-sca diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6b35b33b..037fbffb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,63 +1,88 @@ name: Build and Push Docker Image +# Builds each platform on its own native runner, then stitches the two into one +# manifest list. +# +# This was a single amd64 job emulating arm64 through QEMU and took ~31 minutes: +# QEMU translates every Arm instruction into x86, so the arm64 half ran several +# times slower than the amd64 half it shared a runner with. GitHub's free +# `ubuntu-24.04-arm` runners (public repos) remove the emulation entirely, and +# the two halves now run concurrently on their own hardware. +# +# The cost is a second job. A native single-platform build cannot produce a +# multi-platform manifest by itself, so each build pushes an untagged, +# digest-addressed image and `merge` composes the tag list from those digests +# with `imagetools create`. Tagging, signing, and scanning all belong to `merge`, +# because the digest consumers actually resolve is the manifest list's, not +# either platform's. +# +# workflow_dispatch is deliberate: it makes this runnable from a branch before +# merge. On a non-default branch the is_default_branch guard below disables the +# `latest` tag, so a dispatch run publishes only the immutable commit-sha tag and +# cannot move what production pulls. + on: push: branches: [main] tags: ['v*.*.*'] + workflow_dispatch: {} env: REGISTRY: ghcr.io IMAGE_NAME: erp-mafia/gnubok jobs: - build-and-push: - runs-on: ubuntu-latest + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + # Publishing one architecture and silently dropping the other is worse + # than publishing neither: the merge below needs both digests. + fail-fast: true + matrix: + include: + - platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + - platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm permissions: contents: read packages: write - # OIDC token used by cosign keyless signing. - id-token: write - # SARIF upload to the repo's "Security" tab from the Trivy scan. - security-events: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # The build pushes to GHCR via docker/login-action, never over git, so + # the git credential is dead weight inside a Docker build context. + persist-credentials: false - name: Log in to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Labels only. Tags are applied once, to the manifest list, in `merge`. - name: Extract metadata id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # On main: push `latest` + commit-sha tags. - # On v*.*.* tags: push semver tags (1.2.3, 1.2, 1) for production pinning. - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=sha,prefix= - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - - name: Build and push + # No setup-qemu-action. That was the whole point: each runner now builds + # its own architecture natively. + - name: Build and push by digest id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} build-args: | EXTENSIONS_PRESET=self-hosted @@ -65,6 +90,10 @@ jobs: # OCI attestations, queryable via `docker buildx imagetools inspect`. provenance: mode=max sbom: true + # push-by-digest publishes without a tag; `merge` collects the digests + # and builds the tag list from them. name-canonical records the + # repository name in the config so the merged manifest resolves. + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true # Layer cache lives in GHCR, not the GitHub Actions cache. Two # platforms x mode=max is ~7 GB, which alone ate 70% of the repo's # 10 GB Actions cache quota and, because buildx refreshes every blob's @@ -74,23 +103,138 @@ jobs: # would drop the deps/builder stages and make every push redo # `npm ci` + `next build`. # - # A single stable cache tag is safe here: this workflow only runs on - # main and v*.*.* tags, and tags are cut from main, so there is no - # untrusted ref that could poison the layers. + # The cache tag is now per-architecture. Each runner builds a single + # platform, so a shared tag would leave the two jobs racing to + # overwrite a cache manifest describing layers the other cannot use. + # + # A stable cache tag is safe here: this workflow runs only on main, + # on v*.*.* tags (cut from main), and on manual dispatch, so there is + # no untrusted ref that could poison the layers. # # image-manifest=true,oci-mediatypes=true is required by GHCR, which # rejects buildkit's default cache manifest media type. - cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }} + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }},mode=max,image-manifest=true,oci-mediatypes=true - - name: Install cosign - uses: sigstore/cosign-installer@v4.1.2 - - - name: Sign the image (keyless OIDC) + # Digests reach `merge` as artifact filenames: the file content is + # irrelevant, only the name carries information. + - name: Export digest env: DIGEST: ${{ steps.build.outputs.digest }} run: | - cosign sign --yes "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}" + set -euo pipefail + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: digests-${{ matrix.arch }} + path: /tmp/digests/* + # A silently empty artifact would surface later as an imagetools + # invocation with no source digests, which is a much worse error. + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge, sign and scan + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + packages: write + # OIDC token used by cosign keyless signing. + id-token: write + # SARIF upload to the repo's "Security" tab from the Trivy scan. + security-events: write + + steps: + - name: Download digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Log in to GHCR + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # On main: push `latest` + commit-sha tags. + # On v*.*.* tags: push semver tags (1.2.3, 1.2, 1) for production pinning. + # A workflow_dispatch run from a branch gets the sha tag only, because + # is_default_branch gates `latest`. + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix= + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - name: Create and push manifest list + working-directory: /tmp/digests + run: | + set -euo pipefail + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "${REGISTRY}/${IMAGE_NAME}@sha256:%s " *) + + # Cosign and Trivy must address the manifest list, not either platform's + # image: the list digest is what `docker pull` resolves for consumers, so + # it is the thing worth signing and the thing worth scanning. + - name: Resolve manifest list digest + id: manifest + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + DIGEST=$(docker buildx imagetools inspect \ + "${REGISTRY}/${IMAGE_NAME}:${VERSION}" \ + --format '{{json .Manifest}}' | jq -r '.digest') + echo "Resolved ${REGISTRY}/${IMAGE_NAME}:${VERSION} -> ${DIGEST}" + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + + - name: Verify both platforms are present + # A merge that quietly produced a single-arch manifest would ship a + # broken image to every arm64 self-hoster, and nothing downstream checks + # architecture. Assert it here while the digest is still in hand. + env: + DIGEST: ${{ steps.manifest.outputs.digest }} + run: | + set -euo pipefail + PLATFORMS=$(docker buildx imagetools inspect \ + "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" --raw \ + | jq -r '[.manifests[] + | select(.platform != null) + | select(.platform.os != "unknown") + | "\(.platform.os)/\(.platform.architecture)"] + | sort | unique | join(",")') + echo "Manifest platforms: ${PLATFORMS}" + [ "$PLATFORMS" = "linux/amd64,linux/arm64" ] || { + echo "::error::Expected linux/amd64,linux/arm64 but got ${PLATFORMS}" + exit 1 + } + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Sign the image (keyless OIDC) + env: + DIGEST: ${{ steps.manifest.outputs.digest }} + run: | + set -euo pipefail + cosign sign --yes "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" - name: Scan image with Trivy (report-only) id: trivy @@ -108,9 +252,9 @@ jobs: # Accepted residual risk: an image is live for that short scan window # before the gate fires; see docs/SELF-HOSTING.md / the risk register. continue-on-error: true - uses: aquasecurity/trivy-action@v0.36.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }} severity: CRITICAL,HIGH exit-code: '0' ignore-unfixed: true @@ -125,7 +269,7 @@ jobs: # hiccup can't redden an otherwise-good publish. if: always() continue-on-error: true - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: sarif_file: trivy-results.sarif category: trivy diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml index 2ff01e60..1624d603 100644 --- a/.github/workflows/pr-agent.yml +++ b/.github/workflows/pr-agent.yml @@ -64,15 +64,25 @@ jobs: # ── Model: Claude Sonnet 5 via the EU Bedrock inference profile. The # strong and weak slots both point at it: this account has no larger # model enabled, so a second id would only be the same model under - # another name. No FALLBACK_MODELS for the same reason: a fallback - # list naming the primary is not a fallback. custom_model_max_tokens - # is required because this id is not in PR-Agent's built-in map. + # another name. custom_model_max_tokens is required because this id + # is not in PR-Agent's built-in map. CONFIG.MODEL: "bedrock/eu.anthropic.claude-sonnet-5" CONFIG.MODEL_WEAK: "bedrock/eu.anthropic.claude-sonnet-5" - CONFIG.CUSTOM_MODEL_MAX_TOKENS: "200000" + # Emptied explicitly. This block previously said it set no fallback + # list, but "unset" is not "none": PR-Agent's own default is + # ["gpt-5.6-terra"], visible in the resolved config it logs on every + # run. Only AWS credentials reach this container, so that fallback + # could never have authenticated, but a silent third-party model in + # the path of every PR diff should be absent by intent, not by a + # missing key. + CONFIG.FALLBACK_MODELS: "[]" + CONFIG.CUSTOM_MODEL_MAX_TOKENS: "1000000" # Input window PR-Agent prunes the diff to fit. Default (~32k) truncated # large PRs; raise it so the whole diff is reviewed (Sonnet 5 = 1M ctx). - CONFIG.MAX_MODEL_TOKENS: "64000" + # 64000 was tuned against Sonnet 4.6's tokenizer. Sonnet 5 tokenizes the + # same text into roughly 30% more tokens, so that budget silently held + # ~30% less real diff after #1218; 96000 restores parity with headroom. + CONFIG.MAX_MODEL_TOKENS: "96000" LITELLM.DROP_PARAMS: "true" # ── pr_actions = which GitHub PR *event actions* trigger the bot diff --git a/.github/workflows/swedish-compliance-diff.yml b/.github/workflows/swedish-compliance-diff.yml index 616ef73b..8ab51c22 100644 --- a/.github/workflows/swedish-compliance-diff.yml +++ b/.github/workflows/swedish-compliance-diff.yml @@ -19,7 +19,7 @@ jobs: prepare: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -41,7 +41,7 @@ jobs: git diff --name-only "$MERGE_BASE" HEAD > files.txt printf '%s\n' "$PR_NUMBER" > pr-number.txt - name: Upload diff artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: compliance-input path: | diff --git a/.github/workflows/swedish-compliance-review.yml b/.github/workflows/swedish-compliance-review.yml index 9fae7762..ea822f70 100644 --- a/.github/workflows/swedish-compliance-review.yml +++ b/.github/workflows/swedish-compliance-review.yml @@ -33,14 +33,14 @@ jobs: # Base repo only: the TRUSTED copy of the script and .claude/skills/. # persist-credentials: false, no later step needs git push creds, so don't # leave the token in .git/config for the steps that handle untrusted input. - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 20 - name: Download diff artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: compliance-input run-id: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/test-pg-real.yml b/.github/workflows/test-pg-real.yml index 65af2a94..7e9b78aa 100644 --- a/.github/workflows/test-pg-real.yml +++ b/.github/workflows/test-pg-real.yml @@ -2,6 +2,12 @@ name: pg-real tests on: [pull_request] +# Neither job writes anything back: they read the repo, stand up a throwaway +# Postgres, and run tests. Without this block both inherit the repository's +# default token permissions, which are broader than that. +permissions: + contents: read + concurrency: group: pg-real-${{ github.ref }} cancel-in-progress: true @@ -14,11 +20,12 @@ jobs: # ()` comments inside the migration. runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # Full history so the merge-base with the PR base branch exists. fetch-depth: 0 - - uses: actions/setup-node@v6 + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 20 - name: Require pg-real coverage for trigger/RPC/RLS migrations @@ -50,7 +57,9 @@ jobs: PGPASSWORD: postgres steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false # Deliberately no `cache: npm` here. This workflow runs on pull_request # only, so the cache is never written on main, and GitHub scopes caches @@ -59,7 +68,7 @@ jobs: # the repo quota) accumulated in a single day. If this is ever worth # caching again, it has to be actions/cache/save on main plus # actions/cache/restore here, which is the only shape that gets hits. - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 20 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 00000000..4b1c1821 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,81 @@ +name: Workflow audit (zizmor) + +# Static analysis for the workflows themselves. Every other scanner in this repo +# looks at application code or container contents; nothing looked at CI, which is +# where the credentials live. +# +# The motivating incident is concrete: in March 2026 attackers exploited a +# pull_request_target misconfiguration in aquasecurity/trivy-action (an action +# this repo uses in three places) to exfiltrate org and repo secrets, then used +# them to backdoor LiteLLM on PyPI. zizmor's dangerous-triggers and +# unpinned-uses audits cover exactly that class. +# +# Installed from PyPI at a pinned version rather than via zizmorcore/zizmor-action: +# a workflow whose job is to check the supply chain should not widen it. + +on: + pull_request: + push: + branches: [main] + schedule: + # Weekly, off the hour to dodge cron congestion on GitHub. New audits ship + # regularly, so a repo that stopped changing can still start failing here. + - cron: '41 5 * * 1' + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: zizmor-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + name: Audit workflows + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # SARIF upload to the repo's "Security" tab. + security-events: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + # pipx is preinstalled on GitHub's ubuntu runners. Exact pin, because an + # unpinned install here would be the same mistake this workflow exists to + # catch. + - name: Install zizmor + run: pipx install zizmor==1.28.0 + + - name: Audit workflows (SARIF) + env: + # Lets zizmor run its online audits (resolving `uses:` refs against + # the forge) instead of falling back to offline-only checks. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # zizmor suppresses its findings-based exit codes under --format=sarif, + # so this step reports and never gates. The gate is the separate step + # below, which is why the audit is run twice. + run: zizmor --format=sarif . > zizmor.sarif + + - name: Upload zizmor results to GitHub Security tab + # if: always() so findings still reach the Security tab when the gate + # below fails the run. Distinct category from the Trivy uploads so + # workflow findings stay separately traceable. + if: always() + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + sarif_file: zizmor.sarif + category: zizmor + + - name: Gate on high-severity, high-confidence findings + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Deliberately narrower than the SARIF report above. Everything zizmor + # finds is visible in the Security tab; only findings it is confident are + # high severity block a merge. Tighten by lowering --min-severity once + # the backlog is clear. + run: zizmor --min-severity=high --min-confidence=high --format=plain . diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000..43b6d70c --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,30 @@ +# zizmor configuration. See .github/workflows/zizmor.yml for why this repo +# audits its own CI. +# +# Suppressions here are reviewed exceptions, not a backlog. Anything added needs +# a reason that says why the generic rule does not apply, so the Security tab +# stays worth reading. + +rules: + dangerous-triggers: + ignore: + # zizmor's position is that `workflow_run` is almost always used + # insecurely, and it is right in general: the usual mistake is triggering + # on a fork's build and then checking out that fork's code with secrets in + # scope. Both uses below are the opposite pattern, and both are the + # documented fix for the trigger they replaced. + # + # Stage 2 of the fork-safe compliance review. It holds the AWS secrets and + # a write token, and it checks out ONLY the base repo: fork code never + # executes here. The untrusted PR diff arrives as a downloaded artifact + # and is passed to the model as data. This is precisely what GitHub + # recommends instead of `pull_request_target` + checking out the PR head, + # which is what this workflow used to be. + - swedish-compliance-review.yml + # Re-scans an image that has already been published to GHCR. It checks out + # no source at all and only fires on `conclusion == 'success'` of a + # workflow that itself runs only on main and release tags, so there is no + # untrusted input and no fork ref in reach. The trigger exists to shrink + # the window between publishing an image and gating it from up to 24h + # (the cron) down to the scan's own duration. + - docker-image-scan.yml diff --git a/DECISIONS.md b/DECISIONS.md index 40c9144d..48f95c5f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -575,3 +575,10 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-27] Sonnet 5 no-thinking output ceiling kept separate (MAX_TOKENS_NO_THINKING 5400): max_tokens now caps thinking and reply together, so one shared ceiling would have quadrupled what a non-thinking intent may emit; 5400 is the old 4096 scaled for the new tokenizer. [2026-07-27] Assistant panel docks via a --agent-dock-w CSS custom property rather than restructuring the frame: the dashboard layout is a server component, so a client-owned class on
would have meant lifting it to a client boundary for a margin. [2026-07-27] Agent status is a reducer in a React-free module with an unused 'detached' state: durable background runs must publish to the same channel later (plan seam 8.6), and this repo's unit project is node-only so the state machine has to be testable without a component harness. +[2026-07-27] Compliance review script raises max_tokens to 16000 but does NOT send an explicit thinking parameter: on Sonnet 5 an omitted parameter already means adaptive, so sending it would add request surface on the pinned legacy Bedrock SDK 0.29.1 for zero behavioral change, and the workflow_run trigger checks out the base repo so no change to this script is testable before merge. +[2026-07-27] Core-build PR jobs restore the npm cache but never save it, with the save half in a separate main-only workflow (ci-cache.yml): a cache written from a pull_request run is scoped to that PR's ref and unreadable by anything else, which is how fourteen dead 284 MB copies once ate 40% of the repo's cache quota; only default-branch entries are restorable from every PR. +[2026-07-27] Deliberately did NOT cache .next/cache alongside the npm cache: keeping it useful needs a fresh entry per main push, and at a few hundred MB each that recreates the same quota churn, so the ~60s it would save off the build stage is deferred to its own change. +[2026-07-27] zizmor installed from PyPI at a pinned version rather than via zizmorcore/zizmor-action: a workflow whose job is auditing the supply chain should not widen it by adding another third-party action. +[2026-07-27] zizmor gates only on high-severity high-confidence findings while reporting everything to the Security tab: the first run surfaced 56 findings, and a scanner that blocks every merge on day one gets disabled rather than triaged. +[2026-07-27] dangerous-triggers suppressed for swedish-compliance-review.yml and docker-image-scan.yml in .github/zizmor.yml rather than left unresolved: both are workflow_run, but the first is the base-repo-only pattern GitHub recommends INSTEAD of pull_request_target and the second checks out no source at all, so leaving two permanent unexplained errors in the Security tab would just train reviewers to ignore it. +[2026-07-27] Docker layer cache tag is now per-architecture (buildcache-amd64 / buildcache-arm64): with native runners each job builds one platform, so a shared tag would leave the two racing to overwrite a cache manifest describing layers the other cannot use. diff --git a/package-lock.json b/package-lock.json index 91dc2edd..3a8a8725 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13694,462 +13694,6 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next/node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/next/node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/next/node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -14178,64 +13722,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/next/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/next/node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", diff --git a/package.json b/package.json index 34b1fe15..55a705cc 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "vitest": "^4.1.9" }, "overrides": { - "ws": "^8.21.0" + "ws": "^8.21.0", + "sharp": "^0.35.3" } } diff --git a/scripts/swedish-compliance-review.mjs b/scripts/swedish-compliance-review.mjs index 966e25f3..216da72f 100644 --- a/scripts/swedish-compliance-review.mjs +++ b/scripts/swedish-compliance-review.mjs @@ -12,6 +12,13 @@ import path from 'node:path'; const SKILLS_DIR = '.claude/skills'; const ALWAYS_LOAD = 'swedish-accounting-compliance'; const MODEL = process.env.REVIEW_MODEL || 'eu.anthropic.claude-sonnet-5'; +// Budgets thinking AND response text together. Sonnet 4.6 ran this script with +// thinking off (that was what omitting the parameter meant), so 4096 was all +// prose. Sonnet 5 runs adaptive thinking by default, so the same number is now +// shared with reasoning the script never renders (it filters to text blocks) and +// the review silently truncates. 16000 restores prose headroom; the job's +// 10-minute timeout has ample room (runs land in ~30s). +const MAX_TOKENS = Number(process.env.REVIEW_MAX_TOKENS || 16_000); const MAX_DIFF_CHARS = 180_000; const OUTPUT_FILE = 'review.md'; const COMMENT_MARKER = ''; @@ -207,20 +214,37 @@ async function main() { const resp = await client.messages.create({ model: MODEL, - max_tokens: 4096, + max_tokens: MAX_TOKENS, system, messages: [{ role: 'user', content: user }], }); + // `thinking` is deliberately not passed. On Sonnet 5 an omitted thinking + // parameter already means adaptive thinking, so sending it explicitly would + // add request surface (this runs on the pinned legacy Bedrock SDK 0.29.1) + // for no behaviour change. What DID change at #1218: on Sonnet 4.6 an + // omitted parameter meant no thinking at all, and max_tokens caps thinking + // plus response text together. See MAX_TOKENS above. const text = resp.content .filter((b) => b.type === 'text') .map((b) => b.text) .join('\n') .trim(); + // A truncated review is worse than a failed one: it reads as a clean bill of + // health with the findings cut off. The workflow's "Assert review produced + // output" step only catches an empty file, so catch the truncation here. + if (resp.stop_reason === 'max_tokens') { + throw new Error( + `Review truncated: hit max_tokens (${MAX_TOKENS}). Thinking and response text share this budget on Sonnet 5; raise MAX_TOKENS.`, + ); + } + const body = text.startsWith(COMMENT_MARKER) ? text : `${COMMENT_MARKER}\n\n${text}`; writeFileSync(OUTPUT_FILE, body + '\n'); - console.log(`Wrote ${OUTPUT_FILE} (${body.length} chars, model=${MODEL}).`); + console.log( + `Wrote ${OUTPUT_FILE} (${body.length} chars, model=${MODEL}, stop_reason=${resp.stop_reason}).`, + ); } main().catch((err) => {