Skip to main content

Module plugin_source

Module plugin_source 

Source
Expand description

Plugin source staging (Wave 2 § Installer-core agent, PLUGIN_PLAN.md Deliverable B): turns whatever the caller (CLI/HTTP) pointed the installer at — a local directory, a local .zip/.tar.gz/.tgz archive, or a URL — into a validated bundle at plugins_dir()/<id>/, ready to hand to bamboo_plugin::PluginInstaller::install.

§The three sources

  • PluginSourceInput::LocalDir — copies the directory tree.

  • PluginSourceInput::LocalArchive — unpacks a .zip/.tar.gz/.tgz. If the archive wraps everything in a single top-level directory (the common tar czf bundle.tar.gz plugin-name/ convention), that directory is flattened up so plugin.json ends up at the bundle root either way.

  • PluginSourceInput::Url — fetches the manifest bundle (a bare plugin.json, content-only and typically an MCP server backed entirely by a downloadable binary — e.g. a nova-style plugin with no bundled skills/prompts — or an archive containing one, same flattening rule as LocalArchive). Three trust layers, stacked, enforced in [fetch_manifest_bundle] in this order:

    1. Host allowlist (source authorization) — is the URL’s <host><path> one the operator has trusted (bamboo_config::PluginTrustConfig::trusted_hosts)? Refused BEFORE any network access (PluginError::UntrustedHost) unless allow_untrusted_host is set.
    2. Signature (publisher authenticity) — after the bundle is downloaded, does its <url>.sig sidecar (a raw 64-byte ed25519 signature, hex-encoded, over the exact bundle bytes) verify against any bamboo_config::TrustedKey in trusted_keys? Refused (PluginError::UnsignedOrUntrustedSignature) unless allow_unsigned is set.
    3. Checksum (integrity) — same sha256 pin as before, EXCEPT a verified signature from layer 2 already proves integrity+authenticity more strongly than a pasted hash could, so it SATISFIES this layer’s requirement even with neither sha256 nor allow_unverified given (an allow_unsigned bypass grants no such credit — an unsigned install still needs its own sha256/allow_unverified exactly as before). See [fetch_manifest_bundle] for the precise precedence.

    A pasted checksum ALONE never establishes source trust — it is circular if the attacker controls the page the checksum was copied from — which is why layers 1 and 2 exist independently of layer 3.

    Byte-authenticity note: the host allowlist only vets the FIRST hop’s <host><path>, not wherever an HTTP redirect might lead — a signature or checksum is what actually authenticates the downloaded bytes, so redirects are followed whenever either will be checked, but disabled entirely for the fully-opted-out allow_unsigned && sha256.is_none() case, where the host allowlist is the sole control (see [http_client_no_redirects]).

    THEN, separately, for Platform::current (if the manifest declares an artifact for it), fetches the per-platform binary archive named in manifest.artifacts, verifies its sha256 BEFORE unpacking (mandatory — a URL plugin ships a binary that gets executed), and places the single expected executable at bin/<platform>/<id>[.exe] per bamboo_plugin::manifest::PluginArtifact’s archive contract. This artifact-sha256 check is unaffected by the host/signature layers above (the artifact URL is declared inside a manifest that has ALREADY passed all three trust layers) — it remains defense in depth for the binary specifically, closing the gap where the artifact’s own declared hash lives inside the bundle that carries it.

All three paths run the SAME safety checks: PluginManifest::validate before anything is committed to plugins_dir(), and path-traversal-safe archive extraction (a zip entry’s zip::read::ZipFile::enclosed_name rejects ../absolute entries outright; a tar entry’s path is checked for ParentDir/root/prefix components before extraction) — a malicious archive must not be able to write outside its own staging directory.

§Swap safety (why an upgrade doesn’t lose the old bundle on failure)

plugin_dir is a fixed path per id (plugins_dir()/<id>/), so an upgrade necessarily replaces whatever is already there. stage_plugin_source builds the new bundle in a scratch .staging-* directory first (so a bad source — invalid manifest, failed download, sha256 mismatch — never touches the existing install), then — only once the new bundle validates — moves the OLD plugin_dir aside to a .backup-* directory (not deleted) before renaming the staging directory into place. The returned StagedPlugin carries that backup path privately: StagedPlugin::commit deletes it (call after a successful install()), StagedPlugin::rollback removes the half-installed new bundle and restores the backup (call after a failed install()). install_plugin_from_source wires this up automatically and is the seam CLI/HTTP callers should use end to end.

