diff --git a/src/agent_rules_kit/cli.py b/src/agent_rules_kit/cli.py index 6670ce7..669a520 100644 --- a/src/agent_rules_kit/cli.py +++ b/src/agent_rules_kit/cli.py @@ -11,7 +11,7 @@ from agent_rules_kit import __version__ from agent_rules_kit.discovery import InstructionFile, discover_instruction_files from agent_rules_kit.findings import Finding -from agent_rules_kit.governance import find_unsupported_claim_findings +from agent_rules_kit.governance import find_governance_findings from agent_rules_kit.init_plan import InitPlan, build_init_plan from agent_rules_kit.init_write import InitWriteResult, write_init_files from agent_rules_kit.redaction import redact_secret_like_values @@ -113,7 +113,7 @@ def _run_check(repository_root: Path, *, output_format: str = "console") -> int: return 2 status = "ok" if instruction_files else "no_instruction_files" - findings = find_unsupported_claim_findings(repository_root, instruction_files) + findings = find_governance_findings(repository_root, instruction_files) payload = _build_check_payload( repository_root, instruction_files, diff --git a/src/agent_rules_kit/governance.py b/src/agent_rules_kit/governance.py index 60e9b27..2535199 100644 --- a/src/agent_rules_kit/governance.py +++ b/src/agent_rules_kit/governance.py @@ -3,17 +3,62 @@ from __future__ import annotations import re +from collections.abc import Callable from pathlib import Path from re import Pattern from agent_rules_kit.discovery import InstructionFile from agent_rules_kit.findings import Finding, Severity +REVIEW_CI_BYPASS_RULE_ID = "AIRK-GOV003" +REVIEW_CI_BYPASS_MESSAGE = ( + "Instruction file appears to encourage bypassing review, CI, or safe integration boundaries." +) + UNSUPPORTED_CLAIM_RULE_ID = "AIRK-GOV006" UNSUPPORTED_CLAIM_MESSAGE = ( "Instruction file may contain an unsupported security or maturity claim." ) +REVIEW_CI_BYPASS_PATTERNS: tuple[Pattern[str], ...] = ( + re.compile(r"\b(ignore|skip)\s+(failing\s+)?(checks|tests|ci)\b", re.IGNORECASE), + re.compile(r"\bskip\s+(code\s+)?review\b", re.IGNORECASE), + re.compile(r"\b(commit|push)\s+directly\s+to\s+main\b", re.IGNORECASE), + re.compile(r"\bdirect\s+push(?:es)?\s+to\s+main\b", re.IGNORECASE), + re.compile(r"\bmerge\s+without\s+(review|approval)\b", re.IGNORECASE), + re.compile( + r"\bbypass(?:ing)?\s+(" + r"branch protection|review|reviews|pending review gates|ci|checks|safe integration" + r")\b", + re.IGNORECASE, + ), + re.compile( + r"\bforce[- ]push\b.{0,80}\b(normal|default|routine|workflow)\b", + re.IGNORECASE, + ), +) + +NEGATED_REVIEW_CI_BYPASS_CONTEXT_PATTERNS: tuple[Pattern[str], ...] = ( + re.compile( + r"\b(do not|don't|must not|should not|never|avoid|forbid|forbidden|no)\b" + r".{0,120}\b(" + r"bypass(?:ing)?|skip(?:ping)?|ignore|commit(?:ting)?|push(?:ing|es)?|direct push(?:es)?|force[- ]push|merge" + r")\b", + re.IGNORECASE, + ), + re.compile( + r"\bnot\s+(" + r"bypass(?:ing)?|skip(?:ping)?|ignore|commit(?:ting)?|push(?:ing)?|" + r"merge|force[- ]push" + r")\b", + re.IGNORECASE, + ), + re.compile( + r"\b(break[- ]glass|emergency)\b.{0,120}\b(explicit|human|maintainer)\s+approval\b", + re.IGNORECASE, + ), +) + UNSUPPORTED_CLAIM_PATTERNS: tuple[Pattern[str], ...] = ( re.compile(r"\bguarantee[sd]?\s+(security|safety)\b", re.IGNORECASE), re.compile(r"\bguaranteed\s+(secure|safe|security|safety)\b", re.IGNORECASE), @@ -45,16 +90,59 @@ ) +LinePredicate = Callable[[str], bool] + + +def find_governance_findings( + repository_root: Path, + instruction_files: tuple[InstructionFile, ...], +) -> tuple[Finding, ...]: + """Return all governance findings in stable rule order.""" + return ( + *find_unsupported_claim_findings(repository_root, instruction_files), + *find_review_ci_bypass_findings(repository_root, instruction_files), + ) + + +def find_review_ci_bypass_findings( + repository_root: Path, + instruction_files: tuple[InstructionFile, ...], +) -> tuple[Finding, ...]: + """Return review, CI, or safe integration bypass findings.""" + return _find_line_findings( + repository_root, + instruction_files, + rule_id=REVIEW_CI_BYPASS_RULE_ID, + severity=Severity.WARNING, + message=REVIEW_CI_BYPASS_MESSAGE, + predicate=_contains_review_ci_bypass_guidance, + ) + + def find_unsupported_claim_findings( repository_root: Path, instruction_files: tuple[InstructionFile, ...], ) -> tuple[Finding, ...]: - """Return unsupported security or maturity claim findings. + """Return unsupported security or maturity claim findings.""" + return _find_line_findings( + repository_root, + instruction_files, + rule_id=UNSUPPORTED_CLAIM_RULE_ID, + severity=Severity.WARNING, + message=UNSUPPORTED_CLAIM_MESSAGE, + predicate=_contains_unsupported_claim, + ) + - The rule is intentionally conservative and deterministic. It scans only - supported instruction files discovered by agent-rules-kit and does not - execute repository commands, call the network, or call an LLM. - """ +def _find_line_findings( + repository_root: Path, + instruction_files: tuple[InstructionFile, ...], + *, + rule_id: str, + severity: Severity, + message: str, + predicate: LinePredicate, +) -> tuple[Finding, ...]: findings: list[Finding] = [] for instruction_file in instruction_files: @@ -66,12 +154,12 @@ def find_unsupported_claim_findings( continue for line_number, line in enumerate(text.splitlines(), start=1): - if _contains_unsupported_claim(line): + if predicate(line): findings.append( Finding( - rule_id=UNSUPPORTED_CLAIM_RULE_ID, - severity=Severity.WARNING, - message=UNSUPPORTED_CLAIM_MESSAGE, + rule_id=rule_id, + severity=severity, + message=message, path=instruction_file.path, line=line_number, ) @@ -80,6 +168,19 @@ def find_unsupported_claim_findings( return tuple(findings) +def _contains_review_ci_bypass_guidance(line: str) -> bool: + has_bypass_guidance = any( + pattern.search(line) is not None for pattern in REVIEW_CI_BYPASS_PATTERNS + ) + if not has_bypass_guidance: + return False + + return not any( + pattern.search(line) is not None + for pattern in NEGATED_REVIEW_CI_BYPASS_CONTEXT_PATTERNS + ) + + def _contains_unsupported_claim(line: str) -> bool: has_claim = any( pattern.search(line) is not None for pattern in UNSUPPORTED_CLAIM_PATTERNS @@ -94,9 +195,15 @@ def _contains_unsupported_claim(line: str) -> bool: __all__ = [ + "NEGATED_REVIEW_CI_BYPASS_CONTEXT_PATTERNS", "NEGATED_UNSUPPORTED_CLAIM_CONTEXT_PATTERNS", + "REVIEW_CI_BYPASS_MESSAGE", + "REVIEW_CI_BYPASS_PATTERNS", + "REVIEW_CI_BYPASS_RULE_ID", "UNSUPPORTED_CLAIM_MESSAGE", "UNSUPPORTED_CLAIM_PATTERNS", "UNSUPPORTED_CLAIM_RULE_ID", + "find_governance_findings", + "find_review_ci_bypass_findings", "find_unsupported_claim_findings", ] diff --git a/tests/test_cli.py b/tests/test_cli.py index fee6fea..978ec9e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -502,6 +502,37 @@ def test_check_json_reports_empty_findings_for_clean_fixture(self) -> None: self.assertEqual(payload["findings"], []) + def test_check_json_reports_review_ci_bypass_findings(self) -> None: + output = io.StringIO() + + with redirect_stdout(output): + exit_code = main( + [ + "check", + str(FIXTURE_ROOT / "risky-instructions"), + "--format", + "json", + ] + ) + + payload = json.loads(output.getvalue()) + + self.assertEqual(exit_code, 0) + self.assertEqual(payload["summary"]["finding_count"], 3) + self.assertEqual( + [finding["rule_id"] for finding in payload["findings"]], + ["AIRK-GOV003", "AIRK-GOV003", "AIRK-GOV003"], + ) + self.assertEqual( + [finding["line"] for finding in payload["findings"]], + [7, 8, 10], + ) + self.assertEqual( + [finding["path"] for finding in payload["findings"]], + ["AGENTS.md", "AGENTS.md", "AGENTS.md"], + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_governance.py b/tests/test_governance.py index 6d8dc7b..dc66328 100644 --- a/tests/test_governance.py +++ b/tests/test_governance.py @@ -5,7 +5,11 @@ from pathlib import Path from agent_rules_kit.discovery import discover_instruction_files -from agent_rules_kit.governance import find_unsupported_claim_findings +from agent_rules_kit.governance import ( + find_governance_findings, + find_review_ci_bypass_findings, + find_unsupported_claim_findings, +) class GovernanceFindingTests(unittest.TestCase): @@ -77,6 +81,90 @@ def test_scans_only_supported_instruction_files(self) -> None: self.assertEqual(instruction_files, ()) self.assertEqual(findings, ()) + def test_reports_review_ci_bypass_guidance(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + (repository / "AGENTS.md").write_text( + "\n".join( + [ + "# AGENTS.md", + "", + "Rules:", + "", + "- Ignore failing checks when the release is urgent.", + "- Commit directly to main.", + "- Use automation to bypass pending review gates.", + "- Merge without review when urgent.", + ] + ) + + "\n", + encoding="utf-8", + ) + + instruction_files = discover_instruction_files(repository) + findings = find_review_ci_bypass_findings(repository, instruction_files) + + self.assertEqual( + [finding.rule_id for finding in findings], + ["AIRK-GOV003", "AIRK-GOV003", "AIRK-GOV003", "AIRK-GOV003"], + ) + self.assertEqual([finding.line for finding in findings], [5, 6, 7, 8]) + self.assertEqual([finding.path for finding in findings], ["AGENTS.md"] * 4) + + def test_ignores_negative_guidance_about_review_ci_bypass(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + (repository / "AGENTS.md").write_text( + "\n".join( + [ + "# AGENTS.md", + "", + "Rules:", + "", + "- Do not bypass CI.", + "- Never skip review.", + "- Avoid direct pushes to main.", + "- Emergency bypass branch protection requires explicit human approval.", + "- Use pull requests for changes to main.", + ] + ) + + "\n", + encoding="utf-8", + ) + + instruction_files = discover_instruction_files(repository) + findings = find_review_ci_bypass_findings(repository, instruction_files) + + self.assertEqual(findings, ()) + + def test_governance_findings_keep_stable_rule_order(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + (repository / "AGENTS.md").write_text( + "\n".join( + [ + "# AGENTS.md", + "", + "Rules:", + "", + "- This project is production-ready.", + "- Skip CI when the release is urgent.", + ] + ) + + "\n", + encoding="utf-8", + ) + + instruction_files = discover_instruction_files(repository) + findings = find_governance_findings(repository, instruction_files) + + self.assertEqual( + [finding.rule_id for finding in findings], + ["AIRK-GOV006", "AIRK-GOV003"], + ) + self.assertEqual([finding.line for finding in findings], [5, 6]) + + if __name__ == "__main__": unittest.main()