Skip to content

LLM Wiki Block‑3: Enforce runtime stage gates and persist deny reason-code evidence #322

LLM Wiki Block‑3: Enforce runtime stage gates and persist deny reason-code evidence

LLM Wiki Block‑3: Enforce runtime stage gates and persist deny reason-code evidence #322

Workflow file for this run

name: "Gate: PR Core"
# Rechenaufwand-Score: R=2 (K=2, L=2, N=2) | last-calibrated: 2026-08-26
# Trigger policy: repo framework score calibration for workflow cost controls.
# Consolidated PR gate workflow.
# Replaces: 09-pr-gates_community-pipeline-policy.yml,
# 09-pr-gates_high-exception-record.yml,
# 09-pr-gates_private-plugin-boundary.yml,
# 09-pr-gates_release-critical-tests.yml,
# 09-pr-gates_reproducible-builds.yml,
# 09-pr-gates_scanner-delta-report.yml,
# 09-pr-gates_submodule-commit-pins.yml,
# 09-pr-gates_workflow-boundary-guard.yml
#
# Governance (RELEASE_STRATEGY.md §2.3):
# release-critical-tests is a MANDATORY entry gate for PRs targeting develop
# and all edition branches (community, enterprise, hyperscaler, military).
on:
pull_request:
types: [opened, edited, synchronize, reopened]
branches:
- develop
- community
- enterprise
- hyperscaler
- military
- minimal
paths:
- CMakeLists.txt
- CMakePresets.json
- cmake/**/*.cmake
- cmake/**/*.txt
- cmake/**/*.in
- .github/workflows/**/*.yml
- .github/workflows/**/*.yaml
- .github/actions/**/action.yml
- .github/actions/**/*.sh
- .github/actions/**/*.py
- .github/scripts/**/*.py
- .github/scripts/**/*.sh
- .github/WORKFLOW_GUIDELINES.md
- .github/WORKFLOW_REGISTRY.md
- tools/ci/**/*.py
- tools/ci/**/*.sh
- tools/tests/**/*.py
push:
branches: [develop]
paths:
- CMakeLists.txt
- CMakePresets.json
- cmake/**/*.cmake
- cmake/**/*.txt
- cmake/**/*.in
- .github/workflows/gate-pr-core.yml
- .github/WORKFLOW_GUIDELINES.md
workflow_dispatch:
inputs:
run_scan_if_missing:
description: 'scanner-delta: run gap scanner when aggregate is missing'
type: boolean
default: false
concurrency:
group: ci-pr-gates-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
# ──────────────────────────────────────────────────────────────────────────────
jobs:
preflight-ci-policy:
name: Preflight CI Policy + Workflow Lint
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@v4 # v7.0.1
with:
fetch-depth: 1
- name: Run workflow preflight checks
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends python3-yaml
BASE_REF="${{ github.base_ref || 'develop' }}"
git fetch origin "${BASE_REF}" --depth=1
git diff --name-only "origin/${BASE_REF}...HEAD" > /tmp/changed_files.txt || true
python3 - <<'PY'
import re
import sys
from pathlib import Path
try:
import yaml
except Exception as exc:
print(f"::error::PyYAML import failed: {exc}")
sys.exit(1)
changed = [p.strip() for p in Path("/tmp/changed_files.txt").read_text().splitlines() if p.strip()]
changed_wf = [Path(p) for p in changed if p.startswith(".github/workflows/") and p.endswith((".yml", ".yaml"))]
if not changed_wf:
print("No workflow changes detected; preflight checks passed.")
sys.exit(0)
errors = []
third_party = re.compile(r"^[0-9a-f]{40}$")
allowed_unpinned = ("actions/", "github/")
for wf in changed_wf:
if not wf.exists():
continue
text = wf.read_text(encoding="utf-8", errors="replace")
try:
doc = yaml.safe_load(text)
except Exception as exc:
errors.append(f"{wf}: YAML parse error: {exc}")
continue
if not isinstance(doc, dict):
errors.append(f"{wf}: workflow root must be a mapping")
continue
if "permissions" not in doc:
errors.append(f"{wf}: missing top-level permissions block")
for line in text.splitlines():
s = line.strip()
if s.startswith("uses:"):
spec = s.split("uses:", 1)[1].strip()
if "@" not in spec:
errors.append(f"{wf}: invalid uses syntax without version: {spec}")
continue
action, ref = spec.split("@", 1)
action = action.strip()
ref = ref.strip()
if action.startswith("./"):
continue
if action.startswith(allowed_unpinned):
continue
if not third_party.fullmatch(ref):
errors.append(f"{wf}: third-party action must be pinned to full SHA: {spec}")
if "git push" in s:
errors.append(f"{wf}: self-mutating git push is not allowed")
if "continue-on-error: true" in s:
errors.append(f"{wf}: replace continue-on-error with explicit non-blocking job classification")
if errors:
for e in errors:
print(f"::error::{e}")
sys.exit(1)
print(f"Workflow preflight passed for {len(changed_wf)} changed workflow file(s).")
PY
# ────────────────────────────────────────────────────────────────────────────
# WAVE C POLICY GATES — Invokes all 4 policy gate workflows
# Enforces security boundaries, edition compatibility, and supply-chain integrity
# ────────────────────────────────────────────────────────────────────────────
wavec-policy-gates-private-plugin:
name: Wave C Policy Gate — Private/Public Plugin Boundary
needs: preflight-ci-policy
if: github.event_name == 'pull_request' && github.base_ref != ''
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/gate-pr-plugin-boundary.yml
wavec-policy-gates-edition-license:
name: Wave C Policy Gate — Edition & License Validation
needs: preflight-ci-policy
if: github.event_name == 'pull_request' && github.base_ref != ''
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/gate-pr-edition-license.yml
wavec-policy-gates-hash-sbom:
name: Wave C Policy Gate — Hash & SBOM Integrity
needs: preflight-ci-policy
if: github.event_name == 'pull_request' && github.base_ref != ''
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/gate-pr-hash-sbom.yml
wavec-policy-gates-community-fail-closed:
name: Wave C Policy Gate — Community Fail-Closed Validation
needs: preflight-ci-policy
if: github.event_name == 'pull_request' && github.base_ref != ''
permissions:
contents: read
pull-requests: write
uses: ./.github/workflows/gate-pr-community-failclosed.yml
# ────────────────────────────────────────────────────────────────────────────
# AGGREGATED WAVE C POLICY GATES RESULT
# Waits for all 4 policy gates to complete
# ────────────────────────────────────────────────────────────────────────────
wavec-policy-gates:
name: Wave C Policy Gates (Aggregated)
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
needs:
- wavec-policy-gates-private-plugin
- wavec-policy-gates-edition-license
- wavec-policy-gates-hash-sbom
- wavec-policy-gates-community-fail-closed
steps:
- name: Report Wave C policy gate results
run: |
{
echo "## Wave C Policy Gates Results"
echo ""
echo "| Gate | Result |"
echo "|------|--------|"
echo "| Private/Public Plugin Boundary | ${{ needs.wavec-policy-gates-private-plugin.result }} |"
echo "| Edition & License Validation | ${{ needs.wavec-policy-gates-edition-license.result }} |"
echo "| Hash & SBOM Integrity | ${{ needs.wavec-policy-gates-hash-sbom.result }} |"
echo "| Community Fail-Closed | ${{ needs.wavec-policy-gates-community-fail-closed.result }} |"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail if any Wave C policy gate failed
if: |
needs.wavec-policy-gates-private-plugin.result == 'failure' ||
needs.wavec-policy-gates-edition-license.result == 'failure' ||
needs.wavec-policy-gates-hash-sbom.result == 'failure' ||
needs.wavec-policy-gates-community-fail-closed.result == 'failure'
run: |
echo "::error::One or more Wave C policy gates FAILED — review implementation per CI_POLICY_GATES_WAVE_C.md"
exit 1
# ────────────────────────────────────────────────────────────────────────────
# GATE 1 — Community Pipeline Policy (no private credentials in community CI)
# Applies to: PRs targeting community and minimal only
# ────────────────────────────────────────────────────────────────────────────
community-pipeline-policy:
name: Community Pipeline Policy
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
if: >
github.event_name == 'pull_request' &&
(github.base_ref == 'community' || github.base_ref == 'minimal')
steps:
- uses: actions/checkout@v4 # v7.0.1
with:
fetch-depth: 1
- name: Collect changed files
run: |
git fetch origin "${{ github.base_ref }}" --depth=1
git diff --name-only "origin/${{ github.base_ref }}...HEAD" \
-- '.github/workflows/**' 'cmake/**' 'scripts/**' > changed_files.txt
cat changed_files.txt
- name: Scan for private credential patterns
run: |
python3 - <<'PY'
import re, sys
from pathlib import Path
FORBIDDEN_PATTERNS = [
(r'\$\{\{[^}]*secrets\.[A-Z_]*PRIVATE[A-Z_]*\}\}',
"Secret reference to a private-repo credential"),
(r'\$\{\{[^}]*secrets\.[A-Z_]*PLUGIN[A-Z_]*\}\}',
"Secret reference to a plugin credential"),
(r'\$\{\{[^}]*secrets\.PAT[A-Z_]*\}\}',
"PAT reference"),
(r'git\s+clone\s+.*github\.com.*private',
"Hardcoded private repository clone"),
(r'plugins/private',
"Reference to private plugin paths in community files"),
]
files_ok = True
for filepath in Path("changed_files.txt").read_text().splitlines():
p = Path(filepath)
if not p.exists():
continue
text = p.read_text(errors="replace")
for pattern, desc in FORBIDDEN_PATTERNS:
if re.search(pattern, text, re.IGNORECASE):
print(f"::error file={filepath}::{desc} — pattern: {pattern}")
files_ok = False
if not files_ok:
sys.exit(1)
print("No forbidden private-credential patterns found in community CI/build files.")
PY
# ────────────────────────────────────────────────────────────────────────────
# GATE 2 — High Exception Record Guard
# Applies to: PRs targeting all protected branches
# ────────────────────────────────────────────────────────────────────────────
high-exception-record:
name: High Exception Record Guard
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4 # v7.0.1
- uses: actions/setup-python@v5 # v5.3.0
with:
python-version: '3.11'
- name: Write PR body to file
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
printf '%s\n' "$PR_BODY" > pr_body.md
- name: Validate PR high-exception section
run: python .github/scripts/check_pr_high_exception_record.py --body-file pr_body.md
# ────────────────────────────────────────────────────────────────────────────
# GATE 3 — Private Plugin Boundary Guard
# Applies to: PRs targeting all protected branches
# ────────────────────────────────────────────────────────────────────────────
private-plugin-boundary:
name: Private Plugin Boundary Guard
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
if: >
github.event_name == 'pull_request' &&
contains(fromJSON('["develop","community","enterprise","hyperscaler","military"]'), github.base_ref)
steps:
- uses: actions/checkout@v4 # v7.0.1
with:
fetch-depth: 1
- name: Collect changed files
run: |
BASE_REF="${{ github.base_ref }}"
git fetch origin "${BASE_REF}"
if git merge-base "origin/${BASE_REF}" HEAD >/dev/null 2>&1; then
git diff --name-only "origin/${BASE_REF}...HEAD" > changed_files.txt
else
echo "::warning::No merge base found for origin/${BASE_REF}...HEAD; falling back to two-dot diff."
git diff --name-only "origin/${BASE_REF}..HEAD" > changed_files.txt
fi
- name: Enforce private plugin boundary rules
run: |
python3 - <<'PY'
import re, sys
from pathlib import Path
changed = set(Path("changed_files.txt").read_text().splitlines())
errors = []
for f in changed:
# Community/minimal packaging must not reference private plugin submodules or tokens
if f.startswith(".github/workflows/") and (
"community" in f or "minimal" in f
):
text = Path(f).read_text(errors="replace") if Path(f).exists() else ""
if "plugins/private" in text:
errors.append(f"{f}: community/minimal workflow references plugins/private")
# .gitmodules changes must still carry commit pins for Wave-1 submodules
if f == ".gitmodules":
WAVE1_PATHS = {
"plugins/themisdb_ethic_ai",
"plugins/themisdb_llm_wiki",
"plugins/themisdb_storage",
"plugins/themisdb_importer",
"plugins/themisdb_plugin_signer",
"plugins/themisdb_geo",
"plugins/themisdb_timeseries",
}
text = Path(".gitmodules").read_text(errors="replace")
blocks = re.split(r'(?=\[submodule\s)', text)
for block in blocks:
path_match = re.search(r'path\s*=\s*(.+)', block)
if not path_match:
continue
submod_path = path_match.group(1).strip()
if submod_path in WAVE1_PATHS and not re.search(r'commit\s*=', block):
errors.append(f".gitmodules: {submod_path} is missing a commit pin")
if errors:
for err in errors:
print(f"::error::{err}")
sys.exit(1)
print("Private plugin boundary check passed.")
PY
# ────────────────────────────────────────────────────────────────────────────
# GATE 4 — Release-Critical Tests ← MANDATORY GATE (RELEASE_STRATEGY.md §2.3)
# Applies to: PRs targeting develop + all edition branches
# ────────────────────────────────────────────────────────────────────────────
release-critical-tests:
name: Release-Critical Test Suite
runs-on: ubuntu-latest
timeout-minutes: 45
needs: preflight-ci-policy
env:
SCCACHE_GHA_ENABLED: "false"
permissions:
contents: read
if: >
github.event_name == 'pull_request' &&
contains(fromJSON('["develop","community","enterprise","hyperscaler","military"]'), github.base_ref)
steps:
- uses: actions/checkout@v4 # v7.0.1
- name: Setup C++ build dependencies
uses: ./.github/actions/setup-cpp-build
with:
cc: gcc-12
cxx: g++-12
extra-packages: librocksdb-dev libssl-dev zlib1g-dev libspdlog-dev nlohmann-json3-dev libtbb-dev libyaml-cpp-dev libmimalloc-dev libcurl4-openssl-dev libboost-system-dev libboost-filesystem-dev libgrpc++-dev libprotobuf-dev protobuf-compiler-grpc libpugixml-dev
- name: Configure (community-release)
run: |
set -euo pipefail
cmake --preset community-release \
-DTHEMIS_BUILD_TESTS=ON 2>&1 | tee /tmp/release-critical-configure.log
- name: Build release-critical targets
run: |
set -euo pipefail
cmake --build build-community-release \
--target themis_release_critical_tests \
--parallel "$(nproc)" 2>&1 | tee /tmp/release-critical-build.log
- name: Run release-critical tests
run: |
set -euo pipefail
ctest --test-dir build-community-release \
--label-regex "release_critical" \
--output-on-failure \
--parallel 1 \
--timeout 120 2>&1 | tee /tmp/release-critical-ctest.log
- name: Upload release-critical triage bundle
if: always()
uses: actions/upload-artifact@v4 # v7.0.1
with:
name: release-critical-triage-${{ github.run_number }}
path: |
/tmp/release-critical-configure.log
/tmp/release-critical-build.log
/tmp/release-critical-ctest.log
if-no-files-found: ignore
retention-days: 30
# ────────────────────────────────────────────────────────────────────────────
# GATE 5 — Reproducible Build Metadata
# Applies to: PRs and pushes targeting develop; cmake/build-system changes
# ────────────────────────────────────────────────────────────────────────────
reproducible-builds:
name: Reproducible Build Metadata
runs-on: ubuntu-latest
if: >
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref == 'develop')
steps:
- uses: actions/checkout@v4 # v7.0.1
- name: Derive SOURCE_DATE_EPOCH from HEAD commit
id: source_date_epoch
run: echo "value=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT"
- name: Verify reproducible build metadata
env:
SOURCE_DATE_EPOCH: ${{ steps.source_date_epoch.outputs.value }}
run: |
cmake -DOUTPUT_DIR="$RUNNER_TEMP/themis-repro" \
-P cmake/VerifyReproducibleBuildInfo.cmake
- name: Record generated header hashes
run: |
sha256sum \
"$RUNNER_TEMP/themis-repro/same-epoch-a/include/updates/build_info.h" \
"$RUNNER_TEMP/themis-repro/same-epoch-b/include/updates/build_info.h" \
"$RUNNER_TEMP/themis-repro/different-epoch/include/updates/build_info.h" \
| tee /tmp/build_info_hashes.txt
- name: Verify same-epoch hashes match (determinism)
run: |
HASH_A=$(sha256sum "$RUNNER_TEMP/themis-repro/same-epoch-a/include/updates/build_info.h" | cut -d' ' -f1)
HASH_B=$(sha256sum "$RUNNER_TEMP/themis-repro/same-epoch-b/include/updates/build_info.h" | cut -d' ' -f1)
if [ "$HASH_A" != "$HASH_B" ]; then
echo "::error::Reproducibility violation: same-epoch builds produced different hashes"
exit 1
fi
echo "Reproducibility check passed: same-epoch hashes match."
# ────────────────────────────────────────────────────────────────────────────
# GATE 6 — Scanner Delta Report
# Applies to: PRs targeting develop + community; scanner/tooling changes
# ────────────────────────────────────────────────────────────────────────────
scanner-delta-report:
name: Scanner Delta Report
runs-on: ubuntu-latest
timeout-minutes: 25
if: >
github.event_name == 'pull_request' &&
(github.base_ref == 'develop' || github.base_ref == 'community')
steps:
- uses: actions/checkout@v4 # v7.0.1
with:
fetch-depth: 1
- uses: actions/setup-python@v5 # v5.3.0
with:
python-version: '3.11'
- name: Prepare directories
run: mkdir -p ai_working ai_working/delta
- name: Resolve baseline aggregate from base branch
id: baseline
run: |
BASE_FILE="ai_working/gap_scan_v3_aggregate.baseline.json"
git fetch origin "${{ github.base_ref }}" --depth=1
git show "origin/${{ github.base_ref }}:ai_working/gap_scan_v3_aggregate.json" > "${BASE_FILE}" 2>/dev/null \
&& echo "baseline_exists=true" >> "$GITHUB_OUTPUT" \
|| echo "baseline_exists=false" >> "$GITHUB_OUTPUT"
- name: Run gap scanner (if aggregate missing and flag set)
if: steps.baseline.outputs.baseline_exists == 'false' && github.event.inputs.run_scan_if_missing == 'true'
run: |
python3 tools/gap_scanner.py \
--output ai_working/gap_scan_v3_aggregate.json \
--format json || true
- name: Compute scanner delta
run: |
python3 - <<'PY'
import json, sys
from pathlib import Path
baseline_file = Path("ai_working/gap_scan_v3_aggregate.baseline.json")
current_file = Path("ai_working/gap_scan_v3_aggregate.json")
if not baseline_file.exists():
print("No baseline aggregate — skipping delta comparison.")
sys.exit(0)
if not current_file.exists():
print("No current aggregate — skipping delta comparison.")
sys.exit(0)
baseline = json.loads(baseline_file.read_text())
current = json.loads(current_file.read_text())
baseline_ids = {item.get("id") for item in baseline.get("findings", []) if item.get("id")}
current_ids = {item.get("id") for item in current.get("findings", []) if item.get("id")}
new_findings = current_ids - baseline_ids
resolved = baseline_ids - current_ids
print(f"Scanner delta: +{len(new_findings)} new, -{len(resolved)} resolved")
if new_findings:
print("New findings: " + ", ".join(sorted(new_findings)))
Path("ai_working/delta/report.txt").write_text(
f"+{len(new_findings)} new\n-{len(resolved)} resolved\n"
)
PY
- name: Upload delta report
if: always()
uses: actions/upload-artifact@v4 # v7.0.1
with:
name: scanner-delta-${{ github.run_number }}
path: ai_working/delta/
retention-days: 14
if-no-files-found: ignore
# ────────────────────────────────────────────────────────────────────────────
# GATE 7 — Submodule Commit-Pin Gate
# Applies to: PRs targeting all protected branches; .gitmodules changes
# ────────────────────────────────────────────────────────────────────────────
submodule-commit-pins:
name: Submodule Commit-Pin Gate
runs-on: ubuntu-latest
timeout-minutes: 5
if: >
github.event_name == 'pull_request' &&
contains(fromJSON('["develop","community","enterprise","hyperscaler","military"]'), github.base_ref)
steps:
- uses: actions/checkout@v4 # v7.0.1
- name: Verify Wave-1 submodule commit pins
run: |
python3 - <<'PY'
import re, sys
from pathlib import Path
WAVE1_PATHS = {
"plugins/themisdb_ethic_ai",
"plugins/themisdb_llm_wiki",
"plugins/themisdb_storage",
"plugins/themisdb_importer",
"plugins/themisdb_plugin_signer",
"plugins/themisdb_geo",
"plugins/themisdb_timeseries",
}
text = Path(".gitmodules").read_text(encoding="utf-8")
blocks = re.split(r'(?=\[submodule\s)', text)
missing_pin = []
for block in blocks:
path_match = re.search(r'path\s*=\s*(.+)', block)
if not path_match:
continue
submod_path = path_match.group(1).strip()
if submod_path in WAVE1_PATHS and not re.search(r'commit\s*=', block):
missing_pin.append(submod_path)
if missing_pin:
for p in missing_pin:
print(f"::error::{p} is missing a commit pin in .gitmodules")
sys.exit(1)
print("All Wave-1 submodules carry commit pins.")
PY
# ────────────────────────────────────────────────────────────────────────────
# GATE 8 — Workflow Boundary Guard
# Applies to: PRs targeting community + develop; .github/workflows changes
# ────────────────────────────────────────────────────────────────────────────
workflow-boundary-guard:
name: Workflow Boundary Guard
runs-on: ubuntu-latest
timeout-minutes: 10
if: >
github.event_name == 'pull_request' &&
(github.base_ref == 'community' || github.base_ref == 'develop')
steps:
- uses: actions/checkout@v4 # v7.0.1
with:
fetch-depth: 1
- uses: actions/setup-python@v5 # v5.3.0
with:
python-version: '3.12'
- run: python -m pip install --upgrade pip pytest pyyaml
- name: Collect changed files
run: |
BASE_REF="${{ github.base_ref }}"
git fetch origin "${BASE_REF}"
if git merge-base "origin/${BASE_REF}" HEAD >/dev/null 2>&1; then
git diff --name-status --find-renames "origin/${BASE_REF}...HEAD" > workflow_changes.txt
else
echo "::warning::No merge base found for origin/${BASE_REF}...HEAD; falling back to two-dot diff."
git diff --name-status --find-renames "origin/${BASE_REF}..HEAD" > workflow_changes.txt
fi
- name: Run workflow boundary unit tests
run: python -m pytest tools/tests/test_check_workflow_boundaries.py -q
- name: Enforce workflow boundaries on changed files
run: |
python3 tools/ci/check_workflow_boundaries.py \
--diff-file workflow_changes.txt
# ────────────────────────────────────────────────────────────────────────────
# WAVE C BATCH 3 — GATE 9: Scoped Checkout Validation
# Prevents private submodules from being fetched in community builds
# Applies to: PRs targeting community/minimal branches
# ────────────────────────────────────────────────────────────────────────────
scoped-checkout-validation:
name: Scoped Checkout Validation (Wave C Batch 3)
runs-on: ubuntu-latest
timeout-minutes: 5
if: >
github.event_name == 'pull_request' &&
(github.base_ref == 'community' || github.base_ref == 'minimal')
steps:
- uses: actions/checkout@v4 # v7.0.1
- name: Validate private submodules are scoped for community
run: |
python3 - <<'PY'
import re, sys
from pathlib import Path
# Private plugin submodules that must be scoped (shallow or disabled)
PRIVATE_PLUGINS = {
"plugins/themisdb_ethic_ai",
"plugins/themisdb_storage",
"plugins/themisdb_importer",
}
gitmodules = Path(".gitmodules").read_text(encoding="utf-8")
blocks = re.split(r'(?=\[submodule\s)', gitmodules)
errors = []
for block in blocks:
# Extract submodule path
path_match = re.search(r'path\s*=\s*(.+)', block)
if not path_match:
continue
submod_path = path_match.group(1).strip()
if submod_path in PRIVATE_PLUGINS:
# Check if shallow is set
has_shallow = re.search(r'shallow\s*=\s*true', block)
if not has_shallow:
errors.append(
f"FAIL-CLOSED: {submod_path} is a private plugin "
f"but does not have shallow=true in .gitmodules. "
f"Community builds must not fetch full private repositories."
)
if errors:
for err in errors:
print(f"::error::{err}")
sys.exit(1)
print("✅ Scoped checkout validation passed: all private plugins are properly scoped.")
PY
# ────────────────────────────────────────────────────────────────────────────
# WAVE C BATCH 2 — GATE 9: Plugin Manifest Edition/License/Boundary Validation
# Fail-closed validation of edition restrictions, license gates, and public/private boundaries
# Applies to: PRs targeting all protected branches
# ────────────────────────────────────────────────────────────────────────────
manifest-edition-gates:
name: Plugin Manifest Edition Gates (Wave C Batch 2)
runs-on: ubuntu-latest
timeout-minutes: 10
if: >
github.event_name == 'pull_request' &&
contains(fromJSON('["develop","community","enterprise","hyperscaler","military","minimal"]'), github.base_ref)
steps:
- uses: actions/checkout@v4 # v7.0.1
with:
fetch-depth: 1
- name: Collect changed manifest files
run: |
git fetch origin "${{ github.base_ref }}" --depth=1
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | grep -E "plugin\.json|manifest.*\.json" > changed_manifests.txt || true
echo "Manifest files changed: $(wc -l < changed_manifests.txt)"
- name: Validate plugin manifests against schema and fail-closed gates
run: |
python3 - <<'PY'
import json
import re
import sys
from pathlib import Path
# Load all plugin manifest files
changed_manifests = []
manifest_file = Path("changed_manifests.txt")
if manifest_file.exists():
changed_manifests = [p.strip() for p in manifest_file.read_text().splitlines() if p.strip()]
if not changed_manifests:
print("✅ No plugin manifest changes detected")
sys.exit(0)
# Edition validation rules
VALID_EDITIONS = {"minimal", "community", "enterprise", "hyperscaler", "military"}
VISIBILITY_VALUES = {"public", "private", "restricted"}
LICENSE_FEATURE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_.-]*$")
errors = []
for manifest_path in changed_manifests:
p = Path(manifest_path)
if not p.exists():
continue
try:
manifest_content = json.loads(p.read_text())
except json.JSONDecodeError as e:
errors.append(f"[SCHEMA] {manifest_path}: Invalid JSON - {e}")
continue
# Validate allowed_editions field
if "allowed_editions" in manifest_content:
allowed_eds = manifest_content["allowed_editions"]
if not isinstance(allowed_eds, list):
errors.append(f"[SCHEMA] {manifest_path}: 'allowed_editions' must be array, got {type(allowed_eds).__name__}")
else:
for edition in allowed_eds:
if edition not in VALID_EDITIONS:
errors.append(f"[VALIDATION] {manifest_path}: Invalid edition '{edition}' in allowed_editions. Valid: {', '.join(VALID_EDITIONS)}")
# Validate license_feature format
if "license_feature" in manifest_content:
license_feature = manifest_content["license_feature"]
if license_feature and not LICENSE_FEATURE_PATTERN.match(license_feature):
errors.append(f"[VALIDATION] {manifest_path}: 'license_feature' '{license_feature}' violates pattern ^[a-z0-9][a-z0-9_.-]*$")
# Validate visibility field
if "visibility" in manifest_content:
visibility = manifest_content["visibility"]
if visibility not in VISIBILITY_VALUES:
errors.append(f"[SCHEMA] {manifest_path}: Invalid visibility '{visibility}'. Valid: {', '.join(VISIBILITY_VALUES)}")
# Boundary check: path contains "private/" but visibility != "private"
if "private" in manifest_path.lower():
visibility = manifest_content.get("visibility", "public")
if visibility != "private":
errors.append(f"[BOUNDARY] {manifest_path}: Path contains 'private/' but visibility is '{visibility}', not 'private'")
# Boundary check: visibility="private" in community edition
visibility = manifest_content.get("visibility", "public")
target_branch = "${{ github.base_ref }}"
if visibility == "private" and target_branch in ("community", "minimal"):
errors.append(f"[BOUNDARY] {manifest_path}: Private plugin cannot target {target_branch} edition")
if errors:
print("::error::Plugin Manifest Validation FAILED (fail-closed):")
for err in errors:
print(f" {err}")
sys.exit(1)
print(f"✅ Manifest validation passed: {len(changed_manifests)} manifest(s) validated")
PY
# ────────────────────────────────────────────────────────────────────────────
# WAVE C BATCH 3 — GATE 10: Private-Credential Scanning
# Enhanced credential detection beyond standard patterns
# Applies to: PRs targeting all protected branches
# ────────────────────────────────────────────────────────────────────────────
private-credential-scan:
name: Private-Credential Scanning (Wave C Batch 3)
runs-on: ubuntu-latest
timeout-minutes: 10
if: >
github.event_name == 'pull_request' &&
contains(fromJSON('["develop","community","enterprise","hyperscaler","military","minimal"]'), github.base_ref)
steps:
- uses: actions/checkout@v4 # v7.0.1
with:
fetch-depth: 1
- name: Collect changed files
run: |
BASE_REF="${{ github.base_ref }}"
git fetch origin "${BASE_REF}"
DIFF_RANGE="origin/${BASE_REF}...HEAD"
if git merge-base "origin/${BASE_REF}" HEAD >/dev/null 2>&1; then
:
else
echo "::warning::No merge base found for origin/${BASE_REF}...HEAD; falling back to two-dot diff."
DIFF_RANGE="origin/${BASE_REF}..HEAD"
fi
git diff --name-only "${DIFF_RANGE}" > changed_files.txt
git diff --unified=0 --no-color "${DIFF_RANGE}" > pr.diff
echo "Files changed: $(wc -l < changed_files.txt)"
- name: Scan for private credentials (AWS/Azure/GCP/OAuth/SSH)
run: |
python3 - <<'PY'
import re, sys
from pathlib import Path
# Credential patterns (CRITICAL and HIGH severity)
CREDENTIAL_PATTERNS = {
"aws_access_key": (r"AKIA[0-9A-Z]{16}", "CRITICAL"),
"aws_secret_key": (r"aws_secret_access_key\s*[:=]\s*['\"]([^'\"]{20,})['\"]", "CRITICAL"),
"azure_connection": (r"DefaultEndpointsProtocol=https;AccountName=.*;AccountKey=.{80,}", "CRITICAL"),
"azure_key": (r"SharedAccessKey=.{20,}", "CRITICAL"),
"gcp_api_key": (r"AIza[0-9A-Za-z\-_]{35}", "HIGH"),
"gcp_service_account": (r'"type":\s*"service_account".*"private_key":', "CRITICAL"),
"oauth_token": (r"oauth_token\s*[:=]\s*['\"]([a-zA-Z0-9\-_.]{50,})['\"]", "CRITICAL"),
"github_token": (r"ghp_[A-Za-z0-9_]{36}", "CRITICAL"),
"github_oauth": (r"gho_[A-Za-z0-9_]{36}", "CRITICAL"),
"github_pat": (r"github_pat_[A-Za-z0-9_]{36}", "CRITICAL"),
"ssh_private_key": (r"-----BEGIN RSA " r"PRIVATE KEY-----", "CRITICAL"), # gitleaks:allow
"openssh_private_key": (r"-----BEGIN OPENSSH " r"PRIVATE KEY-----", "CRITICAL"), # gitleaks:allow
"pgp_private_key": (r"-----BEGIN PGP " r"PRIVATE KEY BLOCK-----", "CRITICAL"), # gitleaks:allow
"private_secret_env": (r"\$\{\{.*secrets\.[A-Z_]*PRIVATE[A-Z_]*\}\}", "CRITICAL"),
"db_password": (r"db_password\s*[:=]\s*['\"]([^'\"]{8,})['\"]", "HIGH"),
}
critical_found = False
matches_summary = []
current_file = None
target_line = None
for raw_line in Path("pr.diff").read_text(errors="replace").splitlines():
if raw_line.startswith("+++ b/"):
current_file = raw_line[6:]
continue
if raw_line.startswith("@@"):
match = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", raw_line)
target_line = int(match.group(1)) if match else None
continue
if current_file is None or target_line is None:
continue
if raw_line.startswith("+") and not raw_line.startswith("+++"):
line = raw_line[1:]
line_num = target_line
target_line += 1
for pattern_name, (pattern, severity) in CREDENTIAL_PATTERNS.items():
if re.search(pattern, line, re.IGNORECASE):
msg = f"[{severity}] {pattern_name} detected at {current_file}:{line_num}"
print(f"::error::{msg}")
matches_summary.append(f"{severity}: {pattern_name}")
if severity == "CRITICAL":
critical_found = True
if critical_found:
print("\n::error::FAIL-CLOSED: Critical credential patterns found in PR diff. Do not commit secrets.")
sys.exit(1)
if matches_summary:
print(f"\n⚠️ Found {len(set(matches_summary))} credential pattern(s)")
else:
print("✅ No private credential patterns detected in PR diff")
PY
# ────────────────────────────────────────────────────────────────────────────
# Secret Scanning — Active TruffleHog scan on PR diff (non-blocking initially)
# Catches staged secrets before they land in history.
# Promotion path: set fail-on-match: true after a stabilization period.
# Debug: prints install, scan scope, file count, and detailed result summary.
# ────────────────────────────────────────────────────────────────────────────
secret-scan:
name: 🔐 Secret Scanning (TruffleHog)
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: read
steps:
- name: Print scan context
run: |
echo "============================================================"
echo " Gate: PR Core | Secret Scanning (TruffleHog)"
echo "============================================================"
echo " PR number : ${{ github.event.pull_request.number }}"
echo " Base ref : ${{ github.base_ref }}"
echo " Head SHA : ${{ github.sha }}"
echo " Actor : ${{ github.actor }}"
echo " Run ID : ${{ github.run_id }}"
echo " Mode : non-blocking findings report"
echo " Scan scope : PR diff only (HEAD vs origin/base_ref)"
echo " Detector : verified secrets only (--only-verified)"
echo "============================================================"
- name: Checkout repository
uses: actions/checkout@v4 # v7.0.1
with:
fetch-depth: 0 # TruffleHog --since-commit requires full commit history
- name: Install TruffleHog
run: |
set -euo pipefail
echo " Installing TruffleHog from official install script..."
T0=$(date +%s)
INSTALL_OK=0
for TAG in v3.97.2 v3.97.1; do
echo " Attempting TruffleHog install for ${TAG}..."
if curl -sSfL --retry 3 --retry-all-errors --retry-delay 2 \
https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh \
| sh -s -- -b /usr/local/bin "${TAG}"; then
INSTALL_OK=1
break
fi
echo "::warning::TruffleHog install failed for ${TAG}; trying fallback version."
done
if [ "${INSTALL_OK}" -ne 1 ]; then
echo "::error::Unable to install TruffleHog from pinned versions."
exit 1
fi
echo " ✅ TruffleHog installed in $(( $(date +%s) - T0 ))s"
echo " Version: $(trufflehog --version 2>&1 || echo 'N/A')"
- name: Run TruffleHog on PR diff
run: |
set -euo pipefail
BASE_REF="${{ github.base_ref }}"
echo "──────────────────────────────────────────────"
echo " Fetching base branch: ${BASE_REF}"
git fetch origin "${BASE_REF}" --depth=1
echo ""
DIFF_FILES=$(git diff --name-only "origin/${BASE_REF}...HEAD" | wc -l || echo 0)
echo " Files in PR diff : ${DIFF_FILES}"
echo " Scan strategy : git diff from origin/${BASE_REF} to HEAD"
echo " Detectors : all (verified secrets only)"
echo "──────────────────────────────────────────────"
T0=$(date +%s)
SCAN_EXIT=0
trufflehog git file://. \
--since-commit "origin/${BASE_REF}" \
--branch HEAD \
--only-verified \
--fail \
--json \
> trufflehog-results.json 2>&1 || SCAN_EXIT=$?
SCAN_SECS=$(( $(date +%s) - T0 ))
RESULT_COUNT=$(wc -l < trufflehog-results.json 2>/dev/null || echo 0)
if [ "${SCAN_EXIT}" -ne 0 ] && [ "${RESULT_COUNT}" -eq 0 ]; then
echo "::error::TruffleHog exited with status ${SCAN_EXIT} before producing scan results"
exit "${SCAN_EXIT}"
fi
echo ""
echo " Scan completed in : ${SCAN_SECS}s"
echo " Result lines : ${RESULT_COUNT}"
if [ -s trufflehog-results.json ]; then
echo ""
echo " ⚠️ POTENTIAL SECRETS DETECTED — summary:"
head -5 trufflehog-results.json | python3 -c 'import json, sys; [print(f" Detector={r.get(\"DetectorName\", \"?\")} Raw={r.get(\"Raw\", \"\")[:40]}... Source={r.get(\"SourceMetadata\", {}).get(\"Data\", {})}") for line in sys.stdin if line.strip() for r in [json.loads(line.strip())]]' 2>/dev/null || head -5 trufflehog-results.json
echo ""
echo "::warning::🔐 TruffleHog found potential secrets — see trufflehog-results artifact."
{
echo "### ⚠️ Secret Scanning — Potential secrets detected"
echo ""
echo "TruffleHog identified **${RESULT_COUNT}** potential verified secret(s) in this PR diff."
echo ""
echo "**Action required:** Review the \`trufflehog-results-${{ github.run_number }}\` artifact and"
echo "rotate any exposed credentials immediately."
echo ""
echo "_Scan scope: PR diff (HEAD vs \`origin/${{ github.base_ref }}\`). Duration: ${SCAN_SECS}s._"
} >> "$GITHUB_STEP_SUMMARY"
else
echo " ✅ No verified secrets found."
{
echo "### ✅ Secret Scanning — No secrets detected"
echo ""
echo "TruffleHog scanned **${DIFF_FILES}** PR diff file(s) and found no verified secrets."
echo ""
echo "_Scan duration: ${SCAN_SECS}s. Scan scope: HEAD vs \`origin/${{ github.base_ref }}\`._"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload TruffleHog results
if: always()
uses: actions/upload-artifact@v4 # v7.0.1
with:
name: trufflehog-results-${{ github.run_number }}
path: trufflehog-results.json
retention-days: 7 # short tier per ci-scope-config.yaml
# ────────────────────────────────────────────────────────────────────────────
# SUMMARY — Aggregates all gate results (always runs)
# ────────────────────────────────────────────────────────────────────────────
gates-summary:
name: PR Gates Summary
runs-on: ubuntu-latest
if: always()
needs:
- preflight-ci-policy
- wavec-policy-gates
- community-pipeline-policy
- high-exception-record
- private-plugin-boundary
- release-critical-tests
- reproducible-builds
- scanner-delta-report
- submodule-commit-pins
- workflow-boundary-guard
- scoped-checkout-validation
- private-credential-scan
- secret-scan
steps:
- name: Write summary
run: |
{
echo "## PR Gates Summary"
echo ""
echo "| Gate | Result |"
echo "|------|--------|"
echo "| Preflight CI Policy + Workflow Lint | ${{ needs.preflight-ci-policy.result }} |"
echo "| **Wave C Policy Gates** | ${{ needs.wavec-policy-gates.result }} |"
echo "| Community Pipeline Policy | ${{ needs.community-pipeline-policy.result }} |"
echo "| High Exception Record | ${{ needs.high-exception-record.result }} |"
echo "| Private Plugin Boundary | ${{ needs.private-plugin-boundary.result }} |"
echo "| Release-Critical Tests (**MANDATORY**) | ${{ needs.release-critical-tests.result }} |"
echo "| Reproducible Builds | ${{ needs.reproducible-builds.result }} |"
echo "| Scanner Delta Report | ${{ needs.scanner-delta-report.result }} |"
echo "| Submodule Commit Pins | ${{ needs.submodule-commit-pins.result }} |"
echo "| Workflow Boundary Guard | ${{ needs.workflow-boundary-guard.result }} |"
echo "| Scoped Checkout Validation (Wave C B3) | ${{ needs.scoped-checkout-validation.result }} |"
echo "| Private-Credential Scanning (Wave C B3) | ${{ needs.private-credential-scan.result }} |"
echo "| Secret Scanning (non-blocking) | ${{ needs.secret-scan.result }} |"
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail if mandatory Wave C policy gates failed
if: needs.wavec-policy-gates.result != '' && needs.wavec-policy-gates.result == 'failure'
run: |
echo "::error::Wave C policy gates FAILED — review implementation per CI_POLICY_GATES_WAVE_C.md"
exit 1
- name: Fail if mandatory release-critical gate failed
if: needs.release-critical-tests.result != '' && needs.release-critical-tests.result == 'failure'
run: |
echo "::error::release-critical-tests FAILED — this is a mandatory gate per RELEASE_STRATEGY.md §2.3"
exit 1