Files
codex/codex-rs/network-proxy/src/remote_config_tests.rs
T
olliem-oai 94350a9101 Add managed network review domains before allow rules (#56154)
## Why

Managed network policies need to require approval for selected hosts even when an exact or wildcard allow rule matches them.

## What changed

- Add `experimental_network.review_domains` to managed requirements and route matching hosts through the existing network approval flow. Explicit denials retain precedence, and approved connections still undergo DNS and local-address checks.
- Preserve review patterns across environment policy attachments and remote proxy configuration. Reject execution on an exec-server that lacks the `networkProxyReviewDomains` capability when review patterns are present.
- Expose `reviewDomains` through the app-server requirements API and generated schemas, and show `review_domains` in debug configuration output.
- Update approval reasons to say that network access requires approval rather than assuming the host is absent from the allowlist.

## Testing

Extend coverage for review precedence over exact and wildcard allows, explicit denials, DNS checks after local and remote approvals, approval hooks, policy merging, remote configuration round trips, and requirements API serialization.

GitOrigin-RevId: 4a861061a0ed80b3537d3b72f2caecb0db2a4f0b
2026-10-22 11:52:34 +00:00

446 lines
16 KiB
Rust

use pretty_assertions::assert_eq;
use super::RemoteNetworkProxyConfig;
use super::RemoteNetworkProxyLaunchConfig;
use crate::LocalBindingPolicy::DefaultFalse;
use crate::LocalBindingPolicy::RequireTrue;
use crate::MitmHookConfig;
use crate::NetworkMitmCaConfig;
use crate::NetworkMode;
use crate::NetworkProxy;
use crate::NetworkProxyAuditMetadata;
use crate::NetworkProxyConfig;
use crate::NetworkProxyState;
use crate::Platform;
use crate::RemoteCredentialMasking;
use std::sync::Arc;
#[test]
fn optional_socket_policy_preserves_input_and_resolves_at_remote_boundary() {
for allow_all in [None, Some(false), Some(true)] {
let mut config = NetworkProxyConfig {
dangerously_allow_all_unix_sockets: allow_all,
..NetworkProxyConfig::default()
};
config.set_allow_unix_sockets(Vec::new());
let serialized = serde_json::to_value(&config).expect("serialize config");
assert_eq!(
serialized.get("dangerously_allow_all_unix_sockets"),
allow_all.map(serde_json::Value::Bool).as_ref()
);
assert_eq!(serialized["unix_sockets"], serde_json::json!({}));
assert_eq!(
serde_json::from_value::<NetworkProxyConfig>(serialized).expect("deserialize config"),
config
);
let remote = RemoteNetworkProxyConfig::from_effective_config(&config)
.expect("supported remote config");
assert_eq!(
remote.dangerously_allow_all_unix_sockets,
allow_all.unwrap_or(false)
);
config.dangerously_allow_all_unix_sockets = Some(allow_all.unwrap_or(false));
config.allow_local_binding = Some(false);
assert_eq!(remote.into_network_proxy_config(), config);
}
}
#[tokio::test]
async fn round_trip_preserves_supported_effective_settings() {
for (executor_os, socket, credential_masking, configured_provider) in [
(Platform::Linux, "/var/run/example.sock", false, false),
(Platform::Linux, "/var/run/example.sock", true, false),
(Platform::Linux, "/var/run/example.sock", true, true),
(Platform::Macos, "/var/run/example.sock", false, false),
(Platform::Macos, "/var/run/example.sock", true, true),
(Platform::Windows, r"C:\example.sock", false, false),
(Platform::Unknown, r"C:\example.sock", false, false),
] {
let mut config = NetworkProxyConfig {
enabled: true,
enable_socks5: false,
enable_socks5_udp: false,
allow_upstream_proxy: false,
dangerously_allow_all_unix_sockets: Some(true),
mode: NetworkMode::Limited,
allow_local_binding: Some(true),
review_domains: vec!["review.example.com".to_string()],
..NetworkProxyConfig::default()
};
// Domain permissions serialize as a sorted map.
config.set_denied_domains(vec!["blocked.example.com".into()]);
config.set_allowed_domains(vec!["example.com".into()]);
config.set_allow_unix_sockets(vec![socket.into()]);
let mut remote = RemoteNetworkProxyConfig::from_effective_config(&config)
.expect("supported remote config");
config.set_credential_broker_enabled(credential_masking);
if configured_provider {
config.credential_providers.insert(
"vendor".to_string(),
crate::CredentialProviderConfig {
env: vec!["VENDOR_TOKEN".to_string()],
patterns: vec!["vendor_[a-z]{8}".to_string()],
url_prefix_from_env: Some("VENDOR_BASE_URL".to_string()),
auth: vec![crate::CredentialAuthMethod::Bearer],
..Default::default()
},
);
}
if credential_masking {
remote.enable_credential_masking(config.credential_providers.clone());
}
let wire = serde_json::to_value(&remote).expect("serialize remote config");
assert_eq!(
wire.get("credentialMasking"),
credential_masking.then_some(&serde_json::Value::Bool(true))
);
assert_eq!(
wire.get("credentialProviders"),
configured_provider
.then(|| serde_json::to_value(&config.credential_providers).unwrap())
.as_ref()
);
assert_eq!(
serde_json::from_value::<RemoteNetworkProxyConfig>(wire.clone())
.expect("deserialize remote config"),
remote
);
let mut disabled_wire = wire;
disabled_wire
.as_object_mut()
.unwrap()
.remove("credentialMasking");
let mut expected_wire = disabled_wire.clone();
expected_wire
.as_object_mut()
.unwrap()
.remove("credentialProviders");
// Ignore provider rules when the peer omits or explicitly disables masking.
let mut explicitly_disabled_wire = disabled_wire.clone();
explicitly_disabled_wire["credentialMasking"] = false.into();
for wire in [disabled_wire, explicitly_disabled_wire] {
let disabled: RemoteNetworkProxyConfig =
serde_json::from_value(wire).expect("disabled masking ignores provider rules");
assert!(!disabled.credential_masking_enabled());
assert_eq!(serde_json::to_value(disabled).unwrap(), expected_wire);
}
let diagnostic = format!("{remote:?}");
assert!(!diagnostic.contains("vendor_[a-z]{8}"));
assert!(!diagnostic.contains("VENDOR_BASE_URL"));
let round_trip = remote.clone().into_network_proxy_config();
assert_eq!(round_trip, config);
let state = Arc::new(
NetworkProxyState::from_remote_launch_config(
RemoteNetworkProxyLaunchConfig::new(remote),
executor_os,
&crate::RemoteCredentialBrokerCache::default(),
&std::collections::HashMap::new(),
)
.unwrap(),
);
// Policy edits and carrier construction must retain the executor's OS even
// when the controller itself cannot interpret the socket as a native path.
state.add_allowed_domain("added.example.com").await.unwrap();
let proxy = NetworkProxy::builder()
.state(state)
.managed_by_codex(/*managed_by_codex*/ false)
.build()
.await
.unwrap();
config.upsert_domain_permission(
"added.example.com".into(),
crate::NetworkDomainPermission::Allow,
crate::normalize_host,
);
assert_eq!(
proxy
.remote_launch_config(
DefaultFalse,
if credential_masking {
RemoteCredentialMasking::WithConfiguredProviders
} else {
RemoteCredentialMasking::Disabled
},
)
.await
.unwrap()
.proxy
.into_network_proxy_config(),
config
);
}
}
#[test]
fn rejects_unsupported_configuration() {
let cases = [
(
"MITM",
NetworkProxyConfig {
enabled: true,
mitm: true,
..NetworkProxyConfig::default()
},
),
(
"credential broker",
NetworkProxyConfig {
enabled: true,
credential_broker: true,
..NetworkProxyConfig::default()
},
),
(
"plaintext credential injection",
NetworkProxyConfig {
enabled: true,
dangerously_allow_plaintext_credential_injection: true,
..NetworkProxyConfig::default()
},
),
(
"MITM hooks",
NetworkProxyConfig {
enabled: true,
mitm_hooks: vec![MitmHookConfig::default()],
..NetworkProxyConfig::default()
},
),
];
for (feature, config) in cases {
assert!(
RemoteNetworkProxyConfig::from_effective_config(&config).is_err(),
"{feature} must not cross the remote executor boundary"
);
}
}
#[test]
fn accepts_unsupported_configuration_when_proxy_is_disabled() {
let config = NetworkProxyConfig {
mitm: true,
credential_broker: true,
dangerously_allow_plaintext_credential_injection: true,
mitm_hooks: vec![MitmHookConfig::default()],
..NetworkProxyConfig::default()
};
let remote = RemoteNetworkProxyConfig::from_effective_config(&config)
.expect("disabled proxy configuration does not cross the executor boundary");
assert!(!remote.enabled);
}
#[test]
fn rejects_external_ca_even_when_proxy_is_disabled() {
for (enabled, mitm_enabled) in [(false, false), (false, true), (true, false), (true, true)] {
let config = NetworkProxyConfig {
enabled,
mitm: mitm_enabled,
mitm_ca: Some(NetworkMitmCaConfig {
certificate_file: "/run/proxy/ca.pem".to_string(),
private_key_file: "/run/proxy/key.pem".to_string(),
}),
..NetworkProxyConfig::default()
};
assert!(
RemoteNetworkProxyConfig::from_effective_config(&config).is_err(),
"external CA configuration must not cross the remote executor boundary (proxy enabled={enabled}, MITM enabled={mitm_enabled})"
);
}
}
#[test]
fn launch_config_materializes_audit_and_execution_attribution() {
let proxy = RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig {
enabled: true,
..NetworkProxyConfig::default()
})
.expect("supported remote config");
let audit_metadata = NetworkProxyAuditMetadata {
conversation_id: Some("conversation-1".to_string()),
user_account_id: Some("account-1".to_string()),
originator: Some("codex_cli_rs".to_string()),
model: Some("model-1".to_string()),
..NetworkProxyAuditMetadata::default()
};
let state = NetworkProxyState::from_remote_launch_config(
RemoteNetworkProxyLaunchConfig {
proxy,
audit_metadata: audit_metadata.clone(),
environment_id: Some("remote".to_string()),
execution_id: Some("execution-1".to_string()),
policy_decision_timeout_ms: None,
},
Platform::native(),
&crate::RemoteCredentialBrokerCache::default(),
&std::collections::HashMap::new(),
)
.expect("remote launch state");
assert_eq!(state.audit_metadata(), &audit_metadata);
assert_eq!(state.environment_id(), Some("remote"));
assert_eq!(state.execution_id().as_deref(), Some("execution-1"));
}
#[tokio::test]
async fn remote_credential_mappings_follow_the_environment_and_broker_config() {
const TOKEN: &str = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
let brokers = crate::RemoteCredentialBrokerCache::default();
let launch = |environment_id: &str,
conversation_id: &str,
mode,
allow_local_binding,
env: &std::collections::HashMap<String, String>| {
let mut proxy = RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig {
enabled: true,
allow_local_binding: Some(allow_local_binding),
mode,
..NetworkProxyConfig::default()
})
.unwrap();
proxy.enable_credential_masking(Default::default());
NetworkProxyState::from_remote_launch_config(
RemoteNetworkProxyLaunchConfig::new(proxy)
.for_execution(environment_id.to_string(), "execution".to_string())
.with_audit_metadata(NetworkProxyAuditMetadata {
conversation_id: Some(conversation_id.to_string()),
..NetworkProxyAuditMetadata::default()
}),
Platform::Linux,
&brokers,
env,
)
.unwrap()
};
let mut env = std::collections::HashMap::from([
("GH_ENTERPRISE_TOKEN".to_string(), TOKEN.to_string()),
(
"GH_HOST".to_string(),
"github.enterprise.example".to_string(),
),
]);
let first = launch(
"remote",
"thread",
NetworkMode::Full,
/*allow_local_binding*/ false,
&env,
);
first.virtualize_child_credentials(&mut env);
let placeholder = env["GH_ENTERPRISE_TOKEN"].clone();
assert_ne!(placeholder, TOKEN);
let mut next_env = std::collections::HashMap::from([
("GH_ENTERPRISE_TOKEN".to_string(), TOKEN.to_string()),
("GH_HOST".to_string(), String::new()),
]);
let next = launch(
"remote",
"thread",
NetworkMode::Full,
/*allow_local_binding*/ false,
&env,
);
let listener = next.clone();
next.bind_credential_broker_environment(&brokers, &next_env)
.await
.expect("bind startup credential context");
next.virtualize_child_credentials(&mut next_env);
let mut headers = rama_http::HeaderMap::from_iter([(
rama_http::header::AUTHORIZATION,
rama_http::HeaderValue::from_str(&format!("Bearer {placeholder}")).unwrap(),
)]);
let mut listener_headers = headers.clone();
listener.inject_request_credentials("github.enterprise.example", &mut listener_headers);
assert_eq!(listener_headers, headers);
first.inject_request_credentials("github.enterprise.example", &mut headers);
assert_eq!(
headers[rama_http::header::AUTHORIZATION].to_str().unwrap(),
format!("Bearer {TOKEN}")
);
drop(first);
for (environment_id, conversation_id, allow_local_binding, expected) in [
("remote", "thread", false, TOKEN),
("other", "thread", false, placeholder.as_str()),
("remote", "other", false, placeholder.as_str()),
("remote", "thread", true, placeholder.as_str()),
] {
// A different network mode must not discard mappings for the same scope.
let state = launch(
environment_id,
conversation_id,
NetworkMode::Limited,
allow_local_binding,
&env,
);
let mut headers = rama_http::HeaderMap::new();
headers.insert(
rama_http::header::AUTHORIZATION,
rama_http::HeaderValue::from_str(&format!("Bearer {placeholder}")).unwrap(),
);
state.inject_request_credentials("github.enterprise.example", &mut headers);
assert_eq!(
headers[rama_http::header::AUTHORIZATION].to_str().unwrap(),
format!("Bearer {expected}")
);
}
}
#[test]
fn policy_decision_callback_timeout_round_trips() {
let config = RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig::default())
.expect("supported remote config");
let mut launch = RemoteNetworkProxyLaunchConfig::new(config);
let without_timeout = serde_json::to_value(&launch).expect("serialize launch config");
assert_eq!(without_timeout.get("policyDecisionTimeoutMs"), None);
launch.policy_decision_timeout_ms = Some(900_000);
let with_timeout = serde_json::to_value(&launch).expect("serialize launch timeout");
assert_eq!(with_timeout["policyDecisionTimeoutMs"], 900_000);
assert_eq!(
serde_json::from_value::<RemoteNetworkProxyLaunchConfig>(with_timeout)
.expect("deserialize launch timeout"),
launch
);
}
#[tokio::test]
async fn local_binding_is_resolved_for_each_executor() -> anyhow::Result<()> {
for (binding, policy, expected) in [
(None, DefaultFalse, Some(false)),
(None, RequireTrue, Some(true)),
(Some(false), DefaultFalse, Some(false)),
(Some(false), RequireTrue, None),
(Some(true), DefaultFalse, Some(true)),
] {
let config = NetworkProxyConfig {
enabled: true,
allow_local_binding: binding,
..Default::default()
};
let mut state = crate::runtime::network_proxy_state_for_policy(config);
state.local_binding_policy = RequireTrue;
let proxy = NetworkProxy::builder()
.state(Arc::new(state))
.managed_by_codex(false)
.build()
.await?;
assert_eq!(
proxy
.remote_launch_config(policy, RemoteCredentialMasking::Disabled)
.await
.ok()
.map(|launch| launch.proxy.allow_local_binding),
expected,
);
}
Ok(())
}