Enforce marketplace source policy for curated plugins (#41953)

## Why

Marketplace source restrictions should cover curated plugins backed by the OpenAI plugins repository, not only user-configured marketplaces.

## What changed

- Validate both local curated catalogs against the existing OpenAI plugins Git source allowlist.
- Apply that policy to catalog discovery, installation, cached plugin and skill loading, and startup repository sync.
- Keep bundled and remote installed plugins independent of the curated Git source policy.

## Testing

Added coverage for exact and shorthand Git allowlist entries, host patterns, blocked and invalid rules, curated catalog variants, plugin skills, startup sync, and remote installed plugins.

GitOrigin-RevId: 04617acb4a93040c70a13759eb4ce872877ba211
This commit is contained in:
willwang-openai
2026-09-01 01:37:15 +00:00
committed by copyberry
parent 55e5158e18
commit 633ab199cf
8 changed files with 388 additions and 3 deletions
+7 -1
View File
@@ -69,6 +69,7 @@ use crate::remote_plugin_id_resolver::RemoteInstalledPluginsSnapshot;
use crate::remote_plugin_id_resolver::RemotePluginIdResolver;
use crate::remote_plugin_id_resolver::persisted_remote_plugin_id_for_installation;
use crate::skill_snapshots::new_plugin_skill_snapshots;
use crate::startup_sync::OPENAI_PLUGINS_GIT_URL;
use crate::startup_sync::curated_plugins_api_marketplace_path;
use crate::startup_sync::curated_plugins_repo_path;
use crate::startup_sync::read_curated_plugins_sha;
@@ -677,7 +678,12 @@ impl PluginsManager {
config: &PluginsConfigInput,
on_effective_plugins_changed: Option<EffectivePluginsChangedCallback>,
) {
if config.plugins_enabled && !self.remote_global_catalog_active(config) {
if config.plugins_enabled
&& !self.remote_global_catalog_active(config)
&& MarketplacePolicy::from_requirements(config.config_layer_stack.requirements())
.validate_git_source(OPENAI_PLUGINS_GIT_URL, /*ref_name*/ None)
.is_ok()
{
self.start_curated_repo_sync(
config.http_client_factory.clone(),
on_effective_plugins_changed,
@@ -75,6 +75,9 @@ use wiremock::matchers::query_param;
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
#[path = "marketplace_policy/curated_loading_tests.rs"]
mod curated_marketplace_policy;
fn unrestricted_config_layer_stack() -> ConfigLayerStack {
ConfigLayerStack::default()
}
@@ -10,6 +10,7 @@ use crate::marketplace::marketplace_root_dir;
use crate::marketplace_add::MarketplaceSource;
use crate::marketplace_add::parse_marketplace_source;
use crate::remote::RemotePluginScope;
use crate::startup_sync::OPENAI_PLUGINS_GIT_URL;
use crate::startup_sync::curated_plugins_api_marketplace_path;
use crate::startup_sync::curated_plugins_repo_path;
use codex_config::ConfigLayerStack;
@@ -110,6 +111,9 @@ impl MarketplacePolicy {
) -> Result<(), String> {
let root = marketplace_root_dir(marketplace_path).map_err(|err| err.to_string())?;
if let Some(expected_name) = managed_marketplace_name(codex_home, marketplace_path, &root) {
if is_openai_curated_marketplace_name(expected_name) {
self.validate_git_source(OPENAI_PLUGINS_GIT_URL, /*ref_name*/ None)?;
}
return validate_expected_marketplace_name(expected_name, marketplace_name);
}
if is_reserved_marketplace_name(marketplace_name) {
@@ -239,7 +243,12 @@ pub(crate) fn policy_filtered_plugin_config(
if configured_marketplace_names.contains(marketplace_name) {
return allowed_marketplace_names.contains(marketplace_name);
}
is_openai_curated_marketplace_name(marketplace_name) || !policy.is_restricted()
if is_openai_curated_marketplace_name(marketplace_name) {
return policy
.validate_git_source(OPENAI_PLUGINS_GIT_URL, /*ref_name*/ None)
.is_ok();
}
!policy.is_restricted()
});
}
Some(effective_config)
@@ -0,0 +1,118 @@
//! Exercises curated Git requirements across discovery, installation, cached loading, and sync.
use super::*;
use pretty_assertions::assert_eq;
#[path = "curated_skill_tests.rs"]
mod skills;
#[tokio::test]
async fn curated_git_policy_controls_catalog_install_and_cached_activation() {
for (name, auth) in [
(OPENAI_CURATED_MARKETPLACE_NAME, AuthMode::Chatgpt),
(OPENAI_API_CURATED_MARKETPLACE_NAME, AuthMode::ApiKey),
] {
let home = TempDir::new().unwrap();
let root = curated_plugins_repo_path(home.path());
write_openai_curated_marketplace(&root, &["sample"]);
write_openai_api_curated_marketplace(&root, &["sample"]);
write_curated_plugin_sha(home.path(), TEST_CURATED_PLUGIN_SHA);
write_cached_plugin(home.path(), name, "sample");
let user_config = format!("[plugins.\"sample@{name}\"]\nenabled = true\n");
let allowed = plugins_config_input_with_requirements(
home.path(),
&user_config,
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.curated]
source = "git"
url = "https://github.com/openai/plugins.git"
"#,
);
let blocked = plugins_config_input_with_requirements(
home.path(),
&user_config,
"[marketplaces]\nrestrict_to_allowed_sources = true\n",
);
let host_allowed = plugins_config_input_with_requirements(
home.path(),
&user_config,
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.github]
source = "host_pattern"
host_pattern = '^github[.]com$'
"#,
);
let manager = Arc::new(test_plugins_manager_with_options(
home.path().to_path_buf(),
Some(Product::Codex),
Some(auth),
));
let manifest = if name == OPENAI_CURATED_MARKETPLACE_NAME {
root.join(".agents/plugins/marketplace.json")
} else {
curated_plugins_api_marketplace_path(home.path())
};
for (config, permitted) in [(&allowed, true), (&blocked, false), (&host_allowed, true)] {
let expected = if permitted {
vec![format!("sample@{name}")]
} else {
Vec::new()
};
assert_eq!(loaded_plugin_names(&manager, config).await, expected);
let catalogs = manager
.list_marketplaces_for_config(config, &[], /*include_openai_curated*/ true)
.unwrap();
assert_eq!(
catalogs
.marketplaces
.iter()
.map(|catalog| catalog.name.as_str())
.collect::<Vec<_>>(),
if permitted { vec![name] } else { Vec::new() }
);
assert_eq!(
manager
.install_plugin(
&config.config_layer_stack,
PluginInstallRequest {
plugin_name: "sample".to_string(),
marketplace_path: AbsolutePathBuf::try_from(manifest.clone()).unwrap(),
}
)
.await
.is_ok(),
permitted
);
}
manager.maybe_start_curated_repo_sync_for_config(
&blocked, /*on_effective_plugins_changed*/ None,
);
assert!(!CURATED_REPO_SYNC_STARTED.load(std::sync::atomic::Ordering::SeqCst));
}
}
#[tokio::test]
async fn blocking_curated_git_does_not_block_remote_installed_plugins() {
let home = TempDir::new().unwrap();
write_cached_plugin(home.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear");
let manager = test_plugins_manager_with_options(
home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]);
let mut config = plugins_config_input_with_requirements(
home.path(),
"",
"[marketplaces]\nrestrict_to_allowed_sources = true\n",
);
config.remote_plugin_enabled = true;
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec!["linear@openai-curated-remote".to_string()]
);
}
@@ -0,0 +1,135 @@
//! Verifies managed curated Git restrictions through plugin and skill loading.
use std::fs;
use crate::PluginsConfigInput;
use crate::PluginsManager;
use crate::test_support::test_skill_root_loader;
use anyhow::Result;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_config::loader::load_config_layers_state;
use codex_exec_server::LOCAL_FS;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::CodexAuth;
use codex_login::test_support::auth_manager_from_optional_auth;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_skills::LoadedSkills;
use codex_skills::SkillMetadata;
use codex_skills::SkillRootLoadRequest;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[tokio::test]
async fn curated_git_requirements_control_plugin_skills() -> Result<()> {
for (name, allow_curated) in [
("openai-curated", true),
("openai-curated", false),
("openai-api-curated", true),
("openai-api-curated", false),
("openai-bundled", false),
] {
let home = TempDir::new()?;
let plugin_id = format!("sample@{name}");
let root = home
.path()
.join(format!("plugins/cache/{name}/sample/local"));
fs::create_dir_all(root.join(".codex-plugin"))?;
fs::write(
root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample","description":"inspect sample data"}"#,
)?;
let skill_dir = root.join("skills/sample-search");
fs::create_dir_all(&skill_dir)?;
let skill_path = skill_dir.join("SKILL.md");
fs::write(
&skill_path,
"---\ndescription: inspect sample data\n---\n\n# body\n",
)?;
let mut user_config = format!("[plugins.\"{plugin_id}\"]\nenabled = true\n");
if name == "openai-bundled" {
let bundled = home.path().join(".tmp/bundled-marketplaces/openai-bundled");
user_config.push_str(&format!(
"\n[marketplaces.openai-bundled]\nsource_type = \"local\"\nsource = {bundled:?}\n"
));
}
fs::write(home.path().join("config.toml"), user_config)?;
let requirements_path = home.path().join("requirements.toml");
let rule = if allow_curated {
"[marketplaces.allowed_sources.curated]\nsource = 'git'\nurl = 'https://github.com/openai/plugins.git'\n"
} else {
""
};
fs::write(
&requirements_path,
format!("[marketplaces]\nrestrict_to_allowed_sources = true\n{rule}"),
)?;
let home_path = AbsolutePathBuf::try_from(home.path())?;
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
home.path(),
Some(home_path),
&[],
LoaderOverrides {
system_requirements_path: Some(requirements_path),
..LoaderOverrides::without_managed_config_for_tests()
},
&NoopThreadConfigLoader,
)
.await?;
let config = PluginsConfigInput::new(
config_layer_stack,
"openai".to_string(),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ false,
"https://chatgpt.com/backend-api/".to_string(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
let auth = if name == "openai-curated" {
CodexAuth::create_dummy_chatgpt_auth_for_testing()
} else {
CodexAuth::from_api_key("test-api-key")
};
let skills = test_skill_root_loader();
let manager = PluginsManager::new(
home.path().to_path_buf(),
auth_manager_from_optional_auth(Some(auth)),
skills.clone(),
);
let plugins = manager.plugins_for_config(&config).await;
let loaded = skills
.load_roots(SkillRootLoadRequest {
roots: plugins.effective_plugin_skill_roots(),
restriction_product: Some(Product::Codex),
snapshots: manager.plugin_skill_snapshots_for_config(&config),
})
.await;
let expected = if allow_curated || name == "openai-bundled" {
LoadedSkills {
skills: vec![SkillMetadata {
name: "sample:sample-search".to_string(),
description: "inspect sample data".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: AbsolutePathBuf::try_from(fs::canonicalize(skill_path)?)?,
scope: SkillScope::User,
plugin_id: Some(plugin_id),
remote_plugin_id: None,
}],
errors: Vec::new(),
}
} else {
LoadedSkills::default()
};
assert_eq!(
loaded, expected,
"marketplace={name}, allow_curated={allow_curated}"
);
}
Ok(())
}
@@ -0,0 +1,108 @@
//! Checks the shared curated Git identity while preserving other managed marketplace exemptions.
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn curated_catalogs_apply_the_existing_git_source_policy() {
let home = TempDir::new().unwrap();
for (rule, allowed) in [
("", false),
(
"source = 'git'\nurl = 'https://github.com/openai/plugins.git'",
true,
),
(
"source = 'git'\nurl = 'https://github.com/openai/plugins'",
true,
),
("source = 'git'\nurl = 'openai/plugins'", true),
(
"source = 'git'\nurl = 'https://github.com/other/plugins.git'",
false,
),
(
"source = 'host_pattern'\nhost_pattern = '^github[.]com$'",
true,
),
(
"source = 'host_pattern'\nhost_pattern = '^git[.]example[.]com$'",
false,
),
(
"source = 'git'\nurl = 'https://github.com/openai/plugins.git'\nref = 'main'",
false,
),
(
"source = 'git'\nurl = 'openai/plugins'\n[marketplaces.allowed_sources.invalid]\nsource = 'host_pattern'\nhost_pattern = '('",
false,
),
] {
let rule = if rule.is_empty() {
String::new()
} else {
format!("[marketplaces.allowed_sources.curated]\n{rule}")
};
let stack = config_layer_stack(&format!(
"[marketplaces]\nrestrict_to_allowed_sources = true\n{rule}"
));
let policy = MarketplacePolicy::from_requirements(stack.requirements());
for (name, manifest) in [
(
OPENAI_CURATED_MARKETPLACE_NAME,
curated_plugins_repo_path(home.path()).join(".agents/plugins/marketplace.json"),
),
(
OPENAI_API_CURATED_MARKETPLACE_NAME,
curated_plugins_api_marketplace_path(home.path()),
),
] {
let manifest = AbsolutePathBuf::try_from(manifest).unwrap();
assert_eq!(
policy
.validate_install(&stack, home.path(), &manifest, name)
.is_ok(),
allowed
);
}
}
}
#[test]
fn a_local_allowlist_cannot_authorize_curated_but_other_managed_sources_remain_allowed() {
let home = TempDir::new().unwrap();
let root = curated_plugins_repo_path(home.path());
let stack = config_layer_stack(&format!(
"[marketplaces]\nrestrict_to_allowed_sources = true\n[marketplaces.allowed_sources.local]\nsource = 'local'\npath = {root:?}"
));
let policy = MarketplacePolicy::from_requirements(stack.requirements());
assert!(
policy
.validate_git_source(OPENAI_PLUGINS_GIT_URL, /*ref_name*/ None)
.is_err()
);
// Windows test sandboxes may omit USERPROFILE, leaving no primary runtime cache root.
let primary_runtime = primary_runtime_marketplace_root()
.map(|root| (OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME, root));
for (name, root) in [
(
OPENAI_BUNDLED_MARKETPLACE_NAME,
home.path().join(".tmp/bundled-marketplaces/openai-bundled"),
),
(
OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME,
home.path()
.join(".tmp/bundled-marketplaces/openai-bundled-alpha"),
),
]
.into_iter()
.chain(primary_runtime)
{
let manifest =
AbsolutePathBuf::try_from(root.join(".agents/plugins/marketplace.json")).unwrap();
assert_eq!(
policy.validate_install(&stack, home.path(), &manifest, name),
Ok(())
);
}
}
@@ -11,6 +11,9 @@ use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
#[path = "marketplace_policy/curated_tests.rs"]
mod curated;
fn config_layer_stack(requirements_toml: &str) -> ConfigLayerStack {
config_layer_stack_with_user_config(requirements_toml, /*user_config*/ None)
}
@@ -361,6 +364,9 @@ fn curated_marketplace_requires_its_expected_name() {
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.curated]
source = "git"
url = "https://github.com/openai/plugins.git"
"#,
);
let marketplace_path = AbsolutePathBuf::try_from(
+1 -1
View File
@@ -27,7 +27,7 @@ const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str =
"https://chatgpt.com/backend-api/plugins/export/curated";
const OPENAI_PLUGINS_OWNER: &str = "openai";
const OPENAI_PLUGINS_REPO: &str = "plugins";
const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git";
pub(crate) const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git";
const CURATED_PLUGINS_FETCH_REF: &str = "refs/codex/curated-sync";
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";