Skip to content
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

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

2 changes: 2 additions & 0 deletions codex-rs/core/src/network_policy_decision_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ fn denied_network_policy_message_requires_deny_decision() {
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
execution_id: None,
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
Expand All @@ -178,6 +179,7 @@ fn denied_network_policy_message_for_denylist_block_is_explicit() {
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
execution_id: None,
decision: Some("deny".to_string()),
source: Some("baseline_policy".to_string()),
port: Some(80),
Expand Down
149 changes: 116 additions & 33 deletions codex-rs/core/src/tools/network_approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::WarningEvent;
use codex_sandboxing::SandboxType;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::Notify;
Expand Down Expand Up @@ -59,6 +61,7 @@ pub(crate) struct DeferredNetworkApproval {
registration_id: String,
cancellation_token: CancellationToken,
finish_outcome: Arc<OnceCell<Option<NetworkApprovalOutcome>>>,
_execution_proxy: Option<NetworkProxy>,
}

impl DeferredNetworkApproval {
Expand Down Expand Up @@ -89,6 +92,7 @@ pub(crate) struct ActiveNetworkApproval {
registration_id: Option<String>,
mode: NetworkApprovalMode,
cancellation_token: CancellationToken,
execution_proxy: NetworkProxy,
}

impl ActiveNetworkApproval {
Expand All @@ -100,18 +104,24 @@ impl ActiveNetworkApproval {
self.cancellation_token.clone()
}

pub(crate) fn execution_proxy(&self) -> &NetworkProxy {
&self.execution_proxy
}

pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {
let ActiveNetworkApproval {
registration_id,
mode,
cancellation_token,
execution_proxy,
} = self;
match (mode, registration_id) {
(NetworkApprovalMode::Deferred, Some(registration_id)) => {
Some(DeferredNetworkApproval {
registration_id,
cancellation_token,
finish_outcome: Arc::new(OnceCell::new()),
_execution_proxy: Some(execution_proxy),
})
}
_ => None,
Expand Down Expand Up @@ -241,6 +251,11 @@ enum ActiveNetworkApprovalAttribution {
Ambiguous,
}

struct NetworkRequestAttribution {
owner_call: Option<Arc<ActiveNetworkApprovalCall>>,
environment_id: Option<String>,
}

#[derive(Default)]
struct NetworkApprovalCallState {
active_calls: IndexMap<String, Arc<ActiveNetworkApprovalCall>>,
Expand Down Expand Up @@ -305,16 +320,27 @@ impl NetworkApprovalService {

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.
// Shared proxy requests can still arrive without an execution ID. Only pick an owner when
// there is exactly one candidate; with concurrent calls, canceling one would be a guess.
if calls.active_calls.len() == 1 {
return calls.active_calls.values().next().cloned();
}

None
}

async fn resolve_active_call_by_execution_id(
&self,
execution_id: &str,
) -> Option<Arc<ActiveNetworkApprovalCall>> {
self.calls
.lock()
.await
.active_calls
.get(execution_id)
.cloned()
}

async fn resolve_active_call_attribution(&self) -> ActiveNetworkApprovalAttribution {
let calls = self.calls.lock().await;
match calls.active_calls.len() {
Expand All @@ -327,6 +353,54 @@ impl NetworkApprovalService {
}
}

async fn resolve_request_attribution(
&self,
request: &NetworkPolicyRequest,
) -> Option<NetworkRequestAttribution> {
if let Some(execution_id) = request.execution_id.as_deref() {
let call = self
.resolve_active_call_by_execution_id(execution_id)
.await?;
let environment_id = request
.environment_id
.clone()
.unwrap_or_else(|| call.environment_id.clone());
return (call.environment_id == environment_id).then_some(NetworkRequestAttribution {
owner_call: Some(call),
environment_id: Some(environment_id),
});
}

if let Some(environment_id) = request.environment_id.clone() {
let owner_call = match self.resolve_active_call_attribution().await {
ActiveNetworkApprovalAttribution::Single(call) => {
(call.environment_id == environment_id).then_some(call)
}
ActiveNetworkApprovalAttribution::None
| ActiveNetworkApprovalAttribution::Ambiguous => None,
};
return Some(NetworkRequestAttribution {
owner_call,
environment_id: Some(environment_id),
});
}

match self.resolve_active_call_attribution().await {
ActiveNetworkApprovalAttribution::None => Some(NetworkRequestAttribution {
owner_call: None,
environment_id: None,
}),
ActiveNetworkApprovalAttribution::Single(call) => {
let environment_id = call.environment_id.clone();
Some(NetworkRequestAttribution {
owner_call: Some(call),
environment_id: Some(environment_id),
})
}
ActiveNetworkApprovalAttribution::Ambiguous => None,
}
}

async fn get_or_create_pending_approval(
&self,
key: HostApprovalKey,
Expand Down Expand Up @@ -393,8 +467,12 @@ impl NetworkApprovalService {
return;
};

self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message))
.await;
let outcome = NetworkApprovalOutcome::DeniedByPolicy(message);
if let Some(execution_id) = blocked.execution_id.as_deref() {
self.record_call_outcome(execution_id, outcome).await;
} else {
self.record_outcome_for_single_active_call(outcome).await;
}
}

async fn active_turn_context(
Expand Down Expand Up @@ -431,28 +509,13 @@ impl NetworkApprovalService {
NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp,
NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp,
};
let (owner_call, active_environment_id) =
if let Some(environment_id) = request.environment_id.clone() {
let owner_call = match self.resolve_active_call_attribution().await {
ActiveNetworkApprovalAttribution::Single(call) => {
(call.environment_id == environment_id).then_some(call)
}
ActiveNetworkApprovalAttribution::None
| ActiveNetworkApprovalAttribution::Ambiguous => None,
};
(owner_call, Some(environment_id))
} else {
match self.resolve_active_call_attribution().await {
ActiveNetworkApprovalAttribution::None => (None, None),
ActiveNetworkApprovalAttribution::Single(call) => {
let environment_id = call.environment_id.clone();
(Some(call), Some(environment_id))
}
ActiveNetworkApprovalAttribution::Ambiguous => {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
}
};
let Some(NetworkRequestAttribution {
owner_call,
environment_id: active_environment_id,
}) = self.resolve_request_attribution(&request).await
else {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
};
let turn_context = Self::active_turn_context(session.as_ref()).await;
let Some(environment_id) = active_environment_id.or_else(|| {
turn_context
Expand Down Expand Up @@ -788,20 +851,39 @@ pub(crate) async fn begin_network_approval(
session: &Session,
turn_id: &str,
managed_network_active: bool,
selected_sandbox: SandboxType,
spec: Option<NetworkApprovalSpec>,
) -> Option<ActiveNetworkApproval> {
) -> Result<Option<ActiveNetworkApproval>, ToolError> {
let NetworkApprovalSpec {
network,
mode,
trigger,
command,
environment_id,
} = spec?;
if !managed_network_active || network.is_none() {
return None;
} = match spec {
Some(spec) => spec,
None => return Ok(None),
};
let Some(network) = network else {
return Ok(None);
};
if !managed_network_active {
return Ok(None);
}

let registration_id = Uuid::new_v4().to_string();
let execution_proxy = if selected_sandbox == SandboxType::LinuxSeccomp {
let attribution_token = Uuid::new_v4().to_string();
network
.for_execution(&environment_id, &registration_id, attribution_token)
.map_err(|err| {
ToolError::Codex(codex_protocol::error::CodexErr::Io(io::Error::other(
format!("failed to create execution-scoped network proxy: {err}"),
)))
})?
} else {
network.clone()
};
let cancellation_token = CancellationToken::new();
session
.services
Expand All @@ -816,11 +898,12 @@ pub(crate) async fn begin_network_approval(
)
.await;

Some(ActiveNetworkApproval {
Ok(Some(ActiveNetworkApproval {
registration_id: Some(registration_id),
mode,
cancellation_token,
})
execution_proxy,
}))
}

pub(crate) async fn finish_immediate_network_approval(
Expand Down
31 changes: 31 additions & 0 deletions codex-rs/core/src/tools/network_approval_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@ fn denied_blocked_request(host: &str) -> BlockedRequest {
})
}

fn denied_blocked_request_for_execution(host: &str, execution_id: &str) -> BlockedRequest {
let mut blocked = denied_blocked_request(host);
blocked.execution_id = Some(execution_id.to_string());
blocked
}

async fn register_call_with_default_shell_trigger(
service: &NetworkApprovalService,
registration_id: &str,
Expand Down Expand Up @@ -429,6 +435,7 @@ async fn deferred_finish_reuses_denial_result_after_first_consumer() {
registration_id: "registration-1".to_string(),
cancellation_token,
finish_outcome: Arc::new(OnceCell::new()),
_execution_proxy: None,
};
service
.record_call_outcome(
Expand Down Expand Up @@ -481,3 +488,27 @@ async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests(
assert_eq!(service.take_call_outcome("registration-1").await, None);
assert_eq!(service.take_call_outcome("registration-2").await, None);
}

#[tokio::test]
async fn attributed_blocked_request_targets_one_of_multiple_active_calls() {
let service = NetworkApprovalService::default();
let first = register_call_with_default_shell_trigger(&service, "registration-1").await;
let second = register_call_with_default_shell_trigger(&service, "registration-2").await;

service
.record_blocked_request(denied_blocked_request_for_execution(
"example.com",
"registration-2",
))
.await;

assert!(!first.is_cancelled());
assert!(second.is_cancelled());
assert_eq!(service.take_call_outcome("registration-1").await, None);
assert_eq!(
service.take_call_outcome("registration-2").await,
Some(NetworkApprovalOutcome::DeniedByPolicy(
"Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string()
))
);
}
14 changes: 12 additions & 2 deletions codex-rs/core/src/tools/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@ impl ToolOrchestrator {
where
T: ToolRuntime<Rq, Out>,
{
let network_approval = begin_network_approval(
let network_approval = match begin_network_approval(
&tool_ctx.session,
&tool_ctx.turn.sub_id,
managed_network_active,
attempt.sandbox,
tool.network_approval_spec(req, tool_ctx),
)
.await;
.await
{
Ok(network_approval) => network_approval,
Err(err) => return (Err(err), None),
};

let attempt_tool_ctx = ToolCtx {
session: tool_ctx.session.clone(),
Expand All @@ -99,6 +104,9 @@ impl ToolOrchestrator {
network_denial_cancellation_token: network_approval
.as_ref()
.map(ActiveNetworkApproval::cancellation_token),
network_proxy: network_approval
.as_ref()
.map(ActiveNetworkApproval::execution_proxy),
};
let run_result = tool
.run(req, &attempt_with_network_approval, &attempt_tool_ctx)
Expand Down Expand Up @@ -275,6 +283,7 @@ impl ToolOrchestrator {
.permissions
.windows_sandbox_private_desktop,
network_denial_cancellation_token: None,
network_proxy: None,
};

let initial_attempt_start = Instant::now();
Expand Down Expand Up @@ -457,6 +466,7 @@ impl ToolOrchestrator {
.permissions
.windows_sandbox_private_desktop,
network_denial_cancellation_token: None,
network_proxy: None,
};

// Second attempt.
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/core/src/tools/runtimes/apply_patch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ async fn file_system_sandbox_context_uses_active_attempt() {
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
windows_sandbox_private_desktop: true,
network_denial_cancellation_token: None,
network_proxy: None,
};

let sandbox = ApplyPatchRuntime::file_system_sandbox_context_for_attempt(&req, &attempt)
Expand Down Expand Up @@ -300,6 +301,7 @@ async fn no_sandbox_attempt_has_no_file_system_context() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
network_denial_cancellation_token: None,
network_proxy: None,
};

assert_eq!(
Expand Down
Loading
Loading