diff --git a/.github/CICD-CHANGES-2026-06-04.md b/.github/CICD-CHANGES-2026-06-04.md index 672a8a74..8a21131e 100644 --- a/.github/CICD-CHANGES-2026-06-04.md +++ b/.github/CICD-CHANGES-2026-06-04.md @@ -1,3 +1,8 @@ + + # CI/CD Changes — 2026-06-04 **Date:** 2026-06-04 @@ -22,7 +27,7 @@ All 18 workflows in this repository have been updated to include `timeout-minute | Workflow | timeout-minutes | Concurrency Added | Notes | |----------|-----------------|------------------|-------| | `abi-drift.yml` | 15 | | ABI manifest + FFI verification | -| `codeql.yml` | 15 | ✓ | Includes C++ support (has C/C++ headers) | +| `codeql.yml` | 15 | ✓ | JavaScript/TypeScript CodeQL only; Zig FFI is covered by Zig workflows | | `container-publish.yml` | 30 | | Container build & push | | `dogfood-gate.yml` | 5-15 | ✓ | 6 jobs: a2ml(5), k9(5), empty-lint(15), groove(5), eclexiaiser(5), summary(5) | | `e2e.yml` | 15 | ✓ | MCP bridge input fuzz tests | @@ -54,8 +59,13 @@ All 18 workflows in this repository have been updated to include `timeout-minute ## CodeQL Configuration -**Languages:** `javascript-typescript` + `cpp` -**Reason:** This repository contains C/C++ headers in the FFI layer. +**Languages:** `javascript-typescript` + +**Reason:** The FFI implementation is Zig. The tracked C ABI file is a generated +header-only surface (`generated/abi/boj_catalogue.h`), not a C/C++ translation +unit; enabling CodeQL `cpp` for headers alone makes extraction fail before +analysis. Re-add `cpp` only when tracked `.c`, `.cc`, `.cpp`, or `.cxx` sources +exist. --- diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4cb86bb3..ee97c746 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,8 +33,10 @@ jobs: include: - language: javascript-typescript build-mode: none - - language: cpp - build-mode: none + # C/C++ CodeQL is intentionally not enabled for the generated + # header-only ABI surface. The FFI implementation is Zig and is + # covered by the Zig workflows; re-add cpp only when tracked + # .c/.cc/.cpp/.cxx translation units exist. steps: - name: Checkout diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 76d824db..d18476e8 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -165,6 +165,9 @@ jobs: echo "::warning file=${REL_PATH}::Invisible Unicode characters detected (zero-width space, BOM, NBSP, etc.)" done < /tmp/empty-lint-results.txt + - name: Check shebang placement + run: bash scripts/check-shebang-first.sh + - name: Write summary run: | if [ "${{ steps.lint.outputs.ready }}" = "true" ]; then @@ -374,4 +377,3 @@ jobs: *Generated by the [Dogfood Gate](https://github.com/hyperpolymath/rsr-template-repo) workflow.* *Dogfooding is guinea pig fooding — we test our tools on ourselves.* EOF - diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 1b4e269a..8161ec24 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -1,35 +1,16 @@ -# SPDX-License-Identifier: MPL-2.0 -# governance.yml — single wrapper calling the shared estate governance bundle -# in hyperpolymath/standards instead of carrying per-repo copies. -# -# Replaces the per-repo governance scaffolding removed in the same commit: -# quality.yml, guix-nix-policy.yml, npm-bun-blocker.yml, ts-blocker.yml, -# security-policy.yml, rsr-antipattern.yml, wellknown-enforcement.yml, -# workflow-linter.yml -# -# Load-bearing build/security workflows stay standalone in the repo -# (rust-ci, codeql, dependabot, release, scan/mirror/pages plumbing). - +# SPDX-License-Identifier: PMPL-1.0-or-later name: Governance on: push: branches: [main, master] pull_request: + branches: [main, master] workflow_dispatch: -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@861b5e911d9e5dcfb3c0ab3dd2a9a3c8fd0a1613 - timeout-minutes: 10 + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@5a93d9d57cc04de4002d6d0ecd336fc7a8698910 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 127905d2..e7158485 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,415 +1,19 @@ -# SPDX-License-Identifier: MPL-2.0 -# Hypatia Neurosymbolic CI/CD Security Scan +# SPDX-License-Identifier: PMPL-1.0-or-later name: Hypatia Security Scan on: push: - branches: [ main, master, develop ] + branches: [main, master, develop] pull_request: - branches: [ main, master ] + branches: [main, master] schedule: - - cron: '0 0 * * 0' # Weekly on Sunday + - cron: '0 0 * * 0' workflow_dispatch: -# Estate guardrail: cancel superseded runs so re-pushes don't pile up -# queued runs across the estate. Safe here because this workflow only -# performs read-only checks/lint/test/scan with no publish or mutation. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true permissions: contents: read - # security-events: write serves two purposes (write implies read): - # 1. read — lets the built-in GITHUB_TOKEN query this repo's own - # Dependabot alerts via the Hypatia DependabotAlerts rule - # (DA001-DA004). Without read, `scan_from_path` gets HTTP 403 - # and the rule silently returns no findings. - # See 007-lang/audits/audit-dependabot-automation-gap-2026-04-17.md. - # 2. write — lets the "Upload SARIF to code scanning" step publish - # Hypatia findings to the Security → Code scanning page so they - # are triaged/deduplicated like CodeQL alerts instead of living - # only in a build artifact nobody is required to look at. - # See hyperpolymath/burble#35 (SARIF integration). - # This is a single-job workflow, so job-level scoping would not - # narrow the grant further; it stays workflow-level and documented. - security-events: write - # pull-requests: write lets the advisory "Comment on PR with findings" - # step post its summary. Without it the built-in GITHUB_TOKEN gets - # "Resource not accessible by integration" and (absent continue-on-error) - # hard-fails the scan — exactly what the gate-decoupling design forbids. - pull-requests: write + security-events: read jobs: scan: - name: Hypatia Neurosymbolic Analysis - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # Full history for better pattern analysis - - - name: Setup Elixir for Hypatia scanner - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2 - with: - elixir-version: '1.18' - otp-version: '27' - - - name: Clone Hypatia - run: | - if [ ! -d "$HOME/hypatia" ]; then - git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" - fi - - - name: Build Hypatia scanner (if needed) - run: | - cd "$HOME/hypatia" - if [ ! -f hypatia ]; then - echo "Building hypatia scanner..." - mix deps.get - mix escript.build - fi - - - name: Run Hypatia scan - id: scan - env: - # Pass the built-in Actions token through to Hypatia so the - # DependabotAlerts rule can query this repo's own alerts. - # For cross-repo scanning (fleet-coordinator scan-supervised), - # a PAT with `security_events` scope is required instead. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - echo "Scanning repository: ${{ github.repository }}" - - # Run scanner (exits non-zero when findings exist — suppress to continue) - HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json || true - - # Count findings - FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) - echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT - - # Extract severity counts - CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json) - HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json) - MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json) - - echo "critical=$CRITICAL" >> $GITHUB_OUTPUT - echo "high=$HIGH" >> $GITHUB_OUTPUT - echo "medium=$MEDIUM" >> $GITHUB_OUTPUT - - echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY - echo "- Total findings: $FINDING_COUNT" >> $GITHUB_STEP_SUMMARY - echo "- Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY - echo "- High: $HIGH" >> $GITHUB_STEP_SUMMARY - echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY - - - name: Upload findings artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: hypatia-findings - path: hypatia-findings.json - retention-days: 90 - - - name: Convert Hypatia findings to SARIF - # Always runs (no findings_count guard): an EMPTY SARIF run is - # valid and intentional — uploading it clears stale Hypatia - # alerts from the code-scanning page when a repo goes clean. - # The converter is dependency-free Node (Node ships on - # ubuntu-latest; no npm install — estate npm ban respected) and - # is hardened against the heterogeneous Hypatia JSON schema: - # most findings are {rule_module,severity,type,file,reason, - # action}; only some carry an integer `line`; `file` may be - # empty or absolute. See lib/hypatia/cli.ex (collect_findings). - run: | - cat > "$RUNNER_TEMP/hypatia-sarif.cjs" <<'CJS' - const fs = require('fs'); - const path = require('path'); - const crypto = require('crypto'); - - const ws = process.env.GITHUB_WORKSPACE || process.cwd(); - - let findings = []; - try { - const parsed = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); - if (Array.isArray(parsed)) findings = parsed; - } catch (_) { - // Scanner unavailable / empty / malformed -> empty SARIF. - // Intentionally clears stale alerts rather than erroring. - findings = []; - } - - // Mirrors Hypatia's own "github" annotation mapping - // (lib/hypatia/cli.ex output/2): critical|high -> error, - // medium -> warning, everything else -> note. - const levelFor = (sev) => { - switch (String(sev || '').toLowerCase()) { - case 'critical': - case 'high': return 'error'; - case 'medium': return 'warning'; - default: return 'note'; - } - }; - - // SARIF artifactLocation.uri must be a repo-relative POSIX - // path. Hypatia may emit absolute paths (scanned under - // $GITHUB_WORKSPACE) or "" / "." for repo-level findings. - const relUri = (file) => { - if (!file) return '.'; - let f = String(file); - if (path.isAbsolute(f)) { - const rel = path.relative(ws, f); - f = (rel && !rel.startsWith('..')) ? rel : path.basename(f); - } - f = f.replace(/\\/g, '/').replace(/^\.\//, ''); - return f || '.'; - }; - - const rules = new Map(); - const results = findings.map((f) => { - const mod = String(f.rule_module || 'hypatia'); - const type = String(f.type || 'finding'); - const ruleId = `hypatia/${mod}/${type}`; - const level = levelFor(f.severity); - if (!rules.has(ruleId)) { - rules.set(ruleId, { - id: ruleId, - name: `${mod}.${type}`, - shortDescription: { text: `Hypatia ${mod}: ${type}` }, - defaultConfiguration: { level } - }); - } - const uri = relUri(f.file); - const msg = String(f.reason || f.type || 'Hypatia finding'); - const startLine = - Number.isInteger(f.line) && f.line > 0 ? f.line : 1; - // Stable cross-run fingerprint for dedupe (no line, so a - // moved finding in the same file/rule stays one alert). - const fp = crypto - .createHash('sha256') - .update([ruleId, uri, type, msg].join('|')) - .digest('hex'); - return { - ruleId, - level, - message: { text: msg }, - locations: [ - { - physicalLocation: { - artifactLocation: { uri }, - region: { startLine } - } - } - ], - partialFingerprints: { 'hypatiaFindingHash/v1': fp } - }; - }); - - const sarif = { - $schema: 'https://json.schemastore.org/sarif-2.1.0.json', - version: '2.1.0', - runs: [ - { - tool: { - driver: { - name: 'Hypatia', - informationUri: 'https://github.com/hyperpolymath/hypatia', - rules: Array.from(rules.values()) - } - }, - results - } - ] - }; - - fs.writeFileSync('hypatia.sarif', JSON.stringify(sarif, null, 2)); - console.log(`hypatia.sarif written: ${results.length} result(s).`); - CJS - node "$RUNNER_TEMP/hypatia-sarif.cjs" - - - name: Upload SARIF to GitHub code scanning - # Fork PRs get a read-only GITHUB_TOKEN, so security-events:write - # is unavailable and upload-sarif cannot publish — skip there - # rather than hard-fail (the push/schedule run on the default - # branch is the authoritative upload). Same-repo PRs and pushes - # do upload. This step is deliberately NOT continue-on-error: - # if the security-surface integration breaks we want a loud red, - # not a silently-ungated scanner (the exact failure mode #35 - # exists to end). The empty-SARIF "clear stale alerts" path is - # handled in the converter above and does not error here. - if: >- - always() && - (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.fork != true) - uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.28.1 - with: - sarif_file: hypatia.sarif - # Distinct category so Hypatia results coexist with CodeQL's - # (codeql.yml) instead of overwriting them on the same surface. - category: hypatia - - - name: Submit findings to gitbot-fleet (Phase 2) - if: steps.scan.outputs.findings_count > 0 - # Phase 2 is the collaborative LEARNING side-channel ("bots share - # findings via gitbot-fleet"), not the security gate. The gate is - # the baseline-aware "Check for critical or high-severity issues" - # step below. A fleet-side regression (e.g. the submit script being - # moved/removed) must NEVER hard-fail every consuming repo's scan. - # Same reasoning as the "Comment on PR with findings" step. - # See hyperpolymath/hypatia#213 (gate decoupling) and the exit-127 - # estate-wide breakage when gitbot-fleet/scripts/submit-finding.sh - # no longer existed on the default branch. - continue-on-error: true - env: - # All GitHub context values surface as env vars so the run - # block never interpolates `${{ … }}` inline (closes the - # workflow_audit/unsafe_curl_payload + actions_expression_injection - # findings). - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FLEET_PUSH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} - FLEET_DISPATCH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_SHA: ${{ github.sha }} - FINDINGS_COUNT: ${{ steps.scan.outputs.findings_count }} - run: | - echo "📤 Submitting $FINDINGS_COUNT findings to gitbot-fleet..." - - # Clone gitbot-fleet to temp directory. A clone failure (network, - # repo gone) is non-fatal: learning submission is best-effort. - FLEET_DIR="/tmp/gitbot-fleet-$$" - if ! git clone --depth 1 https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR"; then - echo "::warning::Could not clone gitbot-fleet — skipping Phase 2 learning submission (non-fatal)." - exit 0 - fi - - # The submission script's location in gitbot-fleet has drifted - # before (it was absent from the default branch, which exit-127'd - # every consuming repo's scan). Probe known locations rather than - # hard-coding one path, and skip gracefully if none is present. - SUBMIT_SCRIPT="" - for cand in \ - "$FLEET_DIR/scripts/submit-finding.sh" \ - "$FLEET_DIR/scripts/submit_finding.sh" \ - "$FLEET_DIR/bin/submit-finding.sh" \ - "$FLEET_DIR/submit-finding.sh"; do - if [ -f "$cand" ]; then - SUBMIT_SCRIPT="$cand" - break - fi - done - - if [ -z "$SUBMIT_SCRIPT" ]; then - echo "::warning::gitbot-fleet submit-finding script not found at any known path — skipping Phase 2 learning submission (non-fatal). Findings are still uploaded as an artifact and gated below." - rm -rf "$FLEET_DIR" - exit 0 - fi - - # Run submission script. Pass the findings path as ABSOLUTE — - # the script cd's into its own working dir before reading the - # file, so a relative path would resolve to the wrong place. - # A submission-script failure is logged but non-fatal. - if bash "$SUBMIT_SCRIPT" "$GITHUB_WORKSPACE/hypatia-findings.json"; then - echo "✅ Finding submission complete" - else - echo "::warning::gitbot-fleet submission script exited non-zero — Phase 2 learning submission skipped (non-fatal)." - fi - - # Cleanup - rm -rf "$FLEET_DIR" - - - name: Check for critical issues - if: steps.scan.outputs.critical > 0 - # GATING POLICY (explicit, by design — not an oversight): - # Hypatia is ADVISORY here. Critical findings are surfaced - # (step annotation + SARIF alert on the code-scanning page + - # PR comment) but do NOT fail this check. Enforcement is - # delegated to the code-scanning surface: tighten by adding a - # branch-protection "required" status on the `hypatia` SARIF - # category, not by reintroducing an `exit 1` here. This keeps - # the gate decision in one auditable place (hypatia#213 gate - # decoupling) and lets a repo opt into fail-on-critical without - # editing this canonical workflow. To change the policy, change - # branch protection — deliberately no commented-out `exit 1`. - run: | - echo "::warning::Hypatia found critical security issue(s) — advisory." - echo "See the Security → Code scanning page (category: hypatia)" - echo "and the hypatia-findings.json artifact for details." - - - name: Generate scan report - run: | - cat << EOF > hypatia-report.md - # Hypatia Security Scan Report - - **Repository:** ${{ github.repository }} - **Scan Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") - **Commit:** ${{ github.sha }} - - ## Summary - - | Severity | Count | - |----------|-------| - | Critical | ${{ steps.scan.outputs.critical }} | - | High | ${{ steps.scan.outputs.high }} | - | Medium | ${{ steps.scan.outputs.medium }} | - | **Total**| ${{ steps.scan.outputs.findings_count }} | - - ## Next Steps - - 1. Triage findings on the **Security → Code scanning** page - (SARIF category \`hypatia\`) — dismiss/track them there like - CodeQL alerts. - 2. The full finding set is also attached as the - \`hypatia-findings.json\` build artifact for offline review. - 3. Findings are **advisory** today (surfaced, not gated); the - gating policy is documented in the workflow's "Check for - critical issues" step. - - ## Learning - - These findings feed Hypatia's learning engine to improve future rules. - - --- - *Powered by [Hypatia](https://github.com/hyperpolymath/hypatia) - Neurosymbolic CI/CD Intelligence* - EOF - - cat hypatia-report.md >> $GITHUB_STEP_SUMMARY - - - name: Comment on PR with findings - if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0 - # Advisory only — posting findings as a PR comment must never gate - # the scan (hypatia#213 gate decoupling). Belt-and-braces alongside - # the pull-requests: write permission above: a token/API hiccup or - # a fork PR (read-only token) skips the comment, not the check. - continue-on-error: true - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7 - with: - script: | - const fs = require('fs'); - const findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); - - const critical = findings.filter(f => f.severity === 'critical').length; - const high = findings.filter(f => f.severity === 'high').length; - - let comment = `## 🔍 Hypatia Security Scan\n\n`; - comment += `**Findings:** ${findings.length} issues detected\n\n`; - comment += `| Severity | Count |\n|----------|-------|\n`; - comment += `| 🔴 Critical | ${critical} |\n`; - comment += `| 🟠 High | ${high} |\n`; - comment += `| 🟡 Medium | ${findings.length - critical - high} |\n\n`; - - if (critical > 0) { - comment += `⚠️ **Action Required:** Critical security issues found!\n\n`; - } - - comment += `
View findings\n\n`; - comment += `\`\`\`json\n${JSON.stringify(findings.slice(0, 10), null, 2)}\n\`\`\`\n`; - comment += `
\n\n`; - comment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`; - - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); \ No newline at end of file + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@5a93d9d57cc04de4002d6d0ecd336fc7a8698910 diff --git a/.github/workflows/scorecard-enforcer.yml b/.github/workflows/scorecard-enforcer.yml deleted file mode 100644 index 86d81735..00000000 --- a/.github/workflows/scorecard-enforcer.yml +++ /dev/null @@ -1,124 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) 2026 Jonathan D.A. Jewell -# -# Prevention workflow - runs OpenSSF Scorecard and fails on low scores. -# -# Split into TWO jobs to satisfy the OSSF Scorecard webapp's workflow -# restriction: -# "scorecard job must only have steps with `uses`" -# (https://github.com/ossf/scorecard-action#workflow-restrictions). -# -# When the publishing job contains a `run:` step, the webapp rejects -# the signed-result POST with HTTP 400 "workflow verification failed". -# That regression had this workflow `failure`-state ever since -# `publish_results: true` was added — the score *was* computed, but -# the webapp publish step failed and propagated to job-level failure. -# -# Job 1 (`scorecard`): uses-only steps. Computes the score, publishes -# signed results to the OSSF webapp, uploads SARIF to GitHub code -# scanning, and saves results.sarif as an artifact for Job 2. -# -# Job 2 (`enforce-min-score`): downloads the artifact and runs the -# minimum-score gate. May contain `run:` steps freely — it does not -# call scorecard-action. -# -# Sister fix landed in hyperpolymath/ephapax#264 (2026-06-01); this -# PR mirrors it so the two repos stay in sync. - -name: OpenSSF Scorecard Enforcer - -on: - push: - branches: [main] - schedule: - - cron: '0 6 * * 1' # Weekly on Monday - workflow_dispatch: - -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - scorecard: - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - security-events: write - id-token: write # For OIDC - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Run Scorecard - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - - name: Upload SARIF - uses: github/codeql-action/upload-sarif@c6f931105cb2c34c8f901cc885ba1e2e259cf745 # v4 - with: - sarif_file: results.sarif - - - name: Upload results artifact for min-score gate - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: scorecard-results - path: results.sarif - retention-days: 7 - - enforce-min-score: - needs: scorecard - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Download results artifact - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 - with: - name: scorecard-results - - - name: Check minimum score - run: | - # Parse score from SARIF results. - SCORE=$(jq -r '.runs[0].tool.driver.properties.score // 0' results.sarif 2>/dev/null || echo "0") - - echo "OpenSSF Scorecard Score: $SCORE" - - # Minimum acceptable score (0-10 scale). - MIN_SCORE=5 - - if [ "$(echo "$SCORE < $MIN_SCORE" | bc -l)" = "1" ]; then - echo "::error::Scorecard score $SCORE is below minimum $MIN_SCORE" - exit 1 - fi - - # Check specific high-priority items - check-critical: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Check SECURITY.md exists - run: | - if [ ! -f "SECURITY.md" ]; then - echo "::error::SECURITY.md is required" - exit 1 - fi - - - name: Check for pinned dependencies - run: | - # Check workflows for unpinned actions - unpinned=$(grep -r "uses:.*@v[0-9]" .github/workflows/*.yml 2>/dev/null | grep -v "#" | head -5 || true) - if [ -n "$unpinned" ]; then - echo "::warning::Found unpinned actions:" - echo "$unpinned" - fi diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 149b14c8..47acbb5f 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,37 +1,16 @@ -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) 2026 Jonathan D.A. Jewell -# -# Aligned with the documented caller pattern in -# hyperpolymath/standards .github/workflows/scorecard-reusable.yml. -# Previously this workflow was startup_failure-on-every-run because it -# diverged from the reusable's documented caller shape: -# - missing `concurrency:` group (re-pushes piled up superseded runs) -# - `branch_protection_rule:` trigger (unused; complicates startup) -# - `secrets: inherit` against a reusable with no `secrets:` block -# - `permissions: read-all` instead of explicit `contents: read` -# Pattern now matches the reusable's header example verbatim. Sister -# fix landed in hyperpolymath/ephapax#264 (2026-06-01); this PR mirrors -# it so the two repos stay in sync. -name: Scorecards supply-chain security +# SPDX-License-Identifier: PMPL-1.0-or-later +name: OSSF Scorecard on: push: - branches: [main] + branches: [main, master] schedule: - - cron: '23 4 * * 1' + - cron: '0 4 * * *' workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: - analysis: - permissions: - security-events: write - id-token: write - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@e0caf11508a3989574713c78f5f444f2ce5e33ef - timeout-minutes: 10 + scorecard: + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@5a93d9d57cc04de4002d6d0ecd336fc7a8698910 diff --git a/cartridges/claude-ai-mcp/src/server.js b/cartridges/claude-ai-mcp/src/server.js index 2fab17e1..b50fc756 100644 --- a/cartridges/claude-ai-mcp/src/server.js +++ b/cartridges/claude-ai-mcp/src/server.js @@ -1,6 +1,6 @@ +#!/usr/bin/env node // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -#!/usr/bin/env node // // claude-ai-mcp — Anthropic Messages API cartridge for the BoJ // diff --git a/cartridges/model-router-mcp/src/router.js b/cartridges/model-router-mcp/src/router.js index 5bad6d54..46abb2fa 100644 --- a/cartridges/model-router-mcp/src/router.js +++ b/cartridges/model-router-mcp/src/router.js @@ -1,6 +1,6 @@ +#!/usr/bin/env node // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -#!/usr/bin/env node // // Model Router — Intelligent model switching for Claude Code // diff --git a/docs/integration/hcg-tier2-rollout-runbook.md b/docs/integration/hcg-tier2-rollout-runbook.md index 3edc6b61..1946416d 100644 --- a/docs/integration/hcg-tier2-rollout-runbook.md +++ b/docs/integration/hcg-tier2-rollout-runbook.md @@ -6,9 +6,9 @@ Copyright (c) Jonathan D.A. Jewell # HCG tier-2 — rollout & rollback runbook -**Version:** 0.5 (smoke-script verb-canary expansion, Phase E in-progress) -**Date:** 2026-06-13 (rev. from 2026-06-10) -**Status:** Phase E deliverables E1 (deploy spec) + E5 (rollback runbook) drafted; live gateway policy (`config/gateway-policy-boj.yaml`) promoted from the worked example (§1.5); `scripts/hcg-policy-smoke.sh` lands as the checked-in §1.5 operator pre-check (deny-path covers gateway-alone; `--with-backend` adds allow-path coverage); §1.5 verb-canary block extended to cover OPTIONS, regex-route DELETE, and wrong-verb-on-listed-path so the operator pre-check fails closed against more verb-governance regression classes. Owner-input markers (`!OWNER:`) remain to be filled before any traffic-shift action is taken. +**Version:** 0.6 (smoke-script unknown-path canary, Phase E in-progress) +**Date:** 2026-06-14 (rev. from 2026-06-13) +**Status:** Phase E deliverables E1 (deploy spec) + E5 (rollback runbook) drafted; live gateway policy (`config/gateway-policy-boj.yaml`) promoted from the worked example (§1.5); `scripts/hcg-policy-smoke.sh` lands as the checked-in §1.5 operator pre-check (deny-path covers gateway-alone; `--with-backend` adds allow-path coverage); §1.5 verb-canary block covers OPTIONS, regex-route DELETE, and wrong-verb-on-listed-path; a path-canary now exercises the no-match default-deny branch (synthetic unknown path with a `global_verbs` verb) so the operator pre-check fails closed against both unknown-method and unknown-path regression classes. Owner-input markers (`!OWNER:`) remain to be filled before any traffic-shift action is taken. **ADR:** [`docs/decisions/0004-adopt-http-capability-gateway.md`](../decisions/0004-adopt-http-capability-gateway.md) **Plan:** [`docs/integration/http-capability-gateway-plan.md`](http-capability-gateway-plan.md) (§ Phase E) **Contract:** [`docs/integration/http-capability-gateway-boj-contract.md`](http-capability-gateway-boj-contract.md) @@ -91,7 +91,7 @@ These cannot be inferred from the code/contract; the owner must fill them before - [x] `container/gateway-deploy.k9.ncl` exists in the gateway repo (plan §E1) — http-capability-gateway#38 (2026-06-03). Five-level k9-svc pedigree (Snout / Scent / Leash / Gut / Muscle) modelled on `boj-server:container/deploy.k9.ncl`; per-environment `BACKEND_URL` (`http://127.0.0.1:7700` staging, `http://unix:/run/boj/gnosis.sock:/` production); trust source `"header"` staging → `"mtls"` production after §2.4 rehearsal; `max_unavailable = 0`; `failure_mode = "fail-closed"` matching the `[SEAMS] gateway-boj-gnosis` declaration. - [x] Gateway policy file in place: `config/gateway-policy-boj-example.yaml`, covering all BoJ surface routes (`/.well-known/boj-node-pubkey`, `/health`, `/menu`, `/cartridges`, `/cartridge/:name`, `/cartridge/:name/invoke`, `/cartridge/:name/sse`, plus any added since contract v1.0). Re-verified 2026-05-28 against `BojRest.Router`; the `POST /cartridge/:name/sse` route (router.ex line 130, wired since the SSE landing — ADR-0013 §6, STATE entry 2026-05-18) was the only drift since contract v1.0 and is now governed by the `cartridge-sse-post` rule alongside `cartridge-invoke-post` (boj-server#165). - [x] Live policy file (`config/gateway-policy-boj.yaml`) promoted from the example. Content-identical to the example at promotion time; future BoJ-surface evolution lands in the live file and the example remains as the worked-example artefact (Phase A A3). Both §2.1 staging and §3.1 production load the live file via `POLICY_PATH`. -- [ ] Gateway has been smoke-tested in isolation with the policy, returning expected allow/deny on each route. Run `scripts/hcg-policy-smoke.sh --gateway-url ` against the gateway loaded with `config/gateway-policy-boj.yaml`; the script exercises a no-trust-header deny probe for every non-public route (25 in the live policy) plus six default-deny verb canaries — DELETE/PUT/PATCH on listed exact paths, OPTIONS on a listed path (no CORS-preflight bypass), DELETE on a regex-matched route (no per-verb regex regression), and GET on the POST-only `ssg-mcp-webhook` public route (the `{path, verb}` pairing must be enforced even when the path itself is in the policy) — and is fully gateway-internal — BoJ does **not** need to be reachable for this run. Once BoJ is up behind the gateway, re-run with `--with-backend` from a trusted-proxy IP (loopback by default) to also cover the allow path on authenticated/internal routes including the `POST /cartridge/:name/sse` authenticated/untrusted pair carried over from boj-server#165's test plan. Attach the script's PASS/FAIL summary to the cut-over ticket; a single FAIL is a stop-the-rollout condition (gateway loaded the policy but is not enforcing as declared, or BoJ is unreachable from the gateway, or the script is being run from a non-trusted-proxy IP and the trust header is being stripped). +- [ ] Gateway has been smoke-tested in isolation with the policy, returning expected allow/deny on each route. Run `scripts/hcg-policy-smoke.sh --gateway-url ` against the gateway loaded with `config/gateway-policy-boj.yaml`; the script exercises a no-trust-header deny probe for every non-public route (25 in the live policy), six default-deny verb canaries — DELETE/PUT/PATCH on listed exact paths, OPTIONS on a listed path (no CORS-preflight bypass), DELETE on a regex-matched route (no per-verb regex regression), and GET on the POST-only `ssg-mcp-webhook` public route (the `{path, verb}` pairing must be enforced even when the path itself is in the policy) — and one path canary (GET on a synthetic `/__phase-e-canary-unknown-path__` URL that matches no exact rule, no regex rule, and no public exception) which isolates the no-match → default-deny branch of the gateway's three-tier lookup; the verb canaries cover the unknown-method path, the path canary covers the unknown-path path, and both must default-deny on independent code branches. The whole script is fully gateway-internal — BoJ does **not** need to be reachable for this run. Once BoJ is up behind the gateway, re-run with `--with-backend` from a trusted-proxy IP (loopback by default) to also cover the allow path on authenticated/internal routes including the `POST /cartridge/:name/sse` authenticated/untrusted pair carried over from boj-server#165's test plan. Attach the script's PASS/FAIL summary to the cut-over ticket; a single FAIL is a stop-the-rollout condition (gateway loaded the policy but is not enforcing as declared, or BoJ is unreachable from the gateway, or the script is being run from a non-trusted-proxy IP and the trust header is being stripped). --- diff --git a/mcp-bridge/lib/generate-offline-menu.js b/mcp-bridge/lib/generate-offline-menu.js index 6caa35e3..2ed1cd71 100644 --- a/mcp-bridge/lib/generate-offline-menu.js +++ b/mcp-bridge/lib/generate-offline-menu.js @@ -1,6 +1,6 @@ +#!/usr/bin/env -S deno run --allow-read // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -#!/usr/bin/env -S deno run --allow-read // // Generate offline-menu.js from the cartridges/ directory structure. // diff --git a/mcp-bridge/lib/runtime.js b/mcp-bridge/lib/runtime.js index 069438eb..af8ac8bc 100644 --- a/mcp-bridge/lib/runtime.js +++ b/mcp-bridge/lib/runtime.js @@ -7,6 +7,8 @@ // Supports the "Deno > Bun > NPM" hierarchy while maintaining // compatibility with Node-only MCP clients like Glama. +import { writeSync as writeFdSync } from "node:fs"; + const isDeno = typeof globalThis.Deno !== "undefined"; /** @type {{ get: (name: string) => string|undefined }} */ @@ -32,7 +34,7 @@ export const stdout = { if (isDeno) { globalThis.Deno.stdout.writeSync(buf); } else if (typeof process !== "undefined") { - process.stdout.write(buf); + writeFdSync(process.stdout.fd, buf); } } }; @@ -44,7 +46,7 @@ export const stderr = { if (isDeno) { globalThis.Deno.stderr.writeSync(buf); } else if (typeof process !== "undefined") { - process.stderr.write(buf); + writeFdSync(process.stderr.fd, buf); } } }; diff --git a/mcp-bridge/main.js b/mcp-bridge/main.js index 7ce8134d..dbaec208 100755 --- a/mcp-bridge/main.js +++ b/mcp-bridge/main.js @@ -1,6 +1,6 @@ +#!/usr/bin/env -S deno run --allow-net --allow-env --allow-read // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -#!/usr/bin/env -S deno run --allow-net --allow-env --allow-read // // BoJ Server — MCP transport bridge (stdio + HTTP per ADR-0013) // @@ -17,10 +17,8 @@ import { env, stdout } from "./lib/runtime.js"; import { sanitizeErrorMessage } from "./lib/security.js"; -import { dispatchMcpMessage } from "./lib/dispatcher.js"; import { info, error as logError } from "./lib/logger.js"; import * as otel from "./lib/otel.js"; -import { startHttpTransport } from "./lib/http-transport.js"; const TRANSPORT = (env.get("BOJ_TRANSPORT") ?? "stdio").toLowerCase(); @@ -43,6 +41,15 @@ function sendError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message: sanitizeErrorMessage(message) } }); } +let dispatchMcpMessagePromise; + +async function getDispatchMcpMessage() { + if (!dispatchMcpMessagePromise) { + dispatchMcpMessagePromise = import("./lib/dispatcher.js").then((m) => m.dispatchMcpMessage); + } + return dispatchMcpMessagePromise; +} + async function handleStdioLine(line) { let msg; try { @@ -51,6 +58,7 @@ async function handleStdioLine(line) { sendError(null, -32700, "Parse error"); return; } + const dispatchMcpMessage = await getDispatchMcpMessage(); const response = await dispatchMcpMessage(msg, { transport: "stdio" }); if (response !== null) send(response); } @@ -77,8 +85,16 @@ async function runStdio() { if (typeof Deno !== "undefined") { for await (const chunk of Deno.stdin.readable) processChunk(chunk); } else { - // @ts-ignore: process is global in Node - for await (const chunk of process.stdin) processChunk(chunk); + await new Promise((resolve, reject) => { + // @ts-ignore: process is global in Node/Bun + process.stdin.on("data", processChunk); + // @ts-ignore: process is global in Node/Bun + process.stdin.once("end", resolve); + // @ts-ignore: process is global in Node/Bun + process.stdin.once("error", reject); + // @ts-ignore: process is global in Node/Bun + process.stdin.resume(); + }); } await Promise.allSettled(pendingMessages); } @@ -88,6 +104,7 @@ async function runStdio() { // =================================================================== async function runHttp() { + const { startHttpTransport } = await import("./lib/http-transport.js"); const handle = await startHttpTransport(); await new Promise((resolve) => { const stop = async () => { diff --git a/mcp-bridge/tests/boot_smoke.js b/mcp-bridge/tests/boot_smoke.js index f42487e0..33416619 100644 --- a/mcp-bridge/tests/boot_smoke.js +++ b/mcp-bridge/tests/boot_smoke.js @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// Copyright (c) 2026 Jonathan D.A. Jewell // // BoJ Server — bridge boot smoke (runtime portability gate) // @@ -58,14 +58,17 @@ let stdout = ""; let stderr = ""; child.stdout.on("data", (d) => (stdout += d)); child.stderr.on("data", (d) => (stderr += d)); +child.stdin.on("error", (e) => (stderr += `stdin error: ${e.message}\n`)); const killTimer = setTimeout(() => { console.error(`FAIL: bridge did not exit within ${TIMEOUT_MS}ms`); child.kill("SIGKILL"); }, TIMEOUT_MS); -child.stdin.write(requests.map((r) => JSON.stringify(r)).join("\n") + "\n"); -child.stdin.end(); +const payload = requests.map((r) => JSON.stringify(r)).join("\n") + "\n"; +child.once("spawn", () => { + child.stdin.end(payload); +}); child.on("close", (code) => { clearTimeout(killTimer); diff --git a/scripts/check-shebang-first.sh b/scripts/check-shebang-first.sh new file mode 100755 index 00000000..e86fa7a9 --- /dev/null +++ b/scripts/check-shebang-first.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Shebangs are only interpreter directives when they are the first line. +# A license header above "#!" makes Node, Deno, Bun, and POSIX shells parse it +# as source text instead, so executable scripts must keep "#!" at line 1. + +set -euo pipefail + +fail=0 + +while IFS= read -r -d '' path; do + case "$path" in + *.awk|*.bash|*.cjs|*.exs|*.fish|*.js|*.mjs|*.pl|*.py|*.rb|*.scm|*.sh|*.ts|*.zsh) ;; + *) continue ;; + esac + + line_no=0 + while IFS= read -r line || [ -n "$line" ]; do + line_no=$((line_no + 1)) + case "$line" in + '#!'*) + if [ "$line_no" != "1" ]; then + printf 'ERROR: %s:%s has a shebang after line 1\n' "$path" "$line_no" >&2 + fail=1 + fi + ;; + esac + done < "$path" +done < <(git ls-files -z) + +if [ "$fail" -ne 0 ]; then + cat >&2 <<'EOF' + +Shebangs must be the first line of executable scripts. Put SPDX and copyright +comments immediately after the shebang. +EOF + exit 1 +fi + +echo "OK: all tracked shebangs are on line 1" diff --git a/scripts/datasets/process-datasets.js b/scripts/datasets/process-datasets.js index 0265ff8a..88c2f81b 100644 --- a/scripts/datasets/process-datasets.js +++ b/scripts/datasets/process-datasets.js @@ -1,6 +1,6 @@ +#!/usr/bin/env node // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -#!/usr/bin/env node // Process and Integrate Datasets diff --git a/scripts/hcg-policy-smoke.sh b/scripts/hcg-policy-smoke.sh index aa4e278e..a87865b6 100755 --- a/scripts/hcg-policy-smoke.sh +++ b/scripts/hcg-policy-smoke.sh @@ -224,6 +224,20 @@ probe OPTIONS /cartridges deny "verb-canary:OPTIONS /cartr probe DELETE /cartridge/probe/invoke deny "verb-canary:DELETE on regex route (cartridge-invoke-post)" probe GET /cartridges/ssg-mcp/webhook deny "verb-canary:GET on POST-only public route (ssg-mcp-webhook-post)" +# Unknown-path canary — a synthetic path that matches no exact rule, +# no regex rule, and no public exception. The verb (GET) is in +# `global_verbs`, so this probe isolates the no-match → default-deny +# branch of the gateway's three-tier lookup (exact → regex → global) +# in `lib/http_capability_gateway/gateway.ex` at the `{:error, :no_match}` +# clause. The verb-canaries above exercise the unknown-method path +# (a known path with a verb outside `global_verbs`); this canary +# exercises the unknown-path path (a verb in `global_verbs` against +# a path with no matching rule). Both must default-deny, but the code +# paths are distinct — a regression in either is independently +# possible. The synthetic prefix `__phase-e-canary-` is reserved for +# this probe; it must never appear as a real route in the policy. +probe GET /__phase-e-canary-unknown-path__ deny "path-canary:GET on synthetic unknown path (no-match default-deny)" + if [ "$WITH_BACKEND" = "1" ]; then echo echo "==> HCG policy allow smoke (--with-backend)" diff --git a/tools/cartridge-configurator/configurator.js b/tools/cartridge-configurator/configurator.js index 3a914b9f..5e036d34 100644 --- a/tools/cartridge-configurator/configurator.js +++ b/tools/cartridge-configurator/configurator.js @@ -1,6 +1,6 @@ +#!/usr/bin/env node // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -#!/usr/bin/env node // Cartridge Configurator — Apply runtime configuration to cartridges dynamically diff --git a/tools/cartridge-provisioner/provisioner.js b/tools/cartridge-provisioner/provisioner.js index 6cde7428..d5ae02ec 100644 --- a/tools/cartridge-provisioner/provisioner.js +++ b/tools/cartridge-provisioner/provisioner.js @@ -1,6 +1,6 @@ +#!/usr/bin/env node // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -#!/usr/bin/env node // Cartridge Provisioner — Deploy cartridges to BoJ Server, BoJ Server + Elixir Multiplier, or panll diff --git a/tools/panel-harness/harness.js b/tools/panel-harness/harness.js index 8682c50f..c333823f 100644 --- a/tools/panel-harness/harness.js +++ b/tools/panel-harness/harness.js @@ -1,6 +1,6 @@ +#!/usr/bin/env node // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -#!/usr/bin/env node // Panel Harness — Bridge cartridges to BoJ Server and panll