mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Enforce Agent Plugin runtime boundaries (#37027)
## What changed - Track Agent Plugin manifests through plugin, skill, and MCP loading so their capabilities use format-specific behavior without changing legacy plugins. - Discover only direct-child skills, exclude app and hook capabilities, isolate MCP data, and reject MCP configuration files that are non-regular or resolve outside the plugin root. - Bound model-visible skill instructions, plugin instructions, MCP descriptions, schemas, individual tools, and the aggregate Agent Plugin MCP tool set. - Stop MCP and OAuth redirects when Agent Plugins send configured or authorization headers, while retaining existing redirect behavior for legacy MCP servers. ## Testing - Add coverage for capability filtering, skill discovery, isolated MCP data and reserved-path expansion, unsafe MCP configuration files, context limits, and redirect handling. GitOrigin-RevId: c9af66b051269f3226628ca280a58d32c808c38f
This commit is contained in:
1 parent
f21dc46388
commit
56b82e676c
63 files changed
+1830
-106
No files matched your search
@@ -3,10 +3,11 @@ use crate::app_mcp_routing::apps_route_available;
|
||||
use crate::command_migration::migrated_command_skills_root;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::manifest::PluginManifestFormat;
|
||||
use crate::manifest::PluginManifestHooks;
|
||||
use crate::manifest::PluginManifestMcpServers;
|
||||
use crate::manifest::PluginManifestPaths;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::manifest::load_plugin_manifest_with_format;
|
||||
use crate::marketplace::MarketplacePluginSource;
|
||||
use crate::marketplace::find_marketplace_plugin;
|
||||
use crate::marketplace::list_marketplaces_with_home;
|
||||
@@ -23,6 +24,7 @@ use crate::store::plugin_version_for_source_with_fallback_manifest;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::HooksFile;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_config::types::PluginMcpServerConfig;
|
||||
use codex_connectors::parse_plugin_app_config;
|
||||
@@ -33,6 +35,7 @@ use codex_core_skills::config_rules::skill_config_rules_from_stack;
|
||||
use codex_core_skills::loader::SkillRoot;
|
||||
use codex_core_skills::loader::load_skills_from_roots;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_mcp::parse_agent_plugin_mcp_config;
|
||||
use codex_mcp::parse_plugin_mcp_config;
|
||||
use codex_plugin::AppDeclaration;
|
||||
use codex_plugin::LoadedPlugin;
|
||||
@@ -831,6 +834,7 @@ async fn load_plugin(
|
||||
root,
|
||||
enabled: plugin.enabled,
|
||||
skill_roots: Vec::new(),
|
||||
skill_discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
@@ -873,12 +877,19 @@ async fn load_plugin(
|
||||
return loaded_plugin;
|
||||
}
|
||||
|
||||
let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else {
|
||||
let Some(loaded_manifest) = load_plugin_manifest_with_format(plugin_root.as_path()) else {
|
||||
loaded_plugin.error = Some("missing or invalid plugin.json".to_string());
|
||||
return loaded_plugin;
|
||||
};
|
||||
loaded_plugin.skill_discovery_mode = match loaded_manifest.format {
|
||||
PluginManifestFormat::Legacy => SkillDiscoveryMode::Recursive,
|
||||
PluginManifestFormat::AgentPlugin => SkillDiscoveryMode::DirectChildren,
|
||||
};
|
||||
let manifest = loaded_manifest.manifest;
|
||||
|
||||
let manifest_paths = &manifest.paths;
|
||||
let plugin_data_root = store.plugin_data_root(&loaded_plugin_id);
|
||||
let mcp_plugin_data_root = store.mcp_data_root(&loaded_plugin_id, loaded_manifest.format);
|
||||
loaded_plugin.plugin_namespace = Some(manifest.name.clone());
|
||||
match scope {
|
||||
PluginLoadScope::AllCapabilities {
|
||||
@@ -890,40 +901,51 @@ async fn load_plugin(
|
||||
} => {
|
||||
loaded_plugin.manifest_name = Some(manifest.display_name().to_string());
|
||||
loaded_plugin.manifest_description = manifest.description.clone();
|
||||
loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths);
|
||||
loaded_plugin.skill_roots =
|
||||
plugin_skill_roots(&plugin_root, manifest_paths, loaded_manifest.format);
|
||||
let plugin_identity = PluginIdentity {
|
||||
plugin_id: loaded_plugin_id.as_key(),
|
||||
remote_plugin_id: loaded_plugin.remote_plugin_id.clone(),
|
||||
};
|
||||
let resolved_skills = load_plugin_skills_with_identity(
|
||||
let resolved_skills = load_plugin_skill_inventory(
|
||||
&plugin_root,
|
||||
&plugin_identity,
|
||||
&manifest,
|
||||
loaded_manifest.format,
|
||||
*restriction_product,
|
||||
skill_config_rules,
|
||||
*plugin_skill_snapshots,
|
||||
Arc::clone(root_scan_slots),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.resolve(skill_config_rules);
|
||||
let has_enabled_skills = resolved_skills.has_enabled_skills();
|
||||
loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths;
|
||||
loaded_plugin.has_enabled_skills = has_enabled_skills;
|
||||
loaded_plugin.mcp_servers = load_plugin_mcp_servers_from_manifest(
|
||||
loaded_plugin.mcp_servers = load_plugin_mcp_servers_from_manifest_with_format(
|
||||
plugin_root.as_path(),
|
||||
manifest_paths,
|
||||
Some(&plugin.mcp_servers),
|
||||
Some(mcp_plugin_data_root.as_path()),
|
||||
loaded_manifest.format,
|
||||
)
|
||||
.await;
|
||||
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
|
||||
if loaded_manifest.format == PluginManifestFormat::Legacy {
|
||||
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
|
||||
}
|
||||
}
|
||||
PluginLoadScope::HooksOnly => {}
|
||||
}
|
||||
let (hook_sources, hook_load_warnings) = load_plugin_hooks(
|
||||
&plugin_root,
|
||||
&loaded_plugin_id,
|
||||
&store.plugin_data_root(&loaded_plugin_id),
|
||||
manifest_paths,
|
||||
);
|
||||
let (hook_sources, hook_load_warnings) =
|
||||
if loaded_manifest.format == PluginManifestFormat::AgentPlugin {
|
||||
(Vec::new(), Vec::new())
|
||||
} else {
|
||||
load_plugin_hooks(
|
||||
&plugin_root,
|
||||
&loaded_plugin_id,
|
||||
&plugin_data_root,
|
||||
manifest_paths,
|
||||
)
|
||||
};
|
||||
loaded_plugin.hook_sources = hook_sources;
|
||||
loaded_plugin.hook_load_warnings = hook_load_warnings;
|
||||
loaded_plugin
|
||||
@@ -961,7 +983,7 @@ impl PluginSkillInventory {
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve(self, skill_config_rules: &SkillConfigRules) -> ResolvedPluginSkills {
|
||||
pub(crate) fn resolve(self, skill_config_rules: &SkillConfigRules) -> ResolvedPluginSkills {
|
||||
let disabled_skill_paths = resolve_disabled_skill_paths(&self.skills, skill_config_rules);
|
||||
ResolvedPluginSkills {
|
||||
skills: self.skills,
|
||||
@@ -1031,6 +1053,7 @@ pub(crate) async fn load_plugin_skills_with_identity(
|
||||
plugin_root,
|
||||
plugin_identity,
|
||||
manifest,
|
||||
PluginManifestFormat::Legacy,
|
||||
restriction_product,
|
||||
plugin_skill_snapshots,
|
||||
root_scan_slots,
|
||||
@@ -1043,11 +1066,16 @@ pub(crate) async fn load_plugin_skill_inventory(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
plugin_identity: &PluginIdentity,
|
||||
manifest: &PluginManifest,
|
||||
manifest_format: PluginManifestFormat,
|
||||
restriction_product: Option<Product>,
|
||||
plugin_skill_snapshots: Option<&PluginSkillSnapshots>,
|
||||
root_scan_slots: Arc<Semaphore>,
|
||||
) -> PluginSkillInventory {
|
||||
let roots = plugin_skill_roots(plugin_root, &manifest.paths)
|
||||
let discovery_mode = match manifest_format {
|
||||
PluginManifestFormat::Legacy => SkillDiscoveryMode::Recursive,
|
||||
PluginManifestFormat::AgentPlugin => SkillDiscoveryMode::DirectChildren,
|
||||
};
|
||||
let roots = plugin_skill_roots(plugin_root, &manifest.paths, manifest_format)
|
||||
.into_iter()
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
@@ -1056,7 +1084,7 @@ pub(crate) async fn load_plugin_skill_inventory(
|
||||
plugin_identity: Some(plugin_identity.clone()),
|
||||
plugin_namespace: Some(manifest.name.clone()),
|
||||
plugin_root: Some(plugin_root.clone()),
|
||||
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
discovery_mode,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let outcome = load_skills_from_roots(roots, plugin_skill_snapshots, root_scan_slots).await;
|
||||
@@ -1098,15 +1126,18 @@ pub(crate) async fn load_plugin_skill_inventory(
|
||||
fn plugin_skill_roots(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
manifest_format: PluginManifestFormat,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = if manifest_paths.skills.is_empty() {
|
||||
default_skill_roots(plugin_root)
|
||||
} else {
|
||||
manifest_paths.skills.clone()
|
||||
};
|
||||
let migrated_command_skills = migrated_command_skills_root(plugin_root);
|
||||
if migrated_command_skills.is_dir() {
|
||||
paths.push(migrated_command_skills);
|
||||
if manifest_format == PluginManifestFormat::Legacy {
|
||||
let migrated_command_skills = migrated_command_skills_root(plugin_root);
|
||||
if migrated_command_skills.is_dir() {
|
||||
paths.push(migrated_command_skills);
|
||||
}
|
||||
}
|
||||
paths.sort_unstable();
|
||||
paths.dedup();
|
||||
@@ -1146,8 +1177,11 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
||||
}
|
||||
|
||||
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppDeclaration> {
|
||||
if let Some(manifest) = load_plugin_manifest(plugin_root) {
|
||||
return load_plugin_apps_from_manifest(plugin_root, &manifest.paths).await;
|
||||
if let Some(loaded_manifest) = load_plugin_manifest_with_format(plugin_root) {
|
||||
if loaded_manifest.format == PluginManifestFormat::AgentPlugin {
|
||||
return Vec::new();
|
||||
}
|
||||
return load_plugin_apps_from_manifest(plugin_root, &loaded_manifest.manifest.paths).await;
|
||||
}
|
||||
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await
|
||||
}
|
||||
@@ -1345,14 +1379,40 @@ pub async fn plugin_capability_summary_from_root(
|
||||
plugin_id: &PluginId,
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
) -> Option<PluginCapabilitySummary> {
|
||||
let manifest = load_plugin_manifest(plugin_root.as_path())?;
|
||||
let loaded_manifest = load_plugin_manifest_with_format(plugin_root.as_path())?;
|
||||
let manifest_format = loaded_manifest.format;
|
||||
let manifest = loaded_manifest.manifest;
|
||||
let plugin_identity = PluginIdentity {
|
||||
plugin_id: plugin_id.as_key(),
|
||||
remote_plugin_id: None,
|
||||
};
|
||||
|
||||
let manifest_paths = &manifest.paths;
|
||||
let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty();
|
||||
let mut mcp_server_names = load_plugin_mcp_servers_from_manifest(
|
||||
let has_skills = match manifest_format {
|
||||
PluginManifestFormat::Legacy => {
|
||||
!plugin_skill_roots(plugin_root, manifest_paths, manifest_format).is_empty()
|
||||
}
|
||||
PluginManifestFormat::AgentPlugin => {
|
||||
!load_plugin_skill_inventory(
|
||||
plugin_root,
|
||||
&plugin_identity,
|
||||
&manifest,
|
||||
manifest_format,
|
||||
/*restriction_product*/ None,
|
||||
/*plugin_skill_snapshots*/ None,
|
||||
Arc::new(Semaphore::new(1)),
|
||||
)
|
||||
.await
|
||||
.skills
|
||||
.is_empty()
|
||||
}
|
||||
};
|
||||
let mut mcp_server_names = load_plugin_mcp_servers_from_manifest_with_format(
|
||||
plugin_root.as_path(),
|
||||
manifest_paths,
|
||||
/*plugin_policy*/ None,
|
||||
/*plugin_data_root*/ None,
|
||||
manifest_format,
|
||||
)
|
||||
.await
|
||||
.into_keys()
|
||||
@@ -1360,16 +1420,17 @@ pub async fn plugin_capability_summary_from_root(
|
||||
mcp_server_names.sort_unstable();
|
||||
mcp_server_names.dedup();
|
||||
|
||||
let app_declarations = load_apps_from_paths(
|
||||
plugin_root.as_path(),
|
||||
plugin_app_config_paths(plugin_root.as_path(), manifest_paths),
|
||||
)
|
||||
.await;
|
||||
let app_declarations = if manifest_format == PluginManifestFormat::AgentPlugin {
|
||||
Vec::new()
|
||||
} else {
|
||||
load_plugin_apps_from_manifest(plugin_root.as_path(), manifest_paths).await
|
||||
};
|
||||
let app_connector_ids = app_connector_ids_from_declarations(&app_declarations);
|
||||
|
||||
Some(PluginCapabilitySummary {
|
||||
config_name: plugin_id.as_key(),
|
||||
display_name: plugin_id.plugin_name.clone(),
|
||||
plugin_namespace: Some(manifest.name.clone()),
|
||||
description: None,
|
||||
has_skills,
|
||||
mcp_server_names,
|
||||
@@ -1426,17 +1487,26 @@ async fn load_declared_plugin_mcp_servers(
|
||||
plugin_root: &Path,
|
||||
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let Some(manifest) = load_plugin_manifest(plugin_root) else {
|
||||
let Some(loaded_manifest) = load_plugin_manifest_with_format(plugin_root) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
load_plugin_mcp_servers_from_manifest(plugin_root, &manifest.paths, plugin_policy).await
|
||||
load_plugin_mcp_servers_from_manifest_with_format(
|
||||
plugin_root,
|
||||
&loaded_manifest.manifest.paths,
|
||||
plugin_policy,
|
||||
/*plugin_data_root*/ None,
|
||||
loaded_manifest.format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn load_plugin_mcp_servers_from_manifest(
|
||||
pub(crate) async fn load_plugin_mcp_servers_from_manifest_with_format(
|
||||
plugin_root: &Path,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,
|
||||
plugin_data_root: Option<&Path>,
|
||||
manifest_format: PluginManifestFormat,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let mut mcp_servers = HashMap::new();
|
||||
match &manifest_paths.mcp_servers {
|
||||
@@ -1457,7 +1527,13 @@ pub(crate) async fn load_plugin_mcp_servers_from_manifest(
|
||||
}
|
||||
Some(PluginManifestMcpServers::Path(_)) | None => {
|
||||
for mcp_config_path in plugin_mcp_config_paths(plugin_root, manifest_paths) {
|
||||
let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await;
|
||||
let plugin_mcp = load_mcp_servers_from_file(
|
||||
plugin_root,
|
||||
plugin_data_root,
|
||||
manifest_format,
|
||||
&mcp_config_path,
|
||||
)
|
||||
.await;
|
||||
for (name, mut config) in plugin_mcp.mcp_servers {
|
||||
if let Some(policy) = plugin_policy.and_then(|policy| policy.get(&name)) {
|
||||
apply_plugin_mcp_server_policy(&mut config, policy);
|
||||
@@ -1480,12 +1556,74 @@ pub(crate) async fn load_plugin_mcp_servers_from_manifest(
|
||||
|
||||
async fn load_mcp_servers_from_file(
|
||||
plugin_root: &Path,
|
||||
plugin_data_root: Option<&Path>,
|
||||
manifest_format: PluginManifestFormat,
|
||||
mcp_config_path: &AbsolutePathBuf,
|
||||
) -> PluginMcpDiscovery {
|
||||
let is_agent_plugin_mcp = manifest_format == PluginManifestFormat::AgentPlugin;
|
||||
if is_agent_plugin_mcp {
|
||||
match tokio::fs::symlink_metadata(mcp_config_path.as_path()).await {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
|
||||
warn!(
|
||||
path = %mcp_config_path.display(),
|
||||
"Agent Plugins MCP config is not a regular file; disabling MCP"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %mcp_config_path.display(),
|
||||
"failed to inspect Agent Plugins MCP config; disabling MCP: {err}"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
}
|
||||
let resolved_root = match tokio::fs::canonicalize(plugin_root).await {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"failed to resolve Agent Plugins root; disabling MCP: {err}"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
};
|
||||
let resolved_config = match tokio::fs::canonicalize(mcp_config_path.as_path()).await {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %mcp_config_path.display(),
|
||||
"failed to resolve Agent Plugins MCP config; disabling MCP: {err}"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
};
|
||||
if !resolved_config.starts_with(&resolved_root) {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
path = %mcp_config_path.display(),
|
||||
"Agent Plugins MCP config resolves outside the plugin root; disabling MCP"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
}
|
||||
let Ok(contents) = tokio::fs::read_to_string(mcp_config_path.as_path()).await else {
|
||||
return PluginMcpDiscovery::default();
|
||||
};
|
||||
let parsed = match parse_plugin_mcp_config(plugin_root, &contents) {
|
||||
let fallback_data_root = plugin_root.join(".plugin-data");
|
||||
let mut parsed = match if is_agent_plugin_mcp {
|
||||
parse_agent_plugin_mcp_config(
|
||||
plugin_root,
|
||||
plugin_data_root.unwrap_or(&fallback_data_root),
|
||||
&contents,
|
||||
)
|
||||
} else {
|
||||
parse_plugin_mcp_config(plugin_root, &contents)
|
||||
} {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
@@ -1495,6 +1633,23 @@ async fn load_mcp_servers_from_file(
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
};
|
||||
if is_agent_plugin_mcp
|
||||
&& let Some(plugin_data_root) = plugin_data_root
|
||||
&& parsed
|
||||
.servers
|
||||
.values()
|
||||
.any(|server| matches!(&server.transport, McpServerTransportConfig::Stdio { .. }))
|
||||
&& let Err(err) = tokio::fs::create_dir_all(plugin_data_root).await
|
||||
{
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
path = %plugin_data_root.display(),
|
||||
"failed to create Agent Plugins data directory; disabling stdio MCP servers: {err}"
|
||||
);
|
||||
parsed.servers.retain(|_, server| {
|
||||
!matches!(&server.transport, McpServerTransportConfig::Stdio { .. })
|
||||
});
|
||||
}
|
||||
for error in parsed.errors {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
|
||||
Reference in new issue
Block a user