From 426603e864a1f6dbbed2f5961db8c7d694cbc712 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Sat, 16 May 2026 18:02:03 +0100
Subject: [PATCH 1/2] fix(ci): adopt canonical hypatia-scan.yml
(env.HOME/scanner-layout + Comment-step gate)
---
.github/workflows/hypatia-scan.yml | 254 +++++++++++++++++++++++++++++
1 file changed, 254 insertions(+)
create mode 100644 .github/workflows/hypatia-scan.yml
diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml
new file mode 100644
index 0000000..860a2b7
--- /dev/null
+++ b/.github/workflows/hypatia-scan.yml
@@ -0,0 +1,254 @@
+# SPDX-License-Identifier: PMPL-1.0-or-later
+# Hypatia Neurosymbolic CI/CD Security Scan
+name: Hypatia Security Scan
+
+on:
+ push:
+ branches: [ main, master, develop ]
+ pull_request:
+ branches: [ main, master ]
+ schedule:
+ - cron: '0 0 * * 0' # Weekly on Sunday
+ 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: read lets the built-in GITHUB_TOKEN query this
+ # repo's own Dependabot alerts via the Hypatia DependabotAlerts rule
+ # (DA001-DA004). Without this, `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.
+ security-events: read
+ # 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
+
+jobs:
+ scan:
+ name: Hypatia Neurosymbolic Analysis
+ runs-on: ubuntu-latest
+
+ 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.19.4'
+ otp-version: '28.3'
+
+ - 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: 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
+ run: |
+ echo "⚠️ Critical security issues found!"
+ echo "Review hypatia-findings.json for details"
+ # Don't fail the build yet - just warn
+ # exit 1
+
+ - 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. Review findings in the artifact: hypatia-findings.json
+ 2. Auto-fixable issues will be addressed by robot-repo-automaton (Phase 3)
+ 3. Manual review required for complex issues
+
+ ## 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
From 5dd7b7c664aabd6b9074f516aa275ff0d6abf99a Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Sun, 17 May 2026 04:07:37 +0100
Subject: [PATCH 2/2] fix(ci): sync hypatia-scan.yml to canonical (kill
cd-scanner build drift)
The build step did `cd scanner` / built `hypatia-v2` against a path that
no longer exists in the hypatia repo (mix.exs is at root), so the Hypatia
Neurosymbolic Analysis lane exited 1 every run. The env.HOME and Phase-2
sweeps never normalised this older build-step drift. Replace with the
canonical rsr-template-repo hypatia-scan.yml.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.github/workflows/hypatia-scan.yml | 190 ++++++++++++++++++++++++++---
1 file changed, 175 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml
index 860a2b7..a895ce4 100644
--- a/.github/workflows/hypatia-scan.yml
+++ b/.github/workflows/hypatia-scan.yml
@@ -19,12 +19,20 @@ concurrency:
permissions:
contents: read
- # security-events: read lets the built-in GITHUB_TOKEN query this
- # repo's own Dependabot alerts via the Hypatia DependabotAlerts rule
- # (DA001-DA004). Without this, `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.
- security-events: 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)
@@ -45,8 +53,8 @@ jobs:
- name: Setup Elixir for Hypatia scanner
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2
with:
- elixir-version: '1.19.4'
- otp-version: '28.3'
+ elixir-version: '1.18'
+ otp-version: '27'
- name: Clone Hypatia
run: |
@@ -103,6 +111,143 @@ jobs:
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
@@ -174,11 +319,21 @@ jobs:
- 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 "⚠️ Critical security issues found!"
- echo "Review hypatia-findings.json for details"
- # Don't fail the build yet - just warn
- # exit 1
+ 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: |
@@ -200,9 +355,14 @@ jobs:
## Next Steps
- 1. Review findings in the artifact: hypatia-findings.json
- 2. Auto-fixable issues will be addressed by robot-repo-automaton (Phase 3)
- 3. Manual review required for complex issues
+ 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