Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,9 @@ pub(crate) struct Session {
agent_status: watch::Sender<AgentStatus>,
out_of_band_elicitation_paused: watch::Sender<bool>,
state: Mutex<SessionState>,
/// Serializes rebuild/apply cycles for the running proxy; each cycle
/// rebuilds from the current SessionState while holding this lock.
managed_network_proxy_refresh_lock: Mutex<()>,
/// The set of enabled features should be invariant for the lifetime of the
/// session.
features: ManagedFeatures,
Expand Down Expand Up @@ -1344,6 +1347,48 @@ impl Session {
Ok((network_proxy, session_network_proxy))
}

async fn refresh_managed_network_proxy_for_current_sandbox_policy(&self) {
let Some(started_proxy) = self.services.network_proxy.as_ref() else {
return;
};
let _refresh_guard = self.managed_network_proxy_refresh_lock.lock().await;
let session_configuration = {
let state = self.state.lock().await;
state.session_configuration.clone()
};
let Some(spec) = session_configuration
.original_config_do_not_use
.permissions
.network
.as_ref()
else {
return;
};

let spec = match spec
.recompute_for_sandbox_policy(session_configuration.sandbox_policy.get())
{
Ok(spec) => spec,
Err(err) => {
warn!("failed to rebuild managed network proxy policy for sandbox change: {err}");
return;
}
};
let current_exec_policy = self.services.exec_policy.current();
Comment thread
viyatb-oai marked this conversation as resolved.
let spec = match spec.with_exec_policy_network_rules(current_exec_policy.as_ref()) {
Ok(spec) => spec,
Err(err) => {
warn!(
"failed to apply execpolicy network rules while refreshing managed network proxy: {err}"
);
spec
}
};
if let Err(err) = spec.apply_to_started_proxy(started_proxy).await {
warn!("failed to refresh managed network proxy for sandbox change: {err}");
}
}

/// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it.
pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config {
// todo(aibrahim): store this state somewhere else so we don't need to mut config
Expand Down Expand Up @@ -2002,6 +2047,7 @@ impl Session {
agent_status,
out_of_band_elicitation_paused,
state: Mutex::new(state),
managed_network_proxy_refresh_lock: Mutex::new(()),
features: config.features.clone(),
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
Expand Down Expand Up @@ -2421,6 +2467,8 @@ impl Session {
match state.session_configuration.apply(&updates) {
Ok(updated) => {
let previous_cwd = state.session_configuration.cwd.clone();
let sandbox_policy_changed =
state.session_configuration.sandbox_policy != updated.sandbox_policy;
let next_cwd = updated.cwd.clone();
let codex_home = updated.codex_home.clone();
let session_source = updated.session_source.clone();
Expand All @@ -2433,6 +2481,10 @@ impl Session {
&codex_home,
&session_source,
);
if sandbox_policy_changed {
self.refresh_managed_network_proxy_for_current_sandbox_policy()
.await;
Comment thread
viyatb-oai marked this conversation as resolved.
}

Ok(())
}
Expand Down Expand Up @@ -2519,6 +2571,8 @@ impl Session {
.set_approval_policy(&session_configuration.approval_policy);

if sandbox_policy_changed {
self.refresh_managed_network_proxy_for_current_sandbox_policy()
.await;
let sandbox_state = SandboxState {
sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(),
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
Expand Down Expand Up @@ -2954,6 +3008,7 @@ impl Session {
amendment: &NetworkPolicyAmendment,
network_approval_context: &NetworkApprovalContext,
) -> anyhow::Result<()> {
let _refresh_guard = self.managed_network_proxy_refresh_lock.lock().await;
let host =
Self::validated_network_policy_amendment_host(amendment, network_approval_context)?;
let codex_home = self
Expand Down
135 changes: 135 additions & 0 deletions codex-rs/core/src/codex_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,139 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules()
Ok(())
}

#[tokio::test]
async fn managed_network_proxy_refreshes_when_sandbox_policy_changes() -> anyhow::Result<()> {
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig::default(),
Some(NetworkConstraints {
domains: Some(NetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([(
"blocked.example.com".to_string(),
NetworkDomainPermissionToml::Deny,
)]),
}),
danger_full_access_denylist_only: Some(true),
allow_local_binding: Some(false),
..Default::default()
}),
&SandboxPolicy::new_workspace_write_policy(),
)?;
let exec_policy = Policy::empty();

let (started_proxy, _) = Session::start_managed_network_proxy(
&spec,
&exec_policy,
&SandboxPolicy::new_workspace_write_policy(),
/*network_policy_decider*/ None,
/*blocked_request_observer*/ None,
/*managed_network_requirements_enabled*/ false,
crate::config::NetworkProxyAuditMetadata::default(),
)
.await?;

assert!(!started_proxy.proxy().allow_local_binding());
let current_cfg = started_proxy.proxy().current_cfg().await?;
assert_eq!(current_cfg.network.allowed_domains(), None);
assert_eq!(
current_cfg.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);

let spec = spec.recompute_for_sandbox_policy(&SandboxPolicy::DangerFullAccess)?;
spec.apply_to_started_proxy(&started_proxy).await?;

assert!(started_proxy.proxy().allow_local_binding());
let current_cfg = started_proxy.proxy().current_cfg().await?;
assert_eq!(
current_cfg.network.allowed_domains(),
Some(vec!["*".to_string()])
);
assert_eq!(
current_cfg.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);

let spec = spec.recompute_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy())?;
spec.apply_to_started_proxy(&started_proxy).await?;

assert!(!started_proxy.proxy().allow_local_binding());
let current_cfg = started_proxy.proxy().current_cfg().await?;
assert_eq!(current_cfg.network.allowed_domains(), None);
assert_eq!(
current_cfg.network.denied_domains(),
Some(vec!["blocked.example.com".to_string()])
);
Ok(())
}

#[tokio::test]
async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::Result<()> {
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig::default(),
Some(NetworkConstraints {
enabled: Some(true),
danger_full_access_denylist_only: Some(true),
..Default::default()
}),
&SandboxPolicy::DangerFullAccess,
)?;
let exec_policy = Policy::empty();
let decider_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let network_policy_decider: Arc<dyn codex_network_proxy::NetworkPolicyDecider> = Arc::new({
let decider_calls = Arc::clone(&decider_calls);
move |_request| {
decider_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async { codex_network_proxy::NetworkDecision::ask("not_allowed") }
}
});

let (started_proxy, _) = Session::start_managed_network_proxy(
&spec,
&exec_policy,
&SandboxPolicy::DangerFullAccess,
Some(network_policy_decider),
/*blocked_request_observer*/ None,
/*managed_network_requirements_enabled*/ true,
crate::config::NetworkProxyAuditMetadata::default(),
)
.await?;

let spec = spec.recompute_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy())?;
spec.apply_to_started_proxy(&started_proxy).await?;
let current_cfg = started_proxy.proxy().current_cfg().await?;
assert_eq!(current_cfg.network.allowed_domains(), None);

use tokio::io::AsyncReadExt as _;
use tokio::io::AsyncWriteExt as _;

let mut stream = tokio::net::TcpStream::connect(started_proxy.proxy().http_addr()).await?;
stream
.write_all(
b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n",
)
.await?;
let mut buffer = [0_u8; 4096];
let bytes_read = tokio::time::timeout(StdDuration::from_secs(2), stream.read(&mut buffer))
.await
.expect("timed out waiting for proxy response")?;
let response = String::from_utf8_lossy(&buffer[..bytes_read]);

assert!(
response.starts_with("HTTP/1.1 403 Forbidden"),
"unexpected proxy response: {response}"
);
assert!(
response.contains("x-proxy-error: blocked-by-allowlist"),
"unexpected proxy response: {response}"
);
assert_eq!(
decider_calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"unexpected proxy response: {response}"
);
Ok(())
}

#[tokio::test]
async fn get_base_instructions_no_user_content() {
let prompt_with_apply_patch_instructions =
Expand Down Expand Up @@ -2824,6 +2957,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
agent_status: agent_status_tx,
out_of_band_elicitation_paused: watch::channel(false).0,
state: Mutex::new(state),
managed_network_proxy_refresh_lock: Mutex::new(()),
features: config.features.clone(),
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
Expand Down Expand Up @@ -3666,6 +3800,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
agent_status: agent_status_tx,
out_of_band_elicitation_paused: watch::channel(false).0,
state: Mutex::new(state),
managed_network_proxy_refresh_lock: Mutex::new(()),
features: config.features.clone(),
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
Expand Down
66 changes: 47 additions & 19 deletions codex-rs/core/src/config/network_proxy_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const GLOBAL_ALLOWLIST_PATTERN: &str = "*";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkProxySpec {
base_config: NetworkProxyConfig,
requirements: Option<NetworkConstraints>,
config: NetworkProxyConfig,
constraints: NetworkProxyConstraints,
hard_deny_allowlist_misses: bool,
Expand Down Expand Up @@ -91,13 +93,14 @@ impl NetworkProxySpec {
requirements: Option<NetworkConstraints>,
sandbox_policy: &SandboxPolicy,
) -> std::io::Result<Self> {
let base_config = config.clone();
let hard_deny_allowlist_misses = requirements
.as_ref()
.is_some_and(Self::managed_allowed_domains_only);
let (config, constraints) = if let Some(requirements) = requirements {
let (config, constraints) = if let Some(requirements) = requirements.as_ref() {
Self::apply_requirements(
config,
&requirements,
requirements,
sandbox_policy,
hard_deny_allowlist_misses,
)
Expand All @@ -111,6 +114,8 @@ impl NetworkProxySpec {
)
})?;
Ok(Self {
base_config,
requirements,
config,
constraints,
hard_deny_allowlist_misses,
Expand All @@ -127,21 +132,16 @@ impl NetworkProxySpec {
) -> std::io::Result<StartedNetworkProxy> {
let state = self.build_state_with_audit_metadata(audit_metadata)?;
let mut builder = NetworkProxy::builder().state(Arc::new(state));
if enable_network_approval_flow
&& !self.hard_deny_allowlist_misses
&& matches!(
if enable_network_approval_flow && !self.hard_deny_allowlist_misses {
if let Some(policy_decider) = policy_decider {
builder = builder.policy_decider_arc(policy_decider);
} else if matches!(
sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
)
{
builder = match policy_decider {
Some(policy_decider) => builder.policy_decider_arc(policy_decider),
None => builder.policy_decider(|_request| async {
// In restricted sandbox modes, allowlist misses should ask for
// explicit network approval instead of hard-denying.
NetworkDecision::ask("not_allowed")
}),
};
) {
builder = builder
.policy_decider(|_request| async { NetworkDecision::ask("not_allowed") });
}
}
if let Some(blocked_request_observer) = blocked_request_observer {
builder = builder.blocked_request_observer_arc(blocked_request_observer);
Expand All @@ -156,6 +156,17 @@ impl NetworkProxySpec {
Ok(StartedNetworkProxy::new(proxy, handle))
}

pub(crate) fn recompute_for_sandbox_policy(
&self,
sandbox_policy: &SandboxPolicy,
) -> std::io::Result<Self> {
Self::from_config_and_constraints(
self.base_config.clone(),
self.requirements.clone(),
sandbox_policy,
)
}

pub(crate) fn with_exec_policy_network_rules(
&self,
exec_policy: &Policy,
Expand All @@ -171,14 +182,25 @@ impl NetworkProxySpec {
Ok(spec)
}

pub(crate) async fn apply_to_started_proxy(
&self,
started_proxy: &StartedNetworkProxy,
) -> std::io::Result<()> {
let state = self.build_config_state_for_spec()?;
started_proxy
.proxy()
.replace_config_state(state)
Comment thread
viyatb-oai marked this conversation as resolved.
.await
.map_err(|err| {
std::io::Error::other(format!("failed to update network proxy state: {err}"))
})
}

fn build_state_with_audit_metadata(
&self,
audit_metadata: NetworkProxyAuditMetadata,
) -> std::io::Result<NetworkProxyState> {
let state =
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
std::io::Error::other(format!("failed to build network proxy state: {err}"))
})?;
let state = self.build_config_state_for_spec()?;
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
Ok(NetworkProxyState::with_reloader_and_audit_metadata(
state,
Expand All @@ -187,6 +209,12 @@ impl NetworkProxySpec {
))
}

fn build_config_state_for_spec(&self) -> std::io::Result<ConfigState> {
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
std::io::Error::other(format!("failed to build network proxy state: {err}"))
})
}

fn apply_requirements(
mut config: NetworkProxyConfig,
requirements: &NetworkConstraints,
Expand Down
Loading
Loading