Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/app-server-protocol/src/protocol/v2/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ pub struct AnalyticsConfig {
pub enum AppToolApproval {
Auto,
Prompt,
Writes,
Approve,
}

Expand Down
4 changes: 3 additions & 1 deletion codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1879,7 +1879,9 @@ review when allowed by configuration requirements.

Use `apps._default.default_tools_approval_mode` to set the approval mode for
tools without a per-app or per-tool override. Supported values are `"auto"`,
`"prompt"`, and `"approve"`. Tool-level `approval_mode` takes precedence over
`"prompt"`, `"writes"`, and `"approve"`. The `"writes"` mode prompts for tools
that do not advertise `readOnlyHint = true` and skips declared read-only tools.
Tool-level `approval_mode` takes precedence over
the per-app `default_tools_approval_mode`, which takes precedence over the
`apps._default` value. Managed tool requirements take precedence over all of
these settings. When none are configured, the mode defaults to `"auto"`.
Expand Down
4 changes: 2 additions & 2 deletions codex-rs/app-server/tests/suite/v2/config_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ async fn config_read_includes_apps() -> Result<()> {
r#"
[apps._default]
approvals_reviewer = "auto_review"
default_tools_approval_mode = "approve"
default_tools_approval_mode = "writes"

[apps.app1]
enabled = false
Expand Down Expand Up @@ -440,7 +440,7 @@ default_tools_approval_mode = "prompt"
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
destructive_enabled: true,
open_world_enabled: true,
default_tools_approval_mode: Some(AppToolApproval::Approve),
default_tools_approval_mode: Some(AppToolApproval::Writes),
}),
apps: std::collections::HashMap::from([(
"app1".to_string(),
Expand Down
1 change: 1 addition & 0 deletions codex-rs/cli/src/mcp_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
let approval_mode = match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Writes => "writes",
AppToolApproval::Approve => "approve",
};
println!(" default_tools_approval_mode: {approval_mode}");
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/config/src/mcp_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
entry["default_tools_approval_mode"] = value(match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Writes => "writes",
AppToolApproval::Approve => "approve",
});
}
Expand Down Expand Up @@ -242,6 +243,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
tool_entry["approval_mode"] = value(match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Writes => "writes",
AppToolApproval::Approve => "approve",
});
}
Expand Down
1 change: 1 addition & 0 deletions codex-rs/config/src/mcp_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub enum AppToolApproval {
#[default]
Auto,
Prompt,
Writes,
Approve,
}

Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
"enum": [
"auto",
"prompt",
"writes",
"approve"
],
"type": "string"
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/core/src/config/edit/document_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
entry["default_tools_approval_mode"] = value(match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Writes => "writes",
AppToolApproval::Approve => "approve",
});
}
Expand Down Expand Up @@ -173,6 +174,7 @@ fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
entry["approval_mode"] = value(match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Writes => "writes",
AppToolApproval::Approve => "approve",
});
}
Expand Down
30 changes: 22 additions & 8 deletions codex-rs/core/src/mcp_tool_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1223,8 +1223,7 @@ async fn maybe_request_mcp_tool_approval(
}

