mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
TUI: prompt for network proxy approvals
This commit is contained in:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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.\*
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user