Skip to content

Linter API

policyshield.lint.linter

Rule linter — static analysis for PolicyShield YAML rules.

RuleLinter

Static analyzer for PolicyShield rule sets.

Runs a set of checks on a RuleSet and returns a list of LintWarning.

Source code in policyshield/lint/linter.py
class RuleLinter:
    """Static analyzer for PolicyShield rule sets.

    Runs a set of checks on a RuleSet and returns a list of LintWarning.
    """

    def lint(self, ruleset: RuleSet) -> list[LintWarning]:
        """Run all lint checks on a rule set.

        Args:
            ruleset: The RuleSet to analyze.

        Returns:
            List of LintWarning findings.
        """
        warnings: list[LintWarning] = []
        warnings.extend(self.check_duplicate_ids(ruleset))
        warnings.extend(self.check_invalid_regex(ruleset))
        warnings.extend(self.check_broad_tool_pattern(ruleset))
        warnings.extend(self.check_missing_message(ruleset))
        warnings.extend(self.check_conflicting_verdicts(ruleset))
        warnings.extend(self.check_disabled_rules(ruleset))
        warnings.extend(self.check_chain_rules(ruleset))
        return warnings

    def check_duplicate_ids(self, ruleset: RuleSet) -> list[LintWarning]:
        """Check for duplicate rule IDs."""
        warnings: list[LintWarning] = []
        seen: dict[str, int] = {}
        for idx, rule in enumerate(ruleset.rules):
            if rule.id in seen:
                warnings.append(
                    LintWarning(
                        level="ERROR",
                        rule_id=rule.id,
                        check="duplicate_ids",
                        message=f"Duplicate rule ID '{rule.id}' (first seen in rule #{seen[rule.id] + 1})",
                    )
                )
            else:
                seen[rule.id] = idx
        return warnings

    def check_invalid_regex(self, ruleset: RuleSet) -> list[LintWarning]:
        """Check for invalid regex patterns in args_match."""
        warnings: list[LintWarning] = []
        for rule in ruleset.rules:
            args_match = rule.when.get("args_match", {})
            if not isinstance(args_match, dict):
                continue
            for field, matcher in args_match.items():
                # Extract the regex pattern
                pattern = None
                if isinstance(matcher, dict):
                    pattern = matcher.get("regex")
                elif isinstance(matcher, str):
                    pattern = matcher
                if pattern is not None:
                    try:
                        re.compile(pattern)
                    except re.error as e:
                        warnings.append(
                            LintWarning(
                                level="ERROR",
                                rule_id=rule.id,
                                check="invalid_regex",
                                message=f"Invalid regex in args_match.{field}: '{pattern}' ({e})",
                            )
                        )
        return warnings

    def check_broad_tool_pattern(self, ruleset: RuleSet) -> list[LintWarning]:
        """Check for overly broad tool patterns like '.*' or '.+'."""
        warnings: list[LintWarning] = []
        broad_patterns = {".*", ".+"}
        for rule in ruleset.rules:
            tool = rule.when.get("tool", "")
            tools = tool if isinstance(tool, list) else [tool]
            for t in tools:
                if isinstance(t, str) and t in broad_patterns:
                    warnings.append(
                        LintWarning(
                            level="WARNING",
                            rule_id=rule.id,
                            check="broad_tool_pattern",
                            message=f"Tool pattern '{t}' matches all tools",
                        )
                    )
        return warnings

    def check_missing_message(self, ruleset: RuleSet) -> list[LintWarning]:
        """Check for BLOCK rules without a message."""
        warnings: list[LintWarning] = []
        from policyshield.core.models import Verdict

        for rule in ruleset.rules:
            if rule.then == Verdict.BLOCK and not rule.message:
                warnings.append(
                    LintWarning(
                        level="WARNING",
                        rule_id=rule.id,
                        check="missing_message",
                        message="Rule with 'then: block' has no message — agent won't get an explanation",
                    )
                )
        return warnings

    def check_conflicting_verdicts(self, ruleset: RuleSet) -> list[LintWarning]:
        """Check for rules with the same tool and overlapping args_match but different verdicts."""
        warnings: list[LintWarning] = []
        # Group rules by tool pattern
        tool_groups: dict[str | tuple[str, ...], list] = {}
        for rule in ruleset.rules:
            if not rule.enabled:
                continue
            tool = rule.when.get("tool", "")
            # Normalize tool to a hashable key
            if isinstance(tool, list):
                tool_key = tuple(sorted(tool))
            else:
                tool_key = tool
            if tool_key:
                tool_groups.setdefault(tool_key, []).append(rule)

        for tool, rules in tool_groups.items():
            if len(rules) < 2:
                continue
            for i, r1 in enumerate(rules):
                for r2 in rules[i + 1 :]:
                    if r1.then != r2.then:
                        # Check if args_match patterns overlap
                        am1 = r1.when.get("args_match", {})
                        am2 = r2.when.get("args_match", {})
                        if self._args_overlap(am1, am2):
                            warnings.append(
                                LintWarning(
                                    level="WARNING",
                                    rule_id=r1.id,
                                    check="conflicting_verdicts",
                                    message=(
                                        f"Rules '{r1.id}' ({r1.then.value}) and '{r2.id}' "
                                        f"({r2.then.value}) match tool '{tool}' with overlapping args"
                                    ),
                                )
                            )
        return warnings

    @staticmethod
    def _args_overlap(am1: dict, am2: dict) -> bool:
        """Heuristic: check if two args_match patterns could overlap.

        If both are empty, they overlap (both match everything).
        If only one is empty, it matches everything so overlap is possible.
        If they share fields with identical patterns, they overlap.
        If they share fields with different patterns, they likely don't.
        If they have no shared fields, conservatively assume overlap.
        """
        if not am1 or not am2:
            return True  # One or both match everything

        fields1 = set(am1.keys()) if isinstance(am1, dict) else set()
        fields2 = set(am2.keys()) if isinstance(am2, dict) else set()
        shared = fields1 & fields2

        if not shared:
            return True  # Different fields — could still overlap

        # For shared fields, check if patterns are identical
        for f in shared:
            v1 = am1[f]
            v2 = am2[f]
            # Normalize to comparable form
            p1 = v1.get("regex", v1) if isinstance(v1, dict) else v1
            p2 = v2.get("regex", v2) if isinstance(v2, dict) else v2
            if p1 != p2:
                return False  # Different patterns on same field — unlikely overlap

        return True  # Same patterns on shared fields — overlap

    def check_disabled_rules(self, ruleset: RuleSet) -> list[LintWarning]:
        """Report disabled rules as INFO."""
        warnings: list[LintWarning] = []
        for rule in ruleset.rules:
            if not rule.enabled:
                warnings.append(
                    LintWarning(
                        level="INFO",
                        rule_id=rule.id,
                        check="disabled_rules",
                        message="Rule is disabled",
                    )
                )
        return warnings

    def check_chain_rules(self, ruleset: RuleSet) -> list[LintWarning]:
        """Lint chain rule definitions for common mistakes."""
        from policyshield.core.models import Verdict

        warnings: list[LintWarning] = []
        for rule in ruleset.rules:
            if not rule.chain:
                continue

            # ALLOW verdict on chain rule is suspicious
            if rule.then == Verdict.ALLOW:
                warnings.append(
                    LintWarning(
                        level="WARNING",
                        rule_id=rule.id,
                        check="chain_allow_verdict",
                        message="Chain rule has 'then: ALLOW' — chain rules usually BLOCK or ESCALATE",
                    )
                )

            for i, step in enumerate(rule.chain):
                if not isinstance(step, dict):
                    warnings.append(
                        LintWarning(
                            level="ERROR",
                            rule_id=rule.id,
                            check="chain_invalid_step",
                            message=f"Chain step #{i + 1} is not a mapping",
                        )
                    )
                    continue

                if "tool" not in step:
                    warnings.append(
                        LintWarning(
                            level="ERROR",
                            rule_id=rule.id,
                            check="chain_missing_tool",
                            message=f"Chain step #{i + 1} missing required 'tool' field",
                        )
                    )
        return warnings

