Skip to content

feat(rules): WorkflowHardening (WH001-WH012) — 12 rules from actionlint/zizmor/literature#321

Merged
hyperpolymath merged 2 commits into
mainfrom
claude/workflow-hardening-rules
May 26, 2026
Merged

feat(rules): WorkflowHardening (WH001-WH012) — 12 rules from actionlint/zizmor/literature#321
hyperpolymath merged 2 commits into
mainfrom
claude/workflow-hardening-rules

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

Summary

New module Hypatia.Rules.WorkflowHardening catches dangerous GitHub Actions workflow patterns at the content level (complementary to BaselineHealth's drift detection).

12 rules, sourced from a deliberate survey of:

  • actionlint (docs/checks.md, 37 checks)
  • zizmor (docs/audits.md, 37 audits)
  • GitHub Actions security hardening guide
  • OWASP Top 10 CI/CD Security Risks (esp. CICD-SEC-3)
  • Academic literature — Cassel et al. (MSR 2024), Benedetti et al. (SCORED 2022), Koishybayev et al. (USENIX Security 2022)

Rule inventory

ID Detects Severity Upstream tool / paper
WH001 Template injection (${{ github.event.* }} in run:) critical zizmor, actionlint, Benedetti 2022
WH002 Missing or write-all permissions warn/high scorecard, zizmor, Cassel 2024
WH003 pull_request_target + PR-head checkout critical zizmor, scorecard
WH004 uses: not 40-char SHA-pinned warn zizmor, scorecard
WH005 Literal credentials in services/container critical actionlint, zizmor
WH006 Job missing timeout-minutes: warn Datadog CI
WH007 PR-triggered workflow w/o concurrency: info zizmor
WH008 secrets: inherit in reusable call warn zizmor
WH009 ${{ toJSON(secrets) }} dump critical zizmor
WH010 Deprecated workflow commands (::set-output::) warn actionlint
WH011 curl … | sh in run: high Semgrep, OWASP CICD-SEC-3
WH012 Untrusted input → \$GITHUB_ENV / \$GITHUB_PATH critical zizmor

Tests

`mix test test/workflow_hardening_test.exs` → 16 tests / 0 failures.

Coverage spans positive cases (each rule catches its target pattern) and negative cases (each rule passes clean workflows + safe `secrets.*` references + SHA-pinned actions + non-workflow files). The `scan/1` facade is exercised on a clean fixture and on an empty repo.

Design

  • Pure local file scans (no GitHub API).
  • Same workflow-file discovery scope as BH004: repo-root `.github/workflows/` only (GitHub Actions ignores nested paths).
  • Regex on YAML text rather than parsed YAML. Patterns are syntactically distinctive; corpus is small (~1800 workflow files across the estate); false positives caught downstream by safety-triangle review routing.
  • All findings route to sustainabot (advisory — workflow-hardening fixes vary by defect class, not mechanically templatable). Confidence 0.60-0.92 by severity.

🤖 Generated with Claude Code

…nt + zizmor + research

New module `Hypatia.Rules.WorkflowHardening` catches dangerous GitHub
Actions workflow patterns that aren't baseline drift (handled by
`BaselineHealth`) but are content-level defects.

## Rule inventory

| ID | Detects | Severity | Provenance |
|---|---|---|---|
| WH001 | Template injection — `${{ github.event.* }}` in `run:` | critical | zizmor `template-injection`, actionlint `untrusted-inputs`, Benedetti et al. 2022 |
| WH002 | Workflow-level `permissions:` missing or `write-all` | warn/high | scorecard `Token-Permissions`, zizmor `excessive-permissions`, Cassel et al. 2024 |
| WH003 | `pull_request_target` / `workflow_run` + PR-head checkout | critical | zizmor `dangerous-triggers`, scorecard `Dangerous-Workflow` |
| WH004 | `uses:` not pinned to 40-char SHA | warn | zizmor `unpinned-uses`, scorecard `Pinned-Dependencies` |
| WH005 | Literal `password:` in services/container `credentials:` | critical | actionlint `hardcoded-credentials`, zizmor `hardcoded-container-credentials` |
| WH006 | Job missing `timeout-minutes:` (default 360 min) | warn | Datadog CI best-practices |
| WH007 | PR-triggered workflow without top-level `concurrency:` | info | zizmor `concurrency-limits` |
| WH008 | `secrets: inherit` in reusable-workflow call | warn | zizmor `secrets-inherit` |
| WH009 | `${{ toJSON(secrets) }}` overprovisioning | critical | zizmor `overprovisioned-secrets` |
| WH010 | Deprecated workflow commands (`::set-output::`, etc.) | warn | actionlint `deprecated-workflow-commands` |
| WH011 | `curl … \| sh` in `run:` block | high | Semgrep CI rules, OWASP CICD-SEC-3 |
| WH012 | Untrusted input written to `$GITHUB_ENV` / `$GITHUB_PATH` | critical | zizmor `github-env` |

