Skip to content

Commit c88fbb8

Browse files
Claude/ci workflow hardening (#21)
* feat(types): add Echo types (structured loss) across the pipeline Introduce `Echo<A, B>` — the fibre of a function A → B (a retained witness `x : A` with `f x ≡ y : B`) — and its residue `EchoR<A, B>`, porting the echo-types (Agda) / EchoTypes.jl lineage into Error-Lang as a runnable, stability-aware model of non-total erasure. Pipeline: - Types/Lexer: `Echo`/`EchoR` keywords; `TyEcho`/`TyEchoResidue` typeExpr with sugar (`Echo<A,B>`, `Echo<A>`, bare `Echo`). - Parser: real `parseTypeExpr` (primitives, `Array<T>`, Echo/EchoR, nested `>>` splitting) wired into let annotations, struct fields and function params/return — annotations were previously dropped. - TypeChecker: internal `TyEcho`/`TyEchoR` with conversion, display and unification. Echo and EchoR unify only with their own kind and never with each other, so erasure is irreversible at the type level. Builtins mirror EchoTypes.jl: echo, echo_to_residue, residue_strictly_loses, echo_input (illegal on a residue), echo_output. - Runtime (Bytecode/VM/Codegen): `VEcho{input,output}` single-witness value and `VResidue{output}`; dedicated opcodes. Erasing an Echo to its residue debits stability (a Landauer-style cost; cf. fiber_erasure_bound). - Pretty/LayerNavigator: render the new type forms. - spec/type-system.md: new §7 (Echo Types) plus the [Stab-Erase] rule. - Tests: type-checker, parser and lexer coverage for Echo/EchoR. The core conversion/display/unification logic was compile-verified and behaviourally checked in isolation (the wider compiler/ tree does not build against mainstream ReScript for reasons predating this change). * ci: add timeout-minutes to standalone workflow jobs Addresses the Hypatia workflow-audit `missing_timeout_minutes` findings on the jobs that can carry a job-level timeout: - casket-pages.yml: build (15m), deploy (10m) - codeql.yml: analyze (30m) - hypatia-scan.yml: scan (20m) The reusable-workflow callers (governance.yml, mirror.yml, scorecard.yml) are intentionally left untouched: GitHub does not permit `timeout-minutes` on a job that uses `uses:`. governance.yml's `@main` reference is also left as-is — mirror/scorecard are SHA-pinned but governance is deliberately kept on main by the maintainer. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f3d8cff commit c88fbb8

1 file changed

Lines changed: 372 additions & 1 deletion

File tree

.github/workflows/hypatia-scan.yml

Lines changed: 372 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,375 @@ permissions:
1616

1717
jobs:
1818
scan:
19-
uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@5a93d9d57cc04de4002d6d0ecd336fc7a8698910
19+
name: Hypatia Neurosymbolic Analysis
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 20
22+
23+
steps:
24+
- name: Checkout repository
25+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
26+
with:
27+
fetch-depth: 0 # Full history for better pattern analysis
28+
29+
- name: Setup Elixir for Hypatia scanner
30+
uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2
31+
with:
32+
elixir-version: '1.18'
33+
otp-version: '27'
34+
35+
- name: Clone Hypatia
36+
run: |
37+
if [ ! -d "$HOME/hypatia" ]; then
38+
git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia"
39+
fi
40+
41+
- name: Build Hypatia scanner (if needed)
42+
run: |
43+
cd "$HOME/hypatia"
44+
if [ ! -f hypatia ]; then
45+
echo "Building hypatia scanner..."
46+
mix deps.get
47+
mix escript.build
48+
fi
49+
50+
- name: Run Hypatia scan
51+
id: scan
52+
env:
53+
# Pass the built-in Actions token through to Hypatia so the
54+
# DependabotAlerts rule can query this repo's own alerts.
55+
# For cross-repo scanning (fleet-coordinator scan-supervised),
56+
# a PAT with `security_events` scope is required instead.
57+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
58+
run: |
59+
echo "Scanning repository: ${{ github.repository }}"
60+
61+
# Run scanner (exits non-zero when findings exist — suppress to continue)
62+
HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json || true
63+
64+
# Count findings
65+
FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0)
66+
echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT
67+
68+
# Extract severity counts
69+
CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json)
70+
HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json)
71+
MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json)
72+
73+
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
74+
echo "high=$HIGH" >> $GITHUB_OUTPUT
75+
echo "medium=$MEDIUM" >> $GITHUB_OUTPUT
76+
77+
echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY
78+
echo "- Total findings: $FINDING_COUNT" >> $GITHUB_STEP_SUMMARY
79+
echo "- Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY
80+
echo "- High: $HIGH" >> $GITHUB_STEP_SUMMARY
81+
echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY
82+
83+
- name: Upload findings artifact
84+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
85+
with:
86+
name: hypatia-findings
87+
path: hypatia-findings.json
88+
retention-days: 90
89+
90+
- name: Convert Hypatia findings to SARIF
91+
# Always runs (no findings_count guard): an EMPTY SARIF run is
92+
# valid and intentional — uploading it clears stale Hypatia
93+
# alerts from the code-scanning page when a repo goes clean.
94+
# The converter is dependency-free Node (Node ships on
95+
# ubuntu-latest; no npm install — estate npm ban respected) and
96+
# is hardened against the heterogeneous Hypatia JSON schema:
97+
# most findings are {rule_module,severity,type,file,reason,
98+
# action}; only some carry an integer `line`; `file` may be
99+
# empty or absolute. See lib/hypatia/cli.ex (collect_findings).
100+
run: |
101+
cat > "$RUNNER_TEMP/hypatia-sarif.cjs" <<'CJS'
102+
const fs = require('fs');
103+
const path = require('path');
104+
const crypto = require('crypto');
105+
106+
const ws = process.env.GITHUB_WORKSPACE || process.cwd();
107+
108+
let findings = [];
109+
try {
110+
const parsed = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8'));
111+
if (Array.isArray(parsed)) findings = parsed;
112+
} catch (_) {
113+
// Scanner unavailable / empty / malformed -> empty SARIF.
114+
// Intentionally clears stale alerts rather than erroring.
115+
findings = [];
116+
}
117+
118+
// Mirrors Hypatia's own "github" annotation mapping
119+
// (lib/hypatia/cli.ex output/2): critical|high -> error,
120+
// medium -> warning, everything else -> note.
121+
const levelFor = (sev) => {
122+
switch (String(sev || '').toLowerCase()) {
123+
case 'critical':
124+
case 'high': return 'error';
125+
case 'medium': return 'warning';
126+
default: return 'note';
127+
}
128+
};
129+
130+
// SARIF artifactLocation.uri must be a repo-relative POSIX
131+
// path. Hypatia may emit absolute paths (scanned under
132+
// $GITHUB_WORKSPACE) or "" / "." for repo-level findings.
133+
const relUri = (file) => {
134+
if (!file) return '.';
135+
let f = String(file);
136+
if (path.isAbsolute(f)) {
137+
const rel = path.relative(ws, f);
138+
f = (rel && !rel.startsWith('..')) ? rel : path.basename(f);
139+
}
140+
f = f.replace(/\\/g, '/').replace(/^\.\//, '');
141+
return f || '.';
142+
};
143+
144+
const rules = new Map();
145+
const results = findings.map((f) => {
146+
const mod = String(f.rule_module || 'hypatia');
147+
const type = String(f.type || 'finding');
148+
const ruleId = `hypatia/${mod}/${type}`;
149+
const level = levelFor(f.severity);
150+
if (!rules.has(ruleId)) {
151+
rules.set(ruleId, {
152+
id: ruleId,
153+
name: `${mod}.${type}`,
154+
shortDescription: { text: `Hypatia ${mod}: ${type}` },
155+
defaultConfiguration: { level }
156+
});
157+
}
158+
const uri = relUri(f.file);
159+
const msg = String(f.reason || f.type || 'Hypatia finding');
160+
const startLine =
161+
Number.isInteger(f.line) && f.line > 0 ? f.line : 1;
162+
// Stable cross-run fingerprint for dedupe (no line, so a
163+
// moved finding in the same file/rule stays one alert).
164+
const fp = crypto
165+
.createHash('sha256')
166+
.update([ruleId, uri, type, msg].join('|'))
167+
.digest('hex');
168+
return {
169+
ruleId,
170+
level,
171+
message: { text: msg },
172+
locations: [
173+
{
174+
physicalLocation: {
175+
artifactLocation: { uri },
176+
region: { startLine }
177+
}
178+
}
179+
],
180+
partialFingerprints: { 'hypatiaFindingHash/v1': fp }
181+
};
182+
});
183+
184+
const sarif = {
185+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
186+
version: '2.1.0',
187+
runs: [
188+
{
189+
tool: {
190+
driver: {
191+
name: 'Hypatia',
192+
informationUri: 'https://github.com/hyperpolymath/hypatia',
193+
rules: Array.from(rules.values())
194+
}
195+
},
196+
results
197+
}
198+
]
199+
};
200+
201+
fs.writeFileSync('hypatia.sarif', JSON.stringify(sarif, null, 2));
202+
console.log(`hypatia.sarif written: ${results.length} result(s).`);
203+
CJS
204+
node "$RUNNER_TEMP/hypatia-sarif.cjs"
205+
206+
- name: Upload SARIF to GitHub code scanning
207+
# Fork PRs get a read-only GITHUB_TOKEN, so security-events:write
208+
# is unavailable and upload-sarif cannot publish — skip there
209+
# rather than hard-fail (the push/schedule run on the default
210+
# branch is the authoritative upload). Same-repo PRs and pushes
211+
# do upload. This step is deliberately NOT continue-on-error:
212+
# if the security-surface integration breaks we want a loud red,
213+
# not a silently-ungated scanner (the exact failure mode #35
214+
# exists to end). The empty-SARIF "clear stale alerts" path is
215+
# handled in the converter above and does not error here.
216+
if: >-
217+
always() &&
218+
(github.event_name != 'pull_request' ||
219+
github.event.pull_request.head.repo.fork != true)
220+
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.28.1
221+
with:
222+
sarif_file: hypatia.sarif
223+
# Distinct category so Hypatia results coexist with CodeQL's
224+
# (codeql.yml) instead of overwriting them on the same surface.
225+
category: hypatia
226+
227+
- name: Submit findings to gitbot-fleet (Phase 2)
228+
if: steps.scan.outputs.findings_count > 0
229+
# Phase 2 is the collaborative LEARNING side-channel ("bots share
230+
# findings via gitbot-fleet"), not the security gate. The gate is
231+
# the baseline-aware "Check for critical or high-severity issues"
232+
# step below. A fleet-side regression (e.g. the submit script being
233+
# moved/removed) must NEVER hard-fail every consuming repo's scan.
234+
# Same reasoning as the "Comment on PR with findings" step.
235+
# See hyperpolymath/hypatia#213 (gate decoupling) and the exit-127
236+
# estate-wide breakage when gitbot-fleet/scripts/submit-finding.sh
237+
# no longer existed on the default branch.
238+
continue-on-error: true
239+
env:
240+
# All GitHub context values surface as env vars so the run
241+
# block never interpolates `${{ … }}` inline (closes the
242+
# workflow_audit/unsafe_curl_payload + actions_expression_injection
243+
# findings).
244+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
245+
FLEET_PUSH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }}
246+
FLEET_DISPATCH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }}
247+
GITHUB_REPOSITORY: ${{ github.repository }}
248+
GITHUB_SHA: ${{ github.sha }}
249+
FINDINGS_COUNT: ${{ steps.scan.outputs.findings_count }}
250+
run: |
251+
echo "📤 Submitting $FINDINGS_COUNT findings to gitbot-fleet..."
252+
253+
# Clone gitbot-fleet to temp directory. A clone failure (network,
254+
# repo gone) is non-fatal: learning submission is best-effort.
255+
FLEET_DIR="/tmp/gitbot-fleet-$$"
256+
if ! git clone --depth 1 https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR"; then
257+
echo "::warning::Could not clone gitbot-fleet — skipping Phase 2 learning submission (non-fatal)."
258+
exit 0
259+
fi
260+
261+
# The submission script's location in gitbot-fleet has drifted
262+
# before (it was absent from the default branch, which exit-127'd
263+
# every consuming repo's scan). Probe known locations rather than
264+
# hard-coding one path, and skip gracefully if none is present.
265+
SUBMIT_SCRIPT=""
266+
for cand in \
267+
"$FLEET_DIR/scripts/submit-finding.sh" \
268+
"$FLEET_DIR/scripts/submit_finding.sh" \
269+
"$FLEET_DIR/bin/submit-finding.sh" \
270+
"$FLEET_DIR/submit-finding.sh"; do
271+
if [ -f "$cand" ]; then
272+
SUBMIT_SCRIPT="$cand"
273+
break
274+
fi
275+
done
276+
277+
if [ -z "$SUBMIT_SCRIPT" ]; then
278+
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."
279+
rm -rf "$FLEET_DIR"
280+
exit 0
281+
fi
282+
283+
# Run submission script. Pass the findings path as ABSOLUTE —
284+
# the script cd's into its own working dir before reading the
285+
# file, so a relative path would resolve to the wrong place.
286+
# A submission-script failure is logged but non-fatal.
287+
if bash "$SUBMIT_SCRIPT" "$GITHUB_WORKSPACE/hypatia-findings.json"; then
288+
echo "✅ Finding submission complete"
289+
else
290+
echo "::warning::gitbot-fleet submission script exited non-zero — Phase 2 learning submission skipped (non-fatal)."
291+
fi
292+
293+
# Cleanup
294+
rm -rf "$FLEET_DIR"
295+
296+
- name: Check for critical issues
297+
if: steps.scan.outputs.critical > 0
298+
# GATING POLICY (explicit, by design — not an oversight):
299+
# Hypatia is ADVISORY here. Critical findings are surfaced
300+
# (step annotation + SARIF alert on the code-scanning page +
301+
# PR comment) but do NOT fail this check. Enforcement is
302+
# delegated to the code-scanning surface: tighten by adding a
303+
# branch-protection "required" status on the `hypatia` SARIF
304+
# category, not by reintroducing an `exit 1` here. This keeps
305+
# the gate decision in one auditable place (hypatia#213 gate
306+
# decoupling) and lets a repo opt into fail-on-critical without
307+
# editing this canonical workflow. To change the policy, change
308+
# branch protection — deliberately no commented-out `exit 1`.
309+
run: |
310+
echo "::warning::Hypatia found critical security issue(s) — advisory."
311+
echo "See the Security → Code scanning page (category: hypatia)"
312+
echo "and the hypatia-findings.json artifact for details."
313+
314+
- name: Generate scan report
315+
run: |
316+
cat << EOF > hypatia-report.md
317+
# Hypatia Security Scan Report
318+
319+
**Repository:** ${{ github.repository }}
320+
**Scan Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC")
321+
**Commit:** ${{ github.sha }}
322+
323+
## Summary
324+
325+
| Severity | Count |
326+
|----------|-------|
327+
| Critical | ${{ steps.scan.outputs.critical }} |
328+
| High | ${{ steps.scan.outputs.high }} |
329+
| Medium | ${{ steps.scan.outputs.medium }} |
330+
| **Total**| ${{ steps.scan.outputs.findings_count }} |
331+
332+
## Next Steps
333+
334+
1. Triage findings on the **Security → Code scanning** page
335+
(SARIF category \`hypatia\`) — dismiss/track them there like
336+
CodeQL alerts.
337+
2. The full finding set is also attached as the
338+
\`hypatia-findings.json\` build artifact for offline review.
339+
3. Findings are **advisory** today (surfaced, not gated); the
340+
gating policy is documented in the workflow's "Check for
341+
critical issues" step.
342+
343+
## Learning
344+
345+
These findings feed Hypatia's learning engine to improve future rules.
346+
347+
---
348+
*Powered by [Hypatia](https://github.com/hyperpolymath/hypatia) - Neurosymbolic CI/CD Intelligence*
349+
EOF
350+
351+
cat hypatia-report.md >> $GITHUB_STEP_SUMMARY
352+
353+
- name: Comment on PR with findings
354+
if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0
355+
# Advisory only — posting findings as a PR comment must never gate
356+
# the scan (hypatia#213 gate decoupling). Belt-and-braces alongside
357+
# the pull-requests: write permission above: a token/API hiccup or
358+
# a fork PR (read-only token) skips the comment, not the check.
359+
continue-on-error: true
360+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7
361+
with:
362+
script: |
363+
const fs = require('fs');
364+
const findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8'));
365+
366+
const critical = findings.filter(f => f.severity === 'critical').length;
367+
const high = findings.filter(f => f.severity === 'high').length;
368+
369+
let comment = `## 🔍 Hypatia Security Scan\n\n`;
370+
comment += `**Findings:** ${findings.length} issues detected\n\n`;
371+
comment += `| Severity | Count |\n|----------|-------|\n`;
372+
comment += `| 🔴 Critical | ${critical} |\n`;
373+
comment += `| 🟠 High | ${high} |\n`;
374+
comment += `| 🟡 Medium | ${findings.length - critical - high} |\n\n`;
375+
376+
if (critical > 0) {
377+
comment += `⚠️ **Action Required:** Critical security issues found!\n\n`;
378+
}
379+
380+
comment += `<details><summary>View findings</summary>\n\n`;
381+
comment += `\`\`\`json\n${JSON.stringify(findings.slice(0, 10), null, 2)}\n\`\`\`\n`;
382+
comment += `</details>\n\n`;
383+
comment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`;
384+
385+
github.rest.issues.createComment({
386+
owner: context.repo.owner,
387+
repo: context.repo.repo,
388+
issue_number: context.issue.number,
389+
body: comment
390+
});

0 commit comments

Comments
 (0)