check_broad_tool_pattern(ruleset)

Check for overly broad tool patterns like '.*' or '.+'.

Source code in policyshield/lint/linter.py
def check_broad_tool_pattern(self, ruleset: RuleSet) -> list[LintWarning]:
    """Check for overly broad tool patterns like '.*' or '.+'."""
    warnings: list[LintWarning] = []
    broad_patterns = {".*", ".+"}
    for rule in ruleset.rules:
        tool = rule.when.get("tool", "")
        tools = tool if isinstance(tool, list) else [tool]
        for t in tools:
            if isinstance(t, str) and t in broad_patterns:
                warnings.append(
                    LintWarning(
                        level="WARNING",
                        rule_id=rule.id,
                        check="broad_tool_pattern",
                        message=f"Tool pattern '{t}' matches all tools",
                    )
                )
    return warnings

check_chain_rules(ruleset)

Lint chain rule definitions for common mistakes.

Source code in policyshield/lint/linter.py
def check_chain_rules(self, ruleset: RuleSet) -> list[LintWarning]:
    """Lint chain rule definitions for common mistakes."""
    from policyshield.core.models import Verdict

    warnings: list[LintWarning] = []
    for rule in ruleset.rules:
        if not rule.chain:
            continue

        # ALLOW verdict on chain rule is suspicious
        if rule.then == Verdict.ALLOW:
            warnings.append(
                LintWarning(
                    level="WARNING",
                    rule_id=rule.id,
                    check="chain_allow_verdict",
                    message="Chain rule has 'then: ALLOW' — chain rules usually BLOCK or ESCALATE",
                )
            )

        for i, step in enumerate(rule.chain):
            if not isinstance(step, dict):
                warnings.append(
                    LintWarning(
                        level="ERROR",
                        rule_id=rule.id,
                        check="chain_invalid_step",
                        message=f"Chain step #{i + 1} is not a mapping",
                    )
                )
                continue

            if "tool" not in step:
                warnings.append(
                    LintWarning(
                        level="ERROR",
                        rule_id=rule.id,
                        check="chain_missing_tool",
                        message=f"Chain step #{i + 1} missing required 'tool' field",
                    )
                )
    return warnings

check_conflicting_verdicts(ruleset)

Check for rules with the same tool and overlapping args_match but different verdicts.

Source code in policyshield/lint/linter.py
def check_conflicting_verdicts(self, ruleset: RuleSet) -> list[LintWarning]:
    """Check for rules with the same tool and overlapping args_match but different verdicts."""
    warnings: list[LintWarning] = []
    # Group rules by tool pattern
    tool_groups: dict[str | tuple[str, ...], list] = {}
    for rule in ruleset.rules:
        if not rule.enabled:
            continue
        tool = rule.when.get("tool", "")
        # Normalize tool to a hashable key
        if isinstance(tool, list):
            tool_key = tuple(sorted(tool))
        else:
            tool_key = tool
        if tool_key:
            tool_groups.setdefault(tool_key, []).append(rule)

    for tool, rules in tool_groups.items():
        if len(rules) < 2:
            continue
        for i, r1 in enumerate(rules):
            for r2 in rules[i + 1 :]:
                if r1.then != r2.then:
                    # Check if args_match patterns overlap
                    am1 = r1.when.get("args_match", {})
                    am2 = r2.when.get("args_match", {})
                    if self._args_overlap(am1, am2):
                        warnings.append(
                            LintWarning(
                                level="WARNING",
                                rule_id=r1.id,
                                check="conflicting_verdicts",
                                message=(
                                    f"Rules '{r1.id}' ({r1.then.value}) and '{r2.id}' "
                                    f"({r2.then.value}) match tool '{tool}' with overlapping args"
                                ),
                            )
                        )
    return warnings

check_disabled_rules(ruleset)

Report disabled rules as INFO.