## Design notes

- All rules are pure-local file scans (no GitHub API). Each takes a
  `repo_path` and returns a list of finding maps in the standard
  shape used by `BaselineHealth`.
- Workflow file discovery uses the same scope as BH004: only
  repo-root `.github/workflows/*.{yml,yaml}` (GitHub Actions ignores
  nested paths). Monorepo template scaffolds in subdirectory
  `.github/workflows/` are deliberately not scanned.
- We use regex on YAML text rather than full YAML parsing. The
  patterns we care about are syntactically distinctive
  (`${{ ... }}`, `uses:` slugs, `run:` prefixes), the scanning corpus
  is small enough that regex is acceptably fast, and false positives
  are caught downstream by safety-triangle review routing.
- All findings route to sustainabot — these are advisory, not
  mechanically fixable. Confidence calibrated 0.60-0.92 by severity.

## Tests

`mix test test/workflow_hardening_test.exs` → **16 tests / 0 failures**.
Coverage spans WH001 (positive + safe-secrets negative), WH002 (all
three states), WH004 (tag-pinned + SHA-pinned + local/docker negative),
WH005 (literal + secrets negative), WH009/WH010/WH011, plus the
`scan/1` facade on a clean fixture and an empty repo.

## Why this module

The October 2024 Cassel et al. measurement found GITHUB_TOKEN at
default-write-all in ~74% of public workflows; Benedetti et al. 2022
found script-injection sinks reachable in ~7%. These are exactly the
defect classes that `actionlint` and `zizmor` catch — Hypatia now
catches them at the estate level so the next instance is flagged
automatically rather than requiring an out-of-band scan.
@github-actions
Copy link
Copy Markdown

🔍 Hypatia Security Scan

Findings: 116 issues detected

