Skip to content

API Reference

Auto-generated from source docstrings.

Models

agentscanner.models

Core data model: severities, artifact types, the normalized resource IR, and findings.

Design note: the Resource IR carries a line lookup from day one so every finding can cite file:line (see DESIGN.md §6). v1 uses raw-text re-find for line mapping, which is accurate enough for the distinctive strings checks search for (rule text, URLs, env values).

Resource dataclass

One parsed artifact file — the unit a Check runs against.

Source code in src/agentscanner/models.py
@dataclass
class Resource:
    """One parsed artifact file — the unit a Check runs against."""

    type: ArtifactType
    path: Path
    scope: Scope
    raw_text: str
    data: Any = None                     # parsed JSON dict (settings/mcp/manifest)
    frontmatter: Optional[dict] = None   # YAML frontmatter (agent/skill/command)
    body: Optional[str] = None           # markdown body (agent/skill/command/memory)
    parse_error: Optional[str] = None

    _lines: list = field(default_factory=list, repr=False)

    def __post_init__(self) -> None:
        if not self._lines:
            self._lines = self.raw_text.splitlines()

    def line_of(self, needle: str, default: int = 1) -> int:
        """1-based line number of the first line containing ``needle``."""
        if not needle:
            return default
        for i, line in enumerate(self._lines, start=1):
            if needle in line:
                return i
        return default

line_of(needle, default=1)

1-based line number of the first line containing needle.

Source code in src/agentscanner/models.py
def line_of(self, needle: str, default: int = 1) -> int:
    """1-based line number of the first line containing ``needle``."""
    if not needle:
        return default
    for i, line in enumerate(self._lines, start=1):
        if needle in line:
            return i
    return default

Scope

Bases: str, Enum

Where the artifact lives. Discovery tags each resource so the same engine cleanly scans repo + user (+ managed/plugin) config in one run.

Source code in src/agentscanner/models.py
class Scope(str, enum.Enum):
    """Where the artifact lives. Discovery tags each resource so the same engine
    cleanly scans repo + user (+ managed/plugin) config in one run."""

    USER = "user"          # ~/.claude
    PROJECT = "project"    # <repo>/.claude/settings.json, .mcp.json, CLAUDE.md
    LOCAL = "local"        # <repo>/.claude/settings.local.json
    MANAGED = "managed"    # enterprise/managed-settings.json
    PLUGIN = "plugin"      # bundled plugin artifacts
    UNKNOWN = "unknown"

Severity

Bases: IntEnum

Ordered so thresholds can be compared numerically (CRITICAL highest).

Source code in src/agentscanner/models.py
class Severity(enum.IntEnum):
    """Ordered so thresholds can be compared numerically (CRITICAL highest)."""

    INFO = 0
    LOW = 1
    MEDIUM = 2
    HIGH = 3
    CRITICAL = 4

    def __str__(self) -> str:  # pragma: no cover - trivial
        return self.name

    @classmethod
    def parse(cls, value: str) -> "Severity":
        return cls[value.strip().upper()]

Discovery

agentscanner.discovery

Discover and parse Claude Code artifacts across scopes.

Scope-driven and modular: each scope (user / project / local / managed / plugin) is an independent source. The CLI selects which scopes are active, so the same engine cleanly scans a repo, the user's ~/.claude, or both in one run — every resource is tagged with its Scope.

discover(repo_root=None, include_user=False, user_home=None)

Return parsed resources for the selected scopes.

  • repo_root: scan <repo>/.claude, <repo>/.mcp.json, <repo>/CLAUDE.md and the .claude-plugin marketplace manifest if present.
  • include_user: additionally scan ~/.claude.
