mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## 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`
406 lines
14 KiB
Rust
406 lines
14 KiB
Rust
use super::*;
|
|
use crate::config::Config;
|
|
use crate::config::ConfigOverrides;
|
|
use codex_config::config_toml::ConfigToml;
|
|
use codex_config::permissions_toml::FilesystemPermissionToml;
|
|
use codex_config::permissions_toml::FilesystemPermissionsToml;
|
|
use codex_config::permissions_toml::NetworkDomainPermissionToml;
|
|
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
|
|
use codex_config::permissions_toml::NetworkToml;
|
|
use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
|
|
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
|
|
use codex_config::permissions_toml::PermissionProfileToml;
|
|
use codex_config::permissions_toml::PermissionsToml;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::BTreeMap;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
|
|
let parsed = normalize_absolute_path_for_platform(
|
|
r"\\?\D:\c\x\worktrees\2508\swift-base",
|
|
/*is_windows*/ true,
|
|
);
|
|
assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base"));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_verbatim_path_prefix_does_not_count_as_glob_syntax() {
|
|
assert!(!contains_glob_chars_for_platform(
|
|
r"\\?\D:\c\x\worktrees\2508\swift-base",
|
|
/*is_windows*/ true,
|
|
));
|
|
assert!(contains_glob_chars_for_platform(
|
|
r"\\?\D:\c\x\worktrees\2508\**\*.env",
|
|
/*is_windows*/ true,
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> {
|
|
let temp_dir = TempDir::new()?;
|
|
let cwd = temp_dir.path().join("workspace");
|
|
let codex_home = temp_dir.path().join(".codex");
|
|
let zsh_path = temp_dir.path().join("runtime").join("zsh");
|
|
let arg0_root = codex_home.join("tmp").join("arg0");
|
|
let allowed_arg0_dir = arg0_root.join("codex-arg0-session");
|
|
let sibling_arg0_dir = arg0_root.join("codex-arg0-other-session");
|
|
let execve_wrapper = allowed_arg0_dir.join("codex-execve-wrapper");
|
|
std::fs::create_dir_all(&cwd)?;
|
|
std::fs::create_dir_all(zsh_path.parent().expect("zsh path should have parent"))?;
|
|
std::fs::create_dir_all(&allowed_arg0_dir)?;
|
|
std::fs::create_dir_all(&sibling_arg0_dir)?;
|
|
std::fs::write(&zsh_path, "")?;
|
|
std::fs::write(&execve_wrapper, "")?;
|
|
|
|
let config = Config::load_from_base_config_with_overrides(
|
|
ConfigToml {
|
|
default_permissions: Some("workspace".to_string()),
|
|
permissions: Some(PermissionsToml {
|
|
entries: BTreeMap::from([(
|
|
"workspace".to_string(),
|
|
PermissionProfileToml {
|
|
filesystem: Some(FilesystemPermissionsToml {
|
|
glob_scan_max_depth: None,
|
|
entries: BTreeMap::new(),
|
|
}),
|
|
network: None,
|
|
},
|
|
)]),
|
|
}),
|
|
..Default::default()
|
|
},
|
|
ConfigOverrides {
|
|
cwd: Some(cwd.clone()),
|
|
zsh_path: Some(zsh_path.clone()),
|
|
main_execve_wrapper_exe: Some(execve_wrapper),
|
|
..Default::default()
|
|
},
|
|
AbsolutePathBuf::from_absolute_path(&codex_home)?,
|
|
)
|
|
.await?;
|
|
|
|
let expected_zsh = AbsolutePathBuf::try_from(zsh_path)?;
|
|
let expected_allowed_arg0_dir = AbsolutePathBuf::try_from(allowed_arg0_dir)?;
|
|
let expected_sibling_arg0_dir = AbsolutePathBuf::try_from(sibling_arg0_dir)?;
|
|
let policy = config.permissions.file_system_sandbox_policy();
|
|
|
|
assert!(
|
|
policy.can_read_path_with_cwd(expected_zsh.as_path(), &cwd),
|
|
"expected zsh helper path to be readable, policy: {policy:?}"
|
|
);
|
|
assert!(
|
|
policy.can_read_path_with_cwd(expected_allowed_arg0_dir.as_path(), &cwd),
|
|
"expected active arg0 helper dir to be readable, policy: {policy:?}"
|
|
);
|
|
assert!(
|
|
!policy.can_read_path_with_cwd(expected_sibling_arg0_dir.as_path(), &cwd),
|
|
"expected sibling arg0 helper dir to remain unreadable, policy: {policy:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn network_toml_ignores_legacy_network_list_keys() {
|
|
let parsed = toml::from_str::<NetworkToml>(
|
|
r#"
|
|
allowed_domains = ["openai.com"]
|
|
"#,
|
|
)
|
|
.expect("legacy network list keys should be ignored");
|
|
|
|
assert_eq!(parsed, NetworkToml::default());
|
|
}
|
|
|
|
#[test]
|
|
fn network_permission_containers_project_allowed_and_denied_entries() {
|
|
let domains = NetworkDomainPermissionsToml {
|
|
entries: BTreeMap::from([
|
|
(
|
|
"*.openai.com".to_string(),
|
|
NetworkDomainPermissionToml::Allow,
|
|
),
|
|
(
|
|
"api.example.com".to_string(),
|
|
NetworkDomainPermissionToml::Allow,
|
|
),
|
|
(
|
|
"blocked.example.com".to_string(),
|
|
NetworkDomainPermissionToml::Deny,
|
|
),
|
|
]),
|
|
};
|
|
let unix_sockets = NetworkUnixSocketPermissionsToml {
|
|
entries: BTreeMap::from([
|
|
(
|
|
"/tmp/example.sock".to_string(),
|
|
NetworkUnixSocketPermissionToml::Allow,
|
|
),
|
|
(
|
|
"/tmp/ignored.sock".to_string(),
|
|
NetworkUnixSocketPermissionToml::None,
|
|
),
|
|
]),
|
|
};
|
|
|
|
assert_eq!(
|
|
domains.allowed_domains(),
|
|
Some(vec![
|
|
"*.openai.com".to_string(),
|
|
"api.example.com".to_string()
|
|
])
|
|
);
|
|
assert_eq!(
|
|
domains.denied_domains(),
|
|
Some(vec!["blocked.example.com".to_string()])
|
|
);
|
|
assert_eq!(
|
|
NetworkDomainPermissionsToml {
|
|
entries: BTreeMap::from([(
|
|
"api.example.com".to_string(),
|
|
NetworkDomainPermissionToml::Allow,
|
|
)]),
|
|
}
|
|
.denied_domains(),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
unix_sockets.allow_unix_sockets(),
|
|
vec!["/tmp/example.sock".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn network_toml_overlays_unix_socket_permissions_by_path() {
|
|
let mut config = NetworkProxyConfig::default();
|
|
|
|
NetworkToml {
|
|
unix_sockets: Some(NetworkUnixSocketPermissionsToml {
|
|
entries: BTreeMap::from([
|
|
(
|
|
"/tmp/base.sock".to_string(),
|
|
NetworkUnixSocketPermissionToml::Allow,
|
|
),
|
|
(
|
|
"/tmp/override.sock".to_string(),
|
|
NetworkUnixSocketPermissionToml::Allow,
|
|
),
|
|
]),
|
|
}),
|
|
..Default::default()
|
|
}
|
|
.apply_to_network_proxy_config(&mut config);
|
|
|
|
NetworkToml {
|
|
unix_sockets: Some(NetworkUnixSocketPermissionsToml {
|
|
entries: BTreeMap::from([
|
|
(
|
|
"/tmp/extra.sock".to_string(),
|
|
NetworkUnixSocketPermissionToml::Allow,
|
|
),
|
|
(
|
|
"/tmp/override.sock".to_string(),
|
|
NetworkUnixSocketPermissionToml::None,
|
|
),
|
|
]),
|
|
}),
|
|
..Default::default()
|
|
}
|
|
.apply_to_network_proxy_config(&mut config);
|
|
|
|
assert_eq!(
|
|
config.network.unix_sockets,
|
|
Some(codex_network_proxy::NetworkUnixSocketPermissions {
|
|
entries: BTreeMap::from([
|
|
(
|
|
"/tmp/base.sock".to_string(),
|
|
ProxyNetworkUnixSocketPermission::Allow,
|
|
),
|
|
(
|
|
"/tmp/extra.sock".to_string(),
|
|
ProxyNetworkUnixSocketPermission::Allow,
|
|
),
|
|
(
|
|
"/tmp/override.sock".to_string(),
|
|
ProxyNetworkUnixSocketPermission::None,
|
|
),
|
|
]),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() {
|
|
let config = network_proxy_config_from_profile_network(Some(&NetworkToml {
|
|
enabled: Some(true),
|
|
..Default::default()
|
|
}));
|
|
|
|
assert!(!config.network.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_network_proxy_config_keeps_proxy_disabled_for_proxy_policy() {
|
|
let config = network_proxy_config_from_profile_network(Some(&NetworkToml {
|
|
enabled: Some(true),
|
|
proxy_url: Some("http://127.0.0.1:43128".to_string()),
|
|
enable_socks5: Some(false),
|
|
domains: Some(NetworkDomainPermissionsToml {
|
|
entries: BTreeMap::from([(
|
|
"openai.com".to_string(),
|
|
NetworkDomainPermissionToml::Allow,
|
|
)]),
|
|
}),
|
|
..Default::default()
|
|
}));
|
|
|
|
assert!(!config.network.enabled);
|
|
assert_eq!(config.network.proxy_url, "http://127.0.0.1:43128");
|
|
assert!(!config.network.enable_socks5);
|
|
assert_eq!(
|
|
config.network.domains,
|
|
Some(codex_network_proxy::NetworkDomainPermissions {
|
|
entries: vec![codex_network_proxy::NetworkDomainPermissionEntry {
|
|
pattern: "openai.com".to_string(),
|
|
permission: codex_network_proxy::NetworkDomainPermission::Allow,
|
|
}],
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths() {
|
|
let filesystem = FilesystemPermissionsToml {
|
|
glob_scan_max_depth: None,
|
|
entries: BTreeMap::from([
|
|
(
|
|
"/tmp/**/*.log".to_string(),
|
|
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
|
|
),
|
|
(
|
|
"/tmp/cache/**".to_string(),
|
|
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
|
|
),
|
|
(
|
|
":project_roots".to_string(),
|
|
FilesystemPermissionToml::Scoped(BTreeMap::from([
|
|
("**/*.env".to_string(), FileSystemAccessMode::None),
|
|
("docs/**".to_string(), FileSystemAccessMode::Read),
|
|
("src/**/*.rs".to_string(), FileSystemAccessMode::Write),
|
|
])),
|
|
),
|
|
]),
|
|
};
|
|
|
|
assert_eq!(
|
|
unsupported_read_write_glob_paths(&filesystem),
|
|
vec![
|
|
"/tmp/**/*.log".to_string(),
|
|
":project_roots/src/**/*.rs".to_string()
|
|
],
|
|
"`none` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unreadable_globstar_warning_is_suppressed_when_scan_depth_is_configured() {
|
|
let filesystem = FilesystemPermissionsToml {
|
|
glob_scan_max_depth: None,
|
|
entries: BTreeMap::from([(
|
|
":project_roots".to_string(),
|
|
FilesystemPermissionToml::Scoped(BTreeMap::from([
|
|
("**/*.env".to_string(), FileSystemAccessMode::None),
|
|
("*.pem".to_string(), FileSystemAccessMode::None),
|
|
])),
|
|
)]),
|
|
};
|
|
|
|
assert_eq!(
|
|
unbounded_unreadable_globstar_paths(&filesystem),
|
|
vec![":project_roots/**/*.env".to_string()]
|
|
);
|
|
|
|
let configured_filesystem = FilesystemPermissionsToml {
|
|
glob_scan_max_depth: Some(2),
|
|
..filesystem
|
|
};
|
|
assert_eq!(
|
|
unbounded_unreadable_globstar_paths(&configured_filesystem),
|
|
Vec::<String>::new()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn glob_scan_max_depth_must_be_positive() {
|
|
let err = validate_glob_scan_max_depth(Some(0))
|
|
.expect_err("zero depth would silently skip deny-read glob expansion");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert_eq!(err.to_string(), "glob_scan_max_depth must be at least 1");
|
|
assert_eq!(
|
|
validate_glob_scan_max_depth(Some(2)).expect("depth should be valid"),
|
|
Some(2)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> {
|
|
let cwd = TempDir::new()?;
|
|
let mut startup_warnings = Vec::new();
|
|
let (file_system_policy, _) = compile_permission_profile(
|
|
&PermissionsToml {
|
|
entries: BTreeMap::from([(
|
|
"workspace".to_string(),
|
|
PermissionProfileToml {
|
|
filesystem: Some(FilesystemPermissionsToml {
|
|
glob_scan_max_depth: None,
|
|
entries: BTreeMap::from([(
|
|
":project_roots".to_string(),
|
|
FilesystemPermissionToml::Scoped(BTreeMap::from([(
|
|
"docs/**".to_string(),
|
|
FileSystemAccessMode::Read,
|
|
)])),
|
|
)]),
|
|
}),
|
|
network: None,
|
|
},
|
|
)]),
|
|
},
|
|
"workspace",
|
|
cwd.path(),
|
|
&mut startup_warnings,
|
|
)?;
|
|
|
|
assert_eq!(
|
|
file_system_policy,
|
|
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(Some("docs".into())),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
}]),
|
|
"trailing /** should compile as a subtree path instead of a glob pattern"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn read_write_glob_patterns_still_reject_non_subpath_globs() {
|
|
let err = compile_read_write_glob_path("src/**/*.rs", FileSystemAccessMode::Read)
|
|
.expect_err("non-subpath read/write glob should be rejected");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
|
assert!(
|
|
err.to_string()
|
|
.contains("filesystem glob path `src/**/*.rs` only supports `none` access"),
|
|
"{err}"
|
|
);
|
|
}
|