Severity Count
🔴 Critical 0
🟠 High 5
🟡 Medium 111
View findings
[
  {
    "reason": "Docker reference in Nickel config -- Podman/Containerfile highly preferred (Docker permitted) (1 occurrences, CWE-1104)",
    "type": "ncl_docker_not_podman",
    "file": "/home/runner/work/hypatia/hypatia/.machine_readable/svc/k9/hypatia-metadata.k9.ncl",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/batch.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap_or(0) with dangerous default (1 occurrences, CWE-754)",
    "type": "unwrap_dangerous_default",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/batch.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (1 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/scan.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap_or(0) with dangerous default (1 occurrences, CWE-754)",
    "type": "unwrap_dangerous_default",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/scan.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/fleet.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/output.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/build.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (3 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/fixer/src/main.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "expect() in hot path (5 occurrences, CWE-754)",
    "type": "expect_in_hot_path",
    "file": "/home/runner/work/hypatia/hypatia/fixer/src/scanner.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  }
]

Powered by Hypatia Neurosymbolic CI/CD Intelligence

@github-actions
Copy link
Copy Markdown

🔍 Hypatia Security Scan

Findings: 116 issues detected

Severity Count
🔴 Critical 0
🟠 High 5
🟡 Medium 111
View findings
[
  {
    "reason": "Docker reference in Nickel config -- Podman/Containerfile highly preferred (Docker permitted) (1 occurrences, CWE-1104)",
    "type": "ncl_docker_not_podman",
    "file": "/home/runner/work/hypatia/hypatia/.machine_readable/svc/k9/hypatia-metadata.k9.ncl",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/batch.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap_or(0) with dangerous default (1 occurrences, CWE-754)",
    "type": "unwrap_dangerous_default",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/batch.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (1 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/scan.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap_or(0) with dangerous default (1 occurrences, CWE-754)",
    "type": "unwrap_dangerous_default",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/scan.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/commands/fleet.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/src/output.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (2 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/cli/build.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "unwrap() without prior check -- DoS via panic (3 occurrences, CWE-754)",
    "type": "unwrap_without_check",
    "file": "/home/runner/work/hypatia/hypatia/fixer/src/main.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  },
  {
    "reason": "expect() in hot path (5 occurrences, CWE-754)",
    "type": "expect_in_hot_path",
    "file": "/home/runner/work/hypatia/hypatia/fixer/src/scanner.rs",
    "action": "flag",
    "rule_module": "code_safety",
    "severity": "low"
  }
]

Powered by Hypatia Neurosymbolic CI/CD Intelligence

@hyperpolymath hyperpolymath merged commit ab9aa0c into main May 26, 2026
35 checks passed
@hyperpolymath hyperpolymath deleted the claude/workflow-hardening-rules branch May 26, 2026 01:32
hyperpolymath added a commit that referenced this pull request May 26, 2026
…/OWASP/Endor (#322)

## Summary

New module \`Hypatia.Rules.SupplyChain\` covers provenance and integrity
defects across 11 rules, complementary to \`WorkflowHardening\` (content
defects, PR #321) and \`BaselineHealth\` (drift, PR #320).

| ID | Detects | Severity | Provenance |
|---|---|---|---|
| SC001 | .github/workflows/ not in CODEOWNERS | warn | GH hardening |
| SC002 | Dependabot missing or no github-actions ecosystem | warn/info
| Scorecard \`Dependency-Update-Tool\` + GH hardening |
| SC003 | Action repo archived (API check) | warn | zizmor
\`archived-uses\` |
| SC004 | Typosquat action name (Levenshtein ≤2 vs canonical, different
owner) | critical | zizmor \`typosquat-uses\` + Gu et al. (S&P 2023) |
| SC005 | \`pull_request_target\` trigger present (broad signal) | high
| scorecard \`Dangerous-Workflow\` |
| SC006 | Release workflow without SBOM emission | warn | NIST SSDF
PS.2.1 + scorecard SBOM |
| SC007 | Self-hosted runner in public repo (API check) | critical | GH
hardening + StepSecurity |
| SC008 | Publish via long-lived secret instead of OIDC | warn | GH
hardening + zizmor \`use-trusted-publishing\` |
| SC009 | SECURITY.md missing | warn | scorecard \`Security-Policy\` |
| SC010 | Webhook configured without secret (API check) | critical |
scorecard \`Webhooks\` |
| SC011 | Release workflow without signing/provenance | high | scorecard
\`Signed-Releases\` + Endor R-END-02 |

## Tests

\`mix test test/supply_chain_test.exs\` → **18 tests / 0 failures**.
Coverage includes positive cases per rule, negative-case clean fixtures,
and a no-token regression group for the API-backed rules (SC003, SC007,
SC010).

## Dispatch

Mechanically-fixable rules (SC001, SC002, SC009 — add file/section)
route to rhodibot at 0.88-0.92 confidence. The rest are advisory →
sustainabot at confidence calibrated by severity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
hyperpolymath added a commit that referenced this pull request May 26, 2026
…corecard / NIST SSDF (#323)

## Summary

New module `Hypatia.Rules.BranchProtection` adds 7 rules covering
review-and-integrity controls on the default branch. Complements
`BaselineHealth` (drift, #320), `WorkflowHardening` (content, #321), and
`SupplyChain` (provenance, #322) — together the four modules cover the
four facets of estate-wide CI/CD hygiene.

All seven rules read from a single GitHub API endpoint:

```
gh api repos/{owner}/{repo}/branches/main/protection
```

## Rules

| ID | Detects | Severity | Provenance |
|---|---|---|---|
| BP001 | `required_signatures != true` (unsigned commits can land) |
high | CIS GH 1.1.x |
| BP002 | `required_linear_history != true` (merge commits obscure audit
trail) | warn | CIS GH 1.3.x |
| BP003 | `required_approving_review_count < 1` (PRs self-mergeable) |
critical | CIS GH 1.4.x |
| BP004 | `dismiss_stale_reviews != true` (approvals carry across new
commits) | warn | CIS GH 1.5.x |
| BP005 | `require_code_owner_reviews: true` but CODEOWNERS
missing/empty | high | CIS GH 1.6.x + NIST SP 800-218 PO.3.2 |
| BP006 | `enforce_admins != true` (admins bypass every other
protection) | high | CIS GH |
| BP007 | `allow_force_pushes` or `allow_deletions` (history-integrity
break) | critical | scorecard `Branch-Protection` tier 1 |

## Tests

`mix test test/branch_protection_test.exs` → **15 tests / 0 failures**:

- One **no-token regression** per rule (7 tests) — each `bpNNN_*`
returns `[]` cleanly when neither `GITHUB_TOKEN` nor
`HYPATIA_DISPATCH_PAT` is set. Mirrors the convention in
`BaselineHealth` and `SupplyChain`.
- **`scan/2` facade shape** (3 tests) — standard `{findings, total,
by_severity, dispatch}` map; empty findings when no token / no origin
remote.
- **`dispatch_recommendations/1`** (3 tests) — every BP rule routes to
`:sustainabot`; severity→confidence calibration matches the documented
table (critical 0.92, high 0.85, warn 0.75, info 0.60).
- **Module surface** (2 tests) — all seven `bpNNN_*` functions plus
`scan/2` and `dispatch_recommendations/1` are exported at the expected
arities.

## Dispatch routing

Every BP rule routes to `:sustainabot`. Branch-protection edits require
admin scope on the repository, which the bot fleet does not hold — the
finding is necessarily advisory only. Confidence is derived from
severity (0.92 / 0.85 / 0.75 / 0.60 by tier).

This is a deliberate divergence from `SupplyChain`'s mixed routing
(where SC001/SC002/SC009 mechanically route to rhodibot at higher
confidence because they're file-add fixes). No BP rule has a mechanical
fix that a bot can ship — every one is an owner decision against the
live repo settings.

## API style

`curl_github/1` and `fetch_branch_protection/3` are direct ports of the
`BaselineHealth` helpers (same auth header set, same JSON decode path,
same `{:error, :no_token}` short-circuit). Owner/repo extraction from
the `origin` remote also mirrors `BaselineHealth` for the
no-`owner_repo`-passed case. `System.cmd("curl", [...])` form is used
throughout per the hypatia CLAUDE.md scanner-hygiene rules (no
`System.shell`, no `:os.cmd`).

`fetch_branch_protection/3` distinguishes `{:error, :not_protected}`
(the API's `"Branch not protected"` message) from `{:error, :no_token}`
and `{:error, "GitHub API: ..."}` — every BP rule treats all error cases
as `[]` (no false positives without ground truth).

## Module summary

`lib/rules/branch_protection.ex` — 644 lines.
Single endpoint, seven rules, pattern-match-on-JSON-shape predicate per
protection flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hyperpolymath added a commit that referenced this pull request May 26, 2026
…epSecurity/Endor/academic literature (#325)

## Summary

New module `Hypatia.Rules.ResearchExtensions` captures GitHub Actions
workflow defects sourced from commercial CI/CD security tools (Snyk,
StepSecurity, Endor Labs, Datadog CI Visibility) and recent academic
literature. Complementary to `WorkflowHardening` (open-source
actionlint/zizmor cluster, PR #321) and `BaselineHealth` (drift, PR
#320).

10 rules, all pure-local YAML scans on `<repo>/.github/workflows/*.yml`
plus repo-walk for composite `action.yml` files.

## Rule inventory

| ID | Detects | Severity | Provenance |
|---|---|---|---|
| **RE001** | Workflow accesses `secrets.*` but does not install
`step-security/harden-runner` | `:warn` | StepSecurity Harden-Runner |
| **RE002** | Harden-Runner running in `egress-policy: audit` on a
protected-branch trigger (no actual containment) | `:high` |
StepSecurity production-deployment guidance |
| **RE003** | `actions/cache` key interpolates `github.head_ref` / PR
title / PR body — cache poisoning vector | `:high` | Snyk dataflow
auditor + 2022 GitHub cache-poisoning advisory |
| **RE004** | `docker://image:tag` or `container: image:tag` pinned by
tag, not by `@sha256:<digest>` | `:warn` | Snyk container scan + GitHub
pin-by-digest guide |
| **RE005** | Test/spec/check/lint/verify step swallows non-zero exit
via `continue-on-error: true` or `\|\| true` | `:warn` | Datadog CI
Visibility flake-masking patterns |
| **RE006** | Composite `action.yml` (`using: composite`) calls a
third-party `uses:` that isn't SHA-pinned (nested-pinning gap) | `:warn`
| Endor Labs R-END-05 |
| **RE007** | Workflow-level top-level `env:` block contains `\${{
secrets.* }}` — exposes secret to every step | `:warn` | Endor Labs
R-END-01 (least-privilege secrets scope) |
| **RE008** | `if: github.actor == 'dependabot[bot]'` — spoofable bot
identity on `pull_request_target` from a fork | `:critical` | zizmor
`bot-conditions` + Koishybayev et al. (USENIX Security 2022) |
| **RE009** | `\${{ fromJSON(secrets.X) }}` — parsed fields bypass
runner-side log redaction | `:critical` | zizmor `unredacted-secrets` |
| **RE010** | `workflow_run` trigger downloads artifacts without
`run-id` constraint or provenance verification | `:warn` |
Kermabon-Bobinnec et al. (EuroS&P 2023) |

## Tests

`mix test test/research_extensions_test.exs` covers **27 tests**:

- One positive case + one negative case per rule (10 rules × 2 = 20
minimum).
- Additional negatives where a rule has multiple distinct trigger paths
(RE005 has two: `continue-on-error: true` and `\|\| true`; RE010 has two
clean-state paths: `run-id` constraint and `gh attestation verify`).
- `scan/1` facade exercised on a clean workflow, an empty repo, and a
multi-finding repo (asserting all dispatch routes to `:sustainabot` at
severity-calibrated confidence).

## Design

- Pure local file scans (no GitHub API).
- Workflow-file discovery scope: repo-root
`.github/workflows/*.yml`/`*.yaml` only (mirrors `WorkflowHardening` /
`BaselineHealth` BH004).
- RE006 additionally walks the repo (depth ≤ 6) for `action.yml` files —
composite actions can live anywhere.
- Regex on YAML text rather than parsed YAML, for the same reasons cited
in `WorkflowHardening`.
- All findings route to `:sustainabot` (advisory — fixes vary by defect
class). Confidence calibrated by severity: critical 0.92, high 0.85,
warn 0.75, info 0.60.

## Module size

`lib/rules/research_extensions.ex` — ~900 lines.
`test/research_extensions_test.exs` — ~400 lines.

No refactor to existing modules.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
hyperpolymath added a commit that referenced this pull request May 26, 2026
## Summary

Adds delegates on \`Hypatia.Rules\` for the rule families landed in PRs
#320, #321, #322, #323. The new modules were independently usable but
invisible from the top-level facade; this PR closes that gap.

## New facade surface

| Function | Delegates to | Rules |
|---|---|---|
| \`scan_baseline_health/2\` | \`BaselineHealth.scan\` (existing;
docstring updated for BH007) | BH001-BH007 |
| \`scan_workflow_hardening/2\` | \`WorkflowHardening.scan\` |
WH001-WH012 |
| \`scan_supply_chain/2\` | \`SupplyChain.scan\` | SC001-SC011 |
| \`scan_branch_protection/2\` | \`BranchProtection.scan\` (API-backed;
takes \`owner, repo\`) | BP001-BP007 |
| \`scan_all_estate_policies/2\` | — runs all four families and merges
results | 34 rules total |

## Why combined-scan helper

Callers (\`pattern_analyzer\`, \`triangle_router\`,
\`fleet_dispatcher\`) already treat \`Hypatia.Rules\` as the entrypoint.
Without these delegates the new modules are reachable only via
fully-qualified names, which works but inverts the facade pattern
adopted in #316.

## RE001-RE010 (PR #325) not included

PR #325 (ResearchExtensions) is still OPEN at the time of this PR.
Adding its alias here would break compilation on \`main\` for the window
between this PR landing and #325 landing. A follow-up commit will wire
\`ResearchExtensions\` once #325 merges — tracked inline at the alias
block as a \`# wired in follow-up\` comment.

## Test plan

- Existing 18-test \`BaselineHealth\` suite still passes (untouched).
- Module compile-checks pass for the four families + facade.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant