Codex Handbook
core/src/tools/network_approval.rs 767 lines
use crate::guardian::GuardianApprovalRequest;use crate::guardian::GuardianNetworkAccessTrigger;use crate::guardian::guardian_rejection_message;use crate::guardian::guardian_timeout_message;use crate::guardian::new_guardian_review_id;use crate::guardian::review_approval_request;use crate::guardian::routes_approval_to_guardian;use crate::hook_runtime::run_permission_request_hooks;use crate::network_policy_decision::denied_network_policy_message;use crate::session::session::Session;use crate::tools::sandboxing::PermissionRequestPayload;use crate::tools::sandboxing::ToolError;use codex_hooks::PermissionRequestDecision;use codex_network_proxy::BlockedRequest;use codex_network_proxy::BlockedRequestObserver;use codex_network_proxy::NetworkDecision;use codex_network_proxy::NetworkPolicyDecider;use codex_network_proxy::NetworkPolicyRequest;use codex_network_proxy::NetworkProtocol;use codex_network_proxy::NetworkProxy;use codex_protocol::approvals::NetworkApprovalContext;use codex_protocol::approvals::NetworkApprovalProtocol;use codex_protocol::approvals::NetworkPolicyRuleAction;use codex_protocol::models::PermissionProfile;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::Event;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::ReviewDecision;use codex_protocol::protocol::WarningEvent;use indexmap::IndexMap;use std::collections::HashMap;use std::collections::HashSet;use std::sync::Arc;use tokio::sync::Mutex;use tokio::sync::Notify;use tokio::sync::OnceCell;use tokio::sync::RwLock;use tokio_util::sync::CancellationToken;use tracing::warn;use uuid::Uuid;#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub(crate) enum NetworkApprovalMode {    Immediate,    Deferred,}#[derive(Clone, Debug)]pub(crate) struct NetworkApprovalSpec {    pub network: Option<NetworkProxy>,    pub mode: NetworkApprovalMode,    pub trigger: GuardianNetworkAccessTrigger,    pub command: String,}#[derive(Clone, Debug)]pub(crate) struct DeferredNetworkApproval {    registration_id: String,    cancellation_token: CancellationToken,    finish_outcome: Arc<OnceCell<Option<NetworkApprovalOutcome>>>,}impl DeferredNetworkApproval {    pub(crate) fn registration_id(&self) -> &str {        &self.registration_id    }    pub(crate) fn cancellation_token(&self) -> CancellationToken {        self.cancellation_token.clone()    }    pub(crate) fn is_cancelled(&self) -> bool {        self.cancellation_token.is_cancelled()    }    async fn finish(&self, service: &NetworkApprovalService) -> Result<(), ToolError> {        let outcome = self            .finish_outcome            .get_or_init(|| async { service.finish_call_outcome(&self.registration_id).await })            .await            .clone();        network_approval_outcome_to_result(outcome)    }}#[derive(Debug)]pub(crate) struct ActiveNetworkApproval {    registration_id: Option<String>,    mode: NetworkApprovalMode,    cancellation_token: CancellationToken,}impl ActiveNetworkApproval {    pub(crate) fn mode(&self) -> NetworkApprovalMode {        self.mode    }    pub(crate) fn cancellation_token(&self) -> CancellationToken {        self.cancellation_token.clone()    }    pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {        let ActiveNetworkApproval {            registration_id,            mode,            cancellation_token,        } = self;        match (mode, registration_id) {            (NetworkApprovalMode::Deferred, Some(registration_id)) => {                Some(DeferredNetworkApproval {                    registration_id,                    cancellation_token,                    finish_outcome: Arc::new(OnceCell::new()),                })            }            _ => None,        }    }}#[derive(Clone, Debug, Eq, Hash, PartialEq)]struct HostApprovalKey {    host: String,    protocol: &'static str,    port: u16,}impl HostApprovalKey {    fn from_request(request: &NetworkPolicyRequest, protocol: NetworkApprovalProtocol) -> Self {        Self {            host: request.host.to_ascii_lowercase(),            protocol: protocol_key_label(protocol),            port: request.port,        }    }}fn protocol_key_label(protocol: NetworkApprovalProtocol) -> &'static str {    match protocol {        NetworkApprovalProtocol::Http => "http",        NetworkApprovalProtocol::Https => "https",        NetworkApprovalProtocol::Socks5Tcp => "socks5-tcp",        NetworkApprovalProtocol::Socks5Udp => "socks5-udp",    }}#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum PendingApprovalDecision {    AllowOnce,    AllowForSession,    Deny,}#[derive(Clone, Debug, Eq, PartialEq)]enum NetworkApprovalOutcome {    DeniedByUser,    DeniedByPolicy(String),}fn network_approval_outcome_to_result(    outcome: Option<NetworkApprovalOutcome>,) -> Result<(), ToolError> {    match outcome {        Some(NetworkApprovalOutcome::DeniedByUser) => {            Err(ToolError::Rejected("rejected by user".to_string()))        }        Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Err(ToolError::Rejected(message)),        None => Ok(()),    }}/// Whether an allowlist miss may be reviewed instead of hard-denied.fn allows_network_approval_flow(policy: AskForApproval) -> bool {    !matches!(policy, AskForApproval::Never)}fn permission_profile_allows_network_approval_flow(permission_profile: &PermissionProfile) -> bool {    matches!(permission_profile, PermissionProfile::Managed { .. })}impl PendingApprovalDecision {    fn to_network_decision(self) -> NetworkDecision {        match self {            Self::AllowOnce | Self::AllowForSession => NetworkDecision::Allow,            Self::Deny => NetworkDecision::deny("not_allowed"),        }    }}struct PendingHostApproval {    decision: Mutex<Option<PendingApprovalDecision>>,    notify: Notify,}impl PendingHostApproval {    fn new() -> Self {        Self {            decision: Mutex::new(None),            notify: Notify::new(),        }    }    async fn wait_for_decision(&self) -> PendingApprovalDecision {        loop {            let notified = self.notify.notified();            if let Some(decision) = *self.decision.lock().await {                return decision;            }            notified.await;        }    }    async fn set_decision(&self, decision: PendingApprovalDecision) {        {            let mut current = self.decision.lock().await;            *current = Some(decision);        }        self.notify.notify_waiters();    }}struct ActiveNetworkApprovalCall {    registration_id: String,    turn_id: String,    trigger: GuardianNetworkAccessTrigger,    command: String,    cancellation_token: CancellationToken,}#[derive(Default)]struct NetworkApprovalCallState {    active_calls: IndexMap<String, Arc<ActiveNetworkApprovalCall>>,    call_outcomes: HashMap<String, NetworkApprovalOutcome>,}pub(crate) struct NetworkApprovalService {    calls: Mutex<NetworkApprovalCallState>,    pending_host_approvals: Mutex<HashMap<HostApprovalKey, Arc<PendingHostApproval>>>,    session_approved_hosts: Mutex<HashSet<HostApprovalKey>>,    session_denied_hosts: Mutex<HashSet<HostApprovalKey>>,}impl Default for NetworkApprovalService {    fn default() -> Self {        Self {            calls: Mutex::new(NetworkApprovalCallState::default()),            pending_host_approvals: Mutex::new(HashMap::new()),            session_approved_hosts: Mutex::new(HashSet::new()),            session_denied_hosts: Mutex::new(HashSet::new()),        }    }}impl NetworkApprovalService {    /// Replace the target session's approval cache with the source session's    /// currently approved hosts.    pub(crate) async fn sync_session_approved_hosts_to(&self, other: &Self) {        let approved_hosts = self.session_approved_hosts.lock().await.clone();        let mut other_approved_hosts = other.session_approved_hosts.lock().await;        other_approved_hosts.clear();        other_approved_hosts.extend(approved_hosts.iter().cloned());    }    async fn register_call(        &self,        registration_id: String,        turn_id: String,        trigger: GuardianNetworkAccessTrigger,        command: String,        cancellation_token: CancellationToken,    ) {        let mut calls = self.calls.lock().await;        let key = registration_id.clone();        calls.active_calls.insert(            key,            Arc::new(ActiveNetworkApprovalCall {                registration_id,                turn_id,                trigger,                command,                cancellation_token,            }),        );    }    pub(crate) async fn unregister_call(&self, registration_id: &str) {        self.remove_call(registration_id).await;    }    async fn resolve_single_active_call(&self) -> Option<Arc<ActiveNetworkApprovalCall>> {        let calls = self.calls.lock().await;        // Blocked proxy requests are not attributed to a specific tool call. Only pick an owner        // when there is exactly one candidate; with concurrent calls, canceling one would be a guess.        // TODO: Carry blocked-request attribution so concurrent active calls can be handled safely.        if calls.active_calls.len() == 1 {            return calls.active_calls.values().next().cloned();        }        None    }    async fn get_or_create_pending_approval(        &self,        key: HostApprovalKey,    ) -> (Arc<PendingHostApproval>, bool) {        let mut pending = self.pending_host_approvals.lock().await;        if let Some(existing) = pending.get(&key).cloned() {            return (existing, false);        }        let created = Arc::new(PendingHostApproval::new());        pending.insert(key, Arc::clone(&created));        (created, true)    }    async fn record_outcome_for_single_active_call(&self, outcome: NetworkApprovalOutcome) {        let Some(owner_call) = self.resolve_single_active_call().await else {            return;        };        self.record_call_outcome(&owner_call.registration_id, outcome)            .await;    }    #[cfg(test)]    async fn take_call_outcome(&self, registration_id: &str) -> Option<NetworkApprovalOutcome> {        let mut calls = self.calls.lock().await;        calls.call_outcomes.remove(registration_id)    }    async fn record_call_outcome(&self, registration_id: &str, outcome: NetworkApprovalOutcome) {        let mut calls = self.calls.lock().await;        let Some(call) = calls.active_calls.get(registration_id).cloned() else {            return;        };        if matches!(            calls.call_outcomes.get(registration_id),            Some(NetworkApprovalOutcome::DeniedByUser)        ) {            return;        }        calls            .call_outcomes            .insert(registration_id.to_string(), outcome);        drop(calls);        call.cancellation_token.cancel();    }    async fn remove_call(&self, registration_id: &str) -> Option<NetworkApprovalOutcome> {        let mut calls = self.calls.lock().await;        calls.active_calls.shift_remove(registration_id);        calls.call_outcomes.remove(registration_id)    }    async fn finish_call_outcome(&self, registration_id: &str) -> Option<NetworkApprovalOutcome> {        self.remove_call(registration_id).await    }    async fn finish_call(&self, registration_id: &str) -> Result<(), ToolError> {        network_approval_outcome_to_result(self.finish_call_outcome(registration_id).await)    }    pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) {        let Some(message) = denied_network_policy_message(&blocked) else {            return;        };        self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message))            .await;    }    async fn active_turn_context(        session: &Session,    ) -> Option<Arc<crate::session::turn_context::TurnContext>> {        let active_turn = session.active_turn.lock().await;        active_turn            .as_ref()            .and_then(|turn| turn.task.as_ref())            .map(|task| Arc::clone(&task.turn_context))    }    fn format_network_target(protocol: &str, host: &str, port: u16) -> String {        format!("{protocol}://{host}:{port}")    }    fn approval_id_for_key(key: &HostApprovalKey) -> String {        format!("network#{}#{}#{}", key.protocol, key.host, key.port)    }    pub(crate) async fn handle_inline_policy_request(        &self,        session: Arc<Session>,        request: NetworkPolicyRequest,    ) -> NetworkDecision {        const REASON_NOT_ALLOWED: &str = "not_allowed";        let protocol = match request.protocol {            NetworkProtocol::Http => NetworkApprovalProtocol::Http,            NetworkProtocol::HttpsConnect => NetworkApprovalProtocol::Https,            NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp,            NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp,        };        let key = HostApprovalKey::from_request(&request, protocol);        {            let denied_hosts = self.session_denied_hosts.lock().await;            if denied_hosts.contains(&key) {                return NetworkDecision::deny(REASON_NOT_ALLOWED);            }        }        {            let approved_hosts = self.session_approved_hosts.lock().await;            if approved_hosts.contains(&key) {                return NetworkDecision::Allow;            }        }        let (pending, is_owner) = self.get_or_create_pending_approval(key.clone()).await;        if !is_owner {            return pending.wait_for_decision().await.to_network_decision();        }        let target = Self::format_network_target(key.protocol, request.host.as_str(), key.port);        let policy_denial_message =            format!("Network access to \"{target}\" was blocked by policy.");        let prompt_reason = format!("{} is not in the allowed_domains", request.host);        let Some(turn_context) = Self::active_turn_context(session.as_ref()).await else {            pending.set_decision(PendingApprovalDecision::Deny).await;            self.pending_host_approvals.lock().await.remove(&key);            self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(                policy_denial_message,            ))            .await;            return NetworkDecision::deny(REASON_NOT_ALLOWED);        };        if !permission_profile_allows_network_approval_flow(&turn_context.permission_profile()) {            pending.set_decision(PendingApprovalDecision::Deny).await;            self.pending_host_approvals.lock().await.remove(&key);            self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(                policy_denial_message,            ))            .await;            return NetworkDecision::deny(REASON_NOT_ALLOWED);        }        if !allows_network_approval_flow(turn_context.approval_policy.value()) {            pending.set_decision(PendingApprovalDecision::Deny).await;            self.pending_host_approvals.lock().await.remove(&key);            self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(                policy_denial_message,            ))            .await;            return NetworkDecision::deny(REASON_NOT_ALLOWED);        }        let owner_call = self.resolve_single_active_call().await;        let network_approval_context = NetworkApprovalContext {            host: request.host.clone(),            protocol,        };        let guardian_approval_id = Self::approval_id_for_key(&key);        let prompt_command = vec!["network-access".to_string(), target.clone()];        let command = owner_call            .as_ref()            .map_or_else(|| prompt_command.join(" "), |call| call.command.clone());        if let Some(permission_request_decision) = run_permission_request_hooks(            &session,            &turn_context,            &guardian_approval_id,            PermissionRequestPayload::bash(command, Some(format!("network-access {target}"))),        )        .await        {            match permission_request_decision {                PermissionRequestDecision::Allow => {                    pending                        .set_decision(PendingApprovalDecision::AllowOnce)                        .await;                    let mut pending_approvals = self.pending_host_approvals.lock().await;                    pending_approvals.remove(&key);                    return NetworkDecision::Allow;                }                PermissionRequestDecision::Deny { message } => {                    if let Some(owner_call) = owner_call.as_ref() {                        self.record_call_outcome(                            &owner_call.registration_id,                            NetworkApprovalOutcome::DeniedByPolicy(message),                        )                        .await;                    }                    pending.set_decision(PendingApprovalDecision::Deny).await;                    let mut pending_approvals = self.pending_host_approvals.lock().await;                    pending_approvals.remove(&key);                    return NetworkDecision::deny(REASON_NOT_ALLOWED);                }            }        }        let use_guardian = routes_approval_to_guardian(&turn_context);        let guardian_review_id = use_guardian.then(new_guardian_review_id);        let approval_decision = if let Some(review_id) = guardian_review_id.clone() {            review_approval_request(                &session,                &turn_context,                review_id,                GuardianApprovalRequest::NetworkAccess {                    id: guardian_approval_id.clone(),                    turn_id: owner_call                        .as_ref()                        .map_or_else(|| turn_context.sub_id.clone(), |call| call.turn_id.clone()),                    target,                    host: request.host,                    protocol,                    port: key.port,                    trigger: owner_call.as_ref().map(|call| call.trigger.clone()),                },                Some(policy_denial_message.clone()),            )            .await        } else {            let available_decisions = None;            session                .request_command_approval(                    turn_context.as_ref(),                    guardian_approval_id,                    /*approval_id*/ None,                    prompt_command,                    #[allow(deprecated)]                    turn_context.cwd.clone(),                    Some(prompt_reason),                    Some(network_approval_context.clone()),                    /*proposed_execpolicy_amendment*/ None,                    /*additional_permissions*/ None,                    available_decisions,                )                .await        };        let mut cache_session_deny = false;        let resolved = match approval_decision {            ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => {                PendingApprovalDecision::AllowOnce            }            ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession,            ReviewDecision::NetworkPolicyAmendment {                network_policy_amendment,            } => match network_policy_amendment.action {                NetworkPolicyRuleAction::Allow => {                    match session                        .persist_network_policy_amendment(                            &network_policy_amendment,                            &network_approval_context,                        )                        .await                    {                        Ok(()) => {                            session                                .record_network_policy_amendment_message(                                    &turn_context.sub_id,                                    &network_policy_amendment,                                )                                .await;                        }                        Err(err) => {                            let message =                                format!("Failed to apply network policy amendment: {err}");                            warn!("{message}");                            session                                .send_event_raw(Event {                                    id: turn_context.sub_id.clone(),                                    msg: EventMsg::Warning(WarningEvent { message }),                                })                                .await;                        }                    }                    PendingApprovalDecision::AllowForSession                }                NetworkPolicyRuleAction::Deny => {                    match session                        .persist_network_policy_amendment(                            &network_policy_amendment,                            &network_approval_context,                        )                        .await                    {                        Ok(()) => {                            session                                .record_network_policy_amendment_message(                                    &turn_context.sub_id,                                    &network_policy_amendment,                                )                                .await;                        }                        Err(err) => {                            let message =                                format!("Failed to apply network policy amendment: {err}");                            warn!("{message}");                            session                                .send_event_raw(Event {                                    id: turn_context.sub_id.clone(),                                    msg: EventMsg::Warning(WarningEvent { message }),                                })                                .await;                        }                    }                    if let Some(owner_call) = owner_call.as_ref() {                        self.record_call_outcome(                            &owner_call.registration_id,                            NetworkApprovalOutcome::DeniedByUser,                        )                        .await;                    }                    cache_session_deny = true;                    PendingApprovalDecision::Deny                }            },            ReviewDecision::Denied | ReviewDecision::Abort => {                if let Some(review_id) = guardian_review_id.as_deref() {                    if let Some(owner_call) = owner_call.as_ref() {                        let message = guardian_rejection_message(session.as_ref(), review_id).await;                        self.record_call_outcome(                            &owner_call.registration_id,                            NetworkApprovalOutcome::DeniedByPolicy(message),                        )                        .await;                    }                } else if let Some(owner_call) = owner_call.as_ref() {                    self.record_call_outcome(                        &owner_call.registration_id,                        NetworkApprovalOutcome::DeniedByUser,                    )                    .await;                }                PendingApprovalDecision::Deny            }            ReviewDecision::TimedOut => {                if let Some(owner_call) = owner_call.as_ref() {                    self.record_call_outcome(                        &owner_call.registration_id,                        NetworkApprovalOutcome::DeniedByPolicy(guardian_timeout_message()),                    )                    .await;                }                PendingApprovalDecision::Deny            }        };        if matches!(resolved, PendingApprovalDecision::AllowForSession) {            {                let mut denied_hosts = self.session_denied_hosts.lock().await;                denied_hosts.remove(&key);            }            let mut approved_hosts = self.session_approved_hosts.lock().await;            approved_hosts.insert(key.clone());        }        if cache_session_deny {            {                let mut approved_hosts = self.session_approved_hosts.lock().await;                approved_hosts.remove(&key);            }            let mut denied_hosts = self.session_denied_hosts.lock().await;            denied_hosts.insert(key.clone());        }        pending.set_decision(resolved).await;        let mut pending_approvals = self.pending_host_approvals.lock().await;        pending_approvals.remove(&key);        resolved.to_network_decision()    }}pub(crate) fn build_blocked_request_observer(    network_approval: Arc<NetworkApprovalService>,) -> Arc<dyn BlockedRequestObserver> {    Arc::new(move |blocked: BlockedRequest| {        let network_approval = Arc::clone(&network_approval);        async move {            network_approval.record_blocked_request(blocked).await;        }    })}pub(crate) fn build_network_policy_decider(    network_approval: Arc<NetworkApprovalService>,    network_policy_decider_session: Arc<RwLock<std::sync::Weak<Session>>>,) -> Arc<dyn NetworkPolicyDecider> {    Arc::new(move |request: NetworkPolicyRequest| {        let network_approval = Arc::clone(&network_approval);        let network_policy_decider_session = Arc::clone(&network_policy_decider_session);        async move {            let Some(session) = network_policy_decider_session.read().await.upgrade() else {                return NetworkDecision::ask("not_allowed");            };            network_approval                .handle_inline_policy_request(session, request)                .await        }    })}pub(crate) async fn begin_network_approval(    session: &Session,    turn_id: &str,    managed_network_active: bool,    spec: Option<NetworkApprovalSpec>,) -> Option<ActiveNetworkApproval> {    let NetworkApprovalSpec {        network,        mode,        trigger,        command,    } = spec?;    if !managed_network_active || network.is_none() {        return None;    }    let registration_id = Uuid::new_v4().to_string();    let cancellation_token = CancellationToken::new();    session        .services        .network_approval        .register_call(            registration_id.clone(),            turn_id.to_string(),            trigger,            command,            cancellation_token.clone(),        )        .await;    Some(ActiveNetworkApproval {        registration_id: Some(registration_id),        mode,        cancellation_token,    })}pub(crate) async fn finish_immediate_network_approval(    session: &Session,    active: ActiveNetworkApproval,) -> Result<(), ToolError> {    let Some(registration_id) = active.registration_id.as_deref() else {        return Ok(());    };    session        .services        .network_approval        .finish_call(registration_id)        .await}pub(crate) async fn finish_deferred_network_approval(    session: &Session,    deferred: Option<DeferredNetworkApproval>,) -> Result<(), ToolError> {    let Some(deferred) = deferred else {        return Ok(());    };    deferred.finish(&session.services.network_approval).await}#[cfg(test)]#[path = "network_approval_tests.rs"]mod tests;