From 6be78d73ff8d1a0f64c9b4479b1d924316eba70a Mon Sep 17 00:00:00 2001 From: agentson Date: Sun, 1 Mar 2026 22:09:48 +0900 Subject: [PATCH 1/2] governance: enforce READ-ONLY approval evidence for protected file changes (#356) --- .gitea/PULL_REQUEST_TEMPLATE.md | 7 +++ scripts/validate_governance_assets.py | 61 +++++++++++++++++++++++- tests/test_validate_governance_assets.py | 60 +++++++++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md index 90edcf4..1fb6990 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.md +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -47,6 +47,13 @@ - 모니터링 로그 경로: - 이상 징후/이슈 링크: +## READ-ONLY Approval (Required when touching READ-ONLY files) + +- Touched READ-ONLY files: +- Human approval: +- Test suite 1: +- Test suite 2: + ## Approval Gate - [ ] Static Verifier approval comment linked diff --git a/scripts/validate_governance_assets.py b/scripts/validate_governance_assets.py index 5872de1..79bc882 100644 --- a/scripts/validate_governance_assets.py +++ b/scripts/validate_governance_assets.py @@ -3,10 +3,10 @@ from __future__ import annotations -import subprocess -import sys import os import re +import subprocess +import sys from pathlib import Path REQUIREMENTS_REGISTRY = "docs/ouroboros/01_requirements_registry.md" @@ -15,6 +15,8 @@ TASK_DEF_LINE = re.compile(r"^-\s+`(?PTASK-[A-Z0-9-]+-\d{3})`(?P. REQ_ID_IN_LINE = re.compile(r"\bREQ-[A-Z0-9-]+-\d{3}\b") TASK_ID_IN_TEXT = re.compile(r"\bTASK-[A-Z0-9-]+-\d{3}\b") TEST_ID_IN_TEXT = re.compile(r"\bTEST-[A-Z0-9-]+-\d{3}\b") +READ_ONLY_FILES = {"src/core/risk_manager.py"} +PLACEHOLDER_VALUES = {"", "tbd", "n/a", "na", "none", "", ""} def must_contain(path: Path, required: list[str], errors: list[str]) -> None: @@ -118,6 +120,55 @@ def validate_pr_traceability(warnings: list[str]) -> None: warnings.append("PR text missing TEST-ID reference") +def _parse_pr_evidence_line(text: str, field: str) -> str | None: + pattern = re.compile(rf"^\s*-\s*{re.escape(field)}:\s*(?P.+?)\s*$", re.MULTILINE) + match = pattern.search(text) + if not match: + return None + return match.group("value").strip() + + +def _is_placeholder(value: str | None) -> bool: + if value is None: + return True + normalized = value.strip().lower() + return normalized in PLACEHOLDER_VALUES + + +def validate_read_only_approval( + changed_files: list[str], errors: list[str], warnings: list[str] +) -> None: + changed_set = set(changed_files) + touched = sorted(path for path in READ_ONLY_FILES if path in changed_set) + if not touched: + return + + body = os.getenv("GOVERNANCE_PR_BODY", "").strip() + if not body: + warnings.append( + "READ-ONLY file changed but PR body is unavailable; approval evidence check skipped" + ) + return + + if "READ-ONLY Approval" not in body: + errors.append("READ-ONLY file changed without 'READ-ONLY Approval' section in PR body") + return + + touched_field = _parse_pr_evidence_line(body, "Touched READ-ONLY files") + human_approval = _parse_pr_evidence_line(body, "Human approval") + test_suite_1 = _parse_pr_evidence_line(body, "Test suite 1") + test_suite_2 = _parse_pr_evidence_line(body, "Test suite 2") + + if _is_placeholder(touched_field): + errors.append("READ-ONLY Approval section missing 'Touched READ-ONLY files' evidence") + if _is_placeholder(human_approval): + errors.append("READ-ONLY Approval section missing 'Human approval' evidence") + if _is_placeholder(test_suite_1): + errors.append("READ-ONLY Approval section missing 'Test suite 1' evidence") + if _is_placeholder(test_suite_2): + errors.append("READ-ONLY Approval section missing 'Test suite 2' evidence") + + def main() -> int: errors: list[str] = [] warnings: list[str] = [] @@ -141,6 +192,11 @@ def main() -> int: "gh", "Session Handover Gate", "session_handover_check.py --strict", + "READ-ONLY Approval", + "Touched READ-ONLY files", + "Human approval", + "Test suite 1", + "Test suite 2", ], errors, ) @@ -187,6 +243,7 @@ def main() -> int: validate_registry_sync(changed_files, errors) validate_task_req_mapping(errors) validate_pr_traceability(warnings) + validate_read_only_approval(changed_files, errors, warnings) if errors: print("[FAIL] governance asset validation failed") diff --git a/tests/test_validate_governance_assets.py b/tests/test_validate_governance_assets.py index 719d801..398c677 100644 --- a/tests/test_validate_governance_assets.py +++ b/tests/test_validate_governance_assets.py @@ -116,3 +116,63 @@ def test_validate_pr_traceability_warns_when_req_missing(monkeypatch) -> None: module.validate_pr_traceability(warnings) assert warnings assert "PR text missing REQ-ID reference" in warnings + + +def test_validate_read_only_approval_requires_evidence(monkeypatch) -> None: + module = _load_module() + changed_files = ["src/core/risk_manager.py"] + errors: list[str] = [] + warnings: list[str] = [] + monkeypatch.setenv( + "GOVERNANCE_PR_BODY", + "\n".join( + [ + "## READ-ONLY Approval (Required when touching READ-ONLY files)", + "- Touched READ-ONLY files: src/core/risk_manager.py", + "- Human approval: TBD", + "- Test suite 1: pytest -q", + "- Test suite 2: TBD", + ] + ), + ) + + module.validate_read_only_approval(changed_files, errors, warnings) + assert warnings == [] + assert any("Human approval" in err for err in errors) + assert any("Test suite 2" in err for err in errors) + + +def test_validate_read_only_approval_passes_with_complete_evidence(monkeypatch) -> None: + module = _load_module() + changed_files = ["src/core/risk_manager.py"] + errors: list[str] = [] + warnings: list[str] = [] + monkeypatch.setenv( + "GOVERNANCE_PR_BODY", + "\n".join( + [ + "## READ-ONLY Approval (Required when touching READ-ONLY files)", + "- Touched READ-ONLY files: src/core/risk_manager.py", + "- Human approval: https://example.com/review/123", + "- Test suite 1: pytest -q tests/test_risk.py", + "- Test suite 2: pytest -q tests/test_main.py -k risk", + ] + ), + ) + + module.validate_read_only_approval(changed_files, errors, warnings) + assert errors == [] + assert warnings == [] + + +def test_validate_read_only_approval_warns_without_pr_body(monkeypatch) -> None: + module = _load_module() + changed_files = ["src/core/risk_manager.py"] + errors: list[str] = [] + warnings: list[str] = [] + monkeypatch.delenv("GOVERNANCE_PR_BODY", raising=False) + + module.validate_read_only_approval(changed_files, errors, warnings) + assert errors == [] + assert warnings + assert "approval evidence check skipped" in warnings[0] -- 2.49.1 From c431d82c0dec39d871ab8fd63705acc8080c5588 Mon Sep 17 00:00:00 2001 From: agentson Date: Sun, 1 Mar 2026 22:44:02 +0900 Subject: [PATCH 2/2] test: cover no-readonly-change early return in governance validator --- tests/test_validate_governance_assets.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_validate_governance_assets.py b/tests/test_validate_governance_assets.py index 398c677..3f2ff21 100644 --- a/tests/test_validate_governance_assets.py +++ b/tests/test_validate_governance_assets.py @@ -176,3 +176,14 @@ def test_validate_read_only_approval_warns_without_pr_body(monkeypatch) -> None: assert errors == [] assert warnings assert "approval evidence check skipped" in warnings[0] + + +def test_validate_read_only_approval_skips_when_no_readonly_file_changed() -> None: + module = _load_module() + changed_files = ["src/main.py"] + errors: list[str] = [] + warnings: list[str] = [] + + module.validate_read_only_approval(changed_files, errors, warnings) + assert errors == [] + assert warnings == [] -- 2.49.1