TUI: prompt for network proxy approvals

This commit is contained in:
viyatb-oai
2025-12-21 13:03:57 -08:00
parent 4a49d884b4
commit 5f44478b00
27 changed files with 1185 additions and 16 deletions
@@ -659,6 +659,7 @@ mod tests {
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo hello".to_string(),
}],
network_preflight_only: false,
};
let request = ServerRequest::ExecCommandApproval {
request_id: RequestId::Integer(7),
@@ -680,7 +681,8 @@ mod tests {
"type": "unknown",
"cmd": "echo hello"
}
]
],
"networkPreflightOnly": false
}
}),
serde_json::to_value(&request)?,
@@ -227,6 +227,8 @@ pub struct ExecCommandApprovalParams {
pub cwd: PathBuf,
pub reason: Option<String>,
pub parsed_cmd: Vec<ParsedCommand>,
#[serde(default)]
pub network_preflight_only: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -182,6 +182,8 @@ pub(crate) async fn apply_bespoke_event_handling(
reason,
proposed_execpolicy_amendment,
parsed_cmd,
network_preflight_only,
..
}) => match api_version {
ApiVersion::V1 => {
let params = ExecCommandApprovalParams {
@@ -191,6 +193,7 @@ pub(crate) async fn apply_bespoke_event_handling(
cwd,
reason,
parsed_cmd,
network_preflight_only,
};
let rx = outgoing
.send_request(ServerRequestPayload::ExecCommandApproval(params))
@@ -274,6 +274,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> {
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "python3 -c 'print(42)'".to_string()
}],
network_preflight_only: false,
},
params
);
+22
View File
@@ -393,6 +393,7 @@ impl TurnContext {
}
}
#[allow(dead_code)]
#[derive(Clone)]
pub(crate) struct SessionConfiguration {
/// Provider identifier ("openai", "openrouter", ...).
@@ -1101,6 +1102,7 @@ impl Session {
cwd: PathBuf,
reason: Option<String>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
network_preflight_only: bool,
) -> ReviewDecision {
let sub_id = turn_context.sub_id.clone();
// Add the tx_approve callback to the map before sending the request.
@@ -1129,6 +1131,7 @@ impl Session {
reason,
proposed_execpolicy_amendment,
parsed_cmd,
network_preflight_only,
});
self.send_event(turn_context, event).await;
rx_approve.await.unwrap_or_default()
@@ -1662,6 +1665,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
Op::PatchApproval { id, decision } => {
handlers::patch_approval(&sess, id, decision).await;
}
Op::NetworkApprovalCache { host, decision } => {
handlers::network_approval_cache(&sess, host, decision).await;
}
Op::AddToHistory { text } => {
handlers::add_to_history(&sess, &config, text).await;
}
@@ -1725,6 +1731,7 @@ mod handlers {
use crate::features::Feature;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp::collect_mcp_snapshot_from_manager;
use crate::network_proxy;
use crate::review_prompts::resolve_review_request;
use crate::tasks::CompactTask;
use crate::tasks::RegularTask;
@@ -1908,6 +1915,21 @@ mod handlers {
}
}
pub async fn network_approval_cache(
sess: &Arc<Session>,
host: String,
decision: ReviewDecision,
) {
if !matches!(
decision,
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
) {
return;
}
let mut store = sess.services.tool_approvals.lock().await;
network_proxy::cache_network_approval(&mut store, &host, decision);
}
pub async fn add_to_history(sess: &Arc<Session>, config: &Arc<Config>, text: String) {
let id = sess.conversation_id;
let config = Arc::clone(config);
+1
View File
@@ -282,6 +282,7 @@ async fn handle_exec_approval(
event.cwd,
event.reason,
event.proposed_execpolicy_amendment,
event.network_preflight_only,
);
let decision = await_approval_with_cancel(
approval_fut,
+1 -1
View File
@@ -33,7 +33,6 @@ pub mod git_info;
pub mod landlock;
pub mod mcp;
mod mcp_connection_manager;
pub mod network_proxy;
pub mod openai_models;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_NOTIFICATION;
@@ -71,6 +70,7 @@ pub use conversation_manager::NewConversation;
pub use auth::AuthManager;
pub use auth::CodexAuth;
pub mod default_client;
pub mod network_proxy;
pub mod project_doc;
mod rollout;
pub(crate) mod safety;
-3
View File
@@ -71,14 +71,12 @@ pub async fn fetch_blocked(
Ok(payload.blocked)
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub(crate) struct NetworkApprovalKey {
host: String,
}
impl NetworkApprovalKey {
#[allow(dead_code)]
fn new(host: &str) -> Option<Self> {
let host = host.trim();
if host.is_empty() {
@@ -90,7 +88,6 @@ impl NetworkApprovalKey {
}
}
#[allow(dead_code)]
pub(crate) fn cache_network_approval(
store: &mut ApprovalStore,
host: &str,
+31 -3
View File
@@ -9,6 +9,7 @@ use crate::exec_env::create_env;
use crate::exec_policy::create_exec_approval_requirement_for_command;
use crate::function_tool::FunctionCallError;
use crate::is_safe_command::is_known_safe_command;
use crate::network_proxy;
use crate::protocol::ExecCommandSource;
use crate::shell::Shell;
use crate::tools::context::ToolInvocation;
@@ -22,6 +23,7 @@ use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::tools::runtimes::shell::ShellRequest;
use crate::tools::runtimes::shell::ShellRuntime;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::ToolCtx;
pub struct ShellHandler;
@@ -34,12 +36,12 @@ impl ShellHandler {
command: params.command,
cwd: turn_context.resolve_path(params.workdir.clone()),
expiration: params.timeout_ms.into(),
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
env: create_env(
&turn_context.shell_environment_policy,
&turn_context.sandbox_policy,
&turn_context.network_proxy,
),
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
justification: params.justification,
arg0: None,
}
@@ -64,12 +66,12 @@ impl ShellCommandHandler {
command,
cwd: turn_context.resolve_path(params.workdir.clone()),
expiration: params.timeout_ms.into(),
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
env: create_env(
&turn_context.shell_environment_policy,
&turn_context.sandbox_policy,
&turn_context.network_proxy,
),
sandbox_permissions: params.sandbox_permissions.unwrap_or_default(),
justification: params.justification,
arg0: None,
}
@@ -260,7 +262,7 @@ impl ShellHandler {
emitter.begin(event_ctx).await;
let features = session.features();
let exec_approval_requirement = create_exec_approval_requirement_for_command(
let mut exec_approval_requirement = create_exec_approval_requirement_for_command(
&turn.exec_policy,
&features,
&exec_params.command,
@@ -269,6 +271,31 @@ impl ShellHandler {
exec_params.sandbox_permissions,
)
.await;
let network_preflight_blocked = match network_proxy::preflight_blocked_host_if_enabled(
&turn.network_proxy,
&turn.sandbox_policy,
&exec_params.command,
) {
Ok(Some(_)) => true,
Ok(None) => false,
Err(err) => {
tracing::debug!(error = %err, "network proxy preflight failed");
false
}
};
let mut network_preflight_only = false;
if network_preflight_blocked
&& matches!(
exec_approval_requirement,
ExecApprovalRequirement::Skip { .. }
)
{
exec_approval_requirement = ExecApprovalRequirement::NeedsApproval {
reason: Some("Network access requires approval.".to_string()),
proposed_execpolicy_amendment: None,
};
network_preflight_only = true;
}
let req = ShellRequest {
command: exec_params.command.clone(),
@@ -277,6 +304,7 @@ impl ShellHandler {
env: exec_params.env.clone(),
sandbox_permissions: exec_params.sandbox_permissions,
justification: exec_params.justification.clone(),
network_preflight_only,
exec_approval_requirement,
};
let mut orchestrator = ToolOrchestrator::new();
@@ -119,6 +119,7 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
cwd,
Some(reason),
None,
false,
)
.await
} else if user_explicitly_approved {
@@ -32,6 +32,7 @@ pub struct ShellRequest {
pub env: std::collections::HashMap<String, String>,
pub sandbox_permissions: SandboxPermissions,
pub justification: Option<String>,
pub network_preflight_only: bool,
pub exec_approval_requirement: ExecApprovalRequirement,
}
@@ -106,6 +107,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
req.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
req.network_preflight_only,
)
.await
})
@@ -36,6 +36,7 @@ pub struct UnifiedExecRequest {
pub env: HashMap<String, String>,
pub sandbox_permissions: SandboxPermissions,
pub justification: Option<String>,
pub network_preflight_only: bool,
pub exec_approval_requirement: ExecApprovalRequirement,
}
@@ -57,6 +58,7 @@ impl UnifiedExecRequest {
env: HashMap<String, String>,
sandbox_permissions: SandboxPermissions,
justification: Option<String>,
network_preflight_only: bool,
exec_approval_requirement: ExecApprovalRequirement,
) -> Self {
Self {
@@ -65,6 +67,7 @@ impl UnifiedExecRequest {
env,
sandbox_permissions,
justification,
network_preflight_only,
exec_approval_requirement,
}
}
@@ -124,6 +127,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
req.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
req.network_preflight_only,
)
.await
})
@@ -15,6 +15,7 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::exec_env::create_env;
use crate::exec_policy::create_exec_approval_requirement_for_command;
use crate::network_proxy;
use crate::protocol::BackgroundEventEvent;
use crate::protocol::EventMsg;
use crate::sandboxing::ExecEnv;
@@ -488,7 +489,19 @@ impl UnifiedExecSessionManager {
let features = context.session.features();
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self);
let exec_approval_requirement = create_exec_approval_requirement_for_command(
let network_preflight_blocked = match network_proxy::preflight_blocked_host_if_enabled(
&context.turn.network_proxy,
&context.turn.sandbox_policy,
command,
) {
Ok(Some(_)) => true,
Ok(None) => false,
Err(err) => {
tracing::debug!(error = %err, "network proxy preflight failed");
false
}
};
let mut exec_approval_requirement = create_exec_approval_requirement_for_command(
&context.turn.exec_policy,
&features,
command,
@@ -497,12 +510,27 @@ impl UnifiedExecSessionManager {
sandbox_permissions,
)
.await;
let mut network_preflight_only = false;
if network_preflight_blocked
&& matches!(
exec_approval_requirement,
crate::tools::sandboxing::ExecApprovalRequirement::Skip { .. }
)
{
exec_approval_requirement =
crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval {
reason: Some("Network access requires approval.".to_string()),
proposed_execpolicy_amendment: None,
};
network_preflight_only = true;
}
let req = UnifiedExecToolRequest::new(
command.to_vec(),
cwd,
env,
sandbox_permissions,
justification,
network_preflight_only,
exec_approval_requirement,
);
let tool_ctx = ToolCtx {
@@ -181,6 +181,7 @@ async fn run_codex_tool_session_inner(
reason: _,
proposed_execpolicy_amendment: _,
parsed_cmd,
..
}) => {
handle_exec_approval_request(
command,
+6
View File
@@ -9,6 +9,10 @@ use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
fn is_false(value: &bool) -> bool {
!*value
}
/// Proposed execpolicy change to allow commands starting with this prefix.
///
/// The `command` tokens form the prefix that would be added as an execpolicy
@@ -57,6 +61,8 @@ pub struct ExecApprovalRequestEvent {
#[ts(optional)]
pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
pub parsed_cmd: Vec<ParsedCommand>,
#[serde(default, skip_serializing_if = "is_false")]
pub network_preflight_only: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
+8
View File
@@ -167,6 +167,14 @@ pub enum Op {
decision: ElicitationAction,
},
/// Cache a network approval decision for this session.
NetworkApprovalCache {
/// The host that was approved.
host: String,
/// The user's decision in response to the request.
decision: ReviewDecision,
},
/// Append an entry to the persistent cross-session message history.
///
/// Note the entry is not guaranteed to be logged if the user has
+414
View File
@@ -22,11 +22,15 @@ use crate::update_action::UpdateAction;
use codex_ansi_escape::ansi_escape_line;
use codex_core::AuthManager;
use codex_core::ConversationManager;
use codex_core::config;
use codex_core::config::Config;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::types::NetworkProxyConfig;
use codex_core::default_client::create_client;
#[cfg(target_os = "windows")]
use codex_core::features::Feature;
use codex_core::network_proxy;
use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
use codex_core::openai_models::models_manager::ModelsManager;
@@ -34,6 +38,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::FinalOutput;
use codex_core::protocol::ListSkillsResponseEvent;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_core::protocol::SessionSource;
use codex_core::protocol::SkillErrorInfo;
use codex_core::protocol::TokenUsage;
@@ -52,6 +57,8 @@ use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -72,6 +79,10 @@ pub struct AppExitInfo {
pub update_action: Option<UpdateAction>,
}
fn codex_config_path() -> Result<PathBuf> {
config::default_config_path().wrap_err("failed to resolve Codex config path")
}
fn session_summary(
token_usage: TokenUsage,
conversation_id: Option<ConversationId>,
@@ -304,6 +315,11 @@ pub(crate) struct App {
// One-shot suppression of the next world-writable scan after user confirmation.
skip_world_writable_scan_once: bool,
network_proxy_pending: HashSet<String>,
network_proxy_session_restore: HashMap<String, network_proxy::DomainState>,
unix_socket_pending: HashSet<String>,
unix_socket_session_restore: HashSet<String>,
}
impl App {
@@ -434,8 +450,19 @@ impl App {
pending_update_action: None,
suppress_shutdown_complete: false,
skip_world_writable_scan_once: false,
network_proxy_pending: HashSet::new(),
network_proxy_session_restore: HashMap::new(),
unix_socket_pending: HashSet::new(),
unix_socket_session_restore: HashSet::new(),
};
if app.config.network_proxy.enabled && app.config.sandbox_policy.has_full_network_access() {
Self::spawn_network_proxy_poller(
app.config.network_proxy.clone(),
app.app_event_tx.clone(),
);
}
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
#[cfg(target_os = "windows")]
{
@@ -485,6 +512,9 @@ impl App {
app.handle_tui_event(tui, event).await?
}
} {}
if let Err(err) = app.restore_network_proxy_approvals().await {
tracing::error!(error = %err, "failed to restore network proxy approvals");
}
tui.terminal.clear()?;
Ok(AppExitInfo {
token_usage: app.token_usage(),
@@ -544,6 +574,7 @@ impl App {
.await;
match event {
AppEvent::NewSession => {
let session_allow = self.chat_widget.network_session_allow();
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.conversation_id(),
@@ -563,6 +594,7 @@ impl App {
model_family: model_family.clone(),
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
self.chat_widget.set_network_session_allow(session_allow);
self.current_model = model_family.get_model_slug().to_string();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
@@ -575,6 +607,7 @@ impl App {
tui.frame_requester().schedule_frame();
}
AppEvent::OpenResumePicker => {
let session_allow = self.chat_widget.network_session_allow();
match crate::resume_picker::run_resume_picker(
tui,
&self.config.codex_home,
@@ -617,6 +650,7 @@ impl App {
resumed.conversation,
resumed.session_configured,
);
self.chat_widget.set_network_session_allow(session_allow);
self.current_model = model_family.get_model_slug().to_string();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
@@ -1088,6 +1122,78 @@ impl App {
"E X E C".to_string(),
));
}
ApprovalRequest::Network { request } => {
let mut lines = Vec::new();
if !request.host.trim().is_empty() {
lines.push(Line::from(vec![
"Host: ".into(),
request.host.clone().bold(),
]));
}
if !request.reason.trim().is_empty() {
lines.push(Line::from(vec![
"Reason: ".into(),
request.reason.clone().into(),
]));
}
if let Some(method) = request.method.as_ref().filter(|value| !value.is_empty())
{
lines.push(Line::from(vec![
"Method: ".into(),
method.to_string().into(),
]));
}
if cfg!(debug_assertions) {
if !request.protocol.trim().is_empty() {
lines.push(Line::from(vec![
"Protocol: ".into(),
request.protocol.clone().into(),
]));
}
if let Some(mode) = request.mode {
let label = match mode {
codex_core::config::types::NetworkProxyMode::Limited => "limited",
codex_core::config::types::NetworkProxyMode::Full => "full",
};
lines.push(Line::from(vec!["Mode: ".into(), label.into()]));
}
}
if let Some(client) = request.client.as_ref().filter(|value| !value.is_empty())
{
lines.push(Line::from(vec![
"Client: ".into(),
client.to_string().dim(),
]));
}
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_static_with_lines(lines, "N E T".to_string()));
}
ApprovalRequest::UnixSocket { request } => {
let mut lines = Vec::new();
if !request.label.trim().is_empty() {
lines.push(Line::from(vec![
"Resource: ".into(),
request.label.clone().bold(),
]));
}
if !request.socket_path.trim().is_empty() {
lines.push(Line::from(vec![
"Socket: ".into(),
request.socket_path.clone().dim(),
]));
}
if !request.allow_entry.trim().is_empty() {
lines.push(Line::from(vec![
"Allow entry: ".into(),
request.allow_entry.clone().dim(),
]));
}
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_static_with_lines(
lines,
"S O C K E T".to_string(),
));
}
ApprovalRequest::McpElicitation {
server_name,
message,
@@ -1106,6 +1212,234 @@ impl App {
));
}
},
AppEvent::NetworkProxyApprovalRequest(request) => {
if !self.config.network_proxy.enabled {
return Ok(true);
}
let host = request.host.trim().to_string();
if host.is_empty() || self.network_proxy_pending.contains(&host) {
return Ok(true);
}
let reason = request.reason.trim();
if reason.eq_ignore_ascii_case("not_allowed")
&& let Ok(config_path) = codex_config_path()
{
match network_proxy::preflight_host(&config_path, &host) {
Ok(None) => {
self.chat_widget.resume_pending_exec_approval();
return Ok(true);
}
Ok(Some(_)) => {}
Err(err) => {
tracing::debug!(
error = %err,
"network proxy preflight host check failed"
);
}
}
}
if reason.eq_ignore_ascii_case("denied") {
self.chat_widget.add_error_message(format!(
"Network access to {host} is denied by the denylist."
));
return Ok(true);
}
if self.chat_widget.is_network_session_allowed(&host) {
self.chat_widget.resume_pending_exec_approval();
return Ok(true);
}
self.network_proxy_pending.insert(host);
self.chat_widget.on_network_approval_request(request);
}
AppEvent::UnixSocketApprovalRequest(request) => {
if !self.config.network_proxy.enabled {
return Ok(true);
}
let socket_path = request.socket_path.trim().to_string();
if socket_path.is_empty() || self.unix_socket_pending.contains(&socket_path) {
return Ok(true);
}
if let Ok(config_path) = codex_config_path() {
let socket = std::path::Path::new(&socket_path);
match network_proxy::unix_socket_allowed(&config_path, socket) {
Ok(true) => {
self.chat_widget.resume_pending_exec_approval();
return Ok(true);
}
Ok(false) => {}
Err(err) => {
tracing::debug!(error = %err, "unix socket preflight check failed");
}
}
}
self.unix_socket_pending.insert(socket_path);
self.chat_widget.on_unix_socket_approval_request(request);
}
AppEvent::NetworkProxyDecision { host, decision } => {
let host = host.trim().to_string();
if host.is_empty() {
return Ok(true);
}
self.network_proxy_pending.remove(&host);
let client = create_client();
let admin_url = self.config.network_proxy.admin_url.clone();
let config_path = codex_config_path()?;
let mut reload_needed = false;
let mut should_resume_exec = matches!(
decision,
crate::app_event::NetworkProxyDecision::AllowSession
| crate::app_event::NetworkProxyDecision::AllowAlways
);
match decision {
crate::app_event::NetworkProxyDecision::AllowSession => {
let original = match network_proxy::domain_state(&config_path, &host) {
Ok(state) => Some(state),
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to read network policy for {host}: {err}"
));
should_resume_exec = false;
None
}
};
let allow_state = network_proxy::DomainState {
allowed: true,
denied: false,
};
if let Some(original) = original {
match network_proxy::set_domain_state(&config_path, &host, allow_state)
{
Ok(changed) => {
reload_needed |= changed;
self.network_proxy_session_restore
.entry(host.clone())
.or_insert(original);
self.chat_widget.add_network_session_allow(host.clone());
self.chat_widget.submit_op(Op::NetworkApprovalCache {
host: host.clone(),
decision: ReviewDecision::ApprovedForSession,
});
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to allow {host} for this session: {err}"
));
should_resume_exec = false;
}
}
}
}
crate::app_event::NetworkProxyDecision::AllowAlways => {
let allow_state = network_proxy::DomainState {
allowed: true,
denied: false,
};
match network_proxy::set_domain_state(&config_path, &host, allow_state) {
Ok(changed) => {
reload_needed |= changed;
self.chat_widget.add_network_session_allow(host.clone());
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to add {host} to allowlist: {err}"
));
}
}
}
crate::app_event::NetworkProxyDecision::Deny => {
let deny_state = network_proxy::DomainState {
allowed: false,
denied: true,
};
match network_proxy::set_domain_state(&config_path, &host, deny_state) {
Ok(changed) => {
reload_needed |= changed;
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to add {host} to denylist: {err}"
));
}
}
}
}
if reload_needed && let Err(err) = network_proxy::reload(&client, &admin_url).await
{
self.chat_widget.add_error_message(format!(
"Failed to reload network proxy after policy update: {err}"
));
}
if should_resume_exec {
self.chat_widget.resume_pending_exec_approval();
} else {
self.chat_widget.reject_pending_exec_approval();
}
}
AppEvent::UnixSocketDecision {
socket_path,
allow_entry,
decision,
} => {
let socket_path = socket_path.trim().to_string();
if socket_path.is_empty() {
return Ok(true);
}
self.unix_socket_pending.remove(&socket_path);
let config_path = codex_config_path()?;
let mut should_resume_exec = matches!(
decision,
crate::app_event::UnixSocketDecision::AllowSession
| crate::app_event::UnixSocketDecision::AllowAlways
);
match decision {
crate::app_event::UnixSocketDecision::AllowSession => {
match network_proxy::add_allowed_unix_socket(&config_path, &socket_path) {
Ok(changed) => {
if changed {
self.unix_socket_session_restore.insert(socket_path.clone());
}
self.chat_widget
.add_unix_socket_session_allow(socket_path.clone());
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to allow Unix socket {socket_path} for this session: {err}"
));
should_resume_exec = false;
}
}
}
crate::app_event::UnixSocketDecision::AllowAlways => {
if allow_entry.trim().is_empty() {
self.chat_widget.add_error_message(
"Failed to allow Unix socket permanently: missing allow entry"
.to_string(),
);
should_resume_exec = false;
} else {
match network_proxy::add_allowed_unix_socket(&config_path, &allow_entry)
{
Ok(_) => {
self.chat_widget
.add_unix_socket_session_allow(socket_path.clone());
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to add Unix socket allow entry {allow_entry}: {err}"
));
should_resume_exec = false;
}
}
}
}
crate::app_event::UnixSocketDecision::Deny => {}
}
if should_resume_exec {
self.chat_widget.resume_pending_exec_approval();
} else {
self.chat_widget.reject_pending_exec_approval();
}
}
}
Ok(true)
}
@@ -1132,6 +1466,49 @@ impl App {
self.chat_widget.token_usage()
}
async fn restore_network_proxy_approvals(&mut self) -> Result<()> {
if self.network_proxy_session_restore.is_empty()
&& self.unix_socket_session_restore.is_empty()
{
return Ok(());
}
let config_path = codex_config_path()?;
let client = create_client();
let admin_url = self.config.network_proxy.admin_url.clone();
let mut reload_needed = false;
let mut restores: HashMap<String, network_proxy::DomainState> = HashMap::new();
for (host, state) in self.network_proxy_session_restore.drain() {
restores.insert(host, state);
}
for (host, state) in restores {
match network_proxy::set_domain_state(&config_path, &host, state) {
Ok(changed) => {
reload_needed |= changed;
}
Err(err) => {
tracing::error!(
error = %err,
host = host,
"failed to restore network policy"
);
}
}
}
if reload_needed {
network_proxy::reload(&client, &admin_url)
.await
.map_err(|err| color_eyre::eyre::eyre!(err))?;
}
for socket_path in self.unix_socket_session_restore.drain() {
let _ = network_proxy::remove_allowed_unix_socket(&config_path, &socket_path);
}
Ok(())
}
fn on_update_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
self.chat_widget.set_reasoning_effort(effort);
self.config.model_reasoning_effort = effort;
@@ -1197,6 +1574,35 @@ impl App {
};
}
fn spawn_network_proxy_poller(network_proxy: NetworkProxyConfig, tx: AppEventSender) {
if !network_proxy.enabled {
return;
}
let poll_interval_ms = if network_proxy.poll_interval_ms > 0 {
network_proxy.poll_interval_ms
} else {
1000
};
let poll_interval = Duration::from_secs_f64(poll_interval_ms as f64 / 1000.0);
let admin_url = network_proxy.admin_url;
tokio::spawn(async move {
let client = create_client();
loop {
match network_proxy::fetch_blocked(&client, &admin_url).await {
Ok(blocked) => {
for request in blocked {
tx.send(AppEvent::NetworkProxyApprovalRequest(request));
}
}
Err(err) => {
tracing::debug!(error = %err, "network proxy poll failed");
}
}
tokio::time::sleep(poll_interval).await;
}
});
}
#[cfg(target_os = "windows")]
fn spawn_world_writable_scan(
cwd: PathBuf,
@@ -1283,6 +1689,10 @@ mod tests {
pending_update_action: None,
suppress_shutdown_complete: false,
skip_world_writable_scan_once: false,
network_proxy_pending: HashSet::new(),
network_proxy_session_restore: HashMap::new(),
unix_socket_pending: HashSet::new(),
unix_socket_session_restore: HashSet::new(),
}
}
@@ -1323,6 +1733,10 @@ mod tests {
pending_update_action: None,
suppress_shutdown_complete: false,
skip_world_writable_scan_once: false,
network_proxy_pending: HashSet::new(),
network_proxy_session_restore: HashMap::new(),
unix_socket_pending: HashSet::new(),
unix_socket_session_restore: HashSet::new(),
},
rx,
op_rx,
+41
View File
@@ -1,6 +1,7 @@
use std::path::PathBuf;
use codex_common::approval_presets::ApprovalPreset;
use codex_core::network_proxy::NetworkProxyBlockedRequest;
use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event;
use codex_core::protocol::RateLimitSnapshot;
@@ -171,6 +172,25 @@ pub(crate) enum AppEvent {
/// Open the approval popup.
FullScreenApprovalRequest(ApprovalRequest),
/// Prompt for a blocked network request from the proxy.
NetworkProxyApprovalRequest(NetworkProxyBlockedRequest),
/// Prompt to allow a Unix socket path inside the sandbox (macOS only).
UnixSocketApprovalRequest(UnixSocketApprovalRequest),
/// User decision for a blocked network request.
NetworkProxyDecision {
host: String,
decision: NetworkProxyDecision,
},
/// User decision for a Unix socket approval request.
UnixSocketDecision {
socket_path: String,
allow_entry: String,
decision: UnixSocketDecision,
},
/// Open the feedback note entry overlay after the user selects a category.
OpenFeedbackNote {
category: FeedbackCategory,
@@ -183,6 +203,27 @@ pub(crate) enum AppEvent {
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NetworkProxyDecision {
AllowSession,
AllowAlways,
Deny,
}
#[derive(Debug, Clone)]
pub(crate) struct UnixSocketApprovalRequest {
pub label: String,
pub socket_path: String,
pub allow_entry: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UnixSocketDecision {
AllowSession,
AllowAlways,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FeedbackCategory {
BadResult,
@@ -2,6 +2,9 @@ use std::collections::HashMap;
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event::NetworkProxyDecision;
use crate::app_event::UnixSocketApprovalRequest;
use crate::app_event::UnixSocketDecision;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
@@ -16,8 +19,10 @@ use crate::key_hint::KeyBinding;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use codex_core::config::types::NetworkProxyMode;
use codex_core::features::Feature;
use codex_core::features::Features;
use codex_core::network_proxy::NetworkProxyBlockedRequest;
use codex_core::protocol::ElicitationAction;
use codex_core::protocol::ExecPolicyAmendment;
use codex_core::protocol::FileChange;
@@ -51,6 +56,12 @@ pub(crate) enum ApprovalRequest {
cwd: PathBuf,
changes: HashMap<PathBuf, FileChange>,
},
Network {
request: NetworkProxyBlockedRequest,
},
UnixSocket {
request: UnixSocketApprovalRequest,
},
McpElicitation {
server_name: String,
request_id: RequestId,
@@ -119,6 +130,13 @@ impl ApprovalOverlay {
patch_options(),
"Would you like to make the following edits?".to_string(),
),
ApprovalVariant::Network { preflight_only, .. } => (
network_options(*preflight_only),
"Allow network access to this domain?".to_string(),
),
ApprovalVariant::UnixSocket { label, .. } => {
(unix_socket_options(), format!("Allow access to {label}?"))
}
ApprovalVariant::McpElicitation { server_name, .. } => (
elicitation_options(),
format!("{server_name} needs your approval."),
@@ -174,6 +192,19 @@ impl ApprovalOverlay {
(ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => {
self.handle_patch_decision(id, decision.clone());
}
(ApprovalVariant::Network { host, .. }, ApprovalDecision::Network(decision)) => {
self.handle_network_decision(host, *decision);
}
(
ApprovalVariant::UnixSocket {
socket_path,
allow_entry,
..
},
ApprovalDecision::UnixSocket(decision),
) => {
self.handle_unix_socket_decision(socket_path, allow_entry, *decision);
}
(
ApprovalVariant::McpElicitation {
server_name,
@@ -207,6 +238,31 @@ impl ApprovalOverlay {
}));
}
fn handle_network_decision(&self, host: &str, decision: NetworkProxyDecision) {
let cell = history_cell::new_network_approval_decision_cell(host.to_string(), decision);
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
self.app_event_tx.send(AppEvent::NetworkProxyDecision {
host: host.to_string(),
decision,
});
}
fn handle_unix_socket_decision(
&self,
socket_path: &str,
allow_entry: &str,
decision: UnixSocketDecision,
) {
let cell =
history_cell::new_unix_socket_approval_decision_cell(socket_path.to_string(), decision);
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
self.app_event_tx.send(AppEvent::UnixSocketDecision {
socket_path: socket_path.to_string(),
allow_entry: allow_entry.to_string(),
decision,
});
}
fn handle_elicitation_decision(
&self,
server_name: &str,
@@ -286,6 +342,20 @@ impl BottomPaneView for ApprovalOverlay {
ApprovalVariant::ApplyPatch { id, .. } => {
self.handle_patch_decision(id, ReviewDecision::Abort);
}
ApprovalVariant::Network { host, .. } => {
self.handle_network_decision(host, NetworkProxyDecision::Deny);
}
ApprovalVariant::UnixSocket {
socket_path,
allow_entry,
..
} => {
self.handle_unix_socket_decision(
socket_path,
allow_entry,
UnixSocketDecision::Deny,
);
}
ApprovalVariant::McpElicitation {
server_name,
request_id,
@@ -386,6 +456,87 @@ impl From<ApprovalRequest> for ApprovalRequestState {
header: Box::new(ColumnRenderable::with(header)),
}
}
ApprovalRequest::Network { request } => {
let mut header: Vec<Line<'static>> = Vec::new();
let host = request.host.trim().to_string();
if !host.is_empty() {
header.push(Line::from(vec!["Host: ".into(), host.clone().bold()]));
}
let reason = request.reason.trim().to_string();
if !reason.is_empty() {
let reason_label = network_reason_label(&reason);
header.push(Line::from(vec!["Reason: ".into(), reason_label.into()]));
if let Some(hint) = network_reason_hint(&reason) {
header.push(Line::from(vec!["Hint: ".into(), hint.dim()]));
}
}
if let Some(method) = request
.method
.as_ref()
.filter(|value| !value.is_empty())
.cloned()
{
header.push(Line::from(vec!["Method: ".into(), method.into()]));
}
if cfg!(debug_assertions) {
let protocol = request.protocol.trim().to_string();
if !protocol.is_empty() {
header.push(Line::from(vec!["Protocol: ".into(), protocol.into()]));
}
if let Some(mode) = request.mode {
let label = match mode {
NetworkProxyMode::Limited => "limited",
NetworkProxyMode::Full => "full",
};
header.push(Line::from(vec!["Mode: ".into(), label.into()]));
}
}
if let Some(client) = request
.client
.as_ref()
.filter(|value| !value.is_empty())
.cloned()
{
header.push(Line::from(vec!["Client: ".into(), client.dim()]));
}
let preflight_only = request.protocol.trim().eq_ignore_ascii_case("preflight");
Self {
variant: ApprovalVariant::Network {
host,
preflight_only,
},
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
}
}
ApprovalRequest::UnixSocket { request } => {
let mut header: Vec<Line<'static>> = Vec::new();
if !request.label.trim().is_empty() {
header.push(Line::from(vec![
"Resource: ".into(),
request.label.clone().bold(),
]));
}
if !request.socket_path.trim().is_empty() {
header.push(Line::from(vec![
"Socket: ".into(),
request.socket_path.clone().dim(),
]));
}
if !request.allow_entry.trim().is_empty() {
header.push(Line::from(vec![
"Allow entry: ".into(),
request.allow_entry.clone().dim(),
]));
}
Self {
variant: ApprovalVariant::UnixSocket {
socket_path: request.socket_path,
allow_entry: request.allow_entry,
label: request.label,
},
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
}
}
ApprovalRequest::McpElicitation {
server_name,
request_id,
@@ -409,6 +560,26 @@ impl From<ApprovalRequest> for ApprovalRequestState {
}
}
fn network_reason_label(reason: &str) -> String {
match reason {
"not_allowed" => "Domain not in allowlist".to_string(),
"not_allowed_local" => "Loopback blocked by policy".to_string(),
"denied" => "Domain denied by denylist".to_string(),
"method_not_allowed" => "Method blocked by network mode".to_string(),
"mitm_required" => "MITM required for limited HTTPS".to_string(),
_ => reason.to_string(),
}
}
fn network_reason_hint(reason: &str) -> Option<&'static str> {
match reason {
"not_allowed_local" => Some("Allow loopback or add the host to the allowlist."),
"method_not_allowed" => Some("Switch to full mode or enable MITM to allow this method."),
"mitm_required" => Some("Enable MITM or switch to full mode for HTTPS tunneling."),
_ => None,
}
}
#[derive(Clone)]
enum ApprovalVariant {
Exec {
@@ -419,6 +590,15 @@ enum ApprovalVariant {
ApplyPatch {
id: String,
},
Network {
host: String,
preflight_only: bool,
},
UnixSocket {
socket_path: String,
allow_entry: String,
label: String,
},
McpElicitation {
server_name: String,
request_id: RequestId,
@@ -428,6 +608,8 @@ enum ApprovalVariant {
#[derive(Clone)]
enum ApprovalDecision {
Review(ReviewDecision),
Network(NetworkProxyDecision),
UnixSocket(UnixSocketDecision),
McpElicitation(ElicitationAction),
}
@@ -503,6 +685,62 @@ fn patch_options() -> Vec<ApprovalOption> {
]
}
fn network_options(preflight_only: bool) -> Vec<ApprovalOption> {
let mut options = Vec::new();
let mut allow_session = ApprovalOption {
label: if preflight_only {
"Allow for session".to_string()
} else {
"Allow".to_string()
},
decision: ApprovalDecision::Network(NetworkProxyDecision::AllowSession),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('s'))],
};
if preflight_only {
allow_session
.additional_shortcuts
.push(key_hint::plain(KeyCode::Char('y')));
}
options.push(allow_session);
options.push(ApprovalOption {
label: "Allow always (add to allowlist)".to_string(),
decision: ApprovalDecision::Network(NetworkProxyDecision::AllowAlways),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
});
options.push(ApprovalOption {
label: "Deny (add to denylist)".to_string(),
decision: ApprovalDecision::Network(NetworkProxyDecision::Deny),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
});
options
}
fn unix_socket_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Allow for session".to_string(),
decision: ApprovalDecision::UnixSocket(UnixSocketDecision::AllowSession),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('s'))],
},
ApprovalOption {
label: "Allow always (add to allowlist)".to_string(),
decision: ApprovalDecision::UnixSocket(UnixSocketDecision::AllowAlways),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
},
ApprovalOption {
label: "Deny".to_string(),
decision: ApprovalDecision::UnixSocket(UnixSocketDecision::Deny),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
}
fn elicitation_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
+225
View File
@@ -13,6 +13,8 @@ use codex_core::features::FEATURES;
use codex_core::features::Feature;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::local_git_branches;
use codex_core::network_proxy;
use codex_core::network_proxy::NetworkProxyBlockedRequest;
use codex_core::openai_models::model_family::ModelFamily;
use codex_core::openai_models::models_manager::ModelsManager;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
@@ -45,6 +47,7 @@ use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::Op;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::ReviewDecision;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::ReviewTarget;
use codex_core::protocol::SkillsListEntry;
@@ -85,6 +88,7 @@ use tokio::task::JoinHandle;
use tracing::debug;
use crate::app_event::AppEvent;
use crate::app_event::UnixSocketApprovalRequest;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::BetaFeatureItem;
@@ -353,6 +357,9 @@ pub(crate) struct ChatWidget {
pre_review_token_info: Option<Option<TokenUsageInfo>>,
// Whether to add a final message separator after the last message
needs_final_message_separator: bool,
pending_exec_approval: Option<PendingExecApproval>,
network_proxy_session_allow: HashSet<String>,
unix_socket_session_allow: HashSet<String>,
last_rendered_width: std::cell::Cell<Option<usize>>,
// Feedback sink for /feedback
@@ -361,6 +368,17 @@ pub(crate) struct ChatWidget {
current_rollout_path: Option<PathBuf>,
}
enum PendingExecBlock {
NetworkHost(String),
UnixSocket(String),
}
struct PendingExecApproval {
id: String,
event: ExecApprovalRequestEvent,
block: PendingExecBlock,
}
struct UserMessage {
text: String,
image_paths: Vec<PathBuf>,
@@ -846,6 +864,22 @@ impl ChatWidget {
);
}
pub(crate) fn on_network_approval_request(&mut self, request: NetworkProxyBlockedRequest) {
let request2 = request.clone();
self.defer_or_handle(
|q| q.push_network_approval(request),
|s| s.handle_network_approval_request(request2),
);
}
pub(crate) fn on_unix_socket_approval_request(&mut self, request: UnixSocketApprovalRequest) {
let request2 = request.clone();
self.defer_or_handle(
|q| q.push_unix_socket_approval(request),
|s| s.handle_unix_socket_approval_request(request2),
);
}
fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) {
let ev2 = ev.clone();
self.defer_or_handle(
@@ -1188,6 +1222,47 @@ impl ChatWidget {
}
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
if self.pending_exec_approval.is_none()
&& let Some(request) = self.preflight_unix_socket_request(&ev.command)
{
self.pending_exec_approval = Some(PendingExecApproval {
id: id.clone(),
event: ev,
block: PendingExecBlock::UnixSocket(request.socket_path.clone()),
});
self.app_event_tx
.send(AppEvent::UnixSocketApprovalRequest(request));
return;
}
if self.pending_exec_approval.is_none()
&& let Some(mut request) = self.preflight_network_request(&ev.command)
{
if request.reason.trim().eq_ignore_ascii_case("denied") {
self.add_error_message(format!(
"Exec canceled because network access to {} is denied by the denylist.",
request.host
));
self.submit_op(Op::ExecApproval {
id,
decision: ReviewDecision::Denied,
});
return;
}
request.call_id = Some(id.clone());
self.pending_exec_approval = Some(PendingExecApproval {
id,
event: ev,
block: PendingExecBlock::NetworkHost(request.host.clone()),
});
self.app_event_tx
.send(AppEvent::NetworkProxyApprovalRequest(request));
return;
}
self.show_exec_approval(id, ev);
}
fn show_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) {
self.flush_answer_stream_with_separator();
let command = shlex::try_join(ev.command.iter().map(String::as_str))
.unwrap_or_else(|_| ev.command.join(" "));
@@ -1204,6 +1279,124 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn resume_pending_exec_approval(&mut self) {
if let Some(pending) = self.pending_exec_approval.take() {
if pending.event.network_preflight_only {
self.submit_op(Op::ExecApproval {
id: pending.id,
decision: ReviewDecision::Approved,
});
return;
}
self.handle_exec_approval_now(pending.id, pending.event);
}
}
pub(crate) fn reject_pending_exec_approval(&mut self) {
if let Some(pending) = self.pending_exec_approval.take() {
let reason = match pending.block {
PendingExecBlock::NetworkHost(host) => {
format!("Exec canceled because network access to {host} was denied.")
}
PendingExecBlock::UnixSocket(socket_path) => {
format!("Exec canceled because Unix socket access to {socket_path} was denied.")
}
};
self.add_to_history(history_cell::new_error_event(reason));
self.submit_op(Op::ExecApproval {
id: pending.id,
decision: ReviewDecision::Denied,
});
}
}
pub(crate) fn add_network_session_allow(&mut self, host: String) {
let host = host.trim().to_ascii_lowercase();
if host.is_empty() {
return;
}
self.network_proxy_session_allow.insert(host);
}
pub(crate) fn add_unix_socket_session_allow(&mut self, socket_path: String) {
let socket_path = socket_path.trim().to_string();
if socket_path.is_empty() {
return;
}
self.unix_socket_session_allow.insert(socket_path);
}
pub(crate) fn network_session_allow(&self) -> HashSet<String> {
self.network_proxy_session_allow.clone()
}
pub(crate) fn set_network_session_allow(&mut self, allowed: HashSet<String>) {
self.network_proxy_session_allow = allowed;
}
pub(crate) fn is_network_session_allowed(&self, host: &str) -> bool {
let normalized = host.trim().to_ascii_lowercase();
if normalized.is_empty() {
return false;
}
self.network_proxy_session_allow
.contains(normalized.as_str())
}
fn is_unix_socket_session_allowed(&self, socket_path: &str) -> bool {
let socket_path = socket_path.trim();
if socket_path.is_empty() {
return false;
}
self.unix_socket_session_allow.contains(socket_path)
}
fn preflight_network_request(&self, command: &[String]) -> Option<NetworkProxyBlockedRequest> {
let blocked = match network_proxy::preflight_blocked_request_if_enabled(
&self.config.network_proxy,
&self.config.sandbox_policy,
command,
) {
Ok(result) => result,
Err(err) => {
tracing::debug!(error = %err, "network proxy preflight failed");
None
}
};
let request = blocked?;
if self.is_network_session_allowed(&request.host) {
return None;
}
Some(request)
}
fn preflight_unix_socket_request(
&self,
command: &[String],
) -> Option<UnixSocketApprovalRequest> {
let blocked = match network_proxy::preflight_blocked_unix_socket_if_enabled(
&self.config.network_proxy,
&self.config.sandbox_policy,
command,
) {
Ok(result) => result,
Err(err) => {
tracing::debug!(error = %err, "unix socket preflight failed");
None
}
};
let blocked = blocked?;
let socket_path = blocked.socket_path.to_string_lossy().to_string();
if self.is_unix_socket_session_allowed(&socket_path) {
return None;
}
Some(UnixSocketApprovalRequest {
label: "SSH agent socket".to_string(),
socket_path,
allow_entry: blocked.suggested_allow_entry,
})
}
pub(crate) fn handle_apply_patch_approval_now(
&mut self,
id: String,
@@ -1226,6 +1419,27 @@ impl ChatWidget {
});
}
pub(crate) fn handle_network_approval_request(&mut self, request: NetworkProxyBlockedRequest) {
self.flush_answer_stream_with_separator();
let host = request.host.clone();
self.notify(Notification::NetworkApprovalRequested { host });
self.bottom_pane
.push_approval_request(ApprovalRequest::Network { request }, &self.config.features);
self.request_redraw();
}
pub(crate) fn handle_unix_socket_approval_request(
&mut self,
request: UnixSocketApprovalRequest,
) {
self.flush_answer_stream_with_separator();
self.bottom_pane.push_approval_request(
ApprovalRequest::UnixSocket { request },
&self.config.features,
);
self.request_redraw();
}
pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) {
self.flush_answer_stream_with_separator();
@@ -1423,6 +1637,9 @@ impl ChatWidget {
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
pending_exec_approval: None,
network_proxy_session_allow: HashSet::new(),
unix_socket_session_allow: HashSet::new(),
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,
@@ -1509,6 +1726,9 @@ impl ChatWidget {
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
pending_exec_approval: None,
network_proxy_session_allow: HashSet::new(),
unix_socket_session_allow: HashSet::new(),
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,
@@ -3469,6 +3689,7 @@ enum Notification {
AgentTurnComplete { response: String },
ExecApprovalRequested { command: String },
EditApprovalRequested { cwd: PathBuf, changes: Vec<PathBuf> },
NetworkApprovalRequested { host: String },
ElicitationRequested { server_name: String },
}
@@ -3493,6 +3714,9 @@ impl Notification {
}
)
}
Notification::NetworkApprovalRequested { host } => {
format!("Network approval requested: {}", truncate_text(host, 40))
}
Notification::ElicitationRequested { server_name } => {
format!("Approval requested by {server_name}")
}
@@ -3504,6 +3728,7 @@ impl Notification {
Notification::AgentTurnComplete { .. } => "agent-turn-complete",
Notification::ExecApprovalRequested { .. }
| Notification::EditApprovalRequested { .. }
| Notification::NetworkApprovalRequested { .. }
| Notification::ElicitationRequested { .. } => "approval-requested",
}
}
+21
View File
@@ -1,5 +1,6 @@
use std::collections::VecDeque;
use codex_core::network_proxy::NetworkProxyBlockedRequest;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
@@ -9,12 +10,16 @@ use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_protocol::approvals::ElicitationRequestEvent;
use crate::app_event::UnixSocketApprovalRequest;
use super::ChatWidget;
#[derive(Debug)]
pub(crate) enum QueuedInterrupt {
ExecApproval(String, ExecApprovalRequestEvent),
ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
NetworkApproval(NetworkProxyBlockedRequest),
UnixSocketApproval(UnixSocketApprovalRequest),
Elicitation(ElicitationRequestEvent),
ExecBegin(ExecCommandBeginEvent),
ExecEnd(ExecCommandEndEvent),
@@ -53,6 +58,16 @@ impl InterruptManager {
.push_back(QueuedInterrupt::ApplyPatchApproval(id, ev));
}
pub(crate) fn push_network_approval(&mut self, request: NetworkProxyBlockedRequest) {
self.queue
.push_back(QueuedInterrupt::NetworkApproval(request));
}
pub(crate) fn push_unix_socket_approval(&mut self, request: UnixSocketApprovalRequest) {
self.queue
.push_back(QueuedInterrupt::UnixSocketApproval(request));
}
pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) {
self.queue.push_back(QueuedInterrupt::Elicitation(ev));
}
@@ -84,6 +99,12 @@ impl InterruptManager {
QueuedInterrupt::ApplyPatchApproval(id, ev) => {
chat.handle_apply_patch_approval_now(id, ev)
}
QueuedInterrupt::NetworkApproval(request) => {
chat.handle_network_approval_request(request)
}
QueuedInterrupt::UnixSocketApproval(request) => {
chat.handle_unix_socket_approval_request(request)
}
QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev),
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
+9
View File
@@ -405,6 +405,9 @@ fn make_chatwidget_manual(
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
pending_exec_approval: None,
network_proxy_session_allow: std::collections::HashSet::new(),
unix_socket_session_allow: std::collections::HashSet::new(),
last_rendered_width: std::cell::Cell::new(None),
feedback: codex_feedback::CodexFeedback::new(),
current_rollout_path: None,
@@ -724,6 +727,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
),
proposed_execpolicy_amendment: None,
parsed_cmd: vec![],
network_preflight_only: false,
};
chat.handle_codex_event(Event {
id: "sub-short".into(),
@@ -768,6 +772,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
),
proposed_execpolicy_amendment: None,
parsed_cmd: vec![],
network_preflight_only: false,
};
chat.handle_codex_event(Event {
id: "sub-multi".into(),
@@ -818,6 +823,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
reason: None,
proposed_execpolicy_amendment: None,
parsed_cmd: vec![],
network_preflight_only: false,
};
chat.handle_codex_event(Event {
id: "sub-long".into(),
@@ -2282,6 +2288,7 @@ fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
"world".into(),
])),
parsed_cmd: vec![],
network_preflight_only: false,
};
chat.handle_codex_event(Event {
id: "sub-approve".into(),
@@ -2335,6 +2342,7 @@ fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
"world".into(),
])),
parsed_cmd: vec![],
network_preflight_only: false,
};
chat.handle_codex_event(Event {
id: "sub-approve-noreason".into(),
@@ -2556,6 +2564,7 @@ fn status_widget_and_approval_modal_snapshot() {
"hello world".into(),
])),
parsed_cmd: vec![],
network_preflight_only: false,
};
chat.handle_codex_event(Event {
id: "sub-approve-exec".into(),
+90
View File
@@ -1,3 +1,5 @@
use crate::app_event::NetworkProxyDecision;
use crate::app_event::UnixSocketDecision;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -534,6 +536,94 @@ pub fn new_approval_decision_cell(
))
}
pub fn new_network_approval_decision_cell(
host: String,
decision: NetworkProxyDecision,
) -> Box<dyn HistoryCell> {
let host_span = Span::from(host).dim();
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
NetworkProxyDecision::AllowSession => (
"✔ ".green(),
vec![
"You ".into(),
"approved".bold(),
" network access to ".into(),
host_span,
" for this session".bold(),
],
),
NetworkProxyDecision::AllowAlways => (
"✔ ".green(),
vec![
"You ".into(),
"approved".bold(),
" network access to ".into(),
host_span,
" permanently".bold(),
],
),
NetworkProxyDecision::Deny => (
"✗ ".red(),
vec![
"You ".into(),
"denied".bold(),
" network access to ".into(),
host_span,
],
),
};
Box::new(PrefixedWrappedHistoryCell::new(
Line::from(summary),
symbol,
" ",
))
}
pub fn new_unix_socket_approval_decision_cell(
socket_path: String,
decision: UnixSocketDecision,
) -> Box<dyn HistoryCell> {
let socket_span = Span::from(socket_path).dim();
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
UnixSocketDecision::AllowSession => (
"✔ ".green(),
vec![
"You ".into(),
"approved".bold(),
" Unix socket access to ".into(),
socket_span,
" for this session".bold(),
],
),
UnixSocketDecision::AllowAlways => (
"✔ ".green(),
vec![
"You ".into(),
"approved".bold(),
" Unix socket access to ".into(),
socket_span,
" permanently".bold(),
],
),
UnixSocketDecision::Deny => (
"✗ ".red(),
vec![
"You ".into(),
"denied".bold(),
" Unix socket access to ".into(),
socket_span,
],
),
};
Box::new(PrefixedWrappedHistoryCell::new(
Line::from(summary),
symbol,
" ",
))
}
/// Cyan history cell line showing the current review status.
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
PlainHistoryCell {
+3 -2
View File
@@ -351,7 +351,7 @@ Though using this option may also be necessary if you try to use Codex in enviro
### network_proxy
Codex can route subprocess network traffic through an external proxy (for example, `codex-network-proxy`) to enforce domain allow/deny policies for sandboxed subprocesses.
Codex can route subprocess network traffic through an external proxy (for example, the `network_proxy` sandbox proxy) and surface approval prompts when requests are blocked by policy.
```toml
[features]
@@ -384,11 +384,12 @@ Notes:
- When `proxy_url` points at localhost, Codex also assumes a SOCKS5 proxy on `localhost:8081` for `ALL_PROXY`, `GRPC_PROXY`, `FTP_PROXY`, `RSYNC_PROXY`, and (macOS only) `GIT_SSH_COMMAND`.
- `no_proxy` entries bypass the proxy; defaults include localhost + private network ranges. Use sparingly because bypassed traffic is not filtered by the proxy policy.
- `[network_proxy.policy]` can optionally allow localhost binding or Unix socket access (macOS only) when proxy-restricted network access is active.
- When enabled, Codex polls the proxy admin API (`/blocked`) and surfaces a prompt to allow for the session, allow always (add to allowlist), or deny (add to denylist). Allow/deny decisions update `~/.codex/config.toml` under `[network_proxy.policy]`, then Codex calls `/reload`.
- On macOS, `network_proxy.policy.allow_unix_sockets` is useful for local IPC that relies on Unix domain sockets (most commonly the SSH agent). Entries can be:
- absolute socket paths (or directories containing sockets),
- `$SSH_AUTH_SOCK` / `${SSH_AUTH_SOCK}`,
- the preset `ssh-agent` (alias: `ssh_auth_sock`, `ssh_auth_socket`).
If you use `git` over SSH, a common setting is `allow_unix_sockets = ["$SSH_AUTH_SOCK"]`.
When approvals are enabled, Codex may prompt to allow the SSH agent socket before running commands that appear to require it (e.g. `ssh`, `scp`, `sftp`, `ssh-add`, or `git` over SSH).
### tools.\*
+1
View File
@@ -47,6 +47,7 @@ The proxy reads `~/.codex/config.toml`:
- `[network_proxy]` for endpoints, mode, and toggles.
- `[network_proxy.policy]` for `allowed_domains` / `denied_domains` (and, on macOS, optional local IPC allowances).
- `[network_proxy.mitm]` for MITM CA paths and inspection settings.
- Codex integration is rollout-gated behind `[features].network_proxy = true`.
Codex is the source of truth. Approval actions update the config and trigger a proxy reload.
+10
View File
@@ -22,6 +22,9 @@ cargo build -p codex-network-proxy --features mitm
Add this to `~/.codex/config.toml`:
```toml
[features]
network_proxy = true
[network_proxy]
enabled = true
proxy_url = "http://127.0.0.1:3128"
@@ -91,3 +94,10 @@ Switch modes:
```bash
curl -fsS -X POST http://127.0.0.1:8080/mode -d '{"mode":"full"}'
```
## Codex integration sanity check
1) Start the proxy.
2) Launch Codex with `[features].network_proxy = true` and `network_proxy.enabled = true` set in config.
3) Run a network command (e.g., `curl https://example.com`).
4) Confirm you see the allow/deny prompt and that the proxy logs reflect the decision.
+18 -5
View File
@@ -54,16 +54,27 @@ approval_policy = "never"
sandbox_mode = "read-only"
```
### Network proxy
### Network proxy approvals
Codex can optionally route outbound subprocess network traffic through a proxy running outside the sandbox. This lets you enforce an allow-only network policy for sandboxed commands.
Codex can optionally route outbound network traffic through a proxy and prompt you when new domains are blocked by policy.
Key behaviors:
- The OS sandbox is still the first line of defense. If network access is disabled, outbound requests are blocked at the OS level.
- When network is enabled and both `[features].network_proxy = true` and `network_proxy.enabled = true`, Codex injects proxy environment variables into subprocesses and configures the OS sandbox to allow only connections to local proxy ports.
- The proxy enforces `allowed_domains` / `denied_domains` (denylist takes precedence). Requests that are not allowed are rejected at the proxy.
- Update policy in `~/.codex/config.toml` under `[network_proxy.policy]`, then call the proxy admin `/reload` to apply changes.
- When network is enabled and both `[features].network_proxy = true` and `network_proxy.enabled = true`, Codex polls the proxy admin API (`/blocked`) and immediately notifies you about blocked domains.
- For exec commands that include HTTP/HTTPS URLs, Codex preflights the host against the proxy config and prompts before running the command.
- Approvals update `~/.codex/config.toml` under `[network_proxy.policy]` and trigger a proxy reload.
- You can choose to:
- **Deny** the request (adds the domain to the denylist).
- **Allow for session** (temporary allow that is reverted on exit).
- **Allow always** (adds the domain to the allowlist).
Network access is controlled through a proxy server running outside the sandbox:
- **Domain restrictions:** Only approved domains can be accessed (denylist takes precedence).
- **User confirmation:** New domain requests trigger permission prompts.
- **Custom proxy support:** Advanced users can implement custom rules on outgoing traffic.
- **Comprehensive coverage:** Restrictions apply to all scripts, programs, and subprocesses spawned by Codex.
`NO_PROXY` is supported via `[network_proxy].no_proxy` and is passed to subprocesses as `NO_PROXY/no_proxy`. Defaults include localhost and private network ranges; any entries in that list bypass the proxy and are not filtered by proxy policy.
@@ -76,6 +87,8 @@ Unix sockets are deny-by-default. If you run tools that rely on local IPC (most
allow_unix_sockets = ["$SSH_AUTH_SOCK"]
```
When approvals are enabled, Codex may prompt to allow the SSH agent socket before running commands that appear to require it (for example `ssh`, `scp`, `sftp`, `ssh-add`, or `git` over SSH). “Allow always” records `$SSH_AUTH_SOCK`; “Allow for session” records the resolved socket path and is removed when Codex exits.
When MITM is enabled in the proxy config, Codex injects common CA environment variables (for example `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`, `PIP_CERT`, and `NPM_CONFIG_CAFILE`) pointing at the proxy CA cert to reduce per‑tool configuration.
### Sandbox mechanics by platform