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
53 changes: 53 additions & 0 deletions src/agent_rules_kit/governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"Instruction file appears to encourage bypassing review, CI, or safe integration boundaries."
)

AUTHORITY_SCOPE_RULE_ID = "AIRK-GOV001"
AUTHORITY_SCOPE_MESSAGE = "Instruction file may lack clear scope or authority."

SECRET_BOUNDARY_RULE_ID = "AIRK-GOV002"
SECRET_BOUNDARY_MESSAGE = "Instruction file may lack an explicit secret-handling boundary."

Expand Down Expand Up @@ -72,6 +75,19 @@
re.compile(r"\bsensitive\s+(value(?:s)?|information|data)\b", re.IGNORECASE),
)

AUTHORITY_SCOPE_PATTERNS: tuple[Pattern[str], ...] = (
re.compile(r"\bscope\b", re.IGNORECASE),
re.compile(r"\bauthority\b", re.IGNORECASE),
re.compile(r"\bprecedence\b", re.IGNORECASE),
re.compile(r"\bhierarchy\b", re.IGNORECASE),
re.compile(r"\boverride(?:s|n|s)?\b", re.IGNORECASE),
re.compile(r"\bappl(?:y|ies)\s+to\b", re.IGNORECASE),
re.compile(r"\b(repository|repo)[- ]wide\b", re.IGNORECASE),
re.compile(r"\bpath[- ]specific\b", re.IGNORECASE),
re.compile(r"\bnearest\s+AGENTS\.md\b", re.IGNORECASE),
re.compile(r"\binstruction\s+(chain|order|source|sources)\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 @@ -115,9 +131,38 @@ def find_governance_findings(
*find_unsupported_claim_findings(repository_root, instruction_files),
*find_review_ci_bypass_findings(repository_root, instruction_files),
*find_missing_secret_boundary_findings(repository_root, instruction_files),
*find_missing_authority_scope_findings(repository_root, instruction_files),
)


def find_missing_authority_scope_findings(
repository_root: Path,
instruction_files: tuple[InstructionFile, ...],
) -> tuple[Finding, ...]:
"""Return findings for files without visible scope or authority guidance."""
findings: list[Finding] = []

for instruction_file in instruction_files:
candidate = repository_root / instruction_file.path

try:
text = candidate.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue

if not _contains_authority_scope_boundary(text):
findings.append(
Finding(
rule_id=AUTHORITY_SCOPE_RULE_ID,
severity=Severity.WARNING,
message=AUTHORITY_SCOPE_MESSAGE,
path=instruction_file.path,
)
)

return tuple(findings)


def find_missing_secret_boundary_findings(
repository_root: Path,
instruction_files: tuple[InstructionFile, ...],
Expand Down Expand Up @@ -214,6 +259,10 @@ def _contains_secret_boundary(text: str) -> bool:
return any(pattern.search(text) is not None for pattern in SECRET_BOUNDARY_PATTERNS)


def _contains_authority_scope_boundary(text: str) -> bool:
return any(pattern.search(text) is not None for pattern in AUTHORITY_SCOPE_PATTERNS)


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
Expand Down Expand Up @@ -241,6 +290,9 @@ def _contains_unsupported_claim(line: str) -> bool:


__all__ = [
"AUTHORITY_SCOPE_MESSAGE",
"AUTHORITY_SCOPE_PATTERNS",
"AUTHORITY_SCOPE_RULE_ID",
"NEGATED_REVIEW_CI_BYPASS_CONTEXT_PATTERNS",
"NEGATED_UNSUPPORTED_CLAIM_CONTEXT_PATTERNS",
"REVIEW_CI_BYPASS_MESSAGE",
Expand All @@ -253,6 +305,7 @@ def _contains_unsupported_claim(line: str) -> bool:
"UNSUPPORTED_CLAIM_PATTERNS",
"UNSUPPORTED_CLAIM_RULE_ID",
"find_governance_findings",
"find_missing_authority_scope_findings",
"find_missing_secret_boundary_findings",
"find_review_ci_bypass_findings",
"find_unsupported_claim_findings",
Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/repositories/missing-authority-scope/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# AGENTS.md

Rules:

- Read relevant files before editing.
- Run local checks before committing.
- Do not commit secrets, tokens, credentials, private URLs, or customer data.
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Rules:
- Read relevant files before editing.
- Run local checks before committing.
- Use pull requests for changes to main.
Scope: applies to this fixture repository.
1 change: 1 addition & 0 deletions tests/fixtures/repositories/risky-instructions/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ Unsafe examples:
- Use automation to bypass pending review gates.

This fixture contains no real secrets. It uses descriptive placeholder text only.
Scope: applies to this fixture repository.
1 change: 1 addition & 0 deletions tests/fixtures/repositories/single-agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ Rules:
- Keep changes small and reviewable.
- Run local checks before committing.
- Do not commit secrets, tokens, credentials, private URLs, or customer data.
Scope: applies to this fixture repository.
1 change: 1 addition & 0 deletions tests/fixtures/repositories/unsupported-claim/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Rules:
- This process guarantees security for the repository.
- This project is enterprise-grade and production-ready.
- Do not commit secrets, tokens, credentials, private URLs, or customer data.
Scope: applies to this fixture repository.
25 changes: 25 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,31 @@ def test_check_json_reports_missing_secret_boundary_findings(self) -> None:
self.assertNotIn("line", payload["findings"][0])


def test_check_json_reports_missing_authority_scope_findings(self) -> None:
output = io.StringIO()

with redirect_stdout(output):
exit_code = main(
[
"check",
str(FIXTURE_ROOT / "missing-authority-scope"),
"--format",
"json",
]
)

payload = json.loads(output.getvalue())

self.assertEqual(exit_code, 0)
self.assertEqual(payload["summary"]["finding_count"], 1)
self.assertEqual(
[finding["rule_id"] for finding in payload["findings"]],
["AIRK-GOV001"],
)
self.assertEqual(payload["findings"][0]["path"], "AGENTS.md")
self.assertNotIn("line", payload["findings"][0])



if __name__ == "__main__":
unittest.main()
59 changes: 57 additions & 2 deletions tests/test_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from agent_rules_kit.discovery import discover_instruction_files
from agent_rules_kit.governance import (
find_governance_findings,
find_missing_authority_scope_findings,
find_missing_secret_boundary_findings,
find_review_ci_bypass_findings,
find_unsupported_claim_findings,
Expand Down Expand Up @@ -186,6 +187,60 @@ def test_ignores_files_with_secret_handling_boundary(self) -> None:

self.assertEqual(findings, ())


def test_reports_missing_authority_scope_boundary(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
repository = Path(temporary_directory)
(repository / "AGENTS.md").write_text(
"\n".join(
[
"# AGENTS.md",
"",
"Rules:",
"",
"- Read relevant files before editing.",
"- Run local checks before committing.",
"- Do not commit secrets, tokens, credentials, private URLs, or customer data.",
]
)
+ "\n",
encoding="utf-8",
)

instruction_files = discover_instruction_files(repository)
findings = find_missing_authority_scope_findings(repository, instruction_files)

self.assertEqual([finding.rule_id for finding in findings], ["AIRK-GOV001"])
self.assertEqual([finding.path for finding in findings], ["AGENTS.md"])
self.assertEqual([finding.line for finding in findings], [None])

def test_ignores_files_with_authority_scope_boundary(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
repository = Path(temporary_directory)
(repository / "AGENTS.md").write_text(
"\n".join(
[
"# AGENTS.md",
"",
"Scope: applies to this repository.",
"Authority: repository instructions apply before local task notes.",
"",
"Rules:",
"",
"- Read relevant files before editing.",
"- Run local checks before committing.",
"- Do not commit secrets, tokens, credentials, private URLs, or customer data.",
]
)
+ "\n",
encoding="utf-8",
)

instruction_files = discover_instruction_files(repository)
findings = find_missing_authority_scope_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)
Expand All @@ -209,9 +264,9 @@ def test_governance_findings_keep_stable_rule_order(self) -> None:

self.assertEqual(
[finding.rule_id for finding in findings],
["AIRK-GOV006", "AIRK-GOV003", "AIRK-GOV002"],
["AIRK-GOV006", "AIRK-GOV003", "AIRK-GOV002", "AIRK-GOV001"],
)
self.assertEqual([finding.line for finding in findings], [5, 6, None])
self.assertEqual([finding.line for finding in findings], [5, 6, None, None])



Expand Down
Loading