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
73 changes: 71 additions & 2 deletions src/agent_rules_kit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

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.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 @@ -111,21 +113,28 @@ def _run_check(repository_root: Path, *, output_format: str = "console") -> int:
return 2

status = "ok" if instruction_files else "no_instruction_files"
payload = _build_check_payload(repository_root, instruction_files, status=status)
findings = find_unsupported_claim_findings(repository_root, instruction_files)
payload = _build_check_payload(
repository_root,
instruction_files,
findings=findings,
status=status,
)

if output_format == "json":
_print_json(payload)
elif output_format == "markdown":
_print_markdown(payload)
else:
return _print_console_check(repository_root, instruction_files)
return _print_console_check(repository_root, instruction_files, findings)

return 0 if instruction_files else 1


def _print_console_check(
repository_root: Path,
instruction_files: tuple[InstructionFile, ...],
findings: tuple[Finding, ...],
) -> int:
print(f"agent-rules-kit check: {repository_root}")

Expand All @@ -137,6 +146,15 @@ def _print_console_check(
for instruction_file in instruction_files:
print(f"- {instruction_file.path} [{instruction_file.kind.value}]")

if findings:
print("Findings:")
for finding in findings:
location = _format_finding_location(finding)
print(
f"- {finding.rule_id} [{finding.severity.value}] "
f"{location} - {redact_secret_like_values(finding.message)}"
)

return 0


Expand Down Expand Up @@ -193,6 +211,7 @@ def _build_check_payload(
repository_root: Path,
instruction_files: tuple[InstructionFile, ...],
*,
findings: tuple[Finding, ...],
status: str,
) -> dict[str, object]:
return {
Expand All @@ -208,7 +227,9 @@ def _build_check_payload(
],
"summary": {
"supported_instruction_file_count": len(instruction_files),
"finding_count": len(findings),
},
"findings": [_build_finding_payload(finding) for finding in findings],
"error": None,
}

Expand All @@ -224,7 +245,9 @@ def _build_check_error_payload(
"instruction_files": [],
"summary": {
"supported_instruction_file_count": 0,
"finding_count": 0,
},
"findings": [],
"error": {
"message": redact_secret_like_values(str(error)),
},
Expand All @@ -244,6 +267,7 @@ def _print_markdown(payload: dict[str, object]) -> None:
"- Supported instruction files: "
f"{payload['summary']['supported_instruction_file_count']}"
)
print(f"- Findings: {payload['summary']['finding_count']}")

error = payload["error"]
if error is not None:
Expand All @@ -265,6 +289,51 @@ def _print_markdown(payload: dict[str, object]) -> None:
kind = _markdown_value(str(instruction_file["kind"]))
print(f"| {path} | {kind} |")

findings = payload["findings"]
if findings:
print()
print("## Findings")
print()
print("| Rule | Severity | Location | Message |")
print("| --- | --- | --- | --- |")
for finding in findings:
rule_id = _markdown_value(str(finding["rule_id"]))
severity = _markdown_value(str(finding["severity"]))
location = _markdown_value(_format_finding_payload_location(finding))
message = _markdown_value(str(finding["message"]))
print(f"| {rule_id} | {severity} | {location} | {message} |")


def _build_finding_payload(finding: Finding) -> dict[str, str | int]:
payload = finding.to_dict()

if "message" in payload:
payload["message"] = redact_secret_like_values(str(payload["message"]))
if "path" in payload:
payload["path"] = redact_secret_like_values(str(payload["path"]))

return payload


def _format_finding_location(finding: Finding) -> str:
if finding.path is None:
return "repository"
if finding.line is None:
return redact_secret_like_values(finding.path)
return f"{redact_secret_like_values(finding.path)}:{finding.line}"


def _format_finding_payload_location(finding: dict[str, object]) -> str:
path_value = finding.get("path")
if path_value is None:
return "repository"

line_value = finding.get("line")
if line_value is None:
return str(path_value)

return f"{path_value}:{line_value}"


def _markdown_value(value: str) -> str:
return redact_secret_like_values(value).replace("|", "\\|").replace("\n", " ")
Expand Down
102 changes: 102 additions & 0 deletions src/agent_rules_kit/governance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Instruction governance diagnostics."""

from __future__ import annotations

import re
from pathlib import Path
from re import Pattern

from agent_rules_kit.discovery import InstructionFile
from agent_rules_kit.findings import Finding, Severity

UNSUPPORTED_CLAIM_RULE_ID = "AIRK-GOV006"
UNSUPPORTED_CLAIM_MESSAGE = (
"Instruction file may contain an unsupported security or maturity claim."
)

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),
re.compile(
r"\bmake[s]?\s+(the\s+)?(repository|repo|project|tool)\s+(secure|safe)\b",
re.IGNORECASE,
),
re.compile(r"\bcomplete\s+secret\s+scann(?:er|ing)\b", re.IGNORECASE),
re.compile(r"\bproduction[- ]ready\b", re.IGNORECASE),
re.compile(r"\benterprise[- ]grade\b", re.IGNORECASE),
)

NEGATED_UNSUPPORTED_CLAIM_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"claim[s]?|guarantee[sd]?|security|safety|secure|safe|"
r"production[- ]ready|enterprise[- ]grade|complete secret scann(?:er|ing)"
r")\b",
re.IGNORECASE,
),
re.compile(
r"\bnot\s+(a\s+)?("
r"security scanner|secret scanner|production[- ]ready|enterprise[- ]grade|"
r"secure|safe"
r")\b",
re.IGNORECASE,
),
)


def find_unsupported_claim_findings(
repository_root: Path,
instruction_files: tuple[InstructionFile, ...],
) -> tuple[Finding, ...]:
"""Return unsupported security or maturity claim findings.

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.
"""
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

for line_number, line in enumerate(text.splitlines(), start=1):
if _contains_unsupported_claim(line):
findings.append(
Finding(
rule_id=UNSUPPORTED_CLAIM_RULE_ID,
severity=Severity.WARNING,
message=UNSUPPORTED_CLAIM_MESSAGE,
path=instruction_file.path,
line=line_number,
)
)

return tuple(findings)


def _contains_unsupported_claim(line: str) -> bool:
has_claim = any(
pattern.search(line) is not None for pattern in UNSUPPORTED_CLAIM_PATTERNS
)
if not has_claim:
return False

return not any(
pattern.search(line) is not None
for pattern in NEGATED_UNSUPPORTED_CLAIM_CONTEXT_PATTERNS
)


__all__ = [
"NEGATED_UNSUPPORTED_CLAIM_CONTEXT_PATTERNS",
"UNSUPPORTED_CLAIM_MESSAGE",
"UNSUPPORTED_CLAIM_PATTERNS",
"UNSUPPORTED_CLAIM_RULE_ID",
"find_unsupported_claim_findings",
]
6 changes: 6 additions & 0 deletions tests/fixtures/repositories/unsupported-claim/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# AGENTS.md

Rules:

- This process guarantees security for the repository.
- This project is enterprise-grade and production-ready.
83 changes: 83 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,89 @@ def test_init_write_backs_up_existing_agents_file_before_replacing(self) -> None
self.assertIn("- AGENTS.md [backup-and-replace]", text)
self.assertIn("backup: AGENTS.md.agent-rules-kit.bak", text)

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

with redirect_stdout(output):
exit_code = main(["check", str(FIXTURE_ROOT / "unsupported-claim")])

text = output.getvalue()

self.assertEqual(exit_code, 0)
self.assertIn("Found 1 supported instruction file(s):", text)
self.assertIn("Findings:", text)
self.assertIn("AIRK-GOV006 [warning] AGENTS.md:5", text)
self.assertIn("AGENTS.md:6", text)
self.assertIn(
"Instruction file may contain an unsupported security or maturity claim.",
text,
)

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

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

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

self.assertEqual(exit_code, 0)
self.assertEqual(payload["summary"]["finding_count"], 2)
self.assertEqual(len(payload["findings"]), 2)
self.assertEqual(payload["findings"][0]["rule_id"], "AIRK-GOV006")
self.assertEqual(payload["findings"][0]["severity"], "warning")
self.assertEqual(payload["findings"][0]["path"], "AGENTS.md")
self.assertEqual(payload["findings"][0]["line"], 5)
self.assertEqual(payload["findings"][1]["line"], 6)

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

with redirect_stdout(output):
exit_code = main(
[
"check",
str(FIXTURE_ROOT / "unsupported-claim"),
"--format",
"markdown",
]
)

text = output.getvalue()

self.assertEqual(exit_code, 0)
self.assertIn("- Findings: 2", text)
self.assertIn("## Findings", text)
self.assertIn("| AIRK-GOV006 | warning | AGENTS.md:5 |", text)
self.assertIn("| AIRK-GOV006 | warning | AGENTS.md:6 |", text)

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

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

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

self.assertEqual(exit_code, 0)
self.assertEqual(payload["summary"]["finding_count"], 0)
self.assertEqual(payload["findings"], [])



if __name__ == "__main__":
unittest.main()
Loading
Loading