From 30e6d149d65cb4f602c24a33d602ad726f1fd1fe Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:34:56 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20address=20the=20open=20issue=20set=20?= =?UTF-8?q?=E2=80=94=20CI=20truth,=20proof=20gates,=20scanner=20precision,?= =?UTF-8?q?=20toolchain=20policy=20(#814,=20#816,=20#820,=20#825,=20#831,?= =?UTF-8?q?=20#832,=20#834,=20#841,=20#845,=20#847=E2=80=93#851,=20#853,?= =?UTF-8?q?=20#857,=20#636,=20#676,=20#695,=20#746,=20#748)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAVE 1 — CI truth (#841, #814, #847–#851) * --locked passed on every test/check cargo invocation (tests.yml, ci.yml, verify-proofs.yml, abi-codegen-drift.yml, batch-security-scan); the committed Cargo.lock is the constraint it claims to be. * #814 real cause of red cargo test --workspace = unsatisfiable testcontainers ^0.28 vs testcontainers-modules ^0.15 (not network egress); pair made satisfiable and DEBT-REGISTER CI-1 corrected. * security-policy.yml: one scan tier + security-status aggregator on the Fail-on-critical gate; TruffleHog Gates A/B (--only-verified off + historical scan); audit jobs report instead of discard; container-scan joins the aggregator; scanner image pinned (no :latest). Decision record + per-step manifest at the top of the workflow. WAVE 2 — runtime safety (#857, #853) * RateLimiter tests own named instances via start_supervised! (no sleeps); drain crasher (finding.type KeyError on string-keyed maps) cannot take the limiter down; check_internal/2 mirrors the full check incl. burst; crash-isolation regression test. compose tier deleted. WAVE 3 — proofs (#820, #816, #831) * Single-prefix Idris2 bootstrap (make install PREFIX=~/.idris2), cache -3, PATH export, measurement step; abi repair step retained with sequencing comment until one green run prints the support marker. * scripts/check-proof-status.sh + proof-status job: doc rows vs real identifiers, green on tree, mutants killed both directions. * scripts/check-trusted-base.sh + check-trusted-base job: zero escape hatches outside test/soundness/fixtures/; tree re-measured 2026-09-26; mutant killed; docs/proof-debt.adoc counts re-derived (6); AFFIRMATION.adoc claim scoped. verify-proofs.yml now 5 jobs. WAVE 4 — toolchain & policy (#832, #825, #845) * mise.toml no longer provisions python/denojs or the Python-only toolchain; language-blockers.yml enforces LANGUAGE-POLICY (Deno manifests banned, TS/ReScript sources banned — whole-tree, measured 0, banned runtimes cannot silently return to mise.toml) instead of its inverted predecessor. Checks green; planted-python mutant caught. * docs/governance/ACTIONS-PINNING.adoc records the pinning decision (actions.lock is the mechanism, option (b)); README links it and two broken README links are fixed. * mirror.yml pins standards' mirror-reusable at 2479cf76 past the Gitea empty-host fault (host/fingerprint asserted, fingerprints verified, per-forge skip notices); Mirror-coverage job reports the denominator. WAVE 5 — scanner precision & docs (#834, #676, #746/#748, #636, #695) * Zig comment stripping; scan_content reports real lines (SARIF startLine no longer 1); inline hypatia:ignore / hypatia: allow directives honoured per line; main.zig opaque-handle casts carry reviewed pragmas (cast unchanged); RE005 strips YAML comments and honours hypatia:ignore RE005 -- reason. Both directions fixtured. * Deterministic language resolution shared by all readers (count desc, fixed priority, lexical); discriminating tie-break test. * Secret findings dispositioned: placeholder shapes and whole-line comments demote to medium/report with reason; uncommented real-looking values stay critical/revoke_rotate_and_purge. * Logtalk category-A purge (ci.yml header, ROADMAP, poc-scanner.sh, guides, NEURAL-ARCHITECTURE); PROOF-STATUS annotated with reason; B/C/D untouched (owner-gated contractile filed as #864). * PMPL headers fixed (3 workflows); SPDX added where missing (fixture main.rs deliberately headerless, recorded in new .reuse/dep5); AGPL dead weight removed; CITATION.cff added; stale TEST_CI artifact deleted per its own instruction; mix hypatia.rsr_score finds its SSOT without --ssot. New issues filed from findings: #864 (owner-gated contractile), #865 (harvested-registry secret carve-out), #866 (scan-writer determinism boundary — producer lives outside this repo). Mutants and measurements as recorded per issue; mix/cargo evidence runs in CI on this branch. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/TEST_CI_CODEQL_HYPATIA.md | 7 - .github/workflows/abi-codegen-drift.yml | 25 +- .github/workflows/actions.lock | 22 +- .github/workflows/ci.yml | 17 +- .github/workflows/governance.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/language-blockers.yml | 85 +- .github/workflows/mirror.yml | 86 +- .github/workflows/quality.yml | 6 +- .github/workflows/rust.yml | 17 +- .github/workflows/scorecard.yml | 2 +- .github/workflows/security-policy.yml | 918 +++++++----------- .github/workflows/tests.yml | 9 +- .github/workflows/verify-proofs.yml | 91 +- .reuse/dep5 | 21 + AFFIRMATION.adoc | 21 +- CITATION.cff | 22 + LICENSES/AGPL-3.0-or-later.txt | 661 ------------- README.adoc | 5 +- ROADMAP.adoc | 10 +- docs/DEBT-REGISTER.adoc | 54 +- docs/architecture/NEURAL-ARCHITECTURE.adoc | 3 +- docs/governance/ACTIONS-PINNING.adoc | 81 ++ docs/guides/admin-guide.adoc | 12 +- docs/guides/cicd-guidebook.adoc | 10 +- docs/guides/developer-guide.adoc | 28 +- .../containerfiles-chainguard-gap.adoc | 12 +- docs/proof-debt.adoc | 126 +-- docs/testing/needs.adoc | 8 +- ffi/zig/src/main.zig | 6 + integration/Cargo.toml | 10 +- integration/Containerfile.test | 27 - integration/README.adoc | 250 ++--- integration/compose.test.yaml | 192 ---- integration/run-tests.sh | 363 ------- lib/cross_repo_learning.ex | 72 +- lib/hypatia/cli.ex | 22 +- lib/hypatia/scanner_suppression.ex | 63 +- lib/mix/tasks/hypatia.rsr_score.ex | 28 +- lib/neural/graph_of_trust.ex | 16 +- lib/rules/code_safety.ex | 83 +- lib/rules/research_extensions.ex | 48 +- lib/safety/rate_limiter.ex | 64 +- lib/vcl/file_executor.ex | 3 +- poc-scanner.sh | 5 +- scripts/check-proof-status.sh | 185 ++++ scripts/check-trusted-base.sh | 117 +++ test/code_safety_test.exs | 94 ++ test/concurrency_test.exs | 42 +- test/cross_repo_learning_test.exs | 43 + test/research_extensions_test.exs | 59 ++ test/safety_test.exs | 176 ++-- test/scanner_suppression_test.exs | 47 + test/test_helper.exs | 1 + test_integration.exs | 1 + verification/PROOF-STATUS.adoc | 9 +- 56 files changed, 2075 insertions(+), 2314 deletions(-) delete mode 100644 .github/TEST_CI_CODEQL_HYPATIA.md create mode 100644 .reuse/dep5 create mode 100644 CITATION.cff delete mode 100644 LICENSES/AGPL-3.0-or-later.txt create mode 100644 docs/governance/ACTIONS-PINNING.adoc delete mode 100644 integration/Containerfile.test delete mode 100644 integration/compose.test.yaml delete mode 100755 integration/run-tests.sh create mode 100755 scripts/check-proof-status.sh create mode 100755 scripts/check-trusted-base.sh diff --git a/.github/TEST_CI_CODEQL_HYPATIA.md b/.github/TEST_CI_CODEQL_HYPATIA.md deleted file mode 100644 index e6354877..00000000 --- a/.github/TEST_CI_CODEQL_HYPATIA.md +++ /dev/null @@ -1,7 +0,0 @@ -# CI TEST: CodeQL + Hypatia in hypatia repo itself -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) Jonathan D.A. Jewell - -Test commit to verify fixes in the hypatia repo itself. - -DELETE AFTER VERIFICATION. diff --git a/.github/workflows/abi-codegen-drift.yml b/.github/workflows/abi-codegen-drift.yml index faf01b71..f04e5b09 100644 --- a/.github/workflows/abi-codegen-drift.yml +++ b/.github/workflows/abi-codegen-drift.yml @@ -54,14 +54,13 @@ jobs: uses: actions/cache@v6.1.0 with: path: | - /usr/local/bin/idris2 - /usr/local/bin/idris2_app - /usr/local/lib/idris2 ~/.idris2 # Same key as verify-proofs.yml on purpose: this job shares that # workflow's warm cache rather than paying a second ~20-min - # bootstrap. Keep the `-2` suffix in step with it. - key: idris2-${{ env.IDRIS2_VERSION }}-${{ runner.os }}-2 + # bootstrap. Keep the suffix in step with it. `-3` = the + # single-prefix layout (#820); `-2` and earlier entries are the + # split-prefix caches and must not be restored. + key: idris2-${{ env.IDRIS2_VERSION }}-${{ runner.os }}-3 - name: Build Idris 2 from source if: steps.cache-idris.outputs.cache-hit != 'true' @@ -71,9 +70,15 @@ jobs: https://github.com/idris-lang/Idris2 /tmp/idris2 cd /tmp/idris2 make bootstrap SCHEME=chezscheme - sudo make install PREFIX=/usr/local + # ONE prefix (#820), same as verify-proofs.yml: install where the + # bootstrap-built binary already looks ($HOME/.idris2), so + # `idris2 --libdir` and the install trees cannot disagree. + make install PREFIX="$HOME/.idris2" idris2 --version + - name: Put Idris 2 on PATH + run: echo "$HOME/.idris2/bin" >> "$GITHUB_PATH" + - name: Verify Idris is on PATH run: idris2 --version @@ -117,6 +122,14 @@ jobs: # on a cache hit, so this job never mutates the shared entry and redoes # the clone+build each run. Do not "optimise" that away -- two workflows # writing one cache key is how the poisoned cache of -1 happened. + # + # #820 SEQUENCING: the bootstrap above now installs ONE prefix, so a + # correct install makes this step's repair branch unreachable and it + # prints "support installation already complete". That line is the + # measurement #820 AC4 asks for. Once a green run shows it (the first + # run after the -3 cache lands), this whole step can be deleted -- it + # exists to prove the artefacts are present, and deleting the proof + # before the measurement is exactly what the issue forbids. - name: Ensure the Idris2 support installation is complete run: | set -euo pipefail diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 663e10b3..62a9b364 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -14,7 +14,7 @@ workflows: - 'actions/setup-node@v7.0.0' - 'actions/upload-artifact@v7.0.1' - 'dtolnay/rust-toolchain@v1' - - 'taiki-e/install-action@v2.87.16' + - 'taiki-e/install-action@v2.87.18' '.github/workflows/ci-health-sweep.yml': - 'actions/checkout@v7.0.1' '.github/workflows/ci.yml': @@ -26,7 +26,7 @@ workflows: - 'haskell-actions/hlint-setup@v2.4.10' - 'haskell-actions/setup@v2.12.0' - 'swatinem/rust-cache@v2.9.2' - - 'taiki-e/install-action@v2.87.16' + - 'taiki-e/install-action@v2.87.18' '.github/workflows/clusterfuzzlite.yml': - 'actions/checkout@v7.0.1' - 'google/clusterfuzzlite@v1' @@ -100,8 +100,8 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' - 'gitleaks/gitleaks-action@v3.0.0' - - 'taiki-e/install-action@v2.87.16' - - 'trufflesecurity/trufflehog@v3.97.5' + - 'taiki-e/install-action@v2.87.18' + - 'trufflesecurity/trufflehog@v3.97.6' '.github/workflows/tests.yml': - 'actions/cache@v6.1.0' - 'actions/checkout@v7.0.1' @@ -112,7 +112,7 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'erlef/setup-beam@v1.24.1' - 'swatinem/rust-cache@v2.9.2' - - 'taiki-e/install-action@v2.87.16' + - 'taiki-e/install-action@v2.87.18' '.github/workflows/verify-proofs.yml': - 'actions/cache@v6.1.0' - 'actions/checkout@v7.0.1' @@ -317,13 +317,13 @@ dependencies: commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' owner_id: 580492 repo_id: 298565987 - 'taiki-e/install-action@v2.87.16': - ref: 'v2.87.16' - commit: 'sha1-9114bf4d891761788c546334fd37538eae1bf8b3' + 'taiki-e/install-action@v2.87.18': + ref: 'v2.87.18' + commit: 'sha1-dfae9bf3d6f6c6f20ef4ebb3486c01a51341ff12' owner_id: 43724913 repo_id: 442947557 - 'trufflesecurity/trufflehog@v3.97.5': - ref: 'v3.97.5' - commit: 'sha1-f714bf454f350590f4a24c3ddb1aef02c35bf5b6' + 'trufflesecurity/trufflehog@v3.97.6': + ref: 'v3.97.6' + commit: 'sha1-64d939a56362f519781c53ea09b27f8d1dc0140a' owner_id: 79229934 repo_id: 77726177 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88badb02..44c55ab9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,9 @@ # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # Main CI workflow for hypatia -# Tests Rust (adapters, cli, fixer, data), Haskell (registry), and Logtalk (engine) +# Tests Rust (adapters, cli, fixer, data) and the Elixir rules. The Haskell +# (registry) jobs below are dormant-gated on the tree's absence; the Logtalk +# rule engine was retired 2026-03-06 (absorbed into lib/rules/*.ex). name: CI @@ -48,11 +50,16 @@ jobs: workspaces: ". -> target" cache-on-failure: true + # Every cargo invocation in this workflow passes --locked: the committed + # Cargo.lock is a hard constraint, not a hint (#841). A failure reading + # "the lock file ... needs to be updated but --locked was passed" means + # Cargo.lock is out of date w.r.t. the manifests -- regenerate it + # deliberately and commit it; never drop --locked to get green. - name: Run cargo check - run: cargo check --workspace --all-targets + run: cargo check --workspace --all-targets --locked - name: Run clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --locked -- -D warnings rust-fmt: name: Rust Format @@ -92,10 +99,10 @@ jobs: cache-on-failure: true - name: Run tests - run: cargo test --workspace --all-targets + run: cargo test --workspace --all-targets --locked - name: Run doc tests - run: cargo test --workspace --doc + run: cargo test --workspace --doc --locked rust-test-coverage: name: Rust Coverage diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 89c8ce01..0bf27a56 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -1,5 +1,5 @@ # This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: PMPL-1.0-or-later +# SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. name: Governance diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 3533b601..61c81175 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,5 +1,5 @@ # This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: PMPL-1.0-or-later +# SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. name: Hypatia Security Scan diff --git a/.github/workflows/language-blockers.yml b/.github/workflows/language-blockers.yml index a90f2996..17da8a7e 100644 --- a/.github/workflows/language-blockers.yml +++ b/.github/workflows/language-blockers.yml @@ -1,8 +1,5 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 -# This workflow is managed by gh actions-lock. -# Consolidated workflow (behaviour-preserving merge). -# Merged from: npm-bun-blocker.yml, ts-blocker.yml name: Language Policy Blockers on: @@ -15,38 +12,78 @@ concurrency: permissions: read-all +# ============================================================================ +# Enforces hyperpolymath/standards 3-practice/LANGUAGE-POLICY.adoc in this +# repo (issue #832). The doctrine, verbatim: "bun is the runtime; python, +# deno, rescript and typescript are banned." This workflow previously +# enforced the INVERSE of that policy — it failed any build carrying +# `bun.lockb` with the message "npm/bun artifacts detected. Use Deno +# instead", and its TS gate was structurally incapable of failing (it +# diffed against HEAD~1 on a depth-1 checkout and swallowed the error). +# Both are superseded per LANGUAGE-POLICY §1.1/§1.2. +# +# Every check prints its denominator; a check that looked at nothing must +# not read as a pass. +# ============================================================================ + jobs: - check: + language-policy: + name: Language Policy runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read steps: - uses: actions/checkout@v7.0.1 - - name: Block npm/bun + + - name: Deno is banned run: | - if [ -f "package-lock.json" ] || [ -f "bun.lockb" ] || [ -f ".npmrc" ]; then - echo "❌ npm/bun artifacts detected. Use Deno instead." + set -euo pipefail + hits=$(git ls-files | grep -E '(^|/)deno\.jsonc?$' || true) + n=$(printf '%s' "$hits" | grep -c . || true) + echo "deno manifests in tree: ${n}" + if [ "$n" -gt 0 ]; then + echo "::error::Deno is banned (LANGUAGE-POLICY §1.3, owner ruling 2026-09-22). Found:" + printf '%s\n' "$hits" exit 1 fi - echo "✅ No npm/bun violations" + echo "✅ No Deno manifests" - ts_check: - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: read - steps: - - uses: actions/checkout@v7.0.1 - - name: Block new TypeScript/JavaScript + - name: TypeScript/ReScript are banned (AffineScript is the destination) run: | - NEW_TS=$(git diff --name-only --diff-filter=A HEAD~1 2>/dev/null | grep -E '\.(ts|tsx)$' | grep -v '\.gen\.' || true) - NEW_JS=$(git diff --name-only --diff-filter=A HEAD~1 2>/dev/null | grep -E '\.(js|jsx)$' | grep -v '\.res\.js$' | grep -v '\.gen\.' | grep -v 'node_modules' || true) - - if [ -n "$NEW_TS" ] || [ -n "$NEW_JS" ]; then - echo "❌ New TS/JS files detected. Use AffineScript instead." - [ -n "$NEW_TS" ] && echo "$NEW_TS" - [ -n "$NEW_JS" ] && echo "$NEW_JS" + set -euo pipefail + # Whole-tree, not new-files-only: this repo tracks zero .ts/.tsx/.res + # files (measured 2026-09-26), so the stronger check is free — and + # the old `git diff HEAD~1` gate could never fire at all. + hits=$(git ls-files '*.ts' '*.tsx' '*.res' '*.resi' '*.res.js' | grep -v '\.gen\.' || true) + n=$(printf '%s' "$hits" | grep -c . || true) + echo "ts/tsx/res files in tree: ${n}" + if [ "$n" -gt 0 ]; then + echo "::error::TypeScript/ReScript are banned (LANGUAGE-POLICY §1.2/§3). New application code is AffineScript. Found:" + printf '%s\n' "$hits" + exit 1 + fi + echo "✅ No TypeScript/ReScript sources" + + - name: Banned runtimes are not provisioned in mise.toml + run: | + set -euo pipefail + # #832 AC5: a banned runtime cannot be reintroduced to mise.toml + # silently. Checked here (the language-ban workflow) rather than as + # a bespoke grep somewhere new. + banned='python|denojs|deno|rescript' + hits=$(grep -nE "^(${banned})[[:space:]]*=" mise.toml || true) + n=$(printf '%s' "$hits" | grep -c . || true) + echo "banned runtimes in mise.toml [tools]: ${n}" + if [ "$n" -gt 0 ]; then + echo "::error::mise.toml provisions banned runtime(s) (LANGUAGE-POLICY; issue #832). Remove them and any tool that only they can run:" + printf '%s\n' "$hits" + exit 1 + fi + pyenv=$(grep -nE '^PYTHON' mise.toml || true) + if [ -n "$pyenv" ]; then + echo "::error::mise.toml still carries PYTHON* env entries with no Python toolchain to read them (#832):" + printf '%s\n' "$pyenv" exit 1 fi - echo "✅ ReScript policy enforced" + echo "✅ mise.toml provisions no banned runtime" diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 9e7fd017..0bd744f2 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -12,11 +12,95 @@ permissions: actions: read contents: read +# ============================================================================ +# The seven forge mirrors live in hyperpolymath/standards' +# mirror-reusable.yml (see its header for the per-forge configuration +# contract). The pin below was deliberately bumped 2026-09-26 from +# 571cc734 to 2479cf76 (issue #845): the old revision carried three faults +# this repo's runs were reddening on every push — +# +# 1. mirror-gitea ran `ssh-keyscan ... ${{ vars.GITEA_HOST }}` with NO +# non-empty assertion, so an unset variable expanded to an empty +# argument and the job died at ssh-keyscan usage — a silent +# empty-variable expansion that reads exactly like an auth failure. +# 2. mirror-disroot / mirror-bitbucket presented deploy keys the forges +# rejected (Permission denied / Could not read from remote). +# 3. no run-level accounting: a forge deliberately left unconfigured was +# indistinguishable from one that mirrored. +# +# The pinned revision asserts every host/fingerprint variable is non-empty +# and well-formed BEFORE use (naming the variable on failure), verifies the +# SSH host key against a pinned SHA256 fingerprint per forge, and prints an +# explicit "Skipped (... not configured)" notice per forge instead of +# failing obscurely. Fault 2 is OWNER-side (register the public keys on +# Disroot/Bitbucket, or leave those vars disabled) — the skip notices make +# the current state legible either way. Per-forge enablement remains the +# vars._MIRROR_ENABLED contract; a forge that is not wanted should +# be left disabled (skip notice) rather than enabled-and-failing. +# ============================================================================ + jobs: mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@2479cf769ed5f0481ccf64860a2ab954514c2b59 secrets: inherit permissions: actions: read contents: read security-events: write + + # #845 AC3: a run must state the denominator — mirrors enabled out of the + # seven configured forge integrations — so an unconfigured forge reads as + # "skipped", never as a mirror. Push verification by remote SHA (AC5) needs + # remote read access per forge and is tracked on the issue; this job + # reports what THIS repo has actually configured. + mirror-coverage: + name: Mirror coverage + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: mirror + if: always() + steps: + - name: Report configured forge mirrors + env: + GITLAB: ${{ vars.GITLAB_MIRROR_ENABLED }} + BITBUCKET: ${{ vars.BITBUCKET_MIRROR_ENABLED }} + CODEBERG: ${{ vars.CODEBERG_MIRROR_ENABLED }} + SOURCEHUT: ${{ vars.SOURCEHUT_MIRROR_ENABLED }} + DISROOT: ${{ vars.DISROOT_MIRROR_ENABLED }} + GITEA: ${{ vars.GITEA_MIRROR_ENABLED }} + RADICLE: ${{ vars.RADICLE_MIRROR_ENABLED }} + GITEA_HOST: ${{ vars.GITEA_HOST }} + run: | + set -euo pipefail + enabled=0 + total=7 + report() { + name="$1"; val="$2"; note="$3" + if [ "$val" = "true" ]; then + enabled=$((enabled + 1)) + echo "- ${name}: ENABLED ${note}" + else + echo "- ${name}: skipped (vars.${name}_MIRROR_ENABLED != 'true') ${note}" + fi + } + { + echo "## Forge mirror coverage" + echo "" + report GITLAB "$GITLAB" "" + report BITBUCKET "$BITBUCKET" "(deploy key must be registered on the forge or the job skips)" + report CODEBERG "$CODEBERG" "" + report SOURCEHUT "$SOURCEHUT" "" + report DISROOT "$DISROOT" "(deploy key must be registered on the forge or the job skips)" + if [ "$GITEA" = "true" ] && [ -z "$GITEA_HOST" ]; then + echo "- GITEA: ENABLED but vars.GITEA_HOST is empty — the reusable will refuse the push and name the variable (#845)" + fi + report GITEA "$GITEA" "(needs vars.GITEA_HOST + vars.GITEA_SSH_FINGERPRINT)" + report RADICLE "$RADICLE" "" + echo "" + echo "forge mirrors enabled: ${enabled} of ${total}" + } | tee -a "$GITHUB_STEP_SUMMARY" + echo "forge mirrors enabled: ${enabled} of ${total}" + # An empty denominator is a configuration death, not a clean run. + if [ "$enabled" -eq 0 ]; then + echo "::notice::0 of ${total} forge mirrors configured on this repo. Nothing is mirrored; this is legible, not a failure." + fi diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 4a96c9a5..037ce343 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -117,9 +117,13 @@ jobs: toolchain: stable components: rust-docs + # --locked: the committed Cargo.lock is a hard constraint (#841). A + # failure reading "the lock file ... needs to be updated but --locked + # was passed" means Cargo.lock is out of date w.r.t. the manifests -- + # regenerate it deliberately; never drop --locked to get green. - name: Generate Rust API documentation run: | - cargo doc --workspace --no-deps --document-private-items + cargo doc --workspace --no-deps --document-private-items --locked cp -r target/doc/* _site/api/ # NOTE: a "Generate Haskell API documentation" step (working-directory: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index dfda2ae9..a644d1ba 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -32,7 +32,12 @@ jobs: with: toolchain: stable - uses: Swatinem/rust-cache@v2.9.2 - - run: cargo check --workspace + # Every cargo invocation in this workflow passes --locked: the committed + # Cargo.lock is a hard constraint, not a hint (#841). A failure reading + # "the lock file ... needs to be updated but --locked was passed" means + # Cargo.lock is out of date w.r.t. the manifests -- regenerate it + # deliberately and commit it; never drop --locked to get green. + - run: cargo check --workspace --locked test: name: Test @@ -44,7 +49,7 @@ jobs: with: toolchain: stable - uses: Swatinem/rust-cache@v2.9.2 - - run: cargo test --workspace + - run: cargo test --workspace --locked fmt: name: Format @@ -69,7 +74,7 @@ jobs: toolchain: stable components: clippy - uses: Swatinem/rust-cache@v2.9.2 - - run: cargo clippy --workspace -- -D warnings + - run: cargo clippy --workspace --locked -- -D warnings rustci_check: name: Cargo check + clippy + fmt @@ -89,13 +94,13 @@ jobs: uses: Swatinem/rust-cache@v2.9.2 - name: Cargo check - run: cargo check --all-targets 2>&1 + run: cargo check --all-targets --locked 2>&1 - name: Cargo fmt run: cargo fmt --all -- --check - name: Cargo clippy - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --all-targets --locked -- -D warnings rustci_test: name: Cargo test @@ -116,7 +121,7 @@ jobs: uses: Swatinem/rust-cache@v2.9.2 - name: Run tests - run: cargo test --all-targets + run: cargo test --all-targets --locked - name: Write summary if: always() diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 0481ccd0..79a29fee 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,5 +1,5 @@ # This workflow is managed by gh actions-lock. -# SPDX-License-Identifier: PMPL-1.0-or-later +# SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. name: OSSF Scorecard diff --git a/.github/workflows/security-policy.yml b/.github/workflows/security-policy.yml index b32fae8b..43108a2a 100644 --- a/.github/workflows/security-policy.yml +++ b/.github/workflows/security-policy.yml @@ -1,8 +1,6 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 -# This workflow is managed by gh actions-lock. -# Consolidated workflow (behaviour-preserving merge). -# Merged from: security-audit.yml, security.yml +# Consolidated workflow. Merged from: security-audit.yml, security.yml name: Security on: @@ -25,386 +23,56 @@ concurrency: permissions: read-all -jobs: - rust-audit: - name: Rust Dependency Audit - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v7.0.1 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 - with: - toolchain: stable - - - name: Install cargo-audit - run: cargo install cargo-audit --locked - - - name: Run cargo audit - run: cargo audit --json > rust-audit.json || true - - - name: Check for vulnerabilities - run: | - if cargo audit 2>&1 | grep -q "Vulnerability"; then - echo "::warning::Security vulnerabilities found in Rust dependencies" - cargo audit - else - echo "No known vulnerabilities found" - fi - - - name: Upload audit results - uses: actions/upload-artifact@v7.0.1 - with: - name: rust-audit-results - path: rust-audit.json - retention-days: 30 - - rust-deny: - name: Rust License & Ban Check - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v7.0.1 - - - name: Install cargo-deny - uses: taiki-e/install-action@v2.87.18 - with: - tool: cargo-deny - - - name: Check licenses and bans - run: cargo deny check || true - - # ============================================================================ - # Secret Detection - # ============================================================================ - - secret-scan: - name: Secret Detection - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v7.0.1 - with: - fetch-depth: 0 - - # TruffleHog refuses to scan when base == head, exiting non-zero with - # "BASE and HEAD commits are the same. TruffleHog won't scan anything." - # The old inputs were `base: ` + `head: HEAD`. On a - # `pull_request` those legitimately differ, so the diff scan worked and - # the defect was invisible. On `push: main` HEAD *is* the default branch, - # so the range was EMPTY and the default branch went completely unscanned - # -- while the job merely looked "red", which reads like an auth problem - # rather than like zero coverage. - # - # This step resolves the range EXPLICITLY for every event, read off the - # action source at the pinned v3.97.5. That matters, because when both - # inputs are left empty the action does not fall through to one - # behaviour -- it dispatches on the event itself: - # push -> BASE=github.event.before - # HEAD=github.event.after - # ...and if github.event.commits is - # empty it prints "No commits to scan" - # and `exit 0`: a GREEN job that scanned - # nothing, the same lie in a different - # costume. - # schedule/workflow_dispatch -> BASE="", HEAD="" - # pull_request -> BASE=base.sha, HEAD=head.sha - # Passing a non-empty input takes the action's explicit branch instead, - # so nothing about push is left to that hidden dispatch and the silent - # `exit 0` is unreachable. - # - # The action always ends at `trufflehog git --since-commit $BASE - # --branch $HEAD`. Measured against the CLI: empty BASE/HEAD expand to - # empty-STRING arguments (not absent ones), which TruffleHog accepts as - # "all history, all branches" -- verified locally on a scratch repo, - # where that found a private key deleted from the worktree and surviving - # only in git history. - # - # So the coverage this buys, stated exactly: - # pull_request -> the PR's own commits - # push -> the commits the push introduced (everything reachable - # when the branch is new, since `before` is all-zeros) - # schedule -> whole git history, 02:00 daily and 03:00 Mondays - # Whole-history coverage comes from the SCHEDULED run, not from push. - # - # Context goes through `env:` rather than into the shell line, per - # .claude/CLAUDE.md -- a branch name is attacker-controllable text. - - name: Resolve TruffleHog scan range - id: th - env: - EVENT: ${{ github.event_name }} - PR_BASE: ${{ github.event.pull_request.base.sha }} - PR_HEAD: ${{ github.event.pull_request.head.sha }} - PUSH_BEFORE: ${{ github.event.before }} - PUSH_AFTER: ${{ github.event.after }} - run: | - set -uo pipefail - zero='0000000000000000000000000000000000000000' - case "$EVENT" in - pull_request) - base="$PR_BASE" - head="$PR_HEAD" - if [ -z "$base" ] || [ -z "$head" ] || [ "$base" = "$head" ]; then - echo "::error::Degenerate TruffleHog range (base='$base' head='$head'). Refusing to let an unscanned tree report as scanned." - exit 1 - fi - n=$(git rev-list --count "${base}..${head}" 2>/dev/null || echo 0) - echo "trufflehog mode: pull_request diff | range: ${base}..${head} | commits: ${n}" - ;; - push) - head="$PUSH_AFTER" - if [ -z "$head" ] || [ "$head" = "$zero" ]; then - echo "::error::push event with no head commit (after='${head}'). Nothing to scan is not the same as nothing found." - exit 1 - fi - if [ -z "$PUSH_BEFORE" ] || [ "$PUSH_BEFORE" = "$zero" ]; then - base='' - n=$(git rev-list --count "$head" 2>/dev/null || echo 0) - echo "trufflehog mode: push, new branch | range: (root)..${head} | commits: ${n}" - else - base="$PUSH_BEFORE" - if [ "$base" = "$head" ]; then - echo "::error::push event with before == after (${base}). An empty range is not a clean scan." - exit 1 - fi - n=$(git rev-list --count "${base}..${head}" 2>/dev/null || echo 0) - echo "trufflehog mode: push diff | range: ${base}..${head} | commits: ${n}" - fi - ;; - *) - base='' - head='' - n=$(git rev-list --count --all 2>/dev/null || echo 0) - echo "trufflehog mode: whole git history | commits: ${n}" - ;; - esac - if [ "${n:-0}" -eq 0 ]; then - echo "::error::0 commits in the resolved range. A scanner with an empty subject cannot report clean." - exit 1 - fi - echo "base=$base" >> "$GITHUB_OUTPUT" - echo "head=$head" >> "$GITHUB_OUTPUT" - - - name: TruffleHog Secret Scan - uses: trufflesecurity/trufflehog@v3.97.6 - with: - path: ./ - base: ${{ steps.th.outputs.base }} - head: ${{ steps.th.outputs.head }} - extra_args: --only-verified - - # `.gitleaks.toml` carries `[extend] path = ".gitleaks-estate.toml"`, and - # gitleaks resolves that against the PROCESS CWD. Only standards' - # secret-scanner-reusable.yml staged that file, so these two hand-rolled - # jobs died before scanning a single byte: - # FTL failed to load extended config ... no such file or directory - # the action then crashed on the results.sarif it never wrote, and the - # audit summary reported a CRASHED SCANNER as "possible secrets in - # repository" -- a fake red in the worst possible direction for a - # security gate. Pinned to the same standards sha that - # .github/workflows/secret-scanner.yml already consumes. - - name: Fetch estate gitleaks baseline - uses: actions/checkout@v7.0.1 - with: - repository: hyperpolymath/standards - ref: 571cc734cd69fb846032ec77a662aa8ee4fc32cd - path: .standards-gitleaks - sparse-checkout: | - config/gitleaks/estate-baseline.toml - sparse-checkout-cone-mode: false - persist-credentials: false - - - name: Stage estate gitleaks baseline - run: | - set -euo pipefail - cp .standards-gitleaks/config/gitleaks/estate-baseline.toml .gitleaks-estate.toml - # Delete the tooling checkout BEFORE scanning, or the scan walks - # standards' own tree and reports ITS files as hypatia's. - rm -rf .standards-gitleaks - test -s .gitleaks-estate.toml - - - name: Gitleaks Secret Scan - uses: gitleaks/gitleaks-action@v3.0.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - codeql: - name: CodeQL Analysis - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: ['actions'] - steps: - - name: Checkout repository - uses: actions/checkout@v7.0.1 - - - name: Initialize CodeQL - uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) - with: - languages: ${{ matrix.language }} - queries: security-extended,security-and-quality - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) - with: - category: "/language:${{matrix.language}}" - - # ============================================================================ - # SBOM Generation - # ============================================================================ - - sbom-rust: - name: Generate Rust SBOM - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repository - uses: actions/checkout@v7.0.1 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@v1 - with: - toolchain: stable - - - name: Install cargo-cyclonedx - run: cargo install cargo-cyclonedx --locked --version 0.5.9 - - - name: Generate CycloneDX SBOM - run: | - cargo cyclonedx --format json --override-filename rust-sbom.cdx - cargo cyclonedx --format xml --override-filename rust-sbom.cdx - - - name: Upload SBOM artifacts - uses: actions/upload-artifact@v7.0.1 - with: - name: rust-sbom - path: | - **/rust-sbom.cdx.json - **/rust-sbom.cdx.xml - retention-days: 90 - - # ============================================================================ - # Container Security Scanning - # ============================================================================ - - container-scan: - name: Container Security Scan - runs-on: ubuntu-latest - timeout-minutes: 30 - if: github.event_name != 'pull_request' - # The workflow default is `read-all`, and a job-level map REPLACES that - # default rather than merging with it -- so a job that uploads SARIF must - # spell out BOTH scopes. Without this the Trivy scan ran fine and only - # `upload-sarif` died with "Resource not accessible by integration", which - # this workflow then displayed as a failed Container Security Scan. That is - # the same confusion as the TruffleHog one: the scanner looked and found - # nothing wrong; it was the *filing* of the result that was denied. - permissions: - contents: read - security-events: write - steps: - - name: Checkout repository - uses: actions/checkout@v7.0.1 - - - name: Build test image - run: | - docker build -t hypatia:scan -f deploy/Containerfile . +# ============================================================================ +# DECISION RECORD — one scan tier, one aggregator (#847, #848, #849, #850, #851) +# +# This workflow used to run TWO near-duplicate tiers of the same scans +# (`rust-audit` vs `audit_rust-audit`, ...), fed by a behaviour-preserving +# merge of security-audit.yml and security.yml. Two aggregators then consulted +# different subsets, `container-scan` was consulted by nobody, and "did the +# security scan pass?" had two answers. Measured before consolidation: 14 jobs, +# 2 aggregators, 6 duplicated scan pairs. +# +# #847 AC1 — which tier survives, with the display names kept. The Tier-B +# bodies survive (they carry the caching, the estate gitleaks baseline +# staging, the Trivy matrix and the hardened aggregator), with the Tier-A +# fixes folded in. Jobs and display names after consolidation: +# +# rust-audit "Rust Dependency Audit" (findings fail the job, #849) +# rust-deny "Rust License & Ban Check" (findings fail the job, #849) +# secret-gitleaks "Secret Detection (Gitleaks)" +# secret-trufflehog "Secret Detection (TruffleHog)" (two-class policy, #848; +# scanner pinned, #851) +# codeql "CodeQL Analysis" +# container-scan "Container Security (Trivy)" (wired into the rollup, #850) +# license-check "License Compliance Check" +# sbom "Generate SBOM" +# security-status "Security Status" (the single aggregator) +# +# Display names RETIRED by this consolidation: "Secret Detection", +# "CodeQL SAST", "Generate Rust SBOM", "Container Security Scan", +# "Security Audit Summary". +# +# #847 AC2/AC3 — merge order and required contexts. Measured 2026-09-26: +# the only ACTIVE ruleset requiring status checks on the default branch is +# "ABI-Codegen-Drift" (`abi-codegen-drift`, `zig build test (FFI + wire +# contract)`); neither points at a security display name. The ruleset that +# once required "CodeQL Analysis (actions)" and "CodeQL SAST (actions)" +# (Optimus-Branch) is enforcement=disabled and no open PR predates the +# rename. If Optimus-Branch is ever re-enabled, its required contexts must +# be reconciled with the names above first. +# +# #850 AC4 — the aggregator self-checks that every non-aggregator job in +# this file is named in its `needs:`, so a future orphan job is caught +# mechanically rather than by reading. +# ============================================================================ - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: 'hypatia:scan' - format: 'sarif' - output: 'trivy-results.sarif' - severity: 'CRITICAL,HIGH' - - - name: Upload Trivy scan results - uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) - if: always() - with: - sarif_file: 'trivy-results.sarif' - - # ============================================================================ - # Security Status Summary - # ============================================================================ - - security-status: - name: Security Status - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: - - rust-audit - - rust-deny - - secret-scan - - codeql - - sbom-rust - if: always() - steps: - - name: Check security scan status - run: | - echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - declare -A jobs=( - ["rust-audit"]="${{ needs.rust-audit.result }}" - ["rust-deny"]="${{ needs.rust-deny.result }}" - ["secret-scan"]="${{ needs.secret-scan.result }}" - ["codeql"]="${{ needs.codeql.result }}" - ["sbom-rust"]="${{ needs.sbom-rust.result }}" - ) - - # This job is named "Security Status" and, until now, could not - # report a bad one: it computed `failed=true`, wrote a sentence into - # the step summary, and then ended the step successfully. Measured on - # run 35772115983, `Security Status` was GREEN while its own - # dependency `Secret Detection` was RED. A status check that cannot - # go red is decoration, and decoration that looks like a gate is - # worse than no gate -- so it now exits non-zero. - # - # None of the five dependencies carries an `if:`, so `skipped` can - # only mean an upstream failure or a cancelled run, and is counted as - # a non-success rather than warned about. - ok=0 - total=0 - failed=0 - for job in "${!jobs[@]}"; do - result="${jobs[$job]}" - total=$((total + 1)) - if [[ "$result" == "success" ]]; then - echo "- :white_check_mark: $job: $result" >> $GITHUB_STEP_SUMMARY - ok=$((ok + 1)) - else - # Says only what is known. `failure` means the job did not - # complete; it does NOT mean the scanner found something. - echo "- :x: $job: ${result:-} (did not complete -- no verdict)" >> $GITHUB_STEP_SUMMARY - failed=$((failed + 1)) - fi - done - - echo "" >> $GITHUB_STEP_SUMMARY - echo "security scans: ${ok} of ${total} completed successfully" >> $GITHUB_STEP_SUMMARY - echo "security scans: ${ok} of ${total} completed successfully" - - if [[ "$total" -eq 0 ]]; then - echo "::error::0 security scans were evaluated. A status with an empty denominator cannot report clean." - exit 1 - fi - - if [[ "$failed" -gt 0 ]]; then - echo "::error::${failed} of ${total} security scans did not complete. Missing evidence is not a pass." - exit 1 - fi +jobs: + # ========================================================================== + # Rust dependency & licence posture + # ========================================================================== - audit_rust-audit: + rust-audit: name: Rust Dependency Audit runs-on: ubuntu-latest timeout-minutes: 30 @@ -429,30 +97,41 @@ jobs: - name: Install cargo-audit run: cargo install cargo-audit --locked - - name: Run cargo audit (JSON output) - run: cargo audit --json > rust-audit-report.json || true - - - name: Run cargo audit (human-readable) - id: audit + # #849: this job used to end with `exit 0 # Don't fail yet, let the + # summary job decide`. The deferral never happened -- the summary reads + # job *results*, not step outputs, so the scan was green whatever it + # found. The job now fails on its own findings, which is the path the + # old comment promised. No `|| true`, no unconditional `exit 0`. + - name: Run cargo audit run: | - if cargo audit 2>&1 | grep -q "Vulnerability"; then - echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT - echo "::warning::Security vulnerabilities found in Rust dependencies" + set -uo pipefail + set +e + cargo audit --json > rust-audit-report.json + rc=$? + set -e + if [ ! -s rust-audit-report.json ] || ! jq -e '.vulnerabilities' rust-audit-report.json >/dev/null 2>&1; then + echo "::error::cargo audit produced no parsable report (exit ${rc}). A crashed scanner is not a clean scan." + exit 1 + fi + vulns=$(jq -r '.vulnerabilities.count // 0' rust-audit-report.json) + warn_classes=$(jq -r '.warnings | keys | length // 0' rust-audit-report.json) + echo "cargo-audit advisory database scan: vulnerabilities=${vulns} warning-classes=${warn_classes} (report: rust-audit-report.json)" + echo "advisories found: ${vulns} | warning classes: ${warn_classes}" + if [ "${vulns}" -gt 0 ]; then + echo "::error::cargo audit found ${vulns} vulnerable advisories in the dependency tree. This job fails on its own findings (#849)." cargo audit - exit 0 # Don't fail yet, let the summary job decide - else - echo "vulnerabilities_found=false" >> $GITHUB_OUTPUT - echo "No known vulnerabilities found" + exit 1 fi - name: Upload audit results + if: always() uses: actions/upload-artifact@v7.0.1 with: name: rust-audit-report path: rust-audit-report.json retention-days: 30 - audit_rust-deny: + rust-deny: name: Rust License & Ban Check runs-on: ubuntu-latest timeout-minutes: 30 @@ -511,86 +190,29 @@ jobs: EOF fi + # #849: the old step ran `cargo deny check ... || true`, discarding the + # verdict at the source. pipefail + no suppression: a denied licence or + # advisory fails this job on its own findings. The report is uploaded + # either way so the evidence survives a red run. - name: Check licenses and bans - run: cargo deny check 2>&1 | tee cargo-deny-report.txt || true + run: | + set -euo pipefail + cargo deny check 2>&1 | tee cargo-deny-report.txt + echo "cargo-deny checked: advisories, bans, licenses, sources (report: cargo-deny-report.txt)" - name: Upload deny results + if: always() uses: actions/upload-artifact@v7.0.1 with: name: cargo-deny-report path: cargo-deny-report.txt retention-days: 30 - # ============================================================================ - # Haskell Security Audit - # Container Security Scanning - # ============================================================================ - - audit_container-scan: - name: Container Security (Trivy) - runs-on: ubuntu-latest - timeout-minutes: 30 - # Same fault, same cure as `container-scan` above: uploading SARIF needs - # `security-events: write`, and a job-level map replaces the workflow's - # `read-all` rather than adding to it. - permissions: - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - # Only deploy/Containerfile is currently in the tree; the adapter / - # engine / registry variants are part of an earlier multi-image - # split that hasn't landed yet. Add them back here when they exist. - dockerfile: - - deploy/Containerfile - steps: - - name: Checkout repository - uses: actions/checkout@v7.0.1 - - - name: Extract image name from Containerfile path - id: image - run: | - dockerfile="${{ matrix.dockerfile }}" - name=$(basename "$dockerfile" | sed 's/Containerfile/hypatia/' | sed 's/^\.//' | sed 's/^-//') - if [ "$name" = "hypatia" ]; then - name="hypatia-main" - fi - echo "name=$name" >> $GITHUB_OUTPUT - - - name: Build container image - run: | - docker build -t ${{ steps.image.outputs.name }}:scan -f ${{ matrix.dockerfile }} . - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: '${{ steps.image.outputs.name }}:scan' - format: 'sarif' - output: 'trivy-${{ steps.image.outputs.name }}.sarif' - severity: 'CRITICAL,HIGH,MEDIUM' - ignore-unfixed: true - - - name: Run Trivy (table output) - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: '${{ steps.image.outputs.name }}:scan' - format: 'table' - severity: 'CRITICAL,HIGH' - ignore-unfixed: true + # ========================================================================== + # Secret detection + # ========================================================================== - - name: Upload Trivy SARIF results - uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) - if: always() - with: - sarif_file: 'trivy-${{ steps.image.outputs.name }}.sarif' - category: 'trivy-${{ steps.image.outputs.name }}' - - # ============================================================================ - # Secret Detection - # ============================================================================ - - gitleaks: + secret-gitleaks: name: Secret Detection (Gitleaks) runs-on: ubuntu-latest timeout-minutes: 30 @@ -648,14 +270,14 @@ jobs: # `.gitleaks.toml` carries `[extend] path = ".gitleaks-estate.toml"`, and # gitleaks resolves that against the PROCESS CWD. Only standards' - # secret-scanner-reusable.yml staged that file, so these two hand-rolled - # jobs died before scanning a single byte: + # secret-scanner-reusable.yml staged that file, so the hand-rolled job + # died before scanning a single byte: # FTL failed to load extended config ... no such file or directory # the action then crashed on the results.sarif it never wrote, and the - # audit summary reported a CRASHED SCANNER as "possible secrets in - # repository" -- a fake red in the worst possible direction for a - # security gate. Pinned to the same standards sha that - # .github/workflows/secret-scanner.yml already consumes. + # summary reported a CRASHED SCANNER as "possible secrets in repository" + # -- a fake red in the worst possible direction for a security gate. + # Pinned to the same standards sha that .github/workflows/secret-scanner.yml + # already consumes. - name: Fetch estate gitleaks baseline uses: actions/checkout@v7.0.1 with: @@ -676,16 +298,33 @@ jobs: rm -rf .standards-gitleaks test -s .gitleaks-estate.toml - - name: Run Gitleaks + - name: Gitleaks Secret Scan uses: gitleaks/gitleaks-action@v3.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - trufflehog: + secret-trufflehog: name: Secret Detection (TruffleHog) runs-on: ubuntu-latest timeout-minutes: 30 + env: + # Two separate pins live in this job and they bump TOGETHER: + # 1. `uses: trufflesecurity/trufflehog@v3.97.6` -- the action wrapper + # 2. `TRUFFLEHOG_VERSION` / `version:` input below -- the SCANNER + # (the container image the wrapper runs) + # The wrapper's final command is `docker run ... "${IMAGE}:${VERSION}"`, + # and its `version` input defaults to `latest`, so pinning only the + # action (what we did before, #851) left the detector itself floating: + # measured on PR #846, the action pin said v3.97.5 while the log said + # "trufflehog_version": "3.97.6". Detector behaviour IS this gate's + # verdict; it must be reproducible. + # NOTE: a digest reference (`image@sha256:...`) is NOT expressible + # through this action -- it unconditionally appends ":${VERSION}" to + # whatever `image:` holds. The explicit tag below is therefore the + # strongest scanner pin the wrapper supports; the version step proves + # on every run what actually executed. + TRUFFLEHOG_VERSION: '3.97.6' steps: - name: Checkout repository uses: actions/checkout@v7.0.1 @@ -702,7 +341,7 @@ jobs: # rather than like zero coverage. # # This step resolves the range EXPLICITLY for every event, read off the - # action source at the pinned v3.97.5. That matters, because when both + # action source at the pinned v3.97.6. That matters, because when both # inputs are left empty the action does not fall through to one # behaviour -- it dispatches on the event itself: # push -> BASE=github.event.before @@ -790,20 +429,102 @@ jobs: echo "base=$base" >> "$GITHUB_OUTPUT" echo "head=$head" >> "$GITHUB_OUTPUT" - - name: TruffleHog Secret Scan + # #851 AC3: the run must show which scanner actually executed, so drift + # between the pins is visible in the run and not only in this file. + - name: Report scanner version actually run + run: | + set -euo pipefail + docker pull "ghcr.io/trufflesecurity/trufflehog:${TRUFFLEHOG_VERSION}" + docker run --rm "ghcr.io/trufflesecurity/trufflehog:${TRUFFLEHOG_VERSION}" --version + + # ====================================================================== + # SECRET-GATE POLICY (#848) -- stated, not implied by a filter flag. + # + # A leak must be able to redden this gate. `--only-verified` alone + # cannot do that: it reports only credentials TruffleHog can verify + # against a live service, and a leaked PRIVATE KEY -- the single + # highest-severity thing a secret scanner exists to find -- cannot be + # verified against any endpoint. Measured: a real 2048-bit RSA key + # deleted from the worktree but present in history gave + # `verified_secrets: 0, unverified_secrets: 3`, and the old gate was + # GREEN. Two passes resolve the tension #848 names (drop the filter + # outright and false positives flood a gate people then ignore): + # + # Gate A (verified class) -- findings of ANY detector type whose + # credentials TruffleHog could VERIFY against the live service + # fail the build. (--results=verified, the supported spelling of + # the hidden --only-verified alias.) + # + # Gate B (non-verifiable high-severity class) -- findings of the + # PrivateKey detector fail the build REGARDLESS of verification + # status, because key material cannot be verified against an + # endpoint and "unverified" here means "cannot be checked", not + # "probably fine". + # + # Everything else unverified -- reported in the denominator below, + # NOT gating. This is the deliberate middle course: the gate is + # quiet on speculative generic-credential noise but cannot be + # green while private-key material sits in history. + # + # Accepted-noise exclusions: NONE. Every future exclusion must be + # written here as an explicit `--exclude-detectors= -- ` + # line; an exclusion that lives only in someone's head is not a policy. + # ====================================================================== + + - name: Gate A — verified credentials fail the gate uses: trufflesecurity/trufflehog@v3.97.6 with: path: ./ base: ${{ steps.th.outputs.base }} head: ${{ steps.th.outputs.head }} - extra_args: --only-verified + version: ${{ env.TRUFFLEHOG_VERSION }} + extra_args: --results=verified - # ============================================================================ - # SAST (Static Application Security Testing) - # ============================================================================ + - name: Gate B — private keys fail the gate even when unverified + uses: trufflesecurity/trufflehog@v3.97.6 + with: + path: ./ + base: ${{ steps.th.outputs.base }} + head: ${{ steps.th.outputs.head }} + version: ${{ env.TRUFFLEHOG_VERSION }} + extra_args: --include-detectors=PrivateKey --results=verified,unverified,unknown + + # #848 AC4: a zero-finding run must be distinguishable from a run that + # looked at nothing. The range step prints the scan subject (commits); + # this step prints what the scan returned, by verification status. It + # is informational (exit 0 by design): the two gates above own the + # verdicts, and this pass exists so a red or green run can be read. + # Cost note: one extra scan of the same range. The gates cannot be + # merged into one action pass without over-failing (one pass cannot + # say "verified anything OR unverified private keys" without also + # failing on every unverified generic hit), and the wrapper cannot + # emit a countable machine-readable output without a dedicated pass. + - name: Findings denominator (informational) + env: + TH_BASE: ${{ steps.th.outputs.base }} + TH_HEAD: ${{ steps.th.outputs.head }} + run: | + set -uo pipefail + args=(git file:///tmp/ --no-update --json --results=verified,unverified,unknown,filtered_unverified) + if [ -n "$TH_BASE" ]; then args+=(--since-commit "$TH_BASE"); fi + if [ -n "$TH_HEAD" ]; then args+=(--branch "$TH_HEAD"); fi + docker run --rm -v .:/tmp -w /tmp "ghcr.io/trufflesecurity/trufflehog:${TRUFFLEHOG_VERSION}" "${args[@]}" > th-findings.json + if [ ! -s th-findings.json ]; then + echo "trufflehog findings: 0 (scanner ran over the resolved range and returned nothing)" + else + v=$(grep -c '"Verified": *true' th-findings.json || true) + u=$(grep -c '"Verified": *false' th-findings.json || true) + pk=$(grep -c '"DetectorName": *"PrivateKey"' th-findings.json || true) + echo "trufflehog findings: verified=${v} unverified_or_unknown=${u} (of which PrivateKey: ${pk})" + fi + echo "scanner: ghcr.io/trufflesecurity/trufflehog:${TRUFFLEHOG_VERSION} | range commits: $(git rev-list --count --all 2>/dev/null || echo '?')" + + # ========================================================================== + # SAST + # ========================================================================== - audit_codeql: - name: CodeQL SAST + codeql: + name: CodeQL Analysis runs-on: ubuntu-latest timeout-minutes: 60 permissions: @@ -828,9 +549,81 @@ jobs: with: category: "/language:${{matrix.language}}" - # ============================================================================ - # License Compliance - # ============================================================================ + # ========================================================================== + # Container security + # ========================================================================== + + # #850: this job used to exist twice -- `container-scan` (consulted by NO + # aggregator: its failure reached nothing) and `audit_container-scan`. + # One job now, named in the single aggregator's `needs:`, with the + # self-check below making a future orphan mechanical. + container-scan: + name: Container Security (Trivy) + runs-on: ubuntu-latest + timeout-minutes: 30 + # The workflow default is `read-all`, and a job-level map REPLACES that + # default rather than merging with it -- so a job that uploads SARIF must + # spell out BOTH scopes. Without this the Trivy scan ran fine and only + # `upload-sarif` died with "Resource not accessible by integration", + # which this workflow then displayed as a failed container scan. That is + # the same confusion as the TruffleHog one: the scanner looked and found + # nothing wrong; it was the *filing* of the result that was denied. + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + # Only deploy/Containerfile is currently in the tree; the adapter / + # engine / registry variants are part of an earlier multi-image + # split that hasn't landed yet. Add them back here when they exist. + dockerfile: + - deploy/Containerfile + steps: + - name: Checkout repository + uses: actions/checkout@v7.0.1 + + - name: Extract image name from Containerfile path + id: image + run: | + dockerfile="${{ matrix.dockerfile }}" + name=$(basename "$dockerfile" | sed 's/Containerfile/hypatia/' | sed 's/^\.//' | sed 's/^-//') + if [ "$name" = "hypatia" ]; then + name="hypatia-main" + fi + echo "name=$name" >> $GITHUB_OUTPUT + + - name: Build container image + run: | + docker build -t ${{ steps.image.outputs.name }}:scan -f ${{ matrix.dockerfile }} . + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: '${{ steps.image.outputs.name }}:scan' + format: 'sarif' + output: 'trivy-${{ steps.image.outputs.name }}.sarif' + severity: 'CRITICAL,HIGH,MEDIUM' + ignore-unfixed: true + + - name: Run Trivy (table output) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: '${{ steps.image.outputs.name }}:scan' + format: 'table' + severity: 'CRITICAL,HIGH' + ignore-unfixed: true + + - name: Upload Trivy SARIF results + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 (4.38.1 blocked estate-wide; nexia-list#100) + if: always() + with: + sarif_file: 'trivy-${{ steps.image.outputs.name }}.sarif' + category: 'trivy-${{ steps.image.outputs.name }}' + + # ========================================================================== + # Licence compliance + # ========================================================================== license-check: name: License Compliance Check @@ -890,6 +683,7 @@ jobs: echo "**Total files missing SPDX header: $missing_headers**" >> license-report.md cat license-report.md + echo "spdx headers checked; missing: ${missing_headers} (report: license-report.md)" if [ $missing_headers -gt 0 ]; then echo "::warning::$missing_headers files are missing SPDX license headers" @@ -902,11 +696,11 @@ jobs: path: license-report.md retention-days: 30 - # ============================================================================ - # SBOM Generation - # ============================================================================ + # ========================================================================== + # SBOM generation + # ========================================================================== - sbom-generation: + sbom: name: Generate SBOM runs-on: ubuntu-latest timeout-minutes: 30 @@ -922,48 +716,85 @@ jobs: - name: Install cargo-cyclonedx run: cargo install cargo-cyclonedx --locked --version 0.5.9 - - name: Generate Rust SBOM + - name: Generate CycloneDX SBOM run: | - cargo cyclonedx --format json --override-filename sbom-rust.cdx || true - cargo cyclonedx --format xml --override-filename sbom-rust.cdx || true - + set -euo pipefail + cargo cyclonedx --format json --override-filename rust-sbom.cdx + cargo cyclonedx --format xml --override-filename rust-sbom.cdx + echo "sbom generated (cyclonedx), files:" + find . -name 'rust-sbom.cdx.*' -print - name: Upload SBOM artifacts uses: actions/upload-artifact@v7.0.1 with: name: sbom path: | - **/sbom-rust.cdx.json - **/sbom-rust.cdx.xml + **/rust-sbom.cdx.json + **/rust-sbom.cdx.xml retention-days: 90 - # ============================================================================ - # Security Audit Summary - # ============================================================================ + # ========================================================================== + # The single aggregator + # ========================================================================== - audit-summary: - name: Security Audit Summary + security-status: + name: Security Status runs-on: ubuntu-latest timeout-minutes: 30 + # #847 AC4: one aggregator consults EVERY security job. The list below is + # every job in this workflow except this one; the self-check step asserts + # that mechanically, so the two cannot drift apart silently (#850 AC4). needs: - - audit_rust-audit - - audit_rust-deny - - audit_container-scan - - gitleaks - - trufflehog - - audit_codeql + - rust-audit + - rust-deny + - secret-gitleaks + - secret-trufflehog + - codeql + - container-scan - license-check - - sbom-generation + - sbom if: always() steps: - - name: Generate audit summary + # #850 AC4: every job in this workflow except the aggregators must be + # named in some aggregator's `needs:`. Parse the job keys out of this + # very file and compare; a future orphan job fails HERE rather than + # being discovered as an unmonitored gate. + - name: "Self-check — no orphan jobs (every job is in this needs list)" + run: | + set -euo pipefail + wf=.github/workflows/security-policy.yml + # Top-level job keys: two-space indent under `jobs:`, nothing deeper. + mapfile -t all_jobs < <(awk '/^jobs:/{f=1;next} f && /^[^ #]/{exit} f && /^ [A-Za-z0-9_-]+:$/ {sub(/^[[:space:]]+/,""); sub(/:$/,""); print}' "$wf") + needs_list=$(sed -n '/^ needs:$/,/^ if:/p' "$wf" | sed -n 's/^ - //p') + failed=0 + echo "jobs in workflow: ${#all_jobs[@]}" + echo "aggregator needs:" + echo "$needs_list" | sed 's/^/ - /' + for j in "${all_jobs[@]}"; do + [ "$j" = "security-status" ] && continue + if ! grep -qx -- "$j" <<<"$needs_list"; then + echo "::error::orphan job '$j' is consulted by no aggregator -- its failure reaches nothing (#850)." + failed=1 + fi + done + for j in $needs_list; do + if ! grep -qx -- "$j" <<<"$(printf '%s\n' "${all_jobs[@]}")"; then + echo "::error::aggregator needs '$j' but no such job exists in this workflow (#847)." + failed=1 + fi + done + if [ "$failed" -ne 0 ]; then + exit 1 + fi + echo "ok: every non-aggregator job is named in the aggregator's needs" + + - name: Security scan report and verdict run: | - echo "## Security Audit Summary" >> $GITHUB_STEP_SUMMARY + echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY - # Function to map status status_icon() { case "$1" in success) echo ":white_check_mark:" ;; @@ -974,46 +805,39 @@ jobs: esac } - echo "| Rust Audit | $(status_icon '${{ needs.audit_rust-audit.result }}') ${{ needs.audit_rust-audit.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Rust Deny | $(status_icon '${{ needs.audit_rust-deny.result }}') ${{ needs.audit_rust-deny.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Container Scan | $(status_icon '${{ needs.audit_container-scan.result }}') ${{ needs.audit_container-scan.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| Gitleaks | $(status_icon '${{ needs.gitleaks.result }}') ${{ needs.gitleaks.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| TruffleHog | $(status_icon '${{ needs.trufflehog.result }}') ${{ needs.trufflehog.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| CodeQL | $(status_icon '${{ needs.audit_codeql.result }}') ${{ needs.audit_codeql.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| License Check | $(status_icon '${{ needs.license-check.result }}') ${{ needs.license-check.result }} |" >> $GITHUB_STEP_SUMMARY - echo "| SBOM Generation | $(status_icon '${{ needs.sbom-generation.result }}') ${{ needs.sbom-generation.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Rust Dependency Audit | $(status_icon '${{ needs.rust-audit.result }}') ${{ needs.rust-audit.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Rust License & Ban Check | $(status_icon '${{ needs.rust-deny.result }}') ${{ needs.rust-deny.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Secret Detection (Gitleaks) | $(status_icon '${{ needs.secret-gitleaks.result }}') ${{ needs.secret-gitleaks.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Secret Detection (TruffleHog) | $(status_icon '${{ needs.secret-trufflehog.result }}') ${{ needs.secret-trufflehog.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| CodeQL Analysis | $(status_icon '${{ needs.codeql.result }}') ${{ needs.codeql.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Container Security (Trivy) | $(status_icon '${{ needs.container-scan.result }}') ${{ needs.container-scan.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| License Compliance Check | $(status_icon '${{ needs.license-check.result }}') ${{ needs.license-check.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Generate SBOM | $(status_icon '${{ needs.sbom.result }}') ${{ needs.sbom.result }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY echo "_Audit completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)_" >> $GITHUB_STEP_SUMMARY - # Two faults lived in this step, both of which made a broken security - # gate read as a working one: - # - # 1. It inspected 3 of its 8 `needs:`. The table above DISPLAYS all eight; - # displaying a dependency is not gating on it. A failing container scan - # was rendered as :x: and then silently ignored by the decision. - # 2. `failure` means "this job did not complete". It NEVER means "this job - # found something" -- a scanner that crashed, timed out, or was refused - # a credential reports the identical result to one that found a secret. - # The old text asserted "TruffleHog found verified secrets" whenever the - # job was red, which manufactured a detection claim out of a scanner - # that had scanned zero bytes. Fake alarms train people to ignore the - # check; the same confusion lets a genuinely broken scanner read clean. - # - # All eight dependencies are unconditional (none carries an `if:`), so - # `skipped` here can only mean an upstream failure or a cancelled run. - # It is therefore failed on, not warned about: `::warning::` cannot fail a - # job, so warning while calling a skip "not a pass" asserts the opposite. - - name: Check for failures - run: | + # Two faults this gate historically carried, both of which made a + # broken security gate read as a working one: + # + # 1. It inspected a subset of its `needs:`. Displaying a dependency + # is not gating on it -- a failing scan was rendered and then + # silently ignored by the decision. + # 2. `failure` means "this job did not complete". It NEVER means + # "this job found something" -- a scanner that crashed, timed + # out, or was refused a credential reports the identical result + # to one that found a secret. Say "did not complete -- no + # verdict", never "found secrets". + # + # All dependencies are unconditional (none carries an `if:`), so + # `skipped` can only mean an upstream failure or a cancelled run, + # and is failed on, not warned about. set -uo pipefail ok=0 total=0 failed=0 - # Says only what is known: the job did not complete, so the evidence - # that check was supposed to produce is missing. Never a detection. check() { total=$((total + 1)) case "$2" in @@ -1027,17 +851,17 @@ jobs: esac } - check "Rust audit" "${{ needs.audit_rust-audit.result }}" - check "Rust deny" "${{ needs.audit_rust-deny.result }}" - check "Container scan" "${{ needs.audit_container-scan.result }}" - check "Gitleaks" "${{ needs.gitleaks.result }}" - check "TruffleHog" "${{ needs.trufflehog.result }}" - check "CodeQL" "${{ needs.audit_codeql.result }}" - check "License check" "${{ needs.license-check.result }}" - check "SBOM generation" "${{ needs.sbom-generation.result }}" + check "Rust dependency audit" "${{ needs.rust-audit.result }}" + check "Rust licence & ban check" "${{ needs.rust-deny.result }}" + check "Gitleaks" "${{ needs.secret-gitleaks.result }}" + check "TruffleHog" "${{ needs.secret-trufflehog.result }}" + check "CodeQL" "${{ needs.codeql.result }}" + check "Container scan (Trivy)" "${{ needs.container-scan.result }}" + check "Licence compliance check" "${{ needs.license-check.result }}" + check "SBOM generation" "${{ needs.sbom.result }}" - # The denominator is the point: "0 of 0 succeeded" must never be green. echo "security checks: ${ok} of ${total} succeeded" + echo "security checks: ${ok} of ${total} succeeded" >> $GITHUB_STEP_SUMMARY if [ "$total" -eq 0 ]; then echo "::error::0 security checks were evaluated. A rollup with an empty denominator cannot report clean." diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 57b3251c..98836299 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -479,9 +479,12 @@ jobs: # downloads and loads the same `docker-images` artifact. # # If a full-stack compose tier is ever wanted, it needs a server to test - # first, plus `integration/fixtures/mock-server-config/`, which the compose - # file mounts but which is absent from git. Reinstate it together with those, - # not before. See issue #843. + # first. The compose scaffolding this comment used to name + # (`integration/compose.test.yaml`, `integration/run-tests.sh`, + # `integration/Containerfile.test`) was itself deleted 2026-09-26 (issue + # #853): it was CI-dead and bind-mounted `integration/fixtures/ + # mock-server-config/`, which never existed in git. Reinstate together with + # a real subject and real fixtures, not before. See issues #843, #853. # ============================================================================ # ============================================================================ diff --git a/.github/workflows/verify-proofs.yml b/.github/workflows/verify-proofs.yml index 822862b6..5e43f63d 100644 --- a/.github/workflows/verify-proofs.yml +++ b/.github/workflows/verify-proofs.yml @@ -14,6 +14,11 @@ on: - verification/proofs/idris2/** - verification/proofs/lean4/** - verification/proofs/tlaplus/** + - verification/PROOF-STATUS.adoc + - scripts/check-proof-status.sh + - scripts/check-trusted-base.sh + - docs/proof-debt.adoc + - test/soundness/** - src/abi/** - src/Hypatia/ABI/** - verify/** @@ -27,6 +32,11 @@ on: - verification/proofs/idris2/** - verification/proofs/lean4/** - verification/proofs/tlaplus/** + - verification/PROOF-STATUS.adoc + - scripts/check-proof-status.sh + - scripts/check-trusted-base.sh + - docs/proof-debt.adoc + - test/soundness/** - src/abi/** - src/Hypatia/ABI/** - verify/** @@ -72,16 +82,18 @@ jobs: uses: actions/cache@v6.1.0 with: path: | - /usr/local/bin/idris2 - /usr/local/bin/idris2_app - /usr/local/lib/idris2 ~/.idris2 + # ONE prefix (#820). The old cache hedged across /usr/local AND + # ~/.idris2 because the bootstrap split the world in two; the old + # entries are populated under that split layout, so the key is + # bumped to -3 to discard them. Only the prefix the binary + # actually reads is cached now. # `idris2` is only a launcher script; it execs its sibling - # idris2_app/idris2.so. The previous cache omitted idris2_app/, so - # every cache *hit* restored a launcher with no image and died with - # `idris2_app/idris2.so: not found` (exit 127) before any proof ran. - # Key bumped to -2 to discard those poisoned caches. - key: idris2-${{ env.IDRIS2_VERSION }}-${{ runner.os }}-2 + # idris2_app/idris2.so. An earlier cache omitted idris2_app/, so + # every cache *hit* restored a launcher with no image and died + # with `idris2_app/idris2.so: not found` (exit 127) before any + # proof ran. ~/.idris2/bin/{idris2,idris2_app} travels together. + key: idris2-${{ env.IDRIS2_VERSION }}-${{ runner.os }}-3 - name: Build Idris 2 from source if: steps.cache-idris.outputs.cache-hit != 'true' @@ -91,9 +103,37 @@ jobs: https://github.com/idris-lang/Idris2 /tmp/idris2 cd /tmp/idris2 make bootstrap SCHEME=chezscheme - sudo make install PREFIX=/usr/local + # ONE prefix (#820): `make bootstrap` bakes the DEFAULT prefix + # ($HOME/.idris2) into the binary, so install there too. The old + # `sudo make install PREFIX=/usr/local` wrote the trees under + # /usr/local while the binary resolved `--libdir` to + # ~/.idris2/idris2-0.7.0 -- which held neither + # support/chez/support.ss nor lib/libidris2_support.so. Pure + # library work (--check, --build of .ipkg) never links an + # executable and so never noticed; the first job that ran a + # binary died with "INTERNAL ERROR: Can't find data file + # chez/support.ss". Installing to the prefix the binary reads + # removes the mismatch and the need for sudo. + make install PREFIX="$HOME/.idris2" idris2 --version + - name: Put Idris 2 on PATH + run: echo "$HOME/.idris2/bin" >> "$GITHUB_PATH" + + # #820 AC1: measure, do not infer. This step prints what prefix the + # binary reports and what each candidate location actually holds, so + # the layout question is answered by a run rather than by a comment. + - name: Measure Idris2 prefix layout + run: | + set -uo pipefail + echo "idris2 --prefix : $(idris2 --prefix 2>&1)" + echo "idris2 --libdir : $(idris2 --libdir 2>&1)" + echo "--- /usr/local/idris2-0.7.0/ ---"; ls /usr/local/idris2-0.7.0/ 2>&1 || echo "ABSENT" + echo "--- ~/.idris2/idris2-0.7.0/ ---"; ls ~/.idris2/idris2-0.7.0/ 2>&1 || echo "ABSENT" + echo "--- artefacts the executable path needs ---" + ls "$(idris2 --libdir)/support/chez/support.ss" 2>&1 || echo "MISSING: /support/chez/support.ss" + ls "$(idris2 --libdir)/lib/libidris2_support.so" 2>&1 || echo "MISSING: /lib/libidris2_support.so" + - name: Verify Idris is on PATH run: idris2 --version @@ -257,3 +297,36 @@ jobs: name: tlc-log path: verification/proofs/tlaplus/tlc.log retention-days: 14 + + # -------------------------------------------------------------------------- + # Currency gates for the two status documents. A proof-status document that + # is wrong is worse than one that is missing: it is read as evidence (#816, + # #831). Both scripts are proven by mutant (rename a proof / plant an + # un-annotated marker -> red; revert -> green). + # -------------------------------------------------------------------------- + + proof-status: + name: PROOF-STATUS currency + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: PROOF-STATUS.adoc agrees with the tree + run: bash scripts/check-proof-status.sh + + check-trusted-base: + name: check-trusted-base + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: No un-annotated trusted-base markers + run: bash scripts/check-trusted-base.sh diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 00000000..fb63b500 --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,21 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: hypatia +Source: https://github.com/hyperpolymath/hypatia + +Files: *.adoc *.md docs/* .audittraining/* .claude/* .machine_readable/* +Copyright: 2026 Jonathan D.A. Jewell +License: CC-BY-SA-4.0 +Comment: Documentation, design notes and training corpora follow the + README's documentation licence. Files that carry their own line-1 SPDX + header take precedence over this entry (REUSE file-header rule). + +Files: integration/fixtures/test-repo/src/main.rs +Copyright: 2026 Jonathan D.A. Jewell +License: MPL-2.0 +Comment: Deliberately headerless — this fixture exists so missing-SPDX + detection has something to detect. Its licence is recorded here instead. + +Files: integration/fixtures/* +Copyright: 2026 Jonathan D.A. Jewell +License: MPL-2.0 +Comment: Test fixtures. diff --git a/AFFIRMATION.adoc b/AFFIRMATION.adoc index a4169455..aef7547a 100644 --- a/AFFIRMATION.adoc +++ b/AFFIRMATION.adoc @@ -20,11 +20,22 @@ state and commitments. Companion detail: `guix.scm` / guix channels are the sole reproducible-build manifests. No `flake.nix` remains; all references reconciled. * *The formal-proof corpus verifies and is CI-gated* across Idris 2 (v0.7.0), - Lean 4 (v4.30.0) and TLA+ — zero escape hatches - (`believe_me` / `sorry` / `postulate` / `assert_total` / `admit` / - `native_decide`). One obligation remains open — neural-convergence - (PageRank + ESN) — blocked only on Mathlib network access; the handover is - at `docs/proofs/HANDOVER-neural-convergence.md`. + Lean 4 (v4.30.0) and TLA+ — *zero escape hatches outside + `test/soundness/fixtures/`* (`believe_me` / `sorry` / `postulate` / + `assert_total` / `admit` / `native_decide`). Re-measured 2026-09-26 over + the git-tracked tree by `scripts/check-trusted-base.sh`: outside that + fixture directory the only matches are comment lines, one carrying the + explicit `-- hypatia: allow` pragma + (`src/Hypatia/ABI/RuleEngine.idr:19`); 0 `assert_total`, 0 `postulate`, + 0 `%hint`, 0 `native_decide`, 0 `admit` in code. Inside + `test/soundness/fixtures/` the escape-hatch spellings occur *by design* — + each file is a known-bad scanner sample whose header says DO NOT FIX, and + the `check-trusted-base` CI job fails if one loses its fixture status or + an un-annotated marker appears elsewhere. (An earlier revision of this + sentence claimed "zero escape hatches" unscoped, which the fixture corpus + made literally false; see #831.) One obligation remains open — + neural-convergence (PageRank + ESN) — blocked only on Mathlib network + access; the handover is at `docs/proofs/HANDOVER-neural-convergence.md`. * *The unified-api-adapter wire contract cannot silently drift.* The Zig enum, the Idris2 ABI and the Rust client are pinned to one golden source (`ffi/connectors.json`) by `test/unified-api-adapter-contract_test.exs` under diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..be8498a0 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: CC-BY-SA-4.0 +cff-version: 1.2.0 +message: >- + If you use this software, please cite it as below. +title: "Hypatia: the neurosymbolic CI/CD governance scanner" +type: software +authors: + - family-names: Jewell + given-names: Jonathan D.A. + email: j.d.a.jewell@open.ac.uk +repository-code: "https://github.com/hyperpolymath/hypatia" +license: MPL-2.0 +abstract: >- + Hypatia is the neurosymbolic CI/CD governance scanner for the + hyperpolymath estate: Elixir rule engine, Rust workspace, and + machine-checked Idris2/Lean/TLA+ proof debt tracking. +keywords: + - ci-cd + - governance + - static-analysis + - neurosymbolic + - supply-chain diff --git a/LICENSES/AGPL-3.0-or-later.txt b/LICENSES/AGPL-3.0-or-later.txt deleted file mode 100644 index be3f7b28..00000000 --- a/LICENSES/AGPL-3.0-or-later.txt +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/README.adoc b/README.adoc index fc787b64..4e3d76d4 100644 --- a/README.adoc +++ b/README.adoc @@ -142,9 +142,10 @@ by any dispatch path, and the CI blockage currently stopping the Rust lane. * link:docs/README.adoc[docs/README.adoc] — the documentation index * link:docs/rules/catalogue.adoc[Rule catalogue] — every rule module and ID family -* link:docs/DEBT-REGISTER.md[Debt register] — what is broken and what is not gated +* link:docs/DEBT-REGISTER.adoc[Debt register] — what is broken and what is not gated +* link:docs/governance/ACTIONS-PINNING.adoc[Actions pinning decision] — how `uses:` refs are pinned here (actions.lock, not inline SHAs) * https://github.com/hyperpolymath/hypatia/wiki[Wiki] — getting started, architecture, operations, FAQ -* link:PALIMPSEST.adoc[Palimpsest Philosophy] — philosophical underpinnings +* https://github.com/hyperpolymath/standards/blob/main/PALIMPSEST.adoc[Palimpsest Philosophy] — philosophical underpinnings (lives in hyperpolymath/standards) == Related projects diff --git a/ROADMAP.adoc b/ROADMAP.adoc index 3cf2171a..a384971d 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -6,7 +6,8 @@ == Overview -Hypatia is the neurosymbolic CI/CD intelligence layer for the hyperpolymath ecosystem. It coordinates the gitbot-fleet (rhodibot, echidnabot, sustainabot, glambot, seambot, finishbot) via a safety triangle pipeline, with 5 neural networks, VCL query layer, Bayesian confidence updating, and Logtalk rules for pattern detection. +Hypatia is the neurosymbolic CI/CD intelligence layer for the hyperpolymath ecosystem. It coordinates the gitbot-fleet (rhodibot, echidnabot, sustainabot, glambot, seambot, finishbot) via a safety triangle pipeline, with 5 neural networks, VCL query layer, Bayesian confidence updating, and Elixir rules for pattern detection (absorbed from the Logtalk engine, +retired 2026-03-06). == Current State (2026-03-29) @@ -49,8 +50,9 @@ Hypatia is the neurosymbolic CI/CD intelligence layer for the hyperpolymath ecos | Rust workspace | adapters, cli, data, fixer, integration crates -| Logtalk rules -| Error catalog, pattern detection, RSR compliance rules +| Elixir rules (`lib/rules/`) +| Error catalog, pattern detection, RSR compliance rules (absorbed from the +Logtalk engine, retired 2026-03-06) | Data layer | verisim-data (git-backed flat-file store), neural state persistence, JSONL outcomes @@ -89,7 +91,7 @@ Elixir pipeline: * [x] Outcome tracking with Bayesian confidence updating * [x] Re-scan verification via panic-attacker * [x] Dispatch manifest (JSONL bridge to bash execution) -* [x] Logtalk rule engine (error catalog, pattern detection) +* [x] Logtalk rule engine (error catalog, pattern detection) — retired 2026-03-06, absorbed into the Elixir ruleset === v2.0 — Neural Intelligence (COMPLETE) diff --git a/docs/DEBT-REGISTER.adoc b/docs/DEBT-REGISTER.adoc index 4eee2c3d..1ac8376c 100644 --- a/docs/DEBT-REGISTER.adoc +++ b/docs/DEBT-REGISTER.adoc @@ -40,8 +40,44 @@ trusted. *LOW* — hygiene. === 1. CI/CD debt -==== CI-1 · CRITICAL · One stale lockfile pin kills 4 workflows and 13 jobs · UNTRACKED - +==== CI-1 · CRITICAL · Workspace cargo resolution unsatisfiable (testcontainers) · CORRECTED 2026-09-26 (#814) + +[NOTE] +==== +*Corrected 2026-09-26 per issue #814.* This entry originally recorded the +wrong cause. The original text (kept below, struck through) blamed a stale +`+dtolnay/rust-toolchain+` lockfile pin and jobs dying at `+Set up job+`. +*That was no longer true when measured.* The jobs started fine; they died +during `cargo` dependency resolution — a later phase with a different fix. +Anyone acting on the struck entry would have bumped a toolchain pin and +found nothing changed. + +*The actual cause, measured 2026-09-22:* `+integration/Cargo.toml+` +required **both** `+testcontainers ^0.28+` and +`+testcontainers-modules ^0.15+`, and the latter itself requires +`+testcontainers ^0.27+`. `+^0.27+` and `+^0.28+` are disjoint under +Cargo's 0.x semver rules, so the conflict was unsatisfiable *at the +manifest level*: no `+Cargo.lock+` could fix it, resolution failed before +any compilation, and **no crate in the workspace was ever compiled** — +including `+clients/rust/hypatia-client+` and its five wire-contract +tests (`+count_is_sixteen+`, `+wire_ids_are_stable+`, +`+names_round_trip+`, `+from_id_round_trip+`, +`+port_layout_matches_v_lang+`). + +*Status as of 2026-09-26:* resolved at the manifest level, not by a +`+[patch]+` — `+testcontainers-modules+` was dropped (it was never +imported; Dragonfly/Redis is provisioned by compose and reached via +`+DRAGONFLY_URL+`), and the tree now resolves a single +`+testcontainers 0.28.0+`. Every cargo invocation in `+ci.yml+`, +`+rust.yml+` and `+quality.yml+` now passes `+--locked+` (#841), so the +committed `+Cargo.lock+` is a hard constraint and +`+cargo test --workspace --locked+` is the gate. *Residual evidence +still owed (#814 acceptance):* a CI run showing `+hypatia-client+`'s +five tests actually executing with a non-zero denominator, and a mutant +run turning `+wire_ids_are_stable+` red when a wire id is changed. +==== + +[.line-through]# `+.github/workflows/actions.lock+` pins `+dtolnay/rust-toolchain@stable+` to `+4cda84d5c5c54efe2404f9d843567869ab1699d4+`, which no longer matches @@ -63,6 +99,11 @@ AsciiDoc). It also blocks all four open Dependabot PRs (#678, #679, The fix is *structural, not a re-pin*: `+@stable+` is a moving ref and re-pinning it by SHA re-breaks on every Rust release. Stop lockfile-pinning moving refs. +# (Superseded twice over: the lockfile now records +`+dtolnay/rust-toolchain@v1+` at +`+02cb101ec7c40f2c49e1d9714d64511d8e1b74de+` and `+Actions lockfile +verify+` is green as of #823 — but this was never the reason the +workspace was red.) ==== CI-2 · HIGH · OSSF Scorecard `+startup_failure+` — caller omits `+actions: read+` · UNTRACKED @@ -512,9 +553,12 @@ each. [width="100%",cols="20%,20%,20%,20%,20%",options="header",] |=== |# |Item |Sev |Why |Tracked -|1 |*CI-1* stale `+dtolnay/rust-toolchain+` lockfile pin |CRITICAL |One -line unblocks 4 workflows, 13 jobs, 4 Dependabot PRs. Real fix is -structural: never SHA-pin a moving ref |NO +|1 |*CI-1* workspace cargo resolution conflict (was: stale +`+dtolnay/rust-toolchain+` pin — wrong cause, see the struck entry) +|CRITICAL |Manifest-level `+testcontainers+` conflict stopped every +workspace crate compiling; resolved 2026-09-26, `+--locked+` now in force +(#814, #841). Residual: CI evidence that `+hypatia-client+`'s five wire +tests run |partly |2 |*T-1* 242 dark tests, 129 failing, documented as passing |CRITICAL |16.9% of the suite is fiction; corrupts every downstream quality claim diff --git a/docs/architecture/NEURAL-ARCHITECTURE.adoc b/docs/architecture/NEURAL-ARCHITECTURE.adoc index 57ee9740..75a87f2e 100644 --- a/docs/architecture/NEURAL-ARCHITECTURE.adoc +++ b/docs/architecture/NEURAL-ARCHITECTURE.adoc @@ -8,7 +8,8 @@ topology. The coordinator aggregates predictions from all 5 networks and provides a unified intelligence layer for the safety triangle pipeline. *Logtalk is NOT part of the neural architecture.* The Logtalk rule files -(`+.lgt+`) exist as standalone symbolic reasoning rules that are not +(`+.lgt+`) were standalone symbolic reasoning rules (the engine was retired +2026-03-06 and absorbed into `+lib/rules/*.ex+`) that were not loaded or executed by the Elixir application. They are a separate, disconnected layer. diff --git a/docs/governance/ACTIONS-PINNING.adoc b/docs/governance/ACTIONS-PINNING.adoc new file mode 100644 index 00000000..ab99883e --- /dev/null +++ b/docs/governance/ACTIONS-PINNING.adoc @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Decision — how actions are pinned in this repository +:decision-date: 2026-09-26 +:status: accepted + +== Decision + +*`+.github/workflows/actions.lock+` is the pinning mechanism for symbolic +action refs in this repository.* Workflow files keep symbolic refs +(`+uses: actions/checkout@v7.0.1+`); the lock records each ref's immutable +resolution (commit SHA, owner/repo ids), and +`+governance / Actions lockfile verify+` rejects a lock that disagrees with +live resolution. This is option (b) of issue #825, chosen over bulk +SHA-pinning (option (a)) and applied uniformly — there is no mix. + +This is the explicit decision the issue asked for: `+dtolnay/rust-toolchain@v1+` +(the 22-site case that prompted it) and every other symbolic ref in the tree +are pinned by the lock, not by inline SHAs. + +== Why not bulk SHA-pinning + +. *The repository's tooling IS the lock.* Every workflow header reads "This +workflow is managed by `+gh actions-lock+`"; the lock is machine-generated +and verified as a whole, including transitive dependencies of composite +actions, which inline pins cannot express. A hand-pinned tree and the lock +would fight each other. +. *The repo convention is symbolic refs*, measured 40 tag/ref to 8 SHA +across the workflow tree at the time of the decision. A partial conversion +would leave the tree harder to reason about, not easier — and the sweep +denominator (every mutable tag, not just `+dtolnay/rust-toolchain+`) would +have to cover all of them in one change. +. *No rule is being violated.* hypatia's active rulesets (measured +{decision-date}) require no `+sha_pinning_required+`; the estate's +requirements are lockfile coverage + lock verification, which this repo +satisfies. + +== Evidence the lock actually catches a moved ref + +The issue called for a mutant (retag `+@v1+`, watch the gate redden) before +trusting the mechanism. The mechanism has already caught this exact failure +mode in this repository without a mutant: `+docs/DEBT-REGISTER.adoc+` CI-1's +original runner log (run `+31170993296+`) records + +.... +##[error]Lockfile pin 4cda84d5c5c54efe2404f9d843567869ab1699d4 for +`dtolnay/rust-toolchain` does not match ref `stable`. +.... + +— i.e. the verifier resolved the live ref, compared it to the lock, and +failed the run when they differed. A retagged or moved ref reddens +`+Actions lockfile verify+` rather than executing silently. (What the lock +gate does *not* prove is that a SHA exists upstream — see the +`+check-action-pins-resolve+` step in governance; the two gates are +complementary.) + +The deliberate mutant (change a recorded commit in `+actions.lock+`, confirm +red, revert, confirm green) is still worth running once as a drill and is +tracked as residual evidence on #825; the accidental retag above is the same +test performed by the world. + +== Obligations this creates + +* When a workflow changes its `+uses:+` refs, regenerate the lock with + `+gh actions-lock+` *in the same PR* (never hand-edit it; the file says so + on line 1-2). A drifted lock kills the affected workflows at + `+startup_failure+` with zero jobs — see standards' + `+check-lockfile-drift.sh+` (mode 4) and hypatia#723. +* A mutable tag may move; when it does, the lock diff is the reviewable + event. Do not "fix" a red lock gate by editing the lock to match a ref you + did not intend to take — update the workflow ref deliberately, then + relock. +* If the estate later adopts repository rulesets with + `+sha_pinning_required+`, this decision flips to option (a) with a full + sweep; record that reversal here. + +== Provenance + +Filed as issue #825 while fixing #822 (duplicate `+with:+` blocks in +`+build-gossamer-gui.yml+`), which deliberately deferred this decision +rather than half-converting one of 22 identical sites. Decided +{decision-date} in the #847–#857 consolidation session. diff --git a/docs/guides/admin-guide.adoc b/docs/guides/admin-guide.adoc index dfb116fb..c07c6d72 100644 --- a/docs/guides/admin-guide.adoc +++ b/docs/guides/admin-guide.adoc @@ -26,7 +26,7 @@ hypatia consists of several interconnected components: ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Registry │ │ Engine │ │ Adapter │ -│ (Haskell) │ │ (Logtalk) │ │ (Rust) │ +│ (Haskell) │ │ (Elixir) │ │ (Rust) │ │ Port: 8080 │ │ Port: 8081 │ │ Port: 8082 │ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ │ │ @@ -40,7 +40,7 @@ hypatia consists of several interconnected components: ---- *Registry*:: Haskell service for ruleset storage, verification, and distribution -*Engine*:: Logtalk/SWI-Prolog service for rule execution and inference +*Engine*:: Elixir rules service for rule execution and inference (absorbed from the retired Logtalk/SWI-Prolog engine) *Adapter*:: Rust service for forge API integration (GitHub, GitLab, etc.) *ArangoDB*:: Graph database for relationship modeling *Dragonfly*:: High-performance cache for compiled rules @@ -114,7 +114,7 @@ GRAFANA_PASSWORD=admin-password-here | Engine | 8081 -| Logtalk rule engine +| Rule engine (Elixir; Logtalk predecessor retired 2026-03-06) | Adapter | 8082 @@ -320,9 +320,9 @@ source ~/.cargo/env # Install GHC (Haskell) curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh -# Install SWI-Prolog and Logtalk +# Install Elixir (rule engine; the Logtalk/SWI-Prolog engine is retired) apt-get install -y swi-prolog -# Download Logtalk from https://logtalk.org/download.html +# See https://elixir-lang.org/install.html ---- . Build components: @@ -376,7 +376,7 @@ docker compose up -d --scale registry=5 ==== Engine Service -The Logtalk engine can scale horizontally with session affinity: +The rule engine can scale horizontally with session affinity: [source,yaml] ---- diff --git a/docs/guides/cicd-guidebook.adoc b/docs/guides/cicd-guidebook.adoc index 93db830c..bfc42b62 100644 --- a/docs/guides/cicd-guidebook.adoc +++ b/docs/guides/cicd-guidebook.adoc @@ -194,11 +194,11 @@ hypatia/ │ ├── catalog.rs # Error catalog (Rust port) │ └── sha_pins.rs # SHA pins reference (Rust port) │ -├── engine/ # Logtalk rule engine +├── engine/ # (retired) Logtalk engine — logic now in lib/rules/*.ex │ └── rules/ -│ ├── cicd_rules.lgt # Core rules -│ ├── forge_adapters.lgt # Multi-forge support -│ └── rule_distiller.lgt # Rule extraction +│ ├── cicd_rules.lgt # Core rules (absorbed into lib/rules/cicd_rules.ex) +│ ├── forge_adapters.lgt # Multi-forge (absorbed into lib/rules/forge_adapters.ex) +│ └── rule_distiller.lgt # Rule extraction (absorbed into lib/rules/) │ ├── adapters/ # Rust forge adapters │ └── src/ @@ -1026,7 +1026,7 @@ RUST_LOG=cicd_fixer::scanner=trace cicd-fixer scan /path/to/repo === Phase 1: Foundation (Complete) -* [x] Logtalk rule engine +* [x] Logtalk rule engine (retired 2026-03-06, absorbed into the Elixir ruleset) * [x] ERROR-CATALOG.scm * [x] SHA-PINS.scm * [x] cicd-fixer CLI diff --git a/docs/guides/developer-guide.adoc b/docs/guides/developer-guide.adoc index 1c441962..bae3ca12 100644 --- a/docs/guides/developer-guide.adoc +++ b/docs/guides/developer-guide.adoc @@ -10,7 +10,7 @@ == Introduction -This guide covers development of hypatia components, including writing rules in Logtalk, creating rulesets in Haskell, developing forge adapters, and contributing to the project. +This guide covers development of hypatia components, including writing rules in Elixir (the rule engine — absorbed from the Logtalk engine, retired 2026-03-06), developing forge adapters, and contributing to the project. === Development Environment Setup @@ -32,13 +32,9 @@ This guide covers development of hypatia components, including writing rules in | 3.10+ | Haskell build -| SWI-Prolog -| 9.2+ -| Logtalk runtime - -| Logtalk -| 3.70+ -| Rule engine +| Elixir +| 1.14+ +| Rule engine (lib/rules) | Deno | 1.40+ @@ -64,8 +60,8 @@ cd ../adapters && cargo build # Install Haskell dependencies cd ../registry && cabal update && cabal build -# Install Logtalk (if not already installed) -# See https://logtalk.org/download.html +# Rule engine is Elixir — fetch deps and run the test suite +# (the Logtalk engine these docs predated was retired 2026-03-06) # Start development environment cd ../deploy @@ -99,12 +95,12 @@ cabal test │ │ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────────────┐ │ -│ │ SYMBOLIC LAYER (Logtalk) │ │ +│ │ SYMBOLIC LAYER (Elixir rules) │ │ │ │ │ │ -│ │ cicd_rules.lgt - Core rule definitions │ │ -│ │ rule_distiller.lgt - Neural→Symbolic distillation │ │ -│ │ learning.lgt - Feedback learning integration │ │ -│ │ forge_adapters.lgt - Multi-forge operations │ │ +│ │ cicd_rules.ex - Core rule definitions │ │ +│ │ rules.ex - Facade / rule loading │ │ +│ │ learning.ex - Feedback learning integration │ │ +│ │ forge_adapters.ex - Multi-forge operations │ │ │ │ │ │ │ └────────────────────────────────────────────────────────────────────┘ │ │ │ │ @@ -126,7 +122,7 @@ cabal test . *User Request*: CLI or API call . *Registry Lookup*: Fetch ruleset from cache or Git storage -. *Engine Execution*: Logtalk evaluates rules against repository state +. *Engine Execution*: The Elixir ruleset evaluates rules against repository state . *Adapter Action*: Forge-specific actions (create PR, update file) . *Learning Feedback*: Outcomes feed back to improve rules diff --git a/docs/operations/containerfiles-chainguard-gap.adoc b/docs/operations/containerfiles-chainguard-gap.adoc index 338edb1e..dda97c6c 100644 --- a/docs/operations/containerfiles-chainguard-gap.adoc +++ b/docs/operations/containerfiles-chainguard-gap.adoc @@ -25,8 +25,10 @@ under "Known Gaps". | `integration/Containerfile.test` | `docker.io/library/rust:1.94-slim-bookworm` | (same — single-stage) -| ✅ Pinned to versioned tag (2026-05-24); cargo-tarpaulin requires - the full rustc toolchain which Chainguard does not publish +| ❌ *Removed* 2026-09-26 (issue #853) with the rest of the compose test + tier — the tier was CI-dead and had no test subject. Was pinned to a + versioned tag (2026-05-24); cargo-tarpaulin requires the full rustc + toolchain which Chainguard does not publish |=== Both are pinned to specific tags; neither uses `:latest` (which is @@ -110,6 +112,12 @@ done when one of: == Audit history + * 2026-09-26 — integration/Containerfile.test removed (issue #853) with + the compose test tier (compose.test.yaml, run-tests.sh): CI-dead after + its only caller was removed in #843/#852, and bind-mounted + integration/fixtures/mock-server-config/, a directory that never + existed in git. deploy/Containerfile is now the only Containerfile and + keeps its Chainguard runtime. * 2026-05-24 — initial memo. integration/Containerfile.test pinned to rust:1.94-slim-bookworm (matching deploy/Containerfile's builder). Backslash line-continuations restored on the apt-get diff --git a/docs/proof-debt.adoc b/docs/proof-debt.adoc index 461aeffe..e5979762 100644 --- a/docs/proof-debt.adoc +++ b/docs/proof-debt.adoc @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 == Proof Debt — hypatia *Schema*: @@ -9,21 +10,24 @@ https://github.com/hyperpolymath/standards/blob/main/docs/TRUSTED-BASE-REDUCTION The 2026-05-26 estate proof-debt audit (https://github.com/hyperpolymath/standards/pull/195[standards#195]) reported *5 soundness-relevant escape hatches* in this repo. A follow-up -local run of `+check-trusted-base.sh+` reports *15* — the delta (10) is -shadowed copies of the same 5 fixtures inside two stale agent worktrees -under `+.claude/worktrees/+`. The worktrees are untracked (they do not -ship with the repo), so a fresh CI checkout still sees 5; the 15-count -is the worst-case local-tree value the seed should accept without -flagging. - -*Marker count (canonical / tracked):* 5. *Marker count (local-tree max, -incl. agent worktrees):* 15. +local run reported *15* — the delta (10) was shadowed copies of the same +5 fixtures inside two stale agent worktrees under `+.claude/worktrees/+`. +Those worktrees are gone as of 2026-09-26 and were never tracked (they +did not ship with the repo). + +*Marker count (canonical / tracked): 6* — re-derived 2026-09-26 by +running `+scripts/check-trusted-base.sh+` (which now exists in this repo; +it previously existed only as an instruction, see #831) over +`+git ls-files+`, not copied from any earlier revision. The count moved +5 → 6 because the inventory below had missed one fixture: +`+test/soundness/fixtures/code_safety/obj_magic_ocaml.ml+` (`+Obj.magic+`). +No escape-hatch marker exists outside +`+test/soundness/fixtures/+` except annotated comments carrying +`+hypatia: allow+` — measured, not asserted. This file is the *initial seed* — every marker starts in §(d) DEBT and the maintainer triages each into §(a) / §(b) / §(c) / §(d) as -classification proceeds. This revision corrects the prior seed (which -said "`no markers; check-trusted-base passes already`") and brings the -count line into agreement with the actual scan output. +classification proceeds. === (a) DISCHARGED in this repo @@ -43,105 +47,72 @@ logic.)_ === (d) DEBT — actively to be closed -All 15 sites below start in this section. Once classified, each moves to -§(a) / §(b) / §(c). The 5 canonical sites are deliberate scanner +All sites below start in this section. Once classified, each moves to +§(a) / §(b) / §(c). The canonical sites are deliberate scanner fixtures (each header literally says `+DO NOT FIX — this file exists so the build fails if the rule stops firing+`); the preliminary classification is therefore *PROPERTY-TEST* (the fixture itself is the property), pending the maintainer’s confirmation. -==== Canonical / tracked sites (5) +==== Canonical / tracked sites (6) + +Line numbers re-derived 2026-09-26 by `+scripts/check-trusted-base.sh+` +(the executing use in each fixture, not the header comment). The earlier +inventory listed 5 rows; `+obj_magic_ocaml.ml+` was missing from it. [width="100%",cols="14%,47%,26%,13%",options="header",] |=== |# |File:line |Kind |Preliminary class -|1 |`+test/soundness/fixtures/code_safety/admitted.v:7+` +|1 |`+test/soundness/fixtures/code_safety/admitted.v:10+` |coq-axiom-or-admit (`+Admitted.+`) |PROPERTY-TEST (fixture for `+code_safety/admitted+` rule) -|2 |`+test/soundness/fixtures/code_safety/sorry.lean:5+` +|2 |`+test/soundness/fixtures/code_safety/sorry.lean:8+` |lean-sorry-or-axiom (`+:= by sorry+`) |PROPERTY-TEST (fixture for `+code_safety/sorry+` rule) -|3 |`+test/soundness/fixtures/code_safety/agda_postulate.agda:5+` +|3 |`+test/soundness/fixtures/code_safety/agda_postulate.agda:8+` |agda-postulate (`+postulate+`) |PROPERTY-TEST (fixture for `+code_safety/agda_postulate+` rule) -|4 |`+test/soundness/fixtures/code_safety/believe_me.idr:8+` +|4 |`+test/soundness/fixtures/code_safety/believe_me.idr:11+` |idris-believe-or-assert (`+believe_me Z+`) |PROPERTY-TEST (fixture for `+code_safety/believe_me+` rule) -|5 |`+test/soundness/fixtures/code_safety/unsafe_coerce.hs:10+` +|5 |`+test/soundness/fixtures/code_safety/unsafe_coerce.hs:13+` |rust-or-hs-unsafe (`+unsafeCoerce n+`) |PROPERTY-TEST (fixture for `+code_safety/unsafe_coerce+` rule) -|=== - -==== Shadowed copies under stale agent worktrees (10) -These are byte-identical copies of the 5 canonical fixtures living under -untracked agent worktrees (`+.claude/worktrees/+`). They will not appear -on a fresh CI checkout, but local invocations of -`+check-trusted-base.sh+` count them. Listed here so the count line -matches the scanner output even when worktrees are present. Disposing of -these is a *separate* janitorial step (deleting `+.claude/worktrees/+` -locally) — not a real proof-debt item. - -[width="100%",cols="14%,47%,26%,13%",options="header",] +|6 |`+test/soundness/fixtures/code_safety/obj_magic_ocaml.ml:5+` +|rust-or-hs-unsafe (`+Obj.magic x+`) |PROPERTY-TEST (fixture for +`+code_safety/obj_magic_ocaml+` rule) |=== -|# |File:line |Kind |Preliminary class -|6 -|`+.claude/worktrees/agent-a4e5738e280951300/test/soundness/fixtures/code_safety/admitted.v:7+` -|coq-axiom-or-admit |TBD-DEBT (shadowed copy of #1) - -|7 -|`+.claude/worktrees/agent-a4e5738e280951300/test/soundness/fixtures/code_safety/sorry.lean:5+` -|lean-sorry-or-axiom |TBD-DEBT (shadowed copy of #2) -|8 -|`+.claude/worktrees/agent-a4e5738e280951300/test/soundness/fixtures/code_safety/agda_postulate.agda:5+` -|agda-postulate |TBD-DEBT (shadowed copy of #3) +==== Shadowed copies under stale agent worktrees — GONE (historical) -|9 -|`+.claude/worktrees/agent-a4e5738e280951300/test/soundness/fixtures/code_safety/believe_me.idr:8+` -|idris-believe-or-assert |TBD-DEBT (shadowed copy of #4) - -|10 -|`+.claude/worktrees/agent-a4e5738e280951300/test/soundness/fixtures/code_safety/unsafe_coerce.hs:10+` -|rust-or-hs-unsafe |TBD-DEBT (shadowed copy of #5) - -|11 -|`+.claude/worktrees/agent-a678c735f94059b5b/test/soundness/fixtures/code_safety/admitted.v:7+` -|coq-axiom-or-admit |TBD-DEBT (shadowed copy of #1) - -|12 -|`+.claude/worktrees/agent-a678c735f94059b5b/test/soundness/fixtures/code_safety/sorry.lean:5+` -|lean-sorry-or-axiom |TBD-DEBT (shadowed copy of #2) - -|13 -|`+.claude/worktrees/agent-a678c735f94059b5b/test/soundness/fixtures/code_safety/agda_postulate.agda:5+` -|agda-postulate |TBD-DEBT (shadowed copy of #3) - -|14 -|`+.claude/worktrees/agent-a678c735f94059b5b/test/soundness/fixtures/code_safety/believe_me.idr:8+` -|idris-believe-or-assert |TBD-DEBT (shadowed copy of #4) - -|15 -|`+.claude/worktrees/agent-a678c735f94059b5b/test/soundness/fixtures/code_safety/unsafe_coerce.hs:10+` -|rust-or-hs-unsafe |TBD-DEBT (shadowed copy of #5) -|=== +The 2026-05-27 revision of this file listed *10* additional sites: +byte-identical copies of the canonical fixtures inside two untracked +agent worktrees (`+.claude/worktrees/agent-a4e5738e280951300/+`, +`+.claude/worktrees/agent-a678c735f94059b5b/+`). Those worktrees no +longer exist (re-measured 2026-09-26) and were never tracked, so the +copies can never return to a fresh checkout. The rows are struck from +the inventory rather than deleted so the record shows the 15-count was +real for its time; the live count is the 6 canonical fixtures above. The full list is reproducible via: [source,bash] ---- -bash /path/to/standards/scripts/check-trusted-base.sh . +bash scripts/check-trusted-base.sh # tracked tree (what CI enforces) +bash scripts/check-trusted-base.sh --all # include untracked local files ---- === Suggested triage process [arabic] -. Run `+scripts/check-trusted-base.sh+` locally; it lists every marker -with file:line. +. Run `+scripts/check-trusted-base.sh+` locally (it exists in this repo; +before #831 the instruction named a script that did not); it lists every +marker with file:line. . For each marker, decide: * Can this be proven? → §(a) DISCHARGED via a PR that adds the proof. * Is this at an FFI / extraction / opaque-primitive boundary? → §(b) or @@ -149,8 +120,11 @@ with file:line. or cite the metatheoretic justification for §(c). * Is this temporary debt? → §(d) with a deadline. . Update this file in the same PR that lands the disposition. -. The `+check-trusted-base+` CI job (standards#211) ensures markers are -never un-annotated AND un-enumerated simultaneously. +. The `+check-trusted-base+` CI job (in +`+.github/workflows/verify-proofs.yml+`, running +`+scripts/check-trusted-base.sh+`; an estate-side enforcement scheme was +proposed in standards#211) ensures markers are never un-annotated AND +un-enumerated simultaneously. === Companion documents diff --git a/docs/testing/needs.adoc b/docs/testing/needs.adoc index 23bd7ae2..3d75500d 100644 --- a/docs/testing/needs.adoc +++ b/docs/testing/needs.adoc @@ -161,8 +161,12 @@ quality failures) * [ ] Workspace `+cargo test+` — blocked by pre-existing `+bincode 3.0.0+` compile error * [ ] Zig FFI integration tests — `+zig build test+` not wired to CI -* [ ] Container integration test (compose.test.yaml exists but needs -validation) +* [x] Container integration test — the compose tier +(`+compose.test.yaml+` / `+run-tests.sh+` / `+Containerfile.test+`) was +CI-dead and structurally broken (mounted a nonexistent fixture directory +against a CLI entrypoint); deleted 2026-09-26 (#853). The live suite is the +`+integration-tests+` job with CI service containers. A full-stack compose +tier would need a real server under test first. ==== Self-Tests diff --git a/ffi/zig/src/main.zig b/ffi/zig/src/main.zig index df3e14dd..d84925ba 100644 --- a/ffi/zig/src/main.zig +++ b/ffi/zig/src/main.zig @@ -57,6 +57,9 @@ const HandleState = struct { }; fn state(handle: *Handle) *HandleState { + // Opaque-handle recovery, the normative ABI idiom (see src/Hypatia/ABI/FFI.idr): + // type and alignment are restored together on the way out of the C boundary. + // hypatia:ignore code_safety/* -- not an unchecked cast; reviewed 2026-09-26 return @ptrCast(@alignCast(handle)); } @@ -81,6 +84,9 @@ export fn hypatia_init() ?*Handle { }; clearError(); + // Concrete pointer presented as the opaque handle at the ABI boundary, + // companion of the state() recovery above. + // hypatia:ignore zig_ptr_cast -- not an unchecked cast; reviewed 2026-09-26 return @ptrCast(handle); } diff --git a/integration/Cargo.toml b/integration/Cargo.toml index 03691482..f4093c70 100644 --- a/integration/Cargo.toml +++ b/integration/Cargo.toml @@ -46,10 +46,12 @@ uuid = { version = "1.23", features = ["v4"] } # Testcontainers for spinning up Docker containers. # NOTE: testcontainers-modules is deliberately NOT a dependency here. Dragonfly/Redis -# is provisioned by integration/compose.test.yaml and reached via DRAGONFLY_URL, so the -# modules crate was never imported. As of 2026-09-22 the newest published -# testcontainers-modules is 0.15.0, which still requires testcontainers ^0.27, so -# the pair was unsatisfiable and resolution failed before any compilation. See #838. +# is provisioned by the CI service containers in .github/workflows/tests.yml and +# reached via DRAGONFLY_URL, so the modules crate was never imported. As of +# 2026-09-22 the newest published testcontainers-modules is 0.15.0, which still +# requires testcontainers ^0.27, so the pair was unsatisfiable and resolution +# failed before any compilation. See #838. (The old docker-compose tier that +# this comment used to name was deleted with #853 — it was CI-dead.) testcontainers = { version = "0.28", features = ["watchdog"] } # Mock HTTP server diff --git a/integration/Containerfile.test b/integration/Containerfile.test deleted file mode 100644 index e2ce1cc5..00000000 --- a/integration/Containerfile.test +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# -# Test container for the Rust integration suite. The runtime image in -# deploy/Containerfile is Chainguard wolfi-base; this builder uses the -# official rust image because cargo-tarpaulin (coverage) needs a full -# rustc toolchain that Chainguard doesn't publish. -# -# Pinned to a versioned tag (not :latest) so test runs are reproducible -# and a Docker Hub registry hiccup can't pull a newer rust mid-flight. -FROM docker.io/library/rust:1.94-slim-bookworm - -WORKDIR /workspace - -# Backslash continuations restored — previous version had them stripped, -# which broke the apt-get install into separate (failing) RUN commands. -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - wget \ - iputils-ping \ - && rm -rf /var/lib/apt/lists/* - -# Install tarpaulin for coverage. --locked uses the project's Cargo.lock -# so a transient registry blip can't drag in an unintended dep set. -RUN cargo install cargo-tarpaulin --locked - -# Default command -CMD ["cargo", "test", "--manifest-path", "integration/Cargo.toml"] diff --git a/integration/README.adoc b/integration/README.adoc index 3f52da02..df2d6f98 100644 --- a/integration/README.adoc +++ b/integration/README.adoc @@ -1,174 +1,122 @@ -== Integration Tests +// SPDX-License-Identifier: CC-BY-SA-4.0 += Integration Tests + +This directory contains the integration test suite for the hypatia +platform: the `hypatia-integration-tests` Rust crate (`tests/*.rs`) and its +fixtures. -This directory contains integration tests for the hypatia platform. +== Overview -=== Overview +The integration test framework validates: -The integration test framework validates: - *Fleet Operations*: Bot -orchestration and inter-bot communication - *ArangoDB*: Graph database -connectivity, queries, and data operations - *Registry*: Haskell ruleset -registry operations and validation - *Git Hooks*: Pre-commit, pre-push, -and post-receive hook execution - *Forge Adapters*: GitHub, GitLab, and -Bitbucket API integrations +* *Fleet Operations*: Bot orchestration and inter-bot communication +* *ArangoDB*: Graph database connectivity, queries, and data operations +* *Registry*: Ruleset registry operations and validation +* *Git Hooks*: Pre-commit, pre-push, and post-receive hook execution +* *Forge Adapters*: GitHub, GitLab, and Bitbucket API integrations (wiremock) -=== Directory Structure +== Directory structure .... integration/ ├── Cargo.toml # Test crate configuration -├── docker-compose.test.yml # Test environment containers -├── run-tests.sh # Test runner script -├── README.md # This file +├── README.adoc # This file ├── tests/ -│ ├── fleet_test.rs # Bot fleet orchestration tests -│ ├── arangodb_test.rs # ArangoDB connectivity tests -│ ├── registry_test.rs # Haskell registry tests -│ ├── hooks_test.rs # Git hooks execution tests -│ └── forge_test.rs # Forge adapter tests (wiremock) -└── fixtures/ - ├── test-repo/ # Sample repository for testing - │ ├── .github/workflows/ # Test workflow files - │ ├── src/ # Test source files - │ └── index.html # Test HTML with accessibility issues - ├── sample-ruleset.json # Sample ruleset for registry tests - └── mock-responses/ # Mock API responses for forges - ├── github-repo.json - ├── github-workflows.json - ├── github-alerts.json - ├── gitlab-project.json - └── bitbucket-repo.json +│ ├── fleet_test.rs # Bot fleet orchestration tests +│ ├── arangodb_test.rs # ArangoDB connectivity tests +│ ├── registry_test.rs # Registry tests +│ ├── hooks_test.rs # Git hooks execution tests +│ ├── forge_test.rs # Forge adapter tests (wiremock) +│ └── ci_simulation_test.rs # CI simulation tests +├── fixtures/ +│ ├── test-repo/ # Sample repository for testing +│ ├── sample-ruleset.json # Sample ruleset for registry tests +│ └── mock-responses/ # Mock API responses for forges +├── src/ # Support library for the tests +└── robot-repo-automaton/ # Automation support code .... -=== Prerequisites - -* Rust 1.75+ with cargo -* Docker and Docker Compose -* Git -* (Optional) redis-cli for health checks +== How the suite runs -=== Quick Start +The suite runs in CI as the `integration-tests` job of +`.github/workflows/tests.yml`. It does *not* use a docker-compose tier: the +job declares `arangodb` and `dragonfly` as GitHub Actions *service +containers*, waits for both, seeds the ArangoDB database, and runs: [source,bash] ---- -# Run all integration tests -./run-tests.sh - -# Run with verbose output -./run-tests.sh --verbose - -# Run specific test file -./run-tests.sh --test fleet_test - -# Run without slow tests -./run-tests.sh --quick +# Rust (workspace-wide, integration feature) +cargo test --workspace --features integration-tests -- --test-threads=1 -# Generate coverage report -./run-tests.sh --coverage +# Elixir integration tests (same services) +mix test --include integration -# Keep containers running after tests -./run-tests.sh --keep +# CLI crate tests +cargo test --manifest-path cli/Cargo.toml --features integration-tests -- --test-threads=1 ---- -=== Test Categories - -==== Fleet Tests (`+fleet_test.rs+`) - -Tests bot fleet operations: - Sequential bot execution pipeline - Shared -context propagation between bots - Alert aggregation from multiple bots -- Fix deduplication - Error handling and recovery - Metrics collection - -==== ArangoDB Tests (`+arangodb_test.rs+`) - -Tests database operations: - Connection pooling and health checks - -Repository CRUD operations - Alert storage and retrieval - Rule -management - AQL query execution - Bulk insert performance - Graph -traversal queries - -==== Registry Tests (`+registry_test.rs+`) - -Tests Haskell registry: - Ruleset registration and validation - -LiquidHaskell verification (mock) - Duplicate rule detection - Rule -search by category and severity - Fix template application - JSON -serialization - -==== Hooks Tests (`+hooks_test.rs+`) - -Tests git hooks: - Hook installation and permissions - Pre-commit hook -success/failure - Hook bypass with –no-verify - SPDX header validation - -SHA pin validation - Permissions validation - Multiple hooks execution - -Environment variable handling - -==== Forge Tests (`+forge_test.rs+`) - -Tests forge adapters: - GitHub API mock server - GitLab API mock server -- Bitbucket API mock server - Authentication headers - Rate limiting -responses - Webhook payload handling - Error handling (404, 500) - -Pagination handling - Concurrent requests - -=== Test Environment - -The test environment uses Docker containers: - -[cols=",,",options="header",] -|=== -|Service |Port |Purpose -|ArangoDB |8529 |Graph database -|Dragonfly |6379 |Redis-compatible cache -|Mock GitHub |1080 |GitHub API mock -|Mock GitLab |1081 |GitLab API mock -|Mock Bitbucket |1082 |Bitbucket API mock -|Webhook Receiver |8088 |Webhook testing -|=== - -=== Environment Variables - -The test runner sets these automatically: +Environment the job provides: [cols=",,",options="header",] |=== |Variable |Default |Description |`+ARANGODB_URL+` |http://localhost:8529 |ArangoDB connection URL -|`+ARANGODB_DATABASE+` |cicd_hyper_a_test |Test database name -|`+ARANGODB_USERNAME+` |root |Database username +|`+ARANGODB_DATABASE+` |hypatia |Test database name +|`+ARANGODB_USER+` |root |Database username |`+ARANGODB_PASSWORD+` |testpassword |Database password |`+DRAGONFLY_URL+` |redis://localhost:6379 |Dragonfly/Redis URL -|`+RUST_LOG+` |info |Logging level -|`+RUST_BACKTRACE+` |1 |Enable backtraces +|`+RUST_LOG+` |debug |Logging level |=== -=== Features +Locally, run the same commands against your own ArangoDB and +Dragonfly/Redis instances and export the variables above. Individual test +files can be run with `cargo test --manifest-path integration/Cargo.toml +--test `. + +== The compose tier is gone (deliberately) + +Earlier revisions of this directory shipped a docker-compose test tier +(`compose.test.yaml`, `run-tests.sh`, `Containerfile.test`). It was deleted +2026-09-26 (issue #853) because it was CI-dead — its only caller had already +been removed with the fictional "End-to-End Tests" job (issue #843) — and +structurally broken: it bind-mounted +`integration/fixtures/mock-server-config/`, a directory that never existed +in git, against `deploy/Containerfile`, whose entrypoint is a CLI that +listens on no port. Nothing asserted anything about a real subject. -Enable additional test categories: +If a full-stack compose tier is ever wanted, it needs a *server* under test +and real mock-server expectation files first. Reinstate it together with +those, not before (issues #843, #853). + +`integration/tests/*.rs` and the `integration-tests` CI job are the live +suite and are unaffected by that removal. + +== Features [source,bash] ---- # Run with live service tests (requires real credentials) -./run-tests.sh --live +cargo test --features live-tests # Run with slow tests (extended timeouts, performance tests) cargo test --features slow-tests ---- -=== Writing New Tests +== Writing new tests -==== Test Structure +=== Test structure Each test file follows this pattern: [source,rust] ---- -// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-License-Identifier: MPL-2.0 use anyhow::Result; use tracing::info; -mod common; -use common::setup_test_logging; - #[tokio::test] async fn test_example() -> Result<()> { - setup_test_logging(); - // Test implementation info!("Test completed"); Ok(()) @@ -179,7 +127,7 @@ fn main() { } ---- -==== Using Mock Servers +=== Using mock servers [source,rust] ---- @@ -202,7 +150,10 @@ async fn test_with_mock() -> Result<()> { } ---- -==== Using Testcontainers +=== Using testcontainers + +Provisioned services (ArangoDB, Dragonfly) are the CI services' job; for a +one-off container in a test, `testcontainers` is available directly: [source,rust] ---- @@ -222,23 +173,9 @@ async fn test_with_container() -> Result<()> { } ---- -=== Troubleshooting - -==== Container Issues - -[source,bash] ----- -# Check container logs -docker logs hypatia-test-arangodb - -# Restart containers -docker-compose -f docker-compose.test.yml restart +== Troubleshooting -# Clean up everything -docker-compose -f docker-compose.test.yml down -v ----- - -==== Database Connection +=== Service containers (CI) [source,bash] ---- @@ -249,43 +186,14 @@ curl http://localhost:8529/_api/version redis-cli -h localhost -p 6379 ping ---- -==== Test Failures +=== Test failures [source,bash] ---- -# Run with maximum verbosity -RUST_LOG=debug ./run-tests.sh --verbose - # Run single test with output -cargo test test_name -- --nocapture ----- - -=== CI/CD Integration - -For GitHub Actions: - -[source,yaml] ----- -jobs: - integration-tests: - runs-on: ubuntu-latest - services: - arangodb: - image: arangodb:3.11 - env: - ARANGO_ROOT_PASSWORD: testpassword - ports: - - 8529:8529 - dragonfly: - image: docker.dragonflydb.io/dragonflydb/dragonfly - ports: - - 6379:6379 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: cargo test --manifest-path integration/Cargo.toml +cargo test --manifest-path integration/Cargo.toml test_name -- --nocapture ---- -=== License +== License MPL-2.0 diff --git a/integration/compose.test.yaml b/integration/compose.test.yaml deleted file mode 100644 index 17128830..00000000 --- a/integration/compose.test.yaml +++ /dev/null @@ -1,192 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# Test environment for hypatia integration tests -# -# Usage: -# podman-compose -f compose.test.yaml up -d -# cargo test --manifest-path integration/Cargo.toml -# podman-compose -f compose.test.yaml down -v - -version: "3.9" - -services: - # ============================================================================ - # ArangoDB Test Instance - # ============================================================================ - arangodb: - image: arangodb:3.11 - container_name: hypatia-test-arangodb - environment: - ARANGO_ROOT_PASSWORD: testpassword - ARANGO_NO_AUTH: 0 - ports: - - "8529:8529" - volumes: - - arangodb-test-data:/var/lib/arangodb3 - - arangodb-test-apps:/var/lib/arangodb3-apps - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8529/_api/version"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - networks: - - cicd-test-net - restart: unless-stopped - - # ============================================================================ - # Dragonfly Test Instance (Redis-compatible) - # ============================================================================ - dragonfly: - image: docker.dragonflydb.io/dragonflydb/dragonfly:latest - container_name: hypatia-test-dragonfly - ulimits: - memlock: -1 - ports: - - "6379:6379" - volumes: - - dragonfly-test-data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - networks: - - cicd-test-net - restart: unless-stopped - - # ============================================================================ - # Mock GitHub API Server - # ============================================================================ - mock-github: - image: mockserver/mockserver:5.15.0 - container_name: hypatia-test-github - environment: - MOCKSERVER_INITIALIZATION_JSON_PATH: /config/github-expectations.json - MOCKSERVER_LOG_LEVEL: WARN - ports: - - "1080:1080" - volumes: - - ./fixtures/mock-server-config:/config:ro - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:1080/mockserver/status"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - cicd-test-net - restart: unless-stopped - - # ============================================================================ - # Mock GitLab API Server - # ============================================================================ - mock-gitlab: - image: mockserver/mockserver:5.15.0 - container_name: hypatia-test-gitlab - environment: - MOCKSERVER_INITIALIZATION_JSON_PATH: /config/gitlab-expectations.json - MOCKSERVER_LOG_LEVEL: WARN - ports: - - "1081:1080" - volumes: - - ./fixtures/mock-server-config:/config:ro - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:1080/mockserver/status"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - cicd-test-net - restart: unless-stopped - - # ============================================================================ - # Mock Bitbucket API Server - # ============================================================================ - mock-bitbucket: - image: mockserver/mockserver:5.15.0 - container_name: hypatia-test-bitbucket - environment: - MOCKSERVER_INITIALIZATION_JSON_PATH: /config/bitbucket-expectations.json - MOCKSERVER_LOG_LEVEL: WARN - ports: - - "1082:1080" - volumes: - - ./fixtures/mock-server-config:/config:ro - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:1080/mockserver/status"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - cicd-test-net - restart: unless-stopped - - # ============================================================================ - # Webhook Receiver (for testing webhook delivery) - # ============================================================================ - webhook-receiver: - image: tarampampam/webhook-tester:1.2 - container_name: hypatia-test-webhook - ports: - - "8088:8088" - environment: - LISTEN_PORT: 8088 - CREATE_SESSION_ENABLED: "true" - healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8088/health"] - interval: 10s - timeout: 5s - retries: 3 - networks: - - cicd-test-net - restart: unless-stopped - - # ============================================================================ - # Test Runner Container - # ============================================================================ - test-runner: - build: - context: . - dockerfile: Containerfile.test - container_name: hypatia-test-runner - depends_on: - arangodb: - condition: service_healthy - dragonfly: - condition: service_healthy - environment: - ARANGODB_URL: http://arangodb:8529 - ARANGODB_PASSWORD: testpassword - DRAGONFLY_URL: redis://dragonfly:6379 - MOCK_GITHUB_URL: http://mock-github:1080 - MOCK_GITLAB_URL: http://mock-gitlab:1080 - MOCK_BITBUCKET_URL: http://mock-bitbucket:1080 - WEBHOOK_RECEIVER_URL: http://webhook-receiver:8088 - RUST_LOG: info - RUST_BACKTRACE: 1 - volumes: - - ../:/workspace:ro - - cargo-cache:/root/.cargo - - target-cache:/workspace/target - working_dir: /workspace/integration - networks: - - cicd-test-net - profiles: - - test - -networks: - cicd-test-net: - driver: bridge - name: hypatia-test-network - -volumes: - arangodb-test-data: - name: hypatia-test-arangodb-data - arangodb-test-apps: - name: hypatia-test-arangodb-apps - dragonfly-test-data: - name: hypatia-test-dragonfly-data - cargo-cache: - name: hypatia-test-cargo-cache - target-cache: - name: hypatia-test-target-cache diff --git a/integration/run-tests.sh b/integration/run-tests.sh deleted file mode 100755 index 960c3bc6..00000000 --- a/integration/run-tests.sh +++ /dev/null @@ -1,363 +0,0 @@ -#!/bin/bash -# SPDX-License-Identifier: MPL-2.0 -# -# Integration Test Runner for hypatia -# -# This script: -# 1. Starts test containers (ArangoDB, Dragonfly, mock servers) -# 2. Waits for services to be healthy -# 3. Runs integration tests -# 4. Collects coverage data -# 5. Cleans up containers -# -# Usage: -# ./run-tests.sh # Run all tests -# ./run-tests.sh --quick # Skip slow tests -# ./run-tests.sh --live # Include tests against live services -# ./run-tests.sh --coverage # Generate coverage report -# ./run-tests.sh --keep # Keep containers running after tests -# ./run-tests.sh --test NAME # Run specific test file - -set -euo pipefail - -# Configuration -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -COMPOSE_FILE="$SCRIPT_DIR/compose.test.yaml" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Default options -QUICK_MODE=false -LIVE_TESTS=false -COVERAGE=false -KEEP_CONTAINERS=false -SPECIFIC_TEST="" -VERBOSE=false - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --quick|-q) - QUICK_MODE=true - shift - ;; - --live|-l) - LIVE_TESTS=true - shift - ;; - --coverage|-c) - COVERAGE=true - shift - ;; - --keep|-k) - KEEP_CONTAINERS=true - shift - ;; - --test|-t) - SPECIFIC_TEST="$2" - shift 2 - ;; - --verbose|-v) - VERBOSE=true - shift - ;; - --help|-h) - echo "Usage: $0 [OPTIONS]" - echo "" - echo "Options:" - echo " --quick, -q Skip slow tests" - echo " --live, -l Include tests against live services" - echo " --coverage, -c Generate coverage report" - echo " --keep, -k Keep containers running after tests" - echo " --test, -t NAME Run specific test file (e.g., fleet_test)" - echo " --verbose, -v Verbose output" - echo " --help, -h Show this help message" - exit 0 - ;; - *) - echo -e "${RED}Unknown option: $1${NC}" - exit 1 - ;; - esac -done - -# Logging functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Check dependencies -check_dependencies() { - log_info "Checking dependencies..." - - local missing=() - - if ! command -v podman &> /dev/null; then - missing+=("podman") - fi - - if ! command -v podman-compose &> /dev/null; then - missing+=("podman-compose") - fi - - if ! command -v cargo &> /dev/null; then - missing+=("cargo") - fi - - if [[ ${#missing[@]} -gt 0 ]]; then - log_error "Missing dependencies: ${missing[*]}" - exit 1 - fi - - log_success "All dependencies found" -} - -# Start test containers -start_containers() { - log_info "Starting test containers..." - - cd "$SCRIPT_DIR" - - COMPOSE_CMD="podman-compose" - - $COMPOSE_CMD -f "$COMPOSE_FILE" up -d arangodb dragonfly - - log_info "Waiting for services to be healthy..." - - # Wait for ArangoDB - local max_attempts=30 - local attempt=0 - while ! curl -sf http://localhost:8529/_api/version > /dev/null 2>&1; do - attempt=$((attempt + 1)) - if [[ $attempt -ge $max_attempts ]]; then - log_error "ArangoDB failed to start" - exit 1 - fi - echo -n "." - sleep 2 - done - echo "" - log_success "ArangoDB is ready" - - # Wait for Dragonfly - attempt=0 - while ! redis-cli -h localhost -p 6379 ping > /dev/null 2>&1; do - attempt=$((attempt + 1)) - if [[ $attempt -ge $max_attempts ]]; then - log_error "Dragonfly failed to start" - exit 1 - fi - echo -n "." - sleep 2 - done - echo "" - log_success "Dragonfly is ready" -} - -# Stop test containers -stop_containers() { - if [[ "$KEEP_CONTAINERS" == true ]]; then - log_info "Keeping containers running (--keep flag)" - return - fi - - log_info "Stopping test containers..." - - cd "$SCRIPT_DIR" - - podman-compose -f "$COMPOSE_FILE" down -v - - log_success "Containers stopped and volumes removed" -} - -# Initialize test database -init_database() { - log_info "Initializing test database..." - - # Create database and collections using ArangoDB HTTP API. - # NOTE: `testpassword` is the documented integration-test fixture - # — see integration/compose.test.yaml, integration/README.md, and - # integration/tests/arangodb_test.rs. It is NOT a production secret; - # the local containerized ArangoDB is set up to use this exact value. - local arango_url="http://localhost:8529" - local auth="root:testpassword" - - # Create database - curl -sf -X POST "$arango_url/_api/database" \ - -u "$auth" \ - -H "Content-Type: application/json" \ - -d '{"name": "cicd_hyper_a_test"}' > /dev/null 2>&1 || true - - # Create collections - local db_url="$arango_url/_db/cicd_hyper_a_test/_api/collection" - - for collection in repositories alerts rules rulesets; do - curl -sf -X POST "$db_url" \ - -u "$auth" \ - -H "Content-Type: application/json" \ - -d "{\"name\": \"$collection\"}" > /dev/null 2>&1 || true - done - - # Create edge collections - for edge in repo_has_alert rule_applies_to; do - curl -sf -X POST "$db_url" \ - -u "$auth" \ - -H "Content-Type: application/json" \ - -d "{\"name\": \"$edge\", \"type\": 3}" > /dev/null 2>&1 || true - done - - log_success "Test database initialized" -} - -# Build tests -build_tests() { - log_info "Building integration tests..." - - cd "$SCRIPT_DIR" - - local features="" - if [[ "$LIVE_TESTS" == true ]]; then - features="--features live-tests" - fi - if [[ "$QUICK_MODE" == false ]]; then - features="$features --features slow-tests" - fi - - if [[ "$COVERAGE" == true ]]; then - # Use cargo-tarpaulin for coverage - if ! command -v cargo-tarpaulin &> /dev/null; then - log_warning "cargo-tarpaulin not installed, installing..." - cargo install cargo-tarpaulin - fi - fi - - cargo build --release $features - - log_success "Tests built successfully" -} - -# Run tests -run_tests() { - log_info "Running integration tests..." - - cd "$SCRIPT_DIR" - - # Set environment variables - export ARANGODB_URL="http://localhost:8529" - export ARANGODB_DATABASE="cicd_hyper_a_test" - export ARANGODB_USERNAME="root" - # scanner-allow: shell-secrets -- test fixture, not a real credential - export ARANGODB_PASSWORD="testpassword" - export DRAGONFLY_URL="redis://localhost:6379" - export RUST_LOG="${RUST_LOG:-info}" - export RUST_BACKTRACE=1 - - local test_args="" - if [[ -n "$SPECIFIC_TEST" ]]; then - test_args="--test $SPECIFIC_TEST" - fi - - local features="" - if [[ "$QUICK_MODE" == false ]]; then - features="--features slow-tests" - fi - if [[ "$LIVE_TESTS" == true ]]; then - features="$features --features live-tests" - fi - - local verbose_flag="" - if [[ "$VERBOSE" == true ]]; then - verbose_flag="--nocapture" - fi - - if [[ "$COVERAGE" == true ]]; then - log_info "Running tests with coverage..." - cargo tarpaulin \ - --out Html \ - --output-dir "$SCRIPT_DIR/coverage" \ - $features \ - $test_args \ - -- $verbose_flag - log_success "Coverage report generated in $SCRIPT_DIR/coverage/" - else - cargo test \ - --release \ - $features \ - $test_args \ - -- $verbose_flag - fi -} - -# Print test summary -print_summary() { - echo "" - echo "============================================" - echo " Integration Test Summary" - echo "============================================" - echo "" - echo "Tests completed at: $(date)" - echo "" - if [[ "$COVERAGE" == true ]]; then - echo "Coverage report: $SCRIPT_DIR/coverage/tarpaulin-report.html" - fi - if [[ "$KEEP_CONTAINERS" == true ]]; then - echo "" - echo "Containers are still running:" - echo " ArangoDB: http://localhost:8529" - echo " Dragonfly: redis://localhost:6379" - echo "" - echo "To stop: podman-compose -f $COMPOSE_FILE down -v" - fi - echo "============================================" -} - -# Cleanup handler -cleanup() { - local exit_code=$? - if [[ $exit_code -ne 0 ]]; then - log_error "Tests failed with exit code $exit_code" - fi - stop_containers - exit $exit_code -} - -# Main execution -main() { - echo "" - echo "============================================" - echo " hypatia Integration Test Runner" - echo "============================================" - echo "" - - # Set up trap for cleanup - trap cleanup EXIT - - check_dependencies - start_containers - init_database - build_tests - run_tests - print_summary - - log_success "All tests completed successfully!" -} - -main "$@" diff --git a/lib/cross_repo_learning.ex b/lib/cross_repo_learning.ex index 996d9cc0..1d3c1d1e 100644 --- a/lib/cross_repo_learning.ex +++ b/lib/cross_repo_learning.ex @@ -573,7 +573,62 @@ defmodule Hypatia.CrossRepoLearning do end) end - # --- Private: Language Detection --- + # --- Language Detection (deterministic; issue #676) --- + + # Fixed tie-break priority for equally-counted languages. The ORDER is + # arbitrary but MUST be stable — it exists so two runs never disagree on + # a multi-language repo. Policy-relevant languages first, then common + # ones; anything unlisted sorts after all listed languages, lexically. + @language_priority ~w( + rust elixir idris idris2 zig nickel shell bash javascript typescript + rescript haskell ocaml coq lean agda isabelle hol4 fstar ada spark + python go java c cpp scheme ruby + ) + + @doc """ + Deterministic primary-language resolution from decoded scan data. + + Prefer the declared `primary_language` field; else pick the language with + the highest count in the `languages` map. Ties are broken by the fixed + `#{@language_priority |> Enum.take(3) |> Enum.join(" > ")}` … priority + above, then lexically — a TOTAL order, so the same scan data always + resolves to the same language. + + Issue #676 measured opposite-direction flips (`idris→rust` and + `rust→idris` in one rescan) traced to `Enum.max_by/3` on a map: with + equal counts the winner depends on map enumeration order, which is not + a reviewable contract. This function is the single resolution point; + `GraphOfTrust` and `VCL.FileExecutor` delegate here so the three readers + cannot drift apart again. + + Note: the scan files' singular `language` field is the PRODUCER's + classification and was the field observed flipping upstream. It is + deliberately NOT consulted here — resolution from the full `languages` + count map is strictly more stable. + """ + def primary_language_from_scan_data(data) when is_map(data) do + cond do + Map.has_key?(data, "primary_language") -> + String.downcase(Map.get(data, "primary_language", "unknown")) + + Map.has_key?(data, "languages") -> + data + |> Map.get("languages", %{}) + |> Enum.sort_by(fn {lang, count} -> + count_score = if is_number(count), do: -count, else: 0 + prio = Enum.find_index(@language_priority, &(String.downcase(&1) == String.downcase(lang))) + {count_score, if(prio, do: prio, else: length(@language_priority)), String.downcase(lang)} + end) + |> List.first({"unknown", 0}) + |> elem(0) + |> String.downcase() + + true -> + "unknown" + end + end + + def primary_language_from_scan_data(_data), do: "unknown" # Detect primary language for a repo from scan data. # Falls back to "unknown" if scan data isn't available. @@ -585,20 +640,7 @@ defmodule Hypatia.CrossRepoLearning do case Jason.decode(content) do {:ok, data} -> # Scan data may contain language info directly or in weak_points - cond do - Map.has_key?(data, "primary_language") -> - String.downcase(Map.get(data, "primary_language", "unknown")) - - Map.has_key?(data, "languages") -> - data - |> Map.get("languages", %{}) - |> Enum.max_by(fn {_lang, count} -> count end, fn -> {"unknown", 0} end) - |> elem(0) - |> String.downcase() - - true -> - "unknown" - end + primary_language_from_scan_data(data) {:error, _} -> "unknown" diff --git a/lib/hypatia/cli.ex b/lib/hypatia/cli.ex index 2d063b30..112cb000 100644 --- a/lib/hypatia/cli.ex +++ b/lib/hypatia/cli.ex @@ -1044,7 +1044,13 @@ defmodule Hypatia.CLI do severity: cli_context_severity(file, f.rule, f.severity), type: to_string(f.rule), file: file, - reason: "#{f.description} (#{f.occurrences} occurrences, #{f.cwe})", + # First match line — SARIF's region.startLine reads this + # (sarif.ex) and previously defaulted to 1 for every + # code_safety finding (the main.zig:1 defect, issue #834). + line: List.first(f.lines, 1), + reason: + "#{f.description} (#{f.occurrences} occurrences, #{f.cwe}, " <> + "line #{Enum.join(f.lines, ", ")})", action: "flag" } end) @@ -1168,14 +1174,22 @@ defmodule Hypatia.CLI do &Hypatia.ScannerSuppression.comment_masked_secret_label?(&1, line, idx + 1) ) |> Enum.map(fn label -> + # Placeholder-shaped values and commented-out lines downgrade to + # medium/report instead of critical/revoke_rotate_and_purge + # (#746, #748): 45 measured false positives were template + # placeholders; the gate blocks on critical, so a placeholder + # must not read as a live leak. + {severity, action, suffix} = + Hypatia.ScannerSuppression.secret_line_disposition(line, idx + 1) + %{ rule_module: rule_module, - severity: "critical", + severity: severity, type: rule_type, file: file, line: idx + 1, - reason: "Secret found: #{label}", - action: "revoke_rotate_and_purge" + reason: "Secret found: #{label}#{suffix}", + action: action } end) end diff --git a/lib/hypatia/scanner_suppression.ex b/lib/hypatia/scanner_suppression.ex index 60f5bc44..88bc6ccd 100644 --- a/lib/hypatia/scanner_suppression.ex +++ b/lib/hypatia/scanner_suppression.ex @@ -51,8 +51,7 @@ defmodule Hypatia.ScannerSuppression do "scripts/fix-scripts/", "test/", "tests/", - "integration/fixtures/", - "integration/run-tests.sh" + "integration/fixtures/" ] @default_exemptions %{ @@ -274,6 +273,61 @@ defmodule Hypatia.ScannerSuppression do # and therefore cannot make this distinction. @form_ambiguous_secret_labels ["Generic API key", "Generic secret", "Password"] + # ── Placeholder-shaped values (#746, #748) ──────────────────────────────── + # + # Measured 2026-09-03 across 73 repos with a live gate: 45 of 614 critical + # findings were commented-out placeholders from templates + # (`# export API_KEY="..."`, `# token = "ghp_xxxxxxxxxxxxxxxxxxxx"`), zero + # real credentials. The shapes below are placeholder tell-tales: ellipses, + # angle-bracket metavariables, long same-character runs, `your-*`/`my-*` + # fillers, `changeme`. They cannot plausibly occur in a real generated + # credential; and where they might (a pathological all-same-char key) the + # cost is a severity downgrade to `medium`, never a suppression. + @placeholder_re ~r/(?:\.{3,}|…|<[^<>]{1,40}>|[xX*]{6,}|(?:your|my|our)[-_][a-z0-9_-]{2,}|change[-_]?me\b|redacted\b|dummy[-_]?\w*\b|placeholder\b|insert[-_]?here\b|example\b)/i + + @doc """ + Return true when `line` carries an obvious placeholder value rather than a + real credential (`#746`/`#748`). Used to DOWNGRADE a finding to `medium`, + never to drop it. + """ + def placeholder_secret_line?(line) when is_binary(line) do + Regex.match?(@placeholder_re, line) + end + + def placeholder_secret_line?(_line), do: false + + @doc """ + Severity/action disposition for a secret-detection hit on `line`. + + Returns `{severity, action, reason_suffix}`: + + * placeholder-shaped value → `{"medium", "report", ...}` — template + filler, inform but do not gate (the `revoke_rotate_and_purge` of a + commented placeholder is the expensive direction to be wrong in). + * whole-line comment → `{"medium", "report", " (commented-out + credential placeholder — verify before rotating)"}` — a commented + leak is still reported (never silenced), just not merge-blocking. + * anything else → `{"critical", "revoke_rotate_and_purge", ""}`. + + Comment detection deliberately uses `whole_line_comment?/2`, which never + treats `--` as a comment marker (shell long-options) and never suppresses + a shebang line. Note also `comment_masked_secret_label?/3` runs BEFORE + this and fully suppresses the three form-ambiguous labels in comments; + this disposition covers everything else. + """ + def secret_line_disposition(line, line_number \\ nil) when is_binary(line) do + cond do + placeholder_secret_line?(line) -> + {"medium", "report", " (placeholder-shaped value — verify it is not a real credential)"} + + whole_line_comment?(line, line_number) -> + {"medium", "report", " (commented-out credential placeholder — verify before rotating)"} + + true -> + {"critical", "revoke_rotate_and_purge", ""} + end + end + @doc """ Return true when `label` is one of the three form-ambiguous secret labels AND `line` is a whole-line comment — i.e. a commented-out example rather @@ -396,8 +450,11 @@ defmodule Hypatia.ScannerSuppression do do: ~r/(?i)(?:password|secret|api[_-]?key|token)\s*[:=]\s*["'](?:test|dummy|fake|example|placeholder)[-_].*?["']/ + # `hypatia:ignore` is a spelling alias for `hypatia: allow` — issue #834's + # pragmas (`hypatia:ignore RE005 -- `, `hypatia:ignore zig_ptr_cast`) + # use the verb form; both are honoured identically. defp directive_re, - do: ~r/(?:^|[\s#\/\-;])hypatia:\s*allow\s+([A-Za-z0-9_\*]+)(?:\/([A-Za-z0-9_\*]+))?/i + do: ~r/(?:^|[\s#\/\-;])hypatia:\s*(?:allow|ignore)\s+([A-Za-z0-9_\*]+)(?:\/([A-Za-z0-9_\*]+))?/i defp directive_matches?(line, rule_module, rule_type) do case Regex.run(directive_re(), line) do diff --git a/lib/mix/tasks/hypatia.rsr_score.ex b/lib/mix/tasks/hypatia.rsr_score.ex index e5652492..eb6e532b 100644 --- a/lib/mix/tasks/hypatia.rsr_score.ex +++ b/lib/mix/tasks/hypatia.rsr_score.ex @@ -52,6 +52,15 @@ defmodule Mix.Tasks.Hypatia.RsrScore do end end + # Resolve the SSOT from candidates that exist on disk (#695). The old + # default computed `_build//lib/hypatia/priv/../test/fixtures/…` + # from `:code.priv_dir/1`, which lands INSIDE `_build` — the bare command + # (the one the template dogfood gate and estate corpus run) failed with + # `:enoent` unless `--ssot` was passed. Candidates are tried in order: + # source-tree `priv/` (release-shaped home), the current dogfood home + # under `test/fixtures/a2ml/`, then the built app's `priv/` (escript/ + # release). First existing file wins; if none exist, the dogfood path is + # returned so the error message names the location it expected. defp resolve_ssot(opts) do cond do opts[:ssot] -> @@ -61,9 +70,22 @@ defmodule Mix.Tasks.Hypatia.RsrScore do Path.join(opts[:standards], "0-canon/rsr/rsr-criteria-v2.a2ml") true -> - Path.join(:code.priv_dir(:hypatia) |> to_string(), "..") - |> Path.join("test/fixtures/a2ml/rsr-criteria-v2.a2ml") - |> Path.expand() + source_tree_root = Path.expand("../../..", __DIR__) + + candidates = [ + Path.join(source_tree_root, "priv/a2ml/rsr-criteria-v2.a2ml"), + Path.join(source_tree_root, "test/fixtures/a2ml/rsr-criteria-v2.a2ml"), + built_priv_candidate() + ] + + Enum.find(candidates, List.last(candidates), &File.regular?/1) + end + end + + defp built_priv_candidate do + case :code.priv_dir(:hypatia) do + dir when is_list(dir) -> Path.join(to_string(dir), "a2ml/rsr-criteria-v2.a2ml") + _ -> "/nonexistent/rsr-criteria-v2.a2ml" end end diff --git a/lib/neural/graph_of_trust.ex b/lib/neural/graph_of_trust.ex index 196fedd6..a53305a8 100644 --- a/lib/neural/graph_of_trust.ex +++ b/lib/neural/graph_of_trust.ex @@ -355,20 +355,8 @@ defmodule Hypatia.Neural.GraphOfTrust do {:ok, content} -> case Jason.decode(content) do {:ok, data} -> - cond do - Map.has_key?(data, "primary_language") -> - String.downcase(Map.get(data, "primary_language", "unknown")) - - Map.has_key?(data, "languages") -> - data - |> Map.get("languages", %{}) - |> Enum.max_by(fn {_lang, count} -> count end, fn -> {"unknown", 0} end) - |> elem(0) - |> String.downcase() - - true -> - "unknown" - end + # Deterministic resolution shared with CrossRepoLearning (#676) + Hypatia.CrossRepoLearning.primary_language_from_scan_data(data) {:error, _} -> "unknown" diff --git a/lib/rules/code_safety.ex b/lib/rules/code_safety.ex index 89e22e82..63f82096 100644 --- a/lib/rules/code_safety.ex +++ b/lib/rules/code_safety.ex @@ -819,6 +819,12 @@ defmodule Hypatia.Rules.CodeSafety do def scan_content(content, language) do scannable = strip_inline_test_blocks(content, language) runtime_only = strip_lazy_initialisers(scannable, language) + # Every stripper above is line-count-preserving (whole-line comment + # removal keeps the newline; lazy-elision pads with the elided span's + # newlines), so a byte offset in `subject` maps to the same line number + # as in `content` — and in the raw line array below. If a future stripper + # breaks that invariant it must say so here. + orig_lines = String.split(content, "\n") patterns_for_language(language) |> Enum.flat_map(fn rule -> @@ -829,35 +835,78 @@ defmodule Hypatia.Rules.CodeSafety do # quoted guidance or comments. Apply the same central suppression oracle # used by the CLI before aggregating occurrences; otherwise a file-level # finding survives even though every matching line is known-safe. + # Rejected lines are BLANKED, not dropped — dropping would shift every + # subsequent line number (the main.zig:1 class of defect, issue #834). subject = if rule.id == :shell_download_then_run do subject |> String.split("\n") |> Enum.with_index(1) - |> Enum.reject(fn {line, line_number} -> - Hypatia.ScannerSuppression.context_safe_line?( - "shell_download_then_run", - line, - line_number - ) + |> Enum.map(fn {line, line_number} -> + if Hypatia.ScannerSuppression.context_safe_line?( + "shell_download_then_run", + line, + line_number + ) do + "" + else + line + end end) - |> Enum.map_join("\n", &elem(&1, 0)) + |> Enum.join("\n") else subject end - case Regex.scan(rule.pattern, subject) do + # Inline `hypatia:ignore ` / `hypatia: allow ` directives + # suppress the rule per line (directive line or the line below it), + # with the ORIGINAL lines — the directive itself is usually a comment + # and has been stripped from `subject` for matching purposes. + subject = + subject + |> String.split("\n") + |> Enum.with_index(1) + |> Enum.map(fn {line, line_number} -> + raw = Enum.at(orig_lines, line_number - 1, line) + prev = if line_number >= 2, do: Enum.at(orig_lines, line_number - 2) + + if Hypatia.ScannerSuppression.inline_allowed?( + raw, + prev, + "code_safety", + to_string(rule.id) + ) do + "" + else + line + end + end) + |> Enum.join("\n") + + case Regex.scan(rule.pattern, subject, return: :index) do [] -> [] matches -> + lines = + matches + |> Enum.map(fn [{byte_off, _len} | _] -> + subject + |> binary_part(0, byte_off) + |> :binary.matches("\n") + |> length() + |> Kernel.+(1) + end) + |> Enum.uniq() + [ %{ rule: rule.id, severity: rule.severity, cwe: rule.cwe, description: rule.description, - occurrences: length(matches) + occurrences: length(matches), + lines: lines } ] end @@ -918,7 +967,12 @@ defmodule Hypatia.Rules.CodeSafety do close -> before = binary_part(content, from, start - from) - elide_lazy_inits(content, close + 1, ["LAZY_INIT_ELIDED", before | acc]) + # Keep the elided span's newline count so byte-offset → line + # mapping stays truthful for every line after the initialiser. + span = binary_part(content, start, close + 1 - start) + nls = span |> :binary.matches("\n") |> length() + elided = "LAZY_INIT_ELIDED" <> String.duplicate("\n", nls) + elide_lazy_inits(content, close + 1, [elided, before | acc]) end end end @@ -1083,6 +1137,15 @@ defmodule Hypatia.Rules.CodeSafety do strip_ml_style_block_comments(content) end + # Zig: `//` line comments (`///` doc, `//!` doc-as-module included). Zig has + # no block comments. Without this, docs and comments in .zig files describing + # `@ptrCast` / `@alignCast` / `@intToPtr` trip the Zig patterns — the + # main.zig:1 false positives of issue #834. Whole-line stripping preserves + # line numbering (the newline is not part of the match). + defp strip_inline_test_blocks(content, "zig") do + strip_rust_line_comments(content) + end + defp strip_inline_test_blocks(content, _other), do: content defp strip_after_cfg_test(content) do diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 93197d04..970e1d72 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -603,11 +603,53 @@ defmodule Hypatia.Rules.ResearchExtensions do is_binary(name) and Regex.match?(~r/(?:test|spec|check|lint|verify)/i, name) - has_continue_on_error? = Regex.match?(~r/continue-on-error:\s*true\b/, body) + # Strip YAML comments before matching (issue #834): a commented-out + # `# continue-on-error: true` or a trailing note containing `|| true` + # is not a swallow. Whole-line comments and whitespace-preceded + # trailing comments — YAML and bash agree that `#` begins a comment + # only after whitespace or at line start, so `a#b` is untouched. + stripped_body = + body + |> String.split("\n") + |> Enum.map_join("\n", fn line -> + line + |> String.replace(~r/^[ \t]*#.*$/m, "") + |> String.replace(~r/[ \t]#.*$/m, "") + end) - has_or_true? = Regex.match?(~r/\|\|\s*true\b/, body) + has_continue_on_error? = Regex.match?(~r/continue-on-error:\s*true\b/, stripped_body) + + has_or_true? = Regex.match?(~r/\|\|\s*true\b/, stripped_body) + + # A step carrying `hypatia:ignore RE005 -- ` (or the + # `hypatia: allow research_extensions/RE005` spelling) is reviewed — + # the swallow is deliberate and the reason is on the record. The + # directive is read from the RAW step text, comments included, so it + # can live in a YAML comment next to the swallow it blesses. + inside_step_lines = + [name, body] + |> Enum.filter(&is_binary/1) + |> Enum.flat_map(&String.split(&1, "\n")) + + # ... plus the three physical lines above the step opener, so the + # directive can also sit where a reviewer's eye lands first. + above_step_lines = + content + |> String.split("\n") + |> Enum.slice(max(line_no - 4, 0), 3) + + reviewed? = + (inside_step_lines ++ above_step_lines) + |> Enum.any?(fn line -> + Hypatia.ScannerSuppression.inline_allowed?( + line, + nil, + "research_extensions", + "RE005" + ) + end) - if is_test? and (has_continue_on_error? or has_or_true?) do + if is_test? and (has_continue_on_error? or has_or_true?) and not reviewed? do mechanism = cond do has_continue_on_error? and has_or_true? -> diff --git a/lib/safety/rate_limiter.ex b/lib/safety/rate_limiter.ex index 8a1e15b4..6e4fbc27 100644 --- a/lib/safety/rate_limiter.ex +++ b/lib/safety/rate_limiter.ex @@ -43,7 +43,12 @@ defmodule Hypatia.Safety.RateLimiter do # --- GenServer API --- def start_link(opts \\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) + # `:name` lets a test own its own instance (started with `name:`, shut + # down with the test process) instead of reaching for the app-level + # shared one, whose state the rest of the suite touches and whose crash + # resets it silently (#857). Production starts it unnamed -> __MODULE__. + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) end def init(_opts) do @@ -53,24 +58,28 @@ defmodule Hypatia.Safety.RateLimiter do # --- Public API --- - @doc "Check if a dispatch is allowed for a bot. Returns :ok or {:rate_limited, retry_after_ms}" - def check(bot_name) do - GenServer.call(__MODULE__, {:check, bot_name}) + @doc """ + Check if a dispatch is allowed for a bot. Returns :ok or {:rate_limited, retry_after_ms}. + + `server` defaults to the app-level instance; tests pass their own (#857). + """ + def check(bot_name, server \\ __MODULE__) do + GenServer.call(server, {:check, bot_name}) end @doc "Record a dispatch (call after successful dispatch)" - def record_dispatch(bot_name) do - GenServer.cast(__MODULE__, {:record, bot_name}) + def record_dispatch(bot_name, server \\ __MODULE__) do + GenServer.cast(server, {:record, bot_name}) end @doc "Enqueue a dispatch for later if rate limited" - def enqueue(dispatch_entry) do - GenServer.cast(__MODULE__, {:enqueue, dispatch_entry}) + def enqueue(dispatch_entry, server \\ __MODULE__) do + GenServer.cast(server, {:enqueue, dispatch_entry}) end @doc "Get rate limiter statistics" - def stats do - GenServer.call(__MODULE__, :stats) + def stats(server \\ __MODULE__) do + GenServer.call(server, :stats) end # --- Callbacks --- @@ -174,9 +183,32 @@ defmodule Hypatia.Safety.RateLimiter do case check_internal(state, bot) do :ok -> - # Dispatch via fleet dispatcher + # Dispatch via fleet dispatcher. A dispatch that raises must NOT + # take the rate limiter down with it: the limiter is + # app-supervised, so a crash here is silently restarted with + # empty `%__MODULE__{}` state, resetting every bot's window and + # all counters underneath the whole suite (#857). Drop the entry, + # log the failure, keep the limiter alive. Logger.info("Rate limiter: draining queued dispatch to #{bot}") - Hypatia.FleetDispatcher.dispatch_finding(entry) + + try do + Hypatia.FleetDispatcher.dispatch_finding(entry) + rescue + e -> + Logger.error( + "Rate limiter: queued dispatch to #{bot} raised " <> + Exception.format(:error, e, __STACKTRACE__) <> + " -- dropping the entry; the limiter stays up" + ) + catch + kind, reason -> + Logger.error( + "Rate limiter: queued dispatch to #{bot} exited " <> + Exception.format(kind, reason, __STACKTRACE__) <> + " -- dropping the entry; the limiter stays up" + ) + end + drain_queued(%{state | queue: remaining_queue}) {:rate_limited, _, _} -> @@ -189,6 +221,11 @@ defmodule Hypatia.Safety.RateLimiter do end end + # Mirrors handle_call({:check, ...}) INCLUDING the burst limit. It used to + # check only the per-bot and global windows, so `drain_queued/1`'s "still + # rate limited, put back" branch was dead: a burst-saturated bot was still + # :ok here, the entry was dispatched instead of requeued, and a raising + # dispatch then killed the limiter (#857). defp check_internal(state, bot_name) do now = System.system_time(:millisecond) @@ -199,7 +236,10 @@ defmodule Hypatia.Safety.RateLimiter do global_window = Enum.filter(state.global_window, fn ts -> now - ts < @global_window_ms end) + burst_window = Enum.filter(bot_window, fn ts -> now - ts < @burst_window_ms end) + cond do + length(burst_window) >= @burst_limit -> {:rate_limited, :burst, 0} length(bot_window) >= @per_bot_limit -> {:rate_limited, :per_bot, 0} length(global_window) >= @global_limit -> {:rate_limited, :global, 0} true -> :ok diff --git a/lib/vcl/file_executor.ex b/lib/vcl/file_executor.ex index d1d67ba4..cb4e81cc 100644 --- a/lib/vcl/file_executor.ex +++ b/lib/vcl/file_executor.ex @@ -573,7 +573,8 @@ defmodule Hypatia.VCL.FileExecutor do {:ok, content} -> case Jason.decode(content) do {:ok, data} -> - String.downcase(Map.get(data, "primary_language", "unknown")) + # Deterministic resolution shared with CrossRepoLearning (#676) + Hypatia.CrossRepoLearning.primary_language_from_scan_data(data) _ -> "unknown" diff --git a/poc-scanner.sh b/poc-scanner.sh index 72e9662a..d2287712 100755 --- a/poc-scanner.sh +++ b/poc-scanner.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 # Proof-of-concept scanner implementing hypatia rules in bash+ripgrep -# Demonstrates the patterns work before full Logtalk deployment +# Demonstrates the patterns work standalone (predates the Elixir ruleset +# that absorbed the Logtalk engine, retired 2026-03-06) set -euo pipefail @@ -245,6 +246,6 @@ else echo " - code-safety-lessons.lgt: has_cors_misconfiguration" echo " - code-safety-lessons.lgt: has_auth_bypass, has_privilege_escalation" echo "" - echo "Run with full Logtalk for detailed analysis and fix suggestions." + echo "Run the hypatia CLI for detailed analysis and fix suggestions." exit 1 fi diff --git a/scripts/check-proof-status.sh b/scripts/check-proof-status.sh new file mode 100755 index 00000000..2f511e63 --- /dev/null +++ b/scripts/check-proof-status.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-proof-status.sh — keep verification/PROOF-STATUS.adoc honest (#816). +# +# A proof-status document that is wrong is worse than one that is missing: it +# is read as evidence. This script makes the document go stale LOUDLY: +# +# 1. every source path the document names must exist in the tree +# (bare filenames are resolved against the proof roots); +# 2. every `+identifier+` named in an inventory row must appear inside +# that row's file; +# 3. every `+identifier+` named in a "Properties Proven" section must +# appear inside that section's file. +# +# Rows marked `[line-through]` / `RETIRED` are historical record and are +# skipped. The dangerous-pattern audit names escape hatch SPELLINGS on +# purpose (their count is the claim), so its section is not treated as a +# proof inventory. +# +# Both directions are load-bearing: a renamed proof turns this check red +# (the document claims something the source no longer contains), and a +# document naming a file or proof that never existed is also red. Proven by +# mutant both ways against this tree (rename `connectorCount` in +# src/Hypatia/ABI/Types.idr -> red; revert -> green). +# +# Usage: scripts/check-proof-status.sh [path/to/PROOF-STATUS.adoc] +# Exit: 0 = document agrees with the tree; 1 = stale; 2 = usage/IO error. + +set -uo pipefail + +DOC="${1:-verification/PROOF-STATUS.adoc}" +ROOTS="src/Hypatia/ABI src/abi verify/src verification/proofs" + +if [ ! -f "$DOC" ]; then + echo "::error::check-proof-status: $DOC not found" + exit 2 +fi + +resolve_file() { + # $1 = path or bare filename. Prints the resolved path or nothing. + case "$1" in + */*) [ -f "$1" ] && printf '%s\n' "$1" ;; + *) find $ROOTS -name "$1" -type f 2>/dev/null | head -1 ;; + esac +} + +# --------------------------------------------------------------------------- +# Extraction. One awk pass emits records for the bash loop below: +# FILE — a source path named by the document +# IDENT — an identifier claimed in that file +# Rows/sections marked [line-through] or RETIRED are skipped; the Properties +# Proven section bodies are attached to the file named in their ==== header. +# --------------------------------------------------------------------------- +extract() { + awk ' + function emit_row(b, spans, n, i, span, file, first) { + if (b ~ /line-through/ || b ~ /RETIRED/) return + n = split(b, spans, /`\+/) + file = "" + for (i = 2; i <= n; i += 2) { + span = spans[i] + sub(/\+`.*/, "", span) # cut at the closing +` + first = span + sub(/[ \t(:<>=].*/, "", first) + sub(/^[^A-Za-z_]*/, "", first) + sub(/[^A-Za-z0-9_'"'"'.]*$/, "", first) + if (span ~ /\.(idr|lean|tla|agda)/) { + if (file == "") { + file = span + sub(/:.*$/, "", file) + print "FILE\t" file "\t" + } + continue + } + if (file != "" && first ~ /^[A-Za-z_][A-Za-z0-9_'"'"'.]*$/) + print "IDENT\t" file "\t" first + } + } + function emit_section(s, header, body, spans, n, i, span, file, first) { + header = s + sub(/\n.*/, "", header) + if (header !~ /`\+[^`]+\+`/) return + file = header + sub(/^[^`]*`\+/, "", file) + sub(/\+`.*$/, "", file) + print "FILE\t" file "\t" + body = s + sub(/^[^\n]*\n/, "", body) + n = split(body, spans, /`\+/) + for (i = 2; i <= n; i += 2) { + span = spans[i] + sub(/\+`.*/, "", span) # cut at the closing +` + first = span + sub(/[ \t(:<>=].*/, "", first) + sub(/^[^A-Za-z_]*/, "", first) + sub(/[^A-Za-z0-9_'"'"'.]*$/, "", first) + if (first ~ /^[A-Za-z_][A-Za-z0-9_'"'"'.]*$/) + print "IDENT\t" file "\t" first + } + } + + # Inventory tables: everything before "Properties Proven". + /^=== Properties Proven/ { + in_props = 1 + if (block != "") emit_row(block) + block = "" + next + } + # Properties Proven sections run until the dangerous-pattern audit. + /^=== Dangerous Pattern Audit/ { + if (section != "") emit_section(section) + section = "" + in_props = 0 + done = 1 + next + } + done { next } + + in_props { + if (/^==== /) { + if (section != "") emit_section(section) + section = $0 + next + } + if (section != "") section = section "\n" $0 + next + } + + # Inventory row accumulation: a row starts at a "|"-line and continues + # to the next one. + /^\|/ { + if (block != "") emit_row(block) + block = $0 + next + } + { if (block != "") block = block "\n" $0 } + END { + if (!done && block != "") emit_row(block) + if (section != "") emit_section(section) + } + ' "$DOC" +} + +FILES_CHECKED=0 +IDENTS_CHECKED=0 +FAILURES=0 + +while IFS=$'\t' read -r kind file ident; do + case "$kind" in + FILE) + FILES_CHECKED=$((FILES_CHECKED + 1)) + if [ -z "$(resolve_file "$file")" ]; then + echo "::error::PROOF-STATUS names '$file' but no such file exists in the tree (#816)" + FAILURES=$((FAILURES + 1)) + fi + ;; + IDENT) + resolved="$(resolve_file "$file")" + [ -z "$resolved" ] && continue # its FILE record reports the miss + IDENTS_CHECKED=$((IDENTS_CHECKED + 1)) + if ! grep -qwF -- "$ident" "$resolved" 2>/dev/null; then + echo "::error::PROOF-STATUS says '$ident' is proven in $file, but $resolved does not contain it (renamed or removed? #816)" + FAILURES=$((FAILURES + 1)) + fi + ;; + esac +done < <(extract) + +echo "PROOF-STATUS currency: files named ${FILES_CHECKED}, identifiers checked ${IDENTS_CHECKED}, mismatches ${FAILURES}" + +if [ "$FAILURES" -gt 0 ]; then + echo "PROOF-STATUS currency check: STALE — the document disagrees with the tree" + exit 1 +fi + +if [ "$FILES_CHECKED" -eq 0 ]; then + echo "::error::check-proof-status: parsed 0 files from $DOC — the extractor matched nothing, which is not a pass" + exit 1 +fi + +echo "PROOF-STATUS currency check: document agrees with the tree" +exit 0 diff --git a/scripts/check-trusted-base.sh b/scripts/check-trusted-base.sh new file mode 100755 index 00000000..988ab898 --- /dev/null +++ b/scripts/check-trusted-base.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-trusted-base.sh — enumerate soundness-relevant escape-hatch markers +# in the proof corpus and fail on any that is neither a deliberate scanner +# fixture nor annotated with a `hypatia: allow` pragma. +# +# This is the script docs/proof-debt.adoc instructs readers to run (#831). +# It exists so the document's inventory can be re-derived by command rather +# than edited by hand, and so a new marker can never be simultaneously +# un-annotated AND un-enumerated: the `check-trusted-base` CI job runs this +# on every PR that touches a proof file. +# +# Marker kinds (the same set verification/PROOF-STATUS.adoc audits): +# idris-believe-or-assert believe_me, assert_total +# agda-postulate postulate +# lean-sorry-or-axiom sorry, admit, native_decide +# coq-axiom-or-admit Admitted +# rust-or-hs-unsafe unsafeCoerce, Obj.magic +# +# Classification per match: +# FIXTURE under test/soundness/fixtures/ — deliberate known-bad sample +# (expected; counted and listed, never a failure) +# ALLOWED comment/code line carrying `hypatia: allow ` — annotated by a human, recorded in +# docs/proof-debt.adoc's inventory +# COMMENT match inside a comment-only line without a pragma — prose, not a call site (reported) +# DEBT anything else — FAILS this check +# +# Scans git-tracked files only. Untracked local shadows (e.g. stale agent +# worktrees) are invisible on a fresh CI checkout and are the local tree's +# business; pass `--all` to scan untracked files too when reconciling a +# local count against docs/proof-debt.adoc. +# +# Usage: scripts/check-trusted-base.sh [--all] [root] +# Exit: 0 = no un-annotated markers outside fixtures; 1 = DEBT found; 2 = usage. + +set -uo pipefail + +SCAN_UNTRACKED=0 +if [ "${1:-}" = "--all" ]; then + SCAN_UNTRACKED=1 + shift +fi +ROOT="${1:-.}" + +FIXTURE_PREFIX="test/soundness/fixtures/" + +MARKER_RE='believe_me|assert_total|postulate|sorry|admit|native_decide|Admitted|unsafeCoerce|Obj\.magic' +EXTS='*.idr *.lean *.agda *.v *.hs *.ml' + +cd "$ROOT" || exit 2 + +if [ "$SCAN_UNTRACKED" -eq 1 ]; then + FILES=$(find . -type f \( -name '*.idr' -o -name '*.lean' -o -name '*.agda' -o -name '*.v' -o -name '*.hs' -o -name '*.ml' \) \ + -not -path './.git/*' -not -path './_build/*' -not -path './deps/*' | sed 's|^\./||') +else + FILES=$(git ls-files -- $EXTS) +fi + +FIXTURES=0 +ALLOWED=0 +COMMENTS=0 +DEBT=0 + +while IFS= read -r file; do + [ -f "$file" ] || continue + # grep -n the markers; then classify each hit line. + while IFS= read -r hit; do + [ -z "$hit" ] && continue + lineno="${hit%%:*}" + line="${hit#*:}" + case "$file" in + ${FIXTURE_PREFIX}*) + class=FIXTURE + ;; + *) + case "$line" in + *'hypatia: allow'*) + class=ALLOWED + ;; + *) + # Comment-only lines are prose, not call sites; anything else is + # a call site and must be annotated or it is debt. + stripped="$(printf '%s' "$line" | sed 's/^[[:space:]]*//')" + case "$stripped" in + --*|//*|\#*|\(*|\**) class=COMMENT ;; + *) class=DEBT ;; + esac + ;; + esac + ;; + esac + + case "$class" in + FIXTURE) FIXTURES=$((FIXTURES + 1)) ;; + ALLOWED) ALLOWED=$((ALLOWED + 1)) ;; + COMMENT) COMMENTS=$((COMMENTS + 1)) ;; + DEBT) + DEBT=$((DEBT + 1)) + echo "::error::un-annotated trusted-base marker at ${file}:${lineno}: ${line}" + ;; + esac + echo "${class} ${file}:${lineno}" + done < <(grep -nE "$MARKER_RE" "$file" 2>/dev/null) +done <<< "$FILES" + +echo "trusted-base markers: fixture=${FIXTURES} allowed=${ALLOWED} comment=${COMMENTS} debt=${DEBT}" + +if [ "$DEBT" -gt 0 ]; then + echo "check-trusted-base: FAIL — ${DEBT} marker(s) outside ${FIXTURE_PREFIX} carry no 'hypatia: allow' pragma. Add the proof, annotate the debt in docs/proof-debt.adoc, or move a deliberate fixture under ${FIXTURE_PREFIX}." + exit 1 +fi + +echo "check-trusted-base: OK — every marker is a fixture or annotated" +exit 0 diff --git a/test/code_safety_test.exs b/test/code_safety_test.exs index 35e0755b..258f9034 100644 --- a/test/code_safety_test.exs +++ b/test/code_safety_test.exs @@ -448,4 +448,98 @@ defmodule Hypatia.Rules.CodeSafetyTest do assert findings == [] end end + + describe "zig comment stripping and suppression (#834)" do + test "Zig // comment mention of @ptrCast / @alignCast is NOT a finding" do + code = """ + const std = @import("std"); + // The normative idiom is `return @ptrCast(@alignCast(handle));` — see FFI.idr. + /// Doc: never @intToPtr a stale integer. + fn ok() void {} + """ + + assert CodeSafety.scan_content(code, "zig") == [] + end + + test "Zig real @ptrCast in code IS a finding, at the actual line number" do + code = """ + const std = @import("std"); + // harmless commentary + // more commentary + fn state(h: *Opaque) *State { + return @ptrCast(h); + } + """ + + findings = CodeSafety.scan_content(code, "zig") + casts = Enum.find(findings, &(&1.rule == :zig_ptr_cast)) + assert casts, "real @ptrCast must still fire" + assert 5 in casts.lines + end + + test "line numbers survive comment stripping (rust .unwrap after comments)" do + code = """ + // line 1 comment + // line 2 comment + // line 3 comment + fn main() { + x.unwrap() + } + """ + + findings = CodeSafety.scan_content(code, "rust") + unw = Enum.find(findings, &(&1.rule == :unwrap_without_check)) + assert unw + assert 5 in unw.lines + end + + test "hypatia:ignore zig_ptr_cast suppresses the real cast (pragmatic review)" do + code = """ + fn state(h: *Opaque) *State { + // hypatia:ignore zig_ptr_cast -- opaque-handle recovery, reviewed + return @ptrCast(h); + } + """ + + refute Enum.any?(CodeSafety.scan_content(code, "zig"), &(&1.rule == :zig_ptr_cast)) + end + + test "an unchecked @ptrCast WITHOUT a pragma still fires (both directions)" do + code = """ + fn bad(h: *Opaque) *State { + // hypatia:ignore zig_ptr_cast -- covers the line below ONLY + return @ptrCast(h); + } + + fn worse(h: *Opaque) *State { + return @ptrCast(h); + } + """ + + findings = CodeSafety.scan_content(code, "zig") + casts = Enum.find(findings, &(&1.rule == :zig_ptr_cast)) + assert casts, "the unreviewed cast must still fire" + assert 7 in casts.lines + refute 3 in casts.lines + end + + test "hypatia: allow code_safety/zig_ptr_cast bare and qualified forms both work" do + qualified = """ + fn a(h: *Opaque) *State { + // hypatia: allow code_safety/zig_ptr_cast -- reviewed + return @ptrCast(h); + } + """ + + bare = """ + fn b(h: *Opaque) *State { + // hypatia:ignore zig_ptr_cast -- reviewed + return @ptrCast(h); + } + """ + + refute Enum.any?(CodeSafety.scan_content(qualified, "zig"), &(&1.rule == :zig_ptr_cast)) + refute Enum.any?(CodeSafety.scan_content(bare, "zig"), &(&1.rule == :zig_ptr_cast)) + end + end end diff --git a/test/concurrency_test.exs b/test/concurrency_test.exs index c09a44c8..8b93d3d0 100644 --- a/test/concurrency_test.exs +++ b/test/concurrency_test.exs @@ -161,21 +161,25 @@ defmodule Hypatia.Concurrency.SafetyModulesTest do alias Hypatia.Safety.Quarantine setup do - case GenServer.whereis(RateLimiter) do - nil -> start_supervised!(RateLimiter) - _pid -> :ok - end + # Owned instance per test, not the shared app-level one (#857). These + # tests used to reach for whatever `GenServer.whereis(RateLimiter)` + # returned — almost always the application-supervised process — so a + # crash anywhere in the suite silently reset the state they asserted on, + # and their `start_supervised!` branch was dead code. The concurrent + # load below now runs against a process this test owns end to end. + rl_name = :"rate_limiter_conc_#{System.unique_integer([:positive])}" + start_supervised!({RateLimiter, name: rl_name}) case GenServer.whereis(Quarantine) do nil -> start_supervised!(Quarantine) _pid -> :ok end - :ok + %{server: rl_name} end describe "RateLimiter under concurrent load" do - test "concurrent check/1 calls never crash the GenServer" do + test "concurrent check/2 calls never crash the GenServer", %{server: server} do bots = for i <- 1..10, do: "concurrent_bot_#{System.unique_integer([:positive])}_#{i}" # Flood the rate limiter with concurrent calls from multiple bots @@ -183,45 +187,47 @@ defmodule Hypatia.Concurrency.SafetyModulesTest do for bot <- bots do Task.async(fn -> for _ <- 1..5 do - RateLimiter.check(bot) - RateLimiter.record_dispatch(bot) + RateLimiter.check(bot, server) + RateLimiter.record_dispatch(bot, server) end end) end Enum.each(tasks, &Task.await(&1, 5_000)) - # GenServer must still be alive and responsive - pid = GenServer.whereis(RateLimiter) + # The owned GenServer must still be alive and responsive + pid = GenServer.whereis(server) assert pid != nil and Process.alive?(pid), "RateLimiter crashed under concurrent load" - stats = RateLimiter.stats() + stats = RateLimiter.stats(server) assert is_map(stats) assert stats.total_dispatched >= 0 end - test "concurrent record_dispatch/1 increments total monotonically" do + test "concurrent record_dispatch/2 increments total monotonically", %{server: server} do bot = "mono_dispatch_bot_#{System.unique_integer([:positive])}" - before_stats = RateLimiter.stats() + before_stats = RateLimiter.stats(server) tasks = for _ <- 1..10 do Task.async(fn -> for _ <- 1..5 do - RateLimiter.record_dispatch(bot) + RateLimiter.record_dispatch(bot, server) end end) end Enum.each(tasks, &Task.await(&1, 5_000)) - :timer.sleep(100) - after_stats = RateLimiter.stats() + # No sleep needed: every task's casts are sent before Task.await + # returns, and this call is issued after all of them, so the serial + # server has queued them all behind it (#857 AC5 — no timer.sleep). + after_stats = RateLimiter.stats(server) - assert after_stats.total_dispatched >= before_stats.total_dispatched, - "total_dispatched decreased after concurrent dispatches (non-monotonic)" + assert after_stats.total_dispatched == before_stats.total_dispatched + 50, + "expected 50 dispatches recorded on the owned instance, got #{after_stats.total_dispatched - before_stats.total_dispatched}" end end diff --git a/test/cross_repo_learning_test.exs b/test/cross_repo_learning_test.exs index c68877a3..0a8825f5 100644 --- a/test/cross_repo_learning_test.exs +++ b/test/cross_repo_learning_test.exs @@ -106,4 +106,47 @@ defmodule Hypatia.CrossRepoLearningTest do end end end + + describe "primary_language_from_scan_data/1 (#676 determinism)" do + test "declared primary_language wins over counts" do + data = %{"primary_language" => "Idris", "languages" => %{"rust" => 99}} + assert CrossRepoLearning.primary_language_from_scan_data(data) == "idris" + end + + test "higher count wins regardless of priority (both directions)" do + rust_dominant = %{"languages" => %{"rust" => 5, "idris" => 4}} + idris_dominant = %{"languages" => %{"rust" => 4, "idris" => 5}} + + assert CrossRepoLearning.primary_language_from_scan_data(rust_dominant) == "rust" + assert CrossRepoLearning.primary_language_from_scan_data(idris_dominant) == "idris" + end + + test "equal counts break ties by fixed language priority, not enumeration order" do + # DISCRIMINATING TEST: before the #676 fix this resolved via + # Enum.max_by/3 — ties fell to map enumeration order ("idris" first + # in term order), not the documented priority. Fails before, passes + # after. + tie = %{"languages" => %{"idris" => 10, "rust" => 10, "zig" => 10}} + assert CrossRepoLearning.primary_language_from_scan_data(tie) == "rust" + end + + test "equal counts for unlisted languages fall back to lexical order" do + tie = %{"languages" => %{"zebra" => 3, "apple" => 3}} + assert CrossRepoLearning.primary_language_from_scan_data(tie) == "apple" + end + + test "resolution is identical whatever order the map was built in" do + a = Enum.reduce(["idris", "rust", "zig"], %{}, fn l, acc -> Map.put(acc, l, 10) end) + b = Enum.reduce(["zig", "rust", "idris"], %{}, fn l, acc -> Map.put(acc, l, 10) end) + + assert CrossRepoLearning.primary_language_from_scan_data(a) == + CrossRepoLearning.primary_language_from_scan_data(b) + end + + test "absent or empty language data resolves to unknown" do + assert CrossRepoLearning.primary_language_from_scan_data(%{}) == "unknown" + assert CrossRepoLearning.primary_language_from_scan_data(%{"languages" => %{}}) == "unknown" + assert CrossRepoLearning.primary_language_from_scan_data(nil) == "unknown" + end + end end diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index bcaf94ab..d4afd080 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -356,6 +356,65 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do File.rm_rf!(repo) end + test "ignores commented-out continue-on-error (YAML comment is not a swallow)" do + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - name: run tests + # continue-on-error: true + run: cargo test + """) + + assert ResearchExtensions.re005_test_swallows_exit(repo) == [] + File.rm_rf!(repo) + end + + test "ignores || true that only appears in a trailing comment" do + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - name: run-tests + run: cargo test # || true was tried here and removed + """) + + assert ResearchExtensions.re005_test_swallows_exit(repo) == [] + File.rm_rf!(repo) + end + + test "real || true is not shadowed by stripping (both directions)" do + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + - name: run-tests + run: cargo test || true # reviewed swallow + """) + + findings = ResearchExtensions.re005_test_swallows_exit(repo) + assert length(findings) == 1 + File.rm_rf!(repo) + end + + test "honours hypatia:ignore RE005 -- as a reviewed swallow" do + repo = + create_repo_with_workflow(""" + jobs: + x: + steps: + # hypatia:ignore RE005 -- flaky upstream test harness, reviewed 2026-09-26 + - name: run-tests + run: cargo test || true + """) + + assert ResearchExtensions.re005_test_swallows_exit(repo) == [] + File.rm_rf!(repo) + end + test "ignores non-test steps with continue-on-error" do repo = create_repo_with_workflow(""" diff --git a/test/safety_test.exs b/test/safety_test.exs index 8e2536c2..34f54c49 100644 --- a/test/safety_test.exs +++ b/test/safety_test.exs @@ -7,101 +7,118 @@ defmodule Hypatia.Safety.RateLimiterTest do alias Hypatia.Safety.RateLimiter setup do - # RateLimiter may already be started by the OTP application - case GenServer.whereis(RateLimiter) do - nil -> start_supervised!(RateLimiter) - _pid -> :ok - end - - :ok + # This test owns its RateLimiter (#857). `start_supervised!/2` ties the + # process to the test's own supervisor, and the `:name` option keeps it + # out of the app-level shared instance that the application supervisor + # starts (lib/application.ex) and the rest of the suite touches. + # + # The `whereis` / `start_supervised!` branch this setup used to carry + # asserted nothing: the application had almost always started the shared + # process already, so `start_supervised!` never ran and the test neither + # owned the process's lifetime nor its state. Worse, the shared process + # could be killed and restarted with empty `%RateLimiter{}` state + # mid-test — measured as `active_bots: 0` on PR #856 — because + # `drain_queued/1` used to call `FleetDispatcher.dispatch_finding/1` + # unprotected, `check_internal/2` never enforced the burst limit, and a + # drained test entry raised KeyError on `finding.type` inside the + # GenServer. The application supervisor then restarted it, silently + # resetting every counter and window this module asserts on. + name = :"rate_limiter_test_#{System.unique_integer([:positive])}" + start_supervised!({RateLimiter, name: name}) + + %{server: name} end - describe "check/1" do - test "allows first dispatch for a bot" do - assert :ok = RateLimiter.check("echidnabot") + # Ordering note: `record_dispatch/2` is a cast and `stats/1` is a call, + # issued from the SAME test process to the SAME server. Erlang guarantees + # per-pair message ordering and the server processes its mailbox serially, + # so every cast below is observed by the call that follows it. The old + # `:timer.sleep(50)` waits were paper over a race that this ownership + # makes impossible (#857 AC5). + + describe "check/2" do + test "allows first dispatch for a bot", %{server: server} do + assert :ok = RateLimiter.check("echidnabot", server) end - test "allows dispatches within burst limit for fresh bot" do - # Use a unique bot name to avoid shared state from other tests/OTP + test "allows dispatches within burst limit for fresh bot", %{server: server} do bot = "burst_test_bot_#{System.unique_integer([:positive])}" for _ <- 1..9 do - RateLimiter.record_dispatch(bot) + RateLimiter.record_dispatch(bot, server) end - assert :ok = RateLimiter.check(bot) + assert :ok = RateLimiter.check(bot, server) end - test "rate limits when burst threshold exceeded" do + test "rate limits when burst threshold exceeded", %{server: server} do bot = "burst_limit_bot_#{System.unique_integer([:positive])}" for _ <- 1..10 do - RateLimiter.record_dispatch(bot) + RateLimiter.record_dispatch(bot, server) end - assert {:rate_limited, :burst, retry_after} = RateLimiter.check(bot) + assert {:rate_limited, :burst, retry_after} = RateLimiter.check(bot, server) assert is_integer(retry_after) assert retry_after > 0 end - test "different bots have independent windows" do + test "different bots have independent windows", %{server: server} do bot_a = "independent_a_#{System.unique_integer([:positive])}" bot_b = "independent_b_#{System.unique_integer([:positive])}" for _ <- 1..10 do - RateLimiter.record_dispatch(bot_a) + RateLimiter.record_dispatch(bot_a, server) end - assert {:rate_limited, :burst, _} = RateLimiter.check(bot_a) - assert :ok = RateLimiter.check(bot_b) + assert {:rate_limited, :burst, _} = RateLimiter.check(bot_a, server) + assert :ok = RateLimiter.check(bot_b, server) end end - describe "record_dispatch/1" do - test "increments total dispatched count" do - RateLimiter.record_dispatch("echidnabot") - RateLimiter.record_dispatch("echidnabot") - # Give GenServer time to process casts - :timer.sleep(50) + describe "record_dispatch/2" do + test "increments total dispatched count", %{server: server} do + RateLimiter.record_dispatch("echidnabot", server) + RateLimiter.record_dispatch("echidnabot", server) - stats = RateLimiter.stats() - assert stats.total_dispatched >= 2 + stats = RateLimiter.stats(server) + # Exact: this instance is owned by this test and starts empty, so the + # two casts above are all it has seen. + assert stats.total_dispatched == 2 end end - describe "enqueue/1" do - test "increments queue size and rate limited count" do - # The entry must belong to a bot that IS rate limited, otherwise this - # test races the 5s :drain_queue timer: drain_queued/1 pops the entry - # and dispatches it whenever check_internal/2 says :ok, so a tick - # landing inside the 50ms sleep empties the queue and `queue_size >= 1` - # fails. Where that window falls shifts with total suite duration, - # which is why it presented as a seed-dependent flake. Saturating the - # burst limit first makes drain put the entry BACK (rate_limiter.ex - # returns state unchanged on :rate_limited), so the queue is stable — - # and it is the realistic scenario, since work is enqueued precisely - # because its bot is rate limited. + describe "enqueue/2" do + test "increments queue size and rate limited count", %{server: server} do + # The entry must belong to a bot that IS rate limited, otherwise the + # 5s :drain_queue timer can pop and dispatch it between the cast and + # the call below. Saturating the burst limit first makes + # `check_internal/2` (which now enforces burst, see rate_limiter.ex) + # put the entry BACK, so the queue is stable — and it is the realistic + # scenario, since work is enqueued precisely because its bot is rate + # limited. Even if it were popped, the drain path no longer crashes + # the limiter on a raising dispatch (#857). bot = "rhodibot-enqueue-#{System.unique_integer([:positive])}" - for _ <- 1..10, do: RateLimiter.record_dispatch(bot) - :timer.sleep(50) - assert {:rate_limited, :burst, _} = RateLimiter.check(bot) - before = RateLimiter.stats() - RateLimiter.enqueue(%{"bot" => bot, "action" => "test"}) - :timer.sleep(50) + for _ <- 1..10 do + RateLimiter.record_dispatch(bot, server) + end + + assert {:rate_limited, :burst, _} = RateLimiter.check(bot, server) + + before = RateLimiter.stats(server) + RateLimiter.enqueue(%{"bot" => bot, "action" => "test"}, server) - stats = RateLimiter.stats() - # Deltas, not absolutes: other tests share this supervised GenServer's - # global counters. + stats = RateLimiter.stats(server) assert stats.total_queued == before.total_queued + 1 assert stats.total_rate_limited == before.total_rate_limited + 1 - assert stats.queue_size >= 1 + assert stats.queue_size == 1 end end - describe "stats/0" do - test "returns expected stat keys" do - stats = RateLimiter.stats() + describe "stats/2" do + test "returns expected stat keys", %{server: server} do + stats = RateLimiter.stats(server) assert Map.has_key?(stats, :total_dispatched) assert Map.has_key?(stats, :total_queued) @@ -111,23 +128,58 @@ defmodule Hypatia.Safety.RateLimiterTest do assert Map.has_key?(stats, :global_window_size) end - test "counters are non-negative integers" do - stats = RateLimiter.stats() + test "counters are non-negative integers", %{server: server} do + stats = RateLimiter.stats(server) assert is_integer(stats.total_dispatched) and stats.total_dispatched >= 0 assert is_integer(stats.total_queued) and stats.total_queued >= 0 assert is_integer(stats.queue_size) and stats.queue_size >= 0 end - test "tracks active bots after dispatch" do + test "tracks active bots after dispatch", %{server: server} do bot_a = "stats_bot_a_#{System.unique_integer([:positive])}" bot_b = "stats_bot_b_#{System.unique_integer([:positive])}" - RateLimiter.record_dispatch(bot_a) - RateLimiter.record_dispatch(bot_b) - :timer.sleep(50) + RateLimiter.record_dispatch(bot_a, server) + RateLimiter.record_dispatch(bot_b, server) + + stats = RateLimiter.stats(server) + # Exact, not >=: owned instance, fresh state, two dispatches. This is + # the assertion that flaked on PR #856 (`left: 0, right: 2`) when a + # crash of the SHARED limiter reset state between the casts and the + # call — with an owned instance that reset cannot reach it (#857). + assert stats.active_bots == 2 + end + + test "a crash of the shared app-level RateLimiter cannot reset this test's instance", %{ + server: server + } do + # The #857 failure mode, reproduced deliberately and shown to be + # survivable: kill the shared, application-supervised RateLimiter + # mid-test (the supervisor restarts it with empty state). This test's + # own instance must be completely unaffected. Against the old + # shared-state test body, this kill reds "tracks active bots after + # dispatch"; against the owned instance it changes nothing. + bot = "crash_isolation_#{System.unique_integer([:positive])}" + RateLimiter.record_dispatch(bot, server) + + case GenServer.whereis(RateLimiter) do + nil -> + :ok + + shared -> + ref = Process.monitor(shared) + Process.exit(shared, :kill) + + receive do + {:DOWN, ^ref, :process, ^shared, :killed} -> :ok + after + 1_000 -> flunk("shared RateLimiter did not die from Process.exit/2") + end + end - stats = RateLimiter.stats() - assert stats.active_bots >= 2 + stats = RateLimiter.stats(server) + assert stats.active_bots == 1 + assert stats.total_dispatched == 1 end end end diff --git a/test/scanner_suppression_test.exs b/test/scanner_suppression_test.exs index aa43d230..b94bbca9 100644 --- a/test/scanner_suppression_test.exs +++ b/test/scanner_suppression_test.exs @@ -546,4 +546,51 @@ defmodule Hypatia.ScannerSuppressionTest do refute ScannerSuppression.whole_line_comment?("#!/usr/bin/env bash", 40) end end + + describe "placeholder_secret_line?/1 and secret_line_disposition/2 (#746, #748)" do + test "template .envrc placeholder is a placeholder" do + assert ScannerSuppression.placeholder_secret_line?(~s{# export API_KEY="..."}) + end + + test "ghp_/glpat_ with xxxxx filler is a placeholder (both spellings)" do + assert ScannerSuppression.placeholder_secret_line?(~s{# token = "ghp_xxxxxxxxxxxxxxxxxxxx"}) + assert ScannerSuppression.placeholder_secret_line?(~s{# token = "glpat-xxxxxxxxxxxxxxxxxxxx"}) + end + + test "your-* and changeme fillers are placeholders" do + assert ScannerSuppression.placeholder_secret_line?(~s{webhook_secret = "your-webhook-secret"}) + assert ScannerSuppression.placeholder_secret_line?("password = \"changeme\"") + end + + test "a real-looking value is NOT a placeholder (both directions)" do + refute ScannerSuppression.placeholder_secret_line?("token = \"ghp_7Qj3vKpLmN5xRtYwZbC8dFgH4jK6mP9qS2vU\"") + refute ScannerSuppression.placeholder_secret_line?("AWS_SECRET_ACCESS_KEY = \"AKIA1a2B3c4D5e6F7g8H\"") + end + + test "placeholder demotes to medium/report regardless of comment" do + assert {"medium", "report", _} = + ScannerSuppression.secret_line_disposition(~s{# export API_KEY="..."}, 24) + + assert {"medium", "report", _} = + ScannerSuppression.secret_line_disposition(~s{API_KEY="..."}, 3) + end + + test "commented real-looking secret demotes to medium/report, never silences" do + assert {"medium", "report", suffix} = + ScannerSuppression.secret_line_disposition( + "# token = \"ghp_7Qj3vKpLmN5xRtYwZbC8dFgH4jK6mP9qS2vU\"", + 41 + ) + + assert suffix =~ "commented-out" + end + + test "uncommented real-looking secret stays critical/revoke_rotate_and_purge" do + assert {"critical", "revoke_rotate_and_purge", ""} = + ScannerSuppression.secret_line_disposition( + ~s{token = "ghp_7Qj3vKpLmN5xRtYwZbC8dFgH4jK6mP9qS2vU"}, + 41 + ) + end + end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 7b6da740..c5c57b8e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: MPL-2.0 # Start every run from an empty throwaway store. # # `config/test.exs` points :verisimdb_data_path and :annealing_state_path at diff --git a/test_integration.exs b/test_integration.exs index 78e41a9b..1bb82eef 100644 --- a/test_integration.exs +++ b/test_integration.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: MPL-2.0 # Test Hypatia integration with verisim-data IO.puts("=== Hypatia VeriSimDB Integration Test ===\n") diff --git a/verification/PROOF-STATUS.adoc b/verification/PROOF-STATUS.adoc index e99e2e47..53c8a4a2 100644 --- a/verification/PROOF-STATUS.adoc +++ b/verification/PROOF-STATUS.adoc @@ -72,7 +72,10 @@ annealing clamp bounds, nascent-never-auto, veteran-unrestricted |`+verification/proofs/idris2/VerisimdbConnector.idr+` |Idris2 |Completeness (mandatory fields), Soundness (no "`unknown`" fallbacks), -Preservation (transit to Logtalk facts) |COMPLETE +Preservation (transit to Logtalk facts — the retired engine's fact +format; the proof covers the connector's preservation property as +specified; status retained unchanged when the engine was retired +2026-03-06) |COMPLETE |`+verification/proofs/idris2/Quarantine.idr+` |Idris2 |State transitions (ok/soft/hard/permanent), Auto-quarantine triggers, Release @@ -171,7 +174,9 @@ internal types `+Nothing+` * *P4.3* Severity Parsing: total mapping for all valid severity levels * *P4.4* Data Preservation: conversion to Logtalk facts preserves repo, -file, category, and severity +file, category, and severity (Logtalk fact format = the retired engine's; +proof status unchanged by the 2026-03-06 retirement — recorded here so the +claim is not silently rewritten) ==== 5. Quarantine Logic (`+Quarantine.idr+`)