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
Unverified
parent 41f15bf07b
commit e8afaed502
40 changed files with 570 additions and 739 deletions
@@ -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();
}
}