feat: add network proxy feature flag (#20147)

## Why

The permissions migration is making
`permissions.<profile>.network.enabled` the canonical sandbox network
bit, while proxy startup is a separate concern. Enabling network access
should not implicitly start the proxy, and users who are still on legacy
sandbox modes need a separate place to opt into proxy startup and
provide proxy-specific settings.

This follow-up to #19900 gives the network proxy its own feature surface
instead of overloading permission-profile network semantics.

## What changed

- Add an experimental `network_proxy` feature with a configurable
`[features.network_proxy]` table.
- Overlay `features.network_proxy` settings onto the configured proxy
state after permission-profile selection, so the proxy only starts when
the active `NetworkSandboxPolicy` already allows network access.
- Preserve `[experimental_network]` startup behavior independently of
the new feature flag.

## Behavior and examples

There are now three related knobs:

- `permissions.<profile>.network.enabled` controls whether the active
permission profile has network access at all.
- `features.network_proxy` enables proxy restrictions for an
already-network-enabled profile.
- Legacy `sandbox_mode` plus `[sandbox_workspace_write].network_access`
still control whether legacy `workspace-write` has network access at
all.

The rule is:

- network off + proxy flag on -> network stays off, proxy is a no-op
- network on + proxy flag off -> unrestricted direct network
- network on + proxy flag on -> network stays on, with proxy
restrictions applied

For permission profiles, the feature toggle adds proxy restrictions only
when network access is already enabled:

```toml
default_permissions = "workspace"

[permissions.workspace.filesystem]
":minimal" = "read"

[permissions.workspace.network]
enabled = true

[features]
network_proxy = true
```

If `network.enabled = false`, the same feature flag is a no-op: network
remains off and the proxy does not start.

For legacy sandbox config, `network_access` remains the master switch:

```toml
sandbox_mode = "workspace-write"

[sandbox_workspace_write]
network_access = true

[features]
network_proxy = true
```

That keeps legacy `workspace-write` network access on, but routes it
through the proxy policy. If `network_access = false`, the proxy feature
is a no-op and legacy `workspace-write` remains offline.

The same proxy opt-in can be supplied from the CLI:

```bash
codex -c 'features.network_proxy=true'
```

Additional proxy settings can be supplied when a table is needed:

```bash
codex \
  -c 'features.network_proxy.enabled=true' \
  -c 'features.network_proxy.enable_socks5=false'
```

The intended behavior matrix is:

| Config surface | Network setting | `features.network_proxy` | Direct
sandbox network | Proxy |
| --- | --- | --- | --- | --- |
| Permission profile | `network.enabled = false` | off | restricted |
off |
| Permission profile | `network.enabled = false` | on | restricted | off
|
| Permission profile | `network.enabled = true` | off | enabled | off |
| Permission profile | `network.enabled = true` | on | enabled | on |
| Legacy `workspace-write` | `network_access = false` | off | restricted
| off |
| Legacy `workspace-write` | `network_access = false` | on | restricted
| off |
| Legacy `workspace-write` | `network_access = true` | off | enabled |
off |
| Legacy `workspace-write` | `network_access = true` | on | enabled | on
|

`[experimental_network]` requirements remain separate from the user
feature toggle and still start the proxy on their own.

Relevant code:
-
[`features/src/feature_configs.rs`](https://github.com/openai/codex/blob/43785aff47/codex-rs/features/src/feature_configs.rs#L58-L117)
defines the feature-specific proxy config.
-
[`core/src/config/mod.rs`](https://github.com/openai/codex/blob/43785aff47/codex-rs/core/src/config/mod.rs#L1959-L1964)
reads the feature table, and [later applies it only when network access
is already
enabled](https://github.com/openai/codex/blob/43785aff47/codex-rs/core/src/config/mod.rs#L2448-L2458).

## Verification

Added focused coverage for:
- keeping the proxy off when `features.network_proxy` is enabled but
sandbox network access is disabled
- the full permission-profile and legacy `workspace-write` matrix above
- preserving `[experimental_network]` startup without the feature
- reusing profile-supplied proxy settings when the feature is enabled

Ran:
- `cargo test -p codex-features`
- `cargo test -p codex-core network_proxy_feature`
- `cargo test -p codex-core
experimental_network_requirements_enable_proxy_without_feature`
This commit is contained in:
viyatb-oai
2026-05-11 14:12:00 -07:00
committed by GitHub
parent 54ec99cb54
commit c7b55cdc46
10 changed files with 718 additions and 124 deletions

View File

@@ -2,6 +2,7 @@ use crate::FeatureConfig;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -54,3 +55,63 @@ impl FeatureConfig for AppsMcpPathOverrideConfigToml {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NetworkProxyConfigToml {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_socks5: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub socks_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_socks5_udp: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_upstream_proxy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dangerously_allow_non_loopback_proxy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<NetworkProxyModeToml>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domains: Option<BTreeMap<String, NetworkProxyDomainPermissionToml>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unix_sockets: Option<BTreeMap<String, NetworkProxyUnixSocketPermissionToml>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_local_binding: Option<bool>,
}
impl FeatureConfig for NetworkProxyConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum NetworkProxyModeToml {
Limited,
Full,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum NetworkProxyDomainPermissionToml {
Allow,
Deny,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum NetworkProxyUnixSocketPermissionToml {
Allow,
None,
}

View File

@@ -18,6 +18,10 @@ mod feature_configs;
mod legacy;
pub use feature_configs::AppsMcpPathOverrideConfigToml;
pub use feature_configs::MultiAgentV2ConfigToml;
pub use feature_configs::NetworkProxyConfigToml;
pub use feature_configs::NetworkProxyDomainPermissionToml;
pub use feature_configs::NetworkProxyModeToml;
pub use feature_configs::NetworkProxyUnixSocketPermissionToml;
use legacy::LegacyFeatureToggles;
pub use legacy::legacy_feature_keys;
@@ -140,6 +144,8 @@ pub enum Feature {
ChildAgentsMd,
/// Compress request bodies (zstd) when sending streaming requests to codex-backend.
EnableRequestCompression,
/// Start the managed network proxy for sandboxed sessions.
NetworkProxy,
/// Enable collab tools.
Collab,
/// Enable task-path-based multi-agent routing.
@@ -574,6 +580,7 @@ pub struct FeaturesToml {
pub multi_agent_v2: Option<FeatureToml<MultiAgentV2ConfigToml>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps_mcp_path_override: Option<FeatureToml<AppsMcpPathOverrideConfigToml>>,
pub network_proxy: Option<FeatureToml<NetworkProxyConfigToml>>,
/// Boolean feature toggles keyed by canonical or legacy feature name.
#[serde(flatten)]
entries: BTreeMap<String, bool>,
@@ -599,6 +606,9 @@ impl FeaturesToml {
{
entries.insert(Feature::AppsMcpPathOverride.key().to_string(), enabled);
}
if let Some(enabled) = self.network_proxy.as_ref().and_then(FeatureToml::enabled) {
entries.insert(Feature::NetworkProxy.key().to_string(), enabled);
}
entries
}
@@ -606,6 +616,7 @@ impl FeaturesToml {
let Self {
multi_agent_v2,
apps_mcp_path_override,
network_proxy,
entries,
} = self;
for key in legacy::legacy_feature_keys() {
@@ -617,6 +628,8 @@ impl FeaturesToml {
materialize_resolved_feature_enabled(multi_agent_v2, enabled);
} else if spec.id == Feature::AppsMcpPathOverride {
materialize_resolved_feature_enabled(apps_mcp_path_override, enabled);
} else if spec.id == Feature::NetworkProxy {
materialize_resolved_feature_enabled(network_proxy, enabled);
} else {
entries.insert(spec.key.to_string(), enabled);
}
@@ -881,6 +894,16 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::NetworkProxy,
key: "network_proxy",
stage: Stage::Experimental {
name: "Network proxy",
menu_description: "Apply network proxy restrictions to sandboxed sessions that already have network access.",
announcement: "NEW: Network proxy can now be enabled from /experimental. Restart Codex after enabling it.",
},
default_enabled: false,
},
FeatureSpec {
id: Feature::Collab,
key: "multi_agent",

View File

@@ -164,6 +164,19 @@ fn tool_suggest_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ToolSuggest.default_enabled(), true);
}
#[test]
fn network_proxy_is_experimental_and_disabled_by_default() {
assert_eq!(
feature_for_key("network_proxy"),
Some(Feature::NetworkProxy)
);
assert!(matches!(
Feature::NetworkProxy.stage(),
Stage::Experimental { .. }
));
assert_eq!(Feature::NetworkProxy.default_enabled(), false);
}
#[test]
fn tool_search_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ToolSearch.stage(), Stage::Stable);
@@ -531,6 +544,7 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config(
let mut features = Features::with_defaults();
features.enable(Feature::CodeMode);
features.enable(Feature::MultiAgentV2);
features.enable(Feature::NetworkProxy);
features.disable(Feature::ToolSearch);
let mut features_toml = FeaturesToml {
@@ -539,6 +553,11 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config(
min_wait_timeout_ms: Some(2500),
..Default::default()
})),
network_proxy: Some(FeatureToml::Config(crate::NetworkProxyConfigToml {
enabled: Some(false),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
..Default::default()
})),
entries: BTreeMap::from([("include_apply_patch_tool".to_string(), true)]),
..Default::default()
};
@@ -563,6 +582,14 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config(
..Default::default()
}))
);
assert_eq!(
features_toml.network_proxy,
Some(FeatureToml::Config(crate::NetworkProxyConfigToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
..Default::default()
}))
);
let replayed = Features::from_sources(
FeatureConfigSource {
features: Some(&features_toml),