diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 68c4aeb255..dc8a696b0a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,6 +10,27 @@ - [ ] Ran existing tests with `uv sync && uv run pytest` - [ ] Tested with a sample project (if applicable) +## Security Review Evidence + + + +- Changed files reviewed: +- Security impact: +- Findings and fixes: +- Residual risk: + +## Lessons Learned + + + +- Central register reviewed: `SoloSentryOrg/github-enterprise-management-solosentry/docs/lessons-learned/register.json` +- Active RCA files reviewed: +- Applicable lesson IDs and controls: +- Considered but not applicable: +- If no central lesson applies, rationale: +- Complexity removed or justified: +- Tracking issue or project item: + ## AI Disclosure @@ -19,4 +40,3 @@ - [ ] I **did** use AI assistance (describe below) - diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 33f72006a2..c157518b63 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,13 +5,24 @@ on: branches: [ main ] pull_request: branches: [ main ] + schedule: + # Keep advanced setup active so the org security configuration does not + # replace it with default setup after 90 days without an analysis. + - cron: "17 4 1 * *" + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: - security-events: write + security-events: write # Upload CodeQL SARIF results for this repository. contents: read strategy: fail-fast: false @@ -20,6 +31,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Initialize CodeQL uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 diff --git a/.github/workflows/lessons-evidence.yml b/.github/workflows/lessons-evidence.yml new file mode 100644 index 0000000000..a96423fe6d --- /dev/null +++ b/.github/workflows/lessons-evidence.yml @@ -0,0 +1,25 @@ +name: Lessons Evidence + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: lessons-evidence-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate: + name: lessons-evidence + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate lessons-learned PR evidence + run: python3 scripts/check-lessons-evidence.py diff --git a/AGENTS.md b/AGENTS.md index 8b5afd4e82..845521c7c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,18 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their --- +## SoloSentry Governance Controls + +- Before changing workflows, security controls, repository governance, release behavior, or cross-repository contracts, review the central register at `SoloSentryOrg/github-enterprise-management-solosentry` under `docs/lessons-learned/register.json` and any referenced active RCA. +- Carry applicable `LL-000x` controls into the implementation plan, pull-request body, verification evidence, and final status. If no lesson applies, state `No applicable central lesson`, give a rationale, and assess whether a new recurring failure mode or RCA follow-up is needed. +- After two failures in one cluster, record the root cause before further fix-forward. After three failed fixes, stop and perform an architecture and simplification review. +- Keep one authoritative CodeQL setup. This repository uses `.github/workflows/codeql.yml` as advanced setup; verify GitHub default-setup state and live check names before changing CodeQL or required checks. +- Use pull requests for `main`, require current-head checks to pass, resolve review findings, and perform a secure review before merge. Do not merge with open security findings or failed required checks. +- Track organization governance delivery through central issue `SoloSentryOrg/github-enterprise-management-solosentry#12` and Project 9 until rollout acceptance criteria and verification are complete. +- Include a short `Complexity removed or justified` statement for workflow, security-control, or governance changes. + +--- + ## Integration Architecture Each AI agent is a self-contained **integration subpackage** under `src/specify_cli/integrations//`. The subpackage exposes a single class that declares all metadata and inherits setup/teardown logic from a base class. Built-in integrations are then instantiated and added to the global `INTEGRATION_REGISTRY` by `src/specify_cli/integrations/__init__.py` via `_register_builtins()`. diff --git a/scripts/check-lessons-evidence.py b/scripts/check-lessons-evidence.py new file mode 100644 index 0000000000..dc38f2f1f1 --- /dev/null +++ b/scripts/check-lessons-evidence.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT + +"""Reference CI guard for central lessons-learned evidence in pull request bodies.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path.cwd() +LESSON_ID_RE = re.compile(r"\bLL-[0-9]{4}\b") +LESSONS_HEADING_RE = re.compile(r"(?im)^##+\s+(central\s+)?lessons?(\s+learned)?\b.*$") +NOT_APPLICABLE_RE = re.compile( + r"\b(no|none)\s+applicable\s+(central\s+)?lessons?\b|\bcentral\s+lessons?\s*:\s*(n/a|not applicable|none)\b", + re.IGNORECASE, +) +RATIONALE_RE = re.compile(r"\b(rationale|reason|why)\s*:", re.IGNORECASE) +DEFAULT_SCOPED_PATTERNS = ( + r"^AGENTS[.]md$", + r"^[.]github/(workflows|ISSUE_TEMPLATE)/", + r"^[.]github/pull_request_template[.]md$", + r"^catalog/", + r"^factory-contracts/", + r"^factory-lifecycle-actions/", + r"^factory-requests/", + r"^images/", + r"^packages/", + r"^policies/", + r"^schemas/", + r"^scripts/", + r"^security/", + r"^docs/", + r"^(README|SECURITY|CONTRIBUTING)[.]md$", +) + + +class LessonsEvidenceError(Exception): + """Raised when lessons evidence is missing or malformed.""" + + +def fail(message: str) -> None: + print(f"lessons evidence validation failed: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise LessonsEvidenceError(f"{path}: JSON parse failed: {exc}") from exc + if not isinstance(data, dict): + raise LessonsEvidenceError(f"{path}: top-level JSON value must be an object") + return data + + +def run_git_diff(base_sha: str, head_sha: str) -> list[str]: + result = subprocess.run( + ["git", "diff", "--name-only", f"{base_sha}...{head_sha}"], + cwd=ROOT, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise LessonsEvidenceError(result.stderr.strip() or "git diff failed") + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def event_context(event_path: Path | None) -> tuple[str, list[str], bool]: + if event_path is None: + return "", [], False + event = load_json(event_path) + pull_request = event.get("pull_request") + if not isinstance(pull_request, dict): + return "", [], False + body = pull_request.get("body") or "" + if not isinstance(body, str): + raise LessonsEvidenceError("pull_request.body must be a string when present") + base = pull_request.get("base") + head = pull_request.get("head") + if not isinstance(base, dict) or not isinstance(head, dict): + raise LessonsEvidenceError("pull_request.base and pull_request.head are required") + base_sha = base.get("sha") + head_sha = head.get("sha") + if not isinstance(base_sha, str) or not isinstance(head_sha, str): + raise LessonsEvidenceError("pull_request base/head sha values are required") + return body, run_git_diff(base_sha, head_sha), True + + +def read_changed_files(args: argparse.Namespace) -> list[str]: + changed: list[str] = [] + changed.extend(args.changed_file or []) + if args.changed_files_file: + changed.extend( + line.strip() + for line in args.changed_files_file.read_text(encoding="utf-8").splitlines() + if line.strip() + ) + return sorted({path for path in changed if path}) + + +def scoped_files(changed_files: list[str], patterns: list[re.Pattern[str]]) -> list[str]: + return sorted( + path + for path in changed_files + if any(pattern.search(path) for pattern in patterns) + ) + + +def lessons_section(body: str) -> str | None: + match = LESSONS_HEADING_RE.search(body) + if not match: + return None + next_heading = re.search(r"(?m)^##+\s+", body[match.end() :]) + if next_heading: + return body[match.start() : match.end() + next_heading.start()] + return body[match.start() :] + + +def validate_body(body: str) -> None: + section = lessons_section(body) + if section is None: + raise LessonsEvidenceError( + "scoped changes require a 'Lessons Learned' PR section citing lesson IDs or a not-applicable rationale" + ) + ids = sorted(set(LESSON_ID_RE.findall(section))) + if ids: + print(f"lessons evidence: cited {', '.join(ids)}") + return + if NOT_APPLICABLE_RE.search(section) and RATIONALE_RE.search(section): + print("lessons evidence: no applicable central lesson recorded with rationale") + return + raise LessonsEvidenceError( + "Lessons Learned section must cite at least one LL-000x ID or state no applicable central lesson with a rationale" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--event-path", type=Path, default=os.environ.get("GITHUB_EVENT_PATH")) + parser.add_argument("--body-file", type=Path) + parser.add_argument("--changed-file", action="append") + parser.add_argument("--changed-files-file", type=Path) + parser.add_argument("--scoped-pattern", action="append", default=[]) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + body = args.body_file.read_text(encoding="utf-8") if args.body_file else "" + changed = read_changed_files(args) + is_pr_event = False + if args.event_path and not body and not changed: + body, changed, is_pr_event = event_context(args.event_path) + if args.event_path and not is_pr_event and not body and not changed: + print("lessons evidence check skipped: event is not a pull_request") + return 0 + raw_patterns = args.scoped_pattern or list(DEFAULT_SCOPED_PATTERNS) + patterns = [re.compile(pattern) for pattern in raw_patterns] + scoped = scoped_files(changed, patterns) + if not scoped: + print("lessons evidence check passed: no scoped files changed") + return 0 + validate_body(body) + except LessonsEvidenceError as exc: + fail(str(exc)) + print(f"lessons evidence check passed for {len(scoped)} scoped file(s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_lessons_evidence_guard.py b/tests/test_lessons_evidence_guard.py new file mode 100644 index 0000000000..5a90c4a550 --- /dev/null +++ b/tests/test_lessons_evidence_guard.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT + +"""Tests for the repository lessons-evidence guard.""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "check-lessons-evidence.py" + + +class LessonsEvidenceGuardTests(unittest.TestCase): + def run_guard(self, body: str, *changed_files: str) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as directory: + body_path = Path(directory) / "body.md" + body_path.write_text(body, encoding="utf-8") + args = ["python3", str(SCRIPT), "--body-file", str(body_path)] + for changed_file in changed_files: + args.extend(["--changed-file", changed_file]) + return subprocess.run( + args, + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + def test_accepts_lesson_ids_for_scoped_change(self) -> None: + result = self.run_guard( + """ +## Lessons Learned + +- Applicable lesson IDs and controls: LL-0002, LL-0008. +""", + ".github/workflows/ci.yml", + ) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertIn("LL-0008", result.stdout) + + def test_accepts_no_applicable_lesson_with_rationale(self) -> None: + result = self.run_guard( + """ +## Lessons Learned + +- No applicable central lesson. +- Rationale: this is a first-time governance taxonomy change. +""", + "docs/new-governance-topic.md", + ) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertIn("no applicable central lesson", result.stdout) + + def test_rejects_scoped_change_without_evidence(self) -> None: + result = self.run_guard( + """ +## BLUF + +- Updates a workflow. +""", + ".github/workflows/ci.yml", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Lessons Learned", result.stderr) + + def test_custom_scoped_pattern_can_be_used(self) -> None: + with tempfile.TemporaryDirectory() as directory: + body_path = Path(directory) / "body.md" + body_path.write_text( + """ +## Lessons Learned + +- Applicable lesson IDs and controls: LL-0004. +""", + encoding="utf-8", + ) + custom = subprocess.run( + [ + "python3", + str(SCRIPT), + "--body-file", + str(body_path), + "--changed-file", + "terraform/main.tf", + "--scoped-pattern", + r"^terraform/", + ], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(custom.returncode, 0, custom.stderr + custom.stdout) + + def test_push_event_without_pull_request_is_skipped(self) -> None: + with tempfile.TemporaryDirectory() as directory: + event_path = Path(directory) / "push.json" + event_path.write_text(json.dumps({"ref": "refs/heads/codex/example"}), encoding="utf-8") + result = subprocess.run( + ["python3", str(SCRIPT), "--event-path", str(event_path)], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertIn("not a pull_request", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow_run_without_project.py b/tests/test_workflow_run_without_project.py index 234d0df377..c4eaa2658b 100644 --- a/tests/test_workflow_run_without_project.py +++ b/tests/test_workflow_run_without_project.py @@ -9,11 +9,12 @@ class TestWorkflowRunWithoutProject: """Tests that specify workflow run works with YAML files without .specify/ dir.""" - def test_workflow_run_yaml_without_project(self, tmp_path): + def test_workflow_run_yaml_without_project(self, tmp_path, monkeypatch): """Running a .yml file should work without a .specify/ directory.""" from typer.testing import CliRunner from specify_cli import app + monkeypatch.setenv("SPECKIT_TRUSTED_WORKFLOW", "1") runner = CliRunner() # Create a minimal workflow YAML with a shell step @@ -54,6 +55,7 @@ def test_workflow_run_yaml_with_tilde_and_uppercase_suffix(self, tmp_path, monke from typer.testing import CliRunner from specify_cli import app + monkeypatch.setenv("SPECKIT_TRUSTED_WORKFLOW", "1") runner = CliRunner() home_dir = tmp_path / "home" @@ -128,11 +130,12 @@ def test_workflow_run_missing_yaml_file(self, tmp_path): # non-existent .yml files fall through to project check or file-not-found assert result.exit_code != 0 - def test_workflow_run_failing_yaml_without_project(self, tmp_path): + def test_workflow_run_failing_yaml_without_project(self, tmp_path, monkeypatch): """A failing workflow YAML should report failure status.""" from typer.testing import CliRunner from specify_cli import app + monkeypatch.setenv("SPECKIT_TRUSTED_WORKFLOW", "1") runner = CliRunner() workflow_file = tmp_path / "fail-workflow.yml"