From 5185567b62c84c6a1404258535930a18eed0fa9c Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 11 Feb 2026 21:12:39 -0800 Subject: [PATCH] update --- .../schema/json/ServerNotification.json | 4 +- .../codex_app_server_protocol.schemas.json | 78 ++++- .../json/v2/AppListUpdatedNotification.json | 4 +- .../schema/json/v2/AppsListResponse.json | 4 +- .../schema/json/v2/ConfigReadResponse.json | 74 ++++- .../schema/typescript/v2/AppInfo.ts | 2 +- .../schema/typescript/v2/AppToolApproval.ts | 5 + .../schema/typescript/v2/AppToolConfig.ts | 7 + .../typescript/v2/AppToolDisabledReason.ts | 5 + .../schema/typescript/v2/AppsConfig.ts | 3 +- .../schema/typescript/v2/index.ts | 3 + .../app-server-protocol/src/protocol/v2.rs | 40 ++- .../app-server/tests/suite/v2/config_rpc.rs | 5 +- codex-rs/core/config.schema.json | 59 +++- codex-rs/core/src/codex.rs | 104 ++++++- codex-rs/core/src/config/service.rs | 5 +- codex-rs/core/src/config/types.rs | 119 +++++++- codex-rs/core/src/connectors.rs | 270 +++++++++++++++++- codex-rs/core/src/mcp_tool_call.rs | 56 +++- docs/config.md | 28 ++ 20 files changed, 828 insertions(+), 47 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppToolConfig.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppToolDisabledReason.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index e7d25486ce..4aaa4e9777 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -194,8 +194,7 @@ "type": "boolean" }, "isEnabled": { - "default": true, - "description": "Whether this app is enabled in config.toml. Example: ```toml [app.bad_app] enabled = false ```", + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, "logoUrl": { @@ -216,6 +215,7 @@ }, "required": [ "id", + "isEnabled", "name" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b3792cd4ca..53dfa3b642 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10071,6 +10071,18 @@ }, "AppConfig": { "properties": { + "disable_destructive": { + "type": [ + "boolean", + "null" + ] + }, + "disable_open_world": { + "type": [ + "boolean", + "null" + ] + }, "disabled_reason": { "anyOf": [ { @@ -10082,8 +10094,19 @@ ] }, "enabled": { - "default": true, - "type": "boolean" + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/v2/AppToolConfig" + }, + "type": [ + "object", + "null" + ] } }, "type": "object" @@ -10124,8 +10147,7 @@ "type": "boolean" }, "isEnabled": { - "default": true, - "description": "Whether this app is enabled in config.toml. Example: ```toml [app.bad_app] enabled = false ```", + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, "logoUrl": { @@ -10146,6 +10168,7 @@ }, "required": [ "id", + "isEnabled", "name" ], "type": "object" @@ -10167,6 +10190,53 @@ "title": "AppListUpdatedNotification", "type": "object" }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "disabled_reason": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolDisabledReason" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppToolDisabledReason": { + "enum": [ + "unknown", + "user", + "admin_policy" + ], + "type": "string" + }, "AppsConfig": { "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json index 4b6e3b53df..4474996a12 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json @@ -30,8 +30,7 @@ "type": "boolean" }, "isEnabled": { - "default": true, - "description": "Whether this app is enabled in config.toml. Example: ```toml [app.bad_app] enabled = false ```", + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, "logoUrl": { @@ -52,6 +51,7 @@ }, "required": [ "id", + "isEnabled", "name" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json index 8e302224f0..04042f01b5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json @@ -30,8 +30,7 @@ "type": "boolean" }, "isEnabled": { - "default": true, - "description": "Whether this app is enabled in config.toml. Example: ```toml [app.bad_app] enabled = false ```", + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", "type": "boolean" }, "logoUrl": { @@ -52,6 +51,7 @@ }, "required": [ "id", + "isEnabled", "name" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index d934df7f58..601f3eb25b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -19,6 +19,18 @@ }, "AppConfig": { "properties": { + "disable_destructive": { + "type": [ + "boolean", + "null" + ] + }, + "disable_open_world": { + "type": [ + "boolean", + "null" + ] + }, "disabled_reason": { "anyOf": [ { @@ -30,8 +42,19 @@ ] }, "enabled": { - "default": true, - "type": "boolean" + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/AppToolConfig" + }, + "type": [ + "object", + "null" + ] } }, "type": "object" @@ -43,6 +66,53 @@ ], "type": "string" }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "disabled_reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolDisabledReason" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppToolDisabledReason": { + "enum": [ + "unknown", + "user", + "admin_policy" + ], + "type": "string" + }, "AppsConfig": { "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts index 919954295f..2f65c18327 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts @@ -10,7 +10,7 @@ export type AppInfo = { id: string, name: string, description: string | null, lo * Whether this app is enabled in config.toml. * Example: * ```toml - * [app.bad_app] + * [apps.bad_app] * enabled = false * ``` */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts new file mode 100644 index 0000000000..e92cd8e28b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppToolApproval = "auto" | "prompt" | "approve"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolConfig.ts new file mode 100644 index 0000000000..88ad224457 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolConfig.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppToolApproval } from "./AppToolApproval"; +import type { AppToolDisabledReason } from "./AppToolDisabledReason"; + +export type AppToolConfig = { enabled: boolean | null, disabled_reason: AppToolDisabledReason | null, approval: AppToolApproval | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolDisabledReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolDisabledReason.ts new file mode 100644 index 0000000000..c0b4da97d9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolDisabledReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppToolDisabledReason = "unknown" | "user" | "admin_policy"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts index ae3e455201..77305ebef3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts @@ -2,5 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AppDisabledReason } from "./AppDisabledReason"; +import type { AppToolConfig } from "./AppToolConfig"; -export type AppsConfig = { [key in string]?: { enabled: boolean, disabled_reason: AppDisabledReason | null, } }; +export type AppsConfig = { [key in string]?: { enabled: boolean | null, disabled_reason: AppDisabledReason | null, disable_destructive: boolean | null, disable_open_world: boolean | null, tools: { [key in string]?: AppToolConfig } | null, } }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 500b1d930f..405faeaadd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -9,6 +9,9 @@ export type { AnalyticsConfig } from "./AnalyticsConfig"; export type { AppDisabledReason } from "./AppDisabledReason"; export type { AppInfo } from "./AppInfo"; export type { AppListUpdatedNotification } from "./AppListUpdatedNotification"; +export type { AppToolApproval } from "./AppToolApproval"; +export type { AppToolConfig } from "./AppToolConfig"; +export type { AppToolDisabledReason } from "./AppToolDisabledReason"; export type { AppsConfig } from "./AppsConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4c83c1de64..ec5bc93a7e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -374,13 +374,42 @@ pub enum AppDisabledReason { User, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum AppToolDisabledReason { + Unknown, + User, + AdminPolicy, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum AppToolApproval { + Auto, + Prompt, + Approve, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolConfig { + pub enabled: Option, + pub disabled_reason: Option, + pub approval: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] pub struct AppConfig { - #[serde(default = "default_enabled")] - pub enabled: bool, + pub enabled: Option, pub disabled_reason: Option, + pub disable_destructive: Option, + pub disable_open_world: Option, + pub tools: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -392,10 +421,6 @@ pub struct AppsConfig { pub apps: HashMap, } -const fn default_enabled() -> bool { - true -} - const fn default_include_platform_defaults() -> bool { true } @@ -1291,10 +1316,9 @@ pub struct AppInfo { /// Whether this app is enabled in config.toml. /// Example: /// ```toml - /// [app.bad_app] + /// [apps.bad_app] /// enabled = false /// ``` - #[serde(default = "default_enabled")] pub is_enabled: bool, } diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index de4c51cde4..68e236b343 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -188,8 +188,11 @@ disabled_reason = "user" apps: std::collections::HashMap::from([( "app1".to_string(), AppConfig { - enabled: false, + enabled: Some(false), disabled_reason: Some(AppDisabledReason::User), + disable_destructive: None, + disable_open_world: None, + tools: None, }, )]), }) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5fb67c22b4..cffbfa1c3a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -59,6 +59,14 @@ "additionalProperties": false, "description": "Config values for a single app/connector.", "properties": { + "disable_destructive": { + "description": "When `true`, tools marked as destructive are disabled for this app.", + "type": "boolean" + }, + "disable_open_world": { + "description": "When `true`, tools marked as open-world are disabled for this app.", + "type": "boolean" + }, "disabled_reason": { "allOf": [ { @@ -68,9 +76,15 @@ "description": "Reason this app was disabled." }, "enabled": { - "default": true, "description": "When `false`, Codex does not surface this app.", "type": "boolean" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/AppToolConfig" + }, + "description": "Per-tool settings keyed by tool name. Supports a `_default` entry.", + "type": "object" } }, "type": "object" @@ -82,6 +96,49 @@ ], "type": "string" }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "additionalProperties": false, + "description": "Config values for a single app MCP tool.", + "properties": { + "approval": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Tool-level MCP approval behavior." + }, + "disabled_reason": { + "allOf": [ + { + "$ref": "#/definitions/AppToolDisabledReason" + } + ], + "description": "Reason this app tool was disabled." + }, + "enabled": { + "description": "When `false`, Codex does not expose this app tool.", + "type": "boolean" + } + }, + "type": "object" + }, + "AppToolDisabledReason": { + "enum": [ + "unknown", + "user", + "admin_policy" + ], + "type": "string" + }, "AppsConfigToml": { "additionalProperties": { "$ref": "#/definitions/AppConfig" diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5d183b29ec..a8c3c7640e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4381,6 +4381,29 @@ fn filter_codex_apps_mcp_tools( mcp_tools } +fn filter_disallowed_codex_apps_tools( + mut mcp_tools: HashMap, + config: &Config, +) -> HashMap { + mcp_tools.retain(|_, tool| { + if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { + return true; + } + let Some(connector_id) = codex_apps_connector_id(tool) else { + return false; + }; + connectors::codex_apps_tool_policy( + config, + connector_id, + &tool.tool_name, + tool.tool.annotations.as_ref(), + ) + .allowed + }); + + mcp_tools +} + fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Option<&str> { tool.connector_id.as_deref() } @@ -4558,9 +4581,11 @@ async fn built_tools( selected_mcp_tools.extend(apps_mcp_tools); } - mcp_tools = selected_mcp_tools; + mcp_tools = + filter_disallowed_codex_apps_tools(selected_mcp_tools, turn_context.config.as_ref()); } else if let Some(connectors) = connectors_for_tools.as_ref() { mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, connectors); + mcp_tools = filter_disallowed_codex_apps_tools(mcp_tools, turn_context.config.as_ref()); } Ok(Arc::new(ToolRouter::from_config( @@ -5355,6 +5380,7 @@ mod tests { use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Tool; + use rmcp::model::ToolAnnotations; use serde::Deserialize; use serde_json::json; use std::path::PathBuf; @@ -5615,6 +5641,82 @@ mod tests { ); } + #[tokio::test] + async fn filter_disallowed_codex_apps_tools_respects_apps_config() { + let codex_home = tempfile::tempdir().expect("tempdir"); + std::fs::write( + codex_home.path().join("config.toml"), + r#" +[apps._default] +disable_destructive = true + +[apps.connector_123.tools._default] +enabled = true +approval = "prompt" + +[apps.connector_123.tools."issues/create"] +enabled = false +"#, + ) + .expect("write config"); + let config = build_test_config(codex_home.path()).await; + + let mut destructive_tool = make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "files/delete", + Some("connector_123"), + Some("Connector 123"), + ); + destructive_tool.tool.annotations = Some(ToolAnnotations { + destructive_hint: Some(true), + idempotent_hint: None, + open_world_hint: None, + read_only_hint: Some(false), + title: None, + }); + + let mcp_tools = HashMap::from([ + ( + "mcp__codex_apps__repos_list".to_string(), + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "repos/list", + Some("connector_123"), + Some("Connector 123"), + ), + ), + ( + "mcp__codex_apps__issues_create".to_string(), + make_mcp_tool( + CODEX_APPS_MCP_SERVER_NAME, + "issues/create", + Some("connector_123"), + Some("Connector 123"), + ), + ), + ( + "mcp__codex_apps__files_delete".to_string(), + destructive_tool, + ), + ( + "mcp__rmcp__echo".to_string(), + make_mcp_tool("rmcp", "echo", None, None), + ), + ]); + + let filtered = filter_disallowed_codex_apps_tools(mcp_tools, &config); + let mut tool_names: Vec = filtered.into_keys().collect(); + tool_names.sort(); + + assert_eq!( + tool_names, + vec![ + "mcp__codex_apps__repos_list".to_string(), + "mcp__rmcp__echo".to_string(), + ] + ); + } + #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index fa37683776..a55606f393 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -849,8 +849,11 @@ remote_models = true apps: std::collections::HashMap::from([( "app1".to_string(), AppConfig { - enabled: false, + enabled: Some(false), disabled_reason: Some(AppDisabledReason::User), + disable_destructive: None, + disable_open_world: None, + tools: None, }, )]), }) diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 5f54c1df28..29d6ab5d77 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -369,17 +369,71 @@ impl fmt::Display for AppDisabledReason { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AppToolDisabledReason { + Unknown, + User, + AdminPolicy, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AppToolApproval { + Auto, + #[default] + Prompt, + Approve, +} + +impl fmt::Display for AppToolApproval { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppToolApproval::Auto => write!(f, "auto"), + AppToolApproval::Prompt => write!(f, "prompt"), + AppToolApproval::Approve => write!(f, "approve"), + } + } +} + +/// Config values for a single app MCP tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolConfig { + /// When `false`, Codex does not expose this app tool. + pub enabled: Option, + + /// Reason this app tool was disabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, + + /// Tool-level MCP approval behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval: Option, +} + /// Config values for a single app/connector. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct AppConfig { /// When `false`, Codex does not surface this app. - #[serde(default = "default_enabled")] - pub enabled: bool, + pub enabled: Option, /// Reason this app was disabled. #[serde(default, skip_serializing_if = "Option::is_none")] pub disabled_reason: Option, + + /// When `true`, tools marked as destructive are disabled for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_destructive: Option, + + /// When `true`, tools marked as open-world are disabled for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_open_world: Option, + + /// Per-tool settings keyed by tool name. Supports a `_default` entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option>, } /// App/connector settings loaded from `config.toml`. @@ -387,6 +441,7 @@ pub struct AppConfig { #[schemars(deny_unknown_fields)] pub struct AppsConfigToml { /// Per-app settings keyed by app ID (for example `[apps.google_drive]`). + /// A `_default` key may be used to configure defaults for all apps. #[serde(default, flatten)] pub apps: HashMap, } @@ -1021,4 +1076,64 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn deserialize_apps_config_with_defaults_and_tool_overrides() { + #[derive(Deserialize)] + struct Wrapper { + apps: AppsConfigToml, + } + + let cfg: Wrapper = toml::from_str( + r#" + [apps._default] + enabled = false + disable_destructive = false + + [apps.connector_123] + enabled = true + disable_open_world = true + + [apps.connector_123.tools._default] + enabled = true + approval = "prompt" + + [apps.connector_123.tools."repos/list"] + approval = "auto" + + [apps.connector_123.tools."issues/create"] + enabled = false + disabled_reason = "admin_policy" + approval = "approve" + "#, + ) + .expect("apps config parses"); + + let defaults = cfg + .apps + .apps + .get("_default") + .expect("apps._default present"); + assert_eq!(defaults.enabled, Some(false)); + assert_eq!(defaults.disable_destructive, Some(false)); + assert_eq!(defaults.disable_open_world, None); + + let connector = cfg + .apps + .apps + .get("connector_123") + .expect("connector_123 present"); + assert_eq!(connector.enabled, Some(true)); + assert_eq!(connector.disable_open_world, Some(true)); + let tools = connector.tools.as_ref().expect("connector tools present"); + let tool_default = tools.get("_default").expect("tool _default present"); + assert_eq!(tool_default.approval, Some(AppToolApproval::Prompt)); + let issues_create = tools.get("issues/create").expect("issues/create present"); + assert_eq!(issues_create.enabled, Some(false)); + assert_eq!( + issues_create.disabled_reason, + Some(AppToolDisabledReason::AdminPolicy) + ); + assert_eq!(issues_create.approval, Some(AppToolApproval::Approve)); + } } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 0c22d18654..df87aebf65 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -17,6 +17,7 @@ use crate::AuthManager; use crate::CodexAuth; use crate::SandboxState; use crate::config::Config; +use crate::config::types::AppToolApproval; use crate::config::types::AppsConfigToml; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -25,8 +26,17 @@ use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; use crate::mcp_connection_manager::McpConnectionManager; use crate::token_data::TokenData; +use rmcp::model::ToolAnnotations; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); +const APPS_DEFAULT_KEY: &str = "_default"; +const APP_TOOLS_DEFAULT_KEY: &str = "_default"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AppToolPolicy { + pub(crate) allowed: bool, + pub(crate) approval: Option, +} #[derive(Clone, PartialEq, Eq)] struct AccessibleConnectorsCacheKey { @@ -268,22 +278,134 @@ pub fn merge_connectors( } pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { - let apps = read_apps_config(config).map(|apps_config| apps_config.apps); + let apps_config = read_apps_config(config); for connector in &mut connectors { - connector.is_enabled = apps - .as_ref() - .and_then(|apps| apps.get(&connector.id)) - .is_none_or(|app| app.enabled); + connector.is_enabled = app_enabled_for_connector(apps_config.as_ref(), &connector.id); } connectors } +pub(crate) fn codex_apps_tool_policy( + config: &Config, + connector_id: &str, + tool_name: &str, + annotations: Option<&ToolAnnotations>, +) -> AppToolPolicy { + let apps_config = read_apps_config(config); + codex_apps_tool_policy_from_apps_config( + apps_config.as_ref(), + connector_id, + tool_name, + annotations, + ) +} + +pub(crate) fn codex_apps_tool_policy_from_apps_config( + apps_config: Option<&AppsConfigToml>, + connector_id: &str, + tool_name: &str, + annotations: Option<&ToolAnnotations>, +) -> AppToolPolicy { + let app_enabled = app_enabled_for_connector(apps_config, connector_id); + let disable_destructive = apps_config + .and_then(|apps_config| { + app_setting_or_default(apps_config, connector_id, |app| app.disable_destructive) + }) + .unwrap_or(false); + let disable_open_world = apps_config + .and_then(|apps_config| { + app_setting_or_default(apps_config, connector_id, |app| app.disable_open_world) + }) + .unwrap_or(false); + let tool_enabled = apps_config + .and_then(|apps_config| { + app_tool_setting_or_default(apps_config, connector_id, tool_name, |tool| tool.enabled) + }) + .unwrap_or(true); + let approval = apps_config.and_then(|apps_config| { + app_tool_setting_or_default(apps_config, connector_id, tool_name, |tool| tool.approval) + }); + + let destructive_blocked = disable_destructive + && annotations.is_some_and(|annotations| annotations.destructive_hint == Some(true)); + let open_world_blocked = disable_open_world + && annotations.is_some_and(|annotations| annotations.open_world_hint == Some(true)); + + AppToolPolicy { + allowed: app_enabled && tool_enabled && !destructive_blocked && !open_world_blocked, + approval, + } +} + fn read_apps_config(config: &Config) -> Option { let effective_config = config.config_layer_stack.effective_config(); let apps_config = effective_config.as_table()?.get("apps")?.clone(); AppsConfigToml::deserialize(apps_config).ok() } +fn app_enabled_for_connector(apps_config: Option<&AppsConfigToml>, connector_id: &str) -> bool { + apps_config + .and_then(|apps_config| { + app_setting_or_default(apps_config, connector_id, |app| app.enabled) + }) + .unwrap_or(true) +} + +fn app_setting_or_default( + apps_config: &AppsConfigToml, + connector_id: &str, + setting: F, +) -> Option +where + T: Copy, + F: Fn(&crate::config::types::AppConfig) -> Option + Copy, +{ + apps_config + .apps + .get(connector_id) + .and_then(setting) + .or_else(|| apps_config.apps.get(APPS_DEFAULT_KEY).and_then(setting)) +} + +fn app_tool_setting_or_default( + apps_config: &AppsConfigToml, + connector_id: &str, + tool_name: &str, + setting: F, +) -> Option +where + T: Copy, + F: Fn(&crate::config::types::AppToolConfig) -> Option + Copy, +{ + let app_tools = apps_config + .apps + .get(connector_id) + .and_then(|app| app.tools.as_ref()); + let default_tools = apps_config + .apps + .get(APPS_DEFAULT_KEY) + .and_then(|app| app.tools.as_ref()); + + app_tools + .and_then(|tools| tools.get(tool_name)) + .and_then(setting) + .or_else(|| { + app_tools + .and_then(|tools| tools.get(APP_TOOLS_DEFAULT_KEY)) + .and_then(setting) + }) + .or_else(|| { + default_tools + .and_then(|tools| tools.get(tool_name)) + .and_then(setting) + }) + .or_else(|| { + default_tools + .and_then(|tools| tools.get(APP_TOOLS_DEFAULT_KEY)) + .and_then(setting) + }) +} + fn collect_accessible_connectors(tools: I) -> Vec where I: IntoIterator)>, @@ -355,3 +477,141 @@ pub fn connector_name_slug(name: &str) -> String { fn format_connector_label(name: &str, _id: &str) -> String { name.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn apps_config_from_toml(toml_src: &str) -> AppsConfigToml { + #[derive(Deserialize)] + struct Wrapper { + apps: AppsConfigToml, + } + + toml::from_str::(toml_src) + .expect("apps config parses") + .apps + } + + fn annotations(destructive: Option, open_world: Option) -> ToolAnnotations { + ToolAnnotations { + destructive_hint: destructive, + idempotent_hint: None, + open_world_hint: open_world, + read_only_hint: Some(false), + title: None, + } + } + + #[test] + fn app_enabled_inherits_and_overrides_default() { + let apps_config = apps_config_from_toml( + r#" + [apps._default] + enabled = false + + [apps.calendar] + disable_open_world = true + + [apps.gmail] + enabled = true + "#, + ); + + assert_eq!( + app_enabled_for_connector(Some(&apps_config), "calendar"), + false + ); + assert_eq!(app_enabled_for_connector(Some(&apps_config), "gmail"), true); + assert_eq!( + app_enabled_for_connector(Some(&apps_config), "drive"), + false + ); + } + + #[test] + fn codex_apps_tool_policy_applies_nested_defaults_and_overrides() { + let apps_config = apps_config_from_toml( + r#" + [apps._default] + disable_destructive = true + disable_open_world = true + + [apps.connector_123.tools._default] + enabled = true + approval = "prompt" + + [apps.connector_123.tools."repos/list"] + approval = "auto" + + [apps.connector_123.tools."issues/create"] + approval = "approve" + "#, + ); + + let repos_list = codex_apps_tool_policy_from_apps_config( + Some(&apps_config), + "connector_123", + "repos/list", + Some(&annotations(None, None)), + ); + assert_eq!( + repos_list, + AppToolPolicy { + allowed: true, + approval: Some(AppToolApproval::Auto), + } + ); + + let issues_create = codex_apps_tool_policy_from_apps_config( + Some(&apps_config), + "connector_123", + "issues/create", + Some(&annotations(None, None)), + ); + assert_eq!( + issues_create, + AppToolPolicy { + allowed: true, + approval: Some(AppToolApproval::Approve), + } + ); + + let destructive_tool = codex_apps_tool_policy_from_apps_config( + Some(&apps_config), + "connector_123", + "files/delete", + Some(&annotations(Some(true), None)), + ); + assert_eq!(destructive_tool.allowed, false); + assert_eq!(destructive_tool.approval, Some(AppToolApproval::Prompt)); + + let open_world_tool = codex_apps_tool_policy_from_apps_config( + Some(&apps_config), + "connector_123", + "web/search", + Some(&annotations(None, Some(true))), + ); + assert_eq!(open_world_tool.allowed, false); + assert_eq!(open_world_tool.approval, Some(AppToolApproval::Prompt)); + } + + #[test] + fn codex_apps_tool_policy_defaults_to_prompt_without_apps_config() { + let policy = codex_apps_tool_policy_from_apps_config( + None, + "connector_123", + "repos/list", + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + allowed: true, + approval: None, + } + ); + } +} diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 831cc6b3a1..24db517201 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -5,6 +5,7 @@ use tracing::error; use crate::codex::Session; use crate::codex::TurnContext; +use crate::config::types::AppToolApproval; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; @@ -126,6 +127,16 @@ pub(crate) async fn handle_mcp_tool_call( ) .await } + McpToolApprovalDecision::BlockedByConfig(message) => { + notify_mcp_tool_call_skip( + sess.as_ref(), + turn_context, + &call_id, + invocation, + message, + ) + .await + } }; let status = if result.is_ok() { "ok" } else { "error" }; @@ -210,12 +221,13 @@ async fn notify_mcp_tool_call_event(sess: &Session, turn_context: &TurnContext, sess.send_event(turn_context, event).await; } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum McpToolApprovalDecision { Accept, AcceptAndRemember, Decline, Cancel, + BlockedByConfig(String), } struct McpToolApprovalMetadata { @@ -245,25 +257,41 @@ async fn maybe_request_mcp_tool_approval( server: &str, tool_name: &str, ) -> Option { - if is_full_access_mode(turn_context) { - return None; - } if server != CODEX_APPS_MCP_SERVER_NAME { return None; } let metadata = lookup_mcp_tool_metadata(sess, server, tool_name).await?; - if !requires_mcp_tool_approval(&metadata.annotations) { - return None; + let connector_id = metadata.connector_id.as_deref()?; + let tool_policy = crate::connectors::codex_apps_tool_policy( + turn_context.config.as_ref(), + connector_id, + tool_name, + Some(&metadata.annotations), + ); + if !tool_policy.allowed { + return Some(McpToolApprovalDecision::BlockedByConfig(format!( + "MCP tool call `{tool_name}` is blocked by apps config for connector `{connector_id}`" + ))); } - let approval_key = metadata - .connector_id - .as_deref() - .map(|connector_id| McpToolApprovalKey { - server: server.to_string(), - connector_id: connector_id.to_string(), - tool_name: tool_name.to_string(), - }); + + match tool_policy.approval { + Some(AppToolApproval::Approve) => return None, + Some(AppToolApproval::Prompt) => {} + Some(AppToolApproval::Auto) | None => { + if is_full_access_mode(turn_context) + || !requires_mcp_tool_approval(&metadata.annotations) + { + return None; + } + } + } + + let approval_key = Some(McpToolApprovalKey { + server: server.to_string(), + connector_id: connector_id.to_string(), + tool_name: tool_name.to_string(), + }); if let Some(key) = approval_key.as_ref() && mcp_tool_approval_is_remembered(sess, key).await { diff --git a/docs/config.md b/docs/config.md index 9d53538cae..e410781607 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,6 +18,34 @@ Use `$` in the composer to insert a ChatGPT connector; the popover lists accessi apps. The `/apps` command lists available and installed apps. Connected apps appear first and are labeled as connected; others are marked as can be installed. +`apps` supports layered defaults and per-tool settings: + +```toml +[apps._default] +disable_destructive = false +disable_open_world = false + +[apps.connector_123] +enabled = false +disabled_reason = "user" +disable_destructive = true +disable_open_world = true + +[apps.connector_123.tools._default] +enabled = true +approval = "prompt" # "auto" | "prompt" | "approve" + +[apps.connector_123.tools."repos/list"] +enabled = true +approval = "auto" +``` + +Tool approval modes: + +- `auto`: Let the tool decide when to ask for approval. +- `prompt`: always ask for approval. +- `approve`: never ask for approval. + ## Notify Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings: