plugins: verify installed cache provenance

This commit is contained in:
Chris Hayduk
2026-06-23 01:33:29 -04:00
parent f959e7fc98
commit bbca0efa26
9 changed files with 412 additions and 27 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -2780,6 +2780,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"tar",
"tempfile",
"thiserror 2.0.18",
@@ -2789,6 +2790,7 @@ dependencies = [
"tracing-subscriber",
"tracing-test",
"url",
"walkdir",
"wiremock",
"zip 2.4.2",
]

View File

@@ -41,6 +41,7 @@ regex = { workspace = true }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tar = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
@@ -48,6 +49,7 @@ tokio = { workspace = true, features = ["fs", "macros", "rt", "time"] }
toml = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
walkdir = { workspace = true }
zip = { workspace = true }
[dev-dependencies]

View File

@@ -0,0 +1,148 @@
use crate::store::PluginStoreError;
use serde::Deserialize;
use serde::Serialize;
use sha2::Digest;
use sha2::Sha256;
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use walkdir::WalkDir;
pub(crate) const INSTALL_PROVENANCE_FILE: &str = ".codex-plugin-install.json";
const INSTALL_PROVENANCE_SCHEMA_VERSION: u8 = 1;
#[derive(Debug, Deserialize, Serialize)]
struct PluginInstallProvenance {
schema_version: u8,
content_sha256: String,
}
enum TreeEntry {
Directory,
File,
VirtualFile(Vec<u8>),
}
pub(crate) fn fingerprint_plugin_tree(
root: &Path,
manifest_override: Option<&str>,
) -> Result<String, PluginStoreError> {
let mut entries = BTreeMap::new();
for entry in WalkDir::new(root)
.sort_by_file_name()
.into_iter()
.filter_entry(|entry| entry.depth() != 1 || entry.file_name() != ".git")
.skip(1)
{
let entry = entry.map_err(|error| {
PluginStoreError::io(
"failed to enumerate plugin source",
std::io::Error::other(error),
)
})?;
let relative = entry.path().strip_prefix(root).map_err(|error| {
PluginStoreError::Invalid(format!("failed to fingerprint plugin path: {error}"))
})?;
let tree_entry = if entry.file_type().is_dir() {
TreeEntry::Directory
} else if entry.file_type().is_file() {
TreeEntry::File
} else {
continue;
};
entries.insert(relative.to_path_buf(), tree_entry);
}
if let Some(manifest) = manifest_override {
entries
.entry(PathBuf::from(".codex-plugin"))
.or_insert(TreeEntry::Directory);
entries.insert(
PathBuf::from(".codex-plugin").join("plugin.json"),
TreeEntry::VirtualFile(manifest.as_bytes().to_vec()),
);
}
let mut hasher = Sha256::new();
for (path, entry) in entries {
hasher.update([if matches!(entry, TreeEntry::Directory) {
b'd'
} else {
b'f'
}]);
hash_contents(&mut hasher, path.as_os_str().as_encoded_bytes());
match entry {
TreeEntry::Directory => {}
TreeEntry::File => hash_file(&mut hasher, &root.join(path))?,
TreeEntry::VirtualFile(contents) => hash_contents(&mut hasher, &contents),
}
}
Ok(format!("{:x}", hasher.finalize()))
}
pub(crate) fn read_install_fingerprint(
plugin_base_root: &Path,
) -> Result<Option<String>, PluginStoreError> {
let path = plugin_base_root.join(INSTALL_PROVENANCE_FILE);
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(PluginStoreError::io(
"failed to read plugin install provenance",
error,
));
}
};
let Ok(provenance) = serde_json::from_str::<PluginInstallProvenance>(&contents) else {
return Ok(None);
};
let fingerprint = provenance.content_sha256;
Ok(
(provenance.schema_version == INSTALL_PROVENANCE_SCHEMA_VERSION
&& fingerprint.len() == 64
&& fingerprint
.chars()
.all(|character| character.is_ascii_hexdigit()))
.then(|| fingerprint.to_ascii_lowercase()),
)
}
pub(crate) fn write_install_fingerprint(
plugin_base_root: &Path,
content_sha256: &str,
) -> Result<(), PluginStoreError> {
let provenance = PluginInstallProvenance {
schema_version: INSTALL_PROVENANCE_SCHEMA_VERSION,
content_sha256: content_sha256.to_string(),
};
let mut temporary = tempfile::NamedTempFile::new_in(plugin_base_root).map_err(|error| {
PluginStoreError::io(
"failed to create temporary plugin install provenance",
error,
)
})?;
serde_json::to_writer_pretty(&mut temporary, &provenance).map_err(|error| {
PluginStoreError::Invalid(format!("failed to serialize plugin provenance: {error}"))
})?;
io::Write::write_all(&mut temporary, b"\n")
.map_err(|error| PluginStoreError::io("failed to write plugin provenance", error))?;
io::Write::flush(temporary.as_file_mut())
.map_err(|error| PluginStoreError::io("failed to flush plugin provenance", error))?;
temporary
.persist(plugin_base_root.join(INSTALL_PROVENANCE_FILE))
.map_err(|error| {
PluginStoreError::io("failed to persist plugin install provenance", error.error)
})?;
Ok(())
}
fn hash_file(hasher: &mut Sha256, path: &Path) -> Result<(), PluginStoreError> {
let mut file = fs::File::open(path)
.map_err(|error| PluginStoreError::io("failed to open plugin file", error))?;
let length = file
.metadata()
.map_err(|error| PluginStoreError::io("failed to inspect plugin file", error))?
.len();
hasher.update(length.to_le_bytes());
io::copy(&mut file, hasher)
.map_err(|error| PluginStoreError::io("failed to read plugin file", error))?;
Ok(())
}
fn hash_contents(hasher: &mut Sha256, contents: &[u8]) {
hasher.update((contents.len() as u64).to_le_bytes());
hasher.update(contents);
}

View File

@@ -1,5 +1,6 @@
mod app_mcp_routing;
mod discoverable;
mod install_provenance;
pub mod installed_marketplaces;
pub mod loader;
mod manager;

View File

@@ -151,18 +151,27 @@ async fn load_plugins_from_layer_stack_with_scope(
configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
let mut plugins = Vec::with_capacity(configured_plugins.len());
let mut seen_mcp_server_names = HashMap::<String, String>::new();
let mut seen_mcp_server_names = HashMap::<String, (String, AbsolutePathBuf)>::new();
for (configured_name, plugin) in configured_plugins {
let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store, &scope).await;
for name in loaded_plugin.mcp_servers.keys() {
if let Some(previous_plugin) =
seen_mcp_server_names.insert(name.clone(), configured_name.clone())
{
if let Some((winner_plugin, winner_path)) = seen_mcp_server_names.get(name) {
let remediation = format!(
"remove `{configured_name}` or set [plugins.\"{configured_name}\"].enabled = false"
);
warn!(
plugin = configured_name,
previous_plugin,
server = name,
"skipping duplicate plugin MCP server name"
winner_plugin = %winner_plugin,
winner_path = %winner_path.display(),
duplicate_plugin = %configured_name,
duplicate_path = %loaded_plugin.root.display(),
remediation = %remediation,
"duplicate plugin MCP server ownership for `{name}`: `{winner_plugin}` wins over `{configured_name}`; {remediation}"
);
} else {
seen_mcp_server_names.insert(
name.clone(),
(configured_name.clone(), loaded_plugin.root.clone()),
);
}
}
@@ -374,7 +383,21 @@ pub fn refresh_curated_plugin_cache(
if store.active_plugin_version(plugin_id).as_deref() == Some(cache_plugin_version.as_str())
{
continue;
let source_matches = store
.active_plugin_matches_source(plugin_id, source_path.as_path())
.map_err(|err| {
format!(
"failed to verify curated plugin cache for {}: {err}",
plugin_id.as_key()
)
})?;
if source_matches {
continue;
}
warn!(
plugin = %plugin_id.as_key(),
"curated plugin cache bytes differ from the intended source; reinstalling"
);
}
store
@@ -545,7 +568,28 @@ fn refresh_non_curated_plugin_cache_with_mode(
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
&& store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str())
{
continue;
let source_matches = match manifest_fallback_contents.as_deref() {
Some(manifest_contents) => store
.active_plugin_matches_source_with_fallback_manifest(
&plugin_id,
source_path.as_path(),
manifest_contents,
),
None => store.active_plugin_matches_source(&plugin_id, source_path.as_path()),
}
.map_err(|err| {
format!(
"failed to verify plugin cache for {}: {err}",
plugin_id.as_key()
)
})?;
if source_matches {
continue;
}
warn!(
plugin = %plugin_id.as_key(),
"plugin cache bytes differ from the intended source; reinstalling"
);
}
match manifest_fallback_contents.as_deref() {

View File

@@ -227,6 +227,52 @@ fn curated_plugin_cache_version_preserves_non_git_sha_versions() {
assert_eq!(curated_plugin_cache_version("0123456"), "0123456");
}
#[tokio::test]
#[tracing_test::traced_test]
async fn duplicate_mcp_diagnostic_names_the_stable_winner_and_remediation() {
let codex_home = tempfile::tempdir().expect("codex home");
for marketplace in ["a", "b", "c"] {
let plugin_root = codex_home.path().join(format!(
"plugins/cache/{marketplace}/structure-viewer/local"
));
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"structure-viewer"}"#,
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{"mcpServers":{"structure":{"command":"echo"}}}"#,
);
}
let stack = ConfigLayerStack::new(
vec![user_layer(
user_config_path(&codex_home, "config.toml"),
"[plugins.\"structure-viewer@c\"]\nenabled = true\n[plugins.\"structure-viewer@a\"]\nenabled = true\n[plugins.\"structure-viewer@b\"]\nenabled = true\n",
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("valid config stack");
let store = PluginStore::new(codex_home.path().to_path_buf());
let plugins = load_plugins_from_layer_stack(
&stack,
HashMap::new(),
&store,
/*plugin_skill_snapshots*/ None,
Some(Product::Codex),
/*prefer_remote_curated_conflicts*/ false,
)
.await;
assert_eq!(plugins.len(), 3);
for expected in [
"`structure-viewer@a` wins over `structure-viewer@b`",
"`structure-viewer@a` wins over `structure-viewer@c`",
"[plugins.\"structure-viewer@b\"].enabled = false",
] {
assert!(logs_contain(expected));
}
}
fn plugin_id() -> PluginId {
PluginId::parse("demo-plugin@test-marketplace").expect("plugin id")
}

View File

@@ -5153,7 +5153,7 @@ plugins = true
}
#[test]
fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
fn refresh_curated_plugin_cache_reinstalls_unverified_current_version() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
@@ -5169,8 +5169,8 @@ fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_curren
);
assert!(
!refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
.expect("cache refresh should be a no-op when configured plugins are current")
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
.expect("cache refresh should reinstall current but unverified plugin bytes")
);
}
@@ -5374,7 +5374,7 @@ enabled = true
}
#[test]
fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
fn refresh_non_curated_plugin_cache_reinstalls_changed_current_version() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -5395,12 +5395,6 @@ fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_cu
]
}"#,
);
write_plugin_with_version(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/1.2.3",
"sample-plugin",
Some("1.2.3"),
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
@@ -5411,13 +5405,11 @@ enabled = true
"#,
);
assert!(
!refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.expect("cache refresh should be a no-op when configured plugins are current")
);
let roots = [AbsolutePathBuf::try_from(repo_root.clone()).unwrap()];
assert!(refresh_non_curated_plugin_cache(tmp.path(), &roots).unwrap());
fs::write(repo_root.join("sample-plugin/skills/SKILL.md"), "updated").unwrap();
assert!(refresh_non_curated_plugin_cache(tmp.path(), &roots).unwrap());
assert!(!refresh_non_curated_plugin_cache(tmp.path(), &roots).unwrap());
}
#[test]

