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]). - Host allowlist (source authorization) — is the URL’s
§--insecure / plugin_trust.enforcement: skip ALL three layers at once
The three allow_* opt-outs above are per-layer. On top of them,
PluginSourceInput::Url::insecure is a convenience AGGREGATE — set it
(CLI: --insecure; HTTP: "insecure": true on the url source) and
[fetch_manifest_bundle] treats allow_untrusted_host, allow_unsigned
AND allow_unverified as all true for that one install, without the
caller spelling out all three. There is also a PERSISTENT, config-level
form for an operator who never wants to pass flags at all:
bamboo_config::PluginTrustConfig::enforcement set to
PluginTrustEnforcement::Off makes EVERY url install/update behave as
if --insecure were passed, with no per-install flag needed. Precedence,
in both cases:
- The aggregate ONLY turns per-layer checks OFF — it never turns off a
check the caller opted INTO. A supplied
sha256is still hashed and compared; a mismatch is stillPluginError::BundleVerificationFailed,--insecure/enforcement: offor not. So--insecure --sha256 <hex>means “skip host/signature enforcement AND the bare sha256-required-by-default rule, but still verify THIS hash”. - The per-layer flags keep working independently — a caller who wants to
waive just the host allowlist (say) still passes
--allow-untrusted-hostalone; the aggregate is a shortcut for “all three”, not a replacement for them. plugin_trust.enforcementdefaults toStrict(secure by default) for both a fresh config and one with noplugin_trust.enforcementkey at all — this is opt-in relaxation, never a silent weakening.
Every install where the aggregate is active — via insecure: true on the
request OR plugin_trust.enforcement: off — logs a prominent
tracing::warn! naming the source URL, and records insecure: true in
the resulting PluginSource::Url provenance (bamboo plugin list/audit
can then tell these installs apart from ones where the same three
individual allow_* flags merely happened to all be set). A server
booting with plugin_trust.enforcement: off also logs its own startup
warning (see AppState::new), since that setting silently affects EVERY
future install, not just one command invocation.
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 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).