Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/agent_rules_kit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
125 changes: 116 additions & 9 deletions src/agent_rules_kit/governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand All @@ -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,
)
Expand All @@ -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
Expand All @@ -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",
]
31 changes: 31 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
90 changes: 89 additions & 1 deletion tests/test_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Loading