Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Self-test CI for the dev-rig.
#
# The rig's other workflows are all reusable (workflow_call) and were never
# exercised end-to-end. This workflow invokes each of them against the rig
# itself — using LOCAL refs (./.github/workflows/*.yml) so a PR validates its
# own versions of the workflows, not the ones already on main. It both proves
# the reusable workflows work and guards them against silent regressions.

name: CI (self-test)

on:
push:
branches: [main]
pull_request: # all PRs, regardless of base — so stacked branches self-test too

jobs:
lint:
uses: ./.github/workflows/lint.yml
with:
source-dirs: "src/legionforge_dev_rig"
extra-mypy-deps: "types-PyYAML httpx respx pytest"

test:
uses: ./.github/workflows/test.yml
with:
coverage-source: "legionforge_dev_rig"
coverage-threshold: 80

sast:
uses: ./.github/workflows/sast.yml
with:
source-dirs: "src/legionforge_dev_rig"
semgrep-configs: "p/python"
permissions:
security-events: write

audit:
uses: ./.github/workflows/audit.yml

supply-chain:
uses: ./.github/workflows/supply-chain.yml
with:
# Pull the risky-exec ruleset from THIS branch so self-CI tests the PR's
# rules, not whatever is on main.
rig-ref: ${{ github.head_ref || github.ref_name }}
secrets: inherit

secrets:
uses: ./.github/workflows/secrets.yml

sbom:
uses: ./.github/workflows/sbom.yml
2 changes: 1 addition & 1 deletion .github/workflows/supply-chain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:
run: |
semgrep --config .dev-rig/semgrep/legionforge-risky-exec.yml \
--error --metrics=off \
--exclude .dev-rig .
--exclude .dev-rig --exclude .claude --exclude node_modules --exclude semgrep .

