mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
update
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
@@ -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, };
|
||||
@@ -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";
|
||||
@@ -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, } };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<bool>,
|
||||
pub disabled_reason: Option<AppToolDisabledReason>,
|
||||
pub approval: Option<AppToolApproval>,
|
||||
}
|
||||
|
||||
#[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<bool>,
|
||||
pub disabled_reason: Option<AppDisabledReason>,
|
||||
pub disable_destructive: Option<bool>,
|
||||
pub disable_open_world: Option<bool>,
|
||||
pub tools: Option<HashMap<String, AppToolConfig>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -392,10 +421,6 @@ pub struct AppsConfig {
|
||||
pub apps: HashMap<String, AppConfig>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)]),
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
+103
-1
@@ -4381,6 +4381,29 @@ fn filter_codex_apps_mcp_tools(
|
||||
mcp_tools
|
||||
}
|
||||
|
||||
fn filter_disallowed_codex_apps_tools(
|
||||
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
config: &Config,
|
||||
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
|
||||
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<String> = 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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)]),
|
||||
})
|
||||
|
||||
@@ -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<bool>,
|
||||
|
||||
/// Reason this app tool was disabled.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_reason: Option<AppToolDisabledReason>,
|
||||
|
||||
/// Tool-level MCP approval behavior.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approval: Option<AppToolApproval>,
|
||||
}
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// Reason this app was disabled.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_reason: Option<AppDisabledReason>,
|
||||
|
||||
/// When `true`, tools marked as destructive are disabled for this app.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disable_destructive: Option<bool>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// Per-tool settings keyed by tool name. Supports a `_default` entry.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<HashMap<String, AppToolConfig>>,
|
||||
}
|
||||
|
||||
/// 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<String, AppConfig>,
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppToolApproval>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct AccessibleConnectorsCacheKey {
|
||||
@@ -268,22 +278,134 @@ pub fn merge_connectors(
|
||||
}
|
||||
|
||||
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
|
||||
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<AppsConfigToml> {
|
||||
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<T, F>(
|
||||
apps_config: &AppsConfigToml,
|
||||
connector_id: &str,
|
||||
setting: F,
|
||||
) -> Option<T>
|
||||
where
|
||||
T: Copy,
|
||||
F: Fn(&crate::config::types::AppConfig) -> Option<T> + 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<T, F>(
|
||||
apps_config: &AppsConfigToml,
|
||||
connector_id: &str,
|
||||
tool_name: &str,
|
||||
setting: F,
|
||||
) -> Option<T>
|
||||
where
|
||||
T: Copy,
|
||||
F: Fn(&crate::config::types::AppToolConfig) -> Option<T> + 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<I>(tools: I) -> Vec<AppInfo>
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>)>,
|
||||
@@ -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::<Wrapper>(toml_src)
|
||||
.expect("apps config parses")
|
||||
.apps
|
||||
}
|
||||
|
||||
fn annotations(destructive: Option<bool>, open_world: Option<bool>) -> 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<McpToolApprovalDecision> {
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user