Extending Doberman: the entry-point plugin catalogue

September 5, 2026 ยท View on GitHub

Doberman discovers third-party code through Python entry points (a name-to-code mapping a package registers, read from src/doberman/engine/registry.py). Core never imports a plugin package by name.

This page catalogues every seam: twelve entry-point groups as of this writing (ALL_GROUPS in registry.py). The count was eleven when this page was first written; doberman.approval_methods was added afterward. Two seams have a runnable worked example, so start there. For the rest, this page plus the group's discover_*() docstring in registry.py is the contract.

Opt in by name first

Installing a plugin package is never enough on its own. Every group below is gated by the same rule (doberman.engine.plugin_config): an entry point is loaded only if its .name is listed in the per-user plugins allowlist:

doberman plugins list             # enabled names, and every installed-but-maybe-not-enabled entry point
doberman plugins enable <name>    # e.g. `doberman plugins enable example_rule`
doberman plugins disable <name>

The allowlist is snapshotted once per process, before discovery starts. Nothing loaded later in the process, such as an already-enabled plugin's own imports, or an env var or file change made after startup, can widen it.

The defensive-loading guarantee

Every group below shares one loading discipline (registry.py's module docstring):

  • Loading is defensive. A plugin that fails to import, fails to instantiate, or does not look like the required shape is logged and skipped. A broken or hostile plugin can never crash core or stop the built-ins from running.
  • Raise-only. Rule and detector plugins follow the same discipline as built-ins: results are reduced with combine(), so a plugin can only add risk, never lower a verdict. Other seams (observers, sinks, adapters, adjudicators, egress brokers) are limited the same way, each to its own advisory, clamped, or shadow role. See each group below for specifics.
  • Nothing installed means core-only. With no plugin installed, or none enabled, discovery returns an empty list or the built-in default. Behavior is identical to core with no plugins at all.

Do not read a stronger contract into this than the code provides. The guarantee above is exactly what registry.py documents, no more.

The twelve groups

doberman.rules

Shape: implements the Guardrail protocol: one method, evaluate(self, action: SecurityObject, ctx: EvalContext) -> GuardrailResult. Resolves via: discover_rules(), consumed by ObjectiveGuardrail (built-in rules + plugins, reduced with combine()). Worked example: examples/plugin-guardrail/, full walkthrough at PLUGINS.md.

doberman.detectors

Shape: the same Guardrail protocol as doberman.rules, structurally identical per discover_detectors()'s own docstring. Resolves via: discover_detectors(), consumed by SubjectiveGuardrail (built-in detectors + the three-axis scoring signal + plugins, reduced with combine()). This is the behavioral (UEBA-style) seam, distinct from the rule-based objective guardrail. Worked example: examples/plugin-detector/.

doberman.policy_sources

Shape: duck-typed policy source: an authority attribute, a callable snapshot, and a string name (_looks_like_policy_source). Resolves via: discover_policy_sources(), merged by the policy resolver (doberman.policy.sources.resolve_policy) alongside any local sources. Worked example: none yet.

doberman.auth_providers

Shape: duck-typed auth provider: a callable authenticate (_looks_like_auth_provider). Resolves via: discover_auth_providers(), consumed by active_provider() (doberman.auth.provider). The first opted-in, correctly shaped provider (allowlist order, not entry-point iteration order) is wrapped in CoGatedProvider. The built-in local provider is always also consulted, for every tier: a plugin's approval is necessary but never sufficient. If no opted-in provider is found, the local provider runs unchanged. Worked example: none yet.

doberman.audit_sinks

Shape: duck-typed audit sink: a callable emit (_looks_like_audit_sink). Resolves via: discover_audit_sinks(), consumed by emit_to_sinks() (doberman.storage.sinks), which runs every enabled plugin sink first, then the built-in webhook and OpenTelemetry sinks. A sink that isn't shaped right, or whose emit raises, is logged and skipped. Sink failures never affect the decision itself. Worked example: examples/plugin-audit-sink/. See also PLUGINS.md for the built-in webhook sink.

doberman.approval_methods

Shape: implements ApprovalMethod: a callable is_available and a callable request(prompt, *, action_id, timeout_s). Resolves via: discover_approval_methods(), which returns the built-in methods (for example, Windows Hello) followed by any registered plugins. A plugin whose name shadows a built-in is skipped, so a third party can't silently replace a core factor. Worked example: none yet.

doberman.drift_observers

Shape: duck-typed observer: a callable on_change (_looks_like_drift_observer). Resolves via: discover_drift_observers(), sends a redacted drift event (drift meaning a change to the policy over time) to every observer via notify_observers() (doberman.policy.drift), after the authoritative decision is made and the ledger is written. A raising observer never affects the gate. Worked example: none yet.

doberman.cost_observers

Shape: duck-typed observer: a callable on_cost (_looks_like_cost_observer). It may also expose on_loop_anomaly(anomaly) (duck-typed, not required) to receive the loop-anomaly detector's readout. Resolves via: discover_cost_observers(), sends a redacted CostEvent to every observer via notify_cost_observers() (doberman.storage.cost), after a successful ledger write. Advisory only, off the decision path. Worked example: none yet.

doberman.algebra_adapters

Shape: duck-typed adapter: a callable refine (_looks_like_algebra_adapter). Distinct from doberman.detectors: an adapter refines the generic action algebra, it never scores or verdicts anything. Resolves via: discover_algebra_adapters(), consumed by the subjective layer's clamp_refinement() (doberman.subjective.adapters). Every ordered dimension takes the MORE severe of the generic-vs-refined class, so a hostile or buggy adapter can only raise, never lower, the resulting algebra. Worked example: none yet.

doberman.adjudicators

Shape: implements the Adjudicator protocol: an adjudicate attribute (a structural isinstance check only; the real safety gate is that the engine validates every return value and isolates exceptions). Resolves via: discover_adjudicators(), consumed by doberman.engine.adjudicator. Shadow-only: a discovered adjudicator observes a decision on REDACTED features and can never change the live verdict. Worked example: none yet.

doberman.egress_brokers

Shape: implements the EgressBroker protocol: enforcement_status, classify, and connection_events attributes. Resolves via: discover_egress_brokers() (memoized with lru_cache, so the entry-point scan runs at most once per process), consulted by ExternalDestinationRule. Fail-closed by design: a broker verdict cannot yet raise or lower a decision. That capability lands in a later slice; for now, consultation is wired in but stays dormant. Worked example: none yet.

doberman.async_challenge_backends

Shape: duck-typed backend: a callable issue and a callable resolve (_looks_like_async_backend). Resolves via (the one exception to discover_*()): active_async_backend() (doberman.auth.async_challenge), which returns the first registered, correctly-shaped backend, else the built-in IN_MEMORY_BACKEND singleton. This lets hosted or push-based approval channels (Slack, email, etc.) supply a custom backend without importing core's synchronous prompter chain. Worked example: none yet.

Adding a worked example for an undocumented seam

Copy examples/plugin-guardrail/ or examples/plugin-detector/ as a starting point: a pyproject.toml entry-points block, a minimal implementation of the group's shape above, and a tests/ package proving real (non-monkeypatched) discovery after pip install -e + doberman plugins enable <name>. Do not add a new example to the root pyproject.toml's pytest testpaths. It stays self-contained under examples/, installed only by its own instructions.