diff --git a/src/agent_rules_kit/governance.py b/src/agent_rules_kit/governance.py index 7462df9..7ff8b85 100644 --- a/src/agent_rules_kit/governance.py +++ b/src/agent_rules_kit/governance.py @@ -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." @@ -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), @@ -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, ...], @@ -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 @@ -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", @@ -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", diff --git a/tests/fixtures/repositories/missing-authority-scope/AGENTS.md b/tests/fixtures/repositories/missing-authority-scope/AGENTS.md new file mode 100644 index 0000000..564ff42 --- /dev/null +++ b/tests/fixtures/repositories/missing-authority-scope/AGENTS.md @@ -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. diff --git a/tests/fixtures/repositories/missing-secret-boundary/AGENTS.md b/tests/fixtures/repositories/missing-secret-boundary/AGENTS.md index 73aa729..1ee5ed4 100644 --- a/tests/fixtures/repositories/missing-secret-boundary/AGENTS.md +++ b/tests/fixtures/repositories/missing-secret-boundary/AGENTS.md @@ -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. diff --git a/tests/fixtures/repositories/risky-instructions/AGENTS.md b/tests/fixtures/repositories/risky-instructions/AGENTS.md index db0ab60..bd4cbaf 100644 --- a/tests/fixtures/repositories/risky-instructions/AGENTS.md +++ b/tests/fixtures/repositories/risky-instructions/AGENTS.md @@ -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. diff --git a/tests/fixtures/repositories/single-agent/AGENTS.md b/tests/fixtures/repositories/single-agent/AGENTS.md index 151b2c4..f58bbce 100644 --- a/tests/fixtures/repositories/single-agent/AGENTS.md +++ b/tests/fixtures/repositories/single-agent/AGENTS.md @@ -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. diff --git a/tests/fixtures/repositories/unsupported-claim/AGENTS.md b/tests/fixtures/repositories/unsupported-claim/AGENTS.md index d716727..576bac1 100644 --- a/tests/fixtures/repositories/unsupported-claim/AGENTS.md +++ b/tests/fixtures/repositories/unsupported-claim/AGENTS.md @@ -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. diff --git a/tests/test_cli.py b/tests/test_cli.py index a6d5274..7d3604a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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() diff --git a/tests/test_governance.py b/tests/test_governance.py index 6e50a47..f80d558 100644 --- a/tests/test_governance.py +++ b/tests/test_governance.py @@ -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, @@ -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) @@ -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])