feat: enforce managed plugin allowlists

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-05-06 18:38:54 -07:00
parent 5e4c73cd78
commit 641f65a858
4 changed files with 387 additions and 15 deletions

View File

@@ -1,4 +1,5 @@
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::manager::configured_plugins_for_stack;
use crate::manifest::PluginManifestHooks;
use crate::manifest::PluginManifestPaths;
use crate::manifest::load_plugin_manifest;
@@ -378,10 +379,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
let Some(user_layer) = config_layer_stack.get_user_layer() else {
return HashMap::new();
};
configured_plugins_from_user_config_value(&user_layer.config)
configured_plugins_for_stack(config_layer_stack)
}
fn is_full_git_sha(value: &str) -> bool {

View File

@@ -48,6 +48,7 @@ use crate::store::PluginStoreError;
use codex_analytics::AnalyticsEventsClient;
use codex_config::ConfigLayerStack;
use codex_config::PluginConfigEdit;
use codex_config::PluginMarketplaceRequirementsToml;
use codex_config::apply_user_plugin_config_edits;
use codex_config::clear_user_plugin;
use codex_config::set_user_plugin_enabled;
@@ -401,6 +402,7 @@ pub struct PluginsManager {
struct CachedPluginLoadOutcome {
config_version: String,
plugin_hooks_enabled: bool,
plugin_marketplace_requirements: Option<PluginMarketplaceRequirementsToml>,
outcome: PluginLoadOutcome,
}
@@ -473,9 +475,13 @@ impl PluginsManager {
let plugin_hooks_enabled = config.plugin_hooks_enabled;
let config_version = version_for_toml(&config.config_layer_stack.effective_config());
let plugin_marketplace_requirements = plugin_marketplace_requirements(config);
if !force_reload
&& let Some(outcome) =
self.cached_enabled_outcome(&config_version, plugin_hooks_enabled)
&& let Some(outcome) = self.cached_enabled_outcome(
&config_version,
plugin_hooks_enabled,
plugin_marketplace_requirements.as_ref(),
)
{
return outcome;
}
@@ -496,6 +502,7 @@ impl PluginsManager {
*cache = Some(CachedPluginLoadOutcome {
config_version,
plugin_hooks_enabled,
plugin_marketplace_requirements,
outcome: outcome.clone(),
});
outcome
@@ -553,6 +560,7 @@ impl PluginsManager {
&self,
config_version: &str,
plugin_hooks_enabled: bool,
plugin_marketplace_requirements: Option<&PluginMarketplaceRequirementsToml>,
) -> Option<PluginLoadOutcome> {
match self.cached_enabled_outcome.read() {
Ok(cache) => cache
@@ -560,6 +568,8 @@ impl PluginsManager {
.filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
&& cached.plugin_marketplace_requirements.as_ref()
== plugin_marketplace_requirements
})
.map(|cached| cached.outcome.clone()),
Err(err) => err
@@ -568,6 +578,8 @@ impl PluginsManager {
.filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
&& cached.plugin_marketplace_requirements.as_ref()
== plugin_marketplace_requirements
})
.map(|cached| cached.outcome.clone()),
}
@@ -589,7 +601,16 @@ impl PluginsManager {
return HashMap::new();
};
remote_installed_plugins_to_config(plugins, &self.store)
if plugin_marketplace_requirements(config).is_none() {
return remote_installed_plugins_to_config(plugins, &self.store);
}
let plugins = plugins
.iter()
.filter(|plugin| marketplace_is_allowed(config, &plugin.marketplace_name))
.cloned()
.collect::<Vec<_>>();
remote_installed_plugins_to_config(&plugins, &self.store)
}
fn write_remote_installed_plugins_cache(&self, plugins: Vec<RemoteInstalledPlugin>) -> bool {
@@ -801,6 +822,25 @@ impl PluginsManager {
self.install_resolved_plugin(resolved).await
}
pub async fn install_plugin_for_config(
&self,
config: &PluginsConfigInput,
request: PluginInstallRequest,
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = find_installable_marketplace_plugin(
&request.marketplace_path,
&request.plugin_name,
self.restriction_product,
)?;
if !marketplace_is_allowed(config, &resolved.plugin_id.marketplace_name) {
return Err(MarketplaceError::MarketplaceBlocked {
marketplace_name: resolved.plugin_id.marketplace_name,
}
.into());
}
self.install_resolved_plugin(resolved).await
}
pub async fn install_plugin_with_remote_sync(
&self,
config: &PluginsConfigInput,
@@ -952,6 +992,9 @@ impl PluginsManager {
if !config.plugins_enabled {
return Ok(RemotePluginSyncResult::default());
}
if !marketplace_is_allowed(config, OPENAI_CURATED_MARKETPLACE_NAME) {
return Ok(RemotePluginSyncResult::default());
}
info!("starting remote plugin sync");
let remote_plugins = crate::remote_legacy::fetch_remote_plugin_status(
@@ -960,7 +1003,7 @@ impl PluginsManager {
)
.await
.map_err(PluginRemoteSyncError::from)?;
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
let configured_plugins = configured_plugins_for_config(config);
let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path());
let curated_marketplace_path = AbsolutePathBuf::try_from(
curated_marketplace_root.join(".agents/plugins/marketplace.json"),
@@ -1174,6 +1217,9 @@ impl PluginsManager {
.marketplaces
.into_iter()
.filter_map(|marketplace| {
if !marketplace_is_allowed(config, &marketplace.name) {
return None;
}
let marketplace_name = marketplace.name.clone();
let plugins = marketplace
.plugins
@@ -1228,6 +1274,11 @@ impl PluginsManager {
}
let plugin = find_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
if !marketplace_is_allowed(config, &plugin.plugin_id.marketplace_name) {
return Err(MarketplaceError::MarketplaceBlocked {
marketplace_name: plugin.plugin_id.marketplace_name,
});
}
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
return Err(MarketplaceError::PluginNotFound {
plugin_name: plugin.plugin_id.plugin_name,
@@ -1491,8 +1542,10 @@ impl PluginsManager {
config: &PluginsConfigInput,
marketplace_name: Option<&str>,
) -> Result<ConfiguredMarketplaceUpgradeOutcome, String> {
let configured_marketplace_names =
configured_git_marketplace_names(&config.config_layer_stack);
if let Some(marketplace_name) = marketplace_name
&& !configured_git_marketplace_names(&config.config_layer_stack)
&& !configured_marketplace_names
.iter()
.any(|name| name == marketplace_name)
{
@@ -1501,11 +1554,49 @@ impl PluginsManager {
));
}
let mut outcome = upgrade_configured_git_marketplaces(
self.codex_home.as_path(),
&config.config_layer_stack,
marketplace_name,
);
let mut outcome = if config
.config_layer_stack
.requirements()
.plugin_marketplaces
.is_none()
{
upgrade_configured_git_marketplaces(
self.codex_home.as_path(),
&config.config_layer_stack,
marketplace_name,
)
} else {
let selected_marketplace_names = match marketplace_name {
Some(marketplace_name) => {
if !marketplace_is_allowed(config, marketplace_name) {
return Err(format!(
"marketplace `{marketplace_name}` is not allowed by managed requirements"
));
}
vec![marketplace_name.to_string()]
}
None => configured_marketplace_names
.into_iter()
.filter(|marketplace_name| marketplace_is_allowed(config, marketplace_name))
.collect::<Vec<_>>(),
};
let mut outcome = ConfiguredMarketplaceUpgradeOutcome::default();
for marketplace_name in selected_marketplace_names {
let mut marketplace_outcome = upgrade_configured_git_marketplaces(
self.codex_home.as_path(),
&config.config_layer_stack,
Some(&marketplace_name),
);
outcome
.selected_marketplaces
.append(&mut marketplace_outcome.selected_marketplaces);
outcome
.upgraded_roots
.append(&mut marketplace_outcome.upgraded_roots);
outcome.errors.append(&mut marketplace_outcome.errors);
}
outcome
};
if !outcome.upgraded_roots.is_empty() {
match refresh_non_curated_plugin_cache_force_reinstall(
self.codex_home.as_path(),
@@ -1813,7 +1904,7 @@ impl PluginsManager {
&self,
config: &PluginsConfigInput,
) -> (HashSet<String>, HashSet<String>) {
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
let configured_plugins = configured_plugins_for_config(config);
let installed_plugins = configured_plugins
.keys()
.filter(|plugin_key| {
@@ -1913,6 +2004,7 @@ impl PluginInstallError {
| MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::PluginNotAvailable { .. }
| MarketplaceError::MarketplaceBlocked { .. }
| MarketplaceError::InvalidPlugin(_)
) | Self::Store(PluginStoreError::Invalid(_))
)
@@ -1957,6 +2049,60 @@ pub(crate) fn configured_plugins_from_stack(
configured_plugins_from_user_config_value(&user_layer.config)
}
pub(crate) fn configured_plugins_for_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
let configured_plugins = configured_plugins_from_stack(config_layer_stack);
if config_layer_stack
.requirements()
.plugin_marketplaces
.is_none()
{
return configured_plugins;
}
configured_plugins
.into_iter()
.filter(|(plugin_key, _)| plugin_key_is_allowed(config_layer_stack, plugin_key))
.collect()
}
fn marketplace_is_allowed(config: &PluginsConfigInput, marketplace_name: &str) -> bool {
marketplace_is_allowed_in_stack(&config.config_layer_stack, marketplace_name)
}
fn plugin_marketplace_requirements(
config: &PluginsConfigInput,
) -> Option<PluginMarketplaceRequirementsToml> {
config
.config_layer_stack
.requirements()
.plugin_marketplaces
.as_ref()
.map(|requirements| requirements.value.clone())
}
pub(crate) fn marketplace_is_allowed_in_stack(
config_layer_stack: &ConfigLayerStack,
marketplace_name: &str,
) -> bool {
config_layer_stack
.requirements()
.plugin_marketplaces
.as_ref()
.is_none_or(|requirements| requirements.value.allows_marketplace(marketplace_name))
}
fn plugin_key_is_allowed(config_layer_stack: &ConfigLayerStack, plugin_key: &str) -> bool {
PluginId::parse(plugin_key).ok().is_some_and(|plugin_id| {
marketplace_is_allowed_in_stack(config_layer_stack, &plugin_id.marketplace_name)
})
}
fn configured_plugins_for_config(config: &PluginsConfigInput) -> HashMap<String, PluginConfig> {
configured_plugins_for_stack(&config.config_layer_stack)
}
fn configured_plugins_from_user_config_value(
user_config: &toml::Value,
) -> HashMap<String, PluginConfig> {

View File

@@ -23,6 +23,9 @@ use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::McpServerConfig;
use codex_config::McpServerToolConfig;
use codex_config::PluginMarketplaceRequirementsToml;
use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::types::McpServerTransportConfig;
use codex_login::CodexAuth;
use codex_protocol::protocol::Product;
@@ -141,6 +144,32 @@ async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> Plugi
.await
}
fn plugins_config_with_requirements(
codex_home: &Path,
user_config: Value,
requirements: ConfigRequirements,
) -> PluginsConfigInput {
let config_path =
codex_utils_absolute_path::AbsolutePathBuf::try_from(codex_home.join(CONFIG_TOML_FILE))
.expect("config path should be absolute");
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: config_path },
user_config,
)],
requirements,
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack");
PluginsConfigInput::new(
config_layer_stack,
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ false,
/*plugin_hooks_enabled*/ false,
"https://chatgpt.com/backend-api/".to_string(),
)
}
async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
load_plugins_config_input(codex_home, cwd).await
}
@@ -1576,6 +1605,143 @@ enabled = false
);
}
#[tokio::test]
async fn managed_marketplace_allowlist_hides_and_disables_unapproved_plugins() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample/local",
"sample",
);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./sample"
}
}
]
}"#,
)
.unwrap();
let config = plugins_config_with_requirements(
tmp.path(),
toml::toml! {
[plugins."sample@debug"]
enabled = true
}
.into(),
ConfigRequirements {
plugin_marketplaces: Some(Sourced::new(
PluginMarketplaceRequirementsToml {
allowed_names: Some(vec!["openai-curated".to_string()]),
allow_user_additions: Some(false),
},
RequirementSource::Unknown,
)),
..Default::default()
},
);
let manager = PluginsManager::new(tmp.path().to_path_buf());
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(outcome.plugins(), &[]);
let marketplaces = manager
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap()
.marketplaces;
assert_eq!(marketplaces, Vec::new());
let err = manager
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "sample".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.expect_err("disallowed marketplace should not be readable");
assert!(matches!(
err,
MarketplaceError::MarketplaceBlocked { marketplace_name }
if marketplace_name == "debug"
));
let err = manager
.install_plugin_for_config(
&config,
PluginInstallRequest {
plugin_name: "sample".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.expect_err("disallowed marketplace should not be installable");
assert!(matches!(
err,
PluginInstallError::Marketplace(MarketplaceError::MarketplaceBlocked {
marketplace_name
}) if marketplace_name == "debug"
));
}
#[tokio::test]
async fn plugins_for_config_separates_cache_entries_by_managed_marketplace_allowlist() {
let tmp = tempfile::tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample/local",
"sample",
);
let user_config: Value = toml::toml! {
[plugins."sample@debug"]
enabled = true
}
.into();
let unrestricted = plugins_config_with_requirements(
tmp.path(),
user_config.clone(),
ConfigRequirements::default(),
);
let restricted = plugins_config_with_requirements(
tmp.path(),
user_config,
ConfigRequirements {
plugin_marketplaces: Some(Sourced::new(
PluginMarketplaceRequirementsToml {
allowed_names: Some(vec!["openai-curated".to_string()]),
allow_user_additions: Some(false),
},
RequirementSource::Unknown,
)),
..Default::default()
},
);
let manager = PluginsManager::new(tmp.path().to_path_buf());
let unrestricted = manager.plugins_for_config(&unrestricted).await;
assert_eq!(unrestricted.plugins().len(), 1);
let restricted = manager.plugins_for_config(&restricted).await;
assert_eq!(restricted.plugins(), &[]);
}
#[tokio::test]
async fn list_marketplaces_returns_empty_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
@@ -2827,6 +2993,65 @@ enabled = false
assert!(config.contains("enabled = false"));
}
#[tokio::test]
async fn sync_plugins_from_remote_skips_disallowed_curated_marketplace() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["gmail"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let mut config = load_config(tmp.path(), tmp.path()).await;
config.config_layer_stack = ConfigLayerStack::new(
config
.config_layer_stack
.get_layers(
codex_config::ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.cloned()
.collect(),
ConfigRequirements {
plugin_marketplaces: Some(Sourced::new(
PluginMarketplaceRequirementsToml {
allowed_names: Some(vec!["arm-internal".to_string()]),
allow_user_additions: Some(false),
},
RequirementSource::Unknown,
)),
..Default::default()
},
ConfigRequirementsToml::default(),
)
.unwrap();
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap();
assert_eq!(result, RemotePluginSyncResult::default());
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/gmail")
.exists()
);
assert_eq!(
fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(),
"[features]\nplugins = true\n"
);
}
#[tokio::test]
async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -158,6 +158,9 @@ pub enum MarketplaceError {
#[error("plugins feature is disabled")]
PluginsDisabled,
#[error("marketplace `{marketplace_name}` is not allowed by managed requirements")]
MarketplaceBlocked { marketplace_name: String },
#[error("{0}")]
InvalidPlugin(String),
}