Skip to content

Plugin Authoring

Pengfei Hu edited this page May 31, 2026 · 2 revisions

Plugin Authoring

Write a custom check, distribute it as a Python package, and have it run alongside the built-in catalog.

Plugins break the zero-execution trust guarantee when enabled. Treat plugin code as arbitrary Python and review accordingly. Plugins are off by default.


The contract

A plugin is a Python callable with this signature:

from agents_shipgate.core.context import ScanContext
from agents_shipgate.core.models import Finding

def run(context: ScanContext) -> list[Finding]:
    ...

Optionally attach metadata so the check shows up in agents-shipgate list-checks and explain:

from agents_shipgate.core.models import CheckMetadata

run.AGENTS_SHIPGATE_METADATA = CheckMetadata(
    id="ACME-COMPLIANCE-PII-MASKING",
    category="compliance",
    default_severity="high",
    description="Tools that handle PII must declare a masking policy.",
    rationale="Regulatory requirement; release reviewers need this evidence.",
    fires_when="Tool has sensitive_data_access tag without acme.compliance.pii_masking annotation.",
    evidence_fields=["risk_tags", "annotation_present"],
    recommendation="Add the acme.compliance.pii_masking annotation or document an exemption.",
)

CheckMetadata accepts a dict in place of the model — handy if you don't want to import shipgate at the metadata level.


Minimal plugin

# acme_shipgate_checks/compliance.py
from agents_shipgate.checks.base import tool_finding
from agents_shipgate.core.context import ScanContext
from agents_shipgate.core.models import CheckMetadata, Finding
from agents_shipgate.core.risk_hints import has_risk_tag


def run(context: ScanContext) -> list[Finding]:
    findings: list[Finding] = []
    for tool in context.tools:
        if not has_risk_tag(tool, {"sensitive_data_access"}, min_confidence="medium"):
            continue
        if tool.annotations.get("acme.compliance.pii_masking") is True:
            continue
        findings.append(
            tool_finding(
                tool=tool,
                check_id="ACME-COMPLIANCE-PII-MASKING",
                title=f"{tool.name} handles PII without a masking policy annotation",
                severity="high",
                category="compliance",
                evidence={
                    "risk_tags": [hint.tag for hint in tool.risk_hints],
                    "annotation_present": False,
                },
                confidence="high",
                recommendation=(
                    f"Set acme.compliance.pii_masking: true on {tool.name} "
                    "or add a documented exemption."
                ),
                context=context,
            )
        )
    return findings


run.AGENTS_SHIPGATE_METADATA = CheckMetadata(
    id="ACME-COMPLIANCE-PII-MASKING",
    category="compliance",
    default_severity="high",
    description="Tools that handle PII must declare a masking policy.",
    rationale="Regulatory requirement; release reviewers need this evidence.",
    fires_when="Tool has sensitive_data_access tag without acme.compliance.pii_masking annotation.",
    evidence_fields=["risk_tags", "annotation_present"],
    recommendation="Add the annotation or document an exemption.",
)

tool_finding(...) and agent_finding(...) are exported helpers that handle severity/confidence parsing and source-reference assembly. Importing them is recommended over building Finding objects by hand — they keep your evidence format consistent with the built-ins.


Packaging

pyproject.toml:

[project]
name = "acme-shipgate-checks"
version = "1.2.3"
requires-python = ">=3.12"
dependencies = ["agents-shipgate>=0.10,<0.11"]

[project.entry-points."agents_shipgate.checks"]
compliance = "acme_shipgate_checks.compliance:run"

The entry point group is agents_shipgate.checks. The entry-point name (compliance here) is for human inspection — the actual check ID is whatever your AGENTS_SHIPGATE_METADATA.id (or check_id; the v0.17 alias) declares.

Custom adapters (new tool_sources[].type source families) are a separate extension point: register a class satisfying the ToolSourceAdapter Protocol under the agents_shipgate.adapters entry-point group. Same opt-in env var and --no-plugins kill switch as check plugins. See docs/framework-adapter-checklist.md.

Install in the same environment as agents-shipgate:

pip install agents-shipgate==0.10.0 acme-shipgate-checks==1.2.3

Enabling and running

Plugins are off by default. Opt in per-environment:

export AGENTS_SHIPGATE_ENABLE_PLUGINS=1
agents-shipgate scan -c shipgate.yaml

Or for a single invocation:

AGENTS_SHIPGATE_ENABLE_PLUGINS=1 agents-shipgate scan -c shipgate.yaml

To force plugins off for a specific scan even when the env var is set:

agents-shipgate scan --no-plugins
agents-shipgate list-checks --no-plugins
agents-shipgate explain --no-plugins SHIP-POLICY-APPROVAL-MISSING

The action exposes the same:

- uses: ThreeMoonsLab/agents-shipgate@v0.10.0
  with:
    no_plugins: 'true'

Plugin provenance in reports

When a plugin runs, the JSON report's loaded_plugins array enumerates every contributing entry point:

