diff --git a/src/agent_rules_kit/governance.py b/src/agent_rules_kit/governance.py index 2535199..7462df9 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." ) +SECRET_BOUNDARY_RULE_ID = "AIRK-GOV002" +SECRET_BOUNDARY_MESSAGE = "Instruction file may lack an explicit secret-handling boundary." + UNSUPPORTED_CLAIM_RULE_ID = "AIRK-GOV006" UNSUPPORTED_CLAIM_MESSAGE = ( "Instruction file may contain an unsupported security or maturity claim." @@ -59,6 +62,16 @@ ), ) +SECRET_BOUNDARY_PATTERNS: tuple[Pattern[str], ...] = ( + re.compile(r"\bsecret(?:s)?\b", re.IGNORECASE), + re.compile(r"\btoken(?:s)?\b", re.IGNORECASE), + re.compile(r"\bcredential(?:s)?\b", re.IGNORECASE), + re.compile(r"\bapi[-_ ]?key(?:s)?\b", re.IGNORECASE), + re.compile(r"\bprivate\s+(data|url(?:s)?|key(?:s)?)\b", re.IGNORECASE), + re.compile(r"\bcustomer\s+data\b", re.IGNORECASE), + re.compile(r"\bsensitive\s+(value(?:s)?|information|data)\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), @@ -101,9 +114,38 @@ def find_governance_findings( return ( *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), ) +def find_missing_secret_boundary_findings( + repository_root: Path, + instruction_files: tuple[InstructionFile, ...], +) -> tuple[Finding, ...]: + """Return findings for files without visible secret-handling 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_secret_boundary(text): + findings.append( + Finding( + rule_id=SECRET_BOUNDARY_RULE_ID, + severity=Severity.WARNING, + message=SECRET_BOUNDARY_MESSAGE, + path=instruction_file.path, + ) + ) + + return tuple(findings) + + def find_review_ci_bypass_findings( repository_root: Path, instruction_files: tuple[InstructionFile, ...], @@ -168,6 +210,10 @@ def _find_line_findings( return tuple(findings) +def _contains_secret_boundary(text: str) -> bool: + return any(pattern.search(text) is not None for pattern in SECRET_BOUNDARY_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 @@ -200,10 +246,14 @@ def _contains_unsupported_claim(line: str) -> bool: "REVIEW_CI_BYPASS_MESSAGE", "REVIEW_CI_BYPASS_PATTERNS", "REVIEW_CI_BYPASS_RULE_ID", + "SECRET_BOUNDARY_MESSAGE", + "SECRET_BOUNDARY_PATTERNS", + "SECRET_BOUNDARY_RULE_ID", "UNSUPPORTED_CLAIM_MESSAGE", "UNSUPPORTED_CLAIM_PATTERNS", "UNSUPPORTED_CLAIM_RULE_ID", "find_governance_findings", + "find_missing_secret_boundary_findings", "find_review_ci_bypass_findings", "find_unsupported_claim_findings", ] diff --git a/tests/fixtures/repositories/missing-secret-boundary/AGENTS.md b/tests/fixtures/repositories/missing-secret-boundary/AGENTS.md new file mode 100644 index 0000000..73aa729 --- /dev/null +++ b/tests/fixtures/repositories/missing-secret-boundary/AGENTS.md @@ -0,0 +1,7 @@ +# AGENTS.md + +Rules: + +- Read relevant files before editing. +- Run local checks before committing. +- Use pull requests for changes to main. diff --git a/tests/fixtures/repositories/unsupported-claim/AGENTS.md b/tests/fixtures/repositories/unsupported-claim/AGENTS.md index 5708204..d716727 100644 --- a/tests/fixtures/repositories/unsupported-claim/AGENTS.md +++ b/tests/fixtures/repositories/unsupported-claim/AGENTS.md @@ -4,3 +4,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. diff --git a/tests/test_cli.py b/tests/test_cli.py index 978ec9e..a6d5274 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -533,6 +533,31 @@ def test_check_json_reports_review_ci_bypass_findings(self) -> None: ) + def test_check_json_reports_missing_secret_boundary_findings(self) -> None: + output = io.StringIO() + + with redirect_stdout(output): + exit_code = main( + [ + "check", + str(FIXTURE_ROOT / "missing-secret-boundary"), + "--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-GOV002"], + ) + 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 dc66328..6e50a47 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_secret_boundary_findings, find_review_ci_bypass_findings, find_unsupported_claim_findings, ) @@ -137,6 +138,54 @@ def test_ignores_negative_guidance_about_review_ci_bypass(self) -> None: self.assertEqual(findings, ()) + + def test_reports_missing_secret_handling_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.", + ] + ) + + "\n", + encoding="utf-8", + ) + + instruction_files = discover_instruction_files(repository) + findings = find_missing_secret_boundary_findings(repository, instruction_files) + + self.assertEqual([finding.rule_id for finding in findings], ["AIRK-GOV002"]) + self.assertEqual([finding.path for finding in findings], ["AGENTS.md"]) + self.assertEqual([finding.line for finding in findings], [None]) + + def test_ignores_files_with_secret_handling_boundary(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repository = Path(temporary_directory) + (repository / "AGENTS.md").write_text( + "\n".join( + [ + "# AGENTS.md", + "", + "Rules:", + "", + "- Do not commit secrets, tokens, credentials, private URLs, or customer data.", + ] + ) + + "\n", + encoding="utf-8", + ) + + instruction_files = discover_instruction_files(repository) + findings = find_missing_secret_boundary_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) @@ -160,9 +209,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-GOV006", "AIRK-GOV003", "AIRK-GOV002"], ) - self.assertEqual([finding.line for finding in findings], [5, 6]) + self.assertEqual([finding.line for finding in findings], [5, 6, None])