Move MCP policy types into codex-protocol (#39017)

## What changed

- Define MCP server identities, matchers, requirements, and per-plugin MCP requirements in the new `codex_protocol::mcp_policy` module.
- Keep requirement validation and matching against `McpServerConfig` in `codex-config`.
- Re-export the moved types from `codex-config` to preserve existing consumers.

GitOrigin-RevId: ad8a107e3085191241a5766f061dd004103e21e6
This commit is contained in:
sayan-oai
2026-08-17 16:20:52 +00:00
committed by copyberry
parent 21cfd369ef
commit 32a383c0ba
7 changed files with 184 additions and 167 deletions
+7 -16
View File
@@ -23,9 +23,11 @@ use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedAuthPolicy;
use crate::ManagedHooksRequirementsToml;
use crate::McpServerRequirement;
use crate::PluginRequirementsToml;
use crate::RequirementsExecPolicy;
use crate::config_toml::ConfigToml;
use crate::mcp_requirements::McpServerRequirement;
use crate::mcp_requirements::validate_mcp_server_requirement;
use crate::mcp_types::AppToolApproval;
use crate::permissions_toml::PermissionProfileToml;
use crate::types::FeedbackConfigToml;
@@ -286,11 +288,6 @@ impl ConfigRequirements {
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginRequirementsToml {
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MarketplaceRequirementsToml {
@@ -326,12 +323,6 @@ pub enum MarketplaceAllowedSourceKind {
Local,
}
impl PluginRequirementsToml {
pub fn is_empty(&self) -> bool {
self.mcp_servers.as_ref().is_none_or(BTreeMap::is_empty)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct NetworkDomainPermissionsToml {
#[serde(flatten)]
@@ -1516,15 +1507,15 @@ fn validate_mcp_server_requirements(
plugin_name: Option<&str>,
) -> Result<(), ConstraintError> {
for (server_name, requirement) in requirements {
requirement
.validate()
.map_err(|reason| ConstraintError::McpServerRequirementParse {
validate_mcp_server_requirement(requirement).map_err(|reason| {
ConstraintError::McpServerRequirementParse {
server_name: plugin_name
.map(|plugin_name| format!("{plugin_name}/{server_name}"))
.unwrap_or_else(|| server_name.clone()),
requirement_source: source.clone(),
reason,
})?;
}
})?;
}
Ok(())
}
+5 -5
View File
@@ -52,6 +52,11 @@ pub use codex_execpolicy::RequirementsExecPolicy;
pub use codex_protocol::config_types::ProfileV2Name;
pub use codex_protocol::config_types::ProfileV2NameParseError;
pub use codex_protocol::config_types::ToolExposureSurface;
pub use codex_protocol::mcp_policy::McpServerCommandMatcher;
pub use codex_protocol::mcp_policy::McpServerIdentity;
pub use codex_protocol::mcp_policy::McpServerRequirement;
pub use codex_protocol::mcp_policy::McpServerValueMatcher;
pub use codex_protocol::mcp_policy::PluginMcpRequirements as PluginRequirementsToml;
pub use codex_utils_absolute_path::AbsolutePathBuf;
pub use codex_utils_absolute_path::AbsolutePathBufGuard;
pub use config_layer_source::ConfigLayer;
@@ -83,7 +88,6 @@ pub use config_requirements::NetworkRequirementsToml;
pub use config_requirements::NetworkUnixSocketPermissionToml;
pub use config_requirements::NetworkUnixSocketPermissionsToml;
pub use config_requirements::NewThreadModelDefaultsToml;
pub use config_requirements::PluginRequirementsToml;
pub use config_requirements::RemoteSandboxConfigToml;
pub use config_requirements::RequirementSource;
pub use config_requirements::ResidencyRequirement;
@@ -121,10 +125,6 @@ pub use marketplace_edit::record_user_marketplace;
pub use marketplace_edit::remove_user_marketplace;
pub use marketplace_edit::remove_user_marketplace_config;
pub use mcp_edit::load_global_mcp_servers;
pub use mcp_requirements::McpServerCommandMatcher;
pub use mcp_requirements::McpServerIdentity;
pub use mcp_requirements::McpServerRequirement;
pub use mcp_requirements::McpServerValueMatcher;
pub use mcp_types::AppToolApproval;
pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
pub use mcp_types::McpServerAuth;
+47 -117
View File
@@ -1,131 +1,57 @@
use crate::McpServerIdentity;
use crate::McpServerRequirement;
use crate::McpServerValueMatcher;
use crate::mcp_types::McpServerConfig;
use crate::mcp_types::McpServerTransportConfig;
use regex_lite::Regex;
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum McpServerIdentity {
Command { command: String },
Url { url: String },
fn compile_full_regex(expression: &str) -> Result<Regex, String> {
Regex::new(&format!(r"\A(?:{expression})\z")).map_err(|err| {
format!("regex `{expression}` cannot be used for full-value matching: {err}")
})
}
/// String matching operations available to managed MCP server matchers.
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "match", rename_all = "snake_case", deny_unknown_fields)]
pub enum McpServerValueMatcher {
Exact { value: String },
Prefix { value: String },
Regex { expression: String },
fn validate_value_matcher(matcher: &McpServerValueMatcher) -> Result<(), String> {
let McpServerValueMatcher::Regex { expression } = matcher else {
return Ok(());
};
Regex::new(expression).map_err(|err| format!("invalid regex `{expression}`: {err}"))?;
compile_full_regex(expression).map(|_| ())
}
impl McpServerValueMatcher {
fn compile_full_regex(expression: &str) -> Result<Regex, String> {
Regex::new(&format!(r"\A(?:{expression})\z")).map_err(|err| {
format!("regex `{expression}` cannot be used for full-value matching: {err}")
})
}
fn validate(&self) -> Result<(), String> {
let Self::Regex { expression } = self else {
return Ok(());
};
Regex::new(expression).map_err(|err| format!("invalid regex `{expression}`: {err}"))?;
Self::compile_full_regex(expression).map(|_| ())
}
fn matches(&self, candidate: &str) -> bool {
match self {
Self::Exact { value } => candidate == value,
Self::Prefix { value } => candidate.starts_with(value),
Self::Regex { expression } => Self::compile_full_regex(expression)
.ok()
.is_some_and(|regex| regex.is_match(candidate)),
}
fn matches_value(matcher: &McpServerValueMatcher, candidate: &str) -> bool {
match matcher {
McpServerValueMatcher::Exact { value } => candidate == value,
McpServerValueMatcher::Prefix { value } => candidate.starts_with(value),
McpServerValueMatcher::Regex { expression } => compile_full_regex(expression)
.ok()
.is_some_and(|regex| regex.is_match(candidate)),
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpServerCommandMatcher {
pub executable: String,
pub args: Vec<McpServerValueMatcher>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct RawMcpServerCommandIdentity {
command: McpServerCommandMatcher,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct RawMcpServerUrlIdentity {
url: McpServerValueMatcher,
}
/// A requirement for one named MCP server.
///
/// The `Identity` variant preserves the released exact-match contract. The
/// command and URL variants are the normalized matcher-based forms accepted
/// under the `identity` key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpServerRequirement {
Identity { identity: McpServerIdentity },
Command(McpServerCommandMatcher),
Url(McpServerValueMatcher),
}
#[derive(Deserialize)]
struct RawMcpServerRequirement {
identity: RawMcpServerIdentity,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawMcpServerIdentity {
Exact(McpServerIdentity),
Command(RawMcpServerCommandIdentity),
Url(RawMcpServerUrlIdentity),
}
impl<'de> Deserialize<'de> for McpServerRequirement {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let RawMcpServerRequirement { identity } =
RawMcpServerRequirement::deserialize(deserializer)?;
match identity {
RawMcpServerIdentity::Exact(identity) => Ok(Self::Identity { identity }),
RawMcpServerIdentity::Command(matcher) => Ok(Self::Command(matcher.command)),
RawMcpServerIdentity::Url(matcher) => Ok(Self::Url(matcher.url)),
}
}
}
impl McpServerRequirement {
pub(crate) fn validate(&self) -> Result<(), String> {
match self {
Self::Identity { .. } => Ok(()),
Self::Command(matcher) => {
for (index, arg) in matcher.args.iter().enumerate() {
arg.validate().map_err(|err| {
format!("invalid argument matcher at index {index}: {err}")
})?;
}
Ok(())
pub(crate) fn validate_mcp_server_requirement(
requirement: &McpServerRequirement,
) -> Result<(), String> {
match requirement {
McpServerRequirement::Identity { .. } => Ok(()),
McpServerRequirement::Command(matcher) => {
for (index, arg) in matcher.args.iter().enumerate() {
validate_value_matcher(arg)
.map_err(|err| format!("invalid argument matcher at index {index}: {err}"))?;
}
Self::Url(matcher) => matcher.validate(),
Ok(())
}
McpServerRequirement::Url(matcher) => validate_value_matcher(matcher),
}
}
pub fn matches(&self, server: &McpServerConfig) -> bool {
impl McpServerConfig {
pub fn matches_requirement(&self, requirement: &McpServerRequirement) -> bool {
// HTTP requirements intentionally authorize the complete server configuration by URL.
match (self, &server.transport) {
match (requirement, &self.transport) {
(
Self::Identity {
McpServerRequirement::Identity {
identity:
McpServerIdentity::Command {
command: want_command,
@@ -137,23 +63,27 @@ impl McpServerRequirement {
},
) => got_command == want_command,
(
Self::Identity {
McpServerRequirement::Identity {
identity: McpServerIdentity::Url { url: want_url },
},
McpServerTransportConfig::StreamableHttp { url: got_url, .. },
) => got_url == want_url,
(Self::Command(matcher), McpServerTransportConfig::Stdio { command, args, .. }) => {
(
McpServerRequirement::Command(matcher),
McpServerTransportConfig::Stdio { command, args, .. },
) => {
matcher.executable == *command
&& matcher.args.len() == args.len()
&& matcher
.args
.iter()
.zip(args)
.all(|(matcher, arg)| matcher.matches(arg))
}
(Self::Url(matcher), McpServerTransportConfig::StreamableHttp { url, .. }) => {
matcher.matches(url)
.all(|(matcher, arg)| matches_value(matcher, arg))
}
(
McpServerRequirement::Url(matcher),
McpServerTransportConfig::StreamableHttp { url, .. },
) => matches_value(matcher, url),
_ => false,
}
}
+33 -27
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::McpServerCommandMatcher;
use crate::mcp_types::McpServerConfig;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
@@ -45,22 +46,28 @@ fn command_matcher_matches_exact_positional_arguments() {
],
});
assert!(requirement.matches(&stdio_server(
"company-cli",
&["mcp", "https://pricing.example.com"]
)));
assert!(!requirement.matches(&stdio_server(
"company-cli",
&["https://pricing.example.com", "mcp"]
)));
assert!(!requirement.matches(&stdio_server(
"company-cli",
&["mcp", "https://pricing.example.com", "--verbose"]
)));
assert!(!requirement.matches(&stdio_server(
"/usr/local/bin/company-cli",
&["mcp", "https://pricing.example.com"]
)));
assert!(
stdio_server("company-cli", &["mcp", "https://pricing.example.com"])
.matches_requirement(&requirement)
);
assert!(
!stdio_server("company-cli", &["https://pricing.example.com", "mcp"])
.matches_requirement(&requirement)
);
assert!(
!stdio_server(
"company-cli",
&["mcp", "https://pricing.example.com", "--verbose"]
)
.matches_requirement(&requirement)
);
assert!(
!stdio_server(
"/usr/local/bin/company-cli",
&["mcp", "https://pricing.example.com"]
)
.matches_requirement(&requirement)
);
}
#[test]
@@ -69,9 +76,9 @@ fn regex_matcher_requires_a_full_value_match() {
expression: "mcp".to_string(),
};
assert!(matcher.matches("mcp"));
assert!(!matcher.matches("mcp-proxy"));
assert!(!matcher.matches("prefix-mcp"));
assert!(matches_value(&matcher, "mcp"));
assert!(!matches_value(&matcher, "mcp-proxy"));
assert!(!matches_value(&matcher, "prefix-mcp"));
}
#[test]
@@ -80,7 +87,7 @@ fn regex_matcher_allows_a_later_alternative_to_match_the_full_value() {
expression: r"https://api\.example\.com|https://api\.example\.com/mcp".to_string(),
};
assert!(matcher.matches("https://api.example.com/mcp"));
assert!(matches_value(&matcher, "https://api.example.com/mcp"));
}
#[test]
@@ -89,8 +96,7 @@ fn regex_matcher_validation_rejects_expression_that_cannot_be_wrapped() {
expression: "(?x)mcp # trailing comment".to_string(),
};
let err = matcher
.validate()
let err = validate_value_matcher(&matcher)
.expect_err("expression should not be valid for full-value matching");
assert!(
err.contains("cannot be used for full-value matching"),
@@ -108,11 +114,11 @@ command = "company-cli"
)
.expect("legacy command identity");
assert!(requirement.matches(&stdio_server(
"company-cli",
&["any", "arguments", "remain", "allowed"]
)));
assert!(!requirement.matches(&stdio_server("different-cli", &[])));
assert!(
stdio_server("company-cli", &["any", "arguments", "remain", "allowed"])
.matches_requirement(&requirement)
);
assert!(!stdio_server("different-cli", &[]).matches_requirement(&requirement));
}
#[test]
+2 -2
View File
@@ -2018,7 +2018,7 @@ fn filter_mcp_servers_by_requirements(
let allowed = allowlist
.value
.get(name)
.is_some_and(|requirement| requirement.matches(server));
.is_some_and(|requirement| server.matches_requirement(requirement));
if allowed {
server.disabled_reason = None;
} else {
@@ -2054,7 +2054,7 @@ fn filter_plugin_mcp_servers_by_requirements(
for (name, server) in mcp_servers.iter_mut() {
let allowed = plugin_mcp_requirements
.and_then(|mcp_requirements| mcp_requirements.get(name))
.is_some_and(|requirement| requirement.matches(server));
.is_some_and(|requirement| server.matches_requirement(requirement));
if allowed {
server.disabled_reason = None;
} else {
+1
View File
@@ -24,6 +24,7 @@ mod legacy_events;
pub mod local_media;
pub mod mcp;
pub mod mcp_approval_meta;
pub mod mcp_policy;
pub mod memory_citation;
pub mod models;
pub mod network_policy;
+89
View File
@@ -0,0 +1,89 @@
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum McpServerIdentity {
Command { command: String },
Url { url: String },
}
/// String matching operations available to managed MCP server matchers.
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "match", rename_all = "snake_case", deny_unknown_fields)]
pub enum McpServerValueMatcher {
Exact { value: String },
Prefix { value: String },
Regex { expression: String },
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpServerCommandMatcher {
pub executable: String,
pub args: Vec<McpServerValueMatcher>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct RawMcpServerCommandIdentity {
command: McpServerCommandMatcher,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct RawMcpServerUrlIdentity {
url: McpServerValueMatcher,
}
/// A requirement for one named MCP server.
///
/// The `Identity` variant preserves the released exact-match contract. The
/// command and URL variants are the normalized matcher-based forms accepted
/// under the `identity` key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpServerRequirement {
Identity { identity: McpServerIdentity },
Command(McpServerCommandMatcher),
Url(McpServerValueMatcher),
}
#[derive(Deserialize)]
struct RawMcpServerRequirement {
identity: RawMcpServerIdentity,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawMcpServerIdentity {
Exact(McpServerIdentity),
Command(RawMcpServerCommandIdentity),
Url(RawMcpServerUrlIdentity),
}
impl<'de> Deserialize<'de> for McpServerRequirement {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let RawMcpServerRequirement { identity } =
RawMcpServerRequirement::deserialize(deserializer)?;
match identity {
RawMcpServerIdentity::Exact(identity) => Ok(Self::Identity { identity }),
RawMcpServerIdentity::Command(matcher) => Ok(Self::Command(matcher.command)),
RawMcpServerIdentity::Url(matcher) => Ok(Self::Url(matcher.url)),
}
}
}
/// Managed MCP server requirements for one plugin.
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginMcpRequirements {
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
}
impl PluginMcpRequirements {
pub fn is_empty(&self) -> bool {
self.mcp_servers.as_ref().is_none_or(BTreeMap::is_empty)
}
}