Compare commits

...

4 Commits

Author SHA1 Message Date
viyatb-oai
bf9d6b148b fix: match managed app tool approvals by raw name
Co-authored-by: Codex noreply@openai.com
2026-05-04 18:19:06 -07:00
viyatb-oai
aa60403203 docs: preserve managed app requirements comment context
Co-authored-by: Codex noreply@openai.com
2026-05-04 12:12:16 -07:00
viyatb-oai
03ae22edf6 test: cover lower-precedence app tool requirements
Co-authored-by: Codex noreply@openai.com
2026-05-04 12:07:53 -07:00
viyatb-oai
233a15294e feat: support managed app tool approval requirements
Co-authored-by: Codex noreply@openai.com
2026-05-04 11:59:00 -07:00
6 changed files with 448 additions and 23 deletions

View File

@@ -829,6 +829,7 @@ mod tests {
use super::*;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_config::AppToolApproval;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::auth::AgentIdentityAuth;
use codex_login::auth::AgentIdentityAuthRecord;
@@ -1399,6 +1400,40 @@ enabled = false
"connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(),
codex_config::AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
..Default::default()
})
);
}
#[tokio::test]
async fn fetch_cloud_requirements_parses_apps_tool_requirements_toml() {
let result = parse_for_fetch(Some(
r#"
[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa.tools."calendar/list_events"]
approval_mode = "approve"
"#,
));
assert_eq!(
result,
Some(ConfigRequirementsToml {
apps: Some(codex_config::AppsRequirementsToml {
apps: BTreeMap::from([(
"connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(),
codex_config::AppRequirementToml {
enabled: None,
tools: Some(codex_config::AppToolsRequirementsToml {
tools: BTreeMap::from([(
"calendar/list_events".to_string(),
codex_config::AppToolRequirementToml {
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}),
},
)]),
}),

View File

@@ -18,6 +18,7 @@ use super::requirements_exec_policy::RequirementsExecPolicyToml;
use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedHooksRequirementsToml;
use crate::mcp_types::AppToolApproval;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequirementSource {
@@ -597,9 +598,43 @@ impl FeatureRequirementsToml {
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct AppToolRequirementToml {
pub approval_mode: Option<AppToolApproval>,
}
impl AppToolRequirementToml {
pub fn is_empty(&self) -> bool {
self.approval_mode.is_none()
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct AppToolsRequirementsToml {
#[serde(default, flatten)]
pub tools: BTreeMap<String, AppToolRequirementToml>,
}
impl AppToolsRequirementsToml {
pub fn is_empty(&self) -> bool {
self.tools.values().all(AppToolRequirementToml::is_empty)
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct AppRequirementToml {
pub enabled: Option<bool>,
pub tools: Option<AppToolsRequirementsToml>,
}
impl AppRequirementToml {
pub fn is_empty(&self) -> bool {
self.enabled.is_none()
&& self
.tools
.as_ref()
.is_none_or(AppToolsRequirementsToml::is_empty)
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
@@ -610,14 +645,14 @@ pub struct AppsRequirementsToml {
impl AppsRequirementsToml {
pub fn is_empty(&self) -> bool {
self.apps.values().all(|app| app.enabled.is_none())
self.apps.values().all(AppRequirementToml::is_empty)
}
}
/// Merge `enabled` configs from a lower-precedence source into an existing higher-precedence set.
/// This lets managed sources (for example Cloud/MDM) enforce setting disablement across layers.
/// Implemented with AppsRequirementsToml for now, could be abstracted if we have more enablement-style configs in the future.
pub(crate) fn merge_enablement_settings_descending(
/// Merge app requirements from a lower-precedence source into an existing higher-precedence set.
/// This lets managed sources (for example Cloud/MDM) enforce setting disablement across layers,
/// while exact tool approval settings keep the higher-precedence value when present.
pub(crate) fn merge_app_requirements_descending(
base: &mut AppsRequirementsToml,
incoming: AppsRequirementsToml,
) {
@@ -631,6 +666,17 @@ pub(crate) fn merge_enablement_settings_descending(
} else {
higher_precedence.or(lower_precedence)
};
let Some(incoming_tools) = incoming_requirement.tools else {
continue;
};
let base_tools = base_requirement.tools.get_or_insert_with(Default::default);
for (tool_name, incoming_tool) in incoming_tools.tools {
let base_tool = base_tools.tools.entry(tool_name).or_default();
if base_tool.approval_mode.is_none() {
base_tool.approval_mode = incoming_tool.approval_mode;
}
}
}
}
@@ -769,7 +815,7 @@ impl ConfigRequirementsWithSources {
if let Some(incoming_apps) = other.apps.take() {
if let Some(existing_apps) = self.apps.as_mut() {
merge_enablement_settings_descending(&mut existing_apps.value, incoming_apps);
merge_app_requirements_descending(&mut existing_apps.value, incoming_apps);
} else {
self.apps = Some(Sourced::new(incoming_apps, source));
}
@@ -1583,6 +1629,37 @@ allowed_approvals_reviewers = ["user"]
"connector_123123".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
})
);
Ok(())
}
#[test]
fn deserialize_apps_tool_requirements() -> Result<()> {
let toml_str = r#"
[apps.connector_123123.tools."calendar/list_events"]
approval_mode = "approve"
"#;
let requirements: ConfigRequirementsToml = from_str(toml_str)?;
assert_eq!(
requirements.apps,
Some(AppsRequirementsToml {
apps: BTreeMap::from([(
"connector_123123".to_string(),
AppRequirementToml {
enabled: None,
tools: Some(AppToolsRequirementsToml {
tools: BTreeMap::from([(
"calendar/list_events".to_string(),
AppToolRequirementToml {
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}),
},
)]),
})
@@ -1597,19 +1674,45 @@ allowed_approvals_reviewers = ["user"]
.map(|(app_id, enabled)| {
(
(*app_id).to_string(),
AppRequirementToml { enabled: *enabled },
AppRequirementToml {
enabled: *enabled,
tools: None,
},
)
})
.collect(),
}
}
fn app_tool_requirements(
app_id: &str,
tool_name: &str,
approval_mode: AppToolApproval,
) -> AppsRequirementsToml {
AppsRequirementsToml {
apps: BTreeMap::from([(
app_id.to_string(),
AppRequirementToml {
enabled: None,
tools: Some(AppToolsRequirementsToml {
tools: BTreeMap::from([(
tool_name.to_string(),
AppToolRequirementToml {
approval_mode: Some(approval_mode),
},
)]),
}),
},
)]),
}
}
#[test]
fn merge_enablement_settings_descending_unions_distinct_apps() {
fn merge_app_requirements_descending_unions_distinct_apps() {
let mut merged = apps_requirements(&[("connector_high", Some(false))]);
let lower = apps_requirements(&[("connector_low", Some(true))]);
merge_enablement_settings_descending(&mut merged, lower);
merge_app_requirements_descending(&mut merged, lower);
assert_eq!(
merged,
@@ -1621,11 +1724,11 @@ allowed_approvals_reviewers = ["user"]
}
#[test]
fn merge_enablement_settings_descending_prefers_false_from_lower_precedence() {
fn merge_app_requirements_descending_prefers_false_from_lower_precedence() {
let mut merged = apps_requirements(&[("connector_123123", Some(true))]);
let lower = apps_requirements(&[("connector_123123", Some(false))]);
merge_enablement_settings_descending(&mut merged, lower);
merge_app_requirements_descending(&mut merged, lower);
assert_eq!(
merged,
@@ -1634,11 +1737,11 @@ allowed_approvals_reviewers = ["user"]
}
#[test]
fn merge_enablement_settings_descending_keeps_higher_true_when_lower_is_unset() {
fn merge_app_requirements_descending_keeps_higher_true_when_lower_is_unset() {
let mut merged = apps_requirements(&[("connector_123123", Some(true))]);
let lower = apps_requirements(&[("connector_123123", None)]);
merge_enablement_settings_descending(&mut merged, lower);
merge_app_requirements_descending(&mut merged, lower);
assert_eq!(
merged,
@@ -1647,11 +1750,11 @@ allowed_approvals_reviewers = ["user"]
}
#[test]
fn merge_enablement_settings_descending_uses_lower_value_when_higher_missing() {
fn merge_app_requirements_descending_uses_lower_value_when_higher_missing() {
let mut merged = apps_requirements(&[]);
let lower = apps_requirements(&[("connector_123123", Some(true))]);
merge_enablement_settings_descending(&mut merged, lower);
merge_app_requirements_descending(&mut merged, lower);
assert_eq!(
merged,
@@ -1660,11 +1763,11 @@ allowed_approvals_reviewers = ["user"]
}
#[test]
fn merge_enablement_settings_descending_preserves_higher_false_when_lower_missing_app() {
fn merge_app_requirements_descending_preserves_higher_false_when_lower_missing_app() {
let mut merged = apps_requirements(&[("connector_123123", Some(false))]);
let lower = apps_requirements(&[]);
merge_enablement_settings_descending(&mut merged, lower);
merge_app_requirements_descending(&mut merged, lower);
assert_eq!(
merged,
@@ -1672,6 +1775,52 @@ allowed_approvals_reviewers = ["user"]
);
}
#[test]
fn merge_app_requirements_descending_preserves_higher_tool_approval_mode() {
let mut merged = app_tool_requirements(
"connector_123123",
"calendar/list_events",
AppToolApproval::Approve,
);
let lower = app_tool_requirements(
"connector_123123",
"calendar/list_events",
AppToolApproval::Prompt,
);
merge_app_requirements_descending(&mut merged, lower);
assert_eq!(
merged,
app_tool_requirements(
"connector_123123",
"calendar/list_events",
AppToolApproval::Approve,
)
);
}
#[test]
fn merge_app_requirements_descending_uses_lower_tool_approval_when_higher_missing() {
let mut merged = apps_requirements(&[("connector_123123", None)]);
let lower = app_tool_requirements(
"connector_123123",
"calendar/list_events",
AppToolApproval::Approve,
);
merge_app_requirements_descending(&mut merged, lower);
assert_eq!(
merged,
app_tool_requirements(
"connector_123123",
"calendar/list_events",
AppToolApproval::Approve,
)
);
}
#[test]
fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() {
let higher_source = RequirementSource::CloudRequirements;

View File

@@ -33,6 +33,8 @@ pub use cloud_requirements::CloudRequirementsLoader;
pub use codex_app_server_protocol::ConfigLayerSource;
pub use codex_utils_absolute_path::AbsolutePathBuf;
pub use config_requirements::AppRequirementToml;
pub use config_requirements::AppToolRequirementToml;
pub use config_requirements::AppToolsRequirementsToml;
pub use config_requirements::AppsRequirementsToml;
pub use config_requirements::ConfigRequirements;
pub use config_requirements::ConfigRequirementsToml;

View File

@@ -578,13 +578,21 @@ pub(crate) fn app_tool_policy(
annotations: Option<&ToolAnnotations>,
) -> AppToolPolicy {
let apps_config = read_apps_config(config);
app_tool_policy_from_apps_config(
let mut policy = app_tool_policy_from_apps_config(
apps_config.as_ref(),
connector_id,
tool_name,
tool_title,
annotations,
)
);
if let Some(approval) = managed_app_tool_approval(
config.config_layer_stack.requirements_toml().apps.as_ref(),
connector_id,
tool_name,
) {
policy.approval = approval;
}
policy
}
pub(crate) fn codex_app_tool_is_enabled(config: &Config, tool_info: &ToolInfo) -> bool {
@@ -636,14 +644,29 @@ fn apply_requirements_apps_constraints(
};
for (app_id, requirement) in &requirements_apps_config.apps {
if requirement.enabled != Some(false) {
continue;
if requirement.enabled == Some(false) {
let app = apps_config.apps.entry(app_id.clone()).or_default();
app.enabled = false;
}
let app = apps_config.apps.entry(app_id.clone()).or_default();
app.enabled = false;
}
}
fn managed_app_tool_approval(
requirements_apps_config: Option<&AppsRequirementsToml>,
connector_id: Option<&str>,
tool_name: &str,
) -> Option<AppToolApproval> {
let connector_id = connector_id?;
requirements_apps_config?
.apps
.get(connector_id)?
.tools
.as_ref()?
.tools
.get(tool_name)?
.approval_mode
}
fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> bool {
let default_enabled = apps_config
.default

View File

@@ -2,6 +2,8 @@ use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use codex_config::AppRequirementToml;
use codex_config::AppToolRequirementToml;
use codex_config::AppToolsRequirementsToml;
use codex_config::AppsRequirementsToml;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigLayerStack;
@@ -503,6 +505,7 @@ fn requirements_disabled_connector_overrides_enabled_connector() {
"connector_123123".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
};
@@ -535,6 +538,7 @@ fn requirements_enabled_does_not_override_disabled_connector() {
"connector_123123".to_string(),
AppRequirementToml {
enabled: Some(true),
tools: None,
},
)]),
};
@@ -568,6 +572,7 @@ enabled = true
"connector_123123".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
@@ -611,6 +616,7 @@ async fn cloud_requirements_disable_connector_applies_without_user_apps_table()
"connector_123123".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
@@ -661,6 +667,7 @@ async fn local_requirements_disable_connector_overrides_user_apps_config() {
"connector_123123".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
@@ -712,6 +719,7 @@ async fn local_requirements_disable_connector_applies_without_user_apps_table()
"connector_123123".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
@@ -753,6 +761,7 @@ async fn with_app_enabled_state_preserves_unrelated_disabled_connector() {
"connector_drive".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
@@ -804,6 +813,211 @@ fn app_tool_policy_honors_default_app_enabled_false() {
);
}
#[test]
fn managed_app_tool_approval_uses_raw_tool_name() {
let requirements_apps = AppsRequirementsToml {
apps: BTreeMap::from([(
"connector_123123".to_string(),
AppRequirementToml {
enabled: None,
tools: Some(AppToolsRequirementsToml {
tools: BTreeMap::from([(
"calendar/list_events".to_string(),
AppToolRequirementToml {
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}),
},
)]),
};
assert_eq!(
managed_app_tool_approval(
Some(&requirements_apps),
Some("connector_123123"),
"calendar/list_events",
),
Some(AppToolApproval::Approve)
);
assert_eq!(
managed_app_tool_approval(
Some(&requirements_apps),
Some("connector_123123"),
"calendar/create_event",
),
None
);
}
#[tokio::test]
async fn cloud_requirements_tool_approval_overrides_user_apps_config() {
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[apps.connector_123123.tools."calendar/list_events"]
approval_mode = "prompt"
"#,
)
.expect("write config");
let requirements = ConfigRequirementsToml {
apps: Some(AppsRequirementsToml {
apps: BTreeMap::from([(
"connector_123123".to_string(),
AppRequirementToml {
enabled: None,
tools: Some(AppToolsRequirementsToml {
tools: BTreeMap::from([(
"calendar/list_events".to_string(),
AppToolRequirementToml {
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}),
},
)]),
}),
..Default::default()
};
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async move {
Ok(Some(requirements))
}))
.build()
.await
.expect("config should build");
let policy = app_tool_policy(
&config,
Some("connector_123123"),
"calendar/list_events",
/*tool_title*/ None,
/*annotations*/ None,
);
assert_eq!(
policy,
AppToolPolicy {
enabled: true,
approval: AppToolApproval::Approve,
}
);
}
#[tokio::test]
async fn local_requirements_tool_approval_overrides_user_apps_config() {
let codex_home = tempdir().expect("tempdir should succeed");
let config_toml_path =
AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE)).expect("abs path");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect("config should build");
let requirements = ConfigRequirementsToml {
apps: Some(AppsRequirementsToml {
apps: BTreeMap::from([(
"connector_123123".to_string(),
AppRequirementToml {
enabled: None,
tools: Some(AppToolsRequirementsToml {
tools: BTreeMap::from([(
"calendar/list_events".to_string(),
AppToolRequirementToml {
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}),
},
)]),
}),
..Default::default()
};
config.config_layer_stack =
ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements)
.expect("requirements stack")
.with_user_config(
&config_toml_path,
toml::from_str::<toml::Value>(
r#"
[apps.connector_123123.tools."calendar/list_events"]
approval_mode = "prompt"
"#,
)
.expect("apps config"),
);
let policy = app_tool_policy(
&config,
Some("connector_123123"),
"calendar/list_events",
/*tool_title*/ None,
/*annotations*/ None,
);
assert_eq!(
policy,
AppToolPolicy {
enabled: true,
approval: AppToolApproval::Approve,
}
);
}
#[tokio::test]
async fn local_requirements_tool_approval_does_not_match_tool_title() {
let codex_home = tempdir().expect("tempdir should succeed");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect("config should build");
let requirements = ConfigRequirementsToml {
apps: Some(AppsRequirementsToml {
apps: BTreeMap::from([(
"connector_123123".to_string(),
AppRequirementToml {
enabled: None,
tools: Some(AppToolsRequirementsToml {
tools: BTreeMap::from([(
"calendar/list_events".to_string(),
AppToolRequirementToml {
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}),
},
)]),
}),
..Default::default()
};
config.config_layer_stack =
ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements)
.expect("requirements stack");
let policy = app_tool_policy(
&config,
Some("connector_123123"),
"calendar/create_event",
Some("calendar/list_events"),
/*annotations*/ None,
);
assert_eq!(
policy,
AppToolPolicy {
enabled: true,
approval: AppToolApproval::Auto,
}
);
}
#[test]
fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() {
let apps_config = AppsConfigToml {

View File

@@ -1916,6 +1916,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove
"connector_1".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
@@ -1989,6 +1990,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_
"connector_1".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),