mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add request permissions tool (#13092)
Adds a built-in `request_permissions` tool and wires it through the Codex core, protocol, and app-server layers so a running turn can ask the client for additional permissions instead of relying on a static session policy. The new flow emits a `RequestPermissions` event from core, tracks the pending request by call ID, forwards it through app-server v2 as an `item/permissions/requestApproval` request, and resumes the tool call once the client returns an approved subset of the requested permission profile.
This commit is contained in:
@@ -103,6 +103,9 @@ use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnContextNetworkItem;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsArgs;
|
||||
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
@@ -2820,6 +2823,37 @@ impl Session {
|
||||
rx_approve
|
||||
}
|
||||
|
||||
pub async fn request_permissions(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
call_id: String,
|
||||
args: RequestPermissionsArgs,
|
||||
) -> Option<RequestPermissionsResponse> {
|
||||
let (tx_response, rx_response) = oneshot::channel();
|
||||
let prev_entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.insert_pending_request_permissions(call_id.clone(), tx_response)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
if prev_entry.is_some() {
|
||||
warn!("Overwriting existing pending request_permissions for call_id: {call_id}");
|
||||
}
|
||||
|
||||
let event = EventMsg::RequestPermissions(RequestPermissionsEvent {
|
||||
call_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
reason: args.reason,
|
||||
permissions: args.permissions,
|
||||
});
|
||||
self.send_event(turn_context, event).await;
|
||||
rx_response.await.ok()
|
||||
}
|
||||
|
||||
pub async fn request_user_input(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
@@ -2956,6 +2990,42 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn notify_request_permissions_response(
|
||||
&self,
|
||||
call_id: &str,
|
||||
response: RequestPermissionsResponse,
|
||||
) {
|
||||
let entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
let entry = ts.remove_pending_request_permissions(call_id);
|
||||
if entry.is_some() && !response.permissions.is_empty() {
|
||||
ts.record_granted_permissions(response.permissions.clone());
|
||||
}
|
||||
entry
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
match entry {
|
||||
Some(tx_response) => {
|
||||
tx_response.send(response).ok();
|
||||
}
|
||||
None => {
|
||||
warn!("No pending request_permissions found for call_id: {call_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn granted_turn_permissions(&self) -> Option<PermissionProfile> {
|
||||
let active = self.active_turn.lock().await;
|
||||
let active = active.as_ref()?;
|
||||
let ts = active.turn_state.lock().await;
|
||||
ts.granted_permissions()
|
||||
}
|
||||
|
||||
pub async fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) {
|
||||
let entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
@@ -3924,6 +3994,10 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
handlers::request_user_input_response(&sess, id, response).await;
|
||||
false
|
||||
}
|
||||
Op::RequestPermissionsResponse { id, response } => {
|
||||
handlers::request_permissions_response(&sess, id, response).await;
|
||||
false
|
||||
}
|
||||
Op::DynamicToolResponse { id, response } => {
|
||||
handlers::dynamic_tool_response(&sess, id, response).await;
|
||||
false
|
||||
@@ -4106,6 +4180,7 @@ mod handlers {
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
@@ -4348,6 +4423,15 @@ mod handlers {
|
||||
sess.notify_user_input_response(&id, response).await;
|
||||
}
|
||||
|
||||
pub async fn request_permissions_response(
|
||||
sess: &Arc<Session>,
|
||||
id: String,
|
||||
response: RequestPermissionsResponse,
|
||||
) {
|
||||
sess.notify_request_permissions_response(&id, response)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn dynamic_tool_response(
|
||||
sess: &Arc<Session>,
|
||||
id: String,
|
||||
@@ -6217,6 +6301,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
|
||||
| EventMsg::ImageGenerationBegin(_)
|
||||
| EventMsg::ImageGenerationEnd(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::RequestPermissions(_)
|
||||
| EventMsg::RequestUserInput(_)
|
||||
| EventMsg::DynamicToolCallRequest(_)
|
||||
| EventMsg::DynamicToolCallResponse(_)
|
||||
|
||||
@@ -13,6 +13,9 @@ use codex_protocol::protocol::RequestUserInputEvent;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::request_permissions::RequestPermissionsArgs;
|
||||
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -251,6 +254,19 @@ async fn forward_events(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Event {
|
||||
msg: EventMsg::RequestPermissions(event),
|
||||
..
|
||||
} => {
|
||||
handle_request_permissions(
|
||||
&codex,
|
||||
&parent_session,
|
||||
&parent_ctx,
|
||||
event,
|
||||
&cancel_token,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Event {
|
||||
id,
|
||||
msg: EventMsg::RequestUserInput(event),
|
||||
@@ -425,6 +441,30 @@ async fn handle_request_user_input(
|
||||
let _ = codex.submit(Op::UserInputAnswer { id, response }).await;
|
||||
}
|
||||
|
||||
async fn handle_request_permissions(
|
||||
codex: &Codex,
|
||||
parent_session: &Session,
|
||||
parent_ctx: &TurnContext,
|
||||
event: RequestPermissionsEvent,
|
||||
cancel_token: &CancellationToken,
|
||||
) {
|
||||
let call_id = event.call_id;
|
||||
let args = RequestPermissionsArgs {
|
||||
reason: event.reason,
|
||||
permissions: event.permissions,
|
||||
};
|
||||
let response_fut = parent_session.request_permissions(parent_ctx, call_id.clone(), args);
|
||||
let response =
|
||||
await_request_permissions_with_cancel(response_fut, parent_session, &call_id, cancel_token)
|
||||
.await;
|
||||
let _ = codex
|
||||
.submit(Op::RequestPermissionsResponse {
|
||||
id: call_id,
|
||||
response,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn await_user_input_with_cancel<F>(
|
||||
fut: F,
|
||||
parent_session: &Session,
|
||||
@@ -451,6 +491,32 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_request_permissions_with_cancel<F>(
|
||||
fut: F,
|
||||
parent_session: &Session,
|
||||
call_id: &str,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> RequestPermissionsResponse
|
||||
where
|
||||
F: core::future::Future<Output = Option<RequestPermissionsResponse>>,
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => {
|
||||
let empty = RequestPermissionsResponse {
|
||||
permissions: Default::default(),
|
||||
};
|
||||
parent_session
|
||||
.notify_request_permissions_response(call_id, empty.clone())
|
||||
.await;
|
||||
empty
|
||||
}
|
||||
response = fut => response.unwrap_or_else(|| RequestPermissionsResponse {
|
||||
permissions: Default::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Await an approval decision, aborting on cancellation.
|
||||
async fn await_approval_with_cancel<F>(
|
||||
fut: F,
|
||||
@@ -479,11 +545,16 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_channel::bounded;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentStatus;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RawResponseItemEvent;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::watch;
|
||||
|
||||
@@ -602,4 +673,90 @@ mod tests {
|
||||
.expect("forward_ops did not exit")
|
||||
.expect("forward_ops join error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
|
||||
let (parent_session, parent_ctx, rx_events) =
|
||||
crate::codex::make_session_and_context_with_rx().await;
|
||||
*parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
|
||||
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
|
||||
let codex = Arc::new(Codex {
|
||||
tx_sub,
|
||||
rx_event: rx_events_child,
|
||||
agent_status,
|
||||
session: Arc::clone(&parent_session),
|
||||
});
|
||||
|
||||
let call_id = "tool-call-1".to_string();
|
||||
let expected_response = RequestPermissionsResponse {
|
||||
permissions: PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
..PermissionProfile::default()
|
||||
},
|
||||
};
|
||||
let cancel_token = CancellationToken::new();
|
||||
let request_call_id = call_id.clone();
|
||||
|
||||
let handle = tokio::spawn({
|
||||
let codex = Arc::clone(&codex);
|
||||
let parent_session = Arc::clone(&parent_session);
|
||||
let parent_ctx = Arc::clone(&parent_ctx);
|
||||
let cancel_token = cancel_token.clone();
|
||||
async move {
|
||||
handle_request_permissions(
|
||||
codex.as_ref(),
|
||||
parent_session.as_ref(),
|
||||
parent_ctx.as_ref(),
|
||||
RequestPermissionsEvent {
|
||||
call_id: request_call_id,
|
||||
turn_id: "child-turn-1".to_string(),
|
||||
reason: Some("need access".to_string()),
|
||||
permissions: PermissionProfile {
|
||||
network: Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
..PermissionProfile::default()
|
||||
},
|
||||
},
|
||||
&cancel_token,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
let request_event = timeout(Duration::from_secs(1), rx_events.recv())
|
||||
.await
|
||||
.expect("request_permissions event timed out")
|
||||
.expect("request_permissions event missing");
|
||||
let EventMsg::RequestPermissions(request) = request_event.msg else {
|
||||
panic!("expected RequestPermissions event");
|
||||
};
|
||||
assert_eq!(request.call_id, call_id.clone());
|
||||
|
||||
parent_session
|
||||
.notify_request_permissions_response(&call_id, expected_response.clone())
|
||||
.await;
|
||||
|
||||
timeout(Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("handle_request_permissions hung")
|
||||
.expect("handle_request_permissions join error");
|
||||
|
||||
let submission = timeout(Duration::from_secs(1), rx_sub.recv())
|
||||
.await
|
||||
.expect("request_permissions response timed out")
|
||||
.expect("request_permissions response missing");
|
||||
assert_eq!(
|
||||
submission.op,
|
||||
Op::RequestPermissionsResponse {
|
||||
id: call_id,
|
||||
response: expected_response,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2141,6 +2141,28 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
(session, turn_context)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_request_permissions_response_ignores_unmatched_call_id() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
|
||||
session
|
||||
.notify_request_permissions_response(
|
||||
"missing",
|
||||
codex_protocol::request_permissions::RequestPermissionsResponse {
|
||||
permissions: codex_protocol::models::PermissionProfile {
|
||||
network: Some(codex_protocol::models::NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(session.granted_turn_permissions().await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_with_id_captures_current_span_trace_context() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -93,6 +93,8 @@ pub enum Feature {
|
||||
ApplyPatchFreeform,
|
||||
/// Allow requesting additional filesystem permissions while staying sandboxed.
|
||||
RequestPermissions,
|
||||
/// Expose the built-in request_permissions tool.
|
||||
RequestPermissionsTool,
|
||||
/// Allow the model to request web searches that fetch live content.
|
||||
WebSearchRequest,
|
||||
/// Allow the model to request web searches that fetch cached content.
|
||||
@@ -581,6 +583,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::RequestPermissionsTool,
|
||||
key: "request_permissions_tool",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::UseLinuxSandboxBwrap,
|
||||
key: "use_linux_sandbox_bwrap",
|
||||
@@ -920,6 +928,21 @@ mod tests {
|
||||
assert_eq!(Feature::GuardianApproval.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_permissions_is_under_development() {
|
||||
assert_eq!(Feature::RequestPermissions.stage(), Stage::UnderDevelopment);
|
||||
assert_eq!(Feature::RequestPermissions.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_permissions_tool_is_under_development() {
|
||||
assert_eq!(
|
||||
Feature::RequestPermissionsTool.stage(),
|
||||
Stage::UnderDevelopment
|
||||
);
|
||||
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_generation_is_under_development() {
|
||||
assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment);
|
||||
|
||||
@@ -144,6 +144,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::RequestPermissions(_)
|
||||
| EventMsg::RequestUserInput(_)
|
||||
| EventMsg::ElicitationRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
|
||||
@@ -31,6 +31,28 @@ pub(crate) fn merge_macos_seatbelt_profile_extensions(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn intersect_macos_seatbelt_profile_extensions(
|
||||
requested: Option<MacOsSeatbeltProfileExtensions>,
|
||||
granted: Option<MacOsSeatbeltProfileExtensions>,
|
||||
) -> Option<MacOsSeatbeltProfileExtensions> {
|
||||
match (requested, granted) {
|
||||
(Some(requested), Some(granted)) => {
|
||||
let macos_automation = intersect_macos_automation_permission(
|
||||
&requested.macos_automation,
|
||||
&granted.macos_automation,
|
||||
);
|
||||
|
||||
Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: requested.macos_preferences.min(granted.macos_preferences),
|
||||
macos_automation,
|
||||
macos_accessibility: requested.macos_accessibility && granted.macos_accessibility,
|
||||
macos_calendar: requested.macos_calendar && granted.macos_calendar,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unions two preferences permissions by keeping the more permissive one.
|
||||
///
|
||||
/// The larger rank wins: `None < ReadOnly < ReadWrite`. When both sides have
|
||||
@@ -75,8 +97,40 @@ fn union_macos_automation_permission(
|
||||
}
|
||||
}
|
||||
|
||||
fn intersect_macos_automation_permission(
|
||||
requested: &MacOsAutomationPermission,
|
||||
granted: &MacOsAutomationPermission,
|
||||
) -> MacOsAutomationPermission {
|
||||
match (requested, granted) {
|
||||
(_, MacOsAutomationPermission::None) | (MacOsAutomationPermission::None, _) => {
|
||||
MacOsAutomationPermission::None
|
||||
}
|
||||
(MacOsAutomationPermission::All, granted) => granted.clone(),
|
||||
(MacOsAutomationPermission::BundleIds(requested), MacOsAutomationPermission::All) => {
|
||||
MacOsAutomationPermission::BundleIds(requested.clone())
|
||||
}
|
||||
(
|
||||
MacOsAutomationPermission::BundleIds(requested),
|
||||
MacOsAutomationPermission::BundleIds(granted),
|
||||
) => {
|
||||
let bundle_ids = requested
|
||||
.iter()
|
||||
.filter(|bundle_id| granted.contains(bundle_id))
|
||||
.cloned()
|
||||
.collect::<Vec<String>>();
|
||||
if bundle_ids.is_empty() {
|
||||
MacOsAutomationPermission::None
|
||||
} else {
|
||||
MacOsAutomationPermission::BundleIds(bundle_ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::intersect_macos_automation_permission;
|
||||
use super::intersect_macos_seatbelt_profile_extensions;
|
||||
use super::merge_macos_seatbelt_profile_extensions;
|
||||
use super::union_macos_automation_permission;
|
||||
use super::union_macos_preferences_permission;
|
||||
@@ -141,4 +195,38 @@ mod tests {
|
||||
|
||||
assert_eq!(merged, MacOsAutomationPermission::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_macos_automation_permission_keeps_common_bundle_ids() {
|
||||
let requested = MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
"com.apple.Calendar".to_string(),
|
||||
]);
|
||||
let granted = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]);
|
||||
|
||||
let intersected = intersect_macos_automation_permission(&requested, &granted);
|
||||
|
||||
assert_eq!(
|
||||
intersected,
|
||||
MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_macos_seatbelt_profile_extensions_preserves_default_grant() {
|
||||
let requested = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
};
|
||||
let granted = MacOsSeatbeltProfileExtensions::default();
|
||||
|
||||
let intersected =
|
||||
intersect_macos_seatbelt_profile_extensions(Some(requested), Some(granted));
|
||||
|
||||
assert_eq!(intersected, Some(MacOsSeatbeltProfileExtensions::default()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
pub use codex_protocol::models::SandboxPermissions;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
@@ -40,6 +41,7 @@ use codex_protocol::protocol::NetworkAccess;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize;
|
||||
use macos_permissions::intersect_macos_seatbelt_profile_extensions;
|
||||
use macos_permissions::merge_macos_seatbelt_profile_extensions;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -146,22 +148,137 @@ impl EffectiveSandboxPermissions {
|
||||
pub(crate) fn normalize_additional_permissions(
|
||||
additional_permissions: PermissionProfile,
|
||||
) -> Result<PermissionProfile, String> {
|
||||
let network = additional_permissions
|
||||
.network
|
||||
.filter(|network| !network.is_empty());
|
||||
let file_system = additional_permissions
|
||||
.file_system
|
||||
.map(|file_system| {
|
||||
let read = file_system
|
||||
.read
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.read"));
|
||||
let write = file_system
|
||||
.write
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.write"));
|
||||
FileSystemPermissions { read, write }
|
||||
})
|
||||
.filter(|file_system| !file_system.is_empty());
|
||||
let macos = additional_permissions.macos;
|
||||
|
||||
Ok(PermissionProfile {
|
||||
network: additional_permissions.network,
|
||||
file_system: additional_permissions
|
||||
.file_system
|
||||
.map(|file_system| FileSystemPermissions {
|
||||
read: file_system
|
||||
.read
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.read")),
|
||||
write: file_system
|
||||
.write
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.write")),
|
||||
}),
|
||||
macos: additional_permissions.macos,
|
||||
network,
|
||||
file_system,
|
||||
macos,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn merge_permission_profiles(
|
||||
base: Option<&PermissionProfile>,
|
||||
permissions: Option<&PermissionProfile>,
|
||||
) -> Option<PermissionProfile> {
|
||||
let Some(permissions) = permissions else {
|
||||
return base.cloned();
|
||||
};
|
||||
|
||||
match base {
|
||||
Some(base) => {
|
||||
let network = match (base.network.as_ref(), permissions.network.as_ref()) {
|
||||
(
|
||||
Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
_,
|
||||
)
|
||||
| (
|
||||
_,
|
||||
Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
) => Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) {
|
||||
(Some(base), Some(permissions)) => Some(FileSystemPermissions {
|
||||
read: merge_permission_paths(base.read.as_ref(), permissions.read.as_ref()),
|
||||
write: merge_permission_paths(base.write.as_ref(), permissions.write.as_ref()),
|
||||
})
|
||||
.filter(|file_system| !file_system.is_empty()),
|
||||
(Some(base), None) => Some(base.clone()),
|
||||
(None, Some(permissions)) => Some(permissions.clone()),
|
||||
(None, None) => None,
|
||||
};
|
||||
let macos = merge_macos_seatbelt_profile_extensions(
|
||||
base.macos.as_ref(),
|
||||
permissions.macos.as_ref(),
|
||||
);
|
||||
|
||||
Some(PermissionProfile {
|
||||
network,
|
||||
file_system,
|
||||
macos,
|
||||
})
|
||||
.filter(|permissions| !permissions.is_empty())
|
||||
}
|
||||
None => Some(permissions.clone()).filter(|permissions| !permissions.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn intersect_permission_profiles(
|
||||
requested: PermissionProfile,
|
||||
granted: PermissionProfile,
|
||||
) -> PermissionProfile {
|
||||
let file_system = requested
|
||||
.file_system
|
||||
.map(|requested_file_system| {
|
||||
let granted_file_system = granted.file_system.unwrap_or_default();
|
||||
let read = requested_file_system
|
||||
.read
|
||||
.map(|requested_read| {
|
||||
let granted_read = granted_file_system.read.unwrap_or_default();
|
||||
requested_read
|
||||
.into_iter()
|
||||
.filter(|path| granted_read.contains(path))
|
||||
.collect()
|
||||
})
|
||||
.filter(|paths: &Vec<_>| !paths.is_empty());
|
||||
let write = requested_file_system
|
||||
.write
|
||||
.map(|requested_write| {
|
||||
let granted_write = granted_file_system.write.unwrap_or_default();
|
||||
requested_write
|
||||
.into_iter()
|
||||
.filter(|path| granted_write.contains(path))
|
||||
.collect()
|
||||
})
|
||||
.filter(|paths: &Vec<_>| !paths.is_empty());
|
||||
FileSystemPermissions { read, write }
|
||||
})
|
||||
.filter(|file_system| !file_system.is_empty());
|
||||
let network = match (requested.network, granted.network) {
|
||||
(
|
||||
Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
) => Some(NetworkPermissions {
|
||||
enabled: Some(true),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let macos = intersect_macos_seatbelt_profile_extensions(requested.macos, granted.macos);
|
||||
|
||||
PermissionProfile {
|
||||
network,
|
||||
file_system,
|
||||
macos,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_permission_paths(
|
||||
paths: Vec<AbsolutePathBuf>,
|
||||
_permission_kind: &str,
|
||||
@@ -182,6 +299,29 @@ fn normalize_permission_paths(
|
||||
out
|
||||
}
|
||||
|
||||
fn merge_permission_paths(
|
||||
base: Option<&Vec<AbsolutePathBuf>>,
|
||||
permissions: Option<&Vec<AbsolutePathBuf>>,
|
||||
) -> Option<Vec<AbsolutePathBuf>> {
|
||||
match (base, permissions) {
|
||||
(Some(base), Some(permissions)) => {
|
||||
let mut merged = Vec::with_capacity(base.len() + permissions.len());
|
||||
let mut seen = HashSet::with_capacity(base.len() + permissions.len());
|
||||
|
||||
for path in base.iter().chain(permissions.iter()) {
|
||||
if seen.insert(path.clone()) {
|
||||
merged.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Some(merged).filter(|paths| !paths.is_empty())
|
||||
}
|
||||
(Some(base), None) => Some(base.clone()),
|
||||
(None, Some(permissions)) => Some(permissions.clone()),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
|
||||
let mut out = Vec::with_capacity(paths.len());
|
||||
let mut seen = HashSet::new();
|
||||
@@ -581,6 +721,8 @@ mod tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::EffectiveSandboxPermissions;
|
||||
use super::SandboxManager;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::intersect_permission_profiles;
|
||||
use super::merge_file_system_policy_with_additional_permissions;
|
||||
use super::normalize_additional_permissions;
|
||||
use super::sandbox_policy_with_additional_permissions;
|
||||
@@ -791,6 +933,77 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_additional_permissions_drops_empty_nested_profiles() {
|
||||
let permissions = normalize_additional_permissions(PermissionProfile {
|
||||
network: Some(NetworkPermissions { enabled: None }),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: None,
|
||||
}),
|
||||
macos: None,
|
||||
})
|
||||
.expect("permissions");
|
||||
|
||||
assert_eq!(permissions, PermissionProfile::default());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn normalize_additional_permissions_preserves_default_macos_preferences_permission() {
|
||||
let permissions = normalize_additional_permissions(PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions::default()),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("permissions");
|
||||
|
||||
assert_eq!(
|
||||
permissions,
|
||||
PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions::default()),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn intersect_permission_profiles_preserves_default_macos_grants() {
|
||||
let requested = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(Vec::from(["/tmp/requested"
|
||||
.try_into()
|
||||
.expect("absolute path")])),
|
||||
write: None,
|
||||
}),
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let granted = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(Vec::new()),
|
||||
write: None,
|
||||
}),
|
||||
macos: Some(MacOsSeatbeltProfileExtensions::default()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
intersect_permission_profiles(requested, granted),
|
||||
PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions::default()),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn normalize_additional_permissions_preserves_macos_permissions() {
|
||||
|
||||
@@ -10,6 +10,7 @@ use tokio_util::task::AbortOnDropHandle;
|
||||
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use rmcp::model::RequestId;
|
||||
@@ -18,7 +19,9 @@ use tokio::sync::oneshot;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::protocol::ReviewDecision;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::sandboxing::merge_permission_profiles;
|
||||
use crate::tasks::SessionTask;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
|
||||
/// Metadata about the currently running turn.
|
||||
pub(crate) struct ActiveTurn {
|
||||
@@ -73,10 +76,12 @@ impl ActiveTurn {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TurnState {
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_request_permissions: HashMap<String, oneshot::Sender<RequestPermissionsResponse>>,
|
||||
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
|
||||
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
|
||||
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
granted_permissions: Option<PermissionProfile>,
|
||||
pub(crate) tool_calls: u64,
|
||||
pub(crate) token_usage_at_turn_start: TokenUsage,
|
||||
}
|
||||
@@ -99,12 +104,28 @@ impl TurnState {
|
||||
|
||||
pub(crate) fn clear_pending(&mut self) {
|
||||
self.pending_approvals.clear();
|
||||
self.pending_request_permissions.clear();
|
||||
self.pending_user_input.clear();
|
||||
self.pending_elicitations.clear();
|
||||
self.pending_dynamic_tools.clear();
|
||||
self.pending_input.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn insert_pending_request_permissions(
|
||||
&mut self,
|
||||
key: String,
|
||||
tx: oneshot::Sender<RequestPermissionsResponse>,
|
||||
) -> Option<oneshot::Sender<RequestPermissionsResponse>> {
|
||||
self.pending_request_permissions.insert(key, tx)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_pending_request_permissions(
|
||||
&mut self,
|
||||
key: &str,
|
||||
) -> Option<oneshot::Sender<RequestPermissionsResponse>> {
|
||||
self.pending_request_permissions.remove(key)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_pending_user_input(
|
||||
&mut self,
|
||||
key: String,
|
||||
@@ -171,6 +192,15 @@ impl TurnState {
|
||||
pub(crate) fn has_pending_input(&self) -> bool {
|
||||
!self.pending_input.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) {
|
||||
self.granted_permissions =
|
||||
merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions));
|
||||
}
|
||||
|
||||
pub(crate) fn granted_permissions(&self) -> Option<PermissionProfile> {
|
||||
self.granted_permissions.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveTurn {
|
||||
|
||||
@@ -10,6 +10,7 @@ mod mcp_resource;
|
||||
pub(crate) mod multi_agents;
|
||||
mod plan;
|
||||
mod read_file;
|
||||
mod request_permissions;
|
||||
mod request_user_input;
|
||||
mod search_tool_bm25;
|
||||
mod shell;
|
||||
@@ -24,8 +25,10 @@ use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::sandboxing::merge_permission_profiles;
|
||||
use crate::sandboxing::normalize_additional_permissions;
|
||||
pub use apply_patch::ApplyPatchHandler;
|
||||
pub use artifacts::ArtifactsHandler;
|
||||
@@ -41,6 +44,8 @@ pub use mcp_resource::McpResourceHandler;
|
||||
pub use multi_agents::MultiAgentHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use read_file::ReadFileHandler;
|
||||
pub use request_permissions::RequestPermissionsHandler;
|
||||
pub(crate) use request_permissions::request_permissions_tool_description;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
pub(crate) use request_user_input::request_user_input_tool_description;
|
||||
pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT;
|
||||
@@ -95,6 +100,7 @@ pub(super) fn normalize_and_validate_additional_permissions(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
permissions_preapproved: bool,
|
||||
_cwd: &Path,
|
||||
) -> Result<Option<PermissionProfile>, String> {
|
||||
let uses_additional_permissions = matches!(
|
||||
@@ -112,7 +118,7 @@ pub(super) fn normalize_and_validate_additional_permissions(
|
||||
}
|
||||
|
||||
if uses_additional_permissions {
|
||||
if !matches!(approval_policy, AskForApproval::OnRequest) {
|
||||
if !permissions_preapproved && !matches!(approval_policy, AskForApproval::OnRequest) {
|
||||
return Err(format!(
|
||||
"approval policy is {approval_policy:?}; reject command — you cannot request additional permissions unless the approval policy is OnRequest"
|
||||
));
|
||||
@@ -146,3 +152,51 @@ pub(super) fn normalize_and_validate_additional_permissions(
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct EffectiveAdditionalPermissions {
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub additional_permissions: Option<PermissionProfile>,
|
||||
pub permissions_preapproved: bool,
|
||||
}
|
||||
|
||||
pub(super) async fn apply_granted_turn_permissions(
|
||||
session: &Session,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
) -> EffectiveAdditionalPermissions {
|
||||
if matches!(sandbox_permissions, SandboxPermissions::RequireEscalated) {
|
||||
return EffectiveAdditionalPermissions {
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
permissions_preapproved: false,
|
||||
};
|
||||
}
|
||||
|
||||
let granted_permissions = session.granted_turn_permissions().await;
|
||||
let effective_permissions = merge_permission_profiles(
|
||||
additional_permissions.as_ref(),
|
||||
granted_permissions.as_ref(),
|
||||
);
|
||||
let permissions_preapproved = match (effective_permissions.as_ref(), granted_permissions) {
|
||||
(Some(effective_permissions), Some(granted_permissions)) => {
|
||||
crate::sandboxing::intersect_permission_profiles(
|
||||
effective_permissions.clone(),
|
||||
granted_permissions,
|
||||
) == *effective_permissions
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let sandbox_permissions =
|
||||
if effective_permissions.is_some() && !sandbox_permissions.uses_additional_permissions() {
|
||||
SandboxPermissions::WithAdditionalPermissions
|
||||
} else {
|
||||
sandbox_permissions
|
||||
};
|
||||
|
||||
EffectiveAdditionalPermissions {
|
||||
sandbox_permissions,
|
||||
additional_permissions: effective_permissions,
|
||||
permissions_preapproved,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::request_permissions::RequestPermissionsArgs;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::sandboxing::normalize_additional_permissions;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
|
||||
pub(crate) fn request_permissions_tool_description() -> String {
|
||||
"Request additional 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."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub struct RequestPermissionsHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for RequestPermissionsHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
call_id,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"request_permissions handler received unsupported payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut args: RequestPermissionsArgs =
|
||||
parse_arguments_with_base_path(&arguments, turn.cwd.as_path())?;
|
||||
args.permissions = normalize_additional_permissions(args.permissions)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
if args.permissions.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"request_permissions requires at least one permission".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let response = session
|
||||
.request_permissions(turn.as_ref(), call_id, args)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"request_permissions was cancelled before receiving a response".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let content = serde_json::to_string(&response).map_err(|err| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"failed to serialize request_permissions response: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text(content),
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::handlers::apply_granted_turn_permissions;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
@@ -332,17 +333,29 @@ impl ShellHandler {
|
||||
}
|
||||
|
||||
let request_permission_enabled = session.features().enabled(Feature::RequestPermissions);
|
||||
let effective_additional_permissions = apply_granted_turn_permissions(
|
||||
session.as_ref(),
|
||||
exec_params.sandbox_permissions,
|
||||
additional_permissions,
|
||||
)
|
||||
.await;
|
||||
let normalized_additional_permissions = normalize_and_validate_additional_permissions(
|
||||
request_permission_enabled,
|
||||
turn.approval_policy.value(),
|
||||
exec_params.sandbox_permissions,
|
||||
additional_permissions,
|
||||
effective_additional_permissions.sandbox_permissions,
|
||||
effective_additional_permissions.additional_permissions,
|
||||
effective_additional_permissions.permissions_preapproved,
|
||||
&exec_params.cwd,
|
||||
)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
|
||||
// Approval policy guard for explicit escalation in non-OnRequest modes.
|
||||
if exec_params.sandbox_permissions.requests_sandbox_override()
|
||||
// Sticky turn permissions have already been approved, so they should
|
||||
// continue through the normal exec approval flow for the command.
|
||||
if effective_additional_permissions
|
||||
.sandbox_permissions
|
||||
.requests_sandbox_override()
|
||||
&& !effective_additional_permissions.permissions_preapproved
|
||||
&& !matches!(
|
||||
turn.approval_policy.value(),
|
||||
codex_protocol::protocol::AskForApproval::OnRequest
|
||||
@@ -387,7 +400,11 @@ impl ShellHandler {
|
||||
command: &exec_params.command,
|
||||
approval_policy: turn.approval_policy.value(),
|
||||
sandbox_policy: turn.sandbox_policy.get(),
|
||||
sandbox_permissions: exec_params.sandbox_permissions,
|
||||
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
|
||||
codex_protocol::models::SandboxPermissions::UseDefault
|
||||
} else {
|
||||
effective_additional_permissions.sandbox_permissions
|
||||
},
|
||||
prefix_rule,
|
||||
})
|
||||
.await;
|
||||
@@ -399,7 +416,7 @@ impl ShellHandler {
|
||||
env: exec_params.env.clone(),
|
||||
explicit_env_overrides,
|
||||
network: exec_params.network.clone(),
|
||||
sandbox_permissions: exec_params.sandbox_permissions,
|
||||
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
|
||||
additional_permissions: normalized_additional_permissions,
|
||||
justification: exec_params.justification.clone(),
|
||||
exec_approval_requirement,
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::skills::maybe_emit_implicit_skill_invocation;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::apply_granted_turn_permissions;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
@@ -170,8 +171,19 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
|
||||
let request_permission_enabled =
|
||||
session.features().enabled(Feature::RequestPermissions);
|
||||
let effective_additional_permissions = apply_granted_turn_permissions(
|
||||
context.session.as_ref(),
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
)
|
||||
.await;
|
||||
|
||||
if sandbox_permissions.requests_sandbox_override()
|
||||
// Sticky turn permissions have already been approved, so they should
|
||||
// continue through the normal exec approval flow for the command.
|
||||
if effective_additional_permissions
|
||||
.sandbox_permissions
|
||||
.requests_sandbox_override()
|
||||
&& !effective_additional_permissions.permissions_preapproved
|
||||
&& !matches!(
|
||||
context.turn.approval_policy.value(),
|
||||
codex_protocol::protocol::AskForApproval::OnRequest
|
||||
@@ -192,8 +204,9 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
match normalize_and_validate_additional_permissions(
|
||||
request_permission_enabled,
|
||||
context.turn.approval_policy.value(),
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
effective_additional_permissions.sandbox_permissions,
|
||||
effective_additional_permissions.additional_permissions,
|
||||
effective_additional_permissions.permissions_preapproved,
|
||||
&cwd,
|
||||
) {
|
||||
Ok(normalized) => normalized,
|
||||
@@ -229,8 +242,11 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
workdir,
|
||||
network: context.turn.network.clone(),
|
||||
tty,
|
||||
sandbox_permissions,
|
||||
sandbox_permissions: effective_additional_permissions
|
||||
.sandbox_permissions,
|
||||
additional_permissions: normalized_additional_permissions,
|
||||
additional_permissions_preapproved: effective_additional_permissions
|
||||
.permissions_preapproved,
|
||||
justification,
|
||||
prefix_rule,
|
||||
},
|
||||
|
||||
+239
-97
@@ -16,6 +16,7 @@ use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
|
||||
use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::request_permissions_tool_description;
|
||||
use crate::tools::handlers::request_user_input_tool_description;
|
||||
use crate::tools::registry::ToolRegistryBuilder;
|
||||
use codex_protocol::config_types::WebSearchConfig;
|
||||
@@ -65,6 +66,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
|
||||
pub search_tool: bool,
|
||||
pub request_permission_enabled: bool,
|
||||
pub request_permissions_tool_enabled: bool,
|
||||
pub js_repl_enabled: bool,
|
||||
pub js_repl_tools_only: bool,
|
||||
pub collab_tools: bool,
|
||||
@@ -106,6 +108,7 @@ impl ToolsConfig {
|
||||
features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info);
|
||||
let include_agent_jobs = include_collab_tools;
|
||||
let request_permission_enabled = features.enabled(Feature::RequestPermissions);
|
||||
let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool);
|
||||
let shell_command_backend =
|
||||
if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) {
|
||||
ShellCommandBackendConfig::ZshFork
|
||||
@@ -166,6 +169,7 @@ impl ToolsConfig {
|
||||
agent_roles: BTreeMap::new(),
|
||||
search_tool: include_search_tool,
|
||||
request_permission_enabled,
|
||||
request_permissions_tool_enabled,
|
||||
js_repl_enabled: include_js_repl,
|
||||
js_repl_tools_only: include_js_repl_tools_only,
|
||||
collab_tools: include_collab_tools,
|
||||
@@ -254,6 +258,94 @@ impl From<JsonSchema> for AdditionalProperties {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_network_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"enabled".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Set to true to request network access.".to_string()),
|
||||
},
|
||||
)]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_file_system_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"read".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("Absolute paths to grant read access to.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"write".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("Absolute paths to grant write access to.".to_string()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_macos_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"preferences".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"macOS preferences access. Supported values: `none`, `read_only`, or `read_write`."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"automations".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some("macOS automation access as app bundle identifiers.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"accessibility".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Whether to request macOS accessibility access.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"calendar".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Whether to request macOS calendar access.".to_string()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
("network".to_string(), create_network_permissions_schema()),
|
||||
(
|
||||
"file_system".to_string(),
|
||||
create_file_system_permissions_schema(),
|
||||
),
|
||||
("macos".to_string(), create_macos_permissions_schema()),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap<String, JsonSchema> {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
@@ -298,103 +390,7 @@ fn create_approval_parameters(request_permission_enabled: bool) -> BTreeMap<Stri
|
||||
if request_permission_enabled {
|
||||
properties.insert(
|
||||
"additional_permissions".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"network".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
"enabled".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Set to true to enable network access for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
)]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"file_system".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"read".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
"Additional filesystem paths to grant read access for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"write".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
"Additional filesystem paths to grant write access for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"macos".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"preferences".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Additional macOS preferences access for this command. Supported values: \"readonly\" or \"readwrite\"."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"automations".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
"Additional macOS automation targets for this command as bundle IDs, or use true in clients that support boolean union payloads."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"accessibility".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Set to true to allow macOS accessibility APIs for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"calendar".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Set to true to allow macOS Calendar access for this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
create_permissions_schema(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1100,6 +1096,30 @@ fn create_request_user_input_tool(
|
||||
})
|
||||
}
|
||||
|
||||
fn create_request_permissions_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"reason".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional short explanation for why additional permissions are needed.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert("permissions".to_string(), create_permissions_schema());
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "request_permissions".to_string(),
|
||||
description: request_permissions_tool_description(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["permissions".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_close_agent_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
@@ -1819,6 +1839,7 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::MultiAgentHandler;
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::ReadFileHandler;
|
||||
use crate::tools::handlers::RequestPermissionsHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::SearchToolBm25Handler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
@@ -1839,6 +1860,7 @@ pub(crate) fn build_specs(
|
||||
let mcp_handler = Arc::new(McpHandler);
|
||||
let mcp_resource_handler = Arc::new(McpResourceHandler);
|
||||
let shell_command_handler = Arc::new(ShellCommandHandler::from(config.shell_command_backend));
|
||||
let request_permissions_handler = Arc::new(RequestPermissionsHandler);
|
||||
let request_user_input_handler = Arc::new(RequestUserInputHandler {
|
||||
default_mode_request_user_input: config.default_mode_request_user_input,
|
||||
});
|
||||
@@ -1912,6 +1934,11 @@ pub(crate) fn build_specs(
|
||||
builder.register_handler("request_user_input", request_user_input_handler);
|
||||
}
|
||||
|
||||
if config.request_permissions_tool_enabled {
|
||||
builder.push_spec(create_request_permissions_tool());
|
||||
builder.register_handler("request_permissions", request_permissions_handler);
|
||||
}
|
||||
|
||||
if config.search_tool
|
||||
&& let Some(app_tools) = app_tools
|
||||
{
|
||||
@@ -2296,6 +2323,11 @@ mod tests {
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
}
|
||||
|
||||
if config.request_permission_enabled {
|
||||
let spec = create_request_permissions_tool();
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
}
|
||||
|
||||
// Exact name set match — this is the only test allowed to fail when tools change.
|
||||
let actual_names: HashSet<_> = actual.keys().cloned().collect();
|
||||
let expected_names: HashSet<_> = expected.keys().cloned().collect();
|
||||
@@ -2424,6 +2456,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_permissions_requires_feature_flag() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let features = Features::with_defaults();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
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,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
let request_permissions_tool = find_tool(&tools, "request_permissions");
|
||||
assert_eq!(
|
||||
request_permissions_tool.spec,
|
||||
create_request_permissions_tool()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_permissions_tool_is_independent_from_additional_permissions() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::RequestPermissions);
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
|
||||
assert_lacks_tool_name(&tools, "request_permissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_memory_requires_feature_flag() {
|
||||
let config = test_config();
|
||||
@@ -3528,6 +3609,67 @@ Examples of valid command strings:
|
||||
assert!(additional_properties.contains_key("macos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_permissions_tool_includes_full_permission_schema() {
|
||||
let tool = super::create_request_permissions_tool();
|
||||
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
let JsonSchema::Object { properties, .. } = parameters else {
|
||||
panic!("expected object parameters");
|
||||
};
|
||||
let Some(JsonSchema::Object {
|
||||
properties: permission_properties,
|
||||
additional_properties,
|
||||
..
|
||||
}) = properties.get("permissions")
|
||||
else {
|
||||
panic!("expected permissions object");
|
||||
};
|
||||
|
||||
assert_eq!(additional_properties, &Some(false.into()));
|
||||
assert!(permission_properties.contains_key("network"));
|
||||
assert!(permission_properties.contains_key("file_system"));
|
||||
assert!(permission_properties.contains_key("macos"));
|
||||
|
||||
let Some(JsonSchema::Object {
|
||||
properties: network_properties,
|
||||
additional_properties,
|
||||
..
|
||||
}) = permission_properties.get("network")
|
||||
else {
|
||||
panic!("expected network object");
|
||||
};
|
||||
assert_eq!(additional_properties, &Some(false.into()));
|
||||
assert!(network_properties.contains_key("enabled"));
|
||||
|
||||
let Some(JsonSchema::Object {
|
||||
properties: file_system_properties,
|
||||
additional_properties,
|
||||
..
|
||||
}) = permission_properties.get("file_system")
|
||||
else {
|
||||
panic!("expected file_system object");
|
||||
};
|
||||
assert_eq!(additional_properties, &Some(false.into()));
|
||||
assert!(file_system_properties.contains_key("read"));
|
||||
assert!(file_system_properties.contains_key("write"));
|
||||
|
||||
let Some(JsonSchema::Object {
|
||||
properties: macos_properties,
|
||||
additional_properties,
|
||||
..
|
||||
}) = permission_properties.get("macos")
|
||||
else {
|
||||
panic!("expected macos object");
|
||||
};
|
||||
assert_eq!(additional_properties, &Some(false.into()));
|
||||
assert!(macos_properties.contains_key("preferences"));
|
||||
assert!(macos_properties.contains_key("automations"));
|
||||
assert!(macos_properties.contains_key("accessibility"));
|
||||
assert!(macos_properties.contains_key("calendar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_command_tool() {
|
||||
let tool = super::create_shell_command_tool(true, false);
|
||||
|
||||
@@ -95,6 +95,7 @@ pub(crate) struct ExecCommandRequest {
|
||||
pub tty: bool,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub additional_permissions: Option<PermissionProfile>,
|
||||
pub additional_permissions_preapproved: bool,
|
||||
pub justification: Option<String>,
|
||||
pub prefix_rule: Option<Vec<String>>,
|
||||
}
|
||||
@@ -240,6 +241,7 @@ mod tests {
|
||||
tty: true,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
additional_permissions_preapproved: false,
|
||||
justification: None,
|
||||
prefix_rule: None,
|
||||
},
|
||||
|
||||
@@ -583,7 +583,11 @@ impl UnifiedExecProcessManager {
|
||||
command: &request.command,
|
||||
approval_policy: context.turn.approval_policy.value(),
|
||||
sandbox_policy: context.turn.sandbox_policy.get(),
|
||||
sandbox_permissions: request.sandbox_permissions,
|
||||
sandbox_permissions: if request.additional_permissions_preapproved {
|
||||
crate::sandboxing::SandboxPermissions::UseDefault
|
||||
} else {
|
||||
request.sandbox_permissions
|
||||
},
|
||||
prefix_rule: request.prefix_rule.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
Reference in New Issue
Block a user