# ── Optional Socket.dev behavioral npm scan ──────────────────────────────────
# Self-skips when no SOCKET_SECURITY_API_KEY secret is provided, so callers
Expand Down
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ analysis = [
"pre-commit>=3.7",
"types-PyYAML",
]
# The `dev` extra is the contract the reusable lint.yml / test.yml workflows
# install (`pip install -e .[dev]`). Consuming projects provide their own; the
# rig defines one so it can self-test those workflows (see ci.yml).
dev = [
"ruff>=0.4",
"bandit[toml]>=1.7",
"mypy>=1.10",
"pytest>=8",
"pytest-asyncio>=0.23",
"pytest-cov>=5",
"types-PyYAML",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
Expand All @@ -43,6 +55,11 @@ ignore = [
"S607", # partial executable path — acceptable for system binaries
]

[tool.ruff.lint.per-file-ignores]
# Tests and examples legitimately use `assert` (S101) for verification.
"tests/**" = ["S101"]
"examples/**" = ["S101"]

[tool.bandit]
exclude_dirs = [".venv", "venv", ".git", "tests"]
skips = [
Expand Down
5 changes: 3 additions & 2 deletions scripts/audit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ if (Get-Command osv-scanner -ErrorAction SilentlyContinue) {
# Runs only when the repo contains shell scripts.

$shellFiles = Get-ChildItem -Path $ProjectPath -Recurse -File -Include *.sh, *.bash -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notmatch '[\\/](\.git|\.venv|node_modules)[\\/]' }
Where-Object { $_.FullName -notmatch '[\\/](\.git|\.venv|node_modules|\.claude)[\\/]' }
if ($shellFiles) {
if (Get-Command shellcheck -ErrorAction SilentlyContinue) {
Invoke-Tool "shellcheck" {
Expand Down Expand Up @@ -193,7 +193,8 @@ if (Test-Path $riskyRules) {
-v "${ProjectPath}:/src" `
-v "${rigSemgrep}:/rules:ro" `
semgrep/semgrep `
semgrep --config /rules/legionforge-risky-exec.yml /src --error
semgrep --config /rules/legionforge-risky-exec.yml /src --error `
--exclude .claude --exclude .git --exclude node_modules --exclude semgrep
}
}

Expand Down
6 changes: 4 additions & 2 deletions scripts/audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ fi
shell_files=()
while IFS= read -r f; do shell_files+=("$f"); done < <(
find "$PROJECT" -type f \( -name '*.sh' -o -name '*.bash' \) \
-not -path '*/.git/*' -not -path '*/.venv/*' -not -path '*/node_modules/*' 2>/dev/null
-not -path '*/.git/*' -not -path '*/.venv/*' -not -path '*/node_modules/*' \
-not -path '*/.claude/*' 2>/dev/null
)
if [[ ${#shell_files[@]} -gt 0 ]]; then
if command -v shellcheck > /dev/null 2>&1; then
Expand Down Expand Up @@ -156,7 +157,8 @@ if [[ -f "$RISKY_RULES" ]]; then
-v "${WIN_PROJECT}:/src" \
-v "${WIN_RIG_SEMGREP}:/rules:ro" \
semgrep/semgrep \
semgrep --config /rules/legionforge-risky-exec.yml /src --error
semgrep --config /rules/legionforge-risky-exec.yml /src --error \
--exclude .claude --exclude .git --exclude node_modules --exclude semgrep
fi

# ── Summary ───────────────────────────────────────────────────────────────────
Expand Down
13 changes: 13 additions & 0 deletions semgrep/legionforge-risky-exec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
# Scope: shell (.sh/.bash), PowerShell (.ps1/.psm1), and CI YAML, matched in
# Semgrep "generic" mode so a single ruleset spans every file type.
#
# Comment handling: rules use pattern-regex (raw text), which bypasses generic
# mode's comment tokenizer — so each rule ANDs a `pattern-not-regex` excluding
# full-line #-comments. This keeps documented usage examples (e.g. a
# "# curl ... | bash" install note) from tripping the rules; only executable
# lines are flagged. (# is the line-comment char in shell, PowerShell, and YAML.)
#
# Run:
# semgrep --config semgrep/legionforge-risky-exec.yml --error <path>
# Wired into scripts/audit.sh and .github/workflows/supply-chain.yml.
Expand All @@ -25,6 +31,7 @@ rules:
include: ["*.sh", "*.bash", "*.yml", "*.yaml", "*.zsh"]
patterns:
- pattern-regex: '(curl|wget)\b[^\n|]*\|[^\n]*\b(sh|bash|zsh|dash)\b'
- pattern-not-regex: '(?m)^\s*#.*'

# ── PowerShell download-cradle ───────────────────────────────────────────────
- id: lf-powershell-download-cradle
Expand All @@ -42,6 +49,7 @@ rules:
- pattern-regex: '(?i)(New-Object\s+Net\.WebClient)[^\n]*\.DownloadString'
- pattern-regex: '(?i)DownloadString\([^\n]*\)[^\n]*\|\s*(iex|Invoke-Expression)'
- pattern-regex: '(?i)(iwr|Invoke-WebRequest|curl)\b[^\n|]*\|\s*(iex|Invoke-Expression)'
- pattern-not-regex: '(?m)^\s*#.*'

# ── Decode-and-execute (obfuscation) ─────────────────────────────────────────
- id: lf-decode-and-exec
Expand All @@ -58,6 +66,7 @@ rules:
- pattern-regex: '\bbase64\s+(-d|--decode)\b[^\n]*\|[^\n]*\b(sh|bash|python|perl|node)\b'
- pattern-regex: '(?i)\[Convert\]::FromBase64String\([^\n]*\)[^\n]*(iex|Invoke-Expression)'
- pattern-regex: '(?i)-(enc|encodedcommand)\b'
- pattern-not-regex: '(?m)^\s*#.*'

# ── eval over fetched / command-substituted content ──────────────────────────
- id: lf-eval-remote
Expand All @@ -71,6 +80,7 @@ rules:
include: ["*.sh", "*.bash", "*.zsh", "*.yml", "*.yaml"]
patterns:
- pattern-regex: '\beval\b[^\n]*\$\((curl|wget)\b'
- pattern-not-regex: '(?m)^\s*#.*'

# ── TLS verification disabled on download ────────────────────────────────────
- id: lf-tls-verification-disabled
Expand All @@ -88,6 +98,7 @@ rules:
- pattern-regex: '\bcurl\b[^\n]*\s(-k|--insecure)\b'
- pattern-regex: '\bwget\b[^\n]*--no-check-certificate\b'
- pattern-regex: '(?i)ServerCertificateValidationCallback\s*=\s*\{?\s*\$?true'
- pattern-not-regex: '(?m)^\s*#.*'

# ── Install straight from a VCS URL / git ref ────────────────────────────────
- id: lf-install-from-vcs-url
Expand All @@ -105,6 +116,7 @@ rules:
- pattern-regex: '\bpip(3)?\s+install\b[^\n]*\bgit\+'
- pattern-regex: '\bnpm\s+(install|i)\b[^\n]*\b(git\+|https?://|github:)'
- pattern-regex: '\bcargo\s+install\b[^\n]*--git\b'
- pattern-not-regex: '(?m)^\s*#.*'

# ── npm install scripts re-enabled inline ────────────────────────────────────
- id: lf-npm-scripts-reenabled
Expand All @@ -118,3 +130,4 @@ rules:
include: ["*.sh", "*.bash", "*.yml", "*.yaml"]
patterns:
- pattern-regex: '\bnpm\b[^\n]*(--foreground-scripts|--ignore-scripts[=\s]+false)'
- pattern-not-regex: '(?m)^\s*#.*'
10 changes: 7 additions & 3 deletions src/legionforge_dev_rig/fixtures/http.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Shared httpx / respx fixtures for async HTTP provider testing."""
from collections.abc import AsyncGenerator, Generator
from collections.abc import Generator
from typing import Any

import httpx
Expand All @@ -26,9 +26,13 @@ def test_something(respx_mock_base_url):
@pytest.fixture
def mock_http_client(respx_mock_base_url: respx.MockRouter) -> httpx.AsyncClient:
"""
An httpx.AsyncClient wired into the respx mock router.
An httpx.AsyncClient whose requests are intercepted by the respx mock.
Pass this directly to provider constructors that accept a client parameter.

The respx_mock_base_url fixture activates respx.mock(), which patches httpx's
transport globally — so a plain AsyncClient is routed through the mock. (Do
NOT pass the router as `transport=`; a MockRouter is not an httpx transport.)

Usage:
async def test_health(mock_http_client):
respx_mock_base_url.get("http://127.0.0.1:11434/api/tags").mock(
Expand All @@ -37,7 +41,7 @@ async def test_health(mock_http_client):
provider = OllamaProvider(client=mock_http_client)
assert await provider.health_check() is True
"""
return httpx.AsyncClient(transport=respx_mock_base_url)
return httpx.AsyncClient()


def json_response(data: dict[str, Any], status: int = 200) -> httpx.Response:
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Re-export the shared fixtures so pytest discovers them in the rig's own
# self-test suite (mirrors the consuming-project pattern in examples/conftest.py).
from legionforge_dev_rig.fixtures import mock_http_client, respx_mock_base_url

__all__ = ["mock_http_client", "respx_mock_base_url"]
56 changes: 56 additions & 0 deletions tests/test_fixtures_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Tests for the shared httpx/respx fixtures shipped by the dev-rig.

These also serve as the rig's own self-test target so test.yml is validated
end-to-end against the rig itself (see .github/workflows/ci.yml).
"""
import httpx
import pytest

from legionforge_dev_rig.fixtures import http as fx


def test_json_response_defaults() -> None:
resp = fx.json_response({"ok": True})
assert resp.status_code == 200
assert resp.json() == {"ok": True}


def test_json_response_custom_status() -> None:
resp = fx.json_response({"created": 1}, status=201)
assert resp.status_code == 201
assert resp.json() == {"created": 1}


def test_error_response_with_detail() -> None:
resp = fx.error_response(404, "missing")
assert resp.status_code == 404
assert resp.json() == {"detail": "missing"}


def test_error_response_without_detail() -> None:
resp = fx.error_response(500)
assert resp.status_code == 500
assert resp.json() == {}


def test_respx_fixture_stubs_route(respx_mock_base_url) -> None:
respx_mock_base_url.get("http://svc.local/ping").mock(
return_value=httpx.Response(200, json={"pong": True})
)
# respx.mock() is active via the fixture; a plain client is intercepted.
with httpx.Client() as client:
r = client.get("http://svc.local/ping")
assert r.json() == {"pong": True}


@pytest.mark.asyncio
async def test_mock_http_client_is_async_and_wired(
mock_http_client, respx_mock_base_url
) -> None:
respx_mock_base_url.get("http://svc.local/health").mock(
return_value=httpx.Response(200, json={"status": "ok"})
)
async with mock_http_client as client:
r = await client.get("http://svc.local/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
Loading