Residual gap (documented, not solved here): the plugin_dir swap itself and install()’s own capability-registration rollback (see crate::plugin_installer’s module docs) are two separate best-effort steps, not one atomic transaction. If the process crashes between the swap and install() returning, a retry is still safe (staging always starts from a fresh scratch dir; a leftover .backup-*/.staging-* dir is inert and can be swept by an operator or a future cleanup pass) but is not automatic today.

§Known follow-ups (deferred — tracked here, not fixed on this branch)

  • URL content-bundle integrity pin: IMPLEMENTED (secure by default). Previously only the per-platform BINARY artifact was sha256-pinned (PluginArtifact.sha256, verified in [fetch_and_place_artifact]), while the plugin.json / content archive fetched by [fetch_manifest_bundle] was trusted on HTTPS alone — a MITM or a compromised host could serve a tampered bundle, and since the binary’s sha256 is DECLARED INSIDE that same untrusted manifest, tampering the bundle could rewrite the artifact hash too (the trust chain was circular). Fixed: PluginSourceInput::Url now carries a sha256 (the expected hash of the downloaded bundle) and an allow_unverified opt-out. [fetch_manifest_bundle] verifies the bundle’s actual sha256 against it BEFORE any extraction/parsing on a mismatch (PluginError::BundleVerificationFailed); with neither a sha256 nor allow_unverified: true, the fetch is refused up front — before the URL is ever requested — with PluginError::ChecksumRequired. A URL install can therefore no longer just download-and-trust any tar.gz. The verified bundle sha256 (not the binary artifact’s) is what PluginSource::Url.sha256 records for provenance/audit.
  • Source-TRUST layer: IMPLEMENTED (host allowlist + ed25519 publisher signature — see the module-level “three trust layers” summary above and [fetch_manifest_bundle]). A sha256 pin alone only proves “this is the bytes the installer expected”, not “an entity I trust produced them” — and worse, a checksum pasted from the SAME page as a malicious URL is circular, proving nothing about the source. bamboo_config::PluginTrustConfig (trusted_hosts + trusted_keys, both user-editable in config.json) closes that: a URL install now also needs an operator-trusted host and (absent allow_unsigned) a bundle signature verifying against a trusted key. Still deferred:
    • SSRF guard, described next.
  • No SSRF guard on URL fetch. [download_bytes] will fetch any URL, including http://169.254.169.254/... (cloud metadata) or private-range / loopback addresses. In a hosted/multi-tenant deployment a plugin-install URL is an SSRF vector. A private-IP / metadata-endpoint blocklist (or an allowlist of plugin registries) is a threat-model call for the deploy layer; noted here so it isn’t forgotten.
  • prompt-presets.json’s save_store is non-atomic (fs::write in place, pre-existing behaviour shared with the HTTP prompt-preset handlers): a crash mid-write can truncate prompt-presets.json. A write-to-temp-then-rename would make it atomic, matching what bamboo_plugin::registry::InstalledPlugins::save (installed.json) now does; deferred here as a change to a shared, pre-existing storage helper rather than this branch’s new code.
  • The plugin_dir swap in stage_plugin_source runs BEFORE PLUGIN_OP_LOCK is acquired (that lock is taken inside ServerPluginInstaller::install/uninstall, in crate::plugin_installer, which only runs after staging returns). Two concurrent installs/updates of the SAME plugin id could therefore race the backup-rename + staging-rename swap itself (not just the capability-registration steps the lock does protect). Plugin ops are rare/operator-driven, not concurrent by design, so this is accepted as a documented gap rather than moving the lock acquisition into this module; a real fix would need stage_plugin_source and PluginInstaller::install to share one lock scope (a bigger seam change than this branch’s fixes).

Structs§

StagedPlugin
The result of staging a PluginSourceInput into plugins_dir()/<id>/: the validated manifest (so a caller can preview manifest.provides — what the install WILL register — before committing to install()), the final on-disk plugin_dir, and the PluginSource provenance record install() expects. See the module docs for the backup/swap contract.

Enums§

PluginSourceInput
What the caller pointed the installer at.

Functions§

install_plugin_from_source
Stage + install() + commit/rollback, in one call — the seam CLI/HTTP callers should use. See the module docs.
stage_plugin_source
Stage input into plugins_root/<id>/, validating the manifest before anything is committed. Returns a StagedPlugin ready to hand to bamboo_plugin::PluginInstaller::install — call StagedPlugin::commit or StagedPlugin::rollback once you know whether install() succeeded (or just use install_plugin_from_source, which does both for you).