mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
committed by
copyberry
parent
55e5158e18
commit
633ab199cf
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user