Documentation
¶
Overview ¶
Package plugin defines the stable ABI for out-of-tree Praxis capability handlers (Phase 3 M3.1).
A plugin is a Go module that exports a `Plugin` symbol implementing this package's Plugin interface. The Praxis runtime loads it via the standard plugin discovery flow (manifest.json + signature verification, both of which are layered on top of this ABI in follow-up tasks):
- Plugin author writes a Go package exporting `var Plugin praxis.Plugin = ...`
- The author signs the build with cosign and places the artefact + manifest.json in $PRAXIS_PLUGIN_DIR.
- Praxis verifies the signature against a configured root, loads the symbol, and registers each Capability returned by Capabilities() with the in-process registry.
ABI version: v1. Breaking changes to this interface bump ABIVersion and the runtime refuses to load older plugins. Additive changes (new optional methods on the Plugin interface) are signalled by minor-version bumps and remain compatible.
Index ¶
- Constants
- Variables
- func BudgetEnvForTest(env []string, budget ResourceBudget) []string
- func ClassifyError(err error) string
- func HTTPClient(ctx context.Context) *http.Client
- func Load(ctx context.Context, p Plugin, loader Loader) error
- func LoadFulcioRoots(paths []string) ([]*x509.Certificate, error)
- func LoadTrustedKeys(paths []string) ([]*ecdsa.PublicKey, error)
- func LoadWithHooks(ctx context.Context, p Plugin, loader Loader, hooks *LoadHooks) error
- func Sandboxed(inner capability.Handler, budget ResourceBudget) capability.Handler
- func VerifyDiscovered(d Discovered, keys []*ecdsa.PublicKey) error
- func VerifyKeyless(d Discovered, v KeylessVerifier) error
- type ABIMismatchError
- type BudgetedPlugin
- type DefaultOpener
- type Discovered
- type DiscoveryError
- type DiscoveryResult
- type DiskManifest
- type Identity
- type KeylessVerifier
- type LoadEvent
- type LoadHooks
- type Loader
- type Manager
- type ManagerConfig
- type Manifest
- type Opener
- type PipelineConfig
- type PipelineError
- type PipelineResult
- type Plugin
- type ProcessOpener
- type Registration
- type ResourceBudget
- type Watchable
- type Watcher
- type WatcherConfig
Constants ¶
const ( ResultSuccess = "success" ResultManifest = "manifest_invalid" ResultArtifact = "artifact_missing" ResultUnsafeArtifact = "unsafe_artifact" ResultDuplicate = "duplicate_name" ResultManifestMiss = "manifest_missing" ResultSignature = "signature_failed" ResultNoTrustedKeys = "no_trusted_keys" ResultABIMismatch = "abi_mismatch" ResultDlopen = "dlopen_failed" ResultLoad = "load_failed" ResultCrashed = "crashed" // post-load: child process or IPC stream died )
Failure-cause labels used by callers to populate the praxis_plugin_load_total{result} metric. Stable strings — operators build alerts against them.
const ABIVersion = "v1"
ABIVersion is the major version of the plugin ABI. Bump on any breaking change to Plugin or Handler.
const CertificateExtension = ".cert"
CertificateExtension is the suffix Praxis appends to a plugin artefact path to locate the keyless certificate emitted by Fulcio. `cosign sign-blob --output-certificate plugin.so.cert` produces this file unchanged.
const ManifestFilename = "manifest.json"
ManifestFilename is the fixed filename Praxis looks for in each plugin directory under PRAXIS_PLUGIN_DIR.
const SignatureExtension = ".sig"
SignatureExtension is the suffix Praxis appends to a plugin artefact path to locate its detached signature. Keeping the convention identical to `cosign sign-blob --output-signature plugin.so.sig` so operators can sign with the upstream tool unchanged.
const SymbolName = "Plugin"
SymbolName is the exported variable plugin authors must define. The runtime looks up this symbol after dlopen and asserts its type to the Plugin interface.
Variables ¶
var ( ErrManifestMissing = errors.New("manifest.json not found") ErrManifestInvalid = errors.New("manifest.json invalid") ErrUnsafeArtifact = errors.New("artifact path escapes plugin directory") ErrArtifactMissing = errors.New("artifact file not found") ErrDuplicateName = errors.New("duplicate plugin name") )
Sentinel errors. Callers use errors.Is to branch on the failure mode.
var ( ErrCertificateMissing = errors.New("plugin certificate file not found") ErrCertificateUntrusted = errors.New("plugin certificate did not chain to a trusted Fulcio root") ErrCertificateExpired = errors.New("plugin certificate expired or not yet valid") ErrIdentityMismatch = errors.New("plugin certificate identity not in trust policy") ErrNoFulcioRoots = errors.New("no Fulcio roots configured for keyless verification") )
Sentinel errors. Operators branch on these to log precise reasons in strict-mode failures.
var ( ErrSignatureMissing = errors.New("plugin signature file not found") ErrSignatureInvalid = errors.New("plugin signature does not verify under any trusted key") ErrNoTrustedKeys = errors.New("no trusted plugin keys configured") )
Sentinel errors. Callers branch on these to decide between "this load is forbidden, refuse" and "operator misconfiguration, fail loud".
var ErrEgressBlocked = errors.New("plugin egress blocked: host not in allowlist")
ErrEgressBlocked signals that a sandboxed plugin attempted to reach a host outside its declared AllowedHosts. The wrapped handler surfaces this error directly so audit detail records the blocked destination.
var ErrPluginNotLoaded = errors.New("plugin not loaded")
ErrPluginNotLoaded signals a reload request against a plugin name the Manager has no record of — typically a typo or a plugin that failed its initial load.
Functions ¶
func BudgetEnvForTest ¶
func BudgetEnvForTest(env []string, budget ResourceBudget) []string
BudgetEnvForTest exposes budgetEnv to the package's external test suite. Production callers should not depend on this.
func ClassifyError ¶
ClassifyError maps a per-plugin error to one of the Result* constants. Falls back to ResultLoad for unrecognised errors so the metric always labels something.
func HTTPClient ¶
HTTPClient retrieves the sandbox-supplied http.Client from ctx. Plugin authors call this for every outbound HTTP request — the returned client honours the plugin's AllowedHosts policy. Returns nil if the context carries no sandbox client (handler is not sandboxed).
func Load ¶
Load is the canonical plugin-load helper. The runtime calls this once per discovered plugin. It validates the ABI version, runs Capabilities, and routes each Registration through the supplied Loader.
If the plugin implements BudgetedPlugin, every handler in every Registration is wrapped in a Sandboxed wrapper that enforces the declared ResourceBudget. Plugins that don't opt in load unwrapped — existing untrusted-plugin-rejection happens upstream of Load (manifest validation + signature verification).
Returning an error short-circuits load — a partial registration is rolled back by the caller (the runtime must not allow half-loaded plugins).
func LoadFulcioRoots ¶ added in v0.2.0
func LoadFulcioRoots(paths []string) ([]*x509.Certificate, error)
LoadFulcioRoots reads PEM-encoded certificates (one or many per file) from each path. Mixed PEM bundles are supported so operators can drop the standard Sigstore `root.pem` in unchanged.
func LoadTrustedKeys ¶
LoadTrustedKeys reads each PEM-encoded ECDSA P-256 public key from the given paths and returns the parsed bundle. Any unreadable file or non-ECDSA key fails the load — partial trust silently dropping a key would let an operator believe a stricter policy was active than really is.
func LoadWithHooks ¶
LoadWithHooks is Load with an optional WrapHandler hook. Used by the Manager to layer versioned-handler tracking on top of the existing sandbox wrapping without forking the load pipeline.
func Sandboxed ¶
func Sandboxed(inner capability.Handler, budget ResourceBudget) capability.Handler
Sandboxed wraps a handler so each Execute and Simulate call runs under the supplied budget. The wrapper is always-on: if the caller invokes Sandboxed, the handler is sandboxed. Use this only when the budget was explicitly declared (e.g. plugin author opted in via BudgetedPlugin).
func VerifyDiscovered ¶
func VerifyDiscovered(d Discovered, keys []*ecdsa.PublicKey) error
VerifyDiscovered verifies the cosign-blob signature for a Discovered plugin against the configured trust bundle. The signature is expected at <artifact>.sig as base64-encoded ASN.1 DER over SHA-256(artifact), matching `cosign sign-blob` output exactly.
Returns:
- nil on a successful verification.
- ErrNoTrustedKeys when keys is empty (fail-closed: never load unsigned plugins by accident if trust isn't configured).
- ErrSignatureMissing if the .sig file is absent.
- ErrSignatureInvalid if no trusted key validates the signature (covers wrong key, tampered artefact, malformed sig).
func VerifyKeyless ¶ added in v0.2.0
func VerifyKeyless(d Discovered, v KeylessVerifier) error
VerifyKeyless validates that the discovered plugin was signed under a Fulcio-issued certificate that:
- chains to a trusted root,
- was valid at signing time,
- encodes a (SAN, issuer) pair on the operator's allowlist, and
- produced an ECDSA signature that verifies over SHA-256(artifact).
The artefact's signature still lives at <artifact>.sig (cosign sign-blob format), and the certificate at <artifact>.cert. Returns nil only when every check passes.
Types ¶
type ABIMismatchError ¶
type ABIMismatchError struct {
Want, Got string
}
ABIMismatchError signals an ABI-version mismatch between runtime and plugin.
type BudgetedPlugin ¶
type BudgetedPlugin interface {
Plugin
Budget() ResourceBudget
}
BudgetedPlugin is the optional interface a plugin implements to declare its resource budget. The loader detects this via type assertion so the stable Plugin interface (ABI v1) does not break for plugins that don't opt in.
type DefaultOpener ¶
type DefaultOpener struct{}
DefaultOpener loads a plugin artefact via Go's stdlib plugin package. Available on linux + darwin only; other platforms get the noop opener that always errors out (see opener_unsupported.go).
type Discovered ¶
type Discovered struct {
Dir string // absolute path to the plugin directory
Manifest Manifest // identity + provenance subset of the disk manifest
ABI string // declared ABI version (validated against ABIVersion at load time)
Artifact string // absolute path to the plugin artefact file
}
Discovered is one validated plugin found on disk. The runtime consumes this list at startup, verifies signatures (follow-up task), then loads each artefact and calls plugin.Load.
type DiscoveryError ¶
DiscoveryError carries a per-plugin failure surfaced by Discover. The scan continues past these so a single bad plugin cannot hide healthy siblings.
func (*DiscoveryError) Unwrap ¶
func (e *DiscoveryError) Unwrap() error
Unwrap exposes the underlying cause to errors.Is/As.
type DiscoveryResult ¶
type DiscoveryResult struct {
Plugins []Discovered
Errors []DiscoveryError
}
DiscoveryResult separates clean plugins from per-plugin errors so callers can decide whether to fail-closed (any error aborts startup) or fail-open (log errors, register healthy plugins).
func Discover ¶
func Discover(root string) (DiscoveryResult, error)
Discover scans root for plugin directories. Each immediate subdirectory of root is treated as a candidate plugin: it must contain manifest.json declaring name, version, abi, and artifact. Files in root and other non-directory entries are ignored silently. Subdirectories without a manifest.json surface as ErrManifestMissing.
Per-plugin failures populate Errors and the scan continues. The first successfully discovered plugin with a given name wins; later duplicates surface as ErrDuplicateName. Plugins are returned sorted by name for deterministic startup behaviour.
type DiskManifest ¶
DiskManifest is the on-disk manifest schema. It extends the in-process Manifest with deployment metadata: the ABI version the plugin was built against and the artefact filename relative to the plugin directory.
type Identity ¶ added in v0.2.0
Identity is one allowed (subject, issuer) pair. SubjectGlob accepts a literal SAN value or a single-asterisk suffix wildcard ("https://github.com/felixgeelhaar/*"). Issuer is matched literally against the OIDC issuer extension.
type KeylessVerifier ¶ added in v0.2.0
type KeylessVerifier struct {
FulcioRoots []*x509.Certificate
Intermediates []*x509.Certificate
TrustedIdentities []Identity
Now func() time.Time
}
KeylessVerifier holds the trust policy for Fulcio-issued plugin signatures. Zero value is unusable: callers must populate at least FulcioRoots and TrustedIdentities before VerifyKeyless will succeed.
FulcioRoots is the operator's pinned root bundle (typically the production Sigstore root + any private Fulcio instance). Intermediates is optional — `cosign sign-blob` already attaches the chain inside the leaf cert when present.
TrustedIdentities is the allowlist that gates which build identities may produce loadable plugins. Any cert whose (SAN, issuer) pair fails to match every entry is rejected with ErrIdentityMismatch.
Now is injected so tests can pin a deterministic clock; production callers leave it nil for time.Now.
type LoadEvent ¶
type LoadEvent struct {
Name string
Version string
ABI string
Dir string
ArtifactSHA string
Result string
Err error
}
LoadEvent reports a single plugin's load outcome with enough metadata for /metrics labelling and `praxis plugins list` rendering. Phase 4.
type LoadHooks ¶
type LoadHooks struct {
// WrapHandler runs against every Registration's handler after the
// optional Sandboxed wrap. The plugin's Manifest is supplied so the
// caller can record per-plugin state. Returning the same handler
// unchanged is the no-op default.
WrapHandler func(manifest Manifest, h capability.Handler) capability.Handler
// OnLoaded fires once after every Registration has been forwarded
// to the Loader, with the plugin instance and the (possibly
// wrapped) registrations the runtime now sees. Used by the Manager
// to wire Watchable crash detection and record per-plugin
// capability names. Phase 4 out-of-process loader.
OnLoaded func(p Plugin, regs []Registration)
}
LoadHooks lets callers (the Manager) wrap handlers post-sandbox and post-budgeting. A nil LoadHooks reduces to the original Load behaviour. Phase 4 graceful rollover.
type Loader ¶
type Loader interface {
Register(reg Registration) error
}
Loader is the runtime interface that registers a plugin's capabilities. Implemented by cmd/praxis at startup; tested in this package against a fake plugin to lock down the contract.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager owns the runtime state of loaded plugins. It serialises load and reload calls so a watcher event and a SIGHUP-driven full re-scan don't race against each other.
func NewManager ¶
func NewManager(cfg ManagerConfig) *Manager
NewManager constructs a Manager. Mandatory fields (Dir, Loader, Opener) are validated lazily — LoadAll surfaces missing wiring as a clear error rather than panicking at construction.
func (*Manager) Drain ¶
Drain blocks until every retired wrapper for the given plugin has completed its in-flight calls. New traffic, which already routes to the post-reload version, is unaffected. Returns immediately if the plugin has no retired wrappers (fresh load with no prior version).
Drained wrappers are removed from the retired pool so a follow-up Drain call against the same name returns instantly rather than re-walking already-completed calls.
func (*Manager) LoadAll ¶
func (m *Manager) LoadAll(ctx context.Context) (PipelineResult, error)
LoadAll runs the full discover→verify→open→Load pipeline once and records the outcome of every plugin. Returns the underlying pipeline error only if discovery fails outright; per-plugin failures populate the Manager's loaded state and are surfaced via OnEvent.
Existing wrappers are retired before the new pipeline runs so the graceful drain semantics apply across full reloads — in-flight calls finish on the version they started with, and Drain(name) on the retired wrapper unblocks once they do.
func (*Manager) ReloadOne ¶
ReloadOne re-runs the pipeline scoped to a single plugin directory. The lookup is by plugin Name (the one returned by Manifest()), not by directory path — operators reload by capability identity, not by filesystem layout.
The plugin's previous wrappers are retired before the new ones are registered so in-flight calls drain naturally; Drain(name) is the supported way to wait for that completion.
type ManagerConfig ¶
type ManagerConfig struct {
Dir string
TrustedKeys []*ecdsa.PublicKey
Keyless *KeylessVerifier
Loader Loader
Opener Opener
Unregister func(capName string)
OnEvent func(LoadEvent)
}
ManagerConfig parameters the plugin Manager. Loader, Opener, and Dir are required; TrustedKeys may be empty (the load pipeline will then reject every plugin via ErrNoTrustedKeys, which is the safe default).
Unregister, when set, is invoked for every capability name the Manager needs to remove from the runtime registry on a plugin crash. The host wires this to capability.Registry.Unregister so the in-process registry stays consistent with the Manager's snapshot of loaded plugins.
type Manifest ¶
type Manifest struct {
Name string `json:"name"`
Version string `json:"version"`
Author string `json:"author,omitempty"`
Description string `json:"description,omitempty"`
Homepage string `json:"homepage,omitempty"`
License string `json:"license,omitempty"`
}
Manifest describes the plugin's identity and build provenance. Required fields: Name, Version. Optional fields surface in audit detail.
type Opener ¶
Opener loads a plugin artefact from disk and returns its exported Plugin symbol. The default implementation (DefaultOpener) wraps Go's stdlib `plugin` package and is build-tagged to Linux+macOS only — platforms where Go plugin loading is supported. Tests inject a fake Opener to exercise the pipeline without producing real .so artefacts.
type PipelineConfig ¶
type PipelineConfig struct {
Dir string
TrustedKeys []*ecdsa.PublicKey
// Keyless, when populated with at least one Fulcio root, switches
// the pipeline to keyless verification for any plugin that ships a
// `<artifact>.cert` file. Plugins that do not ship a certificate
// fall back to TrustedKeys (PEM-key path) so the two modes coexist
// during a migration.
Keyless *KeylessVerifier
Loader Loader
Opener Opener
// LoadHooks is forwarded to LoadWithHooks for every plugin loaded
// through the pipeline. Optional; nil reduces to the unhookable
// Load path.
LoadHooks *LoadHooks
}
PipelineConfig parameters the runtime plugin-load pipeline. Dir, Loader and Opener are required; TrustedKeys is required as soon as any plugin is discovered (the pipeline fail-closes when it finds a plugin with no trust bundle to verify against, even if Strict is false).
type PipelineError ¶
PipelineError pairs a discovered plugin's directory with the reason it failed to load. Wraps the underlying error so errors.Is/As works against the sentinel set (ErrSignatureMissing, ErrSignatureInvalid, ErrNoTrustedKeys, ABIMismatchError, dlopen and Capabilities errors).
func (*PipelineError) Unwrap ¶
func (e *PipelineError) Unwrap() error
Unwrap exposes the underlying cause.
type PipelineResult ¶
type PipelineResult struct {
Loaded []Discovered
Errors []PipelineError
}
PipelineResult describes the outcome of one Pipeline run. Loaded is the set of plugins that successfully reached the registry; Errors captures every per-plugin failure so the caller can decide whether to log, fail-soft, or abort.
func RunPipeline ¶
func RunPipeline(ctx context.Context, cfg PipelineConfig) (PipelineResult, error)
RunPipeline executes the discover → verify → open → Load chain over every subdirectory of cfg.Dir. Per-plugin failures populate the returned PipelineResult.Errors and never stop the sweep — one bad plugin must not hide its healthy siblings. The function returns a hard error only when the operator-level setup is wrong: the plugin directory is missing, or filesystem walk fails.
Empty cfg.Dir is a no-op (plugin discovery disabled).
type Plugin ¶
type Plugin interface {
// ABI reports the ABI version this plugin was built against. Plugins
// MUST return ABIVersion (the constant in this package as compiled at
// build time). The runtime refuses to load plugins whose ABI does not
// match the runtime's ABIVersion.
ABI() string
// Manifest returns identity + provenance metadata. Authors set Name
// and Version; the runtime validates Name uniqueness against already-
// registered capabilities and surfaces Version in audit detail.
Manifest() Manifest
// Capabilities returns one or more (descriptor, handler) pairs for
// registration. A plugin may expose multiple capabilities — convenient
// when one vendor has several related actions (e.g. github_create_issue
// + github_add_comment).
Capabilities(ctx context.Context) ([]Registration, error)
}
Plugin is the symbol every plugin must export. The runtime calls Capabilities() at load time and registers each returned descriptor + handler with the registry.
type ProcessOpener ¶
type ProcessOpener struct {
Binary string
Budget ResourceBudget
// CgroupParent, when non-empty, names a cgroup v2 subtree under
// which each spawned plugin host runs. Phase 5: when the host
// detects cgroup v2 availability the bootstrap sets this to
// /sys/fs/cgroup/praxis so memory.max + cpu.max enforce the
// declared budget. Empty falls back to the setrlimit-only path.
CgroupParent string
// OnUsageReport, when set, receives the cgroup-recorded high-water
// memory peak and cumulative CPU time for the plugin just before
// the cgroup is reclaimed on Close. Bootstrap wires this to the
// praxis_plugin_memory_peak_bytes / praxis_plugin_cpu_seconds_total
// metrics. Phase 5 t-cgroup-v2-usage-metrics.
OnUsageReport func(pluginName string, peakBytes uint64, cpuNs uint64)
// SpawnFn is the test seam: production uses exec.Command, tests
// supply a custom transport pair without touching the OS.
SpawnFn func(ctx context.Context, artefactPath string) (io.WriteCloser, io.ReadCloser, func() error, error)
// contains filtered or unexported fields
}
ProcessOpener is an Opener implementation that spawns a praxis-pluginhost child process per plugin and proxies the Plugin interface over IPC. Phase 4 out-of-process loader.
The Binary field is the absolute path to the praxis-pluginhost binary; tests inject their own command for round-tripping the protocol against an in-process echo server. Budget, when non-zero, is forwarded to the child via PRAXIS_PLUGIN_BUDGET_* env vars and the child applies setrlimit at startup.
func (*ProcessOpener) Open ¶
func (o *ProcessOpener) Open(artefactPath string) (Plugin, error)
Open spawns a child host for artefactPath and returns a Plugin that forwards every call across the IPC boundary. Manifest is fetched eagerly so the parent can fail fast on a child that doesn't speak the protocol.
type Registration ¶
type Registration struct {
Capability domain.Capability
Handler capability.Handler
}
Registration pairs a Capability descriptor with the handler that runs it. Returned by Plugin.Capabilities and consumed by the runtime registrar.
type ResourceBudget ¶
ResourceBudget caps what a plugin handler may consume during a single Execute or Simulate call. Plugins opt in by implementing BudgetedPlugin; unknown plugins run unsandboxed.
Phase 3 M3.1 enforces:
- CPUTimeout: real, via context.WithTimeout. Zero disables the cap.
- AllowedHosts: real, via a runtime-supplied http.Client whose transport rejects unlisted destinations. Plugins that bypass the supplied client (e.g. instantiate their own net.Dialer) defeat this layer; the contract is "use plugin.HTTPClient(ctx) for any outbound call."
- MaxMemoryBytes: NOT enforced in-process. Go's runtime does not expose per-handler accounting and the global allocator is shared with the rest of Praxis. The field is reserved so plugin authors can declare their intended ceiling today; the out-of-process loader (future task) will hold it to that limit.
type Watchable ¶
type Watchable interface {
Watch() <-chan error
}
Watchable is implemented by Plugin variants whose underlying transport can fail asynchronously — the out-of-process loader is the canonical example. The Manager type-asserts every loaded plugin against Watchable; matching plugins get a goroutine that listens for crash events and triggers capability deregistration. Phase 4 out-of-process loader (M3.1).
type Watcher ¶
type Watcher struct {
// contains filtered or unexported fields
}
Watcher monitors PRAXIS_PLUGIN_DIR and triggers OnReload when a plugin's manifest.json or .sig changes. Sub-second event bursts are coalesced via Debounce so a single edit doesn't cause five reloads.
The watcher is opt-in: callers control startup (no goroutine starts implicitly) and pass the cancel context that ends the loop.
func NewWatcher ¶
func NewWatcher(cfg WatcherConfig) (*Watcher, error)
NewWatcher constructs a Watcher rooted at cfg.Root. Returns an error if fsnotify cannot watch the directory (root missing, permissions, platform unsupported). Debounce defaults to 200ms.
type WatcherConfig ¶
WatcherConfig parameters the plugin-directory watcher. Root is the directory to watch (PRAXIS_PLUGIN_DIR); OnReload is invoked with the affected plugin directory after a debounce window. Debounce coalesces bursts of FS events (a manifest rewrite often produces several writes per second) into a single reload.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package cgroup probes the host for cgroup v2 support and the presence of a delegated subtree the praxis runtime can use to enforce per-plugin memory and CPU caps.
|
Package cgroup probes the host for cgroup v2 support and the presence of a delegated subtree the praxis runtime can use to enforce per-plugin memory and CPU caps. |
|
Package ipc carries the wire protocol between Praxis and a praxis-pluginhost child process.
|
Package ipc carries the wire protocol between Praxis and a praxis-pluginhost child process. |