let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
let approval_required = requires_mcp_tool_approval(annotations);
if !approval_required && approval_mode != AppToolApproval::Prompt {
if !requires_mcp_tool_approval_for_mode(annotations, approval_mode) {
return None;
}

Expand Down Expand Up @@ -1927,12 +1926,13 @@ fn normalize_approval_decision_for_mode(
decision: McpToolApprovalDecision,
approval_mode: AppToolApproval,
) -> McpToolApprovalDecision {
if approval_mode == AppToolApproval::Prompt
&& matches!(
decision,
McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember
)
{
if matches!(
approval_mode,
AppToolApproval::Prompt | AppToolApproval::Writes
) && matches!(
decision,
McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember
) {
McpToolApprovalDecision::Accept
} else {
decision
Expand Down Expand Up @@ -2179,6 +2179,20 @@ fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool {
.unwrap_or(true)
}

fn requires_mcp_tool_approval_for_mode(
annotations: Option<&ToolAnnotations>,
approval_mode: AppToolApproval,
) -> bool {
match approval_mode {
AppToolApproval::Auto => requires_mcp_tool_approval(annotations),
AppToolApproval::Prompt => true,
AppToolApproval::Writes => !annotations
.and_then(|annotations| annotations.read_only_hint)
.unwrap_or(false),
AppToolApproval::Approve => false,
}
}

async fn notify_mcp_tool_call_skip(
sess: &Session,
turn_context: &TurnContext,
Expand Down
46 changes: 35 additions & 11 deletions codex-rs/core/src/mcp_tool_call_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,23 +398,47 @@ fn approval_not_required_when_read_only_and_other_hints_are_absent() {
}

#[test]
fn prompt_mode_does_not_allow_persistent_remember() {
fn writes_mode_requires_approval_for_non_read_only_tools() {
let annotations = annotations(Some(false), Some(false), Some(false));
assert_eq!(
normalize_approval_decision_for_mode(
McpToolApprovalDecision::AcceptForSession,
AppToolApproval::Prompt,
),
McpToolApprovalDecision::Accept
requires_mcp_tool_approval_for_mode(Some(&annotations), AppToolApproval::Writes),
true
);
assert_eq!(
normalize_approval_decision_for_mode(
McpToolApprovalDecision::AcceptAndRemember,
AppToolApproval::Prompt,
),
McpToolApprovalDecision::Accept
requires_mcp_tool_approval_for_mode(/*annotations*/ None, AppToolApproval::Writes),
true
);
}

#[test]
fn writes_mode_does_not_require_approval_for_read_only_tools() {
let annotations = annotations(Some(true), Some(true), Some(true));
assert_eq!(
requires_mcp_tool_approval_for_mode(Some(&annotations), AppToolApproval::Writes),
false
);
}

#[test]
fn prompting_modes_do_not_allow_persistent_remember() {
for approval_mode in [AppToolApproval::Prompt, AppToolApproval::Writes] {
assert_eq!(
normalize_approval_decision_for_mode(
McpToolApprovalDecision::AcceptForSession,
approval_mode,
),
McpToolApprovalDecision::Accept
);
assert_eq!(
normalize_approval_decision_for_mode(
McpToolApprovalDecision::AcceptAndRemember,
approval_mode,
),
McpToolApprovalDecision::Accept
);
}
}

#[tokio::test]
async fn mcp_tool_call_span_records_expected_fields() {
let buffer: &'static std::sync::Mutex<Vec<u8>> =
Expand Down
124 changes: 124 additions & 0 deletions codex-rs/core/tests/suite/mcp_turn_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use codex_protocol::user_input::UserInput;
use core_test_support::PathExt;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL;
use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE;
use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id;
use core_test_support::apps_test_server::search_capable_apps_builder;
Expand All @@ -45,6 +46,7 @@ fn set_calendar_approval_mode(config: &mut Config, approval_mode: AppToolApprova
let approval_mode = match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Writes => "writes",
AppToolApproval::Approve => "approve",
};
let user_config_path = config.codex_home.join("config.toml").abs();
Expand All @@ -68,6 +70,7 @@ fn set_default_app_approval_mode_and_reviewer(
let approval_mode = match approval_mode {
AppToolApproval::Auto => "auto",
AppToolApproval::Prompt => "prompt",
AppToolApproval::Writes => "writes",
AppToolApproval::Approve => "approve",
};
let user_config_path = config.codex_home.join("config.toml").abs();
Expand Down Expand Up @@ -333,6 +336,127 @@ async fn apps_default_prompt_with_auto_review_routes_actual_mcp_approval_to_guar
Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apps_default_writes_prompts_for_writes_but_not_reads() -> Result<()> {
skip_if_no_network!(Ok(()));

let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
let read_call_id = "calendar-read";
let write_call_id = "calendar-write";
let list_args = serde_json::to_string(&json!({}))?;
let create_args = serde_json::to_string(&json!({
"title": "Lunch",
"starts_at": "2026-03-10T12:00:00Z"
}))?;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-read"),
ev_function_call_with_namespace(
read_call_id,
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_LIST_TOOL,
&list_args,
),
ev_completed("resp-read"),
]),
sse(vec![
ev_response_created("resp-write"),
ev_function_call_with_namespace(
write_call_id,
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_CREATE_TOOL,
&create_args,
),
ev_completed("resp-write"),
]),
sse(vec![
ev_response_created("resp-done"),
ev_assistant_message("msg-done", "done"),
ev_completed("resp-done"),
]),
],
)
.await;

let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone())
.with_config(|config| {
config
.features
.enable(Feature::ToolCallMcpElicitation)
.expect("test config should allow feature update");
set_default_app_approval_mode_and_reviewer(
config,
AppToolApproval::Writes,
ApprovalsReviewer::User,
);
});
let test = builder.build(&server).await?;

submit_user_turn(
&test,
"Use [$calendar](app://calendar) to list events, then create one.",
AskForApproval::OnRequest,
/*collaboration_mode*/ None,
)
.await?;

let EventMsg::McpToolCallBegin(read_begin) = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::McpToolCallBegin(_))
})
.await
else {
unreachable!("event guard guarantees McpToolCallBegin");
};
assert_eq!(read_begin.call_id, read_call_id);

let next_route = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::McpToolCallBegin(_) | EventMsg::ElicitationRequest(_)
)
})
.await;
let EventMsg::McpToolCallBegin(write_begin) = next_route else {
panic!("read-only app action should not prompt in writes mode");
};
assert_eq!(write_begin.call_id, write_call_id);

let EventMsg::ElicitationRequest(request) = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ElicitationRequest(_) | EventMsg::TurnComplete(_)
)
})
.await
else {
panic!("write app action should prompt in writes mode");
};

test.codex
.submit(Op::ResolveElicitation {
server_name: request.server_name,
request_id: request.id,
decision: ElicitationAction::Accept,
content: None,
meta: None,
})
.await?;

wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;

assert_eq!(responses.requests().len(), 3);
recorded_apps_tool_call_by_call_id(&server, read_call_id).await;
recorded_apps_tool_call_by_call_id(&server, write_call_id).await;

Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_tool_call_metadata_records_prior_request_user_input_tool() -> Result<()> {
skip_if_no_network!(Ok(()));
Expand Down
Loading