Source code in src/agentscanner/discovery.py
def discover(
    repo_root: Optional[Path] = None,
    include_user: bool = False,
    user_home: Optional[Path] = None,
) -> List[Resource]:
    """Return parsed resources for the selected scopes.

    - repo_root: scan ``<repo>/.claude``, ``<repo>/.mcp.json``, ``<repo>/CLAUDE.md``
      and the ``.claude-plugin`` marketplace manifest if present.
    - include_user: additionally scan ``~/.claude``.
    """
    resources: List[Resource] = []

    if repo_root is not None:
        repo_root = repo_root.resolve()
        claude_dir = repo_root / ".claude"
        if claude_dir.is_dir():
            resources += _collect_claude_dir(claude_dir, Scope.PROJECT)

        # project-level files outside .claude
        for name, atype, sc in (
            (".mcp.json", ArtifactType.MCP, Scope.PROJECT),
            ("CLAUDE.md", ArtifactType.MEMORY, Scope.PROJECT),
        ):
            f = repo_root / name
            if f.is_file() and not _too_big(f):
                if atype is ArtifactType.MCP:
                    resources.append(parse_json(f, sc, atype))
                else:
                    resources.append(parse_markdown(f, sc, atype))

        # marketplace manifest + plugin trees (plugin/marketplace repos)
        mkt = repo_root / ".claude-plugin" / "marketplace.json"
        if mkt.is_file() and not _too_big(mkt):
            resources.append(parse_json(mkt, Scope.PLUGIN, ArtifactType.PLUGIN_MANIFEST))
        if (repo_root / "plugins").is_dir():
            resources += _collect_plugin_tree(repo_root)

    if include_user:
        home = user_home or Path(os.path.expanduser("~"))
        user_claude = home / ".claude"
        if user_claude.is_dir():
            resources += _collect_claude_dir(user_claude, Scope.USER)

    return resources

Engine

agentscanner.engine

Scan engine: dispatch resources to applicable checks and collect findings.

apply_model_tier(findings, tier)

Bump the severity of model_sensitive findings by one level when tier is "low".

Baseline severities assume a frontier-capable model. Content that relies on the model to resist an embedded instruction (prompt injection, social-engineering framing, obfuscated payloads, injected hook context) is measurably more exploitable against a smaller / less-aligned model — this reflects that without needing to actually run the model (see the probe command for that). No-op when tier is "high" (default).

Source code in src/agentscanner/engine.py
def apply_model_tier(findings: List[Finding], tier: str) -> List[Finding]:
    """Bump the severity of ``model_sensitive`` findings by one level when
    *tier* is ``"low"``.

    Baseline severities assume a frontier-capable model. Content that relies
    on the model to resist an embedded instruction (prompt injection,
    social-engineering framing, obfuscated payloads, injected hook context)
    is measurably more exploitable against a smaller / less-aligned model —
    this reflects that without needing to actually run the model (see the
    `probe` command for that). No-op when *tier* is ``"high"`` (default).
    """
    if tier != "low":
        return findings
    from .checks import get_checks

    sensitive_ids = {c.id for c in get_checks() if getattr(c, "model_sensitive", False)}
    for f in findings:
        if f.check_id in sensitive_ids and f.severity < Severity.CRITICAL:
            f.severity = Severity(int(f.severity) + 1)
    findings.sort(key=lambda f: (-int(f.severity), f.check_id, str(f.resource.path)))
    return findings

filter_by_threshold(findings, threshold)

Return only findings at or above threshold severity.

Source code in src/agentscanner/engine.py
def filter_by_threshold(findings: List[Finding], threshold: Severity) -> List[Finding]:
    """Return only findings at or above *threshold* severity."""
    return [f for f in findings if f.severity >= threshold]

run_checks(resources, only=(), skip=())

Run all registered checks against resources and return sorted findings.

Parameters:

Name Type Description Default
resources Iterable[Resource]

Parsed artifacts from :func:~agentscanner.discovery.discover.

required
only Iterable[str]

If non-empty, run only checks whose IDs are in this iterable.

()
skip Iterable[str]

Check IDs to exclude from the run.

()

Returns:

Type Description
List[Finding]

Findings sorted by descending severity, then check ID, then file path.

List[Finding]

A buggy check never aborts the scan — it produces an INFO finding instead.

Source code in src/agentscanner/engine.py
def run_checks(
    resources: Iterable[Resource],
    only: Iterable[str] = (),
    skip: Iterable[str] = (),
) -> List[Finding]:
    """Run all registered checks against *resources* and return sorted findings.

    Args:
        resources: Parsed artifacts from :func:`~agentscanner.discovery.discover`.
        only: If non-empty, run only checks whose IDs are in this iterable.
        skip: Check IDs to exclude from the run.

    Returns:
        Findings sorted by descending severity, then check ID, then file path.
        A buggy check never aborts the scan — it produces an INFO finding instead.
    """
    checks = get_checks(only=only, skip=skip)
    findings: List[Finding] = []

    for resource in resources:
        if resource.parse_error:
            findings.append(
                Finding(
                    check_id="CC-PARSE-000",
                    severity=Severity.LOW,
                    title="Artifact could not be parsed",
                    message=resource.parse_error,
                    resource=resource,
                    line=1,
                    remediation="Fix the syntax so the file can be security-scanned.",
                    framework="Operational",
                )
            )
            continue
        for check in checks:
            if resource.type not in check.applies_to:
                continue
            try:
                findings.extend(check.analyze(resource))
            except Exception as exc:  # a buggy check must never abort the scan
                findings.append(
                    Finding(
                        check_id=check.id,
                        severity=Severity.INFO,
                        title="Check raised an error",
                        message=f"{check.id} failed on this resource: {exc}",
                        resource=resource,
                    )
                )

    findings.sort(key=lambda f: (-int(f.severity), f.check_id, str(f.resource.path)))
    return findings

Checks

Base

agentscanner.checks.base

Check abstract base class + registry.

Each check declares the artifact types it applies to and yields Findings. The engine instantiates every registered check once and dispatches resources to it.

Check

Abstract base class for all agentscanner checks.

Subclass this, declare the class attributes, implement :meth:analyze, and decorate with :func:register to add a check to the scanner.

Attributes:

Name Type Description
id str

Unique check identifier (e.g. AS-HOOK-001).

severity Severity

Default severity for findings emitted by this check.

title str

Short human-readable name shown in the CLI table.

applies_to Set[ArtifactType]

Artifact types this check runs against.

remediation str

Actionable fix guidance included in every finding.

framework str

Comma-separated OWASP/NIST framework references.

Source code in src/agentscanner/checks/base.py
class Check:
    """Abstract base class for all agentscanner checks.

    Subclass this, declare the class attributes, implement :meth:`analyze`,
    and decorate with :func:`register` to add a check to the scanner.

    Attributes:
        id: Unique check identifier (e.g. ``AS-HOOK-001``).
        severity: Default severity for findings emitted by this check.
        title: Short human-readable name shown in the CLI table.
        applies_to: Artifact types this check runs against.
        remediation: Actionable fix guidance included in every finding.
        framework: Comma-separated OWASP/NIST framework references.
    """

    id: str = ""
    severity: Severity = Severity.MEDIUM
    title: str = ""
    applies_to: Set[ArtifactType] = set()
    remediation: str = ""
    framework: str = ""
    # True for checks whose real-world exploitability depends on which model
    # runs the flagged content (e.g. prompt-injection resistance) — smaller /
    # less-aligned models are more likely to comply with embedded
    # instructions than a frontier model. See --model-tier in the CLI.
    model_sensitive: bool = False

    def analyze(self, resource: Resource) -> Iterable[Finding]:  # pragma: no cover
        """Inspect *resource* and yield zero or more :class:`~agentscanner.models.Finding` objects."""
        raise NotImplementedError

    def finding(self, resource: Resource, message: str, line: int = 1) -> Finding:
        """Build a :class:`~agentscanner.models.Finding` pre-populated with this check's metadata."""
        return Finding(
            check_id=self.id,
            severity=self.severity,
            title=self.title,
            message=message,
            resource=resource,
            line=line,
            remediation=self.remediation,
            framework=self.framework,
        )

analyze(resource)

Inspect resource and yield zero or more :class:~agentscanner.models.Finding objects.

Source code in src/agentscanner/checks/base.py
def analyze(self, resource: Resource) -> Iterable[Finding]:  # pragma: no cover
    """Inspect *resource* and yield zero or more :class:`~agentscanner.models.Finding` objects."""
    raise NotImplementedError

finding(resource, message, line=1)

Build a :class:~agentscanner.models.Finding pre-populated with this check's metadata.

Source code in src/agentscanner/checks/base.py
def finding(self, resource: Resource, message: str, line: int = 1) -> Finding:
    """Build a :class:`~agentscanner.models.Finding` pre-populated with this check's metadata."""
    return Finding(
        check_id=self.id,
        severity=self.severity,
        title=self.title,
        message=message,
        resource=resource,
        line=line,
        remediation=self.remediation,
        framework=self.framework,
    )

get_checks(only=(), skip=())

Return registered checks filtered by only / skip ID sets, sorted by ID.

Source code in src/agentscanner/checks/base.py
def get_checks(
    only: Iterable[str] = (),
    skip: Iterable[str] = (),
) -> List[Check]:
    """Return registered checks filtered by *only* / *skip* ID sets, sorted by ID."""
    only_set = {c.strip() for c in only if c.strip()}
    skip_set = {c.strip() for c in skip if c.strip()}
    checks = []
    for cid, chk in sorted(CHECK_REGISTRY.items()):
        if only_set and cid not in only_set:
            continue
        if cid in skip_set:
            continue
        checks.append(chk)
    return checks

register(cls)

Class decorator that instantiates and registers a :class:Check subclass.

Raises ValueError if the check has no id or its ID is already taken.

Source code in src/agentscanner/checks/base.py
def register(cls: Type[Check]) -> Type[Check]:
    """Class decorator that instantiates and registers a :class:`Check` subclass.

    Raises ``ValueError`` if the check has no ``id`` or its ID is already taken.
    """
    inst = cls()
    if not inst.id:
        raise ValueError(f"{cls.__name__} missing id")
    if inst.id in CHECK_REGISTRY:
        raise ValueError(f"duplicate check id {inst.id}")
    CHECK_REGISTRY[inst.id] = inst
    return cls

Hooks

agentscanner.checks.hooks

Hook checks (AS-HOOK-*).

Hooks run arbitrary commands at lifecycle events — the highest-impact artifact. We inspect the command strings as DATA only; agentscanner never executes them. Hooks can live in settings.json and in agent frontmatter, so checks iterate both.

iter_hooks(resource)

Yield (event_name, hook_entry) for every command-type hook in a resource.

Source code in src/agentscanner/checks/hooks.py
def iter_hooks(resource) -> Iterator[Tuple[str, dict]]:
    """Yield (event_name, hook_entry) for every command-type hook in a resource."""
    container = None
    if resource.type == ArtifactType.SETTINGS and isinstance(resource.data, dict):
        container = resource.data.get("hooks")
    elif resource.frontmatter:
        container = resource.frontmatter.get("hooks")
    if not isinstance(container, dict):
        return
    for event, groups in container.items():
        if not isinstance(groups, list):
            continue
        for group in groups:
            if not isinstance(group, dict):
                continue
            for entry in group.get("hooks", []):
                if isinstance(entry, dict):
                    yield event, entry

Permissions

agentscanner.checks.permissions

Permission & permission-mode checks (AS-PERM-*).

Grounded in verified Claude Code matching semantics (DESIGN.md §11): - Bash rule wildcards are space-boundary aware; Bash(*) / Bash(:*) match effectively everything. - Resolution is deny > ask > allow; we only inspect allow for over-grant.

MCP

agentscanner.checks.mcp

MCP server checks (AS-MCP-*).

MCP servers are arbitrary processes (stdio) or remote endpoints (http/sse). Risks: plaintext secrets, cleartext transport, auto-trust, unpinned supply chain.

Environment & Secrets

agentscanner.checks.env_secrets

Environment-posture and hardcoded-secret checks (AS-ENV-, AS-SECRET-).

Agents & Skills

agentscanner.checks.agents_skills

Agent & skill privilege checks (AS-AGENT-*).

Prompts

agentscanner.checks.prompts

Prompt-content checks for steering files (AS-PROMPT-*).

Scans the prose body of agents, skills, commands, and CLAUDE.md for prompt- injection / steering indicators and hidden unicode. Patterns are deliberately specific to keep false positives low (security docs legitimately mention "exfiltrate" etc., so we require imperative phrasing).