Source code in policyshield/lint/linter.py
def check_disabled_rules(self, ruleset: RuleSet) -> list[LintWarning]:
    """Report disabled rules as INFO."""
    warnings: list[LintWarning] = []
    for rule in ruleset.rules:
        if not rule.enabled:
            warnings.append(
                LintWarning(
                    level="INFO",
                    rule_id=rule.id,
                    check="disabled_rules",
                    message="Rule is disabled",
                )
            )
    return warnings

check_duplicate_ids(ruleset)

Check for duplicate rule IDs.

Source code in policyshield/lint/linter.py
def check_duplicate_ids(self, ruleset: RuleSet) -> list[LintWarning]:
    """Check for duplicate rule IDs."""
    warnings: list[LintWarning] = []
    seen: dict[str, int] = {}
    for idx, rule in enumerate(ruleset.rules):
        if rule.id in seen:
            warnings.append(
                LintWarning(
                    level="ERROR",
                    rule_id=rule.id,
                    check="duplicate_ids",
                    message=f"Duplicate rule ID '{rule.id}' (first seen in rule #{seen[rule.id] + 1})",
                )
            )
        else:
            seen[rule.id] = idx
    return warnings

check_invalid_regex(ruleset)

Check for invalid regex patterns in args_match.

Source code in policyshield/lint/linter.py
def check_invalid_regex(self, ruleset: RuleSet) -> list[LintWarning]:
    """Check for invalid regex patterns in args_match."""
    warnings: list[LintWarning] = []
    for rule in ruleset.rules:
        args_match = rule.when.get("args_match", {})
        if not isinstance(args_match, dict):
            continue
        for field, matcher in args_match.items():
            # Extract the regex pattern
            pattern = None
            if isinstance(matcher, dict):
                pattern = matcher.get("regex")
            elif isinstance(matcher, str):
                pattern = matcher
            if pattern is not None:
                try:
                    re.compile(pattern)
                except re.error as e:
                    warnings.append(
                        LintWarning(
                            level="ERROR",
                            rule_id=rule.id,
                            check="invalid_regex",
                            message=f"Invalid regex in args_match.{field}: '{pattern}' ({e})",
                        )
                    )
    return warnings

check_missing_message(ruleset)

Check for BLOCK rules without a message.

Source code in policyshield/lint/linter.py
def check_missing_message(self, ruleset: RuleSet) -> list[LintWarning]:
    """Check for BLOCK rules without a message."""
    warnings: list[LintWarning] = []
    from policyshield.core.models import Verdict

    for rule in ruleset.rules:
        if rule.then == Verdict.BLOCK and not rule.message:
            warnings.append(
                LintWarning(
                    level="WARNING",
                    rule_id=rule.id,
                    check="missing_message",
                    message="Rule with 'then: block' has no message — agent won't get an explanation",
                )
            )
    return warnings

lint(ruleset)

Run all lint checks on a rule set.

Parameters:

Name Type Description Default
ruleset RuleSet

The RuleSet to analyze.

required

Returns:

Type Description
list[LintWarning]

List of LintWarning findings.

Source code in policyshield/lint/linter.py
def lint(self, ruleset: RuleSet) -> list[LintWarning]:
    """Run all lint checks on a rule set.

    Args:
        ruleset: The RuleSet to analyze.

    Returns:
        List of LintWarning findings.
    """
    warnings: list[LintWarning] = []
    warnings.extend(self.check_duplicate_ids(ruleset))
    warnings.extend(self.check_invalid_regex(ruleset))
    warnings.extend(self.check_broad_tool_pattern(ruleset))
    warnings.extend(self.check_missing_message(ruleset))
    warnings.extend(self.check_conflicting_verdicts(ruleset))
    warnings.extend(self.check_disabled_rules(ruleset))
    warnings.extend(self.check_chain_rules(ruleset))
    return warnings

LintWarning dataclass

A single lint finding.

Source code in policyshield/lint/linter.py
@dataclass
class LintWarning:
    """A single lint finding."""

    level: str  # ERROR, WARNING, INFO
    rule_id: str  # ID of the rule (or "*" for global issues)
    check: str  # Name of the check (duplicate_ids, invalid_regex, ...)
    message: str  # Human-readable description