feat(tui): preselect scoped permission requests

Enable `request_permissions_tool` by default and add a scope hint so
conversational requests for named paths can open the permissions UI with
the requested duration selected.

Thread the suggested scope through protocol, core, app-server, and TUI
handling so broad mode changes still use the preset picker while narrow
filesystem and network grants use the granular permissions flow.
This commit is contained in:
Felipe Coury
2026-04-12 19:59:15 -03:00
parent 13bdd524f2
commit afefd3b1b1
22 changed files with 190 additions and 39 deletions
@@ -39,6 +39,13 @@
},
"type": "object"
},
"PermissionGrantScope": {
"enum": [
"turn",
"session"
],
"type": "string"
},
"RequestPermissionProfile": {
"additionalProperties": false,
"properties": {
@@ -79,6 +86,14 @@
"null"
]
},
"suggestedScope": {
"allOf": [
{
"$ref": "#/definitions/PermissionGrantScope"
}
],
"default": "turn"
},
"threadId": {
"type": "string"
},
@@ -1409,6 +1409,13 @@
}
]
},
"PermissionGrantScope": {
"enum": [
"turn",
"session"
],
"type": "string"
},
"PermissionPresetId": {
"enum": [
"auto",
@@ -1481,6 +1488,14 @@
"null"
]
},
"suggestedScope": {
"allOf": [
{
"$ref": "#/definitions/PermissionGrantScope"
}
],
"default": "turn"
},
"threadId": {
"type": "string"
},
@@ -3359,6 +3359,14 @@
"null"
]
},
"suggestedScope": {
"allOf": [
{
"$ref": "#/definitions/PermissionGrantScope"
}
],
"default": "turn"
},
"threadId": {
"type": "string"
},
@@ -1,6 +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 { PermissionGrantScope } from "./PermissionGrantScope";
import type { RequestPermissionProfile } from "./RequestPermissionProfile";
export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: RequestPermissionProfile, };
export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: RequestPermissionProfile, suggestedScope: PermissionGrantScope, };
@@ -6221,6 +6221,8 @@ pub struct PermissionsRequestApprovalParams {
pub item_id: String,
pub reason: Option<String>,
pub permissions: RequestPermissionProfile,
#[serde(default)]
pub suggested_scope: PermissionGrantScope,
}
v2_enum_from_core!(
@@ -6650,6 +6652,7 @@ mod tests {
}),
}
);
assert_eq!(params.suggested_scope, PermissionGrantScope::Turn);
assert_eq!(
CoreRequestPermissionProfile::from(params.permissions),
@@ -6671,6 +6674,24 @@ mod tests {
);
}
#[test]
fn permissions_request_approval_preserves_suggested_scope() {
let params = serde_json::from_value::<PermissionsRequestApprovalParams>(json!({
"threadId": "thr_123",
"turnId": "turn_123",
"itemId": "call_123",
"reason": "Select a workspace root",
"permissions": {
"network": null,
"fileSystem": null,
},
"suggestedScope": "session",
}))
.expect("permissions request should deserialize");
assert_eq!(params.suggested_scope, PermissionGrantScope::Session);
}
#[test]
fn permissions_request_approval_rejects_macos_permissions() {
let err = serde_json::from_value::<PermissionsRequestApprovalParams>(json!({
@@ -869,6 +869,7 @@ pub(crate) async fn apply_bespoke_event_handling(
item_id: request.call_id.clone(),
reason: request.reason,
permissions: request.permissions.into(),
suggested_scope: request.suggested_scope.into(),
};
let (pending_request_id, rx) = outgoing
.send_request(ServerRequestPayload::PermissionsRequestApproval(params))
+1
View File
@@ -3329,6 +3329,7 @@ impl Session {
turn_id: turn_context.sub_id.clone(),
reason: args.reason,
permissions: args.permissions,
suggested_scope: args.scope,
});
self.send_event(turn_context, event).await;
rx_response.await.ok()
+1
View File
@@ -771,6 +771,7 @@ async fn handle_request_permissions(
let args = RequestPermissionsArgs {
reason: event.reason,
permissions: event.permissions,
scope: event.suggested_scope,
};
let response_fut = parent_session.request_permissions(parent_ctx, call_id.clone(), args);
let response =
@@ -202,6 +202,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
}),
..RequestPermissionProfile::default()
},
suggested_scope: PermissionGrantScope::Turn,
},
&cancel_token,
)
+3
View File
@@ -3022,6 +3022,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
}),
..RequestPermissionProfile::default()
},
scope: PermissionGrantScope::Session,
},
)
.await
@@ -3036,6 +3037,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
panic!("expected request_permissions event");
};
assert_eq!(request.call_id, call_id);
assert_eq!(request.suggested_scope, PermissionGrantScope::Session);
session
.notify_request_permissions_response(&request.call_id, expected_response.clone())
@@ -3080,6 +3082,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
}),
..RequestPermissionProfile::default()
},
scope: PermissionGrantScope::Turn,
},
)
.await;
+2 -2
View File
@@ -708,8 +708,8 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::RequestPermissionsTool,
key: "request_permissions_tool",
stage: Stage::UnderDevelopment,
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::RequestPermissionPresetTool,
+3 -6
View File
@@ -107,12 +107,9 @@ fn request_permissions_is_under_development() {
}
#[test]
fn request_permissions_tool_is_under_development() {
assert_eq!(
Feature::RequestPermissionsTool.stage(),
Stage::UnderDevelopment
);
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
fn request_permissions_tool_is_stable_and_enabled_by_default() {
assert_eq!(Feature::RequestPermissionsTool.stage(), Stage::Stable);
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), true);
}
#[test]
+8 -3
View File
@@ -509,6 +509,7 @@ impl DeveloperInstructions {
DeveloperInstructions::new(message)
}
#[allow(clippy::too_many_arguments)]
pub fn from_policy(
sandbox_policy: &SandboxPolicy,
approval_policy: AskForApproval,
@@ -635,11 +636,11 @@ fn granular_prompt_intro_text() -> &'static str {
}
fn request_permissions_tool_prompt_section() -> &'static str {
"# request_permissions Tool\n\nThe built-in `request_permissions` tool is available in this session. Invoke it when you need to request additional `network` or `file_system` permissions before later shell-like commands need them. Request only the specific permissions required for the task."
"# request_permissions Tool\n\nThe built-in `request_permissions` tool is available in this session. Invoke it immediately when the user asks conversationally to allow specific filesystem or network access, for example \"allow writing to ~/Downloads in this session\" or \"grant access to /tmp/output\". Request only the specific permissions required. Set `scope` to `session` when the user explicitly asks for session-long access; otherwise use `turn`."
}
fn request_permission_preset_tool_prompt_section() -> &'static str {
"# request_permission_preset Tool\n\nThe built-in `request_permission_preset` tool is available in this session. Invoke it immediately when the user asks conversationally to change sandboxing, approval, or permission mode, for example \"make this session full access\", \"switch to full access\", \"make the session read-only\", or \"use guardian approvals\". The tool opens the permission-mode picker with the requested preset pre-selected; the session only changes if the user selects a mode there. Do not claim the mode changed until the tool returns an accepted decision. Use `request_permissions` instead for narrow filesystem or network grants required by later shell-like commands."
"# request_permission_preset Tool\n\nThe built-in `request_permission_preset` tool is available in this session. Invoke it immediately when the user asks conversationally to change the broad sandboxing, approval, or permission mode, for example \"make this session full access\", \"switch to full access\", \"make the session read-only\", or \"use guardian approvals\". The tool opens the permission-mode picker with the requested preset pre-selected; the session only changes if the user selects a mode there. Do not claim the mode changed until the tool returns an accepted decision. Use `request_permissions` instead for named paths or narrow filesystem/network grants; never use the preset picker for requests like \"allow writing to ~/Downloads\"."
}
fn granular_instructions(
@@ -1875,6 +1876,8 @@ mod tests {
assert!(
text.contains("The built-in `request_permissions` tool is available in this session.")
);
assert!(text.contains("allow writing to ~/Downloads in this session"));
assert!(text.contains("Set `scope` to `session`"));
}
#[test]
@@ -1896,8 +1899,10 @@ mod tests {
let text = instructions.into_text();
assert!(text.contains("# request_permission_preset Tool"));
assert!(text.contains(
"Invoke it immediately when the user asks conversationally to change sandboxing"
"asks conversationally to change the broad sandboxing, approval, or permission mode"
));
assert!(text.contains("Use `request_permissions` instead for named paths"));
assert!(text.contains("never use the preset picker"));
}
#[test]
@@ -50,6 +50,8 @@ pub struct RequestPermissionsArgs {
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub permissions: RequestPermissionProfile,
#[serde(default)]
pub scope: PermissionGrantScope,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
@@ -70,4 +72,6 @@ pub struct RequestPermissionsEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub permissions: RequestPermissionProfile,
#[serde(default)]
pub suggested_scope: PermissionGrantScope,
}
+11 -1
View File
@@ -275,6 +275,16 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec {
)),
),
("permissions".to_string(), permission_profile_schema()),
(
"scope".to_string(),
JsonSchema::string_enum(
vec![json!("turn"), json!("session")],
Some(
"How long the granted permissions should apply. Use \"session\" when the user explicitly asks for access in this session; defaults to \"turn\"."
.to_string(),
),
),
),
]);
ToolSpec::Function(ResponsesApiTool {
@@ -292,7 +302,7 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec {
}
pub fn request_permissions_tool_description() -> String {
"Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope."
"Open a permissions request for specific filesystem or network access, such as writing to a named path like ~/Downloads. Use this immediately when the user conversationally asks to allow access to a specific path or network permission. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session when scope is \"session\" and the client approves them at session scope."
.to_string()
}
+10
View File
@@ -308,6 +308,16 @@ fn request_permissions_tool_includes_full_permission_schema() {
)),
),
("permissions".to_string(), permission_profile_schema()),
(
"scope".to_string(),
JsonSchema::string_enum(
vec![serde_json::json!("turn"), serde_json::json!("session")],
Some(
"How long the granted permissions should apply. Use \"session\" when the user explicitly asks for access in this session; defaults to \"turn\"."
.to_string(),
),
),
),
]);
assert_eq!(
+27 -23
View File
@@ -123,7 +123,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
expected.insert(spec.name().to_string(), spec);
}
if config.exec_permission_approvals_enabled {
if config.request_permissions_tool_enabled {
let spec = create_request_permissions_tool(request_permissions_tool_description());
expected.insert(spec.name().to_string(), spec);
}
@@ -569,7 +569,7 @@ fn request_user_input_description_reflects_default_mode_feature_flag() {
}
#[test]
fn request_permissions_requires_feature_flag() {
fn request_permissions_is_enabled_by_default_and_can_be_disabled() {
let model_info = model_info();
let features = Features::with_defaults();
let available_models = Vec::new();
@@ -589,31 +589,31 @@ fn request_permissions_requires_feature_flag() {
/*deferred_mcp_tools*/ None,
&[],
);
assert_lacks_tool_name(&tools, "request_permissions");
let mut features = Features::with_defaults();
features.enable(Feature::RequestPermissionsTool);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
&[],
);
let request_permissions_tool = find_tool(&tools, "request_permissions");
assert_eq!(
request_permissions_tool.spec,
create_request_permissions_tool(request_permissions_tool_description())
);
let mut features = Features::with_defaults();
features.disable(Feature::RequestPermissionsTool);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
&[],
);
assert_lacks_tool_name(&tools, "request_permissions");
}
#[test]
@@ -693,7 +693,11 @@ fn request_permissions_tool_is_independent_from_additional_permissions() {
&[],
);
assert_lacks_tool_name(&tools, "request_permissions");
let request_permissions_tool = find_tool(&tools, "request_permissions");
assert_eq!(
request_permissions_tool.spec,
create_request_permissions_tool(request_permissions_tool_description())
);
}
#[test]
+7
View File
@@ -1842,6 +1842,7 @@ impl App {
call_id: params.item_id.clone(),
reason: params.reason.clone(),
permissions: params.permissions.clone().into(),
suggested_scope: params.suggested_scope.to_core(),
}),
),
ServerRequest::PermissionPresetRequestApproval { params, .. } => Some(
@@ -8711,11 +8712,13 @@ guardian_approval = true
write: Some(vec![test_absolute_path("/tmp/write")]),
}),
},
suggested_scope: codex_app_server_protocol::PermissionGrantScope::Turn,
},
};
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions {
permissions,
suggested_scope,
..
})) = app
.interactive_request_for_thread_request(thread_id, &request)
@@ -8736,6 +8739,10 @@ guardian_approval = true
}),
}
);
assert_eq!(
suggested_scope,
codex_protocol::request_permissions::PermissionGrantScope::Turn
);
}
#[tokio::test]
@@ -394,6 +394,7 @@ mod tests {
"network": { "enabled": null }
}))
.expect("valid permissions"),
suggested_scope: codex_app_server_protocol::PermissionGrantScope::Turn,
},
}),
None
@@ -60,6 +60,7 @@ pub(crate) enum ApprovalRequest {
call_id: String,
reason: Option<String>,
permissions: RequestPermissionProfile,
suggested_scope: PermissionGrantScope,
},
ApplyPatch {
thread_id: ThreadId,
@@ -144,7 +145,7 @@ impl ApprovalOverlay {
header: Box<dyn Renderable>,
_features: &Features,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match request {
let (options, title, initial_selected_idx) = match request {
ApprovalRequest::Exec {
available_decisions,
network_approval_context,
@@ -165,18 +166,24 @@ impl ApprovalOverlay {
)
},
),
None,
),
ApprovalRequest::Permissions { .. } => (
ApprovalRequest::Permissions {
suggested_scope, ..
} => (
permissions_options(),
"Would you like to grant these permissions?".to_string(),
matches!(suggested_scope, PermissionGrantScope::Session).then_some(1),
),
ApprovalRequest::ApplyPatch { .. } => (
patch_options(),
"Would you like to make the following edits?".to_string(),
None,
),
ApprovalRequest::McpElicitation { server_name, .. } => (
elicitation_options(),
format!("{server_name} needs your approval."),
None,
),
};
@@ -202,6 +209,7 @@ impl ApprovalOverlay {
footer_hint: Some(approval_footer_hint(request)),
items,
header,
initial_selected_idx,
..Default::default()
};
@@ -918,6 +926,7 @@ mod tests {
write: Some(vec![absolute_path("/tmp/out.txt")]),
}),
},
suggested_scope: PermissionGrantScope::Turn,
}
}
@@ -1258,6 +1267,40 @@ mod tests {
);
}
#[test]
fn permissions_session_suggestion_preselects_session_scope() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut request = make_permissions_request();
let ApprovalRequest::Permissions {
suggested_scope, ..
} = &mut request
else {
panic!("expected permissions request");
};
*suggested_scope = PermissionGrantScope::Session;
let mut view = ApprovalOverlay::new(request, tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: Op::RequestPermissionsResponse { response, .. },
..
} = ev
{
assert_eq!(response.scope, PermissionGrantScope::Session);
saw_op = true;
break;
}
}
assert!(
saw_op,
"expected preselected session approval to submit a session-scoped response"
);
}
#[test]
fn additional_permissions_prompt_shows_permission_rule_line() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
+3 -1
View File
@@ -1577,6 +1577,7 @@ fn request_permissions_from_params(
call_id: params.item_id,
reason: params.reason,
permissions: params.permissions.into(),
suggested_scope: params.suggested_scope.to_core(),
}
}
@@ -4610,6 +4611,7 @@ impl ChatWidget {
call_id: ev.call_id,
reason: ev.reason,
permissions: ev.permissions,
suggested_scope: ev.suggested_scope,
};
self.bottom_pane
.push_approval_request(request, &self.config.features);
@@ -8644,7 +8646,7 @@ impl ChatWidget {
RequestPermissionPresetResponse {
decision: RequestPermissionPresetDecision::Accepted,
preset: *preset,
message: message.clone(),
message,
},
);
}
@@ -230,6 +230,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() {
write: Some(vec![write_path.clone()]),
}),
},
suggested_scope: codex_app_server_protocol::PermissionGrantScope::Turn,
});
assert_eq!(