governance: enforce READ-ONLY approval evidence for protected file changes (#356)
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 33s

This commit is contained in:
agentson
2026-03-01 22:09:48 +09:00
parent f50833941c
commit 6be78d73ff
3 changed files with 126 additions and 2 deletions

View File

@@ -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

View File

@@ -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+`(?P<task_id>TASK-[A-Z0-9-]+-\d{3})`(?P<body>.
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", "<link>", "<required>"}
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<value>.+?)\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")

View File

@@ -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]