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 commontar czf bundle.tar.gz plugin-name/convention), that directory is flattened up soplugin.jsonends up at the bundle root either way. -
PluginSourceInput::Url— fetches the manifest bundle (a bareplugin.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 asLocalArchive). Three trust layers, stacked, enforced in [fetch_manifest_bundle] in this order:- 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) unlessallow_untrusted_hostis set. - Signature (publisher authenticity) — after the bundle is
downloaded, does its
<url>.sigsidecar (a raw 64-byte ed25519 signature, hex-encoded, over the exact bundle bytes) verify against anybamboo_config::TrustedKeyintrusted_keys? Refused (PluginError::UnsignedOrUntrustedSignature) unlessallow_unsignedis set. - 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
sha256norallow_unverifiedgiven (anallow_unsignedbypass 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-outallow_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 inmanifest.artifacts, verifies its sha256 BEFORE unpacking (mandatory — a URL plugin ships a binary that gets executed), and places the single expected executable atbin/<platform>/<id>[.exe]perbamboo_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. - Host allowlist (source authorization) — is the URL’s
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 theplugin.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::Urlnow carries asha256(the expected hash of the downloaded bundle) and anallow_unverifiedopt-out. [fetch_manifest_bundle] verifies the bundle’s actual sha256 against it BEFORE any extraction/parsing on a mismatch (PluginError::BundleVerificationFailed); with neither asha256norallow_unverified: true, the fetch is refused up front — before the URL is ever requested — withPluginError::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 whatPluginSource::Url.sha256records 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 inconfig.json) closes that: a URL install now also needs an operator-trusted host and (absentallow_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, includinghttp://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’ssave_storeis non-atomic (fs::writein place, pre-existing behaviour shared with the HTTP prompt-preset handlers): a crash mid-write can truncateprompt-presets.json. A write-to-temp-then-rename would make it atomic, matching whatbamboo_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_dirswap instage_plugin_sourceruns BEFOREPLUGIN_OP_LOCKis acquired (that lock is taken insideServerPluginInstaller::install/uninstall, incrate::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 needstage_plugin_sourceandPluginInstaller::installto share one lock scope (a bigger seam change than this branch’s fixes).
Structs§
- Staged
Plugin - The result of staging a
PluginSourceInputintoplugins_dir()/<id>/: the validated manifest (so a caller can previewmanifest.provides— what the install WILL register — before committing toinstall()), the final on-diskplugin_dir, and thePluginSourceprovenance recordinstall()expects. See the module docs for the backup/swap contract.
Enums§
- Plugin
Source Input - 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
inputintoplugins_root/<id>/, validating the manifest before anything is committed. Returns aStagedPluginready to hand tobamboo_plugin::PluginInstaller::install— callStagedPlugin::commitorStagedPlugin::rollbackonce you know whetherinstall()succeeded (or just useinstall_plugin_from_source, which does both for you).