Files
codex/codex-rs/network-proxy/src/environment_policy.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

117 lines
5.4 KiB
Rust

use crate::NetworkDomainPermissions;
use crate::NetworkProxyConfig;
use crate::NetworkUnixSocketPermission;
use crate::NetworkUnixSocketPermissions;
use serde::Deserialize;
use serde::Serialize;
/// Managed proxy requirements supplied by the owner of one execution environment.
///
/// The owner can require proxy routing without granting direct network access.
/// Listeners, network mode, MITM, and credentials remain controller-owned.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentNetworkPolicy {
/// Requires managed proxy routing even when direct network access is restricted.
/// False or omission leaves activation to the controller and command permissions.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub requires_proxy: bool,
pub domains: Option<NetworkDomainPermissions>,
/// Managed host selectors inherited by every execution in this environment.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub review_domains: Vec<String>,
pub unix_sockets: Option<NetworkUnixSocketPermissions>,
pub allow_upstream_proxy: bool,
pub dangerously_allow_all_unix_sockets: bool,
/// Omission inherits local-binding policy; an explicit false remains a restriction.
pub allow_local_binding: Option<bool>,
pub managed_allowed_domains_only: bool,
}
impl EnvironmentNetworkPolicy {
/// Captures proxy activation and traffic restrictions without controller runtime settings.
pub fn from_config(config: &NetworkProxyConfig, managed_allowed_domains_only: bool) -> Self {
Self {
requires_proxy: config.enabled,
domains: config.domains.clone(),
review_domains: config.review_domains.clone(),
unix_sockets: config.unix_sockets.clone(),
allow_upstream_proxy: config.allow_upstream_proxy,
dangerously_allow_all_unix_sockets: config
.dangerously_allow_all_unix_sockets
.unwrap_or(false),
allow_local_binding: config.allow_local_binding,
managed_allowed_domains_only,
}
}
/// Applies attachment-owned traffic settings while preserving inherited denials and proxy setup.
pub fn apply_to(&self, config: &mut NetworkProxyConfig) {
// Neither owner nor controller may remove the other's review selectors.
for pattern in &self.review_domains {
if !config
.review_domains
.iter()
.any(|existing| existing.eq_ignore_ascii_case(pattern))
{
config.review_domains.push(pattern.clone());
}
}
// Use the owner's domain rules without dropping controller denials.
let inherited_denials = config.denied_domains().unwrap_or_default();
config.domains.clone_from(&self.domains);
for domain in inherited_denials {
config.upsert_domain_permission(
domain,
crate::NetworkDomainPermission::Deny,
crate::normalize_host,
);
}
// An omitted controller policy leaves socket permissions to the attachment;
// an explicitly empty socket map still supplies a restrictive ceiling.
let inherited_allow_all = config
.dangerously_allow_all_unix_sockets
.unwrap_or(config.unix_sockets.is_none());
let inherited_sockets = config.unix_sockets.take().unwrap_or_default();
let mut effective_sockets = self.unix_sockets.clone().unwrap_or_default();
// "Allow all" cannot override a socket denied by either policy.
let inherited_permits_all = inherited_allow_all
&& !inherited_sockets
.entries
.values()
.any(|permission| matches!(permission, NetworkUnixSocketPermission::Deny));
let owner_permits_all = self.dangerously_allow_all_unix_sockets
&& !effective_sockets
.entries
.values()
.any(|permission| matches!(permission, NetworkUnixSocketPermission::Deny));
// Keep shared socket grants; controller denials always take priority.
effective_sockets.entries.retain(|path, permission| {
matches!(permission, NetworkUnixSocketPermission::Deny)
|| inherited_permits_all
|| matches!(
inherited_sockets.entries.get(path),
Some(NetworkUnixSocketPermission::Allow)
)
});
for (path, permission) in inherited_sockets.entries {
if owner_permits_all || matches!(permission, NetworkUnixSocketPermission::Deny) {
effective_sockets.entries.insert(path, permission);
}
}
// Socket and upstream-proxy permissions require both controller and owner grants.
config.unix_sockets = (!effective_sockets.entries.is_empty()).then_some(effective_sockets);
config.dangerously_allow_all_unix_sockets =
Some(inherited_permits_all && owner_permits_all);
config.allow_upstream_proxy &= self.allow_upstream_proxy;
// Either explicit denial wins; defer unresolved defaults to the executor.
config.allow_local_binding = match (config.allow_local_binding, self.allow_local_binding) {
(Some(controller), Some(owner)) => Some(controller && owner),
(controller, owner) => controller.or(owner),
};
}
}