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. [prepare_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). The server-owned transaction seam then holds
the plugin-operation lock while auditing global ownership; only an accepted
[PreparedPlugin] is activated, after which the OLD plugin_dir is moved
aside to a .backup-* directory (not deleted) and the candidate is renamed
into place. The private staged transaction carries exact directory
identities for the candidate and backup. On install failure it quarantines
the live entry, and restores only an identity-verified backup with
NOREPLACE. An ambiguous destination, candidate, or backup is preserved and
requires manual recovery. Once an upgrade has stopped an old service, any
later failure deliberately leaves it stopped for an operator to reconcile;
source recovery never starts executable code automatically. Production
exposes only install_server_plugin_from_source;
low-level staging helpers exist under cfg(test) and cannot bypass the
server’s ownership preflight or operation lock.
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.
Production install/update handlers retain one PLUGIN_OP_LOCK guard across
ownership preflight, service shutdown, activation, install, and rollback,
so shared server state has no preflight-to-swap race.
Enums§
- Plugin
Source Input - What the caller pointed the installer at.
Functions§
- install_
server_ plugin_ from_ source - Server-owned source transaction. This is the only public source-install
seam for
ServerPluginInstaller: the same process-wide guard spans provenance preflight, prior-service shutdown, live-bundle activation, installer mutation, and commit/rollback. Once an upgrade stops services, a subsequent failure leaves them stopped for explicit operator recovery. - install_
server_ plugin_ from_ source_ with_ event_ sink_ grants - Source transaction with an optional complete per-sink host grant target. Resolution happens against the prepared manifest and prior durable provenance before service shutdown or bundle activation. The installer receives the canonical map and validates it again under the same operation guard before writing the Installing journal row.