"loaded_plugins": [
  {
    "name": "compliance",
    "value": "acme_shipgate_checks.compliance:run",
    "distribution": "acme-shipgate-checks",
    "version": "1.2.3",
    "check_id": "ACME-COMPLIANCE-PII-MASKING"
  }
]

The Markdown report includes a "Loaded Plugins" section with the same data. Release reviewers can see exactly which third-party packages contributed findings.


Plugin validation (load-time gates)

Every entry point is checked against five load-time gates before it is allowed to run. The result is recorded in loaded_plugins[].validation_status (one of valid | load_failed | bad_signature | bad_metadata | dynamic_default_not_supported | id_collision | bad_floor) — invalid plugins still appear in the array (with check_id: null when they failed before metadata parsing) so reviewers can see what was skipped without reading scanner logs:

  1. load — entry_point.load() must not raise.
  2. signature — the loaded object must be callable and accept exactly one required positional parameter (ScanContext); extra defaulted/keyword-only params are fine.
  3. metadata — AGENTS_SHIPGATE_METADATA must be present and parse as CheckMetadata. Both id and check_id are accepted as the identifier key (v0.17 alias); prefer check_id for symmetry with Finding.check_id.
  4. id_collision — your check ID must not shadow a built-in (including legacy aliases) or a previously-registered plugin.
  5. bad_floor — floor_severity must not exceed default_severity on the same metadata block.

At runtime, Shipgate additionally drops any finding whose Finding.check_id does not match your declared id/check_id (the check_id-smuggling guard), drops non-Finding items, and captures any exception raised during your plugin call into loaded_plugins[].runtime_errors. The scan continues regardless.

--strict-plugins elevates any non-valid plugin row or non-empty runtime_errors (and, v0.20+, the same for third-party adapters) to exit code 4. Default behavior is lenient — failures are recorded and the scan proceeds.

Plugins cannot declare dynamic_default=True. Dynamic-severity checks (whose emitted severity depends on manifest values) must wire into the core dynamic_defaults aggregator, which plugins have no path into — so the validation pipeline rejects them with dynamic_default_not_supported. Declare a fixed default_severity (plus an optional floor_severity) instead.


Authoring guidelines

Use existing risk-hint helpers

Don't reinvent risk classification. Import from agents_shipgate.core.risk_hints:

from agents_shipgate.core.risk_hints import (
    has_risk_tag,           # check tag presence at minimum confidence
    risk_tags,              # sorted list of tags at min confidence
    is_effectively_read_only,
    is_high_risk_tool,
    is_write_tool,
)

These guard you against substring false positives that the built-in classifier already addresses (it tokenizes names/descriptions/scopes rather than substring-matching).

Filter early

Always start with the cheapest check that excludes most tools:

for tool in context.tools:
    if is_effectively_read_only(tool):
        continue
    if not has_risk_tag(tool, {"sensitive_data_access"}, min_confidence="medium"):
        continue
    # … expensive checks here

Make evidence structured and stable

Evidence keys go into the fingerprint. Pick names that won't change between runs:

evidence={
    "risk_tags": risk_tags(tool, min_confidence="medium"),  # sorted
    "annotation_present": False,                            # explicit bool
    "policy_match": None,                                   # explicit None vs missing
}

Avoid putting timestamps, absolute paths, or run-specific data into evidence — fingerprints will rotate and baselines will break.

Don't fail closed without reason

A plugin's job is to add findings, not to crash the scan. If something unexpected happens, return an empty list and log via logging.getLogger(__name__).warning(...) — Shipgate's structured logger will pick it up under AGENTS_SHIPGATE_LOG_FORMAT=json.

Test against the bundled fixture

The repo ships sample agent fixtures under samples/. Add your plugin to a test environment and run:

agents-shipgate scan -c samples/support_refund_agent/shipgate.yaml

Pin expected findings in your own test suite so manifest schema changes upstream don't silently break your check.


Versioning compatibility

Shipgate version What it guarantees for plugins
>=0.10,<0.11 The contracts above are stable. ScanContext, Finding, CheckMetadata, and the tool_finding/agent_finding helpers will not change in a breaking way within the pinned minor.
Next minor Likely additive: new context fields, new finding fields. Breaking changes are flagged in release notes.

Pin shipgate carefully in your plugin's pyproject.toml. The runtime ABI is the public Pydantic models under agents_shipgate.schemas.* (and the agents_shipgate.core.context/agents_shipgate.checks.base import paths used above); field changes surface as ValidationError at import time. Do not import from other internal agents_shipgate.core.* modules — those are not a stable consumer API.


What plugins cannot do (intentionally)

  • Modify the manifest in place
  • Replace built-in checks
  • Suppress other plugins' findings programmatically (use checks.ignore instead)
  • Reach the file system outside whatever the user already declared in tool_sources

If you find yourself needing one of these, that's usually a sign the requirement belongs in shipgate core — please open an issue.

Clone this wiki locally