Refactor network approvals to host/protocol/port scope (#12140)

## Summary
Simplify network approvals by removing per-attempt proxy correlation and
moving to session-level approval dedupe keyed by (host, protocol, port).
Instead of encoding attempt IDs into proxy credentials/URLs, we now
treat approvals as a destination policy decision.

- Concurrent calls to the same destination share one approval prompt.
- Different destinations (or same host on different ports) get separate
prompts.
- Allow once approves the current queued request group only.
- Allow for session caches that (host, protocol, port) and auto-allows
future matching requests.
- Never policy continues to deny without prompting.

Example:
- 3 calls: 
  - a.com (line 443)
  - b.com (line 443)
  - a.com (line 443)
=> 2 prompts total (a, b), second a waits on the first decision.
- a.com:80 is treated separately from a.com line 443

## Testing
- `just fmt` (in `codex-rs`)
- `cargo test -p codex-core tools::network_approval::tests`
- `cargo test -p codex-core` (unit tests pass; existing
integration-suite failures remain in this environment)
This commit is contained in:
viyatb-oai
2026-02-20 10:39:55 -08:00
committed by GitHub
parent 41f15bf07b
commit e8afaed502
40 changed files with 570 additions and 739 deletions
-2
View File
@@ -8347,7 +8347,6 @@ mod tests {
expiration: timeout_ms.into(),
env: HashMap::new(),
network: None,
network_attempt_id: None,
sandbox_permissions,
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: Some("test".to_string()),
@@ -8361,7 +8360,6 @@ mod tests {
expiration: timeout_ms.into(),
env: HashMap::new(),
network: None,
network_attempt_id: None,
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
arg0: None,
+3 -21
View File
@@ -15,7 +15,6 @@ use tokio::io::AsyncReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::error::CodexErr;
use crate::error::Result;
@@ -67,7 +66,6 @@ pub struct ExecParams {
pub expiration: ExecExpiration,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub network_attempt_id: Option<Uuid>,
pub sandbox_permissions: SandboxPermissions,
pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
pub justification: Option<String>,
@@ -186,15 +184,13 @@ pub async fn process_exec_tool_call(
mut env,
expiration,
network,
network_attempt_id,
sandbox_permissions,
windows_sandbox_level,
justification,
arg0: _,
} = params;
let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref());
network.apply_to_env(&mut env);
}
let (program, args) = command.split_first().ok_or_else(|| {
CodexErr::Io(io::Error::new(
@@ -242,7 +238,6 @@ pub(crate) async fn execute_exec_env(
cwd,
env,
network,
network_attempt_id,
expiration,
sandbox,
windows_sandbox_level,
@@ -251,18 +246,12 @@ pub(crate) async fn execute_exec_env(
arg0,
} = env;
let network_attempt_id = match network_attempt_id.as_deref() {
Some(attempt_id) => Uuid::parse_str(attempt_id).ok(),
None => network.as_ref().map(|_| Uuid::new_v4()),
};
let params = ExecParams {
command,
cwd,
expiration,
env,
network: network.clone(),
network_attempt_id,
sandbox_permissions,
windows_sandbox_level,
justification,
@@ -356,14 +345,12 @@ async fn exec_windows_sandbox(
cwd,
mut env,
network,
network_attempt_id,
expiration,
windows_sandbox_level,
..
} = params;
let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref());
network.apply_to_env(&mut env);
}
// TODO(iceweasel-oai): run_windows_sandbox_capture should support all
@@ -717,16 +704,13 @@ async fn exec(
cwd,
mut env,
network,
network_attempt_id,
arg0,
expiration,
windows_sandbox_level: _,
..
} = params;
let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref());
network.apply_to_env(&mut env);
}
let (program, args) = command.split_first().ok_or_else(|| {
@@ -1137,7 +1121,6 @@ mod tests {
expiration: 500.into(),
env,
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
justification: None,
@@ -1191,7 +1174,6 @@ mod tests {
expiration: ExecExpiration::Cancellation(cancel_token),
env,
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
justification: None,
@@ -220,7 +220,6 @@ mod tests {
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
@@ -238,7 +237,6 @@ mod tests {
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("deny".to_string()),
source: Some("baseline_policy".to_string()),
port: Some(80),
-2
View File
@@ -46,7 +46,6 @@ pub struct ExecRequest {
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub network_attempt_id: Option<String>,
pub expiration: ExecExpiration,
pub sandbox: SandboxType,
pub windows_sandbox_level: WindowsSandboxLevel,
@@ -222,7 +221,6 @@ impl SandboxManager {
cwd: spec.cwd,
env,
network: network.cloned(),
network_attempt_id: None,
expiration: spec.expiration,
sandbox,
windows_sandbox_level,
-1
View File
@@ -151,7 +151,6 @@ pub(crate) async fn execute_user_shell_command(
Some(session.conversation_id),
),
network: turn_context.network.clone(),
network_attempt_id: None,
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.
expiration: USER_SHELL_TIMEOUT_MS.into(),
@@ -143,7 +143,6 @@ impl ToolHandler for ApplyPatchHandler {
turn: turn.as_ref(),
call_id: call_id.clone(),
tool_name: tool_name.to_string(),
network_attempt_id: None,
};
let out = orchestrator
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy)
@@ -234,7 +233,6 @@ pub(crate) async fn intercept_apply_patch(
turn,
call_id: call_id.to_string(),
tool_name: tool_name.to_string(),
network_attempt_id: None,
};
let out = orchestrator
.run(&mut runtime, &req, &tool_ctx, turn, turn.approval_policy)
@@ -54,7 +54,6 @@ impl ShellHandler {
expiration: params.timeout_ms.into(),
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
network: turn_context.network.clone(),
network_attempt_id: None,
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
@@ -84,7 +83,6 @@ impl ShellCommandHandler {
expiration: params.timeout_ms.into(),
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
network: turn_context.network.clone(),
network_attempt_id: None,
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
@@ -327,7 +325,6 @@ impl ShellHandler {
turn: turn.as_ref(),
call_id: call_id.clone(),
tool_name,
network_attempt_id: None,
};
let out = orchestrator
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy)
+342 -419
View File
@@ -10,12 +10,14 @@ use codex_network_proxy::NetworkProtocol;
use codex_network_proxy::NetworkProxy;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::Notify;
use tokio::sync::RwLock;
use uuid::Uuid;
@@ -27,168 +29,207 @@ pub(crate) enum NetworkApprovalMode {
#[derive(Clone, Debug)]
pub(crate) struct NetworkApprovalSpec {
pub command: Vec<String>,
pub cwd: PathBuf,
pub network: Option<NetworkProxy>,
pub mode: NetworkApprovalMode,
}
#[derive(Clone, Debug)]
pub(crate) struct DeferredNetworkApproval {
attempt_id: String,
registration_id: String,
}
impl DeferredNetworkApproval {
pub(crate) fn attempt_id(&self) -> &str {
&self.attempt_id
pub(crate) fn registration_id(&self) -> &str {
&self.registration_id
}
}
#[derive(Debug)]
pub(crate) struct ActiveNetworkApproval {
attempt_id: Option<String>,
registration_id: Option<String>,
mode: NetworkApprovalMode,
}
impl ActiveNetworkApproval {
pub(crate) fn attempt_id(&self) -> Option<&str> {
self.attempt_id.as_deref()
}
pub(crate) fn mode(&self) -> NetworkApprovalMode {
self.mode
}
pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {
match (self.mode, self.attempt_id) {
(NetworkApprovalMode::Deferred, Some(attempt_id)) => {
Some(DeferredNetworkApproval { attempt_id })
match (self.mode, self.registration_id) {
(NetworkApprovalMode::Deferred, Some(registration_id)) => {
Some(DeferredNetworkApproval { registration_id })
}
_ => 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)]
pub(crate) enum NetworkApprovalOutcome {
enum NetworkApprovalOutcome {
DeniedByUser,
DeniedByPolicy(String),
}
struct NetworkApprovalAttempt {
turn_id: String,
call_id: String,
command: Vec<String>,
cwd: PathBuf,
approved_hosts: Mutex<HashSet<String>>,
outcome: Mutex<Option<NetworkApprovalOutcome>>,
fn allows_network_prompt(policy: AskForApproval) -> bool {
!matches!(policy, AskForApproval::Never)
}
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,
}
pub(crate) struct NetworkApprovalService {
attempts: Mutex<HashMap<String, Arc<NetworkApprovalAttempt>>>,
session_approved_hosts: Mutex<HashSet<String>>,
active_calls: Mutex<IndexMap<String, Arc<ActiveNetworkApprovalCall>>>,
call_outcomes: Mutex<HashMap<String, NetworkApprovalOutcome>>,
pending_host_approvals: Mutex<HashMap<HostApprovalKey, Arc<PendingHostApproval>>>,
session_approved_hosts: Mutex<HashSet<HostApprovalKey>>,
}
impl Default for NetworkApprovalService {
fn default() -> Self {
Self {
attempts: Mutex::new(HashMap::new()),
active_calls: Mutex::new(IndexMap::new()),
call_outcomes: Mutex::new(HashMap::new()),
pending_host_approvals: Mutex::new(HashMap::new()),
session_approved_hosts: Mutex::new(HashSet::new()),
}
}
}
impl NetworkApprovalService {
pub(crate) async fn register_attempt(
&self,
attempt_id: String,
turn_id: String,
call_id: String,
command: Vec<String>,
cwd: PathBuf,
) {
let mut attempts = self.attempts.lock().await;
attempts.insert(
attempt_id,
Arc::new(NetworkApprovalAttempt {
turn_id,
call_id,
command,
cwd,
approved_hosts: Mutex::new(HashSet::new()),
outcome: Mutex::new(None),
}),
);
async fn register_call(&self, registration_id: String) {
let mut active_calls = self.active_calls.lock().await;
let key = registration_id.clone();
active_calls.insert(key, Arc::new(ActiveNetworkApprovalCall { registration_id }));
}
pub(crate) async fn unregister_attempt(&self, attempt_id: &str) {
let mut attempts = self.attempts.lock().await;
attempts.remove(attempt_id);
pub(crate) async fn unregister_call(&self, registration_id: &str) {
let mut active_calls = self.active_calls.lock().await;
active_calls.shift_remove(registration_id);
let mut call_outcomes = self.call_outcomes.lock().await;
call_outcomes.remove(registration_id);
}
pub(crate) async fn take_outcome(&self, attempt_id: &str) -> Option<NetworkApprovalOutcome> {
let attempt = {
let attempts = self.attempts.lock().await;
attempts.get(attempt_id).cloned()
}?;
let mut outcome = attempt.outcome.lock().await;
outcome.take()
}
pub(crate) async fn take_user_denial_outcome(&self, attempt_id: &str) -> bool {
let attempt = {
let attempts = self.attempts.lock().await;
attempts.get(attempt_id).cloned()
};
let Some(attempt) = attempt else {
return false;
};
let mut outcome = attempt.outcome.lock().await;
if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) {
outcome.take();
return true;
}
false
}
async fn resolve_attempt_for_request(
&self,
request: &NetworkPolicyRequest,
) -> Option<Arc<NetworkApprovalAttempt>> {
let attempts = self.attempts.lock().await;
if let Some(attempt_id) = request.attempt_id.as_deref() {
if let Some(attempt) = attempts.get(attempt_id).cloned() {
return Some(attempt);
}
return None;
}
if attempts.len() == 1 {
return attempts.values().next().cloned();
async fn resolve_single_active_call(&self) -> Option<Arc<ActiveNetworkApprovalCall>> {
let active_calls = self.active_calls.lock().await;
if active_calls.len() == 1 {
return active_calls.values().next().cloned();
}
None
}
async fn resolve_attempt_for_blocked_request(
async fn get_or_create_pending_approval(
&self,
blocked: &BlockedRequest,
) -> Option<Arc<NetworkApprovalAttempt>> {
let attempts = self.attempts.lock().await;
if let Some(attempt_id) = blocked.attempt_id.as_deref() {
if let Some(attempt) = attempts.get(attempt_id).cloned() {
return Some(attempt);
}
return None;
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);
}
if attempts.len() == 1 {
return attempts.values().next().cloned();
}
let created = Arc::new(PendingHostApproval::new());
pending.insert(key, Arc::clone(&created));
(created, true)
}
None
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;
}
async fn take_call_outcome(&self, registration_id: &str) -> Option<NetworkApprovalOutcome> {
let mut call_outcomes = self.call_outcomes.lock().await;
call_outcomes.remove(registration_id)
}
async fn record_call_outcome(&self, registration_id: &str, outcome: NetworkApprovalOutcome) {
let mut call_outcomes = self.call_outcomes.lock().await;
if matches!(
call_outcomes.get(registration_id),
Some(NetworkApprovalOutcome::DeniedByUser)
) {
return;
}
call_outcomes.insert(registration_id.to_string(), outcome);
}
pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) {
@@ -196,15 +237,24 @@ impl NetworkApprovalService {
return;
};
let Some(attempt) = self.resolve_attempt_for_blocked_request(&blocked).await else {
return;
};
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message))
.await;
}
let mut outcome = attempt.outcome.lock().await;
if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) {
return;
}
*outcome = Some(NetworkApprovalOutcome::DeniedByPolicy(message));
async fn active_turn_context(session: &Session) -> Option<Arc<crate::codex::TurnContext>> {
let active_turn = session.active_turn.lock().await;
active_turn
.as_ref()
.and_then(|turn| turn.tasks.first())
.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(
@@ -214,46 +264,63 @@ impl NetworkApprovalService {
) -> NetworkDecision {
const REASON_NOT_ALLOWED: &str = "not_allowed";
{
let approved_hosts = self.session_approved_hosts.lock().await;
if approved_hosts.contains(request.host.as_str()) {
return NetworkDecision::Allow;
}
}
let Some(attempt) = self.resolve_attempt_for_request(&request).await else {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
};
{
let approved_hosts = attempt.approved_hosts.lock().await;
if approved_hosts.contains(request.host.as_str()) {
return NetworkDecision::Allow;
}
}
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 Some(turn_context) = session.turn_context_for_sub_id(&attempt.turn_id).await else {
{
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).await else {
pending.set_decision(PendingApprovalDecision::Deny).await;
let mut pending_approvals = self.pending_host_approvals.lock().await;
pending_approvals.remove(&key);
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(
policy_denial_message,
))
.await;
return NetworkDecision::deny(REASON_NOT_ALLOWED);
};
if !allows_network_prompt(turn_context.approval_policy) {
pending.set_decision(PendingApprovalDecision::Deny).await;
let mut pending_approvals = self.pending_host_approvals.lock().await;
pending_approvals.remove(&key);
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(
policy_denial_message,
))
.await;
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
let approval_id = Self::approval_id_for_key(&key);
let prompt_command = vec!["network-access".to_string(), target.clone()];
let approval_decision = session
.request_command_approval(
turn_context.as_ref(),
attempt.call_id.clone(),
approval_id,
None,
attempt.command.clone(),
attempt.cwd.clone(),
Some(format!(
"Network access to \"{}\" is blocked by policy.",
request.host
)),
prompt_command,
turn_context.cwd.clone(),
Some(prompt_reason),
Some(NetworkApprovalContext {
host: request.host.clone(),
protocol,
@@ -262,23 +329,28 @@ impl NetworkApprovalService {
)
.await;
match approval_decision {
let resolved = match approval_decision {
ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
let mut approved_hosts = attempt.approved_hosts.lock().await;
approved_hosts.insert(request.host);
NetworkDecision::Allow
}
ReviewDecision::ApprovedForSession => {
let mut approved_hosts = self.session_approved_hosts.lock().await;
approved_hosts.insert(request.host);
NetworkDecision::Allow
PendingApprovalDecision::AllowOnce
}
ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession,
ReviewDecision::Denied | ReviewDecision::Abort => {
let mut outcome = attempt.outcome.lock().await;
*outcome = Some(NetworkApprovalOutcome::DeniedByUser);
NetworkDecision::deny(REASON_NOT_ALLOWED)
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByUser)
.await;
PendingApprovalDecision::Deny
}
};
if matches!(resolved, PendingApprovalDecision::AllowForSession) {
let mut approved_hosts = self.session_approved_hosts.lock().await;
approved_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()
}
}
@@ -313,8 +385,8 @@ pub(crate) fn build_network_policy_decider(
pub(crate) async fn begin_network_approval(
session: &Session,
turn_id: &str,
call_id: &str,
_turn_id: &str,
_call_id: &str,
has_managed_network_requirements: bool,
spec: Option<NetworkApprovalSpec>,
) -> Option<ActiveNetworkApproval> {
@@ -323,21 +395,15 @@ pub(crate) async fn begin_network_approval(
return None;
}
let attempt_id = Uuid::new_v4().to_string();
let registration_id = Uuid::new_v4().to_string();
session
.services
.network_approval
.register_attempt(
attempt_id.clone(),
turn_id.to_string(),
call_id.to_string(),
spec.command,
spec.cwd,
)
.register_call(registration_id.clone())
.await;
Some(ActiveNetworkApproval {
attempt_id: Some(attempt_id),
registration_id: Some(registration_id),
mode: spec.mode,
})
}
@@ -346,20 +412,20 @@ pub(crate) async fn finish_immediate_network_approval(
session: &Session,
active: ActiveNetworkApproval,
) -> Result<(), ToolError> {
let Some(attempt_id) = active.attempt_id.as_deref() else {
let Some(registration_id) = active.registration_id.as_deref() else {
return Ok(());
};
let approval_outcome = session
.services
.network_approval
.take_outcome(attempt_id)
.take_call_outcome(registration_id)
.await;
session
.services
.network_approval
.unregister_attempt(attempt_id)
.unregister_call(registration_id)
.await;
match approval_outcome {
@@ -371,22 +437,6 @@ pub(crate) async fn finish_immediate_network_approval(
}
}
pub(crate) async fn deferred_rejection_message(
session: &Session,
deferred: &DeferredNetworkApproval,
) -> Option<String> {
match session
.services
.network_approval
.take_outcome(deferred.attempt_id())
.await
{
Some(NetworkApprovalOutcome::DeniedByUser) => Some("rejected by user".to_string()),
Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Some(message),
None => None,
}
}
pub(crate) async fn finish_deferred_network_approval(
session: &Session,
deferred: Option<DeferredNetworkApproval>,
@@ -397,7 +447,7 @@ pub(crate) async fn finish_deferred_network_approval(
session
.services
.network_approval
.unregister_attempt(deferred.attempt_id())
.unregister_call(deferred.registration_id())
.await;
}
@@ -405,272 +455,145 @@ pub(crate) async fn finish_deferred_network_approval(
mod tests {
use super::*;
use codex_network_proxy::BlockedRequestArgs;
use codex_network_proxy::NetworkPolicyRequestArgs;
use codex_protocol::protocol::AskForApproval;
use pretty_assertions::assert_eq;
fn http_request(host: &str, attempt_id: Option<&str>) -> NetworkPolicyRequest {
NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Http,
#[tokio::test]
async fn pending_approvals_are_deduped_per_host_protocol_and_port() {
let service = NetworkApprovalService::default();
let key = HostApprovalKey {
host: "example.com".to_string(),
protocol: "http",
port: 443,
};
let (first, first_is_owner) = service.get_or_create_pending_approval(key.clone()).await;
let (second, second_is_owner) = service.get_or_create_pending_approval(key).await;
assert!(first_is_owner);
assert!(!second_is_owner);
assert!(Arc::ptr_eq(&first, &second));
}
#[tokio::test]
async fn pending_approvals_do_not_dedupe_across_ports() {
let service = NetworkApprovalService::default();
let first_key = HostApprovalKey {
host: "example.com".to_string(),
protocol: "https",
port: 443,
};
let second_key = HostApprovalKey {
host: "example.com".to_string(),
protocol: "https",
port: 8443,
};
let (first, first_is_owner) = service.get_or_create_pending_approval(first_key).await;
let (second, second_is_owner) = service.get_or_create_pending_approval(second_key).await;
assert!(first_is_owner);
assert!(second_is_owner);
assert!(!Arc::ptr_eq(&first, &second));
}
#[tokio::test]
async fn pending_waiters_receive_owner_decision() {
let pending = Arc::new(PendingHostApproval::new());
let waiter = {
let pending = Arc::clone(&pending);
tokio::spawn(async move { pending.wait_for_decision().await })
};
pending
.set_decision(PendingApprovalDecision::AllowOnce)
.await;
let decision = waiter.await.expect("waiter should complete");
assert_eq!(decision, PendingApprovalDecision::AllowOnce);
}
#[test]
fn allow_once_and_allow_for_session_both_allow_network() {
assert_eq!(
PendingApprovalDecision::AllowOnce.to_network_decision(),
NetworkDecision::Allow
);
assert_eq!(
PendingApprovalDecision::AllowForSession.to_network_decision(),
NetworkDecision::Allow
);
}
#[test]
fn never_policy_disables_network_prompts() {
assert!(!allows_network_prompt(AskForApproval::Never));
assert!(allows_network_prompt(AskForApproval::OnRequest));
assert!(allows_network_prompt(AskForApproval::OnFailure));
assert!(allows_network_prompt(AskForApproval::UnlessTrusted));
}
fn denied_blocked_request(host: &str) -> BlockedRequest {
BlockedRequest::new(BlockedRequestArgs {
host: host.to_string(),
port: 80,
client_addr: None,
method: Some("GET".to_string()),
command: None,
exec_policy_hint: None,
attempt_id: attempt_id.map(ToString::to_string),
reason: "not_allowed".to_string(),
client: None,
method: None,
mode: None,
protocol: "http".to_string(),
decision: Some("deny".to_string()),
source: Some("decider".to_string()),
port: Some(80),
})
}
#[tokio::test]
async fn resolve_attempt_for_request_falls_back_to_single_active_attempt() {
async fn record_blocked_request_sets_policy_outcome_for_owner_call() {
let service = NetworkApprovalService::default();
service.register_call("registration-1".to_string()).await;
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.record_blocked_request(denied_blocked_request("example.com"))
.await;
let resolved = service
.resolve_attempt_for_request(&http_request("example.com", None))
.await
.expect("single active attempt should be used as fallback");
assert_eq!(resolved.call_id, "call-1");
}
#[tokio::test]
async fn resolve_attempt_for_request_returns_exact_attempt_match() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
service
.register_attempt(
"attempt-2".to_string(),
"turn-2".to_string(),
"call-2".to_string(),
vec!["curl".to_string(), "openai.com".to_string()],
std::env::temp_dir(),
)
.await;
let resolved = service
.resolve_attempt_for_request(&http_request("openai.com", Some("attempt-2")))
.await
.expect("attempt-2 should resolve");
assert_eq!(resolved.call_id, "call-2");
}
#[tokio::test]
async fn resolve_attempt_for_request_returns_none_for_unknown_attempt_id() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
let resolved = service
.resolve_attempt_for_request(&http_request("example.com", Some("attempt-unknown")))
.await;
assert!(resolved.is_none());
}
#[tokio::test]
async fn resolve_attempt_for_request_returns_none_when_ambiguous() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
service
.register_attempt(
"attempt-2".to_string(),
"turn-2".to_string(),
"call-2".to_string(),
vec!["curl".to_string(), "robinhood.com".to_string()],
std::env::temp_dir(),
)
.await;
let resolved = service
.resolve_attempt_for_request(&http_request("example.com", None))
.await;
assert!(resolved.is_none());
}
#[tokio::test]
async fn take_outcome_clears_stored_value() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
let attempt = {
let attempts = service.attempts.lock().await;
attempts
.get("attempt-1")
.cloned()
.expect("attempt should exist")
};
{
let mut outcome = attempt.outcome.lock().await;
*outcome = Some(NetworkApprovalOutcome::DeniedByUser);
}
assert_eq!(
service.take_outcome("attempt-1").await,
Some(NetworkApprovalOutcome::DeniedByUser)
);
assert_eq!(service.take_outcome("attempt-1").await, None);
}
#[tokio::test]
async fn take_user_denial_outcome_preserves_policy_denial() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
let attempt = {
let attempts = service.attempts.lock().await;
attempts
.get("attempt-1")
.cloned()
.expect("attempt should exist")
};
{
let mut outcome = attempt.outcome.lock().await;
*outcome = Some(NetworkApprovalOutcome::DeniedByPolicy(
"policy denied".to_string(),
));
}
assert!(!service.take_user_denial_outcome("attempt-1").await);
assert_eq!(
service.take_outcome("attempt-1").await,
service.take_call_outcome("registration-1").await,
Some(NetworkApprovalOutcome::DeniedByPolicy(
"policy denied".to_string(),
"Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string()
))
);
}
#[tokio::test]
async fn record_blocked_request_stores_policy_denial_outcome() {
async fn blocked_request_policy_does_not_override_user_denial_outcome() {
let service = NetworkApprovalService::default();
service.register_call("registration-1".to_string()).await;
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.record_call_outcome("registration-1", NetworkApprovalOutcome::DeniedByUser)
.await;
service
.record_blocked_request(BlockedRequest::new(BlockedRequestArgs {
host: "example.com".to_string(),
reason: "denied".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("deny".to_string()),
source: Some("baseline_policy".to_string()),
port: Some(80),
}))
.await;
let outcome = service
.take_outcome("attempt-1")
.await
.expect("outcome should be recorded");
match outcome {
NetworkApprovalOutcome::DeniedByPolicy(message) => {
assert_eq!(
message,
"Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string()
);
}
NetworkApprovalOutcome::DeniedByUser => panic!("expected policy denial"),
}
}
#[tokio::test]
async fn record_blocked_request_does_not_override_user_denial() {
let service = NetworkApprovalService::default();
service
.register_attempt(
"attempt-1".to_string(),
"turn-1".to_string(),
"call-1".to_string(),
vec!["curl".to_string(), "example.com".to_string()],
std::env::temp_dir(),
)
.await;
let attempt = {
let attempts = service.attempts.lock().await;
attempts
.get("attempt-1")
.cloned()
.expect("attempt should exist")
};
{
let mut outcome = attempt.outcome.lock().await;
*outcome = Some(NetworkApprovalOutcome::DeniedByUser);
}
service
.record_blocked_request(BlockedRequest::new(BlockedRequestArgs {
host: "example.com".to_string(),
reason: "denied".to_string(),
client: None,
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("deny".to_string()),
source: Some("baseline_policy".to_string()),
port: Some(80),
}))
.record_blocked_request(denied_blocked_request("example.com"))
.await;
assert_eq!(
service.take_outcome("attempt-1").await,
service.take_call_outcome("registration-1").await,
Some(NetworkApprovalOutcome::DeniedByUser)
);
}
#[tokio::test]
async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests() {
let service = NetworkApprovalService::default();
service.register_call("registration-1".to_string()).await;
service.register_call("registration-2".to_string()).await;
service
.record_blocked_request(denied_blocked_request("example.com"))
.await;
assert_eq!(service.take_call_outcome("registration-1").await, None);
assert_eq!(service.take_call_outcome("registration-2").await, None);
}
}
-3
View File
@@ -69,9 +69,6 @@ impl ToolOrchestrator {
turn: tool_ctx.turn,
call_id: tool_ctx.call_id.clone(),
tool_name: tool_ctx.tool_name.clone(),
network_attempt_id: network_approval.as_ref().and_then(|network_approval| {
network_approval.attempt_id().map(ToString::to_string)
}),
};
let run_result = tool.run(req, attempt, &attempt_tool_ctx).await;
+1 -4
View File
@@ -154,8 +154,6 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
) -> Option<NetworkApprovalSpec> {
req.network.as_ref()?;
Some(NetworkApprovalSpec {
command: req.command.clone(),
cwd: req.cwd.clone(),
network: req.network.clone(),
mode: NetworkApprovalMode::Immediate,
})
@@ -221,10 +219,9 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
req.sandbox_permissions,
req.justification.clone(),
)?;
let mut env = attempt
let env = attempt
.env_for(spec, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
env.network_attempt_id = ctx.network_attempt_id.clone();
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)?;
@@ -157,8 +157,6 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
) -> Option<NetworkApprovalSpec> {
req.network.as_ref()?;
Some(NetworkApprovalSpec {
command: req.command.clone(),
cwd: req.cwd.clone(),
network: req.network.clone(),
mode: NetworkApprovalMode::Deferred,
})
@@ -188,7 +186,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
let mut env = req.env.clone();
if let Some(network) = req.network.as_ref() {
network.apply_to_env_for_attempt(&mut env, ctx.network_attempt_id.as_deref());
network.apply_to_env(&mut env);
}
let spec = build_command_spec(
&command,
-1
View File
@@ -272,7 +272,6 @@ pub(crate) struct ToolCtx<'a> {
pub turn: &'a TurnContext,
pub call_id: String,
pub tool_name: String,
pub network_attempt_id: Option<String>,
}
#[derive(Debug)]
@@ -139,43 +139,6 @@ pub(crate) fn spawn_exit_watcher(
});
}
pub(crate) fn spawn_network_denial_watcher(
process: Arc<UnifiedExecProcess>,
session: Arc<Session>,
process_id: String,
network_attempt_id: String,
) {
let exit_token = process.cancellation_token();
tokio::spawn(async move {
let mut poll = tokio::time::interval(Duration::from_millis(100));
poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = exit_token.cancelled() => {
break;
}
_ = poll.tick() => {
if session
.services
.network_approval
.take_user_denial_outcome(&network_attempt_id)
.await
{
process.terminate();
session
.services
.unified_exec_manager
.release_process_id(&process_id)
.await;
break;
}
}
}
}
});
}
async fn process_chunk(
pending: &mut Vec<u8>,
transcript: &Arc<Mutex<HeadTailBuffer>>,
+1 -1
View File
@@ -155,7 +155,7 @@ struct ProcessEntry {
process_id: String,
command: Vec<String>,
tty: bool,
network_attempt_id: Option<String>,
network_approval_id: Option<String>,
session: Weak<Session>,
last_used: tokio::time::Instant,
}
@@ -20,7 +20,6 @@ use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventStage;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::deferred_rejection_message;
use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest;
@@ -44,7 +43,6 @@ use crate::unified_exec::WARNING_UNIFIED_EXEC_PROCESSES;
use crate::unified_exec::WriteStdinRequest;
use crate::unified_exec::async_watcher::emit_exec_end_for_unified_exec;
use crate::unified_exec::async_watcher::spawn_exit_watcher;
use crate::unified_exec::async_watcher::spawn_network_denial_watcher;
use crate::unified_exec::async_watcher::start_streaming_output;
use crate::unified_exec::clamp_yield_time;
use crate::unified_exec::generate_chunk_id;
@@ -140,18 +138,18 @@ impl UnifiedExecProcessManager {
store.remove(process_id)
};
if let Some(entry) = removed {
Self::unregister_network_attempt_for_entry(&entry).await;
Self::unregister_network_approval_for_entry(&entry).await;
}
}
async fn unregister_network_attempt_for_entry(entry: &ProcessEntry) {
if let Some(attempt_id) = entry.network_attempt_id.as_deref()
async fn unregister_network_approval_for_entry(entry: &ProcessEntry) {
if let Some(network_approval_id) = entry.network_approval_id.as_deref()
&& let Some(session) = entry.session.upgrade()
{
session
.services
.network_approval
.unregister_attempt(attempt_id)
.unregister_call(network_approval_id)
.await;
}
}
@@ -248,17 +246,6 @@ impl UnifiedExecProcessManager {
.await;
self.release_process_id(&request.process_id).await;
if let Some(deferred) = deferred_network_approval.as_ref()
&& let Some(message) =
deferred_rejection_message(context.session.as_ref(), deferred).await
{
finish_deferred_network_approval(
context.session.as_ref(),
deferred_network_approval.take(),
)
.await;
return Err(UnifiedExecError::create_process(message));
}
finish_deferred_network_approval(
context.session.as_ref(),
deferred_network_approval.take(),
@@ -266,27 +253,13 @@ impl UnifiedExecProcessManager {
.await;
process.check_for_sandbox_denial_with_text(&text).await?;
} else {
if let Some(deferred) = deferred_network_approval.as_ref()
&& let Some(message) =
deferred_rejection_message(context.session.as_ref(), deferred).await
{
process.terminate();
finish_deferred_network_approval(
context.session.as_ref(),
deferred_network_approval.take(),
)
.await;
self.release_process_id(&request.process_id).await;
return Err(UnifiedExecError::create_process(message));
}
// Longlived command: persist the process so write_stdin can reuse
// it, and register a background watcher that will emit
// ExecCommandEnd when the PTY eventually exits (even if no further
// tool calls are made).
let network_attempt_id = deferred_network_approval
let network_approval_id = deferred_network_approval
.as_ref()
.map(|deferred| deferred.attempt_id().to_string());
.map(|deferred| deferred.registration_id().to_string());
self.store_process(
Arc::clone(&process),
context,
@@ -295,7 +268,7 @@ impl UnifiedExecProcessManager {
start,
process_id,
request.tty,
network_attempt_id,
network_approval_id,
Arc::clone(&transcript),
)
.await;
@@ -443,7 +416,7 @@ impl UnifiedExecProcessManager {
}
};
if let ProcessStatus::Exited { entry, .. } = &status {
Self::unregister_network_attempt_for_entry(entry).await;
Self::unregister_network_approval_for_entry(entry).await;
}
status
}
@@ -502,17 +475,16 @@ impl UnifiedExecProcessManager {
started_at: Instant,
process_id: String,
tty: bool,
network_attempt_id: Option<String>,
network_approval_id: Option<String>,
transcript: Arc<tokio::sync::Mutex<HeadTailBuffer>>,
) {
let network_attempt_id_for_watcher = network_attempt_id.clone();
let entry = ProcessEntry {
process: Arc::clone(&process),
call_id: context.call_id.clone(),
process_id: process_id.clone(),
command: command.to_vec(),
tty,
network_attempt_id,
network_approval_id,
session: Arc::downgrade(&context.session),
last_used: started_at,
};
@@ -525,7 +497,7 @@ impl UnifiedExecProcessManager {
// prune_processes_if_needed runs while holding process_store; do async
// network-approval cleanup only after dropping that lock.
if let Some(pruned_entry) = pruned_entry {
Self::unregister_network_attempt_for_entry(&pruned_entry).await;
Self::unregister_network_approval_for_entry(&pruned_entry).await;
pruned_entry.process.terminate();
}
@@ -550,17 +522,6 @@ impl UnifiedExecProcessManager {
transcript,
started_at,
);
if context.turn.config.managed_network_requirements_enabled()
&& let Some(network_attempt_id) = network_attempt_id_for_watcher
{
spawn_network_denial_watcher(
Arc::clone(&process),
Arc::clone(&context.session),
process_id,
network_attempt_id,
);
}
}
pub(crate) async fn open_session_with_exec_env(
@@ -637,7 +598,6 @@ impl UnifiedExecProcessManager {
turn: context.turn.as_ref(),
call_id: context.call_id.clone(),
tool_name: "exec_command".to_string(),
network_attempt_id: None,
};
orchestrator
.run(
@@ -792,7 +752,7 @@ impl UnifiedExecProcessManager {
};
for entry in entries {
Self::unregister_network_attempt_for_entry(&entry).await;
Self::unregister_network_approval_for_entry(&entry).await;
entry.process.terminate();
}
}