View File

@@ -1,3 +1,6 @@
use crate::install_provenance::fingerprint_plugin_tree;
use crate::install_provenance::read_install_fingerprint;
use crate::install_provenance::write_install_fingerprint;
use crate::manifest::PluginManifest;
use crate::manifest::load_plugin_manifest;
use crate::manifest::parse_plugin_manifest;
@@ -19,6 +22,7 @@ use std::path::PathBuf;
pub const DEFAULT_PLUGIN_VERSION: &str = "local";
pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";
pub const PLUGINS_DATA_DIR: &str = "plugins/data";
const NO_MANIFEST_OVERRIDE: Option<&str> = None;
const REMOTE_PLUGIN_INSTALL_METADATA_FILE: &str = ".codex-remote-plugin-install.json";
const REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION: u8 = 1;
@@ -47,6 +51,15 @@ enum InstallManifest<'a> {
Fallback(&'a str),
}
impl<'a> InstallManifest<'a> {
fn contents_override(self) -> Option<&'a str> {
match self {
Self::OnDisk => None,
Self::Fallback(contents) => Some(contents),
}
}
}
impl PluginStore {
pub fn new(codex_home: PathBuf) -> Self {
Self::try_new(codex_home)
@@ -116,6 +129,48 @@ impl PluginStore {
self.active_plugin_version(plugin_id).is_some()
}
pub fn active_plugin_matches_source(
&self,
plugin_id: &PluginId,
source_path: &Path,
) -> Result<bool, PluginStoreError> {
self.active_plugin_matches_install_manifest(plugin_id, source_path, InstallManifest::OnDisk)
}
pub(crate) fn active_plugin_matches_source_with_fallback_manifest(
&self,
plugin_id: &PluginId,
source_path: &Path,
manifest_contents: &str,
) -> Result<bool, PluginStoreError> {
self.active_plugin_matches_install_manifest(
plugin_id,
source_path,
InstallManifest::Fallback(manifest_contents),
)
}
fn active_plugin_matches_install_manifest(
&self,
plugin_id: &PluginId,
source_path: &Path,
manifest: InstallManifest<'_>,
) -> Result<bool, PluginStoreError> {
let Some(installed_root) = self.active_plugin_root(plugin_id) else {
return Ok(false);
};
let manifest = resolve_install_manifest(source_path, manifest);
let expected = fingerprint_plugin_tree(source_path, manifest.contents_override())?;
let Some(recorded) = read_install_fingerprint(self.plugin_base_root(plugin_id).as_path())?
else {
return Ok(false);
};
if recorded != expected {
return Ok(false);
}
Ok(fingerprint_plugin_tree(installed_root.as_path(), NO_MANIFEST_OVERRIDE)? == expected)
}
pub fn remote_plugin_id(
&self,
plugin_id: &PluginId,
@@ -297,11 +352,26 @@ impl PluginStore {
}
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
let source_fingerprint =
fingerprint_plugin_tree(source_path.as_path(), manifest.contents_override())?;
replace_plugin_root_atomically(
source_path.as_path(),
self.plugin_base_root(&plugin_id).as_path(),
&plugin_version,
manifest,
&source_fingerprint,
)?;
let installed_fingerprint =
fingerprint_plugin_tree(installed_path.as_path(), NO_MANIFEST_OVERRIDE)?;
if installed_fingerprint != source_fingerprint {
return Err(PluginStoreError::Invalid(format!(
"installed plugin bytes do not match source for `{}`",
plugin_id.as_key()
)));
}
write_install_fingerprint(
self.plugin_base_root(&plugin_id).as_path(),
&source_fingerprint,
)?;
self.remove_remote_plugin_install_metadata(&plugin_id)?;
@@ -316,6 +386,49 @@ impl PluginStore {
remove_existing_target(self.plugin_base_root(plugin_id).as_path())
}
pub(crate) fn other_sources(
&self,
canonical_plugin_id: &PluginId,
active_only: bool,
) -> Result<Vec<PluginId>, PluginStoreError> {
let mut installed = Vec::new();
let marketplaces = match fs::read_dir(self.root.as_path()) {
Ok(entries) => entries,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(installed),
Err(err) => {
return Err(PluginStoreError::io(
"failed to enumerate plugin cache marketplaces",
err,
));
}
};
for marketplace in marketplaces.filter_map(Result::ok) {
let Ok(file_type) = marketplace.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let Ok(marketplace_name) = marketplace.file_name().into_string() else {
continue;
};
let Ok(plugin_id) =
PluginId::new(canonical_plugin_id.plugin_name.clone(), marketplace_name)
else {
continue;
};
if &plugin_id == canonical_plugin_id
|| (active_only && !self.is_installed(&plugin_id))
|| (!active_only && !self.plugin_base_root(&plugin_id).as_path().is_dir())
{
continue;
}
installed.push(plugin_id);
}
installed.sort_unstable_by_key(PluginId::as_key);
Ok(installed)
}
fn remote_plugin_install_metadata_path(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
self.plugin_base_root(plugin_id)
.join(REMOTE_PLUGIN_INSTALL_METADATA_FILE)
@@ -351,7 +464,7 @@ pub enum PluginStoreError {
}
impl PluginStoreError {
fn io(context: &'static str, source: io::Error) -> Self {
pub(crate) fn io(context: &'static str, source: io::Error) -> Self {
Self::Io { context, source }
}
}
@@ -500,6 +613,7 @@ fn replace_plugin_root_atomically(
target_root: &Path,
plugin_version: &str,
manifest: InstallManifest<'_>,
source_fingerprint: &str,
) -> Result<(), PluginStoreError> {
let Some(parent) = target_root.parent() else {
return Err(PluginStoreError::Invalid(format!(
@@ -541,6 +655,12 @@ fn replace_plugin_root_atomically(
fs::write(&manifest_path, contents)
.map_err(|err| PluginStoreError::io("failed to write fallback plugin manifest", err))?;
}
let staged_fingerprint = fingerprint_plugin_tree(&staged_version_root, NO_MANIFEST_OVERRIDE)?;
if staged_fingerprint != source_fingerprint {
return Err(PluginStoreError::Invalid(
"staged plugin bytes do not match source".to_string(),
));
}
let target_version_root = target_root.join(plugin_version);
if target_root.exists() && !target_version_root.exists() {

View File

@@ -192,6 +192,36 @@ fn install_with_version_uses_requested_cache_version() {
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[test]
fn install_records_and_verifies_exact_source_bytes() {
let tmp = tempdir().unwrap();
write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some("1.2.3"));
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let store = PluginStore::new(tmp.path().to_path_buf());
let source = AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap();
let result = store
.install(source.clone(), plugin_id.clone())
.expect("install plugin");
let matches_source = || {
store
.active_plugin_matches_source(&plugin_id, source.as_path())
.expect("verify installed bytes")
};
assert!(matches_source());
let provenance_path = store
.plugin_base_root(&plugin_id)
.join(crate::install_provenance::INSTALL_PROVENANCE_FILE);
assert!(provenance_path.is_file());
fs::write(result.installed_path.join("skills/SKILL.md"), "tampered")
.expect("tamper installed file");
assert!(!matches_source());
store.install(source.clone(), plugin_id.clone()).unwrap();
fs::write(&provenance_path, "corrupt").expect("corrupt install provenance");
assert!(!matches_source());
store.install(source.clone(), plugin_id.clone()).unwrap();
fs::write(source.join("skills/SKILL.md"), "updated source").expect("update source file");
assert!(!matches_source());
}
#[test]
fn remote_plugin_install_metadata_follows_installed_cache_lifecycle() {
let tmp = tempdir().unwrap();