diff --git a/.devcontainer/Containerfile b/.devcontainer/Containerfile index 24f9f41..23532e7 100644 --- a/.devcontainer/Containerfile +++ b/.devcontainer/Containerfile @@ -1,9 +1,9 @@ # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # -# Dev Container image for {{PROJECT_NAME}} +# Dev Container image for cicd-squabbler # Base: Chainguard Wolfi (minimal, supply-chain-secure) -# Build: podman build -t {{PROJECT_NAME}}-dev -f .devcontainer/Containerfile . +# Build: podman build -t cicd-squabbler-dev -f .devcontainer/Containerfile . # `:latest` suits a dev container (rebuilt daily); pin by digest # (...@sha256:) if you need a reproducible dev environment. @@ -26,7 +26,7 @@ RUN groupadd -g 1000 nonroot || true \ && useradd -m -u 1000 -g 1000 -s /bin/bash nonroot || true # Set workspace directory -WORKDIR /workspaces/{{PROJECT_NAME}} +WORKDIR /workspaces/cicd-squabbler # Default shell ENV SHELL=/bin/bash diff --git a/.devcontainer/README.adoc b/.devcontainer/README.adoc index 5eee18b..c53aed9 100644 --- a/.devcontainer/README.adoc +++ b/.devcontainer/README.adoc @@ -1,7 +1,7 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // Copyright (c) Jonathan D.A. Jewell = Dev Container Usage -:author: {{AUTHOR}} <{{AUTHOR_EMAIL}}> +:author: Jonathan D.A. Jewell == Overview @@ -25,4 +25,7 @@ This dev container uses `cgr.dev/chainguard/wolfi-base` with git, curl, bash, an == Customization -Replace `{{PROJECT_NAME}}` placeholders in both `devcontainer.json` and `Containerfile` with your actual project name. Run `just deps` to verify the environment after first launch. +The placeholders this template shipped with are rendered for +`cicd-squabbler`. To customize further, adjust the base image or the package +list in `Containerfile` and the features in `devcontainer.json`. Run +`just deps` to verify the environment after first launch. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a4b33e0..4487530 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// Dev Container configuration for {{PROJECT_NAME}} +// Dev Container configuration for cicd-squabbler // Works with: VS Code Dev Containers, GitHub Codespaces, Gitpod // Container runtime: Podman (recommended) or any OCI-compliant runtime { - "name": "{{PROJECT_NAME}}", + "name": "cicd-squabbler", "build": { "dockerfile": "Containerfile", diff --git a/.envrc b/.envrc index 99e5508..b5dca43 100644 --- a/.envrc +++ b/.envrc @@ -13,7 +13,7 @@ if has guix && [ -f guix.scm ]; then fi # Project environment variables -export PROJECT_NAME="{{PROJECT_NAME}}" +export PROJECT_NAME="cicd-squabbler" export RSR_TIER="infrastructure" # Add non-secret project env vars above (e.g. DATABASE_URL, service endpoints). # Real secrets belong in .env (gitignored) — never commit them to .envrc. diff --git a/.github/.mailmap b/.github/.mailmap index 0ada9de..b47a9b9 100644 --- a/.github/.mailmap +++ b/.github/.mailmap @@ -1 +1 @@ -{{AUTHOR}} <{{AUTHOR_EMAIL}}> <{{AUTHOR_EMAIL_ALT}}> +Jonathan D.A. Jewell diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index adfea58..a255cd2 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -4,25 +4,6 @@ Copyright (c) Jonathan D.A. Jewell --> # Code of Conduct - ## Our Pledge @@ -140,7 +121,7 @@ If you experience or witness unacceptable behaviour, or have any other concerns, | Method | Details | Best For | |--------|---------|----------| -| **Email** | {{CONDUCT_EMAIL}} | Detailed reports, sensitive matters | +| **Email** | j.d.a.jewell@open.ac.uk | Detailed reports, sensitive matters | | **Private Message** | Contact any maintainer directly | Quick questions, minor issues | | **Anonymous Form** | [Link to form if available] | When you need anonymity | @@ -156,8 +137,8 @@ If you experience or witness unacceptable behaviour, or have any other concerns, **What Happens Next** -1. You will receive acknowledgment within **{{RESPONSE_TIME}}** -2. The {{CONDUCT_TEAM}} will review the report +1. You will receive acknowledgment within **48 hours** +2. The the project maintainers will review the report 3. We may ask for additional information 4. We will determine appropriate action 5. We will inform you of the outcome (respecting others' privacy) @@ -173,7 +154,7 @@ All reports will be handled with discretion: ### Conflicts of Interest -If a {{CONDUCT_TEAM}} member is involved in an incident: +If a the project maintainers member is involved in an incident: - They will recuse themselves from the process - Another maintainer or external party will handle the report @@ -183,7 +164,7 @@ If a {{CONDUCT_TEAM}} member is involved in an incident: ## Enforcement Guidelines -The {{CONDUCT_TEAM}} will follow these guidelines in determining consequences: +The the project maintainers will follow these guidelines in determining consequences: ### 1. Correction @@ -235,13 +216,13 @@ For contributors with elevated access (Perimeter 2 or 1): If you believe an enforcement decision was made in error: 1. **Wait 7 days** after the decision (cooling-off period) -2. **Email** {{CONDUCT_EMAIL}} with subject line "Appeal: [Original Report ID]" +2. **Email** j.d.a.jewell@open.ac.uk with subject line "Appeal: [Original Report ID]" 3. **Explain** why you believe the decision should be reconsidered 4. **Provide** any new information not previously available **Appeals Process** -- Appeals are reviewed by a different {{CONDUCT_TEAM}} member than the original +- Appeals are reviewed by a different the project maintainers member than the original - You will receive a response within 14 days - The appeals decision is final - You may only appeal once per incident @@ -315,7 +296,7 @@ We thank these communities for their leadership in creating welcoming spaces. If you have questions about this Code of Conduct: - Open a [Discussion](https://github.com/hyperpolymath/cicd-squabbler/discussions) (for general questions) -- Email {{CONDUCT_EMAIL}} (for private questions) +- Email j.d.a.jewell@open.ac.uk (for private questions) - Contact any maintainer directly --- @@ -328,4 +309,4 @@ We're all here because we care about this project. Let's make it a place where e --- -Last updated: {{CURRENT_YEAR}} · Based on Contributor Covenant 2.1 +Last updated: 2026 · Based on Contributor Covenant 2.1 diff --git a/.github/MAINTAINERS b/.github/MAINTAINERS index 145c4e9..63989c7 100644 --- a/.github/MAINTAINERS +++ b/.github/MAINTAINERS @@ -4,7 +4,7 @@ # Format: Name (role) # Replace placeholders with actual maintainer information. -{{AUTHOR}} <{{AUTHOR_EMAIL}}> (Lead Maintainer) +Jonathan D.A. Jewell (Lead Maintainer) # Additional maintainers: # Name (role) diff --git a/.github/SUPPORT b/.github/SUPPORT index b06c59a..8292521 100644 --- a/.github/SUPPORT +++ b/.github/SUPPORT @@ -2,6 +2,6 @@ For questions, help, and community discussion: -- GitHub Discussions: https://github.com/{{OWNER}}/{{REPO}}/discussions -- GitHub Issues: https://github.com/{{OWNER}}/{{REPO}}/issues +- GitHub Discussions: https://github.com/hyperpolymath/cicd-squabbler/discussions +- GitHub Issues: https://github.com/hyperpolymath/cicd-squabbler/issues - Documentation: See README.adoc in the root directory. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 65acbf3..f6ff9cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,6 @@ SPDX-License-Identifier: CC-BY-SA-4.0 Copyright (c) Jonathan D.A. Jewell --> - # Copilot Instructions @@ -16,7 +15,7 @@ Copyright (c) Jonathan D.A. Jewell - SPDX: `MPL-2.0` on all new files. - Never use AGPL-3.0. -- Copyright: `{{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}>` +- Copyright: `Jonathan D.A. Jewell (hyperpolymath) ` ## Code Style diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 3251f7f..c39d3e1 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -10,7 +10,6 @@ workflows: - 'actions/checkout@v7.0.1' '.github/workflows/dependabot-automerge.yml': - 'dependabot/fetch-metadata@v3.1.0' - - 'step-security/harden-runner@v2.16.0' '.github/workflows/dogfood-gate.yml': - 'actions/checkout@v7.0.1' '.github/workflows/e2e.yml': @@ -57,7 +56,8 @@ workflows: - 'hyperpolymath/standards@8f2ee50841e216cd8c192eeb68953118190f105c' '.github/workflows/security-policy.yml': - 'actions/checkout@v7.0.1' - '.github/workflows/standards-pipeline.yml': [] + '.github/workflows/standards-pipeline.yml': + - 'hyperpolymath/standards@ed5e3f651305dd1ce0d0b5d2d08b97a963634632' '.github/workflows/static-analysis-gate.yml': - 'actions/checkout@v7.0.1' - 'actions/download-artifact@v8.0.1' @@ -218,6 +218,16 @@ dependencies: - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' + 'hyperpolymath/standards@ed5e3f651305dd1ce0d0b5d2d08b97a963634632': + ref: 'ed5e3f651305dd1ce0d0b5d2d08b97a963634632' + commit: 'sha1-ed5e3f651305dd1ce0d0b5d2d08b97a963634632' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'haskell-actions/setup@6037f33647c3f17758a2356c80fc4a53d7e0685d' + - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc': ref: '2d1146689b8cda280b9bc96326124645441f03bc' commit: 'sha1-2d1146689b8cda280b9bc96326124645441f03bc' @@ -233,11 +243,6 @@ dependencies: commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' owner_id: 2242 repo_id: 204253808 - 'step-security/harden-runner@v2.16.0': - ref: 'v2.16.0' - commit: 'sha1-fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594' - owner_id: 88700172 - repo_id: 422287306 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': ref: '6323deb102c322ba6fcbdcafc7e3dddab59af2b6' commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1cf2f18..114bc0f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: CodeQL Security Analysis on: push: diff --git a/.github/workflows/container-build.yml b/.github/workflows/container-build.yml index fa6c089..653f3c6 100644 --- a/.github/workflows/container-build.yml +++ b/.github/workflows/container-build.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: container build on: pull_request: diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 479dd27..5eb73f1 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # # dependabot-automerge.yml — enable GitHub's native auto-merge on # Dependabot pull requests that match a declared severity / ecosystem @@ -57,10 +57,17 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - name: Harden Runner - uses: step-security/harden-runner@v2.16.0 - with: - egress-policy: audit + # RECORDED DECISION (issue #105, 2026-09-26): this job previously ran + # `step-security/harden-runner` in egress-AUDIT mode. That action is not + # on the estate allowlist (hyperpolymath/standards + # rhodium-standard-repositories/actions-allowlist/allowed-actions.json), + # so the governance Allowlist Preflight failed the repo on every run. + # The issue offered two resolutions: (a) allowlist it estate-wide, or + # (b) drop it here. Option (b) was taken: the step was observability + # only (audit mode enforces nothing), and an allowlist change is an + # estate decision that cannot be taken from a single repo. Reversing + # this is a one-line revert IF the estate chooses (a) and adds + # `step-security/harden-runner@*` to the allowlist. - name: Fetch Dependabot metadata id: meta uses: dependabot/fetch-metadata@v3.1.0 diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 7fbbdf6..02c326b 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # dogfood-gate.yml — Hyperpolymath Dogfooding Quality Gate diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b3b60a2..48f29c4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # RSR Standard E2E + Aspect + Benchmark Workflow Template diff --git a/.github/workflows/estate-rules.yml b/.github/workflows/estate-rules.yml index 38b523f..432be6f 100644 --- a/.github/workflows/estate-rules.yml +++ b/.github/workflows/estate-rules.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # Estate Rules — enforces hyperpolymath estate-wide conventions: diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 085655b..a543403 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: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Governance on: diff --git a/.github/workflows/guix-policy.yml b/.github/workflows/guix-policy.yml index 2ddf657..a830cba 100644 --- a/.github/workflows/guix-policy.yml +++ b/.github/workflows/guix-policy.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Guix Package Policy on: push: diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index db15277..efcf77c 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: MPL-2.0 +# This workflow is managed by gh actions-lock. # # Standalone Hypatia security scan (push / PR / weekly). This is NOT a duplicate # of the `hypatia-scan` job in `static-analysis-gate.yml`: that job exists to diff --git a/.github/workflows/instant-sync.yml b/.github/workflows/instant-sync.yml index bf3f085..8a8646f 100644 --- a/.github/workflows/instant-sync.yml +++ b/.github/workflows/instant-sync.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Instant Forge Sync - Triggers propagation to all forges on push/release name: Instant Sync on: diff --git a/.github/workflows/label-triage.yml b/.github/workflows/label-triage.yml index 814a192..fc79947 100644 --- a/.github/workflows/label-triage.yml +++ b/.github/workflows/label-triage.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Label Triage # Classify newly-filed issues against the estate label taxonomy. diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 83ab941..af34c6b 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Labels # Applies the canonical estate label set from .github/labels.json. diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 8ff463a..805b98e 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Mirror to Git Forges on: push: diff --git a/.github/workflows/openssf-compliance.yml b/.github/workflows/openssf-compliance.yml index 0eba1ed..4a3f89c 100644 --- a/.github/workflows/openssf-compliance.yml +++ b/.github/workflows/openssf-compliance.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # OpenSSF Best Practices compliance gate — blocks PRs and pushes that lack # required files or still contain unfilled placeholder tokens. name: OpenSSF Compliance diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 71d0e28..436702e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: GitHub Pages (Ddraig SSG) on: push: diff --git a/.github/workflows/push-email-notify.yml b/.github/workflows/push-email-notify.yml index 438d01a..99a1544 100644 --- a/.github/workflows/push-email-notify.yml +++ b/.github/workflows/push-email-notify.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Dormant push-email notification. ARMED by setting the repo variable # PUSH_EMAIL_ENABLED=true (the single on/off switch). Addresses are pre-filled; # sending needs the org SMTP secrets (SMTP_HOST/PORT/USER/PASS). Inherited by diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5f56e93..c97c118 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Code Quality on: push: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6631a1f..b6600f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # Release workflow — triggered by version tags (v*). diff --git a/.github/workflows/rhodibot.yml b/.github/workflows/rhodibot.yml index 26ca7a0..d43032f 100644 --- a/.github/workflows/rhodibot.yml +++ b/.github/workflows/rhodibot.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # rhodibot.yml — RSR compliance CANARY (report-only) # # Rhodibot does NOT mutate this repository. It never deletes, renames, diff --git a/.github/workflows/runtime-policy.yml b/.github/workflows/runtime-policy.yml index 950aa8a..8e1464b 100644 --- a/.github/workflows/runtime-policy.yml +++ b/.github/workflows/runtime-policy.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Runtime and package-manager policy check. # # Authority: hyperpolymath/standards LANGUAGE-POLICY.adoc §1. diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 03c05a0..c41879d 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Rust CI — thin wrapper calling the shared estate reusable in # hyperpolymath/standards. Configure once, propagate everywhere. # See: docs/CI-REUSABLE-WORKFLOWS.adoc in standards. diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d8f4c60..8dfa29b 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: MPL-2.0 +# This workflow is managed by gh actions-lock. name: OSSF Scorecard on: diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index be3f476..16a3eaf 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Secret Scanner on: pull_request: diff --git a/.github/workflows/security-policy.yml b/.github/workflows/security-policy.yml index b71532d..440c4e3 100644 --- a/.github/workflows/security-policy.yml +++ b/.github/workflows/security-policy.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Security Policy on: push: diff --git a/.github/workflows/static-analysis-gate.yml b/.github/workflows/static-analysis-gate.yml index 3ce342b..203d022 100644 --- a/.github/workflows/static-analysis-gate.yml +++ b/.github/workflows/static-analysis-gate.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # Static Analysis Gate — Required by branch protection rules. # Runs panic-attack and hypatia, deposits findings for gitbot-fleet learning. name: Static Analysis Gate diff --git a/.github/workflows/wellknown-enforcement.yml b/.github/workflows/wellknown-enforcement.yml index 6ce6a1f..0801a2e 100644 --- a/.github/workflows/wellknown-enforcement.yml +++ b/.github/workflows/wellknown-enforcement.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. name: Well-Known Standards (RFC 9116 + RSR) on: push: diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index b81e49c..92e738b 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -1,5 +1,5 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. # workflow-linter.yml - Validates GitHub workflows against RSR security standards # This workflow can be copied to other repos for consistent enforcement name: Workflow Security Linter diff --git a/.machine_readable/ENSAID_CONFIG.a2ml b/.machine_readable/ENSAID_CONFIG.a2ml index 20d824e..61ae6b7 100644 --- a/.machine_readable/ENSAID_CONFIG.a2ml +++ b/.machine_readable/ENSAID_CONFIG.a2ml @@ -89,7 +89,7 @@ version = "1.0.0" # Example: a custom portfolio for this project # [[portfolios.custom]] -# id = "{{project}}-dev" +# id = "cicd-squabbler-dev" # name = "cicd-squabbler Development" # description = "Panels for cicd-squabbler development" # panels = ["valence-shell", "editor-bridge", "build-dashboard"] diff --git a/.machine_readable/ai/.clinerules b/.machine_readable/ai/.clinerules index 30d21a5..32565bc 100644 --- a/.machine_readable/ai/.clinerules +++ b/.machine_readable/ai/.clinerules @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # Authoritative source: docs/AI-CONVENTIONS.md # STARTUP: Read 0-AI-MANIFEST.a2ml first, then .machine_readable/descriptiles/STATE.a2ml. @@ -8,7 +8,7 @@ # All original code: MPL-2.0. # Never AGPL-3.0. MPL-2.0 only as platform-required fallback. # SPDX header required on every source file. -# Copyright: {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright: Jonathan D.A. Jewell (hyperpolymath) # STATE FILES (.machine_readable/ ONLY) # Never create in repo root: STATE.a2ml, META.a2ml, ECOSYSTEM.a2ml, diff --git a/.machine_readable/ai/.cursorrules b/.machine_readable/ai/.cursorrules index c13b393..5255a80 100644 --- a/.machine_readable/ai/.cursorrules +++ b/.machine_readable/ai/.cursorrules @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # Authoritative source: docs/AI-CONVENTIONS.md # Read 0-AI-MANIFEST.a2ml in the repo root FIRST for canonical file locations. @@ -7,7 +7,7 @@ # LICENSE # All original code: MPL-2.0 (SPDX header required on every file). # Never use AGPL-3.0. Fallback to MPL-2.0 only when platform requires it. -# Copyright: {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright: Jonathan D.A. Jewell (hyperpolymath) # STATE FILES # .a2ml metadata files go in .machine_readable/ ONLY. diff --git a/.machine_readable/ai/.windsurfrules b/.machine_readable/ai/.windsurfrules index 30d21a5..32565bc 100644 --- a/.machine_readable/ai/.windsurfrules +++ b/.machine_readable/ai/.windsurfrules @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MPL-2.0 -# Copyright (c) {{CURRENT_YEAR}} {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # Authoritative source: docs/AI-CONVENTIONS.md # STARTUP: Read 0-AI-MANIFEST.a2ml first, then .machine_readable/descriptiles/STATE.a2ml. @@ -8,7 +8,7 @@ # All original code: MPL-2.0. # Never AGPL-3.0. MPL-2.0 only as platform-required fallback. # SPDX header required on every source file. -# Copyright: {{AUTHOR}} ({{OWNER}}) <{{AUTHOR_EMAIL}}> +# Copyright: Jonathan D.A. Jewell (hyperpolymath) # STATE FILES (.machine_readable/ ONLY) # Never create in repo root: STATE.a2ml, META.a2ml, ECOSYSTEM.a2ml, diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index ca1c652..aa092ec 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -7,3 +7,42 @@ Changelog], and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Versioning]. === [Unreleased] + +==== Added + +* fetch: `PinWorkflowActions`, `ReconcileActionsPolicy` and + `SetActionsAllowedAll` moves, plus the Actions-policy why-probe + (`actions/permissions` + `.../selected-actions`) that fires when a required + context resolves to `STARTUP_FAILURE` — issue #15. Includes + `examples/actions-policy-deadlock.json` offline fixture coverage. +* Structural scan now surfaces latent Actions-policy deadlocks (empty/gappy + `selected` allowlists, tag-pinned `uses:` under `sha_pinning_required`) + before any run refuses to start. +* ADR 0002 (`docs/decisions/`), the spec for representing vacuous greens in + the `Outcome` contract — the spec-first remainder of issue #58. + +==== Fixed + +* fetch: classic branch protection is read as a gate surface. Absence from + the ruleset listing is no longer reported as "no gate"; a classic + `branches/{b}/protection` `required_status_checks` populates the gate + identically (contexts unioned when both mechanisms exist), and a 403 there + is a hard error, never a no-gate — issue #100. +* `actions.lock` desync: `standards-pipeline.yml`'s job-level reusable ref + (`hyperpolymath/standards@ed5e3f65…`) was unlisted. Its entry and + dependency record (with transitive closure) are now present — Lock Sync + Gate red on `main`, issue #105. +* All 27 workflow files whose `gh actions-lock` stamp had displaced the SPDX + header off line 1 now carry `# SPDX-License-Identifier: MPL-2.0` as byte 1 + again — `lint-workflows / Check SPDX Headers` red on `main`, issue #105. +* Unrendered `{{PLACEHOLDER}}` tokens across `.devcontainer/`, `.envrc`, + `.github/SUPPORT`, `.github/.mailmap`, `.github/MAINTAINERS`, + `.github/CODE_OF_CONDUCT.md`, `.github/copilot-instructions.md` and the + `.machine_readable/ai/` rule files. + +==== Removed + +* `step-security/harden-runner` from `dependabot-automerge.yml` (audit-mode + observability only): it is not on the estate actions allowlist, so the + governance Allowlist Preflight failed on every run — issue #105, resolution + option (b), reversible if the estate allowlists it instead. diff --git a/README.adoc b/README.adoc index 0a7f631..f5218fe 100644 --- a/README.adoc +++ b/README.adoc @@ -15,7 +15,7 @@ A standalone, per-repo CI/CD fighter that drives a stuck pipeline to a *legitimate* green — or improves red→yellow, or fails and hands the human a structured report. It *wins* the gate; it never cheats past it. -NOTE: Status — *v0.1 in active development (updated 2026-07-17).* The engine and +NOTE: Status — *v0.1 in active development (updated 2026-09-26).* The engine and its formal invariant are real and verified (see below); live gate fetch via `gh` landed in #6; `squabble fight` (classify each red as self-win | escalate | assign-owner), the `squabble-app` loopback HTTP backend, and the boj-server @@ -23,7 +23,11 @@ expert summoning (`--summon`, feature `boj`) landed in #13. `squabble fight --apply` now *enacts* the first appliable self-win — stripping an `on.*.paths` trigger filter that strands a required check — by writing the workflow file (propose remains the default; `--apply` never commits, pushes, or re-runs CI). -Still open: applying the other self-wins (required-context reconcile, +The fetch reads *both* gate surfaces — branch rulesets AND classic branch +protection (#100) — and probes the repository's Actions permissions whenever a +required context refuses to start, so a `STARTUP_FAILURE` is attributed to the +posture that caused it rather than to the reusable's owner or a security scan +(#15). Still open: applying the other self-wins (required-context reconcile, reusable-workflow re-pin — these need the GitHub ruleset API / a target SHA) and the GitHub App *webhook* leg (pending the owner's hosting decision). See `docs/CHARTER.adoc` for the design and open owner decisions. @@ -76,6 +80,16 @@ squabbler run). Every move *satisfies* the gate: . *Non-functional gate* — the required check's own script fails unconditionally (cannot be won from the PR side; mined from `hypatia#566`): recognise, stop re-diagnosing, escalate with evidence. +. *Actions-policy `startup_failure` deadlock* (issue #15) — the repository's + own posture refuses to start a required check: an empty/lacking `selected` + allowlist (`ReconcileActionsPolicy` / `SetActionsAllowedAll` — estate + posture of record is `allowed_actions=all` with `sha_pinning_required=true` + KEPT), or a tag-pinned `uses:` under enforced SHA pinning + (`PinWorkflowActions`). The fetch probes `actions/permissions` whenever a + required context resolves to `STARTUP_FAILURE`, so the fight proposes the + settings/pin move instead of mis-attributing to the reusable's owner or a + security scan. These moves only let a required check *start*; none weakens + the gate. Red→green *code* fixes are deliberately *out of scope* for v0.1: "fixing" a failing test by weakening it would violate squabble ≠ bypass. diff --git a/crates/squabble-cli/src/fetch.rs b/crates/squabble-cli/src/fetch.rs index 9ff0b57..c0d41d9 100644 --- a/crates/squabble-cli/src/fetch.rs +++ b/crates/squabble-cli/src/fetch.rs @@ -172,6 +172,309 @@ fn greens_from_rollup(rollup: &[RollupEntry]) -> Vec { .collect() } +// --------------------------------------------------------------------------- +// Classic branch protection (issue #100) +// +// `rules/branches/{branch}` returns RULESET rules only. A branch protected +// the classic way — `branches/{branch}/protection`, with +// `required_status_checks.contexts` — is genuinely gated, and without this +// fallback squabble reported "no gate" for it. The two mechanisms are +// cumulative (GitHub enforces BOTH when both exist), so the required-context +// set is the union, deduplicated, ruleset first. +// --------------------------------------------------------------------------- + +/// The outcome of probing the classic branch-protection endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ProtectionProbe { + /// 2xx: a protection payload (its `required_status_checks` may still be + /// absent — protection can cover force-pushes/deletions only). + Protected(String), + /// 404: the branch is genuinely not protected the classic way. + NotProtected, + /// 403: protection exists-or-not is INVISIBLE to this token. Never to be + /// read as "no gate" — that would reintroduce, at the API layer, exactly + /// the vacuous-gate conflation the `NoGate` variant removed. + Hidden, +} + +#[derive(Debug, Deserialize)] +struct ClassicProtection { + required_status_checks: Option, +} + +#[derive(Debug, Deserialize)] +struct ClassicRequiredChecks { + /// The long-standing shape: a bare list of context names. + #[serde(default)] + contexts: Vec, + /// The newer shape GitHub added alongside `contexts`: objects carrying + /// `context` (and `app_id`). Both are read and unioned; either may be + /// present without the other. + #[serde(default)] + checks: Vec, +} + +#[derive(Debug, Deserialize)] +struct ClassicCheck { + context: String, +} + +/// Classify a FAILED classic-protection probe call. A 404 means genuinely +/// *not protected*; a 403 means *not visible to this token* — the distinction +/// the defect turns on (issue #100's ⚠ note), so it is pure and directly +/// tested on real `gh api` stderr shapes. Any other failure is an ordinary +/// fetch error (`None` here), never a protection finding. +fn probe_from_stderr(stderr: &str) -> Option { + if stderr.contains("HTTP 404") || stderr.contains("Branch not protected") { + return Some(ProtectionProbe::NotProtected); + } + if stderr.contains("HTTP 403") || stderr.contains("Resource not accessible") { + return Some(ProtectionProbe::Hidden); + } + None +} + +/// Probe the classic branch-protection endpoint for `slug@branch`. +fn probe_classic_protection(slug: &str, branch: &str) -> Result { + let out = Command::new("gh") + .args(["api", &format!("repos/{slug}/branches/{branch}/protection")]) + .output() + .map_err(|e| format!("failed to run `gh api …/protection`: {e}"))?; + if out.status.success() { + return Ok(ProtectionProbe::Protected( + String::from_utf8_lossy(&out.stdout).into_owned(), + )); + } + let stderr = String::from_utf8_lossy(&out.stderr); + probe_from_stderr(&stderr).ok_or_else(|| { + format!( + "`gh api repos/{slug}/branches/{branch}/protection` exited {}: {}", + out.status, + stderr.trim() + ) + }) +} + +/// The context names a classic-protection payload requires. Both wire shapes +/// (`contexts` and `checks[].context`) are unioned. +fn parse_classic_contexts(json: &str) -> Result, String> { + let p: ClassicProtection = serde_json::from_str(json) + .map_err(|e| format!("could not parse branch-protection response: {e}"))?; + let mut out: Vec = Vec::new(); + if let Some(rsc) = p.required_status_checks { + for c in rsc.contexts { + if !out.contains(&c) { + out.push(c); + } + } + for c in rsc.checks.into_iter().map(|c| c.context) { + if !out.contains(&c) { + out.push(c); + } + } + } + Ok(out) +} + +/// The union of required contexts from the two protection APIs, in +/// enforcement order: ruleset contexts first, then classic-only ones. +/// `Hidden` is a hard error even when the ruleset yielded contexts — a gate +/// that cannot be fully seen must not be reasoned about partially (the +/// undercount would read as satisfied when a hidden classic requirement is +/// unmet: a vacuous green). +fn required_contexts_from_apis( + rules_json: &str, + protection: &ProtectionProbe, +) -> Result, FetchError> { + let rules: Vec = serde_json::from_str(rules_json) + .map_err(|e| format!("could not parse ruleset response: {e}"))?; + let mut contexts: Vec = Vec::new(); + for c in rules + .into_iter() + .filter(|r| r.rule_type == "required_status_checks") + .filter_map(|r| r.parameters) + .flat_map(|p| p.required_status_checks) + .map(|c| c.context) + { + if !contexts.contains(&c) { + contexts.push(c); + } + } + match protection { + ProtectionProbe::Protected(json) => { + for c in parse_classic_contexts(json)? { + if !contexts.contains(&c) { + contexts.push(c); + } + } + } + ProtectionProbe::NotProtected => {} + ProtectionProbe::Hidden => { + return Err(FetchError::Failed( + "classic branch protection on this branch is not visible to this token \ + (HTTP 403); refusing to conclude anything about a gate that cannot be \ + fully read — grant the token Administration read, or use one that has it" + .to_string(), + )); + } + } + Ok(contexts) +} + +/// Turn a computed required-context set into the gate half of a fetch: an +/// empty union means neither protection mechanism requires anything — the +/// qualified `NoGate` finding (exit 3), no longer a ruleset-only statement. +fn contexts_or_no_gate( + contexts: Vec, + slug: &str, + branch: &str, +) -> Result, FetchError> { + if contexts.is_empty() { + return Err(FetchError::NoGate { + slug: slug.to_string(), + branch: branch.to_string(), + }); + } + Ok(contexts) +} + +/// The required contexts whose realised run concluded `STARTUP_FAILURE` — +/// the trigger for the Actions-policy why-probe (issue #15). A startup +/// refusal means the repository's own Actions posture, not the workflow's +/// content, is the first thing to check; matching is by exact context name, +/// the same key [`build_gate`] uses. +fn startup_failures_from_rollup(required_contexts: &[String], rollup: &[RollupEntry]) -> Vec { + required_contexts + .iter() + .filter(|req| { + rollup + .iter() + .any(|r| &r.name == *req && r.conclusion.as_deref() == Some("STARTUP_FAILURE")) + }) + .cloned() + .collect() +} +// --------------------------------------------------------------------------- +// Actions-policy why-probe (issue #15) +// +// A required-context STARTUP_FAILURE is *created by repository settings*, not +// by the workflow: `allowed_actions=selected` with a `patterns_allowed` that +// does not cover an external `uses:` (mode 1), or a tag-pinned `uses:` under +// `sha_pinning_required=true` (mode 2). The probe reads both permission +// endpoints and hands the posture to the fight as classification input. +// --------------------------------------------------------------------------- + +/// The repository's live Actions permissions posture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionsPolicy { + /// `all` | `local_only` | `selected` (empty when unknown). + pub allowed_actions: String, + /// Whether full-length-SHA pinning is enforced for every `uses:`. + pub sha_pinning_required: bool, + /// Whether GitHub-owned actions pass under a `selected` posture. + pub github_owned_allowed: bool, + /// `patterns_allowed` (meaningful only under `selected`). + pub patterns_allowed: Vec, +} + +#[derive(Debug, Deserialize)] +struct PermissionsResponse { + allowed_actions: Option, + #[serde(default)] + sha_pinning_required: bool, +} + +#[derive(Debug, Deserialize)] +struct SelectedActionsResponse { + #[serde(default)] + github_owned_allowed: bool, + #[serde(default)] + patterns_allowed: Vec, +} + +/// Combine the two permission endpoints' payloads into an [`ActionsPolicy`]. +/// Pure — the unit of test coverage for [`fetch_actions_policy`]. +/// `selected_json` is required iff `allowed_actions == "selected"`. +fn parse_policy(perms_json: &str, selected_json: Option<&str>) -> Result { + let perms: PermissionsResponse = serde_json::from_str(perms_json) + .map_err(|e| format!("could not parse actions/permissions response: {e}"))?; + let allowed_actions = perms.allowed_actions.unwrap_or_default(); + let (github_owned_allowed, patterns_allowed) = if allowed_actions == "selected" { + let sel_json = selected_json + .ok_or_else(|| "allowed_actions=selected but no selected-actions payload".to_string())?; + let sel: SelectedActionsResponse = serde_json::from_str(sel_json) + .map_err(|e| format!("could not parse selected-actions response: {e}"))?; + (sel.github_owned_allowed, sel.patterns_allowed) + } else { + // Under `all` GitHub-owned refs pass (so does everything else); under + // `local_only` nothing external does. Either way there is no pattern + // list to reconcile — mode 1 is scoped to `selected` (issue #15). + (allowed_actions == "all", Vec::new()) + }; + Ok(ActionsPolicy { + allowed_actions, + sha_pinning_required: perms.sha_pinning_required, + github_owned_allowed, + patterns_allowed, + }) +} + +/// Probe the repository's Actions permissions (`actions/permissions`, plus +/// `actions/permissions/selected-actions` when the posture is `selected`). +/// +/// `.github/settings.yml` CANNOT set these (probot/settings manages branch +/// protection/labels/merges only) — the REST endpoints are the only read +/// surface, exactly as issue #15's DO-NOT pointer states. +pub fn fetch_actions_policy(slug: &str) -> Result { + let perms_json = run_gh(&["api", &format!("repos/{slug}/actions/permissions")])?; + // Only fetch the pattern list when the posture actually uses it — one + // Administration-scoped call fewer for the common `all` posture. + let needs_selected = serde_json::from_str::(&perms_json) + .map_err(|e| format!("could not parse actions/permissions response: {e}"))? + .allowed_actions + .as_deref() + == Some("selected"); + let selected_json = if needs_selected { + Some(run_gh(&[ + "api", + &format!("repos/{slug}/actions/permissions/selected-actions"), + ])?) + } else { + None + }; + parse_policy(&perms_json, selected_json.as_deref()) +} + +/// The result of the Actions-policy why-probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyProbe { + /// No required context resolved to `STARTUP_FAILURE` — the probe was + /// deliberately NOT attempted (it would cost an Administration-scoped + /// call for zero information). Distinct from `Failed`, so a consumer can + /// tell "not needed" from "tried and could not". + NotTriggered, + /// The probe ran; carries the live posture. + Fetched(ActionsPolicy), + /// The probe was required and attempted but failed (no-silent-skip — the + /// host narrates this, then classifies without the posture). + Failed(String), +} + +/// Everything a live fetch assembles for the fight. +#[derive(Debug)] +pub struct FetchBundle { + /// The gate: required contexts bound to their realised runs. + pub gate: Gate, + /// Green checks worth a polarity inspection (issue #58). + pub greens: Vec, + /// Required contexts whose realised run concluded `STARTUP_FAILURE` + /// (issue #15) — the inputs that upgrade a mis-attribution into an + /// Actions-policy move. + pub startup_failed: Vec, + /// The why-probe for those startups. + pub policy_probe: PolicyProbe, +} + /// Parse a jobs-API payload into step outcomes. Pure — the unit of test /// coverage for [`fetch_step_outcomes`]. fn parse_steps(json: &str) -> Result, String> { @@ -247,18 +550,19 @@ impl FetchError { impl std::fmt::Display for FetchError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - // Deliberately says "ruleset rule", not "unprotected branch". - // Classic branch protection lives behind a different endpoint - // (`repos/{slug}/branches/{b}/protection`) and is invisible to the - // rules API this function queries — measured 2026-09-21 on a repo - // whose live 6-context ruleset gate reads as 404 "Branch not - // protected" there. Calling the branch unprotected on this - // evidence would be a false statement about a gated branch. + // Unqualified on purpose (issue #100, criterion 6): the fetch now + // reads BOTH protection mechanisms — the branch rulesets AND the + // classic `branches/{b}/protection` endpoint — so an empty union + // really does mean no required status checks apply. The earlier + // caveated phrasing ("no ruleset rule applies … classic protection + // is not visible to this query") was accurate only while the + // classic API was never consulted; keeping it now would understate + // what was actually checked. Self::NoGate { slug, branch } => write!( f, - "no `required_status_checks` ruleset rule applies to `{slug}` branch \ - `{branch}` — nothing to squabble over. (Classic branch protection is a \ - separate API and is not visible to this query.)" + "no required status checks apply to `{slug}` branch `{branch}` — nothing \ + to squabble over. (Checked both protection mechanisms: branch rulesets \ + and classic branch protection.)" ), Self::Failed(msg) => f.write_str(msg), } @@ -277,7 +581,7 @@ impl From for FetchError { /// `slug` is `owner/repo`. Requires `gh` to be authenticated for that repo — /// the same precondition every other `gh`-based estate tool already has. pub fn run(slug: &str, pr: &str) -> Result { - run_with_greens(slug, pr).map(|(gate, _greens)| gate) + run_bundle(slug, pr).map(|b| b.gate) } /// As [`run`], but also returns the checks that concluded **success**, with the @@ -287,6 +591,19 @@ pub fn run(slug: &str, pr: &str) -> Result { /// ever looks at reds, so a gate that could not run reports green and is never /// inspected — that is the whole fake-green class. pub fn run_with_greens(slug: &str, pr: &str) -> Result<(Gate, Vec), FetchError> { + run_bundle(slug, pr).map(|b| (b.gate, b.greens)) +} + +/// The full live fetch: gate, inspectable greens, and the Actions-policy +/// why-probe inputs (issue #15) for any required context that refused to +/// start. `fight` consumes this; the narrower entry points project from it. +/// +/// Protection-mechanism coverage (issue #100): the required-context set is +/// the UNION of the branch ruleset's `required_status_checks` contexts and +/// classic branch protection's `required_status_checks` — GitHub enforces +/// both when both exist. A 404 on the classic endpoint is *not protected*; a +/// 403 is *not visible to this token*, which is an error, never a "no gate". +pub fn run_bundle(slug: &str, pr: &str) -> Result { let (owner, repo) = slug .split_once('/') .ok_or_else(|| format!("expected `owner/repo`, got `{slug}`"))?; @@ -310,28 +627,29 @@ pub fn run_with_greens(slug: &str, pr: &str) -> Result<(Gate, Vec), pr_view.base_ref_name ), ])?; - let rules: Vec = serde_json::from_str(&rules_json) - .map_err(|e| format!("could not parse ruleset response: {e}"))?; - let required_contexts: Vec = rules - .into_iter() - .filter(|r| r.rule_type == "required_status_checks") - .filter_map(|r| r.parameters) - .flat_map(|p| p.required_status_checks) - .map(|c| c.context) - .collect(); + let protection = probe_classic_protection(slug, &pr_view.base_ref_name)?; + let required_contexts = required_contexts_from_apis(&rules_json, &protection)?; + let required_contexts = + contexts_or_no_gate(required_contexts, slug, &pr_view.base_ref_name)?; - if required_contexts.is_empty() { - return Err(FetchError::NoGate { - slug: format!("{owner}/{repo}"), - branch: pr_view.base_ref_name.clone(), - }); - } + let startup_failed = + startup_failures_from_rollup(&required_contexts, &pr_view.status_check_rollup); + let policy_probe = if startup_failed.is_empty() { + PolicyProbe::NotTriggered + } else { + match fetch_actions_policy(slug) { + Ok(p) => PolicyProbe::Fetched(p), + Err(e) => PolicyProbe::Failed(e), + } + }; - Ok(( - build_gate(&required_contexts, &pr_view.status_check_rollup), - greens_from_rollup(&pr_view.status_check_rollup), - )) + Ok(FetchBundle { + gate: build_gate(&required_contexts, &pr_view.status_check_rollup), + greens: greens_from_rollup(&pr_view.status_check_rollup), + startup_failed, + policy_probe, + }) } #[cfg(test)] @@ -377,12 +695,16 @@ mod tests { assert_eq!(e.exit_code(), FetchError::FAILED_EXIT); } - /// Measured 2026-09-21: a branch with a live 6-context `required_status_checks` - /// ruleset reads as 404 "Branch not protected" on the classic protection - /// endpoint. The two APIs are disjoint, so absence of a *ruleset* rule is not - /// evidence the branch is unprotected, and this message must not say it is. + /// Issue #100, criterion 6 — the stronger claim. Since the fetch reads BOTH + /// protection mechanisms (rulesets AND classic branch protection), the + /// `NoGate` message is the unqualified finding it could not be while it + /// queried only one API. This test is the successor of #99's + /// `the_no_gate_message_does_not_claim_the_branch_is_unprotected`, which + /// guarded against overclaiming on partial evidence. The evidence is no + /// longer partial, so the message must no longer hedge — and must not + /// keep the retired caveated phrasing either. #[test] - fn the_no_gate_message_does_not_claim_the_branch_is_unprotected() { + fn the_no_gate_message_is_now_the_unqualified_finding() { let msg = FetchError::NoGate { slug: "hyperpolymath/MetaManifold-WebUI".into(), branch: "main".into(), @@ -391,13 +713,187 @@ mod tests { assert!(msg.contains("hyperpolymath/MetaManifold-WebUI"), "{msg}"); assert!(msg.contains("main"), "{msg}"); assert!( - msg.contains("ruleset"), - "must say which surface it queried: {msg}" + msg.contains("no required status checks apply"), + "must now state the unqualified finding: {msg}" + ); + assert!( + msg.contains("both protection mechanisms"), + "must say what was actually checked: {msg}" + ); + assert!( + !msg.contains("is not visible to this query"), + "keeps the caveat from the single-API era: {msg}" + ); + } + + // --- Issue #100, criterion 5: a fixture per protection mechanism --- + // + // These four run against the pure seam `required_contexts_from_apis` + + // `contexts_or_no_gate`, which is where the fetch pipeline's behaviour + // per mechanism is decided. The `gh` plumbing that feeds it (`run_bundle`) + // is a thin shell: probe both endpoints, hand the payloads here. + + /// The measured shape of `repos/{o}/{r}/rules/branches/main` for a + /// ruleset-gated branch (2 contexts), with no classic protection. + const RULESET_TWO_CONTEXTS: &str = r#"[ + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + {"context": "build"}, + {"context": "test"} + ], + "strict_required_status_checks_policy": true + } + }, + {"type": "pull_request", "parameters": {}} + ]"#; + + /// The measured shape of `repos/{o}/{r}/branches/main/protection` for a + /// classically-protected branch (both wire shapes present). + const CLASSIC_TWO_CONTEXTS: &str = r#"{ + "url": "https://api.github.com/repos/o/r/branches/main/protection", + "required_status_checks": { + "url": "https://api.github.com/repos/o/r/branches/main/protection/required-status-checks", + "strict": true, + "contexts": ["ci", "lint"], + "checks": [ + {"context": "ci", "app_id": null}, + {"context": "lint", "app_id": 15368} + ], + "contexts_url": "https://api.github.com/repos/o/r/branches/main/protection/required-status-checks/contexts" + }, + "enforce_admins": {"url": "…", "enabled": false} + }"#; + + #[test] + fn ruleset_gated_fixture_yields_the_ruleset_contexts() { + let contexts = + required_contexts_from_apis(RULESET_TWO_CONTEXTS, &ProtectionProbe::NotProtected) + .expect("a ruleset gate must parse"); + assert_eq!(contexts, vec!["build".to_string(), "test".to_string()]); + let gated = contexts_or_no_gate(contexts, "o/r", "main"); + assert!(gated.is_ok(), "a ruleset-gated branch is gated, not NoGate"); + } + + #[test] + fn classic_protected_fixture_yields_the_classic_contexts() { + // Criterion 5's load-bearing case: with an EMPTY ruleset list and a + // classic-protected branch, the fetch must produce the classic + // contexts — asserted as "the outcome is a gate" (the result the CLI + // exits 0 on, never NoGate/3) AND as the exact context list, because + // asserting merely "not 3" would not close the defect. + let gated = contexts_or_no_gate( + required_contexts_from_apis("[]", &ProtectionProbe::Protected( + CLASSIC_TWO_CONTEXTS.to_string(), + )) + .expect("classic protection must parse"), + "o/r", + "main", + ); + let contexts = gated.unwrap_or_else(|e| { + panic!("a classically-protected branch must fetch as gated (exit-0 shape), got {e:?}") + }); + assert_eq!(contexts, vec!["ci".to_string(), "lint".to_string()]); + } + + #[test] + fn both_mechanisms_union_the_contexts() { + // GitHub enforces BOTH mechanisms when both exist, so the gate must + // require the union — ruleset contexts first, classic-only ones after, + // duplicates dropped (`ci` appears in both here). + let classic_overlap = r#"{ + "required_status_checks": { + "contexts": ["ci", "classic-only"], + "checks": [{"context": "ci"}, {"context": "classic-only"}] + } + }"#; + let ruleset_with_ci = r#"[ + {"type": "required_status_checks", + "parameters": {"required_status_checks": [{"context": "ci"}, {"context": "ruleset-only"}]}} + ]"#; + let contexts = required_contexts_from_apis( + ruleset_with_ci, + &ProtectionProbe::Protected(classic_overlap.to_string()), + ) + .expect("union must parse"); + assert_eq!( + contexts, + vec![ + "ci".to_string(), + "ruleset-only".to_string(), + "classic-only".to_string() + ] + ); + } + + #[test] + fn a_genuinely_unprotected_branch_is_the_only_no_gate() { + let result = contexts_or_no_gate( + required_contexts_from_apis("[]", &ProtectionProbe::NotProtected) + .expect("empty inputs must parse"), + "hyperpolymath/MetaManifold-WebUI", + "main", ); + let err = result.expect_err("neither mechanism requires anything"); assert!( - !msg.contains("unprotected"), - "claims the branch is unprotected on evidence that cannot show it: {msg}" + matches!(err, FetchError::NoGate { .. }), + "expected NoGate, got {err:?}" ); + assert_eq!(err.exit_code(), FetchError::NO_GATE_EXIT); + } + + #[test] + fn a_hidden_classic_protection_is_never_read_as_no_gate() { + // The issue's ⚠: a 403 on the protection endpoint must NOT become a + // "no gate" — even when the ruleset query DID return contexts. A + // partially-visible gate cannot be reasoned about at all. + for rules in ["[]", RULESET_TWO_CONTEXTS] { + let err = required_contexts_from_apis(rules, &ProtectionProbe::Hidden) + .expect_err("hidden protection must hard-fail"); + assert!( + matches!(err, FetchError::Failed(_)), + "expected Failed (exit 2), not NoGate: {err:?}" + ); + assert_eq!(err.exit_code(), FetchError::FAILED_EXIT); + assert!( + err.to_string().contains("not visible to this token"), + "must name the visibility failure: {err}" + ); + } + } + + #[test] + fn gh_stderr_shapes_classify_404_and_403() { + // Real `gh api` stderr shapes (measured): a 404 prints "Branch not + // protected", a 403 prints "Resource not accessible by …". + assert_eq!( + probe_from_stderr("gh: Branch not protected (HTTP 404)"), + Some(ProtectionProbe::NotProtected) + ); + assert_eq!( + probe_from_stderr("gh: Resource not accessible by integration (HTTP 403)"), + Some(ProtectionProbe::Hidden) + ); + assert_eq!( + probe_from_stderr("gh: Not Found (HTTP 404)"), + Some(ProtectionProbe::NotProtected) + ); + assert_eq!(probe_from_stderr("gh: validation failed"), None); + } + + #[test] + fn classic_checks_shape_is_read_without_contexts() { + // Some payloads carry only the newer `checks` objects. + let json = r#"{"required_status_checks": {"strict": false, "checks": [{"context": "gate"}]}}"#; + assert_eq!( + parse_classic_contexts(json).expect("parse"), + vec!["gate".to_string()] + ); + // And protection with no status-check requirement at all yields none. + assert!(parse_classic_contexts(r#"{"enforce_admins": {"enabled": true}}"#) + .expect("parse") + .is_empty()); } #[test] @@ -684,3 +1180,100 @@ mod polarity_plumbing_tests { ); } } + +#[cfg(test)] +mod policy_plumbing_tests { + use super::*; + + fn entry_with_conclusion(name: &str, conclusion: Option<&str>) -> RollupEntry { + RollupEntry { + name: name.to_string(), + status: Some("COMPLETED".into()), + conclusion: conclusion.map(String::from), + details_url: None, + } + } + + #[test] + fn only_required_contexts_with_startup_failure_trigger_the_probe() { + let required = vec!["gate / a".to_string(), "gate / b".to_string()]; + let rollup = vec![ + entry_with_conclusion("gate / a", Some("STARTUP_FAILURE")), + entry_with_conclusion("gate / b", Some("SUCCESS")), + // An UN-required startup failure must not trigger a probe either. + entry_with_conclusion("other / c", Some("STARTUP_FAILURE")), + ]; + assert_eq!( + startup_failures_from_rollup(&required, &rollup), + vec!["gate / a".to_string()] + ); + // Nothing startup-failed → NotTriggered is the correct probe outcome. + let quiet = vec![entry_with_conclusion("gate / a", Some("SUCCESS"))]; + assert!(startup_failures_from_rollup(&required, &quiet).is_empty()); + } + + #[test] + fn startup_failure_still_classifies_failed_in_the_gate() { + // The gate model is untouched: STARTUP_FAILURE is a Failed check (the + // SPARK machine knows no fourth state). The why-probe is ADDITIONAL + // information carried beside the gate, never a new gate colour. + assert_eq!( + parse_rollup(&entry_with_conclusion("x", Some("STARTUP_FAILURE"))), + CheckRun::Failed + ); + } + + #[test] + fn the_estate_default_posture_parses() { + // allowed_actions=all + sha_pinning_required=true — the decision of + // record; no selected-actions call is made under it. + let policy = parse_policy( + r#"{"enabled": true, "allowed_actions": "all", "sha_pinning_required": true}"#, + None, + ) + .expect("parse"); + assert_eq!(policy.allowed_actions, "all"); + assert!(policy.sha_pinning_required); + assert!(policy.github_owned_allowed); + assert!(policy.patterns_allowed.is_empty()); + } + + #[test] + fn an_empty_selected_allowlist_parses_with_its_patterns() { + let policy = parse_policy( + r#"{"enabled": true, "allowed_actions": "selected", "sha_pinning_required": true}"#, + Some(r#"{"github_owned_allowed": true, "verified_allowed": false, "patterns_allowed": []}"#), + ) + .expect("parse"); + assert_eq!(policy.allowed_actions, "selected"); + assert!(policy.sha_pinning_required); + assert!(policy.github_owned_allowed); + assert!(policy.patterns_allowed.is_empty()); + } + + #[test] + fn a_populated_selected_allowlist_keeps_its_patterns() { + let policy = parse_policy( + r#"{"enabled": true, "allowed_actions": "selected"}"#, + Some( + r#"{"github_owned_allowed": true, "verified_allowed": true, + "patterns_allowed": ["hyperpolymath/*", "oven-sh/setup-bun@*"]}"#, + ), + ) + .expect("parse"); + assert_eq!( + policy.patterns_allowed, + vec![ + "hyperpolymath/*".to_string(), + "oven-sh/setup-bun@*".to_string() + ] + ); + } + + #[test] + fn a_selected_posture_without_its_second_payload_is_an_error() { + let err = parse_policy(r#"{"enabled": true, "allowed_actions": "selected"}"#, None) + .expect_err("selected without selected-actions cannot be understood"); + assert!(err.contains("no selected-actions payload"), "{err}"); + } +} diff --git a/crates/squabble-cli/src/fight.rs b/crates/squabble-cli/src/fight.rs index 7a90089..279370a 100644 --- a/crates/squabble-cli/src/fight.rs +++ b/crates/squabble-cli/src/fight.rs @@ -42,21 +42,50 @@ pub fn run(rest: &[String]) -> ExitCode { } }; - let (gate, greens) = match load_gate(&args) { - Ok(g) => g, + let bundle = match load_bundle(&args) { + Ok(b) => b, Err(e) => { eprintln!("squabble fight: {e}"); return ExitCode::from(e.exit_code()); } }; - let (context, mut outcome) = squabble_fight::plan_at_root(&gate, &args.slug, &args.repo_root); + // Issue #15: the Actions-policy why-probe. A probe failure must be + // narrated, never silently dropped (no-silent-skip) — classification then + // proceeds WITHOUT the posture, degraded to the same conservative + // attribution the engine always had. + let policy_facts = match &bundle.policy_probe { + fetch::PolicyProbe::Fetched(p) => Some(squabble_fight::workflows::ActionsPolicyFacts { + allowed_actions: p.allowed_actions.clone(), + sha_pinning_required: p.sha_pinning_required, + github_owned_allowed: p.github_owned_allowed, + patterns_allowed: p.patterns_allowed.clone(), + }), + fetch::PolicyProbe::Failed(e) => { + eprintln!( + "squabble fight: Actions-policy probe failed: {e} — \ + classifying without the live posture" + ); + None + } + fetch::PolicyProbe::NotTriggered => None, + }; + let startup_failed: std::collections::HashSet = + bundle.startup_failed.iter().cloned().collect(); + + let (context, mut outcome) = squabble_fight::plan_at_root_with_policy( + &bundle.gate, + &args.slug, + &args.repo_root, + policy_facts.as_ref(), + &startup_failed, + ); // `fight` classifies only reds, so a check that could not run reports green // and is never inspected. Surface those before anything else acts on the // outcome — including `--summon`, which must record its honest // non-dispatch for them. - let vacuity = classify_greens(&args, &greens); + let vacuity = classify_greens(&args, &bundle.greens); attach_vacuity(&mut outcome, &vacuity); // `--apply` enacts the appliable self-win moves (v0.1: path-filter strips) @@ -221,18 +250,24 @@ fn attach_vacuity(outcome: &mut Outcome, moves: &[Move]) { } } -fn load_gate(args: &FightArgs) -> Result<(Gate, Vec), fetch::FetchError> { +fn load_bundle(args: &FightArgs) -> Result { if let Some(path) = &args.gate_file { let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read `{path}`: {e}"))?; // Offline mode inspects no live runs, so there are no green checks to - // classify — an honest empty set, not a silent skip. - return serde_json::from_str(&text) - .map(|g| (g, Vec::new())) - .map_err(|e| fetch::FetchError::Failed(format!("`{path}` is not a valid gate: {e}"))); + // classify and no Actions posture to probe (issue #15 is a live-fetch + // concern) — an honest empty extension, not a silent skip. + let gate: Gate = serde_json::from_str(&text) + .map_err(|e| fetch::FetchError::Failed(format!("`{path}` is not a valid gate: {e}")))?; + return Ok(fetch::FetchBundle { + gate, + greens: Vec::new(), + startup_failed: Vec::new(), + policy_probe: fetch::PolicyProbe::NotTriggered, + }); } match &args.pr { - Some(pr) => fetch::run_with_greens(&args.slug, pr), + Some(pr) => fetch::run_bundle(&args.slug, pr), None => Err(fetch::FetchError::Failed(format!( "need a PR number (live) or `--gate ` (offline).\n{USAGE}" ))), diff --git a/crates/squabble-core/src/moves.rs b/crates/squabble-core/src/moves.rs index d41fac8..2ffd52d 100644 --- a/crates/squabble-core/src/moves.rs +++ b/crates/squabble-core/src/moves.rs @@ -124,6 +124,55 @@ pub enum Move { disposition: OwnershipDisposition, rationale: String, }, + + /// Move 10 — *Actions-policy `startup_failure`, mode 2*: a tag-pinned + /// `uses:` under `sha_pinning_required=true` (issue #15). The repository + /// requires every action pinned to a full-length SHA, and this workflow's + /// `uses:` refs are tags/branches, so GitHub refuses to start the run + /// before any job exists. The winning move is to SHA-pin the offending + /// refs **at source** — the twin of [`Move::RepinReusableWorkflow`], + /// operated on this workflow's own `uses:` lines. + /// + /// Self-win (CI/gate configuration, in lane), propose-only in v0.1: + /// resolving each tag to its current SHA needs the network, so `--apply` + /// does not enact it; the report names the exact refs to pin. Pinning + /// *strengthens* the gate posture (immutable references) and touches no + /// required context, so it cannot be a bypass. + PinWorkflowActions { workflow: String, refs: Vec }, + + /// Move 11 — *Actions-policy `startup_failure`, mode 1*: an external + /// `uses:` absent from a `selected` allowlist (issue #15). The repository + /// sets `allowed_actions=selected` with a `patterns_allowed` list that does + /// not cover an action or reusable workflow this check's workflow needs, so + /// the run is refused at startup. The move reconciles the *policy* to the + /// workflows' real needs by adding the minimal `owner/repo@*` patterns. + /// + /// A legitimate **non-bypass settings move**: it only lets a required + /// check *start*; it drops no required context and weakens no check — + /// `squabble ≠ bypass` holds because the green still comes from the check + /// genuinely passing afterwards. Changing repository Actions permissions + /// is an Administration-realm act, so the host proposes it (with the exact + /// patterns) and the owner effectuates it via the REST + /// `actions/permissions/selected-actions` endpoint — never via + /// `.github/settings.yml`, which cannot express Actions permissions. + ReconcileActionsPolicy { + blocked_ref: String, + add_patterns: Vec, + }, + + /// Move 12 — *Actions-policy `startup_failure`, `selected`-with-empty- + /// `patterns_allowed` special case* (issue #15). An empty allowlist refuses + /// every non-GitHub-owned `uses:` at parse time — the whole + /// `startup_failure`/`jobs=0` silent-death class. Where the estate's + /// decision of record applies (**`allowed_actions=all` with + /// `sha_pinning_required=true` KEPT**), the reconciling posture is + /// `allowed_actions=all`, which makes the empty pattern list moot. + /// + /// Never proposed with pinning disabled: the posture *adds* a gate (SHA + /// pinning enforced) while restoring CI's ability to start. Like + /// [`Move::ReconcileActionsPolicy`] this is a legitimate non-bypass + /// settings move — checks do not get weaker, they get *possible*. + SetActionsAllowedAll, } /// A specialist group the squabbler can summon when a gate is out of its lane. @@ -376,6 +425,26 @@ impl Move { "assign `{check}` → `{owner}` [{}] — {rationale}", disposition.describe() ), + Move::PinWorkflowActions { workflow, refs } => format!( + "SHA-pin {} tag-pinned `uses:` in `{workflow}` ({}) — the repo requires \ + full-length SHAs (`sha_pinning_required`), so tags refuse to start", + refs.len(), + refs.join(", ") + ), + Move::ReconcileActionsPolicy { + blocked_ref, + add_patterns, + } => format!( + "reconcile the Actions allowlist: add {} so `{blocked_ref}` can start \ + (`allowed_actions=selected` coverage gap; keep `sha_pinning_required=true`)", + add_patterns.join(", ") + ), + Move::SetActionsAllowedAll => { + "set `allowed_actions=all` while KEEPING `sha_pinning_required=true` — an \ + empty `selected` allowlist refuses every external `uses:` at startup; the \ + estate default posture makes it moot without weakening any check" + .to_string() + } } } @@ -446,6 +515,21 @@ mod tests { rationale: "produced by the reusable governance workflow; fix belongs upstream" .into(), }, + // Issue #15: the Actions-policy moves. All three only let a required + // check START — none drops a required context or weakens a check, so + // the SPARK invariant (green ⇔ non-empty ∧ all passed) is untouched. + Move::PinWorkflowActions { + workflow: "codeql.yml".into(), + refs: vec![ + "github/codeql-action@v4.38.0".into(), + "actions/checkout@v7.0.1".into(), + ], + }, + Move::ReconcileActionsPolicy { + blocked_ref: "hyperpolymath/standards".into(), + add_patterns: vec!["hyperpolymath/standards@*".into()], + }, + Move::SetActionsAllowedAll, ]; assert!(moves.iter().all(Move::is_legitimate)); } @@ -533,4 +617,29 @@ mod tests { let back: Move = serde_json::from_str(&json).expect("deserialise"); assert_eq!(m, back); } + + #[test] + fn actions_policy_moves_serialise_round_trip() { + // The evidence manifest must carry the Actions-policy moves unchanged + // (issue #15) — they are self-win settings/config proposals whose + // payload IS the actionable content (which refs, which patterns). + for m in [ + Move::PinWorkflowActions { + workflow: "codeql.yml".into(), + refs: vec!["github/codeql-action@v4.38.0".into()], + }, + Move::ReconcileActionsPolicy { + blocked_ref: "oven-sh/setup-bun".into(), + add_patterns: vec!["oven-sh/setup-bun@*".into()], + }, + Move::SetActionsAllowedAll, + ] { + let json = serde_json::to_string(&m).expect("serialise"); + let back: Move = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(m, back); + } + // The kebab-case wire names the JSON consumers see. + let json = serde_json::to_string(&Move::SetActionsAllowedAll).unwrap(); + assert!(json.contains("\"kind\":\"set-actions-allowed-all\""), "{json}"); + } } diff --git a/crates/squabble-fight/Cargo.toml b/crates/squabble-fight/Cargo.toml index c336f42..4af7b1b 100644 --- a/crates/squabble-fight/Cargo.toml +++ b/crates/squabble-fight/Cargo.toml @@ -13,3 +13,8 @@ rust-version.workspace = true [dependencies] squabble-core = { workspace = true } serde_yaml_ng = { workspace = true } + +[dev-dependencies] +# The offline gate fixtures in examples/ parse with serde_json (issue #15 +# regression coverage reads examples/actions-policy-deadlock.json). +serde_json = { workspace = true } diff --git a/crates/squabble-fight/src/lib.rs b/crates/squabble-fight/src/lib.rs index f1b9923..0b922de 100644 --- a/crates/squabble-fight/src/lib.rs +++ b/crates/squabble-fight/src/lib.rs @@ -37,7 +37,7 @@ use squabble_core::outcome::{Escalation, Outcome, OwnerAssignment, Report}; use squabble_core::{diagnose_with_hints, Diagnosis}; use std::collections::{HashMap, HashSet}; use std::path::Path; -use workflows::WorkflowFacts; +use workflows::{ActionsPolicyFacts, WorkflowFacts}; /// The pure planning core: classify every unsatisfied check and assemble the /// [`Outcome`]. Pure over its inputs (no IO beyond the structural file probes, @@ -52,15 +52,44 @@ pub fn plan( repo_root: &Path, context: &RepoContext, facts: &WorkflowFacts, +) -> Outcome { + plan_with_policy(gate, slug, repo_root, context, facts, None, &HashSet::new()) +} + +/// As [`plan`], with the Actions-policy why-probe inputs (issue #15) wired +/// through to classification and to the structural scan: +/// +/// * `policy` — the repository's live Actions permissions posture +/// (`{allowed_actions, sha_pinning_required, patterns_allowed}`), or `None` +/// when the probe was not applicable or failed. `None` degrades to exactly +/// [`plan`]'s behaviour; a probe never *invents* a diagnosis. +/// * `startup_failed` — the required contexts whose realised run concluded +/// `STARTUP_FAILURE`, as observed on the rollup. Only for those does the +/// policy attribution replace the OwnedUpstream/Escalate mis-attribution. +pub fn plan_with_policy( + gate: &Gate, + slug: &str, + repo_root: &Path, + context: &RepoContext, + facts: &WorkflowFacts, + policy: Option<&ActionsPolicyFacts>, + startup_failed: &HashSet, ) -> Outcome { let unsatisfied: Vec<_> = gate.unsatisfied().cloned().collect(); // 1. Per-check classification via workflow ground-truth → hints. The full // check (not just its name) is passed so the classifier can see the - // realised run — the path-filter trap is a *Missing*-only diagnosis. + // realised run — the path-filter trap is a *Missing*-only diagnosis, + // and the Actions-policy moves are STARTUP_FAILURE-only ones. let mut hints: HashMap = HashMap::new(); for check in &unsatisfied { - if let Some(m) = facts.classify(check, slug) { + let m = facts.classify_with_policy( + check, + slug, + policy, + startup_failed.contains(&check.required_context), + ); + if let Some(m) = m { hints.insert(check.required_context.clone(), m); } } @@ -109,6 +138,7 @@ pub fn plan( &unsatisfied, facts, &selfwon_path_filter, + policy, ); escalations.extend(extra_escalations); owner_assignments.extend(extra_owner_assignments); @@ -162,6 +192,31 @@ pub fn plan_at_root(gate: &Gate, slug: &str, repo_root: &Path) -> (RepoContext, (context, outcome) } +/// As [`plan_at_root`], with the Actions-policy why-probe inputs (issue #15). +/// The host obtains `policy` / `startup_failed` from its live fetch (see +/// `squabble-cli`'s `fetch`); offline hosts keep calling [`plan_at_root`], +/// which feeds empty inputs and classifies exactly as before. +pub fn plan_at_root_with_policy( + gate: &Gate, + slug: &str, + repo_root: &Path, + policy: Option<&ActionsPolicyFacts>, + startup_failed: &HashSet, +) -> (RepoContext, Outcome) { + let context = RepoContext::load(repo_root); + let facts = WorkflowFacts::load(repo_root); + let outcome = plan_with_policy( + gate, + slug, + repo_root, + &context, + &facts, + policy, + startup_failed, + ); + (context, outcome) +} + /// Plan with **no filesystem at all** — for hosts serving callers that have no /// local checkout (e.g. the App handling a cartridge request without /// `repo_root`). Deliberately does NOT probe the process's own working @@ -188,6 +243,7 @@ fn structural_scan( unsatisfied: &[squabble_core::gate::RequiredCheck], facts: &WorkflowFacts, selfwon_path_filter: &HashSet, + policy: Option<&ActionsPolicyFacts>, ) -> (Vec, Vec) { let mut escalations = Vec::new(); let mut owner_assignments = Vec::new(); @@ -271,6 +327,78 @@ fn structural_scan( } } + // (d) Latent Actions-policy deadlocks (issue #15) — surfaced BEFORE any + // check has startup_failure'd, from the live posture probe alone: + // `allowed_actions=selected` whose `patterns_allowed` does not cover + // an external `uses:` somewhere in the tree will refuse those runs at + // startup. Settings are the owner's realm, so this is an assignment, + // not a self-win proposal. + if let Some(p) = policy { + if p.is_selected() { + let mut uncovered: Vec = facts + .workflows + .iter() + .filter_map(|w| p.first_uncovered(&w.external_uses).map(str::to_string)) + .collect(); + uncovered.sort(); + uncovered.dedup(); + if !uncovered.is_empty() { + owner_assignments.push(OwnerAssignment { + check: "actions / repository permissions".to_string(), + owner: slug.to_string(), + disposition: OwnershipDisposition::MisconfiguredGate { + detail: format!( + "`allowed_actions=selected` patterns do not cover: {} — any run \ + using them is refused at startup (jobs=0)", + uncovered.join(", ") + ), + }, + rationale: if p.patterns_allowed.is_empty() { + "estate default is `allowed_actions=all` with `sha_pinning_required=true` \ + KEPT (issue #15 decision of record) — adopt that posture, or enumerate \ + owner/repo@* patterns explicitly" + .to_string() + } else { + "add the missing `owner/repo@*` patterns to the selected-actions \ + allowlist (or adopt `allowed_actions=all` with pinning kept)" + .to_string() + }, + }); + } + } + + // (e) Latent tag-pin refusals — tag/branch-pinned `uses:` under + // `sha_pinning_required=true` will refuse at startup. These ARE + // the squabbler's lane once they fail (PinWorkflowActions), so the + // assignment points at the workflow files to pre-emptively pin. + if p.sha_pinning_required { + let mut pinned: Vec = facts + .workflows + .iter() + .filter(|w| !w.tag_pinned_uses.is_empty()) + .map(|w| format!("`{}` ({})", w.file, w.tag_pinned_uses.join(", "))) + .collect(); + pinned.sort(); + if !pinned.is_empty() { + owner_assignments.push(OwnerAssignment { + check: "actions / SHA pinning".to_string(), + owner: slug.to_string(), + disposition: OwnershipDisposition::MisconfiguredGate { + detail: format!( + "tag/branch-pinned `uses:` under `sha_pinning_required=true`: {} — \ + these refuse to start on the next trigger", + pinned.join("; ") + ), + }, + rationale: "SHA-pin the named refs at source (the squabbler's \ + PinWorkflowActions move, issue #15); pinning strengthens, \ + never weakens, the gate" + .to_string(), + }); + } + } + } + (escalations, owner_assignments) } @@ -326,6 +454,8 @@ mod tests { job_ids: job_ids.iter().map(|s| s.to_string()).collect(), job_names: vec![], reusable_repos: reusable.iter().map(|s| s.to_string()).collect(), + external_uses: vec![], + tag_pinned_uses: vec![], path_filtered, retired_descriptile_policy: false, empty_jobs: false, @@ -493,3 +623,214 @@ mod tests { .any(|o| matches!(&o.disposition, OwnershipDisposition::MisconfiguredGate { detail } if detail.contains("AGPL")))); } } + +#[cfg(test)] +mod policy_fixture_tests { + use super::*; + use squabble_core::gate::CheckRun; + use workflows::{ActionsPolicyFacts, WorkflowInfo, WorkflowKind}; + + /// A workflow whose `uses:` surface is specified explicitly — the parse + /// side is covered in workflows.rs; the planner works from the facts. + fn wf_uses( + file: &str, + name: &str, + job_ids: &[&str], + reusable: &[&str], + external: &[&str], + tag_pinned: &[&str], + kind: WorkflowKind, + ) -> WorkflowInfo { + WorkflowInfo { + file: file.to_string(), + name: Some(name.to_string()), + job_ids: job_ids.iter().map(|s| s.to_string()).collect(), + job_names: vec![], + reusable_repos: reusable.iter().map(|s| s.to_string()).collect(), + external_uses: external.iter().map(|s| s.to_string()).collect(), + tag_pinned_uses: tag_pinned.iter().map(|s| s.to_string()).collect(), + path_filtered: false, + retired_descriptile_policy: false, + empty_jobs: false, + kind, + } + } + + /// The host facts matching `examples/actions-policy-deadlock.json`: the + /// `Secret Scanner` required context comes from a standards-reusable + /// caller (SHA-pinned — no mode-2 surface), `analyze` from a workflow + /// that tag-pins checkout and the codeql action. + fn fixture_facts() -> WorkflowFacts { + WorkflowFacts { + workflows: vec![ + wf_uses( + "secret-scanner.yml", + "Secret Scanner", + &["scan"], + &["hyperpolymath/standards"], + &["hyperpolymath/standards@8f2ee50841e216cd8c192eeb68953118190f105c"], + &[], + WorkflowKind::Security, + ), + wf_uses( + "codeql.yml", + "CodeQL Security Analysis", + &["analyze"], + &[], + &["actions/checkout@v7.0.1", "github/codeql-action@v4.38.0"], + &["actions/checkout@v7.0.1", "github/codeql-action@v4.38.0"], + WorkflowKind::Security, + ), + ], + } + } + + fn fixture_gate() -> Gate { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("workspace root"); + let text = std::fs::read_to_string(root.join("examples/actions-policy-deadlock.json")) + .expect("the issue #15 fixture must exist"); + serde_json::from_str(&text).expect("the fixture must parse as a gate") + } + + #[test] + fn the_offline_fixture_diagnoses_to_the_new_moves() { + let gate = fixture_gate(); + assert_eq!(gate.checks.len(), 2, "fixture drifted — keep facts in step"); + assert!(gate + .checks + .iter() + .all(|c| c.run == CheckRun::Failed)); + + // The live-fetch inputs the fixture cannot carry: which contexts the + // rollup showed STARTUP_FAILURE for, and the posture the why-probe + // read. `selected` + empty patterns + pinning kept, the estate's + // measured outage posture. + let startup_failed: HashSet = gate + .checks + .iter() + .map(|c| c.required_context.clone()) + .collect(); + let policy = ActionsPolicyFacts { + allowed_actions: "selected".into(), + sha_pinning_required: true, + github_owned_allowed: true, + patterns_allowed: vec![], + }; + + let report = { + let Outcome::Red { report } = plan_with_policy( + &gate, + "hyperpolymath/cicd-squabbler", + Path::new("/nonexistent"), + &RepoContext::default(), + &fixture_facts(), + Some(&policy), + &startup_failed, + ) else { + panic!("v0.1 always plans Red"); + }; + report + }; + + // 1. The mode-1 check is diagnosed to the settings move — NOT to + // OwnedUpstream{standards}, the mis-attribution issue #15 names. + assert!( + report + .moves_attempted + .iter() + .any(|m| matches!(m, Move::SetActionsAllowedAll)), + "empty allowlist → SetActionsAllowedAll, got {:?}", + report.moves_attempted + ); + assert!(!report + .owner_assignments + .iter() + .any(|o| matches!(&o.disposition, OwnershipDisposition::OwnedUpstream { repo } if repo == "hyperpolymath/standards") + && o.check.contains("Secret Scanner"))); + + // 2. The mode-2 check is diagnosed to the pin move — NOT to + // Escalate{Security}, the other mis-attribution issue #15 names. + assert!(report.moves_attempted.iter().any(|m| matches!( + m, + Move::PinWorkflowActions { workflow, refs } + if workflow == "codeql.yml" && refs.contains(&"github/codeql-action@v4.38.0".to_string()) + ))); + assert!(!report + .escalations + .iter() + .any(|e| e.check == "analyze" && e.group == ExpertGroup::Security)); + } + + #[test] + fn the_same_fixture_without_the_probe_shows_the_old_misattribution() { + // Negative control, both directions at once: no posture, no observed + // startup failure → the planner falls back to exactly the + // attributions issue #15 calls wrong. Proves the fixture's checks CAN + // be re-diagnosed and that the probe inputs are what carries the + // redirect. + let gate = fixture_gate(); + let Outcome::Red { report } = plan_with_policy( + &gate, + "hyperpolymath/cicd-squabbler", + Path::new("/nonexistent"), + &RepoContext::default(), + &fixture_facts(), + None, + &HashSet::new(), + ) else { + panic!("v0.1 always plans Red"); + }; + assert!(report + .owner_assignments + .iter() + .any(|o| matches!(&o.disposition, OwnershipDisposition::OwnedUpstream { .. }))); + assert!(report + .escalations + .iter() + .any(|e| e.group == ExpertGroup::Security)); + assert!(!report + .moves_attempted + .iter() + .any(|m| matches!(m, Move::SetActionsAllowedAll | Move::PinWorkflowActions { .. } | Move::ReconcileActionsPolicy { .. }))); + } + + #[test] + fn latent_policy_deadlocks_surface_before_any_startup_failure() { + // The structural-scan half of issue #15: with the live posture probed, + // tag-pins and allowlist gaps are put into the debate even when no + // check has (yet) refused to start. Gate: one unrelated red, so the + // probes are the only policy findings. + let gate = Gate::new(vec![squabble_core::gate::RequiredCheck::new( + "lint-shell", + CheckRun::Failed, + )]); + let policy = ActionsPolicyFacts { + allowed_actions: "selected".into(), + sha_pinning_required: true, + github_owned_allowed: true, + patterns_allowed: vec![], + }; + let Outcome::Red { report } = plan_with_policy( + &gate, + "hyperpolymath/cicd-squabbler", + Path::new("/nonexistent"), + &RepoContext::default(), + &fixture_facts(), + Some(&policy), + &HashSet::new(), + ) else { + panic!("v0.1 always plans Red"); + }; + assert!(report + .owner_assignments + .iter() + .any(|o| o.check == "actions / repository permissions")); + assert!(report + .owner_assignments + .iter() + .any(|o| o.check == "actions / SHA pinning")); + } +} diff --git a/crates/squabble-fight/src/workflows.rs b/crates/squabble-fight/src/workflows.rs index a3c529a..6a3174d 100644 --- a/crates/squabble-fight/src/workflows.rs +++ b/crates/squabble-fight/src/workflows.rs @@ -43,6 +43,16 @@ pub struct WorkflowInfo { pub job_names: Vec, /// `owner/repo` of every reusable workflow this file `uses:`. pub reusable_repos: Vec, + /// Every external (non-local) `uses:` ref, normalised to `owner/repo@ref` + /// with any subpath collapsed — the same key shape as the actions lockfile + /// and the estate allowlist. Input to the Actions-policy probes (issue + /// #15): the mode-1 classification matches these against + /// `patterns_allowed`. + pub external_uses: Vec, + /// The subset of `external_uses` pinned to a tag or branch rather than a + /// 40-hex SHA. Under `sha_pinning_required=true` these refuse to start — + /// the mode-2 Actions-policy `startup_failure` (issue #15). + pub tag_pinned_uses: Vec, /// True if the file declares an `on.*.paths` trigger filter. pub path_filtered: bool, /// Executable policy contradicts the canonical descriptile location. @@ -52,6 +62,70 @@ pub struct WorkflowInfo { pub kind: WorkflowKind, } +/// The repository's live Actions permissions posture — the *why* behind an +/// Actions-policy `startup_failure`, fetched by the host from +/// `repos/{o}/{r}/actions/permissions` (+ `.../selected-actions`) when a +/// required context resolves to `STARTUP_FAILURE` (issue #15). Plain host +/// data: carrying it here keeps `squabble-core` estate-free. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActionsPolicyFacts { + /// `all`, `local_only`, or `selected`. Empty string = unknown. + pub allowed_actions: String, + /// Whether GitHub requires every `uses:` pinned to a full-length SHA. + pub sha_pinning_required: bool, + /// `github_owned_allowed` from the selected-actions endpoint. + pub github_owned_allowed: bool, + /// `patterns_allowed` (meaningful only when `allowed_actions == selected`). + pub patterns_allowed: Vec, +} + +impl ActionsPolicyFacts { + /// `allowed_actions=selected` — the mode-1 posture. + pub fn is_selected(&self) -> bool { + self.allowed_actions == "selected" + } + + /// Is `owner_repo` (normalised `owner/repo`, no `@ref`) permitted by a + /// `selected` posture? Mirrors the estate preflight + /// (`standards` `scripts/check-allowed-actions.sh`): GitHub-owned + /// `actions/*` and `github/*` pass when `github_owned_allowed`; a pattern + /// covers when it is an exact `owner/repo`, an owner-wide `owner/*`, or a + /// trailing-`*` prefix form such as `r-lib/*`. + /// + /// Deliberately NOT consulted when the posture is not `selected` — under + /// `all` nothing is blocked; under `local_only` *everything* external is + /// blocked but that is out of issue #15's scope, so callers gate on + /// [`Self::is_selected`] first. + pub fn covers(&self, owner_repo: &str) -> bool { + let owner = owner_repo.split('/').next().unwrap_or(""); + if self.github_owned_allowed && matches!(owner, "actions" | "github") { + return true; + } + self.patterns_allowed.iter().any(|p| { + let base = p.split('@').next().unwrap_or(p.as_str()); + base == owner_repo + || base == format!("{owner}/*") + || base + .strip_suffix('*') + .is_some_and(|prefix| !prefix.is_empty() && owner_repo.starts_with(prefix)) + }) + } + + /// The first `external_uses` entry (normalised `owner/repo@ref`) this + /// posture does NOT cover, as `owner/repo` — the minimal unit a + /// `ReconcileActionsPolicy` move must unblock. `None` under any posture + /// other than `selected`, or when everything used is covered. + pub fn first_uncovered<'a>(&self, external_uses: &'a [String]) -> Option<&'a str> { + if !self.is_selected() { + return None; + } + external_uses.iter().find_map(|u| { + let owner_repo = u.split('@').next().unwrap_or(u.as_str()); + (!self.covers(owner_repo)).then_some(owner_repo) + }) + } +} + impl WorkflowInfo { /// Does this workflow emit the given check context? For reusable-workflow /// jobs GitHub reports ` / `, so a check @@ -126,9 +200,68 @@ impl WorkflowFacts { /// A workflow that checks a retired descriptile path, or has only commented /// jobs, is classified as a non-functional gate regardless of [`CheckRun`]. pub fn classify(&self, check: &RequiredCheck, slug: &str) -> Option { + self.classify_with_policy(check, slug, None, false) + } + + /// As [`Self::classify`], but with the Actions-policy why-probe (issue #15) + /// consulted first for a check whose realised run was a `STARTUP_FAILURE`. + /// + /// `startup_failed` must be true only when the host actually *observed* + /// that conclusion for this exact required context (the rollup carries + /// it); `policy` is the repository's live Actions permissions posture, or + /// `None` when the probe was unavailable/not attempted. With `None` the + /// classification degrades to exactly [`Self::classify`]'s behaviour — + /// probing never *invents* a diagnosis. + /// + /// Ordering when both modes fire for the same check: mode-1 (allowlist + /// reconciliation) precedes mode-2 (SHA-pinning), because the allowlist + /// rule is evaluated at startup before the pin rule — and the estate + /// decision-of-record is `allowed_actions=all` with pinning KEPT, so the + /// settings move is the estate-conformant first step; the pin move then + /// surfaces on the next pass once the check can start. + pub fn classify_with_policy( + &self, + check: &RequiredCheck, + slug: &str, + policy: Option<&ActionsPolicyFacts>, + startup_failed: bool, + ) -> Option { let name = check.required_context.as_str(); let w = self.find_emitting(name)?; + // 0. Actions-policy attribution (issue #15). A required-context + // STARTUP_FAILURE is mis-attributed by the rules below (a reusable + // caller reads as OwnedUpstream; a security workflow reads as + // Escalate-Security) when the real cause is the repository's own + // Actions posture refusing to start the run. Both replacement moves + // only let the check START — a legitimate non-bypass outcome. + if startup_failed { + if let Some(p) = policy { + // Mode 1 — external `uses:` not covered by a `selected` + // allowlist. An EMPTY pattern list refuses every external at + // parse time; the estate decision-of-record posture + // (allowed_actions=all, pinning kept) reconciles it. + if let Some(blocked) = p.first_uncovered(&w.external_uses) { + if p.patterns_allowed.is_empty() { + return Some(Move::SetActionsAllowedAll); + } + return Some(Move::ReconcileActionsPolicy { + blocked_ref: blocked.to_string(), + add_patterns: vec![format!("{blocked}@*")], + }); + } + // Mode 2 — a tag-pinned `uses:` under `sha_pinning_required`. + // Applies to GitHub-owned actions too (the pin rule makes no + // ownership exception), so this is not gated on `first_uncovered`. + if p.sha_pinning_required && !w.tag_pinned_uses.is_empty() { + return Some(Move::PinWorkflowActions { + workflow: w.file.clone(), + refs: w.tag_pinned_uses.clone(), + }); + } + } + } + if w.retired_descriptile_policy { return Some(Move::FlagNonFunctionalGate { check: name.to_string(), @@ -226,6 +359,8 @@ fn parse_workflow(file: &str, text: &str) -> WorkflowInfo { let mut job_ids = Vec::new(); let mut job_names = Vec::new(); let mut reusable_repos = Vec::new(); + let mut external_uses = Vec::new(); + let mut tag_pinned_uses = Vec::new(); let mut in_jobs = false; let mut seen_jobs_header = false; @@ -270,6 +405,18 @@ fn parse_workflow(file: &str, text: &str) -> WorkflowInfo { reusable_repos.push(reuse); } } + + if let Some(target) = uses_target(t) { + if let Some(norm) = normalize_external_use(target) { + if !external_uses.contains(&norm) { + external_uses.push(norm.clone()); + } + let reference = norm.rsplit('@').next().unwrap_or_default(); + if !is_sha_pin(reference) && !tag_pinned_uses.contains(&norm) { + tag_pinned_uses.push(norm); + } + } + } } let path_filtered = has_path_filter(text); @@ -281,6 +428,8 @@ fn parse_workflow(file: &str, text: &str) -> WorkflowInfo { job_ids, job_names, reusable_repos, + external_uses, + tag_pinned_uses, path_filtered, retired_descriptile_policy: has_retired_descriptile_policy(text), empty_jobs: has_empty_jobs(text), @@ -461,6 +610,43 @@ fn reusable_repo(line: &str) -> Option { Some(format!("{owner}/{repo}")) } +/// The raw `uses:` target of a trimmed workflow line, with any trailing +/// comment cut and quotes removed. `None` for non-`uses:` lines. +fn uses_target<'a>(t: &'a str) -> Option<&'a str> { + let rest = t.strip_prefix("uses:")?.trim(); + let token = rest.split_whitespace().next()?; + Some(token.trim_matches(['\'', '"'])) +} + +/// Normalise an external (non-local) `uses:` target to `owner/repo@ref`, +/// collapsing any subpath — the same key shape as the actions lockfile, which +/// is what the Actions-policy probes (issue #15) compare `patterns_allowed` +/// against. Local actions (`./`, `$/`) and `docker://` images are not +/// external refs and yield `None`. +fn normalize_external_use(target: &str) -> Option { + if target.starts_with("./") || target.starts_with("$/") || target.starts_with("docker://") { + return None; + } + let at = target.rfind('@')?; + let (path, reference) = (&target[..at], &target[at + 1..]); + if path.is_empty() || reference.is_empty() { + return None; + } + let mut segs = path.split('/'); + let (owner, repo) = (segs.next()?, segs.next()?); + if owner.is_empty() || repo.is_empty() { + return None; + } + Some(format!("{owner}/{repo}@{reference}")) +} + +/// Is `reference` a full-length (40-hex) SHA pin, any case? Anything shorter +/// or non-hex is a tag/branch pin — refused at startup when the repository +/// sets `sha_pinning_required=true` (issue #15, mode 2). +fn is_sha_pin(reference: &str) -> bool { + reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) +} + /// True if the file declares an `on.*.paths` filter (the path-filter trap). fn has_path_filter(text: &str) -> bool { let mut before_jobs = true; @@ -800,3 +986,213 @@ jobs: assert!(!has_empty_jobs("jobs: { test: {} }\n")); } } + +#[cfg(test)] +mod policy_tests { + use super::*; + + /// The two workflows of the `examples/actions-policy-deadlock.json` + /// narrative, as text: a reusable-caller refused by an empty allowlist, + /// and a tag-pinned workflow refused under enforced SHA pinning. + const SECRET_SCANNER: &str = r#" +name: Secret Scanner +on: + pull_request: +permissions: + contents: read +jobs: + scan: + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@8f2ee50841e216cd8c192eeb68953118190f105c + secrets: inherit +"#; + + const CODEQL_TAGGED: &str = r#" +name: CodeQL Security Analysis +on: + pull_request: +permissions: + contents: read +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - uses: github/codeql-action/init@v4.38.0 + - uses: github/codeql-action/analyze@v4.38.0 # inline comment must be cut + - uses: ./local-helper +"#; + + fn policy_facts() -> WorkflowFacts { + WorkflowFacts { + workflows: vec![ + parse_workflow("secret-scanner.yml", SECRET_SCANNER), + parse_workflow("codeql.yml", CODEQL_TAGGED), + ], + } + } + + #[test] + fn external_uses_are_normalised_and_tag_pins_detected() { + let w = parse_workflow("codeql.yml", CODEQL_TAGGED); + // Subpath collapsed, deduplicated, the comment-bearing ref parsed, + // the local action excluded, all in file order. + assert_eq!( + w.external_uses, + vec![ + "actions/checkout@v7.0.1".to_string(), + "github/codeql-action@v4.38.0".to_string(), + ] + ); + assert_eq!(w.tag_pinned_uses, w.external_uses); + assert!(w.reusable_repos.is_empty()); + } + + #[test] + fn sha_pinned_reusable_is_external_but_not_tag_pinned() { + let w = parse_workflow("secret-scanner.yml", SECRET_SCANNER); + assert_eq!( + w.external_uses, + vec!["hyperpolymath/standards@8f2ee50841e216cd8c192eeb68953118190f105c".to_string()] + ); + assert!(w.tag_pinned_uses.is_empty()); + assert_eq!(w.reusable_repos, vec!["hyperpolymath/standards".to_string()]); + } + + #[test] + fn covers_matches_exact_owner_wide_and_prefix_patterns() { + let p = ActionsPolicyFacts { + allowed_actions: "selected".into(), + sha_pinning_required: true, + github_owned_allowed: true, + patterns_allowed: vec![ + "hyperpolymath/*".into(), + "oven-sh/setup-bun@*".into(), + "r-lib/*".into(), + ], + }; + assert!(p.covers("actions/checkout"), "github-owned under github_owned_allowed"); + assert!(p.covers("github/codeql-action")); + assert!(p.covers("hyperpolymath/standards"), "owner-wide"); + assert!(p.covers("oven-sh/setup-bun"), "exact owner/repo"); + assert!(p.covers("r-lib/actions"), "prefix glob"); + assert!(!p.covers("step-security/harden-runner")); + // Without github_owned_allowed the GitHub-owned refs are NOT free. + let strict = ActionsPolicyFacts { + github_owned_allowed: false, + ..p.clone() + }; + assert!(!strict.covers("actions/checkout")); + // Under a non-`selected` posture, first_uncovered is inert (mode 1 is + // scoped to `selected` by issue #15). + let all = ActionsPolicyFacts { + allowed_actions: "all".into(), + ..p + }; + assert!(all.first_uncovered(&["step-security/harden-runner@v2".to_string()]).is_none()); + } + + #[test] + fn empty_selected_allowlist_classifies_to_set_allowed_all() { + // Mode 2 shape (pinning required, tags exist anywhere in the tree) + // AND mode 1 shape (externals uncovered) present at once: mode 1 + // wins per the documented ordering — the allowlist rule is evaluated + // first at startup, and allowed_actions=all is the estate's posture + // of record. + let facts = policy_facts(); + let policy = ActionsPolicyFacts { + allowed_actions: "selected".into(), + sha_pinning_required: true, + github_owned_allowed: true, + patterns_allowed: vec![], + }; + assert_eq!( + facts.classify_with_policy( + &RequiredCheck::new("scan", CheckRun::Failed), + "hyperpolymath/cicd-squabbler", + Some(&policy), + true, + ), + Some(Move::SetActionsAllowedAll), + "empty patterns under selected → the estate default posture" + ); + } + + #[test] + fn selected_allowlist_gap_classifies_to_reconcile_with_the_missing_pattern() { + let facts = policy_facts(); + let policy = ActionsPolicyFacts { + allowed_actions: "selected".into(), + sha_pinning_required: true, + github_owned_allowed: true, + patterns_allowed: vec!["oven-sh/setup-bun@*".into()], + }; + assert_eq!( + facts.classify_with_policy( + &RequiredCheck::new("scan", CheckRun::Failed), + "hyperpolymath/cicd-squabbler", + Some(&policy), + true, + ), + Some(Move::ReconcileActionsPolicy { + blocked_ref: "hyperpolymath/standards".into(), + add_patterns: vec!["hyperpolymath/standards@*".into()], + }) + ); + } + + #[test] + fn tag_pinned_under_sha_pinning_classifies_to_pin_not_escalate_security() { + // Issue #15's canonical mis-attribution: a tag-pinned codeql.yml + // STARTUP_FAILURE used to read as Escalate{Security}. + let facts = policy_facts(); + let policy = ActionsPolicyFacts { + allowed_actions: "all".into(), // no mode-1 surface at all + sha_pinning_required: true, + github_owned_allowed: true, + patterns_allowed: vec![], + }; + assert_eq!( + facts.classify_with_policy( + &RequiredCheck::new("analyze", CheckRun::Failed), + "hyperpolymath/cicd-squabbler", + Some(&policy), + true, + ), + Some(Move::PinWorkflowActions { + workflow: "codeql.yml".into(), + refs: vec![ + "actions/checkout@v7.0.1".into(), + "github/codeql-action@v4.38.0".into(), + ], + }), + "a tag-vs-SHA refusal is a pinning fix, not a security scan" + ); + } + + #[test] + fn without_an_observed_startup_failure_the_policy_branches_stay_silent() { + // classify() (no policy) and classify_with_policy(policy, startup=false) + // must agree EXACTLY — the probe names a cause only when the platform + // reported one. A Security-kind workflow then escalates as before. + let facts = policy_facts(); + let policy = ActionsPolicyFacts { + allowed_actions: "selected".into(), + sha_pinning_required: true, + github_owned_allowed: true, + patterns_allowed: vec![], + }; + let check = RequiredCheck::new("analyze", CheckRun::Failed); + assert_eq!( + facts.classify_with_policy(&check, "o/r", Some(&policy), false), + facts.classify(&check, "o/r") + ); + // The reusable caller keeps its ORIGINAL (mis)attribution when the + // probe was not triggered: evidence first, posture second. + let scan = RequiredCheck::new("scan", CheckRun::Failed); + let degraded = facts.classify_with_policy(&scan, "o/r", Some(&policy), false); + assert!( + matches!(degraded, Some(Move::AssignGateOwner { .. })), + "without STARTUP_FAILURE evidence the reusable reads OwnedUpstream as before: {degraded:?}" + ); + } +} diff --git a/docs/CHARTER.adoc b/docs/CHARTER.adoc index fba7f83..ebe7909 100644 --- a/docs/CHARTER.adoc +++ b/docs/CHARTER.adoc @@ -109,6 +109,31 @@ live `Base` rulesets persist. Because the fix is purely list-trimming with a verifiable satisfaction condition, it is the ideal *first* squabbler move once the tool exists. +. *Actions-policy `startup_failure` deadlock* (issue #15) → a required check +refuses to *start* — zero jobs, no logs — because repository *settings*, +not any workflow content, block it. Two measured modes, one why-probe +(`actions/permissions` + `.../selected-actions`, read whenever a required +context resolves to `STARTUP_FAILURE`): ++ +-- +Mode 1:: `allowed_actions=selected` with `patterns_allowed` not covering an + external `uses:` (empty list ⇒ every external refused at parse time). + Winning move: `ReconcileActionsPolicy` (add the missing `owner/repo@*` + patterns) — or, for the empty-list form, `SetActionsAllowedAll` (the + estate posture of record). +Mode 2:: a tag-pinned `uses:` under `sha_pinning_required=true`, GitHub-owned + actions included (the pin rule has no ownership exception). Winning move: + `PinWorkflowActions` — SHA-pin the refs *at source*, the twin of move 3. +-- ++ +The tradeoff is stated plainly: the preferred posture is +**`allowed_actions=all` while KEEPING `sha_pinning_required=true`** — never +disable pinning — plus SHA-pin the workflow refs. `squabble ≠ bypass` holds: +these moves only let a required check *start*; they drop no required context +and weaken no check. Without either mode's evidence, or with the probe +unavailable, classification degrades to the pre-#15 attribution (OwnedUpstream +/ Escalate), never to an invented cause (fail-closed, no-silent-skip). + == Outcome contract GREEN:: Landed via a permitted (arena) strategy on a legitimately satisfied gate. diff --git a/docs/decisions/0002-vacuous-greens-in-outcome.adoc b/docs/decisions/0002-vacuous-greens-in-outcome.adoc new file mode 100644 index 0000000..549e309 --- /dev/null +++ b/docs/decisions/0002-vacuous-greens-in-outcome.adoc @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) Jonathan D.A. Jewell += Architecture Decision Record: 0002-vacuous-greens-in-outcome + +== 2. Representing vacuous greens in the Outcome contract (issue #58 spec) + +Date: 2026-09-26 + +== Status + +Proposed (spec-first, per issue #58's go/no-go rule — implementation MUST NOT +precede acceptance of this record). + +== Context + +`squabble fight` classifies *red* checks as self-win | escalate | assign-owner. +A gate that could not run reports **green**, so the engine never used to look at +it — the entire fake-green class (a scanner goes missing, a stub writes `[]`, +the check goes green, and the gate silently stops being a gate). Detection of +that class landed in #60: the fetch exposes inspectable greens (job id from +`detailsUrl`), `squabble-core`'s `polarity` module classifies each green against +the host-supplied signature from `.machine_readable/bot_directives/gate_triage.a2ml` +(Axis-0 applicability checked first), and every `Vacuous` verdict projects to +**one and only one** move — `EscalateToExpert{GateTriage, AssessConfidence}` +carrying `run-count`, `stub-rate`, `upstream-exists`, `target-tech-present`. + +What #60 deliberately did **not** do: surface the finding in the machine +contract when the *gate overall* is green. `Outcome` has exactly three terminal +variants — `Green { landed_via, moves_applied }`, `Yellow { .. }`, `Red { report }` — +and none can carry "green, but some green checks never ran". `fight.rs` currently +narrates such findings to stderr only, noting they are "not represented in +--json (that needs an Outcome change; see issue #58)". Issue #58's go/no-go +rule says: *if the change needs a new state, write a spec first and stop.* This +record is that spec. + +The constraint that makes this subtle: the SPARK machine in `spark/` proves +`green ⇔ (non-empty ∧ all passed)` for `CheckRun`/`GateState`. Any new shape +must be an annotation ABOUT greens, never a new gate colour (the outcome +contract already forbids a fourth colour that means "gave up but called it +green" — and a "VacuousGreen" outcome state would be precisely that, as well as +forcing a SPARK re-proof). + +== Decision + +Extend `Outcome::Green` with an additive, defaulted annotation field, rather +than adding a variant: + +[source,rust] +---- +Outcome::Green { + landed_via: LandingStrategy, + moves_applied: Vec, + /// Greens that concluded `success` without doing their job, with + /// evidence. Empty ⇔ every inspected green is genuine. `#[serde(default)]` + /// keeps every previously-serialised Green readable. + #[serde(default)] + vacuous_greens: Vec, +} +---- + +with `VacuousGreen { check: String, evidence: String }` (the evidence string is +the same four-field form the polarity module already emits), or reusing +`Escalation` verbatim if reviewers prefer one shape for all hand-offs. + +Rules the extension must keep: + +. `vacuous_greens` is populated ONLY from `PolarityVerdict::Vacuous` verdicts — + the existing `to_move` projection — never from absence-of-steps (an + uninspectable green stays an stderr narration, per `no-silent-skip`). +. The field NEVER changes which outcome variant is chosen, and NEVER counts as + `moves_applied`. The gate is still honestly Green: the required checks passed; + the annotation tells the human a passing check is not worth what it claims. +. The SPARK proof is untouched: no new `CheckRun`, no new `GateState`, no call + into `Gate::evaluate`. The same precedent that admitted `MissingCause` and + the polarity module applies — an annotation `squabble-core` computes around + the gate, not a gate state. +. `Outcome::Red` keeps today's behaviour (vacuity escalations already attach + to its report); this ADR changes nothing there. +. Deletion of a worthless gate remains a *recommendation to the owner* in the + evidence text, never an enacted transition (issue #58 hard constraint). + +== Consequences + +=== Positive + +- `--json` consumers (rhodibot, hypatia, the estate dashboards) can read the + fake-green finding from the contract instead of scraping stderr. +- The outcome contract keeps exactly three colours; the vacuous-gate class is + representable without a fourth that could be misread as "green anyway". +- No SPARK re-proof cost; the proved invariant statement stays bit-identical. + +=== Negative + +- Additive wire change: hosts with strict decoders must tolerate the new field + (`#[serde(default)]` covers the read side only). +- `Outcome::Green` now carries evidence-like payload; documentation must keep + repeating that the annotation is not a gate verdict. + +=== Neutral + +- `fight.rs`'s stderr narration can stay as the human secondary channel; the + struct field becomes the primary one. diff --git a/examples/actions-policy-deadlock.json b/examples/actions-policy-deadlock.json new file mode 100644 index 0000000..a034f71 --- /dev/null +++ b/examples/actions-policy-deadlock.json @@ -0,0 +1,7 @@ +{ + "_comment": "Issue #15 fixture — the Actions-policy startup_failure deadlock, offline. A STARTUP_FAILURE reads `failed` at the gate layer (the gate model deliberately knows no fourth state); the live rollup's STARTUP_FAILURE conclusion is host-side evidence, supplied to plan_with_policy via its `startup_failed` set, exactly as fetch::startup_failures_from_rollup produces it. `Secret Scanner` mirrors the dogfood target this repo itself showed (a standards-reusable caller refused by an empty `selected` allowlist); `analyze` mirrors the tag-pinned codeql.yml refused under sha_pinning_required. See the policy_plumbing/fixture tests in crates/squabble-fight.", + "checks": [ + { "required_context": "Secret Scanner", "run": "failed" }, + { "required_context": "analyze", "run": "failed" } + ] +}