Plugin lifecycle
Jazz’s plugin runtime is a ports-and-adapters feature. Core owns the ABI-facing domain contracts, non-failing, validated hook runner, consent digest, and service tags. Adapters own manifest parsing, downloads, filesystem state, dynamic import, keyring access, and module-session construction. The CLI owns local operator confirmation, and runtime wires the layers.
State machine
catalog/local manifest
-> installed (verified bytes; never imported)
-> trusted (exact code digest)
-> consented (code + hooks + destinations + data classes)
-> enabled for one agent
-> lazily imported into that agent run's PluginSession
Every transition takes $JAZZ_HOME/plugins/.state.lock. The artifact is written and verified before
one atomic state-document replacement makes it current. An incompatible state schema fails closed;
repair is explicit and must not silently erase grants or rollback artifacts.
Saved current and rollback manifests pass through the install-time manifest validator on every state read. Omitted optional capabilities become empty collections before consent calculation; malformed manifests or IDs that disagree with the state entry fail closed with a plugin-state error. Reading does not rewrite the file or change trust, consent, or enablement. Existing consent grants must still match the current disclosure digest; normalization never grants consent.
The module is imported only after the current lock has valid trust, consent, and agent enablement.
Installation and runtime validation share the lifecycle-event vocabulary in
core/types/plugin.ts, including approval events such as permission-request. A plugin declaring
those events can load alongside its completion and input handlers; unknown or duplicate events
are rejected at both boundaries.
Registration is synchronous and sealed on return. Hook/provider registrations are session-local so concurrent agents cannot overwrite one another. Session disposal is best effort only: in-process ESM cannot be unloaded.
Code integrity
A trust grant names a digest, and the digest covers a directory: the installed source tree
(plugins/sources/<digest>/) or the digest-addressed directory holding a packed plugin.mjs
(plugins/artifacts/<digest>/). adapters/src/plugins/source-integrity.ts keeps the imported code
inside that directory. The same checks run at install, in inspect/doctor (artifact valid), and
in PluginModuleLoader before every import, so a copy tampered with after trust is refused:
- The source
entrygoes throughparsePluginSourceEntry(no absolute path,..,., or empty segment), the recorded import path must equal the entry joined under the digest root, and the entry’s real path must be a regular file inside that root. - Extraction, copying (
copySourceTree,O_NOFOLLOWper file), and hashing (listSourceTree) refuse symlinks and special files instead of skipping them, so the hashed tree is the imported tree. scanPluginImportGraphwalks static imports, re-exports,requirecalls, and literal dynamic imports withBun.Transpiler.scanImports, resolves each withBun.resolveSyncas the runtime would, and refuses anything that is not a runtime built-in or a real file inside the directory. Package specifiers are refused becausenode_modulesis outside the digest; the SDK is types-only and must be imported withimport type. Imports are scanned without unused-import trimming, and JavaScript is scanned as JSX, so the scan sees a superset of what the runtime loads.
Install-time bundling was rejected for source installs: Bun.build evaluates Bun macros
(with { type: "macro" }, including escaped spellings) while bundling and has no switch to turn
them off, so bundling an untrusted tree would run its code before trust. scanImports parses
without evaluating. Computed dynamic imports (import(variable)) are not statically visible; they
are part of the reviewed source, like any other runtime behavior of trusted code.
GitHub tarballs go through fetchWithinOrigins (bounded-fetch.ts): HTTPS only, every redirect
hop on api.github.com or codeload.github.com, one deadline for the whole download, and byte caps
on both the compressed body and the inflated tar stream (DEFAULT_TARBALL_LIMITS). Packed manifests
and artifacts use the same helper pinned to the manifest’s origin.
Advisory dispatch
route.skills runs for a fresh, top-level request after skill discovery and before the first model
call. The host validates a probability distribution over the live roster plus explicit no-skill
mass, then applies host policy. Plugin-produced prose is never injected.
The resulting host-rendered hint rides on the agent loop’s iteration-zero provider-only message copy. It never enters canonical messages, persistence, resume state, work state, logs, or telemetry. Disabled or absent plugins leave provider messages byte-equivalent to the previous behavior.
Promise rejection, schema failure, cooperative timeout, missing secrets, and unavailable providers
all become abstention. AbortSignal and Effect interruption cannot stop synchronous loops,
process.exit(), or code that ignores cancellation; the local trust disclosure must remain explicit
about that limit.
Page hook dispatch
classify.page and route.snapshot are advisory hooks dispatched from browser_snapshot. The
runner binds runClassifyPage and runRouteSnapshot of the run’s plugin session onto the tool
context as classifyPage and routeSnapshot, the same way it binds command-risk resolution, so
a tool never holds the session itself. Inputs are built from the snapshot’s role and label pairs,
the page origin, and two structural booleans read from the document; no page body is sent.
validateClassifyPageOutcome rebuilds the answer from flag and probability alone and rejects
an unknown or repeated flag, so a plugin cannot express a risk level, an approval decision, or a
taint instruction. The page’s structural flags are merged with the plugin’s flags above the
threshold, and only tighten: browser_act’s approval returns alwaysAsk with a warning Jazz words
from the flag names. validateRouteSnapshotOutcome requires a full distribution over the offered
refs, and the snapshot renderer only adds a lead section before the unchanged outline.
Policy dispatch
classify.command-risk runs inside the approval path after execute_command has produced its
side-effect-free proposal and before Jazz decides whether the active tier covers that call. It is
never dispatched for a tool with a static read-only, low-risk, or high-risk declaration. The
hook receives the bounded command from the proposal’s execution arguments, not arbitrary tool
state, conversation history, results, environment variables, or file contents.
The classification is evidence consumed by host policy, not an execution capability. Core validates
the answer against the three ToolRiskLevel outcomes before the policy tier sees it. Tool-set
membership, static risk declarations, explicit command allowlists, the selected approval tier, and
the shell denylist remain host-owned controls. A classified command still follows the ordinary
proposal/approval/execute pair and the denylist still runs before execution.
This hook has more authority than an advisory hook: read-only or low-risk can make an eligible
command auto-approved under the active policy. Its manifest declaration and command-text data class
therefore participate in the consent digest. A changed hook, destination, or data declaration
invalidates consent just like a changed artifact digest.
Absent handlers and abstention, validation, timeout, provider, secret, or budget failures fall back
to Jazz’s built-in command classifier. An unresolved fallback is high-risk. Batch preflight stores
the validated classification by tool-call id so the per-call path does not make a second provider
request after parking or concurrent approval checks.
Distribution checks
The author packer uses Bun with package bundling enabled, splitting disabled, and unresolved imports forbidden. It accepts exactly one JavaScript output and rejects native modules, WASM, emitted assets, and remaining runtime imports. Tests must load the identical digest-addressed artifact through both the development runtime and every standalone release target.
Routing component metrics live under evals/skill-routing/. The raw-request lexical scorer is a
proxy, not the current Jazz product baseline. Activation requires paired end-to-end A/B evidence for
actual skill loads and task success, plus latency, failure, false-positive, and cost